@indigoai-us/hq-cli 5.147.0 → 5.149.1

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 (43) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/assets/scaffold/core/scripts/checkpoint-stop-gate.sh +8 -11
  3. package/dist/command-catalog.generated.d.ts +129 -0
  4. package/dist/command-catalog.generated.js +168 -0
  5. package/dist/commands/lanes.js +506 -23
  6. package/dist/commands/mcp-registration.d.ts +29 -16
  7. package/dist/commands/mcp-registration.js +254 -17
  8. package/dist/commands/mesh.js +82 -11
  9. package/dist/lib/lanes/index.d.ts +3 -2
  10. package/dist/lib/lanes/index.js +2 -1
  11. package/dist/lib/lanes/mesh-events.d.ts +8 -0
  12. package/dist/lib/lanes/mesh-events.js +83 -6
  13. package/dist/lib/lanes/outpost-dispatch.d.ts +12 -0
  14. package/dist/lib/lanes/outpost-dispatch.js +17 -0
  15. package/dist/lib/lanes/reconcile.js +39 -12
  16. package/dist/lib/lanes/session-relink.d.ts +25 -0
  17. package/dist/lib/lanes/session-relink.js +216 -0
  18. package/dist/lib/lanes/spawn.d.ts +1 -0
  19. package/dist/lib/lanes/spawn.js +48 -5
  20. package/dist/lib/lanes/store.js +109 -1
  21. package/dist/lib/lanes/stories.d.ts +17 -0
  22. package/dist/lib/lanes/stories.js +73 -0
  23. package/dist/lib/lanes/types.d.ts +20 -0
  24. package/dist/lib/lanes/types.js +9 -2
  25. package/dist/lib/lanes/watcher.js +69 -9
  26. package/dist/lib/mesh/live/daemon/doctor.d.ts +27 -0
  27. package/dist/lib/mesh/live/daemon/doctor.js +84 -0
  28. package/dist/lib/mesh/live/daemon/index.d.ts +3 -3
  29. package/dist/lib/mesh/live/daemon/index.js +2 -2
  30. package/dist/lib/mesh/live/daemon/install.d.ts +40 -0
  31. package/dist/lib/mesh/live/daemon/install.js +88 -0
  32. package/dist/lib/mesh/live/daemon/run.d.ts +2 -0
  33. package/dist/lib/mesh/live/daemon/run.js +12 -1
  34. package/dist/lib/mesh/live/daemon/state.d.ts +4 -0
  35. package/dist/lib/mesh/project-board.d.ts +15 -0
  36. package/dist/lib/mesh/project-board.js +84 -0
  37. package/dist/lib/work-context/prd-sync.d.ts +5 -1
  38. package/dist/lib/work-context/prd-sync.js +22 -1
  39. package/dist/sentry.js +15 -7
  40. package/dist/utils/sentry-fingerprint.d.ts +11 -0
  41. package/dist/utils/sentry-fingerprint.js +56 -11
  42. package/package.json +5 -3
  43. package/scripts/mesh-daemon-if-stale.mjs +16 -0
@@ -42,7 +42,7 @@
42
42
  * of opening a browser nobody is watching, and the watcher records
43
43
  * `mesh_event_failed` and moves on. No credential file is read here.
44
44
  */
45
- import { appendThreadEvent, clamp, ensureProjectThread, patchStoryStatus, requireToken, resolveMeshCompany, } from "../mesh/api.js";
45
+ import { appendThreadEvent, clamp, ensureProjectThread, getProjectView, patchStoryStatus, putProjectView, requireToken, registerMeshProject, resolveMeshCompany, } from "../mesh/api.js";
46
46
  import { LanesError } from "./errors.js";
47
47
  export const LANE_MESH_EVENTS = [
48
48
  "lane_started",
@@ -163,9 +163,64 @@ export function toWireThreadEvent(event, payload) {
163
163
  }
164
164
  }
165
165
  }
166
+ function isProjectViewNotFound(error) {
167
+ if (!error || typeof error !== "object")
168
+ return false;
169
+ const candidate = error;
170
+ return (candidate.code === "PROJECT_VIEW_NOT_FOUND" ||
171
+ (typeof candidate.message === "string" && candidate.message.includes("PROJECT_VIEW_NOT_FOUND")));
172
+ }
173
+ function isAlreadyRegistered(error) {
174
+ if (!error || typeof error !== "object")
175
+ return false;
176
+ const candidate = error;
177
+ return (candidate.code === "PROJECT_ALREADY_REGISTERED" ||
178
+ (typeof candidate.message === "string" && candidate.message.includes("PROJECT_ALREADY_REGISTERED")));
179
+ }
180
+ async function repairProjectView(input) {
181
+ const company = { companyUid: input.company.uid, companySlug: input.company.slug };
182
+ await ensureProjectThread(input.token, company, input.projectId, {
183
+ summary: `HQ lane ${input.projectId}`,
184
+ });
185
+ let current;
186
+ try {
187
+ current = await getProjectView(input.token, input.company.uid, input.projectId);
188
+ }
189
+ catch (error) {
190
+ if (!isProjectViewNotFound(error))
191
+ throw error;
192
+ }
193
+ const stories = [...(current?.stories ?? [])];
194
+ const index = stories.findIndex((story) => story.id === input.storyId);
195
+ const nextStory = {
196
+ ...(index >= 0 ? stories[index] : {}),
197
+ id: input.storyId,
198
+ title: index >= 0 ? stories[index]?.title || input.storyId : input.storyId,
199
+ status: input.status,
200
+ };
201
+ if (index >= 0)
202
+ stories[index] = nextStory;
203
+ else
204
+ stories.push(nextStory);
205
+ await putProjectView(input.token, input.company.uid, input.projectId, {
206
+ name: current?.name || input.projectId,
207
+ description: current?.description || "HQ lane project",
208
+ stories,
209
+ repos: current?.repos ?? [],
210
+ ...(current?.version !== undefined ? { expectedVersion: current.version } : {}),
211
+ });
212
+ try {
213
+ await registerMeshProject(input.token, input.company.uid, input.projectId);
214
+ }
215
+ catch (error) {
216
+ if (!isAlreadyRegistered(error))
217
+ throw error;
218
+ }
219
+ }
166
220
  export function createLaneMesh(opts = {}) {
167
221
  const interactive = opts.interactive ?? true;
168
222
  const token = opts.token ?? (() => requireToken({ interactive }));
223
+ const repair = opts.repairProjectView ?? repairProjectView;
169
224
  return {
170
225
  async ensureProjectThread(company, projectId) {
171
226
  const tok = await token();
@@ -182,7 +237,21 @@ export function createLaneMesh(opts = {}) {
182
237
  },
183
238
  async patchStoryStatus(company, projectId, storyId, status) {
184
239
  const tok = await token();
185
- await patchStoryStatus(tok, company.uid, projectId, storyId, status);
240
+ try {
241
+ await patchStoryStatus(tok, company.uid, projectId, storyId, status);
242
+ }
243
+ catch (error) {
244
+ if (!isProjectViewNotFound(error))
245
+ throw error;
246
+ await repair({
247
+ token: tok,
248
+ company,
249
+ projectId,
250
+ storyId,
251
+ status,
252
+ });
253
+ await patchStoryStatus(tok, company.uid, projectId, storyId, status);
254
+ }
186
255
  },
187
256
  };
188
257
  }
@@ -232,10 +301,18 @@ export async function emitLaneDecisionEvents(mesh, lane, envelope, opts = {}) {
232
301
  return;
233
302
  }
234
303
  case "blocked":
235
- await mesh.appendThreadEvent(company, threadId, "lane_blocked", {
236
- ...baseIdentity(lane),
237
- blocked_reason: envelope.blocked_reason ?? "",
238
- });
304
+ {
305
+ const outcomes = await Promise.allSettled([
306
+ mesh.appendThreadEvent(company, threadId, "lane_blocked", {
307
+ ...baseIdentity(lane),
308
+ blocked_reason: envelope.blocked_reason ?? "",
309
+ }),
310
+ mesh.patchStoryStatus(company, lane.project_id, lane.story_id, "in_progress"),
311
+ ]);
312
+ const failure = outcomes.find((outcome) => outcome.status === "rejected");
313
+ if (failure?.status === "rejected")
314
+ throw failure.reason;
315
+ }
239
316
  return;
240
317
  case "ask":
241
318
  // A mesh question event has one `questionId`; sending one event per id
@@ -26,6 +26,16 @@ export interface OutpostDispatchResult {
26
26
  stdout: string;
27
27
  stderr: string;
28
28
  }
29
+ export interface OutpostLaneCommand {
30
+ action: "message" | "resume";
31
+ laneId: string;
32
+ text?: string;
33
+ project?: string;
34
+ story?: string;
35
+ account?: string;
36
+ model?: string;
37
+ compact?: boolean;
38
+ }
29
39
  export interface OutpostExecPort {
30
40
  stageExecInput: (outpostId?: string) => Promise<{
31
41
  putUrl: string;
@@ -47,6 +57,8 @@ export interface OutpostExecPort {
47
57
  export declare function parseOutpostPickupPayload(raw: unknown): OutpostPickupPayload;
48
58
  /** Same wrapping `hq outposts exec` applies: run from the box's HQ folder. */
49
59
  export declare function buildOutpostPickupCommand(payloadUrl: string): string;
60
+ /** Build the same `hq outposts exec` remote command used by lane pickup. */
61
+ export declare function buildOutpostLaneCommand(input: OutpostLaneCommand): string;
50
62
  export declare function stageOutpostPayload(port: Pick<OutpostExecPort, "stageExecInput">, outpostId: string, bytes: Uint8Array, put?: typeof fetch): Promise<{
51
63
  getUrl: string;
52
64
  }>;
@@ -42,6 +42,23 @@ export function parseOutpostPickupPayload(raw) {
42
42
  export function buildOutpostPickupCommand(payloadUrl) {
43
43
  return withRemoteHqDir(joinCommandParts(["hq", "lanes", "pickup", "--payload-url", payloadUrl]));
44
44
  }
45
+ /** Build the same `hq outposts exec` remote command used by lane pickup. */
46
+ export function buildOutpostLaneCommand(input) {
47
+ const args = ["hq", "lanes", input.action, input.laneId];
48
+ for (const [flag, value] of [
49
+ ["--text", input.text],
50
+ ["--project", input.project],
51
+ ["--story", input.story],
52
+ ["--account", input.account],
53
+ ["--model", input.model],
54
+ ]) {
55
+ if (value !== undefined)
56
+ args.push(flag, value);
57
+ }
58
+ if (input.compact === false)
59
+ args.push("--no-compact");
60
+ return withRemoteHqDir(joinCommandParts(args));
61
+ }
45
62
  export async function stageOutpostPayload(port, outpostId, bytes, put = fetch) {
46
63
  const staged = await port.stageExecInput(outpostId);
47
64
  const upload = await put(staged.putUrl, {
@@ -20,6 +20,7 @@ import { applyDefaultQuotaHold, readQuotaSignal, recordAccountQuota } from "./qu
20
20
  import { answeredQuestionEntries, raiseEnvelopeQuestions } from "./questions.js";
21
21
  import { SILENCE_MS, classifyLaneExit, isPidAlive, readLatestEnvelope, readLatestEnvelopeFile, readLatestEnvelopeRecord, } from "./watcher.js";
22
22
  import { createLaneMesh, emitLaneDecisionEvents, laneMeshCompany } from "./mesh-events.js";
23
+ import { laneStories, storyStatusForEnvelope } from "./stories.js";
23
24
  import { decideEnvelopeRepair, envelopeQuestionIds, routeProviderFailure } from "./watcher.js";
24
25
  import { providerRejectionDetails, readProviderRejection, } from "./provider-rejection.js";
25
26
  /** Spec §9: unanswered at the current hop for 60 minutes → one hop. */
@@ -882,18 +883,44 @@ async function heartbeatAndDeadHosts(hqRoot, lanes, summary, deps, now) {
882
883
  (latest.envelope.questions?.length ?? 0) > 0) {
883
884
  raiseEnvelopeQuestions(hqRoot, readLane(hqRoot, lane.lane_id), latest.envelope, latest.ref, nowIso);
884
885
  }
885
- const result = casLaneIf(hqRoot, lane.lane_id, (current) => current.state === "running", (current) => ({
886
- ...current,
887
- state: repairedClassification.state,
888
- queued_reason: undefined,
889
- terminal_reason: repairedClassification.reason ?? "host_lost",
890
- ...(providerRejection
891
- ? { terminal_details: providerRejectionDetails(providerRejection, current) }
892
- : {}),
893
- ...(quota?.quota_reset_at ? { quota_reset_at: quota.quota_reset_at } : {}),
894
- ...(quota?.marker ? { model_quota_marker: quota.marker } : {}),
895
- last_host_report_at: nowIso,
896
- }), deps.dryRun);
886
+ const result = casLaneIf(hqRoot, lane.lane_id, (current) => current.state === "running", (current) => {
887
+ const next = {
888
+ ...current,
889
+ state: repairedClassification.state,
890
+ queued_reason: undefined,
891
+ terminal_reason: repairedClassification.reason ?? "host_lost",
892
+ ...(providerRejection
893
+ ? { terminal_details: providerRejectionDetails(providerRejection, current) }
894
+ : {}),
895
+ ...(quota?.quota_reset_at ? { quota_reset_at: quota.quota_reset_at } : {}),
896
+ ...(quota?.marker ? { model_quota_marker: quota.marker } : {}),
897
+ last_host_report_at: nowIso,
898
+ };
899
+ if (latest?.envelope) {
900
+ const envelope = latest.envelope;
901
+ next.story_status = envelope.story_status;
902
+ if (envelope.story_complete !== undefined) {
903
+ next.story_complete = envelope.story_complete;
904
+ next.story_complete_reason = envelope.story_complete_reason;
905
+ }
906
+ const storyStatus = envelope.decision === "blocked"
907
+ ? "blocked"
908
+ : envelope.decision === "done"
909
+ ? "review"
910
+ : storyStatusForEnvelope(envelope.story_status);
911
+ next.stories = laneStories(current).map((story) => story.project_id === current.project_id && story.story_id === current.story_id
912
+ ? {
913
+ ...story,
914
+ status: storyStatus,
915
+ updated_at: nowIso,
916
+ ...(envelope.decision === "blocked"
917
+ ? { blocked_reason: envelope.blocked_reason ?? "" }
918
+ : { blocked_reason: undefined }),
919
+ }
920
+ : story);
921
+ }
922
+ return next;
923
+ }, deps.dryRun);
897
924
  if (result.changed) {
898
925
  summary.dead_host.push({
899
926
  id: lane.lane_id,
@@ -0,0 +1,25 @@
1
+ import { type SessionCopyResult } from "./move-account.js";
2
+ import type { LaneProvider } from "./types.js";
3
+ export interface EnsureSessionHomeInput {
4
+ provider: Exclude<LaneProvider, "grok">;
5
+ sessionId: string;
6
+ cwd: string;
7
+ targetHome: string;
8
+ registeredHomes: string[];
9
+ providerHomeAtSpawn?: string;
10
+ }
11
+ export interface EnsureSessionHomeResult extends SessionCopyResult {
12
+ source_home: string;
13
+ transcript_path: string;
14
+ homes_searched: string[];
15
+ }
16
+ /**
17
+ * Older Claude account homes can share a registered `account-N/.claude`
18
+ * parent even after one account is re-registered elsewhere. Discover only
19
+ * those sibling homes, then inspect the exact session path; credential files
20
+ * are never opened.
21
+ */
22
+ export declare function discoverSiblingAccountHomes(provider: EnsureSessionHomeInput["provider"], registeredHomes: string[]): string[];
23
+ /** Find, copy, and verify a provider transcript in the home the next run uses. */
24
+ export declare function ensureSessionHome(input: EnsureSessionHomeInput): EnsureSessionHomeResult;
25
+ //# sourceMappingURL=session-relink.d.ts.map
@@ -0,0 +1,216 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { copySessionFiles, encodeClaudeProjectDir, planSessionCopy, } from "./move-account.js";
4
+ import { LanesError } from "./errors.js";
5
+ function statDirectory(target) {
6
+ try {
7
+ return fs.statSync(target).isDirectory();
8
+ }
9
+ catch (err) {
10
+ const code = err.code;
11
+ if (code === "ENOENT" || code === "ENOTDIR")
12
+ return false;
13
+ throw err;
14
+ }
15
+ }
16
+ function claudeTranscriptPath(home, cwd, sessionId) {
17
+ return path.join(home, "projects", encodeClaudeProjectDir(cwd), `${sessionId}.jsonl`);
18
+ }
19
+ function findCodexRollouts(home, sessionId) {
20
+ const root = path.join(home, "sessions");
21
+ if (!statDirectory(root))
22
+ return [];
23
+ const suffix = `-${sessionId}.jsonl`;
24
+ const stack = [{ dir: root, depth: 0 }];
25
+ const matches = [];
26
+ while (stack.length > 0) {
27
+ const { dir, depth } = stack.pop();
28
+ let entries;
29
+ try {
30
+ entries = fs.readdirSync(dir, { withFileTypes: true });
31
+ }
32
+ catch (err) {
33
+ const code = err.code;
34
+ if (code === "ENOENT" || code === "ENOTDIR")
35
+ continue;
36
+ throw new LanesError("provider_home_search_failed", `Could not search provider home ${home}: ${err instanceof Error ? err.message : String(err)}`, { provider: "codex", home });
37
+ }
38
+ for (const entry of entries) {
39
+ const full = path.join(dir, entry.name);
40
+ if (entry.isDirectory()) {
41
+ if (depth < 3)
42
+ stack.push({ dir: full, depth: depth + 1 });
43
+ }
44
+ else if (entry.isFile() &&
45
+ entry.name.startsWith("rollout-") &&
46
+ entry.name.endsWith(suffix)) {
47
+ matches.push(full);
48
+ }
49
+ }
50
+ }
51
+ return matches;
52
+ }
53
+ function sessionTranscripts(provider, home, cwd, sessionId) {
54
+ if (provider === "codex")
55
+ return findCodexRollouts(home, sessionId);
56
+ const transcript = claudeTranscriptPath(home, cwd, sessionId);
57
+ try {
58
+ return fs.statSync(transcript).isFile() ? [transcript] : [];
59
+ }
60
+ catch (err) {
61
+ const code = err.code;
62
+ if (code === "ENOENT" || code === "ENOTDIR")
63
+ return [];
64
+ throw new LanesError("provider_home_search_failed", `Could not check provider home ${home}: ${err instanceof Error ? err.message : String(err)}`, { provider, home });
65
+ }
66
+ }
67
+ /**
68
+ * Older Claude account homes can share a registered `account-N/.claude`
69
+ * parent even after one account is re-registered elsewhere. Discover only
70
+ * those sibling homes, then inspect the exact session path; credential files
71
+ * are never opened.
72
+ */
73
+ export function discoverSiblingAccountHomes(provider, registeredHomes) {
74
+ const roots = new Set();
75
+ for (const home of registeredHomes) {
76
+ if (provider === "claude") {
77
+ const accountDir = path.dirname(home);
78
+ if (path.basename(home) === ".claude" &&
79
+ path.basename(accountDir).startsWith("account-")) {
80
+ roots.add(path.dirname(accountDir));
81
+ }
82
+ }
83
+ else if (path.basename(home).startsWith("account-")) {
84
+ roots.add(path.dirname(home));
85
+ }
86
+ }
87
+ const homes = [];
88
+ for (const root of [...roots].sort()) {
89
+ let entries;
90
+ try {
91
+ entries = fs.readdirSync(root, { withFileTypes: true });
92
+ }
93
+ catch (err) {
94
+ const code = err.code;
95
+ if (code === "ENOENT" || code === "ENOTDIR")
96
+ continue;
97
+ throw new LanesError("provider_home_search_failed", `Could not search provider account directory ${root}: ${err instanceof Error ? err.message : String(err)}`, { provider, home: root });
98
+ }
99
+ for (const entry of entries) {
100
+ if (!entry.isDirectory() || !entry.name.startsWith("account-"))
101
+ continue;
102
+ const home = provider === "claude"
103
+ ? path.join(root, entry.name, ".claude")
104
+ : path.join(root, entry.name);
105
+ if (statDirectory(home))
106
+ homes.push(home);
107
+ }
108
+ }
109
+ return homes;
110
+ }
111
+ function uniqueHomes(homes) {
112
+ const unique = new Set();
113
+ for (const home of homes) {
114
+ if (typeof home !== "string" || home.trim() === "")
115
+ continue;
116
+ unique.add(path.resolve(home));
117
+ }
118
+ return [...unique];
119
+ }
120
+ function missingTranscript(input, homes) {
121
+ return new LanesError("provider_transcript_missing", `Provider transcript for session ${input.sessionId} was not found. Homes searched: ${homes.join(", ")}`, { provider: input.provider, session_id: input.sessionId, homes_searched: homes });
122
+ }
123
+ function copyVerifiedTranscript(input, sourceHome, targetHome, searched) {
124
+ let copied;
125
+ try {
126
+ copied = copySessionFiles(planSessionCopy(input.provider, {
127
+ sourceHome,
128
+ targetHome,
129
+ cwd: input.cwd,
130
+ sessionId: input.sessionId,
131
+ }));
132
+ }
133
+ catch (err) {
134
+ if (err instanceof LanesError && err.error === "provider_session_missing") {
135
+ throw missingTranscript(input, searched);
136
+ }
137
+ throw err;
138
+ }
139
+ const target = sessionTranscripts(input.provider, targetHome, input.cwd, input.sessionId);
140
+ if (target.length !== 1) {
141
+ throw new LanesError("provider_transcript_relink_failed", `Could not verify session ${input.sessionId} in target home ${targetHome}`, { provider: input.provider, session_id: input.sessionId, target_home: targetHome });
142
+ }
143
+ return {
144
+ ...copied,
145
+ source_home: sourceHome,
146
+ transcript_path: target[0],
147
+ homes_searched: searched,
148
+ };
149
+ }
150
+ /** Find, copy, and verify a provider transcript in the home the next run uses. */
151
+ export function ensureSessionHome(input) {
152
+ const targetHome = path.resolve(input.targetHome);
153
+ const homes = uniqueHomes([
154
+ targetHome,
155
+ ...input.registeredHomes,
156
+ ...(input.providerHomeAtSpawn ? [input.providerHomeAtSpawn] : []),
157
+ ...discoverSiblingAccountHomes(input.provider, input.registeredHomes),
158
+ ]);
159
+ const searched = [targetHome];
160
+ const current = sessionTranscripts(input.provider, targetHome, input.cwd, input.sessionId);
161
+ if (current.length > 1) {
162
+ throw new LanesError("provider_transcript_ambiguous", `Multiple ${input.provider} transcripts for session ${input.sessionId} exist in ${targetHome}`, { provider: input.provider, session_id: input.sessionId, homes_searched: searched, matches: current });
163
+ }
164
+ const lastRunHome = input.providerHomeAtSpawn
165
+ ? path.resolve(input.providerHomeAtSpawn)
166
+ : undefined;
167
+ if (lastRunHome !== undefined && lastRunHome !== targetHome) {
168
+ searched.push(lastRunHome);
169
+ const lastRun = sessionTranscripts(input.provider, lastRunHome, input.cwd, input.sessionId);
170
+ if (lastRun.length > 1) {
171
+ throw new LanesError("provider_transcript_ambiguous", `Multiple ${input.provider} transcripts for session ${input.sessionId} exist in the last run home ${lastRunHome}`, { provider: input.provider, session_id: input.sessionId, homes_searched: searched, matches: lastRun });
172
+ }
173
+ if (lastRun.length === 1) {
174
+ return copyVerifiedTranscript(input, lastRunHome, targetHome, searched);
175
+ }
176
+ }
177
+ if (current.length === 1) {
178
+ const plan = planSessionCopy(input.provider, {
179
+ sourceHome: targetHome,
180
+ targetHome,
181
+ cwd: input.cwd,
182
+ sessionId: input.sessionId,
183
+ });
184
+ return {
185
+ ...plan,
186
+ copied: [],
187
+ source_home: targetHome,
188
+ transcript_path: current[0],
189
+ homes_searched: searched,
190
+ };
191
+ }
192
+ const matches = [];
193
+ for (const home of homes.slice(1)) {
194
+ if (home === lastRunHome && searched.includes(home))
195
+ continue;
196
+ searched.push(home);
197
+ const found = sessionTranscripts(input.provider, home, input.cwd, input.sessionId);
198
+ if (found.length > 1) {
199
+ throw new LanesError("provider_transcript_ambiguous", `Multiple ${input.provider} transcripts for session ${input.sessionId} exist in ${home}`, { provider: input.provider, session_id: input.sessionId, homes_searched: searched, matches: found });
200
+ }
201
+ if (found.length === 1)
202
+ matches.push({ home, transcript: found[0] });
203
+ }
204
+ if (matches.length === 0)
205
+ throw missingTranscript(input, searched);
206
+ if (matches.length > 1) {
207
+ throw new LanesError("provider_transcript_ambiguous", `Provider transcript for session ${input.sessionId} exists in multiple homes: ${matches.map((match) => match.home).join(", ")}`, {
208
+ provider: input.provider,
209
+ session_id: input.sessionId,
210
+ homes_searched: searched,
211
+ matching_homes: matches.map((match) => match.home),
212
+ });
213
+ }
214
+ return copyVerifiedTranscript(input, matches[0].home, targetHome, searched);
215
+ }
216
+ //# sourceMappingURL=session-relink.js.map
@@ -235,6 +235,7 @@ export interface SpawnManifestFile {
235
235
  resume?: {
236
236
  session_id: string;
237
237
  } | null;
238
+ provider_home_at_spawn?: string;
238
239
  /** Byte length of logs/worker.out before this run started; its result is after this. */
239
240
  worker_out_offset?: number;
240
241
  fast_mode?: boolean;
@@ -22,6 +22,7 @@ import { AccountResolutionError, LanesError } from "./errors.js";
22
22
  import { ignoredWorkerProfileFields, loadWorkerProfile, materializeWorkerProfile, workerProfilePrompt, } from "./profile.js";
23
23
  import { resolveMachineIdentity, } from "./machine-identity.js";
24
24
  import { readLane, retryUpdateLane } from "./store.js";
25
+ import { ensureSessionHome } from "./session-relink.js";
25
26
  import { LANE_ENVELOPE_JSON_SCHEMA, parseLaneProvider, } from "./types.js";
26
27
  export const LANES_RUNS_REL = path.join("workspace", "lanes-runs");
27
28
  /** Written by reconcile before a resume spawn; the worker prompt names this path. */
@@ -1027,6 +1028,9 @@ export function readSpawnManifest(runDir) {
1027
1028
  const out = {};
1028
1029
  if (typeof rec.envelope_path === "string")
1029
1030
  out.envelope_path = rec.envelope_path;
1031
+ if (typeof rec.provider_home_at_spawn === "string") {
1032
+ out.provider_home_at_spawn = rec.provider_home_at_spawn;
1033
+ }
1030
1034
  if (typeof rec.worker_out_offset === "number" &&
1031
1035
  Number.isInteger(rec.worker_out_offset) &&
1032
1036
  rec.worker_out_offset >= 0) {
@@ -1053,6 +1057,10 @@ export function readSpawnManifest(runDir) {
1053
1057
  }
1054
1058
  return out;
1055
1059
  }
1060
+ function laneLaunchResult(lane) {
1061
+ const { provider_home_at_spawn: _providerHomeAtSpawn, ...safe } = lane;
1062
+ return safe;
1063
+ }
1056
1064
  function inferTrigger(lane) {
1057
1065
  if (lane.state === "awaiting_input")
1058
1066
  return { kind: "answered_questions" };
@@ -1075,12 +1083,32 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
1075
1083
  const spawnProcess = deps.spawnProcess ?? defaultSpawnProcess;
1076
1084
  const proveDetached = deps.proveDetached ?? defaultProveDetached;
1077
1085
  const killProcess = deps.killProcess ?? defaultKillProcess;
1078
- const existing = readLane(hqRoot, lane.lane_id);
1086
+ const runDir = laneRunDir(hqRoot, lane.lane_id);
1087
+ const previousSpawn = readSpawnManifest(runDir);
1088
+ const stored = readLane(hqRoot, lane.lane_id);
1089
+ const recoveredSessionId = (typeof stored.provider_session_id === "string" && stored.provider_session_id.trim()
1090
+ ? stored.provider_session_id
1091
+ : undefined) ??
1092
+ (typeof stored.last_resume?.session_id === "string" && stored.last_resume.session_id.trim()
1093
+ ? stored.last_resume.session_id
1094
+ : undefined) ??
1095
+ (typeof previousSpawn.resume?.session_id === "string" && previousSpawn.resume.session_id.trim()
1096
+ ? previousSpawn.resume.session_id
1097
+ : undefined);
1098
+ const existing = {
1099
+ ...stored,
1100
+ ...(stored.provider_session_id === undefined && recoveredSessionId !== undefined
1101
+ ? { provider_session_id: recoveredSessionId }
1102
+ : {}),
1103
+ ...(stored.provider_home_at_spawn === undefined && previousSpawn.provider_home_at_spawn !== undefined
1104
+ ? { provider_home_at_spawn: previousSpawn.provider_home_at_spawn }
1105
+ : {}),
1106
+ };
1079
1107
  if (existing.state === "running") {
1080
1108
  if (launch.trigger?.kind === "resume") {
1081
1109
  throw new LanesError("lane_not_resumable", `Lane ${lane.lane_id} is already running`, { lane_id: lane.lane_id, state: existing.state });
1082
1110
  }
1083
- return existing;
1111
+ return laneLaunchResult(existing);
1084
1112
  }
1085
1113
  // A queued lane that a move parked at the target's cap carries the turn
1086
1114
  // it was resumed with: the admit spawn continues that session instead of
@@ -1098,7 +1126,6 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
1098
1126
  if (existing.host.kind === "outpost") {
1099
1127
  refuseOutpostSelfTarget(hqRoot, existing, deps);
1100
1128
  }
1101
- const runDir = laneRunDir(hqRoot, lane.lane_id);
1102
1129
  const logsDir = path.join(runDir, "logs");
1103
1130
  ensureDir(logsDir);
1104
1131
  ensureDir(path.join(runDir, "envelopes"));
@@ -1159,6 +1186,17 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
1159
1186
  if (profileDefaultsApply && profileModel !== undefined && pinnedModel === undefined) {
1160
1187
  validateProfileLaneModel(existing.provider, profileModel, resolved.home, profile.id, modelDeps);
1161
1188
  }
1189
+ if (plan.mode === "resume" && existing.provider !== "grok") {
1190
+ ensureSessionHome({
1191
+ provider: existing.provider,
1192
+ sessionId: plan.session_id,
1193
+ cwd: hqRoot,
1194
+ targetHome: resolved.home,
1195
+ registeredHomes: Object.values(config.providers[existing.provider].accounts)
1196
+ .map((account) => account.home),
1197
+ providerHomeAtSpawn: existing.provider_home_at_spawn,
1198
+ });
1199
+ }
1162
1200
  if (lane.account !== resolved.id) {
1163
1201
  retryUpdateLane(hqRoot, lane.lane_id, "spawn", (current) => {
1164
1202
  if (current.account === resolved.id)
@@ -1325,6 +1363,7 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
1325
1363
  // An account move's reservation ends with the claim.
1326
1364
  pending_resume: undefined,
1327
1365
  account: resolved.id,
1366
+ provider_home_at_spawn: resolved.home,
1328
1367
  ...(model !== undefined ? { model } : {}),
1329
1368
  ...(model !== undefined
1330
1369
  ? {
@@ -1348,6 +1387,10 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
1348
1387
  }
1349
1388
  : {}),
1350
1389
  };
1390
+ if (resume !== undefined) {
1391
+ next.provider_session_id = resume.session_id;
1392
+ next.provider_session_id_absent = undefined;
1393
+ }
1351
1394
  if (effort.effort !== undefined)
1352
1395
  next.effort = effort.effort;
1353
1396
  else
@@ -1406,7 +1449,7 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
1406
1449
  last_compaction: record,
1407
1450
  }));
1408
1451
  }
1409
- return claimedRecord;
1452
+ return laneLaunchResult(claimedRecord);
1410
1453
  }
1411
1454
  const workerOutPath = path.join(logsDir, "worker.out");
1412
1455
  const workerOutOffset = fileSizeOrZero(workerOutPath);
@@ -1476,7 +1519,7 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
1476
1519
  }
1477
1520
  throw err;
1478
1521
  }
1479
- return readLane(hqRoot, lane.lane_id);
1522
+ return laneLaunchResult(readLane(hqRoot, lane.lane_id));
1480
1523
  }
1481
1524
  /**
1482
1525
  * `resume`: claude and codex continue the recorded session with the repair