@ferris1225/pi-subagents 4.3.5 → 4.3.7

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.
@@ -107,7 +107,7 @@ export interface SubagentRuntime {
107
107
  * one-time session-start notice. */
108
108
  restoredRunIds: number[];
109
109
  restoredNotified: boolean;
110
- /** Deliver a batch of completion messages as a waking follow-up. */
110
+ /** Deliver a batch at the next safe parent turn boundary and wake an idle parent. */
111
111
  sendCompletionGroup: (items: CompletionMessageItem[]) => void;
112
112
  /** Claim the sole delivery route before a generation can settle. */
113
113
  claimRunDelivery: (runId: number, route: "background" | "await") => void;
@@ -189,10 +189,10 @@ export function createRuntime(pi: ExtensionAPI, configPath: string): SubagentRun
189
189
  content: formatCompletionMessage(items) + formatActiveRunsFooter(active),
190
190
  display: true,
191
191
  };
192
- // Follow-ups never interrupt an active parent lane. triggerTurn wakes an
193
- // idle parent immediately, while a streaming parent receives the result
194
- // only after its current tool/assistant lane settles.
195
- pi.sendMessage(message, { deliverAs: "followUp", triggerTurn: true });
192
+ // Steer delivers after the current assistant turn's tool calls and before
193
+ // the next model call. A follow-up would wait for the whole parent run to
194
+ // settle, allowing completions and stop results to arrive after its final reply.
195
+ pi.sendMessage(message, { deliverAs: "steer", triggerTurn: true });
196
196
  },
197
197
  claimRunDelivery: (runId, route) => {
198
198
  runDeliveries.set(runId, { route, immediate: false });
@@ -27,11 +27,12 @@ import {
27
27
  queuedResult,
28
28
  } from "../presentation/format.ts";
29
29
  import { monitor } from "../presentation/monitor.ts";
30
- import { findDuplicateActiveDispatch } from "../delegation/prompt.ts";
30
+ import { findDuplicateDispatch } from "../delegation/prompt.ts";
31
31
  import { persistRecoveryRecords, recoveryRecordFromFinalization } from "../isolation/recovery.ts";
32
32
  import type { SubagentRuntime, SubagentThread, ThreadState } from "./runtime.ts";
33
33
  import { forkRetainedSession } from "../execution/session-fork.ts";
34
34
  import {
35
+ buildAppendedObjectivePrompt,
35
36
  buildResumePrompt,
36
37
  getProjectRoot,
37
38
  RpcRunControl,
@@ -135,12 +136,21 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
135
136
 
136
137
  const originalCwd = resolve(cwd ?? runCtx.cwd);
137
138
  if (!existingThread) {
138
- const duplicate = findDuplicateActiveDispatch(runtime.threads.values(), task, originalCwd);
139
- if (duplicate) {
139
+ const duplicate = findDuplicateDispatch(runtime.threads.values(), task, originalCwd);
140
+ if (duplicate?.kind === "active") {
140
141
  return failedStartResult(
141
142
  agentName,
142
143
  task,
143
- `Duplicate active dispatch matches run #${duplicate.id} (${duplicate.agentName}). Use that logical thread instead; resume #${duplicate.id} when it is eligible.`,
144
+ `Duplicate active dispatch matches run #${duplicate.source.id} (${duplicate.source.agentName}). Use that logical thread instead; resume #${duplicate.source.id} when it is eligible.`,
145
+ );
146
+ }
147
+ if (duplicate?.kind === "settled") {
148
+ // The same brief on the same tree would re-buy work whose result main
149
+ // already holds; the retained session continues it for a fraction.
150
+ return failedStartResult(
151
+ agentName,
152
+ task,
153
+ `Run #${duplicate.source.id} (${duplicate.source.agentName}) already ${duplicate.source.state} this exact brief and kept its context; its result was delivered. Resume #${duplicate.source.id} with an appended objective instead of paying for a second run, or restate the brief with what changed.`,
144
154
  );
145
155
  }
146
156
  }
@@ -333,9 +343,9 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
333
343
  ? {
334
344
  sessionId: priorSessionId,
335
345
  sessionDir: priorSessionDir,
336
- stdinText: seed?.prompt ?? (appendedObjectiveOnResume
337
- ? task
338
- : buildResumePrompt(priorTask ?? task, "the retained thread was resumed")),
346
+ stdinText: appendedObjectiveOnResume
347
+ ? buildAppendedObjectivePrompt(priorTask ?? task, task)
348
+ : buildResumePrompt(priorTask ?? task, "the retained thread was resumed"),
339
349
  }
340
350
  : {}),
341
351
  },
@@ -375,17 +385,19 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
375
385
 
376
386
  const lifecycleInterrupted = (): boolean =>
377
387
  thread.lifecycleOperation === "stop" ||
388
+ thread.lifecycleOperation === "park" ||
378
389
  thread.state === "stopped";
379
- // Destructive stop owns publication once it has synchronously claimed
380
- // the lifecycle. Leave the partial result/session on the thread; the
381
- // stop path waits for this queue task, finalizes isolation, and emits
382
- // exactly one aborted result.
383
- if (thread.lifecycleOperation === "stop") return;
390
+ // Destructive stop and park own publication once they have
391
+ // synchronously claimed the lifecycle. Leave the partial result/session
392
+ // on the thread; stop waits for this queue task, finalizes isolation,
393
+ // and emits exactly one aborted result, while park records the
394
+ // checkpoint and answers through its own tool result.
395
+ if (lifecycleInterrupted()) return;
384
396
 
385
397
  // A shutdown can win in the microtask gap after the child RPC
386
398
  // settles. Never replace the stable top-level session with an
387
399
  // aborted partial.
388
- if (backgroundSignal.aborted || lifecycleInterrupted() || !runtime.sessionActive) return;
400
+ if (backgroundSignal.aborted || !runtime.sessionActive) return;
389
401
 
390
402
  if (thread.retireOnSettle) runtime.retireThreadSession(thread);
391
403
  // Claim terminal settlement synchronously before the first slow await.
@@ -471,9 +483,9 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
471
483
  () => {
472
484
  if (runtime.threads.get(runId)?.generation !== generation) return;
473
485
  // A destructive stop owns publication and may still be finalizing an
474
- // isolated worktree. Do not expose a terminal monitor state before
475
- // that owner records the aborted result.
476
- if (thread.lifecycleOperation === "stop") return;
486
+ // isolated worktree; a park owns the checkpoint. Do not expose a
487
+ // terminal monitor state before that owner records its outcome.
488
+ if (thread.lifecycleOperation === "stop" || thread.lifecycleOperation === "park") return;
477
489
  runtime.runControllers.delete(runId);
478
490
  thread.queueController = undefined;
479
491
  thread.state = "stopped";
@@ -487,9 +499,10 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
487
499
  async (error) => {
488
500
  if (runtime.threads.get(runId)?.generation !== generation) return;
489
501
  // Queue-level crashes use the same settlement reservation as ordinary
490
- // results. A concurrent destructive stop may supersede it while slow
491
- // worktree finalization is running, in which case stop publishes once.
492
- if (thread.lifecycleOperation === "stop") return;
502
+ // results. A concurrent destructive stop or park may supersede it while
503
+ // slow worktree finalization is running, in which case that owner
504
+ // publishes once.
505
+ if (thread.lifecycleOperation === "stop" || thread.lifecycleOperation === "park") return;
493
506
  const settlementVersion = ++thread.lifecycleVersion;
494
507
  thread.lifecycleOperation = "settle";
495
508
  const ownsSettlement = (): boolean =>
@@ -24,6 +24,7 @@ import {
24
24
  type SingleResult,
25
25
  } from "../execution/spawn.ts";
26
26
  import { isProcessAlive, killProcessTree, sweepProjectDurableDirs, sweepProjectTempDirs } from "../isolation/temp-hygiene.ts";
27
+ import { readRecoveryRecords, referencedRecoveryPaths } from "../isolation/recovery.ts";
27
28
  import {
28
29
  isPathInside,
29
30
  restoreWorktreeIsolation,
@@ -225,9 +226,11 @@ export function bootstrapDurableState(runtime: SubagentRuntime): Promise<void> {
225
226
  try {
226
227
  // Sessions and worktrees outlive their process on purpose, so only
227
228
  // ownership separates state a live pi still resumes from state a crash
228
- // abandoned. Parked work is claimed by the manifest and always kept.
229
+ // abandoned. Valid thread and recovery records always keep their paths.
229
230
  const records = await readThreadRecords(runtime.configPath);
231
+ const recoveryRecords = await readRecoveryRecords(runtime.configPath);
230
232
  const referenced = [...referencedDurablePaths(records)];
233
+ referenced.push(...await referencedRecoveryPaths(runtime.configPath, recoveryRecords));
231
234
  sweepProjectDurableDirs(projectRoots, {
232
235
  keep: (path) => referenced.some((claimed) => isPathInside(path, claimed)),
233
236
  });
@@ -184,7 +184,6 @@ export interface DispatchEnvironment {
184
184
  export interface SessionSeed {
185
185
  sessionId?: string;
186
186
  sessionDir?: string;
187
- prompt?: string;
188
187
  worktree?: WorktreeIsolation;
189
188
  }
190
189
 
@@ -1,9 +1,10 @@
1
1
  /**
2
- * Thread controls around the subagent runtime: subagent_control (resume) and
3
- * destructive subagent_stop. There is no status/poll tool — completions carry
4
- * each result (with an on-disk artifact when truncated) and wake the main
5
- * model, so waiting is never a tool call; the only in-turn block is `wait:
6
- * true` on a dispatch, for one-shot parents that exit at end of turn.
2
+ * Thread controls around the subagent runtime: subagent_control
3
+ * (steer/resume/park) and destructive subagent_stop. There is no status/poll
4
+ * tool — completions carry each result (with an on-disk artifact when
5
+ * truncated) and wake the main model, so waiting is never a tool call; the only
6
+ * in-turn block is `wait: true` on a dispatch, for one-shot parents that exit
7
+ * at end of turn.
7
8
  */
8
9
 
9
10
  import { StringEnum } from "@earendil-works/pi-ai";
@@ -15,10 +16,10 @@ import { DEFAULT_MAX_RESULT_LINES, loadConfig } from "../configuration/config.ts
15
16
  import { removeThreadRecord } from "./durable.ts";
16
17
  import { formatCompletionBlock, matchRunIds } from "../presentation/format.ts";
17
18
  import { emptyUsage } from "../execution/rpc-control.ts";
18
- import { formatTaskSummary, monitor } from "../presentation/monitor.ts";
19
+ import { formatTaskSummary, formatUsageCompact, monitor } from "../presentation/monitor.ts";
19
20
  import { persistRecoveryRecords, recoveryRecordFromFinalization } from "../isolation/recovery.ts";
20
21
  import type { SubagentRuntime, SubagentThread } from "./runtime.ts";
21
- import { CONTROL_QUIESCE_TIMEOUT_MS, projectResultsRoot, quiesced } from "./thread-shared.ts";
22
+ import { CONTROL_QUIESCE_TIMEOUT_MS, persistThreadCheckpoint, projectResultsRoot, quiesced } from "./thread-shared.ts";
22
23
  import { getResultOutput, type SingleResult } from "../execution/spawn.ts";
23
24
  import type { WorktreeFinalization } from "../isolation/worktree.ts";
24
25
 
@@ -34,19 +35,24 @@ function renderFirstLine(result: { content?: unknown }, label: string, theme: an
34
35
 
35
36
  export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime): void {
36
37
  const SubagentControlParams = Type.Object({
37
- action: StringEnum(["resume"] as const, {
38
- description: "Control operation for the logical sub-agent thread.",
38
+ action: StringEnum(["steer", "resume", "park"] as const, {
39
+ description:
40
+ "steer: send guidance to the running attempt (a settled or parked thread continues with it); resume: continue a parked or settled thread; park: pause a running thread at a stable checkpoint, keeping its session and worktree for a later resume.",
39
41
  }),
40
42
  id: Type.Integer({ minimum: 1, description: "Stable run id shown by subagent dispatch output." }),
41
43
  objective: Type.Optional(
42
- Type.String({ description: "Optional appended objective for resume. Omit to continue the current retained objective." }),
44
+ Type.String({ description: "Guidance for steer (required and nonblank), or an optional appended objective for resume. Ignored by park." }),
43
45
  ),
44
46
  });
45
47
 
48
+ /** A thread that a steer can continue instead of reject: it is not live, but
49
+ * its retained session can absorb the guidance as an appended objective. */
50
+ type ContinuableState = "completed" | "failed" | "parked";
51
+
46
52
  pi.registerTool({
47
53
  name: "subagent_control",
48
54
  label: "Subagent Control",
49
- description: "Resume a parked or settled child thread by run id, reusing its retained session when available. An optional objective is appended to its current goal.",
55
+ description: "Steer a running child with additional guidance (continuing it if it has settled or is parked), resume a parked/settled thread, or park a running thread at a stable checkpoint, by stable run id.",
50
56
  parameters: SubagentControlParams,
51
57
 
52
58
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
@@ -61,31 +67,152 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
61
67
  const trimmed = value?.trim();
62
68
  return trimmed ? trimmed : undefined;
63
69
  };
70
+ const textResult = (text: string) => ({ content: [{ type: "text" as const, text }], details: {} });
71
+ /** Why the thread has no steerable/parkable running RPC attempt right now. */
72
+ const inactiveReason = (): string | undefined => {
73
+ // Park drives the control through `stopped` before it records the
74
+ // checkpoint, so the operation is named ahead of the transient state.
75
+ if (thread.lifecycleOperation === "park") return "parking";
76
+ if (thread.lifecycleOperation === "stop" || thread.state === "stopped") return "stopped";
77
+ if (thread.lifecycleOperation === "resume") return "resuming";
78
+ if (thread.lifecycleOperation === "settle" || thread.state === "completed" || thread.state === "failed") {
79
+ return `settled (${thread.state})`;
80
+ }
81
+ if (thread.state === "queued" || thread.state === "resuming") {
82
+ const phase = thread.control.getPhase();
83
+ return phase === "starting" || phase === "retrying" ? phase : thread.state;
84
+ }
85
+ return thread.state === "running" ? undefined : thread.state;
86
+ };
87
+ const resumeThread = async (
88
+ objective: string | undefined,
89
+ continuedFrom?: ContinuableState,
90
+ ) => {
91
+ if (thread.retired) {
92
+ return textResult(`Run #${thread.id} was retired by subagent_stop and has no resumable session.`);
93
+ }
94
+ if (
95
+ thread.state !== "parked" &&
96
+ thread.state !== "completed" &&
97
+ thread.state !== "failed"
98
+ ) {
99
+ return textResult(`Run #${thread.id} is ${thread.state}; it must be parked or settled before resume.`);
100
+ }
101
+ const requestedObjective = objective === undefined ? undefined : nonBlank(objective);
102
+ if (objective !== undefined && !requestedObjective) {
103
+ return textResult("resume objective must be non-blank when provided.");
104
+ }
105
+ const hadRetainedSession = Boolean(thread.sessionId && thread.sessionDir);
106
+ const pending = await thread.resume(requestedObjective, ctx);
107
+ if (pending.exitCode !== -1) return textResult(getResultOutput(pending));
108
+ const currentObjective = formatTaskSummary(requestedObjective ?? thread.task, 80, false);
109
+ const mode = requestedObjective
110
+ ? `appended objective: ${currentObjective}`
111
+ : `continuing current objective: ${currentObjective}`;
112
+ const context = hadRetainedSession ? "retained context reused" : "no prior child context";
113
+ const prefix = continuedFrom
114
+ ? `Run #${thread.id} was already ${continuedFrom} before steering; resumed the same thread`
115
+ : `Resumed run #${thread.id}`;
116
+ return textResult(`${prefix}: ${mode}; ${context}.`);
117
+ };
118
+ /** Steering guidance for a thread that is no longer live continues the
119
+ * same thread with that guidance instead of being dropped, so the
120
+ * evidence is never re-bought by a second dispatch. */
121
+ const continueSteer = async (objective: string) => {
122
+ const continuable = (): ContinuableState | undefined =>
123
+ thread.state === "completed" || thread.state === "failed" || thread.state === "parked"
124
+ ? thread.state
125
+ : undefined;
126
+ if (thread.lifecycleOperation === "settle" || (!continuable() && thread.control.getPhase() === "settled")) {
127
+ if (!(await quiesced(thread.generationCompletion))) return undefined;
128
+ }
129
+ const state = continuable();
130
+ if (!state || thread.lifecycleOperation) return undefined;
131
+ return resumeThread(objective, state);
132
+ };
64
133
 
65
134
  try {
66
135
  switch (params.action) {
136
+ case "steer": {
137
+ const objective = nonBlank(params.objective);
138
+ if (!objective) {
139
+ return textResult("steer objective must be non-blank.");
140
+ }
141
+ if (thread.retired) {
142
+ return textResult(`Run #${thread.id} was retired by subagent_stop and cannot be steered.`);
143
+ }
144
+ const continued = await continueSteer(objective);
145
+ if (continued) return continued;
146
+ const unavailable = inactiveReason();
147
+ if (unavailable) {
148
+ return textResult(`Run #${thread.id} is ${unavailable}; only an active running RPC attempt can be steered. No guidance was sent.`);
149
+ }
150
+ const steered = await thread.control.steer(objective);
151
+ if (!steered.accepted) {
152
+ const resumed = await continueSteer(objective);
153
+ if (resumed) return resumed;
154
+ if (steered.reason === "no-active-attempt") {
155
+ return textResult(`Run #${thread.id} is marked running but has no active RPC attempt; no guidance was sent.`);
156
+ }
157
+ return textResult(`Run #${thread.id} is ${steered.phase}; only an active running RPC attempt can be steered. No guidance was sent.`);
158
+ }
159
+ return textResult(`Steered run #${thread.id} with additional in-scope guidance; its original objective is unchanged.`);
160
+ }
67
161
  case "resume": {
162
+ return resumeThread(params.objective);
163
+ }
164
+ case "park": {
68
165
  if (thread.retired) {
69
- return { content: [{ type: "text", text: `Run #${thread.id} was retired by subagent_stop and has no resumable session.` }], details: {} };
166
+ return textResult(`Run #${thread.id} was retired by subagent_stop and cannot be parked.`);
70
167
  }
71
- if (!(["parked", "completed", "failed"] as const).includes(thread.state as any)) {
72
- return { content: [{ type: "text", text: `Run #${thread.id} is ${thread.state}; it must be parked or settled before resume.` }], details: {} };
168
+ const unavailable = inactiveReason();
169
+ if (unavailable) {
170
+ return textResult(`Run #${thread.id} is ${unavailable}; only an active running RPC attempt can be parked. Use subagent_stop to discard a run that has not started.`);
73
171
  }
74
- const objective = params.objective === undefined ? undefined : nonBlank(params.objective);
75
- if (params.objective !== undefined && !objective) {
76
- return { content: [{ type: "text", text: "resume objective must be non-blank when provided." }], details: {} };
172
+ if (!thread.sessionId || !thread.sessionDir) {
173
+ return textResult(`Run #${thread.id} has no retained session yet; steer it or let it settle instead.`);
77
174
  }
78
- const hadRetainedSession = Boolean(thread.sessionId && thread.sessionDir);
79
- const pending = await thread.resume(objective, ctx);
80
- if (pending.exitCode !== -1) {
81
- return { content: [{ type: "text", text: getResultOutput(pending) }], details: {} };
175
+ // Park interrupts the child at its next safe point but keeps the
176
+ // session and worktree, so the thread returns to `parked`, not to a
177
+ // failure. Claim synchronously like stop; the generation body sees
178
+ // the claim and leaves publication to this path.
179
+ const parkVersion = ++thread.lifecycleVersion;
180
+ thread.lifecycleOperation = "park";
181
+ const generation = thread.generation;
182
+ const controller = thread.queueController;
183
+ const completion = thread.generationCompletion;
184
+ const ownsPark = (): boolean =>
185
+ runtime.threads.get(thread.id) === thread &&
186
+ thread.generation === generation &&
187
+ thread.lifecycleVersion === parkVersion &&
188
+ thread.lifecycleOperation === "park" &&
189
+ !thread.retired;
190
+ try {
191
+ await quiesced(thread.control.stop("Parked by subagent_control at a stable checkpoint.").catch(() => undefined));
192
+ if (!(await quiesced(completion))) runtime.backgroundQueue.cancel(controller);
193
+ if (!ownsPark()) {
194
+ return textResult(`Run #${thread.id} changed while it was being parked; no checkpoint was recorded by this call.`);
195
+ }
196
+ if (runtime.runControllers.get(thread.id) === controller) runtime.runControllers.delete(thread.id);
197
+ if (thread.queueController === controller) thread.queueController = undefined;
198
+ thread.state = "parked";
199
+ monitor.setStatus(thread.id, "parked");
200
+ thread.elapsedMs = monitor.getElapsedMs(thread.id) ?? thread.elapsedMs;
201
+ persistThreadCheckpoint(runtime, thread, "parked");
202
+ const run = monitor.findRun(thread.id);
203
+ const usage = run ? formatUsageCompact(run.usage) : "";
204
+ if (runtime.sessionActive) {
205
+ ctx.ui.notify(`■ #${thread.id} ${run ? monitor.summarize(run) : thread.agentName} · parked`, "info");
206
+ }
207
+ const retained = thread.isolation === "worktree" ? "session and worktree" : "session";
208
+ return textResult(
209
+ `Parked run #${thread.id} (${thread.agentName}) at a stable checkpoint${usage ? ` after ${usage}` : ""}. Its retained ${retained} continue on subagent_control resume (optionally with an appended objective) or on steer; subagent_stop discards them.`,
210
+ );
211
+ } finally {
212
+ if (thread.lifecycleVersion === parkVersion && thread.lifecycleOperation === "park") {
213
+ thread.lifecycleOperation = undefined;
214
+ }
82
215
  }
83
- const currentObjective = formatTaskSummary(objective ?? thread.task, 80, false);
84
- const mode = objective
85
- ? `appended objective: ${currentObjective}`
86
- : `continuing current objective: ${currentObjective}`;
87
- const context = hadRetainedSession ? "retained context reused" : "no prior child context";
88
- return { content: [{ type: "text", text: `Resumed run #${thread.id}: ${mode}; ${context}.` }], details: {} };
89
216
  }
90
217
  }
91
218
  } catch (error) {