Skip to content

Fix AWS runner sync and lint issues - #19

Closed
roodboi wants to merge 4 commits into
mainfrom
aws
Closed

Fix AWS runner sync and lint issues#19
roodboi wants to merge 4 commits into
mainfrom
aws

Conversation

@roodboi

@roodboi roodboi commented Mar 10, 2026

Copy link
Copy Markdown
Contributor

Summary

  • address AWS runner and ticket sync issues affecting the remote node flow
  • update docs and CLI commands to reflect ongoing work for remote modes and AWS runner foundation
  • clean up the affected macOS Dashboard feature and shared models so lint/type checks pass

Testing

  • Not run (not requested)

Summary by CodeRabbit

  • New Features

    • AWS support: bootstrap EC2 instances as remote nodes and AWS inspect/bootstrap flows.
    • Local dispatch execution: new --local mode for running dispatch in the local workspace.
    • Workspace management: new hack workspace reset for safe workspace resets.
    • Dashboard: explicit remote-action controls, source-backed node indicators, and AWS settings entry in the UI.
  • Documentation

    • New AWS node bootstrap guide and updated CLI docs for dispatch, workspace, and AWS examples.
  • Tests

    • End-to-end tests and scripts for local dispatch and AWS bootstrap flows.

@changeset-bot

changeset-bot Bot commented Mar 10, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 1c8745b

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@coderabbitai

coderabbitai Bot commented Mar 10, 2026

Copy link
Copy Markdown

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 6f5d38bd-fead-47d1-84d3-f51faa617a38

📥 Commits

Reviewing files that changed from the base of the PR and between da8c11f and 1c8745b.

📒 Files selected for processing (1)
  • package.json
🚧 Files skipped from review as they are similar to previous changes (1)
  • package.json

Walkthrough

Adds AWS-backed node bootstrap and inspect flows across macOS UI, CLI, and services; implements a local dispatch mode with PR automation and terminal-state tracking; introduces workspace reset tooling, AWS E2E scripts/tests, new shared AWS data models, HackCLI client AWS methods, and widespread refactors and lint-suppression annotations.

Changes

Cohort / File(s) Summary
macOS Dashboard UI & Settings
apps/macos/.../CommandPalette.swift, apps/macos/.../SettingsOverlayView.swift, apps/macos/.../NodeTopologySettingsView.swift, apps/macos/.../ProjectDetailView.swift
Adds AWS settings pane and navigation action; inserts AwsExtensionSettingsView; replaces Railway navigation with AWS in UI paths; adds explicit remote-actions card and source-aware node displays.
DashboardModel UI API
apps/macos/.../DashboardModel.swift
Adds public methods bootstrapAwsNode(request:) and inspectAws(request:) to drive AWS bootstrap/inspect flows from the UI.
HackCLI Client: AWS operations
apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift
Adds bootstrapAwsNode and inspectAws async methods that invoke the CLI with assembled AWS args and decode JSON responses.
Shared Models: AWS types & node source
apps/macos/Packages/Shared/Models/Sources/HackDesktopModels/Models.swift, apps/macos/.../AwsBootstrapRequestTests.swift
Introduces AWS request/response types (AwsBootstrapRequest, AwsBootstrapResponse, AwsInspectResponse, etc.) and adds optional source to NodeRegistryRecord; includes unit tests for request selection logic. NOTE: AWS types are duplicated in the diff.
CLI: node AWS provider
src/commands/node.ts
Adds provider aws commands: bootstrap and inspect; implements EC2/SSM resolution, bootstrap flow, node enrollment/ensure, and node-auth verification helpers; adds EnsureArgs and related command wiring.
CLI: dispatch local mode & bootstrap modularization
src/commands/dispatch.ts, src/lib/dispatch-runs.ts
Implements --local dispatch execution path with PR automation, terminal-state lifecycle (new DispatchRunTerminalState), modular provider bootstrap arg builders (including AWS), and richer local PR flows and artifacts.
Workspace command & reset
src/commands/workspace.ts, src/cli/spec.ts
Adds new workspace command with reset subcommand to reset project workspace to a base ref with index.lock recovery, excludes handling, and JSON/human summaries; registers workspaceCommand in CLI spec.
E2E tooling & tests
scripts/aws-node-e2e.ts, tests/dispatch-local-command.test.ts, docs/guides/remote-node-aws.md
Adds AWS E2E bootstrap script, extensive dispatch-local end-to-end tests (PR flows mocked), and a new AWS remote-node bootstrap guide.
Documentation & package updates
docs/*.md, package.json
Adds docs links/guides for AWS and local dispatch, updates CLI docs, adds @aws-sdk deps and test:e2e:aws script, and new npm script aliases.
Feature flags & project views
src/constants.ts, src/lib/project-views.ts
Adds dance.hack.aws to GLOBAL_ONLY_EXTENSION_IDS and maps that extension id to feature aws.
Scattered refactors & lint suppressions
many src/ and services/* files (e.g., src/commands/{env,global,session}, src/control-plane/extensions/*, services/auth-broker/*, src/tui/*, src/ui/*, src/daemon/*)
Large refactors: modularized command handlers, token/profile helpers, tickets/tickets-git-channel restructuring, supervisor/shell flows, session picker modularization, global/env flow helpers; numerous biome-ignore comments added to suppress cognitive-complexity linting; mainly internal API and control-flow reorganizations.
Miscellaneous
apps/macos/.../CommandPalette.swift (small)
Adds new CommandPaletteAction "Go to AWS Settings" that posts a settings navigation notification.

Sequence Diagram(s)

sequenceDiagram
    actor User as macOS App User
    participant Dash as Dashboard UI
    participant Model as DashboardModel
    participant CLI as HackCLIClient
    participant AWS as AWS SDK/CLI
    participant SSM as SSM (via AWS)
    participant EC2 as EC2 Instance
    participant Node as remote hack node

    User->>Dash: Click "Bootstrap AWS Node"
    Dash->>Model: bootstrapAwsNode(request)
    Model->>CLI: bootstrapAwsNode(request)
    CLI->>AWS: Describe/Start EC2, check SSM
    AWS-->>CLI: Instance/SSM status
    CLI->>SSM: Send bootstrap command
    SSM->>EC2: Execute bootstrap script
    EC2->>Node: Run hack node init --json
    Node-->>EC2: Enrollment bundle
    EC2-->>SSM: Command output
    SSM-->>CLI: Bootstrap output
    CLI-->>Model: AwsBootstrapResponse
    Model-->>Dash: update UI with node status
Loading
sequenceDiagram
    actor User as CLI User
    participant CLI as dispatch command
    participant Local as Local Workspace
    participant Git as Git
    participant GitHub as GitHub API
    participant Artifacts as Artifact Storage

    User->>CLI: hack dispatch run --local --pr -- "<cmd>"
    CLI->>Local: validate project & run command
    Local-->>CLI: command output & artifacts
    CLI->>Git: inspect branch/diff status
    alt no changes
        CLI->>CLI: terminalState = no_diff
    else changes present
        CLI->>GitHub: check existing PRs
        alt PR exists
            CLI->>GitHub: update PR
            CLI->>CLI: terminalState = pr_created (or others)
        else
            CLI->>GitHub: create PR
            CLI->>CLI: terminalState = pr_created
        end
    end
    CLI->>Artifacts: write manifest including terminalState
    Artifacts-->>User: return exit code and results
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

Poem

🐰 I hopped through clouds and bits today,

EC2s and SSM led the way,
Local runs with PRs in tow,
Workspaces reset, artifacts show,
The rabbit cheers: bootstrap, inspect — hooray!

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch aws

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Note

Due to the large number of review comments, Critical severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (5)
services/auth-broker/src/modules/linear-connections/local-access.ts (1)

119-153: ⚠️ Potential issue | 🟠 Major

Wrap the refresh/rehydrate path in try/catch.

This branch can still reject from refreshAccessToken(...) or the follow-up readLocalAccess(...), which bypasses the declared SeedLinearLocalAccessResult contract and turns a recoverable sync failure into an uncaught exception.

Proposed fix
   if (shouldRefreshEnvelope({ envelope })) {
     const refreshToken = envelope.refreshToken?.trim() ?? ""
     if (!refreshToken) {
       return {
         ok: false,
         error: "linear_local_access_refresh_required",
         statusCode: 409,
       }
     }
-    const refreshedToken = await refreshAccessToken({
-      tokenUrl: input.config.linearTokenUrl,
-      clientId: input.config.linearClientId ?? "",
-      ...(input.config.linearClientSecret
-        ? { clientSecret: input.config.linearClientSecret }
-        : {}),
-      refreshToken,
-    })
-    if (!refreshedToken.ok) {
-      return {
-        ok: false,
-        error: refreshedToken.error,
-        statusCode: refreshedToken.statusCode,
-      }
-    }
-    const persisted = await persistLinearLocalAccessCustody({
-      config: input.config,
-      connectionStore: input.connectionStore,
-      profileId: input.profileId,
-      token: refreshedToken.token,
-      tokenExpiresAt: refreshedToken.tokenExpiresAt,
-      refreshToken: refreshedToken.refreshToken ?? envelope.refreshToken,
-      refreshTokenExpiresAt:
-        refreshedToken.refreshTokenExpiresAt ?? envelope.refreshTokenExpiresAt,
-    })
-    if (!persisted.ok) {
-      return persisted
-    }
-    envelope =
-      (
-        await input.connectionStore.readLocalAccess({
-          profileId: input.profileId,
-          encryptionKey,
-        })
-      )?.envelope ?? envelope
-    refreshed = true
+    try {
+      const refreshedToken = await refreshAccessToken({
+        tokenUrl: input.config.linearTokenUrl,
+        clientId: input.config.linearClientId ?? "",
+        ...(input.config.linearClientSecret
+          ? { clientSecret: input.config.linearClientSecret }
+          : {}),
+        refreshToken,
+      })
+      if (!refreshedToken.ok) {
+        return {
+          ok: false,
+          error: refreshedToken.error,
+          statusCode: refreshedToken.statusCode,
+        }
+      }
+      const persisted = await persistLinearLocalAccessCustody({
+        config: input.config,
+        connectionStore: input.connectionStore,
+        profileId: input.profileId,
+        token: refreshedToken.token,
+        tokenExpiresAt: refreshedToken.tokenExpiresAt,
+        refreshToken: refreshedToken.refreshToken ?? envelope.refreshToken,
+        refreshTokenExpiresAt:
+          refreshedToken.refreshTokenExpiresAt ?? envelope.refreshTokenExpiresAt,
+      })
+      if (!persisted.ok) {
+        return persisted
+      }
+      envelope =
+        (
+          await input.connectionStore.readLocalAccess({
+            profileId: input.profileId,
+            encryptionKey,
+          })
+        )?.envelope ?? envelope
+      refreshed = true
+    } catch (error) {
+      return {
+        ok: false,
+        error: error instanceof Error ? error.message : String(error),
+        statusCode: 500,
+      }
+    }
   }

As per coding guidelines, "Handle errors appropriately in async code with try-catch blocks"

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@services/auth-broker/src/modules/linear-connections/local-access.ts` around
lines 119 - 153, The refresh-and-rehydrate sequence (calls to
refreshAccessToken, persistLinearLocalAccessCustody and
input.connectionStore.readLocalAccess) must be wrapped in a try/catch so any
thrown errors are converted into the SeedLinearLocalAccessResult shape instead
of escaping; wrap the block starting at refreshAccessToken(...) through the
readLocalAccess(...) envelope assignment in a try, and in catch return a failure
result (ok: false) with the caught error (and an appropriate statusCode) so
callers still receive a SeedLinearLocalAccessResult rather than an uncaught
exception.
src/control-plane/extensions/supervisor/commands.ts (1)

1269-1283: ⚠️ Potential issue | 🟡 Minor

Reject fractional offsets and dimensions instead of truncating them.

Math.trunc() turns --from 1.9 into 1 and --rows 24.7 into 24. These flags are integer-only, so silently coercing them changes the user's request.

Suggested fix
 function parseOffset(value: string): number | null {
   const parsed = Number(value)
-  if (!Number.isFinite(parsed) || parsed < 0) {
+  if (!Number.isInteger(parsed) || parsed < 0) {
     return null
   }
-  return Math.trunc(parsed)
+  return parsed
 }

 function parsePositiveInt(value: string): number | null {
   const parsed = Number(value)
-  if (!Number.isFinite(parsed) || parsed <= 0) {
+  if (!Number.isInteger(parsed) || parsed <= 0) {
     return null
   }
-  return Math.trunc(parsed)
+  return parsed
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/control-plane/extensions/supervisor/commands.ts` around lines 1269 -
1283, The parsing helpers parseOffset and parsePositiveInt currently accept
fractional inputs because they use Math.trunc; change their validation to reject
non-integer values instead. In both functions (parseOffset and parsePositiveInt)
keep the Number(value) and Number.isFinite checks, add a
Number.isInteger(parsed) guard, and only accept values that also satisfy the
existing non-negative (parsed >= 0) or positive (parsed > 0) conditions; return
null for fractions or invalid values rather than truncating. Ensure the
functions still return Math.trunc(parsed) (or parsed) only when parsing passes
all checks so fractional inputs like "1.9" or "24.7" are rejected.
src/control-plane/extensions/github/commands.ts (2)

1095-1098: ⚠️ Potential issue | 🟠 Major

Carry the saved installation ID into selection.

The new discovery flow only passes --installation-id into selectGitHubInstallation(). If this profile already has defaults.installationId and the token sees multiple installations, oauth-connect now prompts or fails in CI instead of reusing the configured installation.

💡 Proposed fix
   const selectedInstallation = await selectGitHubInstallation({
-    requestedInstallationId: parsed.value.installationId,
+    requestedInstallationId:
+      parsed.value.installationId ?? defaults.installationId,
     installations: identity.installations,
   });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/control-plane/extensions/github/commands.ts` around lines 1095 - 1098,
The call to selectGitHubInstallation only uses parsed.value.installationId, so
if the profile has a saved defaults.installationId it’s ignored; update the
requestedInstallationId argument to prefer the CLI flag but fall back to the
profile default (e.g. requestedInstallationId: parsed.value.installationId ??
identity.defaults?.installationId) when calling selectGitHubInstallation({ ...,
installations: identity.installations }), preserving existing precedence (CLI
flag > profile default).

1341-1394: ⚠️ Potential issue | 🟠 Major

Wrap fetch calls in try/catch to handle network-level failures.

In Bun/Fetch, DNS/TLS/timeout failures reject the promise instead of returning a non-OK Response. The /user and /user/installations fetch calls are currently unprotected and will propagate unhandled exceptions through connect, oauth-connect, and status commands.

Wrap the /user lookup in try/catch and return an error result. Degrade /user/installations transport failures into installationWarning to maintain partial functionality when the installations endpoint is unreachable.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/control-plane/extensions/github/commands.ts` around lines 1341 - 1394,
The /user and /user/installations fetch calls are unguarded against
network-level rejections; wrap the first fetch (the call that assigns userRes
and userPayload for `${base}/user`) in a try/catch and on catch return an error
result (ok: false) with a descriptive error string including the caught error
message so connect/oauth-connect/status callers get a proper failure instead of
an exception; likewise wrap the installations fetch (the assignment to
installationsRes/installationsPayload for
`${base}/user/installations?per_page=100`) in a try/catch and on failure return
the partial successful response (include login, accountId/accountName if present
and installations: [] ) plus an installationWarning that includes the caught
error message to preserve degraded functionality.
src/control-plane/extensions/linear/commands.ts (1)

7373-7427: ⚠️ Potential issue | 🟠 Major

connect --stdin drops the refresh metadata accepted by the direct token path.

resolveConnectToken() parses --token-expires-at, --refresh-token, and --refresh-token-expires-at, but the stdin branch ignores them because resolveConnectTokenFromStdin() has no inputs besides stdin. Piping a plain token now loses the extra fields that would be saved with --token.

Suggested fix
-async function resolveConnectTokenFromStdin(): Promise<
+async function resolveConnectTokenFromStdin(input: {
+  readonly expiresAt?: string;
+  readonly refreshToken?: string;
+  readonly refreshTokenExpiresAt?: string;
+}): Promise<
   | {
       readonly ok: true;
       readonly token: string;
       readonly expiresAt?: string;
       readonly refreshToken?: string;
@@
   if (!envelope?.token) {
     return { ok: false, error: "Missing token from stdin." };
   }
   return buildResolvedConnectToken({
     token: envelope.token,
-    expiresAt: envelope.expiresAt,
-    refreshToken: envelope.refreshToken,
-    refreshTokenExpiresAt: envelope.refreshTokenExpiresAt,
+    expiresAt: envelope.expiresAt ?? input.expiresAt,
+    refreshToken: envelope.refreshToken ?? input.refreshToken,
+    refreshTokenExpiresAt:
+      envelope.refreshTokenExpiresAt ?? input.refreshTokenExpiresAt,
   });
 }
@@
   if (input.stdin) {
-    return await resolveConnectTokenFromStdin();
+    return await resolveConnectTokenFromStdin({
+      expiresAt: input.expiresAt,
+      refreshToken: input.refreshToken,
+      refreshTokenExpiresAt: input.refreshTokenExpiresAt,
+    });
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/control-plane/extensions/linear/commands.ts` around lines 7373 - 7427,
The stdin path drops token metadata because resolveConnectTokenFromStdin() only
reads stdin; update resolveConnectTokenFromStdin to accept optional metadata
parameters (expiresAt, refreshToken, refreshTokenExpiresAt) or pass those values
into it from resolveConnectToken and merge them with values parsed from the
envelope, then return buildResolvedConnectToken with the final metadata; locate
resolveConnectToken and resolveConnectTokenFromStdin to add the parameters and
merging logic so --token-expires-at / --refresh-token /
--refresh-token-expires-at provided alongside --stdin are preserved.
🟠 Major comments (25)
src/commands/env.ts-427-446 (1)

427-446: ⚠️ Potential issue | 🟠 Major

Persist the backend last to avoid leaving config in a broken state.

Line 427 commits controlPlane.secrets.backend before the rest of the flow can still fail. For example, hack env backend use cloud without --provider now errors after the backend has already been switched to "cloud", and a failed encrypted-file key provisioning has the same partial-write problem. Validate/provision first, then flip the backend once the command is guaranteed to succeed.

💡 Suggested reordering
-  await updateGlobalConfig({
-    path: "controlPlane.secrets.backend",
-    value: backend,
-  })
   await persistSecretBackendConfig({
     backend,
     providerRaw,
     storePath: args.options.storePath,
     keyPath: args.options.keyPath,
     secretProject: args.options.secretProject,
     secretPrefix: args.options.secretPrefix,
   })
 
-  const controlPlane = await readControlPlaneConfig({})
-  const secretsConfig = controlPlane.config.secrets
+  let controlPlane = await readControlPlaneConfig({})
+  let secretsConfig = controlPlane.config.secrets
   const provisionedKey = await maybeProvisionEncryptedFileKey({
     backend,
     shouldProvision: args.options.provisionKey === true,
     secretsConfig,
   })
+
+  await updateGlobalConfig({
+    path: "controlPlane.secrets.backend",
+    value: backend,
+  })
+  controlPlane = await readControlPlaneConfig({})
+  secretsConfig = controlPlane.config.secrets
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/commands/env.ts` around lines 427 - 446, The code currently calls
updateGlobalConfig to set controlPlane.secrets.backend before running
persistSecretBackendConfig and maybeProvisionEncryptedFileKey, which can leave
the system in a partially-updated state on error; change the order so you first
run persistSecretBackendConfig (using backend, providerRaw, storePath, keyPath,
secretProject, secretPrefix) and then call maybeProvisionEncryptedFileKey (with
backend, shouldProvision from args.options.provisionKey, and secretsConfig from
readControlPlaneConfig), and only after those succeed call updateGlobalConfig to
set controlPlane.secrets.backend; references: updateGlobalConfig,
persistSecretBackendConfig, readControlPlaneConfig,
maybeProvisionEncryptedFileKey.
src/commands/session.ts-448-450 (1)

448-450: ⚠️ Potential issue | 🟠 Major

Use repoRoot when running hack up for an existing session.

This branch passes input.project.projectDir, while the create path intentionally runs from project.repoRoot. hack session start --up will therefore execute in different directories depending on whether the session already exists.

Suggested fix
       if (input.runUp) {
-        await runHackUp(input.project.projectDir)
+        await runHackUp(input.project.repoRoot)
       }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/commands/session.ts` around lines 448 - 450, The code calls
runHackUp(input.project.projectDir) when input.runUp is true, causing
inconsistent working directories vs the create path which uses project.repoRoot;
update the branch that handles existing sessions to call
runHackUp(input.project.repoRoot) (i.e., replace use of projectDir with
repoRoot) so both code paths run the "hack up" command from the repository root;
ensure you reference the same property name used by the create path
(project.repoRoot) when invoking runHackUp.
src/commands/session.ts-407-413 (1)

407-413: ⚠️ Potential issue | 🟠 Major

Match repository roots in resolveProjectForSessionStart.

The command advertises "project name or path", but this helper only matches the resolved path against project.projectDir. A call like hack session start /path/to/repo will miss registered projects even though the rest of the start flow uses project.repoRoot.

Suggested fix
   return (
     input.projects.find(
       (project) =>
         project.name === input.projectNameOrPath ||
-        project.projectDir === resolvedPath
+        project.repoRoot === resolvedPath ||
+        project.projectDir === resolvedPath
     ) ?? null
   )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/commands/session.ts` around lines 407 - 413, The helper
resolveProjectForSessionStart currently only checks project.name and
project.projectDir against resolvedPath, so passing a repo path
(input.projectNameOrPath) can miss matches; update the matching logic in
resolveProjectForSessionStart to also compare resolvedPath against
project.repoRoot (and/or normalize/resolve project.repoRoot) in the same find
predicate (alongside project.projectDir and project.name) so repository-root
paths correctly resolve to the registered project.
src/commands/session.ts-458-463 (1)

458-463: ⚠️ Potential issue | 🟠 Major

Don't force the first --new session to be <base>:2.

When there are no existing sessions for a project, this still returns a numbered name. That leaves no canonical <base> session and makes the exact-name lookups in handleList and buildSessionPickerOptions treat the project as if it had no main session.

Suggested fix
   if (input.forceNew) {
+    const hasExistingSession = sessions.some(
+      (session) =>
+        session.name === input.baseName ||
+        session.name.startsWith(`${input.baseName}:`)
+    )
     return {
       ok: true,
-      sessionName: `${input.baseName}:${getNextSessionNumber(sessions, input.baseName)}`,
+      sessionName: hasExistingSession
+        ? `${input.baseName}:${getNextSessionNumber(sessions, input.baseName)}`
+        : input.baseName,
     }
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/commands/session.ts` around lines 458 - 463, The current force-new branch
always returns a numbered name using getNextSessionNumber, causing the first new
session to be "<base>:2" and preventing a canonical "<base>" session; change the
logic in the forceNew handling so that if there are no existing sessions
matching input.baseName (inspect the sessions array for exact baseName matches),
return sessionName = input.baseName (no numeric suffix), otherwise return
`${input.baseName}:${getNextSessionNumber(sessions, input.baseName)}` so
subsequent new sessions get numbered; this preserves exact-name lookups in
handleList and buildSessionPickerOptions.
src/commands/global.ts-564-577 (1)

564-577: ⚠️ Potential issue | 🟠 Major

Don't drop the mutagen remediation warning on install errors.

Lines 568-577 return before warnOnMissing can run. For the mutagen call site, a real install failure now loses the "Remote sync may fail..." guidance and hack doctor --fix hint.

🐛 Proposed fix
   const systemPath = input.resolveSystemPath();
   s.stop(
     systemPath ? `${input.label} available on PATH` : input.missingMessage
   );
   if (result.reason === "failed") {
     input.warnOnFailure?.({ message: result.message });
-    return;
   }
   if (!systemPath) {
     input.warnOnMissing?.({
       reason: result.reason,
       message: result.message,
     });
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/commands/global.ts` around lines 564 - 577, The current logic returns as
soon as result.reason === "failed", which prevents input.warnOnMissing from
running and loses the mutagen remediation warning; update the flow in the block
containing systemPath, s.stop, result.reason, input.warnOnFailure, and
input.warnOnMissing so that when result.reason === "failed" you still call
input.warnOnMissing if !systemPath (passing reason and message) before returning
— i.e., invoke input.warnOnMissing({ reason: result.reason, message:
result.message }) when systemPath is falsy even on a failed install, then call
input.warnOnFailure and return.
src/control-plane/extensions/tickets/commands.ts-773-780 (1)

773-780: ⚠️ Potential issue | 🟠 Major

Make setup --check fail when setup is incomplete.

getTicketsSetupExitCode() only treats "error" as failure, so missing skill/docs and repo drift still exit 0. That makes the new check flow unreliable in automation because incomplete setup looks healthy. This helper needs the action and repo results so check mode can return non-zero for non-ready states.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/control-plane/extensions/tickets/commands.ts` around lines 773 - 780,
getTicketsSetupExitCode currently only fails on "error" but must also fail when
setup is incomplete; update the function signature (getTicketsSetupExitCode) to
accept the action and repo results in addition to skill and docs (e.g., add
opts.action and opts.repo) and change its logic to return non-zero whenever any
of the following are not in a ready state: skill.status !== "ready", any docs
entry where result.status !== "ready" (or missing), action.status !== "ready",
or repo.status !== "ready"; use the existing types (checkTicketsSkill return
type and TicketsSetupDocResults) to locate and wire up the new parameters and
ensure callers are updated to pass action and repo results.
src/control-plane/extensions/tickets/commands.ts-1688-1691 (1)

1688-1691: ⚠️ Potential issue | 🟠 Major

Preserve explicit empty option values during finalization.

These truthy checks turn --body="" / --body= into undefined, so tickets update can no longer clear a body even though the update path explicitly supports empty bodies. The same pattern also lets --title "" skip the empty-title validation. Use !== undefined here.

Suggested fix
   return {
-    ...(state.title ? { title: state.title } : {}),
-    ...(state.body ? { body: state.body } : {}),
-    ...(state.bodyFile ? { bodyFile: state.bodyFile } : {}),
+    ...(state.title !== undefined ? { title: state.title } : {}),
+    ...(state.body !== undefined ? { body: state.body } : {}),
+    ...(state.bodyFile !== undefined ? { bodyFile: state.bodyFile } : {}),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/control-plane/extensions/tickets/commands.ts` around lines 1688 - 1691,
The current finalization builds the update payload using truthy checks which
drop intentionally empty strings (e.g., --body="" or --title="") and prevents
clearing fields; change the spread conditions to explicit undefined checks so
empty strings are preserved: replace each conditional like ...(state.body ? {
body: state.body } : {}) with ...(state.body !== undefined ? { body: state.body
} : {}), and do the same for state.title and state.bodyFile in the payload
construction used by the tickets update path.
src/control-plane/extensions/tickets/commands.ts-792-795 (1)

792-795: ⚠️ Potential issue | 🟠 Major

Defer enabling the extension until after an install action is confirmed.

This write happens before the args are even parsed, so tickets setup --check, --remove, or any invalid invocation can still rewrite hack.config.json. --check should stay side-effect free, and teardown flows should not re-enable the extension on their way through.

Suggested fix
-  await ensureTicketsExtensionEnabled({
-    projectDir: project.projectDir,
-    logger: opts.ctx.logger,
-  });
-
   const parsed = parseTicketsSetupArgs({ args: opts.args });
   if (!parsed.ok) {
     opts.ctx.logger.error({ message: parsed.error });
     return 1;
   }
 
   const action = resolveTicketsSetupAction({ input: parsed.value });
+  if (action === "install") {
+    await ensureTicketsExtensionEnabled({
+      projectDir: project.projectDir,
+      logger: opts.ctx.logger,
+    });
+  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/control-plane/extensions/tickets/commands.ts` around lines 792 - 795, The
call to ensureTicketsExtensionEnabled is happening too early (before args are
parsed) and can mutate hack.config.json for read-only commands; move the
ensureTicketsExtensionEnabled(...) invocation out of the top-level command
initialization in commands.ts and into the actual install/setup execution path
(e.g., inside the handler that performs the 'tickets setup' install action).
Only invoke ensureTicketsExtensionEnabled when the parsed command/action is a
confirmed install (not when flags include --check, --remove, or on invalid
invocations), and add an explicit guard that skips enabling when --check or
--remove are present so teardown and dry-run flows remain side-effect free.
src/control-plane/extensions/supervisor/commands.ts-689-694 (1)

689-694: ⚠️ Potential issue | 🟠 Major

Reject --path together with --project-id.

--path is also a project selector, so hack x supervisor shell --project-id A --path ../B currently parses and later combines B's local repo with A's remote project id.

Suggested fix
-  if (parsedState.state.project && parsedState.state.projectId) {
+  if (
+    parsedState.state.projectId &&
+    (parsedState.state.project || parsedState.state.path)
+  ) {
     return {
       ok: false,
-      error: "Use either --project or --project-id (not both).",
+      error: "Use either --project/--path or --project-id (not both).",
     }
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/control-plane/extensions/supervisor/commands.ts` around lines 689 - 694,
The validation currently rejects using parsedState.state.project with
parsedState.state.projectId but doesn't forbid parsedState.state.path with
parsedState.state.projectId; add a parallel check after the existing one that if
parsedState.state.path and parsedState.state.projectId are both set return { ok:
false, error: "Use either --path or --project-id (not both)." } so the CLI
prevents combining a local --path project selector with a remote --project-id.
Use the same return shape and error style as the existing block that checks
parsedState.state.project and parsedState.state.projectId.
src/control-plane/extensions/supervisor/commands.ts-2011-2028 (1)

2011-2028: ⚠️ Potential issue | 🟠 Major

Make explicit project selection override the ambient context.

projectId/projectName are seeded from ctx first and then only backfilled with ??, so --project or --path can be ignored whenever the handler already has a ctx.projectId. That opens the shell against the wrong project.

Suggested fix
-  let projectId: string | undefined =
-    input.parsed.projectId ?? input.ctx.projectId
-  let projectName: string | undefined =
-    input.parsed.project ?? input.ctx.projectName
+  let projectId: string | undefined = input.parsed.projectId
+  let projectName: string | undefined = input.parsed.project
   let localProject: ProjectContext | undefined = input.ctx.project

   if (input.parsed.project || input.parsed.path) {
     const localProjectResult = await resolveSupervisorProject({
       ctx: input.ctx,
       projectOpt: input.parsed.project,
       pathOpt: input.parsed.path,
     })
     if (!localProjectResult.ok) {
       return localProjectResult
     }
     localProject = localProjectResult.project
-    projectId = projectId ?? localProjectResult.projectId
-    projectName = localProjectResult.projectName ?? projectName
+    projectId = localProjectResult.projectId
+    projectName = localProjectResult.projectName ?? input.parsed.project
+  } else {
+    projectId = projectId ?? input.ctx.projectId
+    projectName = projectName ?? input.ctx.projectName
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/control-plane/extensions/supervisor/commands.ts` around lines 2011 -
2028, The handler seeds projectId/projectName from input.ctx before checking
parsed flags, so explicit flags (--project or --path) can be ignored; change the
assignment logic in the block around resolveSupervisorProject so that when
input.parsed.project or input.parsed.path is provided you override the ambient
values: call resolveSupervisorProject (function resolveSupervisorProject) as you
do, and then unconditionally set projectId = localProjectResult.projectId and
projectName = localProjectResult.projectName (or fall back to parsed values if
those are present) instead of using the nullish coalescing (??) that only
backfills; also ensure localProject is assigned from localProjectResult.project
to reflect the explicit selection.
src/control-plane/extensions/github/auth.ts-924-933 (1)

924-933: ⚠️ Potential issue | 🟠 Major

Don't silently remap a broken defaultProfile.

If config.defaultProfile points at a missing profile, this selection code falls back to "default"/the first configured profile while still reporting the source as "global_default". That can route token resolution and PR automation through the wrong GitHub account instead of surfacing the misconfiguration.

Also applies to: 943-962

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/control-plane/extensions/github/auth.ts` around lines 924 - 933, The code
silently remaps a configured defaultProfile that points to a missing profile;
update the logic around
resolveGitHubProfileStringSetting/defaultProfileFromConfig and
resolveGitHubDefaultProfileId so that when configuredDefaultProfileId (from
defaultProfileFromConfig) is non-null but not present in profilesById you do NOT
fall back silently — instead detect the mismatch, log or surface an explicit
configuration error (or return undefined/invalid source) and avoid using the
first/“default” profile for token resolution; ensure
resolveGitHubDefaultProfileId accepts and propagates an invalid
configuredDefaultProfileId (and uses profilesById and sortedProfileIds to
validate) so callers can fail fast rather than continuing with a remapped
default.
tests/dispatch-local-command.test.ts-38-62 (1)

38-62: ⚠️ Potential issue | 🟠 Major

Delete env vars when the original value was absent.

process.env is string-backed; assigning undefined stores the literal string "undefined". This leaks bogus HOME/HACK_* values into later tests running in the same worker.

🛠️ Example fix
-  if (originalHome === undefined) {
-    process.env.HOME = undefined;
+  if (originalHome === undefined) {
+    delete process.env.HOME;

Apply the same pattern to all five env vars in this block: HACK_GLOBAL_CONFIG_PATH, HACK_LOGGER, HACK_SETUP_SYNC_MODE, and HACK_GITHUB_APP_TOKEN.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/dispatch-local-command.test.ts` around lines 38 - 62, The teardown code
in tests/dispatch-local-command.test.ts assigns undefined to process.env keys
which becomes the string "undefined"; change each conditional to delete the
environment variable when the original value was undefined instead of assigning
undefined. Specifically, for HOME and the four HACK_* keys
(HACK_GLOBAL_CONFIG_PATH, HACK_LOGGER, HACK_SETUP_SYNC_MODE,
HACK_GITHUB_APP_TOKEN) replace assignments like process.env.HACK_LOGGER =
undefined with delete process.env.HACK_LOGGER (or equivalent) while keeping
restoration to the original value when present.
src/commands/node.ts-4736-4758 (1)

4736-4758: ⚠️ Potential issue | 🟠 Major

Don't let SendCommand exceptions escape the result contract.

runAwsSsmShellCommand() returns an ok/error union, but sendAwsSsmCommand() throws on AWS errors. A denied ssm:SendCommand or missing permission will bypass normal CLI error handling and abort the command instead of surfacing a clean message. Return an { ok: false, error } result here or catch the exception inside runAwsSsmShellCommand().

Also applies to: 4787-4807

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/commands/node.ts` around lines 4736 - 4758, runAwsSsmShellCommand
currently calls sendAwsSsmCommand which can throw AWS exceptions and bypass the
{ok:false,error} contract; wrap the sendAwsSsmCommand invocation in a try/catch
inside runAwsSsmShellCommand, catch any thrown error, and return { ok: false,
error: String(err) } (or a sanitized message) instead of letting the exception
escape; also apply the same try/catch/return pattern to the other
sendAwsSsmCommand call referenced around the 4787-4807 area so all SendCommand
errors are converted into the function's result union (keep uses of
readAwsSsmCommandInvocation unchanged).
src/commands/node.ts-85-86 (1)

85-86: ⚠️ Potential issue | 🟠 Major

AWS cold-start budgets are too small for stopped instances.

Each wait loop only gets 40 × 250ms (~10s). That is usually not enough for EC2 to reach running and for SSM to register afterward, so healthy boots will time out during cold starts. Give instance state, SSM readiness, and command execution separate minute-scale budgets instead of reusing this 10-second window everywhere.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/commands/node.ts` around lines 85 - 86, The current constants
DEFAULT_AWS_BOOTSTRAP_RETRIES and DEFAULT_AWS_BOOTSTRAP_DELAY_MS create only
~10s total timeout and are reused everywhere; change the approach by introducing
separate minute-scale budgets and reusing them in the relevant wait loops: add
distinct constants (e.g., DEFAULT_AWS_INSTANCE_START_RETRIES,
DEFAULT_AWS_INSTANCE_START_DELAY_MS for waiting for EC2 to reach "running";
DEFAULT_AWS_SSM_READY_RETRIES, DEFAULT_AWS_SSM_READY_DELAY_MS for waiting SSM
registration; DEFAULT_AWS_COMMAND_EXEC_RETRIES,
DEFAULT_AWS_COMMAND_EXEC_DELAY_MS for waiting command execution) and replace
uses of DEFAULT_AWS_BOOTSTRAP_RETRIES/DELAY_MS in functions/methods that check
instance state, SSM readiness, and command completion (locate the wait loops
that call EC2 status checks, SSM DescribeInstanceInformation /
GetCommandInvocation, and command polling) so each stage gets a minute-scale
budget (e.g., ~60s total) rather than the current ~10s.
apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift-1220-1228 (1)

1220-1228: ⚠️ Potential issue | 🟠 Major

Normalize the AWS target selector before appending CLI flags.

Both methods append --instance-id and the tag selector flags independently. If a request carries stale values from both modes, or only one half of the tag selector, the subprocess gets an ambiguous/invalid target set.

💡 Suggested fix
-    if let value = normalized(request.instanceId) {
-      args.append(contentsOf: ["--instance-id", value])
-    }
-    if let value = normalized(request.instanceTagKey) {
-      args.append(contentsOf: ["--instance-tag-key", value])
-    }
-    if let value = normalized(request.instanceTagValue) {
-      args.append(contentsOf: ["--instance-tag-value", value])
-    }
+    let instanceId = normalized(request.instanceId)
+    let instanceTagKey = normalized(request.instanceTagKey)
+    let instanceTagValue = normalized(request.instanceTagValue)
+
+    if let instanceId {
+      args.append(contentsOf: ["--instance-id", instanceId])
+    } else if let instanceTagKey, let instanceTagValue {
+      args.append(
+        contentsOf: ["--instance-tag-key", instanceTagKey, "--instance-tag-value", instanceTagValue]
+      )
+    } else if instanceTagKey != nil || instanceTagValue != nil {
+      throw HackCLIError.network("AWS instance tag selection requires both a tag key and value.")
+    }

Apply the same normalization in both bootstrapAwsNode(request:) and inspectAws(request:) so the two call paths stay aligned.

Also applies to: 1263-1271

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift`
around lines 1220 - 1228, The AWS target selector flags are appended
independently causing ambiguous/invalid targets when stale values exist; update
both bootstrapAwsNode(request:) and inspectAws(request:) to normalize the
selector first and only append the corresponding CLI flags from the normalized
result (i.e., use the normalized(...) output for instanceId, instanceTagKey, and
instanceTagValue consistently), skipping any flag whose normalized value is nil
so you never mix instance-id with stale tag fields.
src/control-plane/extensions/linear/auth.ts-204-227 (1)

204-227: ⚠️ Potential issue | 🟠 Major

Preserve the broker management token when writing the refreshed envelope.

This rebuild only keeps the access/refresh token fields. After any successful refresh, the stored managementToken and managementTokenExpiresAt disappear, so the next broker-only Linear operation falls back or fails even though the profile was already connected.

💡 Suggested fix
   return {
     token: input.refreshed.token,
     ...(input.refreshed.expiresAt
       ? { expiresAt: input.refreshed.expiresAt }
       : {}),
     ...(nextRefreshToken ? { refreshToken: nextRefreshToken } : {}),
     ...(nextRefreshTokenExpiresAt
       ? { refreshTokenExpiresAt: nextRefreshTokenExpiresAt }
       : {}),
+    ...(input.storedEnvelope.managementToken
+      ? { managementToken: input.storedEnvelope.managementToken }
+      : {}),
+    ...(input.storedEnvelope.managementTokenExpiresAt
+      ? {
+          managementTokenExpiresAt:
+            input.storedEnvelope.managementTokenExpiresAt,
+        }
+      : {}),
   };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/control-plane/extensions/linear/auth.ts` around lines 204 - 227, The
buildRefreshedLinearTokenEnvelope function currently returns an envelope that
omits the broker management token fields, causing stored managementToken and
managementTokenExpiresAt to be dropped after refresh; update the returned
LinearTokenEnvelope to include managementToken and managementTokenExpiresAt
copied from input.storedEnvelope when present (e.g., add
...(input.storedEnvelope.managementToken ? { managementToken:
input.storedEnvelope.managementToken } : {}) and similarly for
managementTokenExpiresAt) so broker-only operations continue to work after a
refresh.
src/commands/workspace.ts-192-199 (1)

192-199: ⚠️ Potential issue | 🟠 Major

Only branch-switch when --base resolved to a real remote ref.

parseBaseRef() splits on the first /, so --base feature/foo becomes remote=feature, branch=foo. This block then runs git checkout -B foo feature/foo, which can rename/reset the wrong local branch for any normal ref that happens to contain a slash. Gate the checkout on a confirmed remote match, not just on the parsed shape.

Suggested fix
-  if (parsedBase.remote && parsedBase.branch) {
+  if (fetchedRemote && parsedBase.branch) {
     await runGitWithLockRecovery({
       projectRoot: input.projectRoot,
       args: ["checkout", "-B", parsedBase.branch, parsedBase.baseRef],
       lockPath,
       context: `git checkout -B ${parsedBase.branch} ${parsedBase.baseRef}`,
@@
-    checkoutBranch: parsedBase.branch,
+    checkoutBranch: fetchedRemote ? parsedBase.branch : null,

Also applies to: 291-301

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/commands/workspace.ts` around lines 192 - 199, The checkout runs whenever
parsedBase looks like "remote/branch" but may be a normal ref with a slash;
change the logic so you first verify the remote ref actually exists before
calling runGitWithLockRecovery for the checkout. Use a git verification command
(e.g., rev-parse or ls-remote) against refs/remotes/<remote>/<branch> or
"<remote> <branch>" from the repo at input.projectRoot and only call
runGitWithLockRecovery({ args: ["checkout","-B", parsedBase.branch,
parsedBase.baseRef], lockPath, ... }) if that verification succeeds; apply the
same guard to the second similar block that performs checkout (the block
referenced at 291-301).
apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SettingsOverlayView.swift-2145-2149 (1)

2145-2149: ⚠️ Potential issue | 🟠 Major

saveAwsDefaults() can leave AWS config partially updated.

These writes ignore each setGlobalConfig result and keep going. If one call fails, earlier keys are already committed and later keys may differ, so the next AWS bootstrap can run against a mixed config. Please fail fast at minimum; ideally use a batched update path here.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SettingsOverlayView.swift`
around lines 2145 - 2149, The loop over writes in saveAwsDefaults currently
ignores each model.setGlobalConfig result, allowing partial commits; update the
loop to check the await model.setGlobalConfig(key:value:) return (or thrown
error) and fail fast on the first failure (propagate or log and return) so
earlier keys aren't committed while later ones fail; if available, prefer using
a single batched update API on the model (e.g., add or call a
setGlobalConfigs/upsertMany method) to apply all keys atomically, and only call
await loadAwsConfigFromDisk() after the update successfully completes.
apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SettingsOverlayView.swift-2056-2069 (1)

2056-2069: ⚠️ Potential issue | 🟠 Major

Require a tag key before treating tag-based bootstrap as ready.

Right now the readiness check only looks at instanceTagValue. If a user clears instanceTagKey, the UI still reports “Bootstrap ready” and enables the action, but the request is sent with instanceTagKey: nil, so the tag selector is incomplete.

Proposed fix
+  private var hasValidTagSelector: Bool {
+    normalizedOrNil(instanceTagKey) != nil && normalizedOrNil(instanceTagValue) != nil
+  }
+
   private var hasTargetSelector: Bool {
-    normalizedOrNil(instanceId) != nil || normalizedOrNil(instanceTagValue) != nil
+    normalizedOrNil(instanceId) != nil || hasValidTagSelector
   }
 
   private var hasExclusiveTargetSelector: Bool {
-    (normalizedOrNil(instanceId) != nil ? 1 : 0) + (normalizedOrNil(instanceTagValue) != nil ? 1 : 0) == 1
+    (normalizedOrNil(instanceId) != nil ? 1 : 0) + (hasValidTagSelector ? 1 : 0) == 1
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SettingsOverlayView.swift`
around lines 2056 - 2069, The readiness logic treats tag-based selection as
valid when only instanceTagValue is present; update the computed properties so
tag-based selectors require both instanceTagKey and instanceTagValue (i.e., use
normalizedOrNil(instanceTagKey) alongside normalizedOrNil(instanceTagValue) in
hasTargetSelector, hasExclusiveTargetSelector, and canBootstrapAwsNode) while
preserving the existing instanceId checks; adjust hasExclusiveTargetSelector to
count the tag pair as one selector only when both key and value are non-nil.
src/control-plane/extensions/linear/commands.ts-1534-1557 (1)

1534-1557: ⚠️ Potential issue | 🟠 Major

Don’t fall back to the default binding when an explicit --project-id misses.

If the caller supplies a project ID that is not part of the current binding, this helper returns the default bound project instead of null. sync-issue/sync-project then reuse the default team/profile while still honoring the user-supplied projectId, which can resolve the wrong team and build invalid Linear mutations.

Suggested fix
 function resolveSelectedProjectBindingTarget(input: {
   readonly binding: ReturnType<typeof resolveProjectLinearBinding>;
   readonly projectId?: string;
 }): LinearProjectBindingTarget | null {
   const explicitTarget = input.projectId
     ? findProjectBindingTarget({
         binding: input.binding,
         projectId: input.projectId,
       })
     : null;
-  if (explicitTarget) {
-    return explicitTarget;
+  if (input.projectId) {
+    return explicitTarget;
   }
   if (!input.binding.projectId) {
     return null;
   }
   return {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/control-plane/extensions/linear/commands.ts` around lines 1534 - 1557,
resolveSelectedProjectBindingTarget currently falls back to the default binding
when a caller supplies an explicit projectId that isn't found; update the
function (which calls findProjectBindingTarget) to return null immediately when
input.projectId is provided but findProjectBindingTarget returns null (i.e., add
a check like "if (input.projectId && !explicitTarget) return null" before
falling back to using input.binding values) so we don't reuse the default
team/profile for a missing explicit projectId.
src/control-plane/extensions/linear/commands.ts-1416-1433 (1)

1416-1433: ⚠️ Potential issue | 🟠 Major

Avoid persisting linear.profile before the bind is validated.

This writes the profile override before checking whether --project-id is present. A call like project-bind --profile foo returns 1 but still mutates project config, leaving a half-applied binding behind.

Suggested fix
 async function bindLinearProject(input: {
   readonly ctx: LinearCommandContext;
   readonly projectDir: string;
   readonly parsed: ProjectBindArgs;
   readonly existingBinding: ReturnType<typeof resolveProjectLinearBinding>;
 }): Promise<number> {
-  const boundProfile =
-    input.parsed.profileId ?? input.existingBinding.profileId;
-  if (boundProfile) {
-    await updateProjectConfig({
-      projectDir: input.projectDir,
-      path: "controlPlane.routing.overrides.linear.profile",
-      value: boundProfile,
-    });
-  }
-
   const projectId = input.parsed.projectId;
   if (!projectId) {
     input.ctx.logger.error({
       message:
         "Missing --project-id. Use --clear to remove mapping or pass a Linear project id to bind.",
     });
     return 1;
   }
+
+  const boundProfile =
+    input.parsed.profileId ?? input.existingBinding.profileId;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/control-plane/extensions/linear/commands.ts` around lines 1416 - 1433,
The code persists linear.profile via updateProjectConfig using boundProfile
before validating the bind; instead, ensure you only call updateProjectConfig
for "controlPlane.routing.overrides.linear.profile" after successful validation
that a projectId exists (i.e., after checking input.parsed.projectId / projectId
and returning non-error), so move the updateProjectConfig call (and any logic
that derives boundProfile from input.parsed.profileId or
input.existingBinding.profileId) to after the projectId check or gate it behind
the successful validation path to avoid mutating project config when the command
returns 1.
src/control-plane/extensions/linear/commands.ts-3961-3980 (1)

3961-3980: ⚠️ Potential issue | 🟠 Major

Linear-authoritative sync still leaks a local assignee update.

input.context.fields is spread first, so when the remote issue has no assignee, a locally resolved assigneeId survives and gets pushed back to Linear. That breaks the "linear" authority branch.

Suggested fix
 function resolveEffectiveTicketMutationFields(input: {
   readonly context: TicketToLinearSyncContext;
   readonly syncToggles: SyncToggles;
 }): LinearTicketMutationFields {
   if (!(input.context.authority === "linear" && input.context.existingIssue)) {
     return input.context.fields;
   }
 
+  const { assigneeId: _assigneeId, ...effectiveFields } = input.context.fields;
+
   return {
-    ...input.context.fields,
+    ...effectiveFields,
     title: input.context.existingIssue.title,
     description: input.context.existingIssue.description ?? "",
     ...(input.context.existingIssue.assigneeId
       ? { assigneeId: input.context.existingIssue.assigneeId }
       : {}),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/control-plane/extensions/linear/commands.ts` around lines 3961 - 3980, In
resolveEffectiveTicketMutationFields, the current spread of input.context.fields
first lets a local assigneeId persist when the Linear-authoritative branch
should remove it if the remote issue has no assignee; change the logic so that
after spreading input.context.fields you explicitly set or remove assigneeId
based on input.context.existingIssue.assigneeId (e.g., if
existingIssue.assigneeId exists assign it, otherwise delete/omit the assigneeId
key), ensuring the final returned object uses the remote assignee presence to
override any local assignee; keep the existing handling for title, description
and stateId in the same function.
src/commands/dispatch.ts-1148-1148 (1)

1148-1148: ⚠️ Potential issue | 🟠 Major

Resolve the actual local branch once and validate any explicit override.

Right now local mode stores input.branch verbatim, and PR automation trusts that same value before querying Git. A plain --local run loses branch metadata, while a stale --branch can target a different branch than the workspace that actually executed.

Also applies to: 3234-3245

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/commands/dispatch.ts` at line 1148, The code currently stores
input.branch verbatim (see the object spread "...(input.branch ? { branch:
input.branch } : {})"); change this to resolve the workspace git branch once
(e.g., call the existing git branch resolver utility or add a helper like
resolveLocalBranch()/getCurrentBranch()) and use that resolved value when in
local mode, and if an explicit input.branch is provided validate it against the
resolved branch (either warn/error on mismatch or refuse the override) before
setting the branch property; apply the same fix to the other occurrence
referenced around lines 3234-3245 so both places use the resolved local branch
and validate any explicit override.
src/commands/dispatch.ts-690-699 (1)

690-699: ⚠️ Potential issue | 🟠 Major

Persist the final remote terminalState after PR automation.

The run record is patched before prOutcome exists and never updated again, so --pr runs stay stored as "completed" even when PR creation later fails or succeeds. dispatch status and the canonical ticket artifact sync will then read the wrong terminal outcome.

Also applies to: 720-758

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/commands/dispatch.ts` around lines 690 - 699, The run record is patched
before prOutcome is known so terminalState computed with prOutcome: null gets
persisted; after PR automation completes compute finalTerminal =
resolveDispatchTerminalState({status, prOutcome}) and re-patch/update the run
record (the same updater used earlier) to set terminalState to finalTerminal;
ensure you do this in the PR automation completion path where prOutcome is set
so both initial and final terminalState (from resolveDispatchTerminalState with
actual prOutcome) are stored (also apply same fix to the other similar block
that spans the later resolveDispatchTerminalState usages).
src/commands/dispatch.ts-1235-1251 (1)

1235-1251: ⚠️ Potential issue | 🟠 Major

Stream local command output instead of buffering it until exit.

This path doesn't append or print any logs until exec() finishes. Long-running local runs show no progress, hack dispatch logs --follow has nothing to tail, and stdout/stderr ordering is lost when the two buffers are joined afterward.


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 9fc517b6-4b52-40db-ac0c-a5b9fd656d2b

📥 Commits

Reviewing files that changed from the base of the PR and between 853450f and da8c11f.

⛔ Files ignored due to path filters (1)
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing (57)
  • apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/CommandPalette.swift
  • apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift
  • apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/NodeTopologySettingsView.swift
  • apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift
  • apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SettingsOverlayView.swift
  • apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift
  • apps/macos/Packages/Shared/Models/Sources/HackDesktopModels/Models.swift
  • apps/macos/Packages/Shared/Models/Tests/HackDesktopModelsTests/AwsBootstrapRequestTests.swift
  • docs/README.md
  • docs/cli.md
  • docs/guides/remote-node-aws.md
  • docs/guides/remote-node-quickstart.md
  • docs/plans/2026-03-10-remote-modes-aws-runner-foundation.md
  • package.json
  • scripts/aws-node-e2e.ts
  • services/auth-broker/src/flow-store.ts
  • services/auth-broker/src/modules/github-oauth/service.ts
  • services/auth-broker/src/modules/linear-agent/plugin.ts
  • services/auth-broker/src/modules/linear-connections/local-access.ts
  • services/auth-broker/src/modules/linear-oauth/service.ts
  • src/cli/spec.ts
  • src/commands/dispatch.ts
  • src/commands/env.ts
  • src/commands/global.ts
  • src/commands/node.ts
  • src/commands/project.ts
  • src/commands/session.ts
  • src/commands/workspace.ts
  • src/constants.ts
  • src/control-plane/extensions/github/auth.ts
  • src/control-plane/extensions/github/commands.ts
  • src/control-plane/extensions/linear/auth.ts
  • src/control-plane/extensions/linear/commands.ts
  • src/control-plane/extensions/supervisor/commands.ts
  • src/control-plane/extensions/tailscale/commands.ts
  • src/control-plane/extensions/tickets/commands.ts
  • src/control-plane/extensions/tickets/store.ts
  • src/control-plane/extensions/tickets/tickets-git-channel.ts
  • src/control-plane/sdk/gateway-client.ts
  • src/daemon/routes/node.ts
  • src/daemon/server.ts
  • src/lib/dispatch-runs.ts
  • src/lib/nodes-registry.ts
  • src/lib/project-views.ts
  • src/lib/remote-caddy-routes.ts
  • src/lib/runtime-projects.ts
  • src/lib/secret-store.ts
  • src/mcp/agent-docs.ts
  • src/tui/hack-tui.ts
  • src/tui/tickets-tui.ts
  • src/ui/log-group.ts
  • src/ui/loki-logs.ts
  • src/ui/planet.ts
  • tests/dispatch-local-command.test.ts
  • tests/node-provider-aws.test.ts
  • tests/node-runner-commands.test.ts
  • tests/workspace-reset-command.test.ts

Comment on lines +3975 to +3984
let command = [
"hack dispatch run",
"--project \(shellQuote(dispatchProjectSelector))",
"--node \(shellQuote(node.id))",
"--target remote",
"--runner generic",
"--",
"sh -lc \(shellQuote("pwd && uname -a"))",
].joined(separator: " ")
openTerminal(kind: .shell, command: command, title: "remote run")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Drop the unsupported --target remote flag from the dispatch command.

hack dispatch run in this PR's CLI reference doesn't expose a --target option, so this button currently opens a command that will fail with a usage error instead of dispatching anything. Selecting --node already makes the run explicit.

Suggested fix
   let command = [
     "hack dispatch run",
     "--project \(shellQuote(dispatchProjectSelector))",
     "--node \(shellQuote(node.id))",
-    "--target remote",
     "--runner generic",
     "--",
     "sh -lc \(shellQuote("pwd && uname -a"))",
   ].joined(separator: " ")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift`
around lines 3975 - 3984, The generated dispatch command incorrectly includes
the unsupported "--target remote" flag; update the command construction in
ProjectDetailView (the array building the local variable command that calls
openTerminal(kind: .shell, command: command, title: "remote run")) to remove the
"--target remote" element while keeping the other flags (including "--project
\(shellQuote(dispatchProjectSelector))" and "--node \(shellQuote(node.id))") and
the final shell invocation; ensure the title and call to openTerminal remain
unchanged.

Comment thread src/commands/global.ts
Comment on lines 482 to +488
async function globalInstall(): Promise<number> {
const s = spinner();
s.start("Ensuring gum…");
const gum = await ensureBundledGumInstalled();
if (gum.ok) {
s.stop(gum.installed ? "Installed bundled gum" : "gum already installed");
} else {
const systemGum = Bun.which("gum");
s.stop(
systemGum ? "gum available on PATH" : "gum not installed (optional)"
);
if (gum.reason === "failed") {
await prepareGlobalInstallFiles();
logger.success({ message: "Global files ready in ~/.hack/" });
await globalUp();
await ensureGlobalDnsReady();
showGlobalInstallNextSteps();
return 0;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Propagate globalUp() failures.

Line 485 discards the non-zero exit code from globalUp(), so hack global install can still run DNS/CA setup, print next steps, and return 0 after the stack failed to start.

🐛 Proposed fix
 async function globalInstall(): Promise<number> {
   await prepareGlobalInstallFiles();
   logger.success({ message: "Global files ready in ~/.hack/" });
-  await globalUp();
+  const upExit = await globalUp();
+  if (upExit !== 0) {
+    return upExit;
+  }
   await ensureGlobalDnsReady();
   showGlobalInstallNextSteps();
   return 0;
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async function globalInstall(): Promise<number> {
const s = spinner();
s.start("Ensuring gum…");
const gum = await ensureBundledGumInstalled();
if (gum.ok) {
s.stop(gum.installed ? "Installed bundled gum" : "gum already installed");
} else {
const systemGum = Bun.which("gum");
s.stop(
systemGum ? "gum available on PATH" : "gum not installed (optional)"
);
if (gum.reason === "failed") {
await prepareGlobalInstallFiles();
logger.success({ message: "Global files ready in ~/.hack/" });
await globalUp();
await ensureGlobalDnsReady();
showGlobalInstallNextSteps();
return 0;
async function globalInstall(): Promise<number> {
await prepareGlobalInstallFiles();
logger.success({ message: "Global files ready in ~/.hack/" });
const upExit = await globalUp();
if (upExit !== 0) {
return upExit;
}
await ensureGlobalDnsReady();
showGlobalInstallNextSteps();
return 0;
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/commands/global.ts` around lines 482 - 488, The current globalInstall()
ignores the exit code from globalUp(), allowing execution to continue on
failure; modify globalInstall() to capture the result of globalUp() (e.g., const
rc = await globalUp()), and if rc is non-zero return rc (or propagate the error)
immediately so ensureGlobalDnsReady() and showGlobalInstallNextSteps() are not
run on failure; update references inside globalInstall to use the returned code
from globalUp() and exit/return early when it indicates failure.

Comment thread src/commands/node.ts
Comment on lines +4542 to +4559
const response = await input.ec2.send(
new DescribeInstancesCommand(
input.instanceId
? {
InstanceIds: [input.instanceId],
}
: {
Filters: [
{
Name: `tag:${input.instanceTagKey}`,
Values: [input.instanceTagValue],
},
],
}
)
);
const instance = extractAwsBootstrapInstance({ response });
if (!instance) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Reject non-unique tag matches before bootstrapping.

Tag-based lookup currently returns the first instance in the response. If multiple EC2 instances share the same tag, bootstrap and --bootstrap-command can run against the wrong host. Fail unless the selector resolves to exactly one instance, or require --instance-id once multiple matches exist.

Also applies to: 4575-4599

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/commands/node.ts` around lines 4542 - 4559, The tag-based EC2 lookup in
the DescribeInstancesCommand branch (using
input.instanceTagKey/instanceTagValue) must fail when the query returns != 1
instance instead of silently using the first match; modify the logic around the
DescribeInstancesCommand response handling (where extractAwsBootstrapInstance is
called) to count matching instances from the DescribeInstances response and, if
zero, report "no matching instance", and if >1, return an error asking the user
to supply --instance-id (or require unique tag) so bootstrapping is aborted;
make the same change for the similar tag-lookup block later (the other
DescribeInstancesCommand usage at lines ~4575-4599) so both places validate
exact-one-match before proceeding.

Comment thread src/commands/workspace.ts
Comment on lines +322 to +359
async function removeGitIndexLock(input: {
readonly lockPath: string;
}): Promise<boolean> {
const existed = await pathExists(input.lockPath);
if (!existed) {
return false;
}
await rm(input.lockPath, { force: true });
return true;
}

async function runGitWithLockRecovery(input: {
readonly projectRoot: string;
readonly args: readonly string[];
readonly lockPath: string;
readonly context: string;
}) {
let result = await exec(["git", "-C", input.projectRoot, ...input.args], {
stdin: "ignore",
});
if (result.exitCode === 0) {
return result;
}

const combined = `${result.stdout}\n${result.stderr}`;
if (hasGitIndexLockError(combined)) {
await removeGitIndexLock({ lockPath: input.lockPath });
result = await exec(["git", "-C", input.projectRoot, ...input.args], {
stdin: "ignore",
});
}

if (result.exitCode !== 0) {
const stderr = result.stderr.trim();
const stdout = result.stdout.trim();
const detail = stderr || stdout || "command failed";
throw new Error(`${input.context} failed: ${detail}`);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Don't delete index.lock unless you've proved it's stale.

Both the eager path and the retry path remove .git/index.lock unconditionally. If another git process is actually active, this reset can race with it and corrupt the repo/index instead of recovering safely.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/commands/workspace.ts` around lines 322 - 359, The code currently
unconditionally deletes .git/index.lock in removeGitIndexLock and in
runGitWithLockRecovery retry path; change removeGitIndexLock to first prove the
lock is stale before removing it by: read the lock file (referencing
removeGitIndexLock and hasGitIndexLockError), parse any PID from the file, check
whether that PID is alive (e.g., process.kill(pid, 0)) and only remove the lock
if the process is not running or if the lock file is older than a safe threshold
(use fs.stat mtime fallback for systems without PID), return false (and do not
rm) when the lock is held by a live process, and ensure runGitWithLockRecovery
only retries removal when removeGitIndexLock returns true so active git
processes are not raced.

@roodboi roodboi closed this Apr 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant