pi-squire 0.15.0 → 0.16.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.
Files changed (60) hide show
  1. package/CHANGELOG.md +323 -1
  2. package/README.md +918 -117
  3. package/package.json +2 -1
  4. package/src/agents.ts +57 -36
  5. package/src/child-tasks.ts +221 -0
  6. package/src/child.ts +35 -41
  7. package/src/completion-holds.ts +104 -0
  8. package/src/dashboard.ts +144 -0
  9. package/src/git.ts +6 -1
  10. package/src/identity.ts +152 -0
  11. package/src/index.ts +358 -56
  12. package/src/local-state.ts +21 -0
  13. package/src/navigator-view.ts +186 -0
  14. package/src/navigator.ts +152 -107
  15. package/src/poll.ts +1 -0
  16. package/src/render.ts +4 -1
  17. package/src/result-close.ts +37 -0
  18. package/src/result-restore.ts +95 -0
  19. package/src/result-types.ts +29 -1
  20. package/src/results-presentation.ts +47 -0
  21. package/src/results.ts +174 -108
  22. package/src/resume-checks.ts +14 -2
  23. package/src/resume-picker.ts +9 -2
  24. package/src/resume.ts +62 -13
  25. package/src/session-owner.ts +279 -0
  26. package/src/task-replies.ts +247 -0
  27. package/src/task-settlement.ts +81 -0
  28. package/src/task-store.ts +26 -0
  29. package/src/tmux.ts +134 -54
  30. package/src/tool-ceiling.ts +125 -0
  31. package/src/tracking.ts +72 -65
  32. package/src/tree-close.ts +236 -0
  33. package/src/tree-fence.ts +51 -0
  34. package/src/tree-lock.ts +77 -0
  35. package/src/tree-notice.ts +124 -0
  36. package/src/tree-registry.ts +365 -0
  37. package/src/tree-settings.ts +31 -0
  38. package/src/tree-types.ts +32 -0
  39. package/src/tree-view.ts +246 -0
  40. package/src/trust-control.ts +214 -0
  41. package/src/trust-prompt.ts +163 -0
  42. package/src/trust-wire.ts +203 -0
  43. package/src/widget-activity-join.ts +24 -0
  44. package/src/widget-activity-runtime.ts +104 -0
  45. package/src/widget-activity.ts +104 -0
  46. package/src/widget-ancestors.ts +142 -0
  47. package/src/widget-current.ts +41 -0
  48. package/src/widget-data.ts +147 -0
  49. package/src/widget-focus.ts +133 -0
  50. package/src/widget-live-process.ts +31 -0
  51. package/src/widget-live.ts +356 -0
  52. package/src/widget-render.ts +181 -0
  53. package/src/widget-selection.ts +58 -0
  54. package/src/widget-settings.ts +29 -0
  55. package/src/widget-summary.ts +51 -0
  56. package/src/widget-title-file.ts +127 -0
  57. package/src/widget-title.ts +118 -0
  58. package/src/widget.ts +272 -0
  59. package/src/window-placement.ts +30 -5
  60. package/src/workspace.ts +38 -90
@@ -0,0 +1,37 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { ChildOutcome } from "./child-tasks.ts";
3
+ import { cwdLine, modelLabel, workspaceLine } from "./render.ts";
4
+ import { resumeDetails } from "./result-message.ts";
5
+ import { finishedModel, locateSession, sessionIdOf } from "./result-sessions.ts";
6
+ import { recordedName, type StartedRecorded } from "./result-types.ts";
7
+ import type { MessageDetails } from "./results.ts";
8
+
9
+ /** A missing/canceled task, never a fabricated result envelope. */
10
+ export function closeOutcome(record: StartedRecorded, ctx: ExtensionContext | undefined): ChildOutcome {
11
+ const session: string | undefined = locateSession(record, ctx?.sessionManager.getSessionDir());
12
+ const finished: string | undefined = finishedModel(session);
13
+ const details: MessageDetails = {
14
+ kind: "gone",
15
+ reason: "explicit-close",
16
+ name: recordedName(record),
17
+ agent: record.agent,
18
+ model: record.model,
19
+ id: record.id,
20
+ ...resumeDetails(record),
21
+ requestId: record.requestId,
22
+ windowName: record.windowName,
23
+ ...(finished ? { finishedModel: finished } : {}),
24
+ ...(session ? { sessionPath: session } : {}),
25
+ cwd: record.cwd,
26
+ sessionDir: record.sessionDir,
27
+ workspace: record.workspace,
28
+ };
29
+ const content: string = [
30
+ `[delegate] Delegation ${record.id} (${record.agent}, ${modelLabel(record.model, finished)}), request ${record.requestId}, canceled by explicit close without a result. This is missing work, not successful completion.`,
31
+ `Working directory: ${cwdLine(record.cwd)}`,
32
+ ...(record.workspace ? [`Workspace: ${workspaceLine(record.workspace)}`] : []),
33
+ `Delegate session: ${session ?? sessionIdOf(record)}`,
34
+ "A genuine reply that arrives later is still delivered. Only the direct parent may resume this session as a new delegation.",
35
+ ].join("\n");
36
+ return { key: `closed:${record.id}`, content, details };
37
+ }
@@ -0,0 +1,95 @@
1
+ // Restore local semantic facts without trusting foreign transcript copies.
2
+ // Any incomplete authoritative read latches a failure until a fresh restore.
3
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
4
+ import type { ChildTasks } from "./child-tasks.ts";
5
+ import { isStarted, type Recorded, type WorkspaceAttempt } from "./result-types.ts";
6
+ import type { Delegation, Result } from "./results.ts";
7
+ import { belongsTo, restoredStarts } from "./tree-view.ts";
8
+
9
+ export function restoreRecords(ctx: ExtensionContext, tasks: ChildTasks): Recorded[] {
10
+ const byId: Map<string, Recorded> = new Map();
11
+ let local: Recorded[] = [];
12
+ try {
13
+ local = tasks.restore(ctx.sessionManager.getSessionId());
14
+ } catch (error) {
15
+ tasks.blockRestore(error);
16
+ }
17
+ try {
18
+ transcript(ctx, tasks, byId);
19
+ } catch (error) {
20
+ tasks.blockRestore(error);
21
+ }
22
+ try {
23
+ for (const record of local) if (accept(record, ctx, tasks, true)) byId.set(record.id, record);
24
+ } catch (error) {
25
+ tasks.blockRestore(error);
26
+ }
27
+ try {
28
+ for (const record of restoredStarts(ctx))
29
+ if (!byId.has(record.id) && accept(record, ctx, tasks, false)) byId.set(record.id, record);
30
+ } catch (error) {
31
+ ctx.ui.notify(`delegate: shared launch recovery unavailable: ${String(error)}`, "warning");
32
+ }
33
+ try {
34
+ interrupt([...byId.values()]);
35
+ } catch (error) {
36
+ tasks.blockRestore(error);
37
+ }
38
+ return [...byId.values()];
39
+ }
40
+ function accept(record: Recorded, ctx: ExtensionContext, tasks: ChildTasks, local: boolean): boolean {
41
+ try {
42
+ return belongsTo(record, ctx);
43
+ } catch (error) {
44
+ tasks.blockRestore(error);
45
+ // Keep known own obligations while blocked, never make a foreign copy local.
46
+ if (!isStarted(record)) return record.parentId === ctx.sessionManager.getSessionId();
47
+ if (record.identity) return record.identity.parent === ctx.sessionManager.getSessionId();
48
+ return local;
49
+ }
50
+ }
51
+ function transcript(ctx: ExtensionContext, tasks: ChildTasks, byId: Map<string, Recorded>): void {
52
+ for (const entry of ctx.sessionManager.getEntries()) {
53
+ if (entry.type !== "custom" || entry.customType !== "delegate") continue;
54
+ const data = entry.data as
55
+ | Partial<Delegation & { kind: string; result: Result; closed: boolean; gone: boolean }>
56
+ | undefined;
57
+ if (!data || typeof data.id !== "string") continue;
58
+ if (data.kind === "workspace-attempt" || typeof data.requestId === "string") {
59
+ restoreStart(data as Recorded, ctx, tasks, byId);
60
+ } else {
61
+ applyTerminal(byId.get(data.id), data);
62
+ }
63
+ }
64
+ }
65
+ function restoreStart(record: Recorded, ctx: ExtensionContext, tasks: ChildTasks, byId: Map<string, Recorded>): void {
66
+ const existing: Recorded | undefined = byId.get(record.id);
67
+ if (!isStarted(record) && existing && isStarted(existing)) return;
68
+ if (accept(record, ctx, tasks, false)) byId.set(record.id, { ...record });
69
+ }
70
+ function applyTerminal(
71
+ record: Recorded | undefined,
72
+ data: Partial<Delegation & { result: Result; closed: boolean; gone: boolean }>,
73
+ ): void {
74
+ if (!record || !isStarted(record)) return;
75
+ if (typeof data.result?.replyId === "string") record.result = data.result;
76
+ else if (data.closed === true) record.closed = true;
77
+ else if (data.gone === true) {
78
+ record.gone = true;
79
+ if (typeof data.sessionPath === "string") record.sessionPath = data.sessionPath;
80
+ }
81
+ }
82
+ function interrupt(records: Recorded[]): void {
83
+ for (const record of records) {
84
+ if (isStarted(record)) continue;
85
+ const attempt: WorkspaceAttempt = record;
86
+ const { phase, resources } = attempt.attempt;
87
+ if (phase === "planned" || phase === "created")
88
+ attempt.attempt = {
89
+ phase: "interrupted",
90
+ resources,
91
+ lastPhase: phase,
92
+ reason: `the parent session ended while the launch was ${phase}; whether anything was created is unknown`,
93
+ };
94
+ }
95
+ }
@@ -1,4 +1,4 @@
1
- import type { Delegation } from "./results.ts";
1
+ import type { Delegation, DelegationState, WindowState } from "./results.ts";
2
2
  import type { WorktreePlan } from "./worktree.ts";
3
3
 
4
4
  /** One started delegation as it stands: what was recorded at its start, plus what came after. */
@@ -34,3 +34,31 @@ export type Recorded = StartedRecorded | WorkspaceAttempt;
34
34
  export function isStarted(record: Recorded): record is StartedRecorded {
35
35
  return !("kind" in record && record.kind === "workspace-attempt");
36
36
  }
37
+
38
+ /** How much of a delegation id a delegate's session name carries. */
39
+ const SHORT_ID = 8;
40
+
41
+ /**
42
+ * A delegate's name, used for both its session and its window: its agent and
43
+ * the label, or the first 8 characters of its id when there is no label.
44
+ */
45
+ export function delegateName(agent: string, id: string, label?: string): string {
46
+ return `${agent}-${label ?? id.slice(0, SHORT_ID)}`;
47
+ }
48
+
49
+ /** The recorded name, or the pre-label name for records from before labels. */
50
+ export function recordedName(record: Recorded): string {
51
+ return record.name ?? delegateName(record.agent, record.id);
52
+ }
53
+
54
+ /** A delegation's state: a recorded close, then a result, then a gone record, then the window. */
55
+ export function deriveState(record: Recorded, window: WindowState): DelegationState {
56
+ if (!isStarted(record)) return record.attempt.phase;
57
+ if (record.accountingOnly) return "unknown";
58
+ if (record.closed === true) return "closed";
59
+ if (record.result !== undefined) return "done";
60
+ if (record.gone === true) return "gone";
61
+ if (window === "open") return "running";
62
+ if (window === "gone") return "window-closed";
63
+ return "unknown";
64
+ }
@@ -0,0 +1,47 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ /** Local presentation signals only; neither observers nor count visibility own task state. */
4
+ export class ResultsPresentation {
5
+ private readonly listeners = new Set<() => void>();
6
+ private readonly icon: string = Math.random() < 0.5 ? "👨" : "👩";
7
+ private visible = true;
8
+ private shown = false;
9
+ private ctx: ExtensionContext | undefined;
10
+ private count = 0;
11
+
12
+ subscribe = (listener: () => void): (() => void) => {
13
+ this.listeners.add(listener);
14
+ return () => {
15
+ this.listeners.delete(listener);
16
+ };
17
+ };
18
+
19
+ setCountVisible = (visible: boolean): void => {
20
+ this.visible = visible;
21
+ this.footer();
22
+ };
23
+
24
+ update(ctx: ExtensionContext | undefined, count: number): void {
25
+ this.ctx = ctx;
26
+ this.count = count;
27
+ this.footer();
28
+ for (const listener of this.listeners) {
29
+ try {
30
+ listener();
31
+ } catch {
32
+ /* An optional view cannot interrupt task/result processing. */
33
+ }
34
+ }
35
+ }
36
+
37
+ private footer(): void {
38
+ if (this.ctx?.hasUI !== true) return;
39
+ const visible: boolean = this.visible && this.count > 0;
40
+ if (!visible && !this.shown) return;
41
+ this.shown = visible;
42
+ this.ctx.ui.setStatus(
43
+ "delegate",
44
+ visible ? `${this.icon} ${this.count} delegate${this.count === 1 ? "" : "s"} running` : undefined,
45
+ );
46
+ }
47
+ }
package/src/results.ts CHANGED
@@ -2,21 +2,27 @@
2
2
  // the parent session when it starts, and again when it is closed; when the
3
3
  // provider claims a reply that answers a recorded task, this listener takes
4
4
  // the message over, shows the parent one delegate result and records the
5
- // result. The records are the one source of truth for a delegation's state,
6
- // for the footer, `delegation_status` and `delegation_close`.
5
+ // result. Local records own result processing; the shared tree registry owns
6
+ // cross-process admission accounting.
7
7
 
8
8
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
9
9
 
10
+ import { ChildTasks } from "./child-tasks.ts";
11
+ import type { Identity } from "./identity.ts";
10
12
  import { cwdLine, modelLabel, renderMessage, workspaceLine } from "./render.ts";
13
+ import { closeOutcome } from "./result-close.ts";
11
14
  import { resultDetails, resultText, resumeDetails } from "./result-message.ts";
15
+ import { restoreRecords } from "./result-restore.ts";
12
16
  import { finishedModel, locateSession, sessionIdOf } from "./result-sessions.ts";
13
17
  import {
14
18
  type AttemptState,
15
19
  isStarted,
16
20
  type Recorded,
21
+ recordedName,
17
22
  type StartedRecorded,
18
23
  type WorkspaceAttempt,
19
24
  } from "./result-types.ts";
25
+ import { ResultsPresentation } from "./results-presentation.ts";
20
26
  import { inspectWorkspace, type WorkspaceSnapshot } from "./snapshot.ts";
21
27
  import type { Workspace } from "./workspace.ts";
22
28
 
@@ -33,6 +39,10 @@ const INBOUND = "message:inbound";
33
39
 
34
40
  /** One delegation, as recorded and rebuilt on resume. It holds no task text. */
35
41
  export type Delegation = {
42
+ /** Read-only registry projection with no authoritative launch/result record. Never local tracking. */
43
+ accountingOnly?: boolean;
44
+ /** Durable launch identity; older single-level records may lack it. */
45
+ identity?: Identity;
36
46
  id: string;
37
47
  agent: string;
38
48
  /** The agent's `display_name` at launch, shown instead of `agent`; absent when it had none. */
@@ -129,7 +139,7 @@ export type MessageDetails =
129
139
  /** The checkout when the result arrived, the same value as the recorded result's; absent in older messages. */
130
140
  workspaceSnapshot?: WorkspaceSnapshot;
131
141
  })
132
- | (CommonDetails & { kind: "gone"; windowName: string });
142
+ | (CommonDetails & { kind: "gone"; windowName: string; reason?: "explicit-close" });
133
143
 
134
144
  /** The provider's payload on `message:inbound`. */
135
145
  export type Inbound = {
@@ -140,6 +150,10 @@ export type Inbound = {
140
150
  };
141
151
 
142
152
  export type Results = {
153
+ /** Presentation-only observers; never part of task accounting or the completion barrier. */
154
+ subscribe(listener: () => void): () => void;
155
+ /** Keep the legacy count for non-TUI/cap-zero views without replacing any footer. */
156
+ setCountVisible(visible: boolean): void;
143
157
  /** Rebuild the records from the session's custom entries, as on a resume. */
144
158
  restore(ctx: ExtensionContext): void;
145
159
  /** Record a delegation and show it as running; it supersedes an attempt with the same id. */
@@ -148,6 +162,8 @@ export type Results = {
148
162
  recordAttempt(attempt: WorkspaceAttempt, ctx: ExtensionContext): void;
149
163
  /** Record a close; does nothing when there is no such delegation or it is already closed. */
150
164
  recordClose(id: string): void;
165
+ /** Import only confirmed own direct-child closes; no foreign semantic writes. */
166
+ syncCloses(): void;
151
167
  /** Delegations with no result, no recorded close and no gone-window record. */
152
168
  running(): StartedRecorded[];
153
169
  /** Record that the window is gone with no result, and tell the parent once. False when it no longer applies. */
@@ -156,6 +172,10 @@ export type Results = {
156
172
  list(): Recorded[];
157
173
  /** One delegation by id. */
158
174
  find(id: string): Recorded | undefined;
175
+ /** No unfinished direct-child task or unprocessed terminal outcome. */
176
+ completionReady(): boolean;
177
+ /** Refuse unhealthy local facts; optionally persist a preflight snapshot before launch effects. */
178
+ checkStorage(write?: boolean): void;
159
179
  };
160
180
 
161
181
  /** What tmux reported for a delegation's window: listed, not listed, or not askable. */
@@ -175,46 +195,47 @@ export type DelegationState =
175
195
  | "unknown"
176
196
  | AttemptState["phase"];
177
197
 
178
- /** How much of a delegation id a delegate's session name carries. */
179
- const SHORT_ID = 8;
198
+ export { delegateName, deriveState, recordedName } from "./result-types.ts";
180
199
 
181
- /**
182
- * A delegate's name, used for both its session and its window: its agent and
183
- * the label, or the first 8 characters of its id when there is no label.
184
- */
185
- export function delegateName(agent: string, id: string, label?: string): string {
186
- return `${agent}-${label ?? id.slice(0, SHORT_ID)}`;
187
- }
188
-
189
- /** The recorded name, or the pre-label name for records from before labels. */
190
- export function recordedName(record: Recorded): string {
191
- return record.name ?? delegateName(record.agent, record.id);
192
- }
193
-
194
- /** A delegation's state: a recorded close, then a result, then a gone record, then the window. */
195
- export function deriveState(record: Recorded, window: WindowState): DelegationState {
196
- if (!isStarted(record)) return record.attempt.phase;
197
- if (record.closed === true) return "closed";
198
- if (record.result !== undefined) return "done";
199
- if (record.gone === true) return "gone";
200
- if (window === "open") return "running";
201
- if (window === "gone") return "window-closed";
202
- return "unknown";
203
- }
204
-
205
- export function createResults(pi: ExtensionAPI): Results {
200
+ export function createResults(
201
+ pi: ExtensionAPI,
202
+ processesChildren: () => boolean = () => false,
203
+ publish: (ctx: ExtensionContext, records: Recorded[]) => void = () => {},
204
+ active: () => boolean = () => true,
205
+ closedChildren: (ctx: ExtensionContext) => string[] = () => [],
206
+ ): Results {
206
207
  pi.registerMessageRenderer(CUSTOM_TYPE, renderMessage);
207
208
  /** Every recorded delegation by its id, in the order recorded. */
208
209
  const byId = new Map<string, Recorded>();
210
+ const tasks = new ChildTasks();
211
+ const save = (): void => {
212
+ try {
213
+ tasks.save([...byId.values()]);
214
+ } catch (error) {
215
+ tasks.failed = true;
216
+ ctx?.ui.notify(
217
+ `delegate: could not persist child tasks: ${String(error)}; keep this session open, repair storage and run again. Completion is blocked.`,
218
+ "error",
219
+ );
220
+ throw error;
221
+ }
222
+ try {
223
+ if (ctx) publish(ctx, [...byId.values()]);
224
+ } catch (error) {
225
+ ctx?.ui.notify(
226
+ `delegate: could not publish shared accounting: ${String(error)}. Local child facts are saved; the next admission reconciles them before checking caps.`,
227
+ "warning",
228
+ );
229
+ }
230
+ };
231
+ let replay: ReturnType<typeof setImmediate> | undefined;
209
232
  /** Started records by their request's envelope id, for matching replies; attempts never match a reply. */
210
233
  const byRequest = new Map<string, StartedRecorded>();
211
234
  /** Reply envelope ids a result message has already been sent for. */
212
235
  const shown = new Set<string>();
213
236
  /** The latest session context, for the footer and the session directory. */
214
237
  let ctx: ExtensionContext | undefined;
215
- /** Whether the footer currently shows the entry, so a clear is sent only when needed. */
216
- let footerShown = false;
217
- const icon = Math.random() < 0.5 ? "👨" : "👩";
238
+ const presentation = new ResultsPresentation();
218
239
 
219
240
  /**
220
241
  * A delegation still going: no result, no recorded close, and not resumed.
@@ -242,64 +263,41 @@ export function createResults(pi: ExtensionAPI): Results {
242
263
 
243
264
  /** The footer counts the running delegations, and hides itself at zero. */
244
265
  function updateFooter(): void {
245
- if (ctx?.hasUI !== true) return;
246
- const count = running().length;
247
- const visible = count > 0;
248
- if (!visible && !footerShown) return;
249
- footerShown = visible;
250
- ctx.ui.setStatus(STATUS_KEY, visible ? `${icon} ${count} delegate${count === 1 ? "" : "s"} running` : undefined);
266
+ presentation.update(ctx, running().length);
251
267
  }
252
268
 
253
269
  /** Rebuild the records from the session's entries, so a resume still recognises replies. */
254
270
  function restore(context: ExtensionContext): void {
271
+ if (replay) clearImmediate(replay);
255
272
  ctx = context;
256
273
  byId.clear();
257
274
  byRequest.clear();
258
275
  shown.clear();
259
- for (const entry of context.sessionManager.getEntries()) {
260
- if (entry.type !== "custom" || entry.customType !== CUSTOM_TYPE) continue;
261
- const data = entry.data as
262
- | Partial<Delegation & { kind: string; result: Result; closed: boolean; gone: boolean }>
263
- | undefined;
264
- if (data === undefined || typeof data.id !== "string") continue;
265
- // An attempt may carry a real request id; it is checked before the legacy start rule below.
266
- if (data.kind === "workspace-attempt") {
267
- const existing = byId.get(data.id);
268
- if (existing === undefined || !isStarted(existing)) {
269
- byId.set(data.id, { ...(data as unknown as WorkspaceAttempt) });
270
- }
271
- } else if (typeof data.requestId === "string") {
272
- const record: StartedRecorded = { ...(data as Delegation) };
273
- byId.set(record.id, record);
276
+ for (const record of restoreRecords(context, tasks)) {
277
+ byId.set(record.id, record);
278
+ if (isStarted(record)) {
274
279
  byRequest.set(record.requestId, record);
275
- } else if (typeof data.result?.replyId === "string") {
276
- shown.add(data.result.replyId);
277
- const record = startedById(data.id);
278
- if (record !== undefined) record.result = data.result;
279
- } else if (data.closed === true) {
280
- const record = startedById(data.id);
281
- if (record !== undefined) record.closed = true;
282
- } else if (data.gone === true) {
283
- const record = startedById(data.id);
284
- if (record === undefined) continue;
285
- record.gone = true;
286
- if (typeof data.sessionPath === "string") record.sessionPath = data.sessionPath;
287
- }
288
- }
289
- // A parent that stopped mid-launch cannot say how far it got; nothing is cleaned up or relaunched.
290
- for (const record of byId.values()) {
291
- if (isStarted(record)) continue;
292
- const { phase, resources } = record.attempt;
293
- if (phase === "planned" || phase === "created") {
294
- record.attempt = {
295
- phase: "interrupted",
296
- resources,
297
- lastPhase: phase,
298
- reason: `the parent session ended while the launch was ${phase}; whether anything was created is unknown`,
299
- };
280
+ if (record.result) shown.add(record.result.replyId);
300
281
  }
301
282
  }
283
+ if (tasks.storageProblem())
284
+ context.ui.notify(
285
+ `delegate: could not restore child tasks: ${tasks.storageProblem()}; task completion is blocked.`,
286
+ "error",
287
+ );
288
+ syncCloses(false);
302
289
  updateFooter();
290
+ // Mail is already claimed; replay only outcomes not processed by a
291
+ // settled coordinator run. No live worker is recreated.
292
+ if (!tasks.failed && tasks.outcomes.length) {
293
+ const session = context.sessionManager.getSessionId();
294
+ replay = setImmediate(() => {
295
+ replay = undefined;
296
+ if (!active() || ctx?.sessionManager.getSessionId() !== session) return;
297
+ tasks.replay(pi, processesChildren());
298
+ save();
299
+ });
300
+ }
303
301
  }
304
302
 
305
303
  function recordStart(delegation: Delegation, context: ExtensionContext): void {
@@ -308,6 +306,7 @@ export function createResults(pi: ExtensionAPI): Results {
308
306
  const record: StartedRecorded = { ...delegation };
309
307
  byId.set(record.id, record);
310
308
  byRequest.set(record.requestId, record);
309
+ save();
311
310
  updateFooter();
312
311
  }
313
312
 
@@ -315,16 +314,43 @@ export function createResults(pi: ExtensionAPI): Results {
315
314
  ctx = context;
316
315
  pi.appendEntry(CUSTOM_TYPE, attempt);
317
316
  byId.set(attempt.id, { ...attempt });
317
+ save();
318
318
  }
319
319
 
320
- function recordClose(id: string): void {
320
+ function recordClose(id: string, wake = true): void {
321
321
  const record = startedById(id);
322
322
  if (record === undefined || record.closed === true) return;
323
- pi.appendEntry(CUSTOM_TYPE, { id, closed: true });
324
323
  record.closed = true;
324
+ const outcome = record.result || record.gone ? undefined : closeOutcome(record, ctx);
325
+ if (outcome) {
326
+ tasks.outcomes.push(outcome);
327
+ if (wake) tasks.paused = false;
328
+ }
329
+ save(); // Close and its processing obligation persist together, before transcript/display effects.
330
+ pi.appendEntry(CUSTOM_TYPE, { id, closed: true });
331
+ if (outcome && wake) {
332
+ pi.sendMessage(
333
+ { customType: CUSTOM_TYPE, content: outcome.content, details: outcome.details, display: true },
334
+ { triggerTurn: true, deliverAs: processesChildren() ? "steer" : "followUp" },
335
+ );
336
+ tasks.delivered(outcome.key);
337
+ save();
338
+ }
325
339
  updateFooter();
326
340
  }
327
341
 
342
+ function syncCloses(wake = true): void {
343
+ if (!ctx || !active() || tasks.failed) return;
344
+ try {
345
+ for (const id of closedChildren(ctx)) recordClose(id, wake);
346
+ } catch (error) {
347
+ ctx.ui.notify(
348
+ `delegate: confirmed close import unavailable: ${String(error)}; pending children remain unresolved.`,
349
+ "warning",
350
+ );
351
+ }
352
+ }
353
+
328
354
  function recordGone(id: string): boolean {
329
355
  const record = startedById(id);
330
356
  if (record === undefined || record.result !== undefined || record.closed === true || record.gone === true)
@@ -348,21 +374,22 @@ export function createResults(pi: ExtensionAPI): Results {
348
374
  ...(record.workspace === undefined ? {} : { workspace: record.workspace }),
349
375
  windowName: record.windowName,
350
376
  };
377
+ const content = [
378
+ `[delegate] Delegation ${record.id}${record.resumedFrom === undefined ? "" : ` (resumed from ${record.resumedFrom})`} (${record.agent}, ${modelLabel(record.model, finished)}), request ${record.requestId}, closed without a result: its window ${record.windowName} is gone and no reply arrived.`,
379
+ `Working directory: ${cwdLine(record.cwd)}`,
380
+ ...(record.workspace === undefined ? [] : [`Workspace: ${workspaceLine(record.workspace)}`]),
381
+ `Delegate session: ${session ?? sessionIdOf(record)}`,
382
+ "A reply that arrives later is still delivered. To continue the delegate's session in a new window, call delegation_resume with this delegation's id.",
383
+ ].join("\n");
384
+ tasks.paused = false; // A fresh outcome is new work, not replay of the stopped turn.
385
+ tasks.outcomes.push({ key: `gone:${record.id}`, content, details });
386
+ save();
351
387
  pi.sendMessage(
352
- {
353
- customType: CUSTOM_TYPE,
354
- details,
355
- content: [
356
- `[delegate] Delegation ${record.id}${record.resumedFrom === undefined ? "" : ` (resumed from ${record.resumedFrom})`} (${record.agent}, ${modelLabel(record.model, finished)}), request ${record.requestId}, closed without a result: its window ${record.windowName} is gone and no reply arrived.`,
357
- `Working directory: ${cwdLine(record.cwd)}`,
358
- ...(record.workspace === undefined ? [] : [`Workspace: ${workspaceLine(record.workspace)}`]),
359
- `Delegate session: ${session ?? sessionIdOf(record)}`,
360
- "A reply that arrives later is still delivered. To continue the delegate's session in a new window, call delegation_resume with this delegation's id.",
361
- ].join("\n"),
362
- display: true,
363
- },
364
- { triggerTurn: true, deliverAs: "followUp" },
388
+ { customType: CUSTOM_TYPE, details, content, display: true },
389
+ { triggerTurn: true, deliverAs: processesChildren() ? "steer" : "followUp" },
365
390
  );
391
+ tasks.delivered(`gone:${record.id}`);
392
+ save();
366
393
  updateFooter();
367
394
  return true;
368
395
  }
@@ -372,25 +399,22 @@ export function createResults(pi: ExtensionAPI): Results {
372
399
  const reply = payload?.envelope;
373
400
  if (reply === undefined || !Array.isArray(reply.in_reply_to) || reply.in_reply_to.length === 0) return;
374
401
  // Only the delegate's own reply counts: `from` must be the delegation's session id.
375
- const requestId = reply.in_reply_to.find((id) => {
376
- const record = byRequest.get(id);
377
- return record !== undefined && sessionIdOf(record) === reply.from;
378
- });
379
- if (requestId === undefined) return;
402
+ const matches = reply.in_reply_to
403
+ .map((id) => byRequest.get(id))
404
+ .filter((record): record is StartedRecorded => record !== undefined && sessionIdOf(record) === reply.from);
405
+ if (matches.length === 0) return;
380
406
  payload.handled = true;
381
- if (shown.has(reply.id)) return;
382
- const delegation = byRequest.get(requestId) as StartedRecorded;
407
+ // Transport retries have fresh envelope ids. Correlate on every task id,
408
+ // not just the envelope, and never present an already answered task again.
409
+ const pending = matches.filter((record) => record.result === undefined);
410
+ if (shown.has(reply.id) || pending.length === 0) return;
411
+ const delegation = pending.at(-1) as StartedRecorded;
383
412
  shown.add(reply.id);
384
413
  const session = locateSession(delegation, ctx?.sessionManager.getSessionDir());
385
414
  const finished = finishedModel(session);
386
415
  // After the claim above, so a slow or failing inspection cannot let the provider or a duplicate deliver it too.
387
416
  const snapshot = inspectWorkspace(delegation.workspace?.cwd, delegation.workspace?.git);
388
417
  const details = resultDetails(delegation, recordedName(delegation), payload, session, finished, snapshot);
389
- // Steer, not follow-up: a busy parent gets the result before its next model call, not after it has answered.
390
- pi.sendMessage(
391
- { customType: CUSTOM_TYPE, content: resultText(details), details, display: true },
392
- { triggerTurn: true, deliverAs: "steer" },
393
- );
394
418
  const result: Result = {
395
419
  status: reply.status,
396
420
  replyId: reply.id,
@@ -399,21 +423,63 @@ export function createResults(pi: ExtensionAPI): Results {
399
423
  ...(finished === undefined ? {} : { finishedModel: finished }),
400
424
  workspaceSnapshot: snapshot,
401
425
  };
402
- pi.appendEntry(CUSTOM_TYPE, { id: delegation.id, result });
403
- delegation.result = result;
426
+ for (const record of pending) {
427
+ pi.appendEntry(CUSTOM_TYPE, { id: record.id, result });
428
+ record.result = result;
429
+ }
430
+ const content = resultText(details);
431
+ const answered = new Set(pending.map((record) => record.id));
432
+ tasks.outcomes = tasks.outcomes.filter(
433
+ (outcome) => outcome.details.kind !== "gone" || !answered.has(outcome.details.id),
434
+ );
435
+ tasks.paused = false; // Only old replay is held by a root stop, not a new live result.
436
+ tasks.outcomes.push({ key: reply.id, content, details });
437
+ save();
438
+ // Persist first, then steer. A crash before injection is recovered from
439
+ // the processing inbox, not from the provider's already-claimed mail.
440
+ pi.sendMessage(
441
+ { customType: CUSTOM_TYPE, content, details, display: true },
442
+ { triggerTurn: true, deliverAs: "steer" },
443
+ );
444
+ tasks.delivered(reply.id);
445
+ save();
404
446
  updateFooter();
405
447
  }
406
448
 
449
+ pi.on("context", () => syncCloses());
450
+ pi.on("agent_before_settle", () => syncCloses());
451
+ pi.on("agent_settled", () => syncCloses());
452
+ tasks.register(
453
+ pi,
454
+ () => [...byId.values()],
455
+ () => ctx,
456
+ processesChildren,
457
+ );
458
+
459
+ pi.on("session_shutdown", () => {
460
+ if (replay) clearImmediate(replay);
461
+ replay = undefined;
462
+ ctx = undefined;
463
+ });
407
464
  pi.events.on(INBOUND, (data) => takeReply(data as Inbound));
408
465
 
409
466
  return {
467
+ subscribe: presentation.subscribe,
468
+ setCountVisible: presentation.setCountVisible,
410
469
  restore,
411
470
  recordStart,
412
471
  recordAttempt,
413
472
  recordClose,
473
+ syncCloses,
414
474
  running,
415
475
  recordGone,
416
476
  list: () => [...byId.values()],
417
477
  find: (id) => byId.get(id),
478
+ completionReady: () => !tasks.failed && running().length === 0 && tasks.outcomes.length === 0,
479
+ checkStorage: (write = false) => {
480
+ const problem = tasks.storageProblem();
481
+ if (problem) throw new Error(problem);
482
+ if (write) save();
483
+ },
418
484
  };
419
485
  }