pi-squire 0.14.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 (62) hide show
  1. package/CHANGELOG.md +438 -1
  2. package/README.md +1062 -152
  3. package/package.json +4 -3
  4. package/src/agents.ts +395 -124
  5. package/src/child-tasks.ts +221 -0
  6. package/src/child.ts +107 -62
  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 +585 -107
  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/new-delegate.ts +17 -8
  16. package/src/poll.ts +1 -0
  17. package/src/render.ts +4 -1
  18. package/src/result-close.ts +37 -0
  19. package/src/result-restore.ts +95 -0
  20. package/src/result-sessions.ts +39 -0
  21. package/src/result-types.ts +29 -1
  22. package/src/results-presentation.ts +47 -0
  23. package/src/results.ts +176 -108
  24. package/src/resume-checks.ts +59 -14
  25. package/src/resume-picker.ts +9 -2
  26. package/src/resume.ts +69 -15
  27. package/src/session-owner.ts +279 -0
  28. package/src/task-replies.ts +247 -0
  29. package/src/task-settlement.ts +81 -0
  30. package/src/task-store.ts +26 -0
  31. package/src/tmux.ts +134 -54
  32. package/src/tool-ceiling.ts +125 -0
  33. package/src/tracking.ts +72 -65
  34. package/src/tree-close.ts +236 -0
  35. package/src/tree-fence.ts +51 -0
  36. package/src/tree-lock.ts +77 -0
  37. package/src/tree-notice.ts +124 -0
  38. package/src/tree-registry.ts +365 -0
  39. package/src/tree-settings.ts +31 -0
  40. package/src/tree-types.ts +32 -0
  41. package/src/tree-view.ts +246 -0
  42. package/src/trust-control.ts +214 -0
  43. package/src/trust-prompt.ts +163 -0
  44. package/src/trust-wire.ts +203 -0
  45. package/src/widget-activity-join.ts +24 -0
  46. package/src/widget-activity-runtime.ts +104 -0
  47. package/src/widget-activity.ts +104 -0
  48. package/src/widget-ancestors.ts +142 -0
  49. package/src/widget-current.ts +41 -0
  50. package/src/widget-data.ts +147 -0
  51. package/src/widget-focus.ts +133 -0
  52. package/src/widget-live-process.ts +31 -0
  53. package/src/widget-live.ts +356 -0
  54. package/src/widget-render.ts +181 -0
  55. package/src/widget-selection.ts +58 -0
  56. package/src/widget-settings.ts +29 -0
  57. package/src/widget-summary.ts +51 -0
  58. package/src/widget-title-file.ts +127 -0
  59. package/src/widget-title.ts +118 -0
  60. package/src/widget.ts +272 -0
  61. package/src/window-placement.ts +30 -5
  62. package/src/workspace.ts +38 -90
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,8 +39,14 @@ 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;
48
+ /** The agent's `display_name` at launch, shown instead of `agent`; absent when it had none. */
49
+ displayName?: string;
38
50
  model: string;
39
51
  thinking: string;
40
52
  /** The delegate's session and window name; absent in records from before labels, where it was `<agent>-<8 id characters>`. */
@@ -127,7 +139,7 @@ export type MessageDetails =
127
139
  /** The checkout when the result arrived, the same value as the recorded result's; absent in older messages. */
128
140
  workspaceSnapshot?: WorkspaceSnapshot;
129
141
  })
130
- | (CommonDetails & { kind: "gone"; windowName: string });
142
+ | (CommonDetails & { kind: "gone"; windowName: string; reason?: "explicit-close" });
131
143
 
132
144
  /** The provider's payload on `message:inbound`. */
133
145
  export type Inbound = {
@@ -138,6 +150,10 @@ export type Inbound = {
138
150
  };
139
151
 
140
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;
141
157
  /** Rebuild the records from the session's custom entries, as on a resume. */
142
158
  restore(ctx: ExtensionContext): void;
143
159
  /** Record a delegation and show it as running; it supersedes an attempt with the same id. */
@@ -146,6 +162,8 @@ export type Results = {
146
162
  recordAttempt(attempt: WorkspaceAttempt, ctx: ExtensionContext): void;
147
163
  /** Record a close; does nothing when there is no such delegation or it is already closed. */
148
164
  recordClose(id: string): void;
165
+ /** Import only confirmed own direct-child closes; no foreign semantic writes. */
166
+ syncCloses(): void;
149
167
  /** Delegations with no result, no recorded close and no gone-window record. */
150
168
  running(): StartedRecorded[];
151
169
  /** Record that the window is gone with no result, and tell the parent once. False when it no longer applies. */
@@ -154,6 +172,10 @@ export type Results = {
154
172
  list(): Recorded[];
155
173
  /** One delegation by id. */
156
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;
157
179
  };
158
180
 
159
181
  /** What tmux reported for a delegation's window: listed, not listed, or not askable. */
@@ -173,46 +195,47 @@ export type DelegationState =
173
195
  | "unknown"
174
196
  | AttemptState["phase"];
175
197
 
176
- /** How much of a delegation id a delegate's session name carries. */
177
- const SHORT_ID = 8;
198
+ export { delegateName, deriveState, recordedName } from "./result-types.ts";
178
199
 
179
- /**
180
- * A delegate's name, used for both its session and its window: its agent and
181
- * the label, or the first 8 characters of its id when there is no label.
182
- */
183
- export function delegateName(agent: string, id: string, label?: string): string {
184
- return `${agent}-${label ?? id.slice(0, SHORT_ID)}`;
185
- }
186
-
187
- /** The recorded name, or the pre-label name for records from before labels. */
188
- export function recordedName(record: Recorded): string {
189
- return record.name ?? delegateName(record.agent, record.id);
190
- }
191
-
192
- /** A delegation's state: a recorded close, then a result, then a gone record, then the window. */
193
- export function deriveState(record: Recorded, window: WindowState): DelegationState {
194
- if (!isStarted(record)) return record.attempt.phase;
195
- if (record.closed === true) return "closed";
196
- if (record.result !== undefined) return "done";
197
- if (record.gone === true) return "gone";
198
- if (window === "open") return "running";
199
- if (window === "gone") return "window-closed";
200
- return "unknown";
201
- }
202
-
203
- 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 {
204
207
  pi.registerMessageRenderer(CUSTOM_TYPE, renderMessage);
205
208
  /** Every recorded delegation by its id, in the order recorded. */
206
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;
207
232
  /** Started records by their request's envelope id, for matching replies; attempts never match a reply. */
208
233
  const byRequest = new Map<string, StartedRecorded>();
209
234
  /** Reply envelope ids a result message has already been sent for. */
210
235
  const shown = new Set<string>();
211
236
  /** The latest session context, for the footer and the session directory. */
212
237
  let ctx: ExtensionContext | undefined;
213
- /** Whether the footer currently shows the entry, so a clear is sent only when needed. */
214
- let footerShown = false;
215
- const icon = Math.random() < 0.5 ? "👨" : "👩";
238
+ const presentation = new ResultsPresentation();
216
239
 
217
240
  /**
218
241
  * A delegation still going: no result, no recorded close, and not resumed.
@@ -240,64 +263,41 @@ export function createResults(pi: ExtensionAPI): Results {
240
263
 
241
264
  /** The footer counts the running delegations, and hides itself at zero. */
242
265
  function updateFooter(): void {
243
- if (ctx?.hasUI !== true) return;
244
- const count = running().length;
245
- const visible = count > 0;
246
- if (!visible && !footerShown) return;
247
- footerShown = visible;
248
- ctx.ui.setStatus(STATUS_KEY, visible ? `${icon} ${count} delegate${count === 1 ? "" : "s"} running` : undefined);
266
+ presentation.update(ctx, running().length);
249
267
  }
250
268
 
251
269
  /** Rebuild the records from the session's entries, so a resume still recognises replies. */
252
270
  function restore(context: ExtensionContext): void {
271
+ if (replay) clearImmediate(replay);
253
272
  ctx = context;
254
273
  byId.clear();
255
274
  byRequest.clear();
256
275
  shown.clear();
257
- for (const entry of context.sessionManager.getEntries()) {
258
- if (entry.type !== "custom" || entry.customType !== CUSTOM_TYPE) continue;
259
- const data = entry.data as
260
- | Partial<Delegation & { kind: string; result: Result; closed: boolean; gone: boolean }>
261
- | undefined;
262
- if (data === undefined || typeof data.id !== "string") continue;
263
- // An attempt may carry a real request id; it is checked before the legacy start rule below.
264
- if (data.kind === "workspace-attempt") {
265
- const existing = byId.get(data.id);
266
- if (existing === undefined || !isStarted(existing)) {
267
- byId.set(data.id, { ...(data as unknown as WorkspaceAttempt) });
268
- }
269
- } else if (typeof data.requestId === "string") {
270
- const record: StartedRecorded = { ...(data as Delegation) };
271
- byId.set(record.id, record);
276
+ for (const record of restoreRecords(context, tasks)) {
277
+ byId.set(record.id, record);
278
+ if (isStarted(record)) {
272
279
  byRequest.set(record.requestId, record);
273
- } else if (typeof data.result?.replyId === "string") {
274
- shown.add(data.result.replyId);
275
- const record = startedById(data.id);
276
- if (record !== undefined) record.result = data.result;
277
- } else if (data.closed === true) {
278
- const record = startedById(data.id);
279
- if (record !== undefined) record.closed = true;
280
- } else if (data.gone === true) {
281
- const record = startedById(data.id);
282
- if (record === undefined) continue;
283
- record.gone = true;
284
- if (typeof data.sessionPath === "string") record.sessionPath = data.sessionPath;
285
- }
286
- }
287
- // A parent that stopped mid-launch cannot say how far it got; nothing is cleaned up or relaunched.
288
- for (const record of byId.values()) {
289
- if (isStarted(record)) continue;
290
- const { phase, resources } = record.attempt;
291
- if (phase === "planned" || phase === "created") {
292
- record.attempt = {
293
- phase: "interrupted",
294
- resources,
295
- lastPhase: phase,
296
- reason: `the parent session ended while the launch was ${phase}; whether anything was created is unknown`,
297
- };
280
+ if (record.result) shown.add(record.result.replyId);
298
281
  }
299
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);
300
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
+ }
301
301
  }
302
302
 
303
303
  function recordStart(delegation: Delegation, context: ExtensionContext): void {
@@ -306,6 +306,7 @@ export function createResults(pi: ExtensionAPI): Results {
306
306
  const record: StartedRecorded = { ...delegation };
307
307
  byId.set(record.id, record);
308
308
  byRequest.set(record.requestId, record);
309
+ save();
309
310
  updateFooter();
310
311
  }
311
312
 
@@ -313,16 +314,43 @@ export function createResults(pi: ExtensionAPI): Results {
313
314
  ctx = context;
314
315
  pi.appendEntry(CUSTOM_TYPE, attempt);
315
316
  byId.set(attempt.id, { ...attempt });
317
+ save();
316
318
  }
317
319
 
318
- function recordClose(id: string): void {
320
+ function recordClose(id: string, wake = true): void {
319
321
  const record = startedById(id);
320
322
  if (record === undefined || record.closed === true) return;
321
- pi.appendEntry(CUSTOM_TYPE, { id, closed: true });
322
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
+ }
323
339
  updateFooter();
324
340
  }
325
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
+
326
354
  function recordGone(id: string): boolean {
327
355
  const record = startedById(id);
328
356
  if (record === undefined || record.result !== undefined || record.closed === true || record.gone === true)
@@ -346,21 +374,22 @@ export function createResults(pi: ExtensionAPI): Results {
346
374
  ...(record.workspace === undefined ? {} : { workspace: record.workspace }),
347
375
  windowName: record.windowName,
348
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();
349
387
  pi.sendMessage(
350
- {
351
- customType: CUSTOM_TYPE,
352
- details,
353
- content: [
354
- `[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.`,
355
- `Working directory: ${cwdLine(record.cwd)}`,
356
- ...(record.workspace === undefined ? [] : [`Workspace: ${workspaceLine(record.workspace)}`]),
357
- `Delegate session: ${session ?? sessionIdOf(record)}`,
358
- "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.",
359
- ].join("\n"),
360
- display: true,
361
- },
362
- { triggerTurn: true, deliverAs: "followUp" },
388
+ { customType: CUSTOM_TYPE, details, content, display: true },
389
+ { triggerTurn: true, deliverAs: processesChildren() ? "steer" : "followUp" },
363
390
  );
391
+ tasks.delivered(`gone:${record.id}`);
392
+ save();
364
393
  updateFooter();
365
394
  return true;
366
395
  }
@@ -370,25 +399,22 @@ export function createResults(pi: ExtensionAPI): Results {
370
399
  const reply = payload?.envelope;
371
400
  if (reply === undefined || !Array.isArray(reply.in_reply_to) || reply.in_reply_to.length === 0) return;
372
401
  // Only the delegate's own reply counts: `from` must be the delegation's session id.
373
- const requestId = reply.in_reply_to.find((id) => {
374
- const record = byRequest.get(id);
375
- return record !== undefined && sessionIdOf(record) === reply.from;
376
- });
377
- 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;
378
406
  payload.handled = true;
379
- if (shown.has(reply.id)) return;
380
- 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;
381
412
  shown.add(reply.id);
382
413
  const session = locateSession(delegation, ctx?.sessionManager.getSessionDir());
383
414
  const finished = finishedModel(session);
384
415
  // After the claim above, so a slow or failing inspection cannot let the provider or a duplicate deliver it too.
385
416
  const snapshot = inspectWorkspace(delegation.workspace?.cwd, delegation.workspace?.git);
386
417
  const details = resultDetails(delegation, recordedName(delegation), payload, session, finished, snapshot);
387
- // Steer, not follow-up: a busy parent gets the result before its next model call, not after it has answered.
388
- pi.sendMessage(
389
- { customType: CUSTOM_TYPE, content: resultText(details), details, display: true },
390
- { triggerTurn: true, deliverAs: "steer" },
391
- );
392
418
  const result: Result = {
393
419
  status: reply.status,
394
420
  replyId: reply.id,
@@ -397,21 +423,63 @@ export function createResults(pi: ExtensionAPI): Results {
397
423
  ...(finished === undefined ? {} : { finishedModel: finished }),
398
424
  workspaceSnapshot: snapshot,
399
425
  };
400
- pi.appendEntry(CUSTOM_TYPE, { id: delegation.id, result });
401
- 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();
402
446
  updateFooter();
403
447
  }
404
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
+ });
405
464
  pi.events.on(INBOUND, (data) => takeReply(data as Inbound));
406
465
 
407
466
  return {
467
+ subscribe: presentation.subscribe,
468
+ setCountVisible: presentation.setCountVisible,
408
469
  restore,
409
470
  recordStart,
410
471
  recordAttempt,
411
472
  recordClose,
473
+ syncCloses,
412
474
  running,
413
475
  recordGone,
414
476
  list: () => [...byId.values()],
415
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
+ },
416
484
  };
417
485
  }
@@ -3,17 +3,18 @@
3
3
  // prerequisites once, then per record the window, session file, agent, thinking
4
4
  // level and model. None of them changes anything; killing an exited window and
5
5
  // deciding trust stay with the resume itself.
6
+
6
7
  import { statSync } from "node:fs";
7
8
  import { isAbsolute } from "node:path";
8
-
9
9
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
10
-
11
- import type { Agent } from "./agents.ts";
12
- import { sessionHeaderCwd, sessionIdOf } from "./result-sessions.ts";
10
+ import { type Agent, disabledProblem, isThinking, withoutLocked } from "./agents.ts";
11
+ import { sessionHeaderCwd, sessionIdOf, sessionThinking } from "./result-sessions.ts";
13
12
  import { deriveState, finishedModel, locateSession, type StartedRecorded } from "./results.ts";
14
13
  import type { ResumeDeps, ResumeRequest } from "./resume.ts";
14
+ import { ownerDetails, sessionIsOwned } from "./session-owner.ts";
15
15
  import { requireTmuxVersion, type TmuxWindow } from "./tmux.ts";
16
16
  import { ownWindow, windowState } from "./tracking.ts";
17
+ import { assertResumeAuthority } from "./tree-view.ts";
17
18
  import type { WindowPlacement } from "./window-placement.ts";
18
19
 
19
20
  /**
@@ -79,10 +80,42 @@ export function sessionFor(record: StartedRecorded, ctx: ExtensionContext): Sess
79
80
  return { path, cwd, dir };
80
81
  }
81
82
 
82
- /** The model choices: an explicit model alone, else where the delegate ended, then the agent's fallbacks. */
83
- export function preferredModels(record: StartedRecorded, agent: Agent, sessionPath: string): string[] {
84
- const ended = finishedModel(sessionPath) ?? record.result?.finishedModel ?? record.model;
85
- return [...new Set([ended, ...agent.fallback])];
83
+ /**
84
+ * What a resume runs with when the call passes nothing. A field the agent
85
+ * locks takes the agent file's current value, or the parent's current one when
86
+ * the file sets none, even when the delegate last ran on another (a fallback
87
+ * after a usage limit, or a value from before the file changed). A field it
88
+ * does not lock keeps what the delegate last ran on: the model of its last
89
+ * answer, and the thinking level its session last changed to, or the
90
+ * recorded ones when the session does not say. The models are in order, the
91
+ * agent's fallbacks after the first.
92
+ */
93
+ export function resumeDefaults(
94
+ record: StartedRecorded,
95
+ agent: Agent,
96
+ sessionPath: string,
97
+ parent: { model?: string; thinking: string },
98
+ ): { models: string[]; thinking: string } {
99
+ const first = agent.locked.includes("model")
100
+ ? (agent.model ?? parent.model)
101
+ : (finishedModel(sessionPath) ?? record.result?.finishedModel ?? record.model);
102
+ const thinking = agent.locked.includes("thinking")
103
+ ? (agent.thinking ?? parent.thinking)
104
+ : lastThinking(record, sessionPath);
105
+ return { models: [...new Set([...(first === undefined ? [] : [first]), ...agent.fallback])], thinking };
106
+ }
107
+
108
+ /** The thinking level the delegate last ran on: its session's last change, else the level recorded at its start. */
109
+ function lastThinking(record: StartedRecorded, sessionPath: string): string {
110
+ const level = sessionThinking(sessionPath);
111
+ return level !== undefined && isThinking(level) ? level : record.thinking;
112
+ }
113
+
114
+ export function assertNoOwner(record: StartedRecorded): void {
115
+ if (sessionIsOwned(sessionIdOf(record)))
116
+ throw new Error(
117
+ `delegation ${record.id} is already open in another process: ${ownerDetails(sessionIdOf(record))}; send it a message instead of resuming it`,
118
+ );
86
119
  }
87
120
 
88
121
  /** Steps 5 and 6: the session file and the agent, and no resume of the delegation already under way. */
@@ -91,8 +124,15 @@ export function recordCheck(
91
124
  record: StartedRecorded,
92
125
  ctx: ExtensionContext,
93
126
  ): { session: SessionFile; agent: Agent } {
127
+ assertResumeAuthority(record, ctx);
128
+ assertNoOwner(record);
129
+ deps.authorize?.(record.id, ctx);
130
+ deps.admit?.(record.agent, ctx);
94
131
  const session = sessionFor(record, ctx);
95
- const agent = deps.roster(ctx).agents.find((item) => item.name === record.agent);
132
+ const roster = deps.roster(ctx);
133
+ const disabled = disabledProblem(roster, record.agent);
134
+ if (disabled !== undefined) throw new Error(`${disabled}, so delegation ${record.id} is not resumed`);
135
+ const agent = roster.agents.find((item) => item.name === record.agent);
96
136
  if (agent === undefined) {
97
137
  throw new Error(
98
138
  `agent ${JSON.stringify(record.agent)} is no longer in the roster, so delegation ${record.id} is not resumed`,
@@ -113,12 +153,17 @@ export async function settleModel(
113
153
  },
114
154
  ctx: ExtensionContext,
115
155
  ): Promise<{ thinking: string; chosen: Awaited<ReturnType<ResumeDeps["chooseModel"]>> }> {
116
- const { record, agent, session, request } = input;
117
- const thinking = request.thinking ?? record.thinking;
156
+ const { record, agent, session } = input;
157
+ const defaults = resumeDefaults(record, agent, session.path, deps.parentDefaults(ctx));
158
+ const { request, notice } = withoutLocked(agent, input.request, {
159
+ model: defaults.models[0],
160
+ thinking: defaults.thinking,
161
+ });
162
+ const thinking = request.thinking ?? defaults.thinking;
118
163
  const badThinking = deps.thinkingProblem(thinking);
119
164
  if (badThinking !== undefined) throw new Error(badThinking);
120
- const preferred = preferredModels(record, agent, session.path);
121
- return { thinking, chosen: await deps.chooseModel(request.model, agent, ctx.modelRegistry, preferred) };
165
+ const chosen = await deps.chooseModel(request.model, agent, ctx.modelRegistry, defaults.models);
166
+ return { thinking, chosen: notice === undefined ? chosen : { ...chosen, notices: [notice, ...chosen.notices] } };
122
167
  }
123
168
 
124
169
  /** The call-wide prerequisites of a resume: inside tmux, a new enough tmux, a valid placement setting (read once). */
@@ -143,7 +188,7 @@ export async function passes(
143
188
  windowCheck(record, windows);
144
189
  const { session, agent } = recordCheck(deps, record, ctx);
145
190
  // Model choice depends on the agent and where the delegate ended, so records alike share one answer.
146
- const key = JSON.stringify([record.thinking, agent.name, preferredModels(record, agent, session.path)]);
191
+ const key = JSON.stringify([agent.name, resumeDefaults(record, agent, session.path, deps.parentDefaults(ctx))]);
147
192
  let ok = models.get(key);
148
193
  if (ok === undefined) {
149
194
  ok = await settleModel(deps, { record, agent, session, request: {} }, ctx).then(
@@ -19,7 +19,7 @@ export type ResumePickerDeps = {
19
19
  const optionFor = (record: StartedRecorded): string =>
20
20
  `${recordedName(record)} · ${record.agent} · ${record.id.slice(0, 8)}`;
21
21
 
22
- export async function pickResume(ctx: ExtensionContext, deps: ResumePickerDeps): Promise<void> {
22
+ export async function pickResume(ctx: ExtensionContext, deps: ResumePickerDeps, id?: string): Promise<void> {
23
23
  const unavailable = deps.unavailable();
24
24
  if (unavailable !== undefined) {
25
25
  ctx.ui.notify(`delegate: ${unavailable}`, "error");
@@ -37,7 +37,14 @@ export async function pickResume(ctx: ExtensionContext, deps: ResumePickerDeps):
37
37
  return;
38
38
  }
39
39
  const options = records.map(optionFor);
40
- const picked = await ctx.ui.select("Resume which delegation?", options);
40
+ const picked =
41
+ id === undefined
42
+ ? await ctx.ui.select("Resume which delegation?", options)
43
+ : options[records.findIndex((record) => record.id === id)];
44
+ if (id !== undefined && picked === undefined) {
45
+ ctx.ui.notify("delegate: selected delegation cannot be resumed now", "warning");
46
+ return;
47
+ }
41
48
  const record = records[options.indexOf(picked ?? "")];
42
49
  if (record === undefined) return;
43
50
  const task = await ctx.ui.editor(`Follow-up for ${recordedName(record)}`);