@indigoai-us/hq-cli 5.345.156 → 5.345.160

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (188) hide show
  1. package/CHANGELOG.md +255 -0
  2. package/assets/scaffold/core/scripts/rebuild-company-knowledge-index.sh +110 -2
  3. package/assets/scaffold/core/scripts/rebuild-projects-index.sh +101 -5
  4. package/assets/scaffold/core/scripts/worktree.sh +4 -0
  5. package/dist/command-catalog.generated.d.ts +535 -10
  6. package/dist/command-catalog.generated.js +678 -31
  7. package/dist/command-registration-plan.d.ts +18 -0
  8. package/dist/command-registration-plan.js +4 -0
  9. package/dist/commands/_patterns.d.ts +17 -0
  10. package/dist/commands/_patterns.js +22 -0
  11. package/dist/commands/access.d.ts +6 -0
  12. package/dist/commands/access.js +18 -1
  13. package/dist/commands/acl-lock.d.ts +41 -0
  14. package/dist/commands/acl-lock.js +28 -0
  15. package/dist/commands/agent-enroll.d.ts +2 -0
  16. package/dist/commands/agent-enroll.js +9 -4
  17. package/dist/commands/agent-kit.js +8 -6
  18. package/dist/commands/agents.js +5 -11
  19. package/dist/commands/billing-sponsor.d.ts +4 -4
  20. package/dist/commands/billing-sponsor.js +11 -11
  21. package/dist/commands/billing-usage.d.ts +22 -8
  22. package/dist/commands/billing-usage.js +57 -22
  23. package/dist/commands/billing.d.ts +7 -7
  24. package/dist/commands/billing.js +13 -13
  25. package/dist/commands/bot.js +7 -4
  26. package/dist/commands/channel-suggest.d.ts +56 -0
  27. package/dist/commands/channel-suggest.js +170 -0
  28. package/dist/commands/channels.js +7 -1
  29. package/dist/commands/cloud-provision.js +2 -2
  30. package/dist/commands/cloud.d.ts +45 -0
  31. package/dist/commands/cloud.js +157 -17
  32. package/dist/commands/company.js +4 -3
  33. package/dist/commands/core-checkpoint-native.d.ts +18 -0
  34. package/dist/commands/core-checkpoint-native.js +67 -2
  35. package/dist/commands/core-pipeline-driver-native.d.ts +12 -1
  36. package/dist/commands/core-pipeline-driver-native.js +131 -5
  37. package/dist/commands/core.js +12 -0
  38. package/dist/commands/crm.js +83 -6
  39. package/dist/commands/db-remote-deps.js +12 -1
  40. package/dist/commands/deploy-access.d.ts +33 -0
  41. package/dist/commands/deploy-access.js +322 -0
  42. package/dist/commands/dm-attach.d.ts +106 -0
  43. package/dist/commands/dm-attach.js +184 -0
  44. package/dist/commands/dm.d.ts +37 -0
  45. package/dist/commands/dm.js +172 -10
  46. package/dist/commands/feedback-show.d.ts +40 -0
  47. package/dist/commands/feedback-show.js +261 -0
  48. package/dist/commands/feedback.d.ts +21 -0
  49. package/dist/commands/feedback.js +152 -2
  50. package/dist/commands/files.d.ts +24 -0
  51. package/dist/commands/files.js +71 -8
  52. package/dist/commands/integrations-connect.js +7 -3
  53. package/dist/commands/lanes-accounts.js +16 -1
  54. package/dist/commands/lanes-loop.js +14 -4
  55. package/dist/commands/lanes-message.js +6 -4
  56. package/dist/commands/lanes-resume.js +36 -13
  57. package/dist/commands/lanes-shared.d.ts +5 -0
  58. package/dist/commands/lanes-shared.js +13 -3
  59. package/dist/commands/lanes-watch.js +1 -0
  60. package/dist/commands/lanes.d.ts +20 -1
  61. package/dist/commands/lanes.js +201 -51
  62. package/dist/commands/meetings.d.ts +4 -4
  63. package/dist/commands/meetings.js +17 -8
  64. package/dist/commands/mesh.js +149 -1
  65. package/dist/commands/monitor.d.ts +1 -0
  66. package/dist/commands/monitor.js +4 -1
  67. package/dist/commands/repo.d.ts +41 -0
  68. package/dist/commands/repo.js +331 -0
  69. package/dist/commands/scan.d.ts +3 -1
  70. package/dist/commands/scan.js +7 -3
  71. package/dist/commands/secrets.d.ts +21 -0
  72. package/dist/commands/secrets.js +114 -14
  73. package/dist/commands/signals.js +17 -5
  74. package/dist/commands/skill.js +6 -6
  75. package/dist/commands/staff/listings.d.ts +41 -0
  76. package/dist/commands/staff/listings.js +160 -0
  77. package/dist/commands/staff/reset-test-account.d.ts +3 -2
  78. package/dist/commands/staff/reset-test-account.js +4 -3
  79. package/dist/commands/whoami.js +1 -1
  80. package/dist/hook-engine-path.js +13 -2
  81. package/dist/hq-cloud-sync-runner/bin/sync-runner.js +3392 -1243
  82. package/dist/hq-cloud-sync-runner/package.json +1 -1
  83. package/dist/lib/agent-kit/run/inbox.js +2 -1
  84. package/dist/lib/agent-kit/wake-secrets.d.ts +7 -0
  85. package/dist/lib/agent-kit/wake-secrets.js +32 -0
  86. package/dist/lib/agent-kit/wake.d.ts +4 -0
  87. package/dist/lib/agent-kit/wake.js +49 -3
  88. package/dist/lib/billing/plan-limit-refusals.d.ts +13 -3
  89. package/dist/lib/billing/plan-limit-refusals.js +26 -3
  90. package/dist/lib/billing/plan-limit-upgrade-url.d.ts +8 -0
  91. package/dist/lib/billing/plan-limit-upgrade-url.js +18 -0
  92. package/dist/lib/billing/plan-lock.d.ts +22 -6
  93. package/dist/lib/billing/plan-lock.js +58 -30
  94. package/dist/lib/billing/usage-cost.d.ts +24 -2
  95. package/dist/lib/billing/usage-cost.js +103 -3
  96. package/dist/lib/billing/usage-only.d.ts +37 -1
  97. package/dist/lib/billing/usage-only.js +89 -4
  98. package/dist/lib/billing/usage-pause.d.ts +27 -0
  99. package/dist/lib/billing/usage-pause.js +50 -0
  100. package/dist/lib/bot/inflight.d.ts +7 -1
  101. package/dist/lib/bot/inflight.js +10 -2
  102. package/dist/lib/bot/prompt.js +3 -0
  103. package/dist/lib/bot/run.js +72 -20
  104. package/dist/lib/bot/session.d.ts +14 -0
  105. package/dist/lib/bot/session.js +70 -0
  106. package/dist/lib/capacity/collectors/claude-oauth.d.ts +6 -0
  107. package/dist/lib/capacity/collectors/claude-oauth.js +12 -0
  108. package/dist/lib/capacity/lane-login.d.ts +6 -0
  109. package/dist/lib/capacity/lane-login.js +5 -0
  110. package/dist/lib/core-utils/worktree.js +2 -0
  111. package/dist/lib/daemon/exit-cause.d.ts +43 -0
  112. package/dist/lib/daemon/exit-cause.js +88 -0
  113. package/dist/lib/daemon/handoff.d.ts +2 -0
  114. package/dist/lib/daemon/handoff.js +6 -1
  115. package/dist/lib/daemon/hq-child-bootstrap.mjs +20 -3
  116. package/dist/lib/daemon/paths.d.ts +2 -0
  117. package/dist/lib/daemon/paths.js +1 -0
  118. package/dist/lib/daemon/run.js +13 -0
  119. package/dist/lib/daemon/supervisor.d.ts +54 -1
  120. package/dist/lib/daemon/supervisor.js +297 -21
  121. package/dist/lib/daemon/unit-holds.d.ts +22 -0
  122. package/dist/lib/daemon/unit-holds.js +86 -0
  123. package/dist/lib/db/control-plane.d.ts +5 -0
  124. package/dist/lib/db/control-plane.js +10 -0
  125. package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
  126. package/dist/lib/flag-registry-worker-source.generated.js +1 -1
  127. package/dist/lib/hook-engine/resolve.d.ts +14 -0
  128. package/dist/lib/hook-engine/resolve.js +55 -1
  129. package/dist/lib/index-render/company-knowledge.js +1 -3
  130. package/dist/lib/index-render/projects.js +1 -3
  131. package/dist/lib/lanes/account-company.d.ts +28 -0
  132. package/dist/lib/lanes/account-company.js +53 -0
  133. package/dist/lib/lanes/accounts.d.ts +22 -3
  134. package/dist/lib/lanes/accounts.js +38 -2
  135. package/dist/lib/lanes/claude-balance.d.ts +6 -1
  136. package/dist/lib/lanes/claude-balance.js +6 -1
  137. package/dist/lib/lanes/claude-credential.d.ts +78 -0
  138. package/dist/lib/lanes/claude-credential.js +163 -0
  139. package/dist/lib/lanes/compaction.d.ts +2 -0
  140. package/dist/lib/lanes/compaction.js +9 -0
  141. package/dist/lib/lanes/config.js +6 -0
  142. package/dist/lib/lanes/envelope-repair-dispatch.js +2 -1
  143. package/dist/lib/lanes/hooks-install.js +2 -1
  144. package/dist/lib/lanes/host-capacity.d.ts +2 -0
  145. package/dist/lib/lanes/host-capacity.js +1 -0
  146. package/dist/lib/lanes/keep-alive.d.ts +1 -1
  147. package/dist/lib/lanes/keep-alive.js +3 -1
  148. package/dist/lib/lanes/links.js +10 -4
  149. package/dist/lib/lanes/loop.js +16 -1
  150. package/dist/lib/lanes/node-binary.d.ts +17 -0
  151. package/dist/lib/lanes/node-binary.js +30 -0
  152. package/dist/lib/lanes/outpost-dispatch.d.ts +2 -1
  153. package/dist/lib/lanes/outpost-dispatch.js +1 -0
  154. package/dist/lib/lanes/reconcile.js +4 -0
  155. package/dist/lib/lanes/remote-control-lane.js +2 -0
  156. package/dist/lib/lanes/spawn.d.ts +3 -0
  157. package/dist/lib/lanes/spawn.js +119 -9
  158. package/dist/lib/lanes/store.d.ts +6 -0
  159. package/dist/lib/lanes/store.js +107 -18
  160. package/dist/lib/lanes/types.d.ts +2 -0
  161. package/dist/lib/lanes/watch.js +28 -5
  162. package/dist/lib/lanes/watcher-main.js +4 -3
  163. package/dist/lib/lanes/watcher.d.ts +4 -0
  164. package/dist/lib/lanes/watcher.js +110 -21
  165. package/dist/lib/mesh/api.d.ts +32 -0
  166. package/dist/lib/mesh/api.js +75 -0
  167. package/dist/lib/plan-limit-nag.d.ts +0 -16
  168. package/dist/lib/plan-limit-nag.js +17 -18
  169. package/dist/lib/secret-scan/detect.js +9 -0
  170. package/dist/lib/secret-scan/repo-config.d.ts +41 -0
  171. package/dist/lib/secret-scan/repo-config.js +237 -0
  172. package/dist/lib/secret-scan/rules.d.ts +7 -1
  173. package/dist/lib/secret-scan/rules.js +3 -1
  174. package/dist/lib/secret-scan/staged.d.ts +5 -0
  175. package/dist/lib/secret-scan/staged.js +19 -2
  176. package/dist/lib/secret-scan/types.d.ts +2 -0
  177. package/dist/lib/sync/server-owned-local-only.d.ts +29 -0
  178. package/dist/lib/sync/server-owned-local-only.js +75 -0
  179. package/dist/main.d.ts +0 -2
  180. package/dist/main.js +19 -57
  181. package/dist/utils/package-use-lease.js +27 -9
  182. package/dist/utils/plan-gate-error.d.ts +30 -14
  183. package/dist/utils/plan-gate-error.js +60 -46
  184. package/dist/utils/team-upgrade.d.ts +19 -6
  185. package/dist/utils/team-upgrade.js +37 -39
  186. package/dist/utils/version-gate.d.ts +7 -0
  187. package/dist/utils/version-gate.js +66 -0
  188. package/package.json +8 -8
@@ -79,6 +79,8 @@ export type RunCompactionFn = (input: {
79
79
  }) => CompactionCommandResult;
80
80
  /** Synchronous `/compact`. stdout is discarded; stderr goes to the run dir. */
81
81
  export declare const defaultRunCompaction: RunCompactionFn;
82
+ /** Stderr line printed before a resume-time `/compact` starts. */
83
+ export declare function compactionStartNotice(sessionId: string): string;
82
84
  export interface CompactLaneSessionInput {
83
85
  command: string;
84
86
  /** Full argv of `claude -p --resume <sid> "/compact" …`, built by buildProviderCommand. */
@@ -388,6 +388,12 @@ export const defaultRunCompaction = (input) => {
388
388
  fs.closeSync(errFd);
389
389
  }
390
390
  };
391
+ /** Stderr line printed before a resume-time `/compact` starts. */
392
+ export function compactionStartNotice(sessionId) {
393
+ const minutes = Math.round(COMPACTION_TIMEOUT_MS / 60_000);
394
+ return `hq lanes: compacting session ${sessionId} before resume; this can take up to ${minutes} minutes. ` +
395
+ `Re-run with --no-compact to skip it.\n`;
396
+ }
391
397
  /**
392
398
  * Run `/compact` on the recorded session and report what happened. A failed
393
399
  * or ineffective compaction is recorded, not thrown: the resume that needed
@@ -398,6 +404,9 @@ export function compactLaneSession(input) {
398
404
  const locate = input.locateTranscript ?? findSessionTranscript;
399
405
  const started = input.now();
400
406
  const beforeProbe = probeCompactBoundaries(locate(input.accountHome, input.sessionId));
407
+ // `/compact` runs synchronously and can take minutes. Say so before it
408
+ // starts, so a resume that is waiting on it does not look hung.
409
+ process.stderr.write(compactionStartNotice(input.sessionId));
401
410
  const result = run({
402
411
  command: input.command,
403
412
  args: input.args,
@@ -186,6 +186,12 @@ function parseAccount(provider, id, raw, deps) {
186
186
  home: canonicalizeHome(raw.home, deps),
187
187
  enabled: raw.enabled,
188
188
  };
189
+ if (raw.company !== undefined) {
190
+ if (typeof raw.company !== "string" || raw.company.trim() === "" || raw.company.trim() !== raw.company) {
191
+ throw new LanesConfigError("invalid_config", `providers.${provider}.accounts.${id}.company must be a non-empty company slug or UID`, { provider, account: id });
192
+ }
193
+ account.company = raw.company;
194
+ }
189
195
  if (raw.hosts !== undefined) {
190
196
  const field = `providers.${provider}.accounts.${id}.hosts`;
191
197
  if (!isPlainObject(raw.hosts)) {
@@ -1,6 +1,7 @@
1
1
  import { spawnSync as nodeSpawnSync } from "node:child_process";
2
2
  import * as path from "node:path";
3
3
  import { LanesError } from "./errors.js";
4
+ import { liveNodeBinary } from "./node-binary.js";
4
5
  const LANE_RUNNER_ENTRY_NAMES = new Set(["watcher-main", "worker-process", "remote-control-lane"]);
5
6
  /**
6
7
  * True for the detached lane runner scripts. They are not the hq CLI: running
@@ -42,7 +43,7 @@ export function spawnEnvelopeRepairViaCurrentCli(hqRoot, lane, input, deps = {})
42
43
  isLaneRunnerEntry(cliEntry)) {
43
44
  throw new LanesError("repair_cli_entry_invalid", "Lane watcher has no valid absolute hq CLI entrypoint for envelope repair", { lane_id: lane.lane_id });
44
45
  }
45
- const result = run(process.execPath, [cliEntry, "lanes", "_spawn-envelope-repair"], {
46
+ const result = run(liveNodeBinary(), [cliEntry, "lanes", "_spawn-envelope-repair"], {
46
47
  encoding: "utf8",
47
48
  timeout: 120_000,
48
49
  input: JSON.stringify(request),
@@ -25,6 +25,7 @@ import * as path from "node:path";
25
25
  import { fileURLToPath } from "node:url";
26
26
  import { parse as parseToml, TomlError } from "smol-toml";
27
27
  import { LanesError } from "./errors.js";
28
+ import { liveNodeBinary } from "./node-binary.js";
28
29
  import { parseLaneProvider } from "./types.js";
29
30
  export const DRAIN_HOOK_BASENAME = "hq-lanes-drain";
30
31
  export const DRAIN_HOOK_TIMEOUT_SECONDS = 30;
@@ -49,7 +50,7 @@ export const PROVIDER_REGISTRATION_KIND = {
49
50
  };
50
51
  export function drainHookCommand(_shPath, provider) {
51
52
  const providerEnv = provider === "grok" ? "HQ_LANE_PROVIDER=grok " : "";
52
- return `${providerEnv}${shellQuote(process.execPath)} ${shellQuote(drainHookEntryPath())}`;
53
+ return `${providerEnv}${shellQuote(liveNodeBinary())} ${shellQuote(drainHookEntryPath())}`;
53
54
  }
54
55
  function legacyDrainHookCommand(shPath) {
55
56
  return `bash ${shPath}`;
@@ -1,4 +1,5 @@
1
1
  import { type AuthState } from "./accounts.js";
2
+ import type { ClaudeCredentialReader } from "./claude-credential.js";
2
3
  import { type QuotaState } from "./quota.js";
3
4
  import type { LaneHost, LaneProvider, LaneRecord, LanesConfig } from "./types.js";
4
5
  export interface HostCapacityAccount {
@@ -63,6 +64,7 @@ export declare function buildHostCapacitySnapshot(input: {
63
64
  hostname?: string;
64
65
  now: Date;
65
66
  quotaState?: QuotaState;
67
+ readClaudeCredential?: ClaudeCredentialReader;
66
68
  }): HostCapacitySnapshot;
67
69
  /** Reject malformed remote snapshots before they can influence host selection. */
68
70
  export declare function parseHostCapacitySnapshot(raw: unknown): HostCapacitySnapshot;
@@ -31,6 +31,7 @@ export function buildHostCapacitySnapshot(input) {
31
31
  ...(input.probe !== undefined ? { probe: input.probe } : {}),
32
32
  ...(input.env !== undefined ? { env: input.env } : {}),
33
33
  ...(input.hostname !== undefined ? { hostname: input.hostname } : {}),
34
+ ...(input.readClaudeCredential !== undefined ? { readClaudeCredential: input.readClaudeCredential } : {}),
34
35
  });
35
36
  const accountLanes = providerLanes.filter((lane) => lane.account === id);
36
37
  const liveLanes = accountLanes.filter((lane) => lane.state === "running" || lane.state === "queued" || lane.state === "awaiting_input");
@@ -30,7 +30,7 @@ export declare function queueLaneTerminalResume(current: LaneRecord, at: string,
30
30
  trigger?: "resume" | "watcher";
31
31
  }): LaneTerminalResumeResult;
32
32
  /** Persist a manager status miss and queue its one bounded follow-up turn. */
33
- export declare function queueManagerStatusGateResume(hqRoot: string, laneId: string, entry: Omit<ManagerStatusGateHistoryEntry, "event" | "resume_queued" | "resume_skipped_reason">, resumeText: string): LaneRecord;
33
+ export declare function queueManagerStatusGateResume(hqRoot: string, laneId: string, entry: Omit<ManagerStatusGateHistoryEntry, "event" | "resume_queued" | "resume_skipped_reason">, resumeText: string, skip?: (current: LaneRecord) => boolean): LaneRecord;
34
34
  export declare const LEAD_LANE_KEEP_ALIVE_RESUME_TEXT = "Automatic keep-alive resume: your last turn ended with decision done and story_complete false. Continue the open work from where you stopped. End with done only when story_complete is true; if you are waiting, use hq lanes ask or stay in the loop.";
35
35
  export declare const LEAD_LANE_KEEP_ALIVE_BLOCKED_RESUME_TEXT = "You ended blocked with no pending question. Record the event you wait on in waiting_on, re-check each blocker, and continue the next unblocked story.";
36
36
  export declare const LEAD_LANE_KEEP_ALIVE_INTERRUPTED_RESUME_TEXT = "Automatic keep-alive resume: your previous turn was interrupted by a provider process exit. Check your last state before continuing. Continue the open work from where you stopped. End with done only when story_complete is true; if you are waiting, use hq lanes ask or stay in the loop.";
@@ -75,8 +75,10 @@ export function queueLaneTerminalResume(current, at, resumeText, options = {}) {
75
75
  };
76
76
  }
77
77
  /** Persist a manager status miss and queue its one bounded follow-up turn. */
78
- export function queueManagerStatusGateResume(hqRoot, laneId, entry, resumeText) {
78
+ export function queueManagerStatusGateResume(hqRoot, laneId, entry, resumeText, skip = () => false) {
79
79
  return retryUpdateLane(hqRoot, laneId, "watcher", (current) => {
80
+ if (skip(current))
81
+ return current;
80
82
  const gate = current.status_gate;
81
83
  if (gate === undefined || gate.turn_id !== entry.turn_id)
82
84
  return current;
@@ -117,6 +117,9 @@ function resolvePointer(root, sid, role, options) {
117
117
  if (role === "child" && !safeId(child))
118
118
  throw new LaneLinkError("child_name_invalid", `Invalid child name: ${child ?? ""}`);
119
119
  const link = options.link ?? pointer.link;
120
+ if (role === "conductor" && link !== pointer.link) {
121
+ throw new LaneLinkError("link_role_invalid", "A conductor can only consume or wait on its own link");
122
+ }
120
123
  if (role === "child") {
121
124
  if (options.link !== undefined && !safeId(options.link))
122
125
  throw new LaneLinkError("link_invalid", "Invalid --link");
@@ -412,15 +415,18 @@ export async function runLaneLink(subcommand, options) {
412
415
  }
413
416
  if (subcommand === "inbox" || subcommand === "read") {
414
417
  const role = subcommand === "inbox" ? "child" : "conductor";
415
- const resolved = role === "conductor" && options.link !== undefined
416
- ? { link: options.link }
417
- : resolvePointer(root, requireSid(), role, options);
418
+ // Preserve explicit-link validation errors before resolving ownership.
419
+ if (role === "conductor" && options.link !== undefined)
420
+ linkPath(root, options.link);
421
+ const resolved = resolvePointer(root, requireSid(), role, options);
418
422
  linkPath(root, resolved.link);
419
423
  const text = drainLinkQueue(queueDir(root, resolved.link, role === "child" ? resolved.child : undefined));
420
424
  return { output: { messages: text } };
421
425
  }
422
426
  if (subcommand === "wait") {
423
- const resolved = options.link ? { link: options.link } : resolvePointer(root, requireSid(), "conductor", options);
427
+ if (options.link !== undefined)
428
+ linkPath(root, options.link);
429
+ const resolved = resolvePointer(root, requireSid(), "conductor", options);
424
430
  return await waitForReport(linkPath(root, resolved.link), options.timeout ?? 1800, options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))));
425
431
  }
426
432
  if (subcommand === "list") {
@@ -1002,6 +1002,21 @@ function appendJournal(runDir, value) {
1002
1002
  fs.mkdirSync(path.dirname(file), { recursive: true });
1003
1003
  fs.appendFileSync(file, `${JSON.stringify(value)}\n`, { mode: 0o600 });
1004
1004
  }
1005
+ /**
1006
+ * Where a synthetic failed or blocked handoff goes. The phase worker may have
1007
+ * written its own handoff to result_path already (for example one that failed
1008
+ * validation). That file is the only copy of the worker's evidence, so it is
1009
+ * never overwritten: the synthetic handoff is written beside it instead
1010
+ * (`<name>.lane-failure.json`). With no worker file, result_path itself gets
1011
+ * the synthetic handoff so the pipeline driver still sees the phase end.
1012
+ */
1013
+ function syntheticHandoffPath(resultPath) {
1014
+ if (!fs.existsSync(resultPath))
1015
+ return resultPath;
1016
+ const ext = path.extname(resultPath);
1017
+ const base = ext ? resultPath.slice(0, -ext.length) : resultPath;
1018
+ return `${base}.lane-failure${ext || ".json"}`;
1019
+ }
1005
1020
  function writeResultAt(runDir, activeFile, record, result, includeHandoffValidationError = true) {
1006
1021
  const localPath = path.join(resultsDir(runDir), resultName(record.envelope_id));
1007
1022
  atomicJson(localPath, result);
@@ -1025,7 +1040,7 @@ function writeResultAt(runDir, activeFile, record, result, includeHandoffValidat
1025
1040
  back_pressure: { tests: "skip", lint: "skip", typecheck: "skip", build: "skip" },
1026
1041
  context_for_next: [result.error ?? result.status, ...(result.question_id ? [`Decision question: ${result.question_id}`] : [])].join("\n"),
1027
1042
  };
1028
- atomicJson(path.resolve(externalPath), failure);
1043
+ atomicJson(syntheticHandoffPath(path.resolve(externalPath)), failure);
1029
1044
  }
1030
1045
  }
1031
1046
  else
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The Node binary to use for a child process started by a long-lived lane
3
+ * process (loop worker, watcher, drain hook registration).
4
+ *
5
+ * `process.execPath` is fixed when the process starts. A Node upgrade can
6
+ * remove that file while the process keeps running (Homebrew deletes the old
7
+ * Cellar directory), and every later child then fails to start. When the
8
+ * launch-time binary is gone:
9
+ *
10
+ * 1. For a Homebrew Cellar path (`<prefix>/Cellar/<formula>/<version>/bin/node`),
11
+ * use the stable `<prefix>/bin/node` symlink when it exists. That points at
12
+ * the upgraded install of the same prefix and does not depend on PATH or on
13
+ * a version manager shim reading the child's cwd.
14
+ * 2. Otherwise fall back to `node` resolved through the child env PATH.
15
+ */
16
+ export declare function liveNodeBinary(execPath?: string, exists?: (candidate: string) => boolean): string;
17
+ //# sourceMappingURL=node-binary.d.ts.map
@@ -0,0 +1,30 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ /**
4
+ * The Node binary to use for a child process started by a long-lived lane
5
+ * process (loop worker, watcher, drain hook registration).
6
+ *
7
+ * `process.execPath` is fixed when the process starts. A Node upgrade can
8
+ * remove that file while the process keeps running (Homebrew deletes the old
9
+ * Cellar directory), and every later child then fails to start. When the
10
+ * launch-time binary is gone:
11
+ *
12
+ * 1. For a Homebrew Cellar path (`<prefix>/Cellar/<formula>/<version>/bin/node`),
13
+ * use the stable `<prefix>/bin/node` symlink when it exists. That points at
14
+ * the upgraded install of the same prefix and does not depend on PATH or on
15
+ * a version manager shim reading the child's cwd.
16
+ * 2. Otherwise fall back to `node` resolved through the child env PATH.
17
+ */
18
+ export function liveNodeBinary(execPath = process.execPath, exists = fs.existsSync) {
19
+ if (exists(execPath))
20
+ return execPath;
21
+ const parts = execPath.split(path.sep);
22
+ const cellar = parts.lastIndexOf("Cellar");
23
+ if (cellar > 0) {
24
+ const prefixLink = path.join(parts.slice(0, cellar).join(path.sep) || path.sep, "bin", "node");
25
+ if (exists(prefixLink))
26
+ return prefixLink;
27
+ }
28
+ return "node";
29
+ }
30
+ //# sourceMappingURL=node-binary.js.map
@@ -27,7 +27,7 @@ export interface OutpostDispatchResult {
27
27
  stderr: string;
28
28
  }
29
29
  export interface OutpostLaneCommand {
30
- action: "message" | "resume" | "stop" | "show" | "logs" | "grant" | "grant-target" | "reparent-target" | "approve-target" | "repair-company-target" | "blocked-escalation-target";
30
+ action: "message" | "resume" | "stop" | "show" | "logs" | "grant" | "grant-target" | "reparent-target" | "approve-target" | "repair-company-target" | "set-target" | "blocked-escalation-target";
31
31
  laneId: string;
32
32
  hqBinary?: string;
33
33
  text?: string;
@@ -46,6 +46,7 @@ export interface OutpostLaneCommand {
46
46
  profileRepinContext?: string;
47
47
  approvalContext?: string;
48
48
  senior?: string;
49
+ actor?: string;
49
50
  reason?: string;
50
51
  company?: string;
51
52
  compact?: boolean;
@@ -225,6 +225,7 @@ export function buildOutpostLaneCommand(input) {
225
225
  ["--approval-context", input.approvalContext],
226
226
  ["--failed-resume-context", input.failedResumeContext],
227
227
  ["--senior", input.senior],
228
+ ["--actor", input.actor],
228
229
  ["--reason", input.reason],
229
230
  ["--company", input.company],
230
231
  ["--message-id", input.messageId],
@@ -5,6 +5,7 @@
5
5
  * compare-and-set. The per-lane watcher owns progress/terminal/silence of a
6
6
  * live process — this pass owns the transitions no single lane can own.
7
7
  */
8
+ import { accountMatchesCompany } from "./account-company.js";
8
9
  import * as fs from "node:fs";
9
10
  import * as path from "node:path";
10
11
  import { parseUserYaml } from "../../utils/user-yaml-error.js";
@@ -724,6 +725,8 @@ function availableBalancedAccount(hqRoot, lane, occupying, config, host, profile
724
725
  for (const account of lane.admission_accounts) {
725
726
  if (account === lane.account)
726
727
  continue;
728
+ if (!accountMatchesCompany(config.providers[lane.provider].accounts[account], resolveLaneCompany(hqRoot, lane).company, config.providers[lane.provider].accounts))
729
+ continue;
727
730
  if (config.providers[lane.provider].accounts[account]?.enabled !== true)
728
731
  continue;
729
732
  const status = accountStatus(lane.provider, account, config, {
@@ -754,6 +757,7 @@ function availableBalancedAccount(hqRoot, lane, occupying, config, host, profile
754
757
  function writeAdmissionAccount(hqRoot, lane, account, dryRun) {
755
758
  return casLaneIf(hqRoot, lane.lane_id, (current) => current.state === "queued" &&
756
759
  current.account === lane.account &&
760
+ accountMatchesCompany(loadLanesConfig(hqRoot).providers[current.provider].accounts[account], resolveLaneCompany(hqRoot, current).company, loadLanesConfig(hqRoot).providers[current.provider].accounts) &&
757
761
  current.admission_accounts?.includes(account) === true, (current) => ({ ...current, account }), dryRun).record;
758
762
  }
759
763
  function casQuestionIf(hqRoot, questionId, shouldWrite, updater, dryRun) {
@@ -15,6 +15,7 @@ import { isOwnedPoolForwarder, poolHostsFile, readRemoteControlPoolsConfig } fro
15
15
  import { accountStatus, isDefaultProviderHome } from "./accounts.js";
16
16
  import { effectiveAccountHome } from "./account-home.js";
17
17
  import { LanesError } from "./errors.js";
18
+ import { readLaneClaudeCredential } from "./claude-credential.js";
18
19
  import { defaultProbe } from "./models.js";
19
20
  import { encodeClaudeProjectDir } from "./move-account.js";
20
21
  /** Keep every credential and network route tied to the selected pool. */
@@ -117,6 +118,7 @@ export function prepareRemoteControlLane(input) {
117
118
  }
118
119
  const status = accountStatus("claude", "a", input.config, {
119
120
  hostname: input.hostname, existsSync: fs.existsSync, probe: defaultProbe, env: input.env,
121
+ readClaudeCredential: readLaneClaudeCredential,
120
122
  });
121
123
  if (!status.logged_in) {
122
124
  throw new LanesError("remote_control_login_missing", "Claude account a must be logged in for Remote Control", {});
@@ -8,6 +8,7 @@
8
8
  * mutable fields are re-read from the lane record (spec §5.4).
9
9
  */
10
10
  import { PROVIDER_HOME_ENV } from "./accounts.js";
11
+ import { type ClaudeCredentialReader } from "./claude-credential.js";
11
12
  import { type RunCompactionFn } from "./compaction.js";
12
13
  import { type ResolvedEffort } from "./effort.js";
13
14
  import { type KnownModelsDeps } from "./models.js";
@@ -66,6 +67,8 @@ export type ProveDetachedFn = (pid: number) => DetachProof;
66
67
  export type KillProcessFn = (pid: number, signal?: NodeJS.Signals) => void;
67
68
  export interface SpawnLaneDeps {
68
69
  env?: NodeJS.ProcessEnv;
70
+ /** Claude credential expiry check; see spawnCredentialReader for the default. */
71
+ readClaudeCredential?: ClaudeCredentialReader;
69
72
  /** Builds the lane's fingerprint from the worker env. Defaults to laneStartFingerprint. */
70
73
  buildFingerprint?: (hqRoot: string, workerEnv: NodeJS.ProcessEnv) => string | null;
71
74
  /** Test seam for the default-off quota account failover registry gate. */
@@ -7,19 +7,22 @@
7
7
  * probed for existence and never read. Identity in the child is `HQ_LANE_ID`;
8
8
  * mutable fields are re-read from the lane record (spec §5.4).
9
9
  */
10
+ import { accountMatchesCompany, accountMayRetainLane, companyMismatchError } from "./account-company.js";
10
11
  import { spawn, spawnSync } from "node:child_process";
11
12
  import { randomUUID } from "node:crypto";
12
13
  import * as fs from "node:fs";
13
14
  import * as path from "node:path";
14
15
  import { fileURLToPath } from "node:url";
15
16
  import { configureRemoteControlChildEnv, prepareRemoteControlLane, remoteControlSelectionConfig } from "./remote-control-lane.js";
16
- import { PROVIDER_HOME_ENV, accountStatus, isDefaultProviderHome, isLoggedOut, resolveAccount, selectBalancedAccount, } from "./accounts.js";
17
+ import { PROVIDER_HOME_ENV, accountStatus, isDefaultProviderHome, isLoggedOut, isSignInExpired, resolveAccount, selectBalancedAccount, } from "./accounts.js";
18
+ import { readLaneClaudeCredential } from "./claude-credential.js";
17
19
  import { effectiveAccountHome } from "./account-home.js";
18
20
  import { buildHostCapacitySnapshot } from "./host-capacity.js";
19
21
  import { selectClaudeAccount } from "./claude-balance.js";
20
22
  import { loadQuotaState } from "./quota.js";
21
23
  import { COMPACTION_PROMPT, compactionAfterUsage, compactionDue, compactLaneSession, } from "./compaction.js";
22
24
  import { installDrainHook } from "./hooks-install.js";
25
+ import { liveNodeBinary } from "./node-binary.js";
23
26
  import { loadLanesConfig, providerAutoCompactTokens, providerCompactAfterMs, } from "./config.js";
24
27
  import { appendEffortFlag, resolveLaneEffort, } from "./effort.js";
25
28
  import { defaultProbe, validateLaneModel, validateProfileLaneModel, } from "./models.js";
@@ -252,9 +255,19 @@ function selectSpawnAccount(input) {
252
255
  };
253
256
  if (lane.account === undefined) {
254
257
  const modelSpecified = pinnedModel !== undefined || profileModel !== undefined;
255
- const allowedAccounts = modelSpecified
256
- ? Object.keys(config.providers[lane.provider].accounts).filter((id) => supportsSpawnModel(lane.provider, id, config, pinnedModel, profileModel, modelDeps, hostname))
257
- : undefined;
258
+ const company = resolveLaneCompany(hqRoot, lane).company;
259
+ const allowedAccounts = Object.entries(config.providers[lane.provider].accounts)
260
+ .filter(([id, account]) => accountMatchesCompany(account, company, config.providers[lane.provider].accounts) &&
261
+ (!modelSpecified || supportsSpawnModel(lane.provider, id, config, pinnedModel, profileModel, modelDeps, hostname)))
262
+ .map(([id]) => id);
263
+ const mismatch = companyMismatchError({
264
+ provider: lane.provider,
265
+ providerAccounts: config.providers[lane.provider].accounts,
266
+ candidates: Object.keys(config.providers[lane.provider].accounts),
267
+ company,
268
+ });
269
+ if (mismatch !== undefined)
270
+ throw mismatch;
258
271
  return {
259
272
  account: selectBalancedAccount(lane.provider, config, {
260
273
  ...context,
@@ -269,7 +282,13 @@ function selectSpawnAccount(input) {
269
282
  let refusal;
270
283
  const operatorMovePending = lane.pending_resume !== undefined &&
271
284
  lane.account_history?.at(-1)?.reason === "operator_move";
272
- if (current === undefined || !current.enabled) {
285
+ if (current !== undefined && !accountMayRetainLane(current, resolveLaneCompany(hqRoot, lane).company, config.providers[lane.provider].accounts)) {
286
+ reason = "account_unavailable";
287
+ refusal = new LanesError("account_company_mismatch", `Account ${lane.account} belongs to another company`, {
288
+ provider: lane.provider, account: lane.account,
289
+ });
290
+ }
291
+ else if (current === undefined || !current.enabled) {
273
292
  reason = "account_unavailable";
274
293
  refusal = new AccountResolutionError(current === undefined ? "unknown_account" : "account_disabled", `Account ${JSON.stringify(lane.account)} is unavailable for ${lane.provider}`, { provider: lane.provider, account: lane.account });
275
294
  }
@@ -278,11 +297,18 @@ function selectSpawnAccount(input) {
278
297
  existsSync,
279
298
  hostname,
280
299
  ...(accountDeps?.probe ? { probe: accountDeps.probe } : {}),
300
+ ...(accountDeps?.readClaudeCredential ? { readClaudeCredential: accountDeps.readClaudeCredential } : {}),
281
301
  ...(accountDeps?.env ? { env: accountDeps.env } : {}),
282
302
  });
283
303
  if (isLoggedOut(status)) {
284
304
  reason = repairRecordedAccount ? "recorded_account_logged_out" : "account_unavailable";
285
- refusal = new LanesError("account_not_logged_in", `Account ${lane.account} for ${lane.provider} is not logged in`, { provider: lane.provider, account: lane.account });
305
+ refusal = new LanesError("account_not_logged_in", isSignInExpired(status)
306
+ ? expiredSignInMessage(lane.provider, lane.account, status)
307
+ : `Account ${lane.account} for ${lane.provider} is not logged in`, {
308
+ provider: lane.provider,
309
+ account: lane.account,
310
+ ...(isSignInExpired(status) ? { sign_in_state: "expired", sign_in_expires_at: status.sign_in_expires_at ?? null } : {}),
311
+ });
286
312
  }
287
313
  else {
288
314
  try {
@@ -339,6 +365,7 @@ function selectSpawnAccount(input) {
339
365
  config,
340
366
  existsSync,
341
367
  probe: accountDeps?.probe,
368
+ readClaudeCredential: accountDeps?.readClaudeCredential,
342
369
  env: accountDeps?.env,
343
370
  hostname,
344
371
  now,
@@ -348,6 +375,7 @@ function selectSpawnAccount(input) {
348
375
  targetHost: lane.host,
349
376
  actualTargetHost: lane.host,
350
377
  requestedPlacement: null,
378
+ company: resolveLaneCompany(hqRoot, lane).company,
351
379
  config,
352
380
  quotaState: loadQuotaState(hqRoot),
353
381
  ...((recordedModel ?? pinnedModel ?? profileModel) !== undefined
@@ -366,11 +394,13 @@ function selectSpawnAccount(input) {
366
394
  };
367
395
  }
368
396
  const allowedAccounts = Object.entries(config.providers[lane.provider].accounts)
397
+ .filter(([, account]) => accountMatchesCompany(account, resolveLaneCompany(hqRoot, lane).company, config.providers[lane.provider].accounts))
369
398
  .filter(([id, account]) => id !== lane.account && account.enabled)
370
399
  .filter(([id]) => accountStatus(lane.provider, id, config, {
371
400
  existsSync,
372
401
  hostname,
373
402
  ...(accountDeps?.probe ? { probe: accountDeps.probe } : {}),
403
+ ...(accountDeps?.readClaudeCredential ? { readClaudeCredential: accountDeps.readClaudeCredential } : {}),
374
404
  ...(accountDeps?.env ? { env: accountDeps.env } : {}),
375
405
  }).logged_in === true)
376
406
  .filter(([id]) => supportsSpawnModel(lane.provider, id, config, pinnedModel, profileModel, modelDeps, hostname))
@@ -406,6 +436,11 @@ function selectSpawnAccount(input) {
406
436
  account_reasons: accountReasons,
407
437
  });
408
438
  }
439
+ if (lane.company !== null && lane.state === "queued" && lane.pending_resume !== undefined && err instanceof AccountResolutionError) {
440
+ throw new LanesError("same_company_account_unavailable", "No eligible account assigned to the lane's company is available", {
441
+ provider: lane.provider, account: lane.account, company: lane.company,
442
+ });
443
+ }
409
444
  if (reason === "account_unavailable" || reason === "recorded_account_logged_out") {
410
445
  if (refusal instanceof Error)
411
446
  throw refusal;
@@ -545,6 +580,7 @@ export async function retryLaneOnAlternateAccount(hqRoot, lane, reason, writer =
545
580
  const accountDeps = {
546
581
  probe: modelDeps.probe ?? defaultProbe,
547
582
  env,
583
+ ...spawnCredentialReader(deps, modelDeps),
548
584
  };
549
585
  const profile = lane.worker_profile ? loadWorkerProfile(hqRoot, lane.worker_profile) : undefined;
550
586
  let quotaFailoverEnabled = false;
@@ -572,6 +608,9 @@ export async function retryLaneOnAlternateAccount(hqRoot, lane, reason, writer =
572
608
  : undefined;
573
609
  const unavailableAccounts = previouslyUnavailableAccounts(lane);
574
610
  const allowedAccounts = Object.entries(config.providers[lane.provider].accounts)
611
+ .filter(([id, account]) => id === lane.account
612
+ ? accountMayRetainLane(account, resolveLaneCompany(hqRoot, lane).company, config.providers[lane.provider].accounts)
613
+ : accountMatchesCompany(account, resolveLaneCompany(hqRoot, lane).company, config.providers[lane.provider].accounts))
575
614
  .filter(([id, account]) => (id !== lane.account || (quotaFailoverEnabled && reason === "usage_limit" && retryingExpiredQuota)) &&
576
615
  !unavailableAccounts.has(id) && account.enabled)
577
616
  .filter(([id]) => !quotaFailoverEnabled ||
@@ -583,6 +622,7 @@ export async function retryLaneOnAlternateAccount(hqRoot, lane, reason, writer =
583
622
  existsSync,
584
623
  hostname,
585
624
  ...(accountDeps?.probe ? { probe: accountDeps.probe } : {}),
625
+ ...(accountDeps?.readClaudeCredential ? { readClaudeCredential: accountDeps.readClaudeCredential } : {}),
586
626
  ...(accountDeps?.env ? { env: accountDeps.env } : {}),
587
627
  }).logged_in === true)
588
628
  .filter(([id]) => supportsSpawnModel(lane.provider, id, config, pinnedModel, profileModel, modelDeps, hostname))
@@ -635,6 +675,7 @@ export async function retryLaneOnAlternateAccount(hqRoot, lane, reason, writer =
635
675
  existsSync,
636
676
  hostname,
637
677
  ...(accountDeps?.probe ? { probe: accountDeps.probe } : {}),
678
+ ...(accountDeps?.readClaudeCredential ? { readClaudeCredential: accountDeps.readClaudeCredential } : {}),
638
679
  ...(accountDeps?.env ? { env: accountDeps.env } : {}),
639
680
  }).logged_in !== true)
640
681
  return { account: id, reason: "login_required" };
@@ -666,6 +707,22 @@ export async function retryLaneOnAlternateAccount(hqRoot, lane, reason, writer =
666
707
  return retryUpdateLane(hqRoot, lane.lane_id, writer, holdLane);
667
708
  }
668
709
  }
710
+ if (resolveLaneCompany(hqRoot, lane).company !== null && err instanceof AccountResolutionError) {
711
+ const waitForAccount = (current) => {
712
+ if (current.state !== retryFromState || current.account !== lane.account || current.provider_session_id !== lane.provider_session_id) {
713
+ throw new LanesError("account_retry_conflict", "Lane changed while company account recovery was prepared", { lane_id: lane.lane_id });
714
+ }
715
+ return {
716
+ ...current, state: "queued", queued_reason: "account_retry", queued_details: undefined,
717
+ queued_at: at, admission_reserved_at: undefined, terminal_reason: undefined,
718
+ terminal_details: undefined, interrupted_at: undefined,
719
+ pending_resume: current.provider_session_id
720
+ ? pendingResumeForRetry(hqRoot, current, current.provider_session_id, at)
721
+ : undefined,
722
+ };
723
+ };
724
+ return deps.dryRun ? waitForAccount(lane) : retryUpdateLane(hqRoot, lane.lane_id, writer, waitForAccount);
725
+ }
669
726
  if (reason === "account_unavailable" || unavailableAccounts.size > 0) {
670
727
  throw new LanesError("account_not_logged_in", `Account ${lane.account ?? "(none)"} for ${lane.provider} is unavailable and no other logged-in account can serve this lane`, { provider: lane.provider, account: lane.account ?? null });
671
728
  }
@@ -845,6 +902,21 @@ function appendCodexWorkerPolicyDirFlag(provider, args, workerPolicyDir) {
845
902
  return [...args, "-c", setting];
846
903
  return [...args.slice(0, separator), "-c", setting, ...args.slice(separator)];
847
904
  }
905
+ /**
906
+ * The Claude credential expiry check rides with the real provider probe: a
907
+ * caller on the production probe also gets the real Keychain/file read, and a
908
+ * test that injects a fake probe gets no credential read unless it passes one.
909
+ */
910
+ function spawnCredentialReader(deps, modelDeps) {
911
+ if (deps.readClaudeCredential !== undefined)
912
+ return { readClaudeCredential: deps.readClaudeCredential };
913
+ return modelDeps.probe === defaultProbe ? { readClaudeCredential: readLaneClaudeCredential } : {};
914
+ }
915
+ function expiredSignInMessage(provider, account, status) {
916
+ const at = status.sign_in_expires_at ? ` at ${status.sign_in_expires_at}` : "";
917
+ return `Account ${account} for ${provider} has an expired Claude sign-in (access token expired${at}). ` +
918
+ `Open Claude Code on this account and run /login, then retry.`;
919
+ }
848
920
  function accountHomeHostname(deps) {
849
921
  return (deps.hostname?.trim() ||
850
922
  deps.admissionHost?.hostname.trim() ||
@@ -1958,7 +2030,7 @@ function spawnTrackedWorker(spawnProcess, command, args, env, cwd, stdoutPath, s
1958
2030
  throw error;
1959
2031
  }
1960
2032
  const runnerPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../dist/lib/lanes/worker-process.js");
1961
- return spawnDetached(spawnProcess, process.execPath, [runnerPath, command, ...args], { ...env, [LANE_WORKER_EXIT_PATH_ENV]: exitPath }, cwd, stdoutPath, stderrPath);
2033
+ return spawnDetached(spawnProcess, liveNodeBinary(), [runnerPath, command, ...args], { ...env, [LANE_WORKER_EXIT_PATH_ENV]: exitPath }, cwd, stdoutPath, stderrPath);
1962
2034
  }
1963
2035
  function requireDetached(proveDetached, pid, kind) {
1964
2036
  const proof = proveDetached(pid);
@@ -2093,7 +2165,7 @@ export function armLaneWatcher(hqRoot, laneId, deps = {}) {
2093
2165
  if (!cliEntry) {
2094
2166
  throw new LanesError("watcher_cli_entry_unavailable", "Cannot arm the lane watcher without the current hq CLI entrypoint", { lane_id: laneId });
2095
2167
  }
2096
- const watcherPid = spawnDetached(spawnProcess, process.execPath, [watcherMainPath(), laneId], {
2168
+ const watcherPid = spawnDetached(spawnProcess, liveNodeBinary(), [watcherMainPath(), laneId], {
2097
2169
  ...env,
2098
2170
  HQ_ROOT: hqRoot,
2099
2171
  HQ_LANE_ID: laneId,
@@ -2332,6 +2404,7 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
2332
2404
  const accountDeps = {
2333
2405
  probe: modelDeps.probe ?? defaultProbe,
2334
2406
  env,
2407
+ ...spawnCredentialReader(deps, modelDeps),
2335
2408
  };
2336
2409
  const diskAccount = laneDiskAccount(config, existing.provider, existing.account);
2337
2410
  if (diskAccount !== undefined) {
@@ -2342,6 +2415,22 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
2342
2415
  ...current,
2343
2416
  state: "queued",
2344
2417
  queued_reason: "low_disk",
2418
+ // Commit an explicit resume's instruction together with the hold. A
2419
+ // later admission must continue that turn, not start the old brief.
2420
+ pending_resume: trigger.kind === "resume" ? {
2421
+ ...(existing.provider_session_id !== undefined ? { session_id: existing.provider_session_id } : {}),
2422
+ trigger: "resume",
2423
+ caller: typeof env.HQ_LANE_ID === "string" ? env.HQ_LANE_ID.trim() || "user" : "user",
2424
+ resumed_from: trigger.from,
2425
+ ...(trigger.fromFailed === true ? { from_failed: true } : {}),
2426
+ ...(trigger.expectedSenior !== undefined ? { expected_senior: trigger.expectedSenior } : {}),
2427
+ ...(trigger.text !== undefined ? { text: trigger.text } : {}),
2428
+ ...(trigger.message_id !== undefined ? { message_id: trigger.message_id } : {}),
2429
+ ...(trigger.fresh === true ? { fresh_thread: true } : {}),
2430
+ ...(deps.workingDirectory !== undefined ? { working_directory: deps.workingDirectory } : {}),
2431
+ compact: launch.compact !== false,
2432
+ at: measuredAt,
2433
+ } : current.pending_resume,
2345
2434
  queued_details: {
2346
2435
  reason: "low_disk",
2347
2436
  hq_free_bytes: free.hq.free_bytes,
@@ -2422,7 +2511,28 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
2422
2511
  if (preservedAccountModel !== undefined)
2423
2512
  pinnedModel = preservedAccountModel;
2424
2513
  const resolved = selection.account;
2425
- const status = accountStatus(lane.provider, resolved.id, config, { existsSync, hostname });
2514
+ const status = accountStatus(lane.provider, resolved.id, config, {
2515
+ existsSync,
2516
+ hostname,
2517
+ ...(accountDeps?.readClaudeCredential ? { readClaudeCredential: accountDeps.readClaudeCredential } : {}),
2518
+ });
2519
+ if (isSignInExpired(status)) {
2520
+ const failed = markPreSpawnFailure
2521
+ ? markSpawnFailure(hqRoot, lane.lane_id, "account_not_logged_in", {
2522
+ provider: lane.provider,
2523
+ account: resolved.id,
2524
+ sign_in_state: "expired",
2525
+ })
2526
+ : existing;
2527
+ throw new LanesError("account_not_logged_in", expiredSignInMessage(lane.provider, resolved.id, status), {
2528
+ provider: lane.provider,
2529
+ account: resolved.id,
2530
+ lane_id: lane.lane_id,
2531
+ sign_in_state: "expired",
2532
+ sign_in_expires_at: status.sign_in_expires_at ?? null,
2533
+ lane: failed,
2534
+ });
2535
+ }
2426
2536
  if (isLoggedOut(status)) {
2427
2537
  const failed = markPreSpawnFailure
2428
2538
  ? markSpawnFailure(hqRoot, lane.lane_id, "account_not_logged_in", {
@@ -24,6 +24,10 @@ export interface StoreDeps {
24
24
  closeSync: typeof fs.closeSync;
25
25
  fsyncSync: typeof fs.fsyncSync;
26
26
  chmodSync: typeof fs.chmodSync;
27
+ /** Optional so existing fakes keep working; defaults to node:fs. */
28
+ statSync?: (p: string) => fs.Stats;
29
+ fstatSync?: (fd: number) => fs.Stats;
30
+ linkSync?: typeof fs.linkSync;
27
31
  }
28
32
  export declare function resolveStoreHqRoot(hqRoot?: string, env?: NodeJS.ProcessEnv, cwd?: string): string;
29
33
  export declare function lanesStoreRoot(hqRoot?: string): string;
@@ -35,6 +39,8 @@ export declare function sanitizeHostComponent(hostname: string): string;
35
39
  export declare function mintLaneId(deps?: Pick<StoreDeps, "now" | "hostname"> & Partial<Pick<StoreDeps, "generateLaneUlid">>): string;
36
40
  export declare function mintQuestionId(deps?: Pick<StoreDeps, "now" | "hostname">): string;
37
41
  export declare function appendCappedHistory<T>(history: readonly T[], entry: T, cap?: number): T[];
42
+ /** Test seam: run withRecordLock with overridden deps and a shorter timeout. */
43
+ export declare function withRecordLockForTests<T>(recordPath: string, overrides: Partial<StoreDeps>, fn: () => T, timeoutMs?: number): T;
38
44
  /** Run a lane-owned file read/modify/write transaction under its sidecar lock. */
39
45
  export declare function withLaneRecordFileLock<T>(recordPath: string, fn: () => T): T;
40
46
  export declare function parseLaneRecord(raw: unknown, filePath?: string): LaneRecord;