pi-squire 0.9.5 → 0.10.0

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.
package/src/render.ts CHANGED
@@ -18,13 +18,106 @@ import { Box, type Component, Container, Markdown, Spacer, Text, TruncatedText }
18
18
  import type { Static } from "typebox";
19
19
 
20
20
  import type { DelegateSchema } from "./index.ts";
21
- import type { MessageDetails } from "./results.ts";
21
+ import type { MessageDetails, ResourceFacts, WorkspaceAttempt } from "./results.ts";
22
+ import type { WorkspaceSnapshot } from "./snapshot.ts";
23
+ import type { PathSummary, Workspace } from "./workspace.ts";
22
24
 
23
25
  /** The launch model, or `launch → finished` when the delegate finished on another one. */
24
26
  export function modelLabel(model: string, finished: string | undefined): string {
25
27
  return finished === undefined || finished === model ? model : `${model} → ${finished}`;
26
28
  }
27
29
 
30
+ const displayReason = (reason: string): string => JSON.stringify(reason).slice(1, -1);
31
+
32
+ /** Where a delegation ran, or that its record, from before cwd was kept, does not say. */
33
+ export function cwdLine(cwd: string | undefined): string {
34
+ return cwd ?? "unknown (not recorded)";
35
+ }
36
+
37
+ /** How a delegation's directory was chosen and its Git checkout at launch, on one line. */
38
+ export function workspaceLine(workspace: Workspace): string {
39
+ if (workspace.mode === "managed") {
40
+ const { createdBranch, requestedBase, baseCommit, source, cwd } = workspace;
41
+ return `managed worktree ${cwd}, new branch ${createdBranch} created from ${requestedBase} (${baseCommit}) of ${source.checkoutRoot}; ${dirtyNotice(workspace.sourceDirty)}`;
42
+ }
43
+ const { mode, git } = workspace;
44
+ const how = mode === "inherited" ? "the parent's directory" : "existing directory";
45
+ if (git.kind === "non-git") return `${how}; not a Git checkout`;
46
+ if (git.kind === "unknown") return `${how}; Git state unknown (${displayReason(git.reason)})`;
47
+ const { mainRoot, checkoutRoot } = git.identity;
48
+ const linked = checkoutRoot === mainRoot ? "" : ` (linked worktree ${checkoutRoot})`;
49
+ const at = git.position.detached ? "detached HEAD" : `branch ${git.position.branch}`;
50
+ return `${how}; Git checkout ${mainRoot}${linked}, ${at}, launch commit ${git.baseline ?? "none (no commits yet)"}`;
51
+ }
52
+
53
+ /** The source's uncommitted paths a worktree does not carry, bounded to a few names. */
54
+ export function dirtyNotice({ paths, total }: PathSummary): string {
55
+ if (total === 0) return "the source had no uncommitted files";
56
+ const shown = paths.slice(0, 5).map((path) => JSON.stringify(path));
57
+ const rest = total > shown.length ? `, and ${total - shown.length} more` : "";
58
+ return `${total} uncommitted source path${total === 1 ? " is" : "s are"} not copied into it (${shown.join(", ")}${rest})`;
59
+ }
60
+
61
+ /** What is known to exist for an attempt, in words. */
62
+ export function resourceLine(resources: ResourceFacts): string {
63
+ switch (resources.kind) {
64
+ case "none":
65
+ return "nothing created by this attempt remains";
66
+ case "branch":
67
+ return `the branch ${resources.branch} remains at ${resources.head}; its worktree is gone`;
68
+ case "worktree":
69
+ return `the worktree ${resources.cwd} and its branch ${resources.branch} (at ${resources.head}) remain`;
70
+ case "unknown":
71
+ return `unknown: ${displayReason(resources.reason)}; observed paths ${JSON.stringify(resources.observedPaths)}, refs ${JSON.stringify(resources.observedRefs)}`;
72
+ }
73
+ }
74
+
75
+ /** The lines that describe an attempt without a window: its plan, what exists, why it stopped and recovery. */
76
+ export function attemptLines({ plan, attempt, requestId }: WorkspaceAttempt): string[] {
77
+ let why: string[] = [];
78
+ if ("cause" in attempt) why = [`cause: ${attempt.cause}`];
79
+ else if ("reason" in attempt) why = [`reason: ${attempt.reason}`];
80
+ return [
81
+ `source: ${plan.sourceCwd} (repository ${plan.source.mainRoot})`,
82
+ `worktree: ${plan.destination} on new branch ${plan.branch} from ${plan.requestedBase} (${plan.baseCommit})`,
83
+ `resources: ${resourceLine(attempt.resources)}`,
84
+ ...why,
85
+ ...(attempt.phase === "retained" && attempt.recovery.length > 0
86
+ ? ["recovery (non-forced; check each command's result):", ...attempt.recovery.map((line) => ` ${line}`)]
87
+ : []),
88
+ ...(requestId === undefined
89
+ ? []
90
+ : [`request: ${requestId} (the task was mailed; no window id/start was recorded and a window may still exist)`]),
91
+ ];
92
+ }
93
+
94
+ /** A path list as data: each name JSON-quoted, so spaces, quotes and newlines stay inside one line. */
95
+ function pathList({ paths, total }: PathSummary): string {
96
+ if (total === 0) return "none";
97
+ const more = total > paths.length ? `, and ${total - paths.length} more not listed` : "";
98
+ return `${total} path${total === 1 ? "" : "s"}: ${paths.map((path) => JSON.stringify(path)).join(", ")}${more}`;
99
+ }
100
+
101
+ /**
102
+ * A checkout observation: a summary line, then for Git one line per path
103
+ * category. Committed changes are counted from the launch commit; pending
104
+ * ones are the index and working tree now.
105
+ */
106
+ export function snapshotLines(snapshot: WorkspaceSnapshot): string[] {
107
+ if (snapshot.kind === "non-git") return ["not a Git checkout"];
108
+ if (snapshot.kind === "unknown") return [`Git state unknown (${displayReason(snapshot.reason)})`];
109
+ const { position, head, baseline, ahead, workingState, committed, pending } = snapshot;
110
+ const at = position.detached ? "detached HEAD" : `branch ${position.branch}`;
111
+ return [
112
+ `${at}, head ${head}, launch commit ${baseline}, ${ahead} commit${ahead === 1 ? "" : "s"} since launch, working tree ${workingState}`,
113
+ `committed since launch: ${pathList(committed)}`,
114
+ `staged: ${pathList(pending.staged)}`,
115
+ `unstaged: ${pathList(pending.unstaged)}`,
116
+ `untracked: ${pathList(pending.untracked)}`,
117
+ `conflicted: ${pathList(pending.conflicted)}`,
118
+ ];
119
+ }
120
+
28
121
  /** Lines of a result the collapsed message previews. */
29
122
  const PREVIEW_LINES = 5;
30
123
  /** Characters of the task's first line the call row shows. */
@@ -63,6 +156,12 @@ function idLines(details: MessageDetails): string[] {
63
156
  const lines = [`delegation ${details.id}`, `request ${details.requestId}`];
64
157
  if (details.kind === "result") lines.push(`envelope ${tilde(details.envelopePath)}`);
65
158
  else lines.push(`window ${details.windowName}`);
159
+ lines.push(`cwd ${details.cwd === undefined ? cwdLine(undefined) : tilde(details.cwd)}`);
160
+ if (details.workspace !== undefined) lines.push(`workspace ${workspaceLine(details.workspace)}`);
161
+ if (details.kind === "result" && details.workspaceSnapshot !== undefined) {
162
+ const [first, ...rest] = snapshotLines(details.workspaceSnapshot);
163
+ lines.push(`checkout when the result arrived: ${first}`, ...rest.map((line) => ` ${line}`));
164
+ }
66
165
  lines.push(
67
166
  `session ${details.sessionPath === undefined ? `${details.id} (no session file found)` : tilde(details.sessionPath)}`,
68
167
  );
@@ -194,6 +293,12 @@ export function renderDelegateResult(
194
293
  ...started.notices,
195
294
  `delegation ${started.id}`,
196
295
  `window ${started.windowName} (${started.windowId})`,
296
+ // Results saved before cwd was recorded have none.
297
+ `cwd ${(started.cwd as string | undefined) === undefined ? cwdLine(undefined) : tilde(started.cwd)}`,
298
+ // Results saved before workspaces were recorded have none.
299
+ ...((started.workspace as Workspace | undefined) === undefined
300
+ ? []
301
+ : [`workspace ${workspaceLine(started.workspace)}`]),
197
302
  `thinking ${started.thinking}, auto-exit ${started.autoExit ? "on" : "off"}`,
198
303
  ].map((line) => theme.fg("dim", line)),
199
304
  );
@@ -0,0 +1,47 @@
1
+ import { readdirSync, readFileSync } from "node:fs";
2
+ import { isAbsolute, join } from "node:path";
3
+ import type { StartedRecorded } from "./result-types.ts";
4
+
5
+ /** The delegate session file in a directory, found by its `_<id>.jsonl` suffix. */
6
+ export function findSession(dir: string | undefined, id: string): string | undefined {
7
+ if (dir === undefined) return undefined;
8
+ let names: string[];
9
+ try {
10
+ names = readdirSync(dir);
11
+ } catch {
12
+ return undefined;
13
+ }
14
+ const name = names.find((candidate) => candidate.endsWith(`_${id}.jsonl`));
15
+ return name === undefined ? undefined : join(dir, name);
16
+ }
17
+ /** Use a recorded absolute session path, otherwise search its recorded session directory. */
18
+ export function locateSession(record: StartedRecorded, fallbackDir: string | undefined): string | undefined {
19
+ const known = record.sessionPath ?? record.result?.sessionPath;
20
+ if (known !== undefined) return known;
21
+ const found = findSession(record.sessionDir ?? fallbackDir, record.id);
22
+ if (found !== undefined && isAbsolute(found)) record.sessionPath = found;
23
+ return found;
24
+ }
25
+ /** The provider/model of the last assistant message in the delegate session, if available. */
26
+ export function finishedModel(sessionPath: string | undefined): string | undefined {
27
+ if (sessionPath === undefined) return undefined;
28
+ let lines: string[];
29
+ try {
30
+ lines = readFileSync(sessionPath, "utf8").split("\n");
31
+ } catch {
32
+ return undefined;
33
+ }
34
+ for (let i = lines.length - 1; i >= 0; i--) {
35
+ let entry: { type?: unknown; message?: { role?: unknown; provider?: unknown; model?: unknown } };
36
+ try {
37
+ entry = JSON.parse(lines[i]);
38
+ } catch {
39
+ continue;
40
+ }
41
+ const message = entry?.message;
42
+ if (entry?.type !== "message" || message?.role !== "assistant") continue;
43
+ if (typeof message.provider === "string" && typeof message.model === "string")
44
+ return `${message.provider}/${message.model}`;
45
+ }
46
+ return undefined;
47
+ }
@@ -0,0 +1,36 @@
1
+ import type { Delegation } from "./results.ts";
2
+ import type { WorktreePlan } from "./worktree.ts";
3
+
4
+ /** One started delegation as it stands: what was recorded at its start, plus what came after. */
5
+ export type StartedRecorded = Delegation & { result?: import("./results.ts").Result; closed?: boolean; gone?: boolean };
6
+ /** What is known to exist for an attempt; facts, not authorization to remove anything. */
7
+ export type ResourceFacts =
8
+ | { kind: "none" }
9
+ | { kind: "branch"; branch: string; head: string }
10
+ | { kind: "worktree"; cwd: string; branch: string; head: string }
11
+ | { kind: "unknown"; reason: string; observedPaths: string[]; observedRefs: string[] };
12
+ /** Where a managed launch stands before, or instead of, a window. */
13
+ export type AttemptState =
14
+ | { phase: "planned"; resources: ResourceFacts }
15
+ | { phase: "created"; resources: ResourceFacts }
16
+ | { phase: "rolled-back"; resources: { kind: "none" }; cause: string }
17
+ | { phase: "retained"; resources: ResourceFacts; cause: string; recovery: string[] }
18
+ | { phase: "interrupted"; resources: ResourceFacts; lastPhase: "planned" | "created"; reason: string };
19
+ /** A managed launch with no window; its request id exists only if mail returned one. */
20
+ export type WorkspaceAttempt = {
21
+ kind: "workspace-attempt";
22
+ id: string;
23
+ parentId: string;
24
+ agent: string;
25
+ model: string;
26
+ thinking: string;
27
+ name?: string;
28
+ sessionDir: string;
29
+ plan: WorktreePlan;
30
+ attempt: AttemptState;
31
+ requestId?: string;
32
+ };
33
+ export type Recorded = StartedRecorded | WorkspaceAttempt;
34
+ export function isStarted(record: Recorded): record is StartedRecorded {
35
+ return !("kind" in record && record.kind === "workspace-attempt");
36
+ }
package/src/results.ts CHANGED
@@ -4,13 +4,25 @@
4
4
  // the message over, shows the parent one delegate result and records the
5
5
  // result. The records are the one source of truth for a delegation's state,
6
6
  // for the footer, `delegation_status` and `delegation_close`.
7
- import { readdirSync, readFileSync } from "node:fs";
8
- import { join } from "node:path";
9
7
 
10
8
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
11
9
 
12
10
  import { readMarks } from "./limits.ts";
13
- import { modelLabel, renderMessage } from "./render.ts";
11
+ import { cwdLine, modelLabel, renderMessage, snapshotLines, workspaceLine } from "./render.ts";
12
+ import { finishedModel, locateSession } from "./result-sessions.ts";
13
+ import {
14
+ type AttemptState,
15
+ isStarted,
16
+ type Recorded,
17
+ type StartedRecorded,
18
+ type WorkspaceAttempt,
19
+ } from "./result-types.ts";
20
+ import { inspectWorkspace, type WorkspaceSnapshot } from "./snapshot.ts";
21
+ import type { Workspace } from "./workspace.ts";
22
+
23
+ export { findSession, finishedModel } from "./result-sessions.ts";
24
+ export type { AttemptState, Recorded, ResourceFacts, StartedRecorded, WorkspaceAttempt } from "./result-types.ts";
25
+ export { isStarted, locateSession };
14
26
 
15
27
  /** The session entry type both delegation records and results use. */
16
28
  export const CUSTOM_TYPE = "delegate";
@@ -36,6 +48,14 @@ export type Delegation = {
36
48
  requestId: string;
37
49
  /** Whether the delegate closes its own window after a normal completion; absent in records from before auto-exit. */
38
50
  autoExit?: boolean;
51
+ /** The directory its window opened in; absent in records from before it was recorded, where it is unknown. */
52
+ cwd?: string;
53
+ /** The session-storage directory passed as `--session-dir`; absent in older records. */
54
+ sessionDir?: string;
55
+ /** The delegate's session file once found; never set at launch, when the file may not exist yet. */
56
+ sessionPath?: string;
57
+ /** How its directory was chosen and its Git checkout at launch; absent in older records. */
58
+ workspace?: Workspace;
39
59
  };
40
60
 
41
61
  /**
@@ -67,6 +87,8 @@ export type Result = {
67
87
  sessionPath?: string;
68
88
  /** The `provider/model` of the delegate's last assistant message, when its session file holds one. */
69
89
  finishedModel?: string;
90
+ /** The checkout as observed when the result arrived; absent in results from before it was kept. */
91
+ workspaceSnapshot?: WorkspaceSnapshot;
70
92
  };
71
93
 
72
94
  /** What both delegate messages carry for their view, beside the text the model reads. */
@@ -79,6 +101,9 @@ type CommonDetails = {
79
101
  id: string;
80
102
  requestId: string;
81
103
  sessionPath?: string;
104
+ cwd?: string;
105
+ sessionDir?: string;
106
+ workspace?: Workspace;
82
107
  };
83
108
 
84
109
  /** The task as the result quotes it: its text and copy path, or why there is none. */
@@ -95,21 +120,13 @@ export type MessageDetails =
95
120
  task: TaskQuote;
96
121
  /** The reply body, capped. */
97
122
  body: string;
123
+ /** The checkout when the result arrived, the same value as the recorded result's; absent in older messages. */
124
+ workspaceSnapshot?: WorkspaceSnapshot;
98
125
  })
99
126
  | (CommonDetails & { kind: "gone"; windowName: string });
100
127
 
101
128
  type ResultDetails = Extract<MessageDetails, { kind: "result" }>;
102
129
 
103
- /** One delegation as it stands: what was recorded at its start, plus what came after. */
104
- export type Recorded = Delegation & {
105
- /** Set when a result arrived. */
106
- result?: Result;
107
- /** Set when `delegation_close` recorded a close. */
108
- closed?: boolean;
109
- /** Set when the poll found the window gone with no result; a result outranks it. */
110
- gone?: boolean;
111
- };
112
-
113
130
  /** The provider's payload on `message:inbound`. */
114
131
  export type Inbound = {
115
132
  envelope: Envelope;
@@ -121,12 +138,14 @@ export type Inbound = {
121
138
  export type Results = {
122
139
  /** Rebuild the records from the session's custom entries, as on a resume. */
123
140
  restore(ctx: ExtensionContext): void;
124
- /** Record a delegation and show it as running. */
141
+ /** Record a delegation and show it as running; it supersedes an attempt with the same id. */
125
142
  recordStart(delegation: Delegation, ctx: ExtensionContext): void;
143
+ /** Record a workspace attempt, or its next state; never counted as running. */
144
+ recordAttempt(attempt: WorkspaceAttempt, ctx: ExtensionContext): void;
126
145
  /** Record a close; does nothing when there is no such delegation or it is already closed. */
127
146
  recordClose(id: string): void;
128
147
  /** Delegations with no result, no recorded close and no gone-window record. */
129
- running(): Recorded[];
148
+ running(): StartedRecorded[];
130
149
  /** Record that the window is gone with no result, and tell the parent once. False when it no longer applies. */
131
150
  recordGone(id: string): boolean;
132
151
  /** Every delegation, in the order it was recorded. */
@@ -143,7 +162,14 @@ export type WindowState = "open" | "gone" | "unknown";
143
162
  * closed without a result (the poll found the window missing); `window-closed`
144
163
  * is a window tmux no longer lists, seen when asked directly.
145
164
  */
146
- export type DelegationState = "closed" | "done" | "gone" | "running" | "window-closed" | "unknown";
165
+ export type DelegationState =
166
+ | "closed"
167
+ | "done"
168
+ | "gone"
169
+ | "running"
170
+ | "window-closed"
171
+ | "unknown"
172
+ | AttemptState["phase"];
147
173
 
148
174
  /** How much of a delegation id a delegate's session name carries. */
149
175
  const SHORT_ID = 8;
@@ -163,6 +189,7 @@ export function recordedName(record: Recorded): string {
163
189
 
164
190
  /** A delegation's state: a recorded close, then a result, then a gone record, then the window. */
165
191
  export function deriveState(record: Recorded, window: WindowState): DelegationState {
192
+ if (!isStarted(record)) return record.attempt.phase;
166
193
  if (record.closed === true) return "closed";
167
194
  if (record.result !== undefined) return "done";
168
195
  if (record.gone === true) return "gone";
@@ -180,47 +207,6 @@ function cap(text: string, max: number): string | undefined {
180
207
  return bytes.subarray(0, end).toString("utf8");
181
208
  }
182
209
 
183
- /** The delegate's session file in `dir`, found by its `_<id>.jsonl` suffix. */
184
- export function findSession(dir: string | undefined, id: string): string | undefined {
185
- if (dir === undefined) return undefined;
186
- let names: string[];
187
- try {
188
- names = readdirSync(dir);
189
- } catch {
190
- return undefined;
191
- }
192
- const name = names.find((candidate) => candidate.endsWith(`_${id}.jsonl`));
193
- return name === undefined ? undefined : join(dir, name);
194
- }
195
-
196
- /**
197
- * The `provider/model` of the last assistant message in a delegate's session
198
- * file: the model that actually answered, after any fallback or `/model`.
199
- * Undefined when the file is missing or holds no assistant message.
200
- */
201
- export function finishedModel(sessionPath: string | undefined): string | undefined {
202
- if (sessionPath === undefined) return undefined;
203
- let lines: string[];
204
- try {
205
- lines = readFileSync(sessionPath, "utf8").split("\n");
206
- } catch {
207
- return undefined;
208
- }
209
- for (let i = lines.length - 1; i >= 0; i--) {
210
- let entry: { type?: unknown; message?: { role?: unknown; provider?: unknown; model?: unknown } };
211
- try {
212
- entry = JSON.parse(lines[i]);
213
- } catch {
214
- continue; // a line still being written, or a blank one
215
- }
216
- const message = entry?.message;
217
- if (entry?.type !== "message" || message?.role !== "assistant") continue;
218
- if (typeof message.provider === "string" && typeof message.model === "string")
219
- return `${message.provider}/${message.model}`;
220
- }
221
- return undefined;
222
- }
223
-
224
210
  /** The line naming a usage-limit mark this delegation recorded, if any; a `done` delegation finished on a fallback. */
225
211
  function limitLine(id: string, status: string): string | undefined {
226
212
  const now = Date.now();
@@ -235,10 +221,11 @@ function limitLine(id: string, status: string): string | undefined {
235
221
 
236
222
  /** The details of a settled delegation's result message. */
237
223
  function resultDetails(
238
- delegation: Delegation,
224
+ delegation: StartedRecorded,
239
225
  payload: Inbound,
240
226
  session: string | undefined,
241
227
  finished: string | undefined,
228
+ snapshot: WorkspaceSnapshot,
242
229
  ): ResultDetails {
243
230
  const reply = payload.envelope;
244
231
  const copy = payload.requests.find((request) => request.envelope?.id === delegation.requestId);
@@ -266,12 +253,22 @@ function resultDetails(
266
253
  requestId: delegation.requestId,
267
254
  envelopePath: payload.path,
268
255
  ...(session === undefined ? {} : { sessionPath: session }),
256
+ ...(delegation.cwd === undefined ? {} : { cwd: delegation.cwd }),
257
+ ...(delegation.sessionDir === undefined ? {} : { sessionDir: delegation.sessionDir }),
258
+ ...(delegation.workspace === undefined ? {} : { workspace: delegation.workspace }),
269
259
  ...(limit === undefined ? {} : { limit }),
270
260
  task,
271
261
  body,
262
+ workspaceSnapshot: snapshot,
272
263
  };
273
264
  }
274
265
 
266
+ /** The snapshot lines of the result text: a heading line, then its path lists indented. */
267
+ function checkoutText(snapshot: WorkspaceSnapshot): string[] {
268
+ const [first, ...rest] = snapshotLines(snapshot);
269
+ return [`Checkout when the result arrived: ${first}`, ...rest.map((line) => ` ${line}`)];
270
+ }
271
+
275
272
  /** The one message the parent model reads for a settled delegation. */
276
273
  function resultText(details: ResultDetails): string {
277
274
  const { task } = details;
@@ -287,6 +284,9 @@ function resultText(details: ResultDetails): string {
287
284
  `Status: ${details.status}`,
288
285
  ...(details.limit === undefined ? [] : [details.limit]),
289
286
  quote,
287
+ `Working directory: ${cwdLine(details.cwd)}`,
288
+ ...(details.workspace === undefined ? [] : [`Workspace: ${workspaceLine(details.workspace)}`]),
289
+ ...(details.workspaceSnapshot === undefined ? [] : checkoutText(details.workspaceSnapshot)),
290
290
  `Envelope: ${details.envelopePath}`,
291
291
  `Delegate session: ${details.sessionPath ?? details.id}`,
292
292
  "",
@@ -298,8 +298,8 @@ export function createResults(pi: ExtensionAPI): Results {
298
298
  pi.registerMessageRenderer(CUSTOM_TYPE, renderMessage);
299
299
  /** Every recorded delegation by its id, in the order recorded. */
300
300
  const byId = new Map<string, Recorded>();
301
- /** The same records by their request's envelope id, for matching replies. */
302
- const byRequest = new Map<string, Recorded>();
301
+ /** Started records by their request's envelope id, for matching replies; attempts never match a reply. */
302
+ const byRequest = new Map<string, StartedRecorded>();
303
303
  /** Reply envelope ids a result message has already been sent for. */
304
304
  const shown = new Set<string>();
305
305
  /** The latest session context, for the footer and the session directory. */
@@ -309,11 +309,18 @@ export function createResults(pi: ExtensionAPI): Results {
309
309
  const icon = Math.random() < 0.5 ? "👨" : "👩";
310
310
 
311
311
  /** A delegation still going: no result and no recorded close. */
312
- const running = (): Recorded[] =>
312
+ const running = (): StartedRecorded[] =>
313
313
  [...byId.values()].filter(
314
- (record) => record.result === undefined && record.closed !== true && record.gone !== true,
314
+ (record): record is StartedRecorded =>
315
+ isStarted(record) && record.result === undefined && record.closed !== true && record.gone !== true,
315
316
  );
316
317
 
318
+ /** The started record for `id`, if it is one. */
319
+ const startedById = (id: unknown): StartedRecorded | undefined => {
320
+ const record = typeof id === "string" ? byId.get(id) : undefined;
321
+ return record !== undefined && isStarted(record) ? record : undefined;
322
+ };
323
+
317
324
  /** The footer counts the running delegations, and hides itself at zero. */
318
325
  function updateFooter(): void {
319
326
  if (ctx?.hasUI !== true) return;
@@ -332,22 +339,45 @@ export function createResults(pi: ExtensionAPI): Results {
332
339
  shown.clear();
333
340
  for (const entry of context.sessionManager.getEntries()) {
334
341
  if (entry.type !== "custom" || entry.customType !== CUSTOM_TYPE) continue;
335
- const data = entry.data as Partial<Delegation & { result: Result; closed: boolean; gone: boolean }> | undefined;
342
+ const data = entry.data as
343
+ | Partial<Delegation & { kind: string; result: Result; closed: boolean; gone: boolean }>
344
+ | undefined;
336
345
  if (data === undefined || typeof data.id !== "string") continue;
337
- if (typeof data.requestId === "string") {
338
- const record: Recorded = { ...(data as Delegation) };
346
+ // An attempt may carry a real request id; it is checked before the legacy start rule below.
347
+ if (data.kind === "workspace-attempt") {
348
+ const existing = byId.get(data.id);
349
+ if (existing === undefined || !isStarted(existing)) {
350
+ byId.set(data.id, { ...(data as unknown as WorkspaceAttempt) });
351
+ }
352
+ } else if (typeof data.requestId === "string") {
353
+ const record: StartedRecorded = { ...(data as Delegation) };
339
354
  byId.set(record.id, record);
340
355
  byRequest.set(record.requestId, record);
341
356
  } else if (typeof data.result?.replyId === "string") {
342
357
  shown.add(data.result.replyId);
343
- const record = byId.get(data.id);
358
+ const record = startedById(data.id);
344
359
  if (record !== undefined) record.result = data.result;
345
360
  } else if (data.closed === true) {
346
- const record = byId.get(data.id);
361
+ const record = startedById(data.id);
347
362
  if (record !== undefined) record.closed = true;
348
363
  } else if (data.gone === true) {
349
- const record = byId.get(data.id);
350
- if (record !== undefined) record.gone = true;
364
+ const record = startedById(data.id);
365
+ if (record === undefined) continue;
366
+ record.gone = true;
367
+ if (typeof data.sessionPath === "string") record.sessionPath = data.sessionPath;
368
+ }
369
+ }
370
+ // A parent that stopped mid-launch cannot say how far it got; nothing is cleaned up or relaunched.
371
+ for (const record of byId.values()) {
372
+ if (isStarted(record)) continue;
373
+ const { phase, resources } = record.attempt;
374
+ if (phase === "planned" || phase === "created") {
375
+ record.attempt = {
376
+ phase: "interrupted",
377
+ resources,
378
+ lastPhase: phase,
379
+ reason: `the parent session ended while the launch was ${phase}; whether anything was created is unknown`,
380
+ };
351
381
  }
352
382
  }
353
383
  updateFooter();
@@ -356,14 +386,20 @@ export function createResults(pi: ExtensionAPI): Results {
356
386
  function recordStart(delegation: Delegation, context: ExtensionContext): void {
357
387
  ctx = context;
358
388
  pi.appendEntry(CUSTOM_TYPE, delegation);
359
- const record: Recorded = { ...delegation };
389
+ const record: StartedRecorded = { ...delegation };
360
390
  byId.set(record.id, record);
361
391
  byRequest.set(record.requestId, record);
362
392
  updateFooter();
363
393
  }
364
394
 
395
+ function recordAttempt(attempt: WorkspaceAttempt, context: ExtensionContext): void {
396
+ ctx = context;
397
+ pi.appendEntry(CUSTOM_TYPE, attempt);
398
+ byId.set(attempt.id, { ...attempt });
399
+ }
400
+
365
401
  function recordClose(id: string): void {
366
- const record = byId.get(id);
402
+ const record = startedById(id);
367
403
  if (record === undefined || record.closed === true) return;
368
404
  pi.appendEntry(CUSTOM_TYPE, { id, closed: true });
369
405
  record.closed = true;
@@ -371,12 +407,12 @@ export function createResults(pi: ExtensionAPI): Results {
371
407
  }
372
408
 
373
409
  function recordGone(id: string): boolean {
374
- const record = byId.get(id);
410
+ const record = startedById(id);
375
411
  if (record === undefined || record.result !== undefined || record.closed === true || record.gone === true)
376
412
  return false;
377
- const session = findSession(ctx?.sessionManager.getSessionDir(), record.id);
413
+ const session = locateSession(record, ctx?.sessionManager.getSessionDir());
378
414
  const finished = finishedModel(session);
379
- pi.appendEntry(CUSTOM_TYPE, { id, gone: true });
415
+ pi.appendEntry(CUSTOM_TYPE, { id, gone: true, ...(session === undefined ? {} : { sessionPath: session }) });
380
416
  record.gone = true;
381
417
  const details: MessageDetails = {
382
418
  kind: "gone",
@@ -387,6 +423,9 @@ export function createResults(pi: ExtensionAPI): Results {
387
423
  id: record.id,
388
424
  requestId: record.requestId,
389
425
  ...(session === undefined ? {} : { sessionPath: session }),
426
+ ...(record.cwd === undefined ? {} : { cwd: record.cwd }),
427
+ ...(record.sessionDir === undefined ? {} : { sessionDir: record.sessionDir }),
428
+ ...(record.workspace === undefined ? {} : { workspace: record.workspace }),
390
429
  windowName: record.windowName,
391
430
  };
392
431
  pi.sendMessage(
@@ -395,6 +434,8 @@ export function createResults(pi: ExtensionAPI): Results {
395
434
  details,
396
435
  content: [
397
436
  `[delegate] Delegation ${record.id} (${record.agent}, ${modelLabel(record.model, finished)}), request ${record.requestId}, closed without a result: its window ${record.windowName} is gone and no reply arrived.`,
437
+ `Working directory: ${cwdLine(record.cwd)}`,
438
+ ...(record.workspace === undefined ? [] : [`Workspace: ${workspaceLine(record.workspace)}`]),
398
439
  `Delegate session: ${session ?? record.id}`,
399
440
  "A reply that arrives later is still delivered.",
400
441
  ].join("\n"),
@@ -415,11 +456,13 @@ export function createResults(pi: ExtensionAPI): Results {
415
456
  if (requestId === undefined) return;
416
457
  payload.handled = true;
417
458
  if (shown.has(reply.id)) return;
418
- const delegation = byRequest.get(requestId) as Recorded;
459
+ const delegation = byRequest.get(requestId) as StartedRecorded;
419
460
  shown.add(reply.id);
420
- const session = findSession(ctx?.sessionManager.getSessionDir(), delegation.id);
461
+ const session = locateSession(delegation, ctx?.sessionManager.getSessionDir());
421
462
  const finished = finishedModel(session);
422
- const details = resultDetails(delegation, payload, session, finished);
463
+ // After the claim above, so a slow or failing inspection cannot let the provider or a duplicate deliver it too.
464
+ const snapshot = inspectWorkspace(delegation.workspace?.cwd, delegation.workspace?.git);
465
+ const details = resultDetails(delegation, payload, session, finished, snapshot);
423
466
  // Steer, not follow-up: a busy parent gets the result before its next model call, not after it has answered.
424
467
  pi.sendMessage(
425
468
  { customType: CUSTOM_TYPE, content: resultText(details), details, display: true },
@@ -431,6 +474,7 @@ export function createResults(pi: ExtensionAPI): Results {
431
474
  envelopePath: payload.path,
432
475
  ...(session === undefined ? {} : { sessionPath: session }),
433
476
  ...(finished === undefined ? {} : { finishedModel: finished }),
477
+ workspaceSnapshot: snapshot,
434
478
  };
435
479
  pi.appendEntry(CUSTOM_TYPE, { id: delegation.id, result });
436
480
  delegation.result = result;
@@ -442,6 +486,7 @@ export function createResults(pi: ExtensionAPI): Results {
442
486
  return {
443
487
  restore,
444
488
  recordStart,
489
+ recordAttempt,
445
490
  recordClose,
446
491
  running,
447
492
  recordGone,