@ferris1225/pi-subagents 4.3.9 → 4.3.11

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.
@@ -12,7 +12,6 @@ import {
12
12
  restoredResultFromSummary,
13
13
  type ThreadRecord,
14
14
  } from "./durable.ts";
15
- import { failedStartResult } from "../presentation/format.ts";
16
15
  import { monitor } from "../presentation/monitor.ts";
17
16
  import { emptyUsage } from "../execution/rpc-control.ts";
18
17
  import type { SubagentRuntime, SubagentThread, ThreadState } from "./runtime.ts";
@@ -33,8 +32,7 @@ import {
33
32
  } from "../isolation/worktree.ts";
34
33
  import { installThreadLifecycle } from "./thread-lifecycle.ts";
35
34
 
36
- /** Dropped restored record: no session means no context to resume, so its
37
- * artifacts go away with the record. */
35
+ /** Release managed artifacts belonging to a discarded, already-settled record. */
38
36
  async function discardRestoredRecord(runtime: SubagentRuntime, record: ThreadRecord): Promise<void> {
39
37
  if (record.sessionDir) {
40
38
  await rm(record.sessionDir, { recursive: true, force: true }).catch(() => undefined);
@@ -74,31 +72,15 @@ function createRestoredThread(
74
72
  sessionId: record.sessionId,
75
73
  sessionDir: record.sessionDir,
76
74
  lastResult: restoredResultFromSummary(record),
77
- resume: async () => failedStartResult(record.agentName, record.task, "Thread resume was not initialized."),
78
75
  finalizeIsolation: async () => undefined,
79
76
  };
80
- installThreadLifecycle(thread, {
81
- runtime,
82
- startBackground: (...args) => {
83
- const dispatcher = runtime.dispatcher;
84
- if (!dispatcher) {
85
- return Promise.resolve(failedStartResult(
86
- record.agentName,
87
- record.task,
88
- `Run #${record.runId} cannot continue: no dispatch context is available yet. Dispatch any subagent once, then retry.`,
89
- ));
90
- }
91
- return dispatcher(...args);
92
- },
93
- });
77
+ installThreadLifecycle(thread, { runtime });
94
78
  return thread;
95
79
  }
96
80
 
97
- /** Rebuild interrupted (parked) threads from the durable manifest after a
98
- * reload or restart. Orphaned children recorded by the previous process are
99
- * killed first; records whose retained session vanished and settled records
100
- * left by older versions, which hold no work worth resuming — drop out with
101
- * their artifacts. Returns the restored run ids. */
81
+ /** Rebuild interrupted records for manual recovery after reload. Orphaned children
82
+ * are stopped first; missing session files do not discard isolated edits. Already-
83
+ * settled records from older versions are removed with their managed artifacts. */
102
84
  export async function restoreDurableThreads(runtime: SubagentRuntime): Promise<number[]> {
103
85
  const records = await readThreadRecords(runtime.configPath);
104
86
  const restoredIds: number[] = [];
@@ -121,22 +103,18 @@ export async function restoreDurableThreads(runtime: SubagentRuntime): Promise<n
121
103
  record.sessionId !== undefined &&
122
104
  record.sessionDir !== undefined &&
123
105
  sessionExists(record.sessionDir, record.sessionId);
124
- if (!sessionValid) {
125
- await discardRestoredRecord(runtime, record);
126
- continue;
127
- }
106
+ const restoredRecord = sessionValid ? record : { ...record, sessionId: undefined, sessionDir: undefined };
128
107
  const worktree = record.worktree
129
108
  ? await restoreWorktreeIsolation(record.worktree).catch(() => undefined)
130
109
  : undefined;
131
110
  const restorationFailed = record.isolation === "worktree" && record.worktree !== undefined && !worktree;
132
- const thread = createRestoredThread(runtime, record, worktree, restorationFailed ? "failed" : "parked");
111
+ const thread = createRestoredThread(runtime, restoredRecord, worktree, restorationFailed ? "failed" : "parked");
133
112
  runtime.threads.set(record.runId, thread);
134
- runtime.sessionDirs.add(record.sessionDir!);
113
+ if (thread.sessionDir) runtime.sessionDirs.add(thread.sessionDir);
135
114
  if (restorationFailed) {
136
- const reason = `Run #${record.runId}'s recorded worktree could not be restored; isolated edits may be unavailable. The retained session and durable record were kept, but this thread cannot be resumed.`;
137
- thread.resumeUnavailableReason = reason;
115
+ const reason = `Run #${record.runId}'s recorded worktree could not be restored; isolated edits may be unavailable. The durable record and any remaining artifacts were kept for manual recovery by main.`;
138
116
  thread.restorationRecord = record;
139
- const previous = restoredResultFromSummary(record);
117
+ const previous = thread.lastResult;
140
118
  const failed: SingleResult = {
141
119
  agent: record.agentName,
142
120
  task: record.task,
@@ -149,8 +127,8 @@ export async function restoreDurableThreads(runtime: SubagentRuntime): Promise<n
149
127
  stopReason: "error",
150
128
  errorMessage: reason,
151
129
  dispatchFailed: true,
152
- sessionId: record.sessionId,
153
- sessionDir: record.sessionDir,
130
+ sessionId: thread.sessionId,
131
+ sessionDir: thread.sessionDir,
154
132
  projectCwd: record.cwd,
155
133
  runId: record.runId,
156
134
  isolation: "worktree",
@@ -228,7 +206,7 @@ export function bootstrapDurableState(runtime: SubagentRuntime): Promise<void> {
228
206
  }
229
207
  try {
230
208
  // Sessions and worktrees outlive their process on purpose, so only
231
- // ownership separates state a live pi still resumes from state a crash
209
+ // ownership separates a live Pi's state from artifacts a crash
232
210
  // abandoned. Valid thread and recovery records always keep their paths.
233
211
  const records = await readThreadRecords(runtime.configPath);
234
212
  const recoveryRecords = await readRecoveryRecords(runtime.configPath);
@@ -17,7 +17,7 @@ import {
17
17
  } from "../configuration/models.ts";
18
18
  import type { SubagentRuntime, SubagentThread } from "./runtime.ts";
19
19
  import { getProjectRoot, type SingleResult } from "../execution/spawn.ts";
20
- import { resolveRepositoryRoot, type IsolationMode, type WorktreeIsolation } from "../isolation/worktree.ts";
20
+ import { resolveRepositoryRoot, type IsolationMode } from "../isolation/worktree.ts";
21
21
 
22
22
  /** Control operations must never wait forever on a settling generation: the
23
23
  * queue task can legitimately spend minutes in worktree finalization (bounded
@@ -114,42 +114,8 @@ export async function runInManagedRepositoryLane<T>(
114
114
  }
115
115
  }
116
116
 
117
- /** Track resume setup that has claimed a thread but has not yet enqueued
118
- * its next generation. Shutdown invalidates these claims and waits for cleanup. */
119
- export function beginRuntimePreflight(runtime: SubagentRuntime): () => void {
120
- let resolvePreflight!: () => void;
121
- const preflight = new Promise<void>((resolve) => {
122
- resolvePreflight = resolve;
123
- });
124
- runtime.preflightOperations.add(preflight);
125
- return () => {
126
- runtime.preflightOperations.delete(preflight);
127
- resolvePreflight();
128
- };
129
- }
130
-
131
- /** Synchronous CAS used by lifecycle controls across their async preflight. */
132
- export function ownsResumeReservation(
133
- runtime: SubagentRuntime,
134
- thread: SubagentThread,
135
- reservation: { version: number; generation: number; sessionId?: string; sessionDir?: string },
136
- ): boolean {
137
- return (
138
- runtime.sessionActive &&
139
- runtime.threads.get(thread.id) === thread &&
140
- !thread.retired &&
141
- thread.lifecycleOperation === "resume" &&
142
- thread.lifecycleVersion === reservation.version &&
143
- thread.generation === reservation.generation &&
144
- thread.sessionId === reservation.sessionId &&
145
- thread.sessionDir === reservation.sessionDir
146
- );
147
- }
148
-
149
- /** Fire-and-forget durable checkpoint. Parked threads stay resumable across
150
- * reloads; a settled thread drops its record so the manifest only exists
151
- * while unfinished work needs it. The live session keeps working when the
152
- * manifest is unwritable; only cross-reload resume is degraded. */
117
+ /** Best-effort recovery checkpoint. Interrupted work retains its artifacts;
118
+ * settled work drops the thread record. A failed write never stops the live run. */
153
119
  export function persistThreadCheckpoint(
154
120
  runtime: SubagentRuntime,
155
121
  thread: SubagentThread,
@@ -183,33 +149,12 @@ export interface DispatchEnvironment {
183
149
  agents: AgentConfig[];
184
150
  }
185
151
 
186
- export interface SessionSeed {
187
- sessionId?: string;
188
- sessionDir?: string;
189
- worktree?: WorktreeIsolation;
190
- }
191
-
192
- export interface ResumeReservation {
193
- version: number;
194
- generation: number;
195
- sessionId?: string;
196
- sessionDir?: string;
197
- }
198
-
199
- /** Dispatcher's internal entry point. Public phase/scope claims are normalized
200
- * into options; resume adds lifecycle-only continuation fields there too. */
152
+ /** Admission metadata for a fresh one-shot run. */
201
153
  export interface StartBackgroundOptions {
202
- /** Normalized identity and claims for a fresh or resumed generation. */
203
154
  phaseId?: string;
204
155
  scope?: PhaseScope;
205
- /** Fresh-dispatch hint OR-merged with live capability; false cannot downgrade a writer. Resume stays monotonic. */
156
+ /** A false hint cannot downgrade a live write-capable role. */
206
157
  writeCapable?: boolean;
207
- /** Resume path only: the thread whose retained context continues. */
208
- existingThread?: SubagentThread;
209
- appendedObjectiveOnResume?: boolean;
210
- environment?: DispatchEnvironment;
211
- seed?: SessionSeed;
212
- resumeReservation?: ResumeReservation;
213
158
  /** Chosen by the tool call before the queue can start a fast child. */
214
159
  deliveryRoute?: "background" | "await";
215
160
  }
@@ -224,12 +169,7 @@ export type StartBackgroundInternal = (
224
169
 
225
170
  export interface ThreadLifecycleDeps {
226
171
  runtime: SubagentRuntime;
227
- /** Fallback context when a control caller supplies none; restored threads
228
- * install without one and rely on the per-call context. */
229
172
  runCtx?: ExtensionContext;
230
- /** Fresh dispatch passes the live dispatcher; restored threads resolve it
231
- * from the runtime at call time so they never pin a stale closure. */
232
- startBackground: StartBackgroundInternal;
233
173
  }
234
174
 
235
175
  interface DispatchModelRoute {
@@ -1,13 +1,5 @@
1
- /**
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.
8
- */
1
+ /** Read-only run inspection and destructive cancellation of one-shot runs. */
9
2
 
10
- import { StringEnum } from "@earendil-works/pi-ai";
11
3
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
4
  import { Text } from "@earendil-works/pi-tui";
13
5
  import { existsSync } from "node:fs";
@@ -16,11 +8,11 @@ import { DEFAULT_MAX_RESULT_LINES, loadConfig } from "../configuration/config.ts
16
8
  import { removeThreadRecord } from "./durable.ts";
17
9
  import { formatCompletionBlock, matchRunIds } from "../presentation/format.ts";
18
10
  import { emptyUsage } from "../execution/rpc-control.ts";
19
- import { formatTaskSummary, formatUsageCompact, monitor } from "../presentation/monitor.ts";
11
+ import { formatDuration, formatTaskSummary, monitor } from "../presentation/monitor.ts";
20
12
  import { persistRecoveryRecords, recoveryRecordFromFinalization } from "../isolation/recovery.ts";
21
13
  import type { SubagentRuntime, SubagentThread } from "./runtime.ts";
22
- import { CONTROL_QUIESCE_TIMEOUT_MS, persistThreadCheckpoint, projectResultsRoot, quiesced } from "./thread-shared.ts";
23
- import { getResultOutput, type SingleResult } from "../execution/spawn.ts";
14
+ import { CONTROL_QUIESCE_TIMEOUT_MS, projectResultsRoot, quiesced } from "./thread-shared.ts";
15
+ import { getResultError, type SingleResult } from "../execution/spawn.ts";
24
16
  import type { WorktreeFinalization } from "../isolation/worktree.ts";
25
17
 
26
18
  function renderFirstLine(result: { content?: unknown }, label: string, theme: any): Text {
@@ -34,207 +26,79 @@ function renderFirstLine(result: { content?: unknown }, label: string, theme: an
34
26
  }
35
27
 
36
28
  export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime): void {
37
- const ResumeScopeSchema = Type.Optional(Type.Object({
38
- paths: Type.Optional(Type.Array(Type.String({ minLength: 1, pattern: "\\S" }))),
39
- symbols: Type.Optional(Type.Array(Type.Object({
40
- path: Type.String({ minLength: 1, pattern: "\\S" }),
41
- name: Type.String({ minLength: 1, pattern: "\\S" }),
42
- }))),
43
- }, { description: "Additional declarative write claims for resume; normalized claims are unioned with retained scope and cannot remove it. Scope is conflict metadata, not permissions or a sandbox." }));
44
- const SubagentControlParams = Type.Object({
45
- action: StringEnum(["steer", "resume", "park"] as const, {
46
- description:
47
- "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.",
48
- }),
49
- id: Type.Integer({ minimum: 1, description: "Stable run id shown by subagent dispatch output." }),
50
- objective: Type.Optional(
51
- Type.String({ description: "Guidance for steer (required and nonblank), or an optional appended objective for resume. Ignored by park." }),
52
- ),
53
- scope: ResumeScopeSchema,
54
- });
55
-
56
- /** A thread that a steer can continue instead of reject: it is not live, but
57
- * its retained session can absorb the guidance as an appended objective. */
58
- type ContinuableState = "completed" | "failed" | "parked";
59
-
60
29
  pi.registerTool({
61
- name: "subagent_control",
62
- label: "Subagent Control",
63
- description: "Steer a running child, resume a parked/settled thread, or park a running thread by stable run id. Resume keeps phaseId immutable and may only extend retained scope.",
64
- parameters: SubagentControlParams,
65
-
66
- async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
67
- // A thread parked by the previous process exists only once restore has
68
- // read the manifest; resuming before that would deny a live run id.
30
+ name: "subagent_status",
31
+ label: "Subagent Status",
32
+ description: "Read current-session run states without waiting or changing execution. Omit id to list runs, or pass an exact numeric id for progress, elapsed time, failure diagnostics, and retained result/recovery paths. Completions arrive automatically; use this for inspection, not a polling loop.",
33
+ parameters: Type.Object({
34
+ id: Type.Optional(Type.Integer({ minimum: 1, description: "Exact run id; omit to list all runs in this parent session." })),
35
+ }),
36
+ async execute(_toolCallId, params) {
69
37
  await runtime.durableRestore;
70
- const thread = runtime.threads.get(params.id);
71
- if (!thread) {
72
- return { content: [{ type: "text", text: `No subagent thread matches run #${params.id}.` }], details: {} };
73
- }
74
- const nonBlank = (value: string | undefined): string | undefined => {
75
- const trimmed = value?.trim();
76
- return trimmed ? trimmed : undefined;
77
- };
78
- const textResult = (text: string) => ({ content: [{ type: "text" as const, text }], details: {} });
79
- /** Why the thread has no steerable/parkable running RPC attempt right now. */
80
- const inactiveReason = (): string | undefined => {
81
- // Park drives the control through `stopped` before it records the
82
- // checkpoint, so the operation is named ahead of the transient state.
83
- if (thread.lifecycleOperation === "park") return "parking";
84
- if (thread.lifecycleOperation === "stop" || thread.state === "stopped") return "stopped";
85
- if (thread.lifecycleOperation === "resume") return "resuming";
86
- if (thread.lifecycleOperation === "settle" || thread.state === "completed" || thread.state === "failed") {
87
- return `settled (${thread.state})`;
88
- }
89
- if (thread.state === "queued" || thread.state === "resuming") {
90
- const phase = thread.control.getPhase();
91
- return phase === "starting" || phase === "retrying" ? phase : thread.state;
92
- }
93
- return thread.state === "running" ? undefined : thread.state;
94
- };
95
- const resumeThread = async (
96
- objective: string | undefined,
97
- continuedFrom?: ContinuableState,
98
- ) => {
99
- if (thread.retired) {
100
- return textResult(`Run #${thread.id} was retired by subagent_stop and has no resumable session.`);
101
- }
102
- if (
103
- thread.state !== "parked" &&
104
- thread.state !== "completed" &&
105
- thread.state !== "failed"
106
- ) {
107
- return textResult(`Run #${thread.id} is ${thread.state}; it must be parked or settled before resume.`);
108
- }
109
- const requestedObjective = objective === undefined ? undefined : nonBlank(objective);
110
- if (objective !== undefined && !requestedObjective) {
111
- return textResult("resume objective must be non-blank when provided.");
112
- }
113
- const hadRetainedSession = Boolean(thread.sessionId && thread.sessionDir);
114
- const pending = await thread.resume(requestedObjective, ctx, {
115
- scope: params.scope,
116
- });
117
- if (pending.exitCode !== -1) return textResult(getResultOutput(pending));
118
- const currentObjective = formatTaskSummary(requestedObjective ?? thread.task, 80, false);
119
- const mode = requestedObjective
120
- ? `appended objective: ${currentObjective}`
121
- : `continuing current objective: ${currentObjective}`;
122
- const context = hadRetainedSession ? "retained context reused" : "no prior child context";
123
- const prefix = continuedFrom
124
- ? `Run #${thread.id} was already ${continuedFrom} before steering; resumed the same thread`
125
- : `Resumed run #${thread.id}`;
126
- return textResult(`${prefix}: ${mode}; ${context}.`);
127
- };
128
- /** Steering guidance for a thread that is no longer live continues the
129
- * same thread with that guidance instead of being dropped, so the
130
- * evidence is never re-bought by a second dispatch. */
131
- const continueSteer = async (objective: string) => {
132
- const continuable = (): ContinuableState | undefined =>
133
- thread.state === "completed" || thread.state === "failed" || thread.state === "parked"
134
- ? thread.state
135
- : undefined;
136
- if (thread.lifecycleOperation === "settle" || (!continuable() && thread.control.getPhase() === "settled")) {
137
- if (!(await quiesced(thread.generationCompletion))) return undefined;
138
- }
139
- const state = continuable();
140
- if (!state || thread.lifecycleOperation) return undefined;
141
- return resumeThread(objective, state);
38
+ const threads = [...runtime.threads.values()]
39
+ .filter((thread) => params.id === undefined || thread.id === params.id)
40
+ .sort((left, right) => left.id - right.id);
41
+ const runs = threads.map((thread) => {
42
+ const live = monitor.findRun(thread.id);
43
+ const result = runtime.settledRuns.get(thread.id) ?? thread.lastResult;
44
+ const state = thread.lifecycleOperation === "stop" ? "interrupting"
45
+ : thread.retired ? "stopped"
46
+ : thread.lifecycleOperation === "settle" ? "settling"
47
+ : thread.state === "parked" ? "interrupted" : thread.state;
48
+ return {
49
+ id: thread.id,
50
+ agent: thread.agentName,
51
+ phaseId: thread.phaseId,
52
+ cwd: thread.cwd,
53
+ executionCwd: thread.executionCwd,
54
+ scope: thread.scope,
55
+ taskSummary: formatTaskSummary(thread.task, 80, false),
56
+ state,
57
+ waitReason: state === "queued" ? live?.waitReason : undefined,
58
+ activity: live?.activity,
59
+ elapsedMs: monitor.getElapsedMs(thread.id) ?? thread.elapsedMs,
60
+ model: live?.model ?? result?.model,
61
+ thinking: live?.thinking ?? result?.thinking ?? thread.thinkingLevel,
62
+ usage: { ...(live?.usage ?? result?.usage ?? emptyUsage()) },
63
+ exitCode: result?.exitCode,
64
+ stopReason: result?.stopReason,
65
+ errorMessage: result ? getResultError(result) : state === "failed" ? "No failure reason was recorded." : undefined,
66
+ resultFile: result?.resultFile,
67
+ sessionDir: thread.retired ? undefined : thread.sessionDir ?? result?.sessionDir,
68
+ isolation: thread.isolation,
69
+ integrationStatus: live?.integrationStatus ?? result?.integrationStatus ?? thread.worktree?.state,
70
+ integrationError: result?.integrationError,
71
+ integrationWorktreePath: result?.integrationWorktreePath ?? (state === "interrupted" ? thread.worktree?.worktreePath : undefined),
72
+ integrationPatchPath: result?.integrationPatchPath,
73
+ };
74
+ });
75
+ const text = runs.length === 0
76
+ ? params.id === undefined ? "No subagent runs in this parent session." : `No subagent run matches #${params.id}.`
77
+ : runs.map((run) => {
78
+ const parts = [`#${run.id} ${run.agent}`, run.state, formatDuration(run.elapsedMs)];
79
+ if (run.waitReason) parts.push(`wait: ${run.waitReason}`);
80
+ if (run.activity) parts.push(run.activity);
81
+ if (run.errorMessage) parts.push(formatTaskSummary(run.errorMessage, 300, false));
82
+ const paths = [
83
+ run.resultFile ? `Result: ${run.resultFile}` : undefined,
84
+ run.sessionDir ? `Session: ${run.sessionDir}` : undefined,
85
+ run.integrationWorktreePath ? `Retained worktree: ${run.integrationWorktreePath}` : undefined,
86
+ run.integrationPatchPath ? `Retained patch: ${run.integrationPatchPath}` : undefined,
87
+ ].filter(Boolean);
88
+ return `${parts.join(" · ")}\n ${run.taskSummary}${paths.length ? `\n ${paths.join("\n ")}` : ""}`;
89
+ }).join("\n");
90
+ return {
91
+ content: [{ type: "text", text }],
92
+ details: { runs },
93
+ ...(params.id !== undefined && runs.length === 0 ? { isError: true } : {}),
142
94
  };
143
-
144
- try {
145
- switch (params.action) {
146
- case "steer": {
147
- const objective = nonBlank(params.objective);
148
- if (!objective) {
149
- return textResult("steer objective must be non-blank.");
150
- }
151
- if (thread.retired) {
152
- return textResult(`Run #${thread.id} was retired by subagent_stop and cannot be steered.`);
153
- }
154
- const continued = await continueSteer(objective);
155
- if (continued) return continued;
156
- const unavailable = inactiveReason();
157
- if (unavailable) {
158
- return textResult(`Run #${thread.id} is ${unavailable}; only an active running RPC attempt can be steered. No guidance was sent.`);
159
- }
160
- const steered = await thread.control.steer(objective);
161
- if (!steered.accepted) {
162
- const resumed = await continueSteer(objective);
163
- if (resumed) return resumed;
164
- if (steered.reason === "no-active-attempt") {
165
- return textResult(`Run #${thread.id} is marked running but has no active RPC attempt; no guidance was sent.`);
166
- }
167
- return textResult(`Run #${thread.id} is ${steered.phase}; only an active running RPC attempt can be steered. No guidance was sent.`);
168
- }
169
- return textResult(`Steered run #${thread.id} with additional in-scope guidance; its original objective is unchanged.`);
170
- }
171
- case "resume": {
172
- return resumeThread(params.objective);
173
- }
174
- case "park": {
175
- if (thread.retired) {
176
- return textResult(`Run #${thread.id} was retired by subagent_stop and cannot be parked.`);
177
- }
178
- const unavailable = inactiveReason();
179
- if (unavailable) {
180
- 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.`);
181
- }
182
- if (!thread.sessionId || !thread.sessionDir) {
183
- return textResult(`Run #${thread.id} has no retained session yet; steer it or let it settle instead.`);
184
- }
185
- // Park interrupts the child at its next safe point but keeps the
186
- // session and worktree, so the thread returns to `parked`, not to a
187
- // failure. Claim synchronously like stop; the generation body sees
188
- // the claim and leaves publication to this path.
189
- const parkVersion = ++thread.lifecycleVersion;
190
- thread.lifecycleOperation = "park";
191
- const generation = thread.generation;
192
- const controller = thread.queueController;
193
- const completion = thread.generationCompletion;
194
- const ownsPark = (): boolean =>
195
- runtime.threads.get(thread.id) === thread &&
196
- thread.generation === generation &&
197
- thread.lifecycleVersion === parkVersion &&
198
- thread.lifecycleOperation === "park" &&
199
- !thread.retired;
200
- try {
201
- await quiesced(thread.control.stop("Parked by subagent_control at a stable checkpoint.").catch(() => undefined));
202
- if (!(await quiesced(completion))) runtime.backgroundQueue.cancel(controller);
203
- if (!ownsPark()) {
204
- return textResult(`Run #${thread.id} changed while it was being parked; no checkpoint was recorded by this call.`);
205
- }
206
- if (runtime.runControllers.get(thread.id) === controller) runtime.runControllers.delete(thread.id);
207
- if (thread.queueController === controller) thread.queueController = undefined;
208
- thread.state = "parked";
209
- monitor.setStatus(thread.id, "parked");
210
- thread.elapsedMs = monitor.getElapsedMs(thread.id) ?? thread.elapsedMs;
211
- persistThreadCheckpoint(runtime, thread, "parked");
212
- const run = monitor.findRun(thread.id);
213
- const usage = run ? formatUsageCompact(run.usage) : "";
214
- if (runtime.sessionActive) {
215
- ctx.ui.notify(`■ #${thread.id} ${run ? monitor.summarize(run) : thread.agentName} · parked`, "info");
216
- }
217
- const retained = thread.isolation === "worktree" ? "session and worktree" : "session";
218
- return textResult(
219
- `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.`,
220
- );
221
- } finally {
222
- if (thread.lifecycleVersion === parkVersion && thread.lifecycleOperation === "park") {
223
- thread.lifecycleOperation = undefined;
224
- }
225
- }
226
- }
227
- }
228
- } catch (error) {
229
- throw new Error(`Could not ${params.action} run #${thread.id}: ${error instanceof Error ? error.message : String(error)}`);
230
- }
231
95
  },
232
-
233
96
  renderCall(args, theme) {
234
- return new Text(`${theme.fg("toolTitle", theme.bold("subagent_control "))}${theme.fg("accent", `${args.action} #${args.id}`)}`, 0, 0);
97
+ return new Text(`${theme.fg("toolTitle", theme.bold("subagent_status "))}${theme.fg("accent", args.id === undefined ? "all" : `#${args.id}`)}`, 0, 0);
235
98
  },
236
- renderResult(result, _options, theme) {
237
- return renderFirstLine(result, "subagent_control ", theme);
99
+ renderResult(result, options, theme) {
100
+ if (options.expanded) return new Text(result.content.map((part) => part.type === "text" ? part.text : "").join("\n"), 0, 0);
101
+ return renderFirstLine(result, "subagent_status ", theme);
238
102
  },
239
103
  });
240
104
 
@@ -253,42 +117,32 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
253
117
  pi.registerTool({
254
118
  name: "subagent_stop",
255
119
  label: "Subagent Stop",
256
- description: "Stop and retire one child thread by run id/prefix, or every active thread with all: true.",
120
+ description: "Destructively stop and retire one run by id/prefix, or all active runs with all: true. Delivers partial results; stopped runs cannot resume.",
257
121
  parameters: SubagentStopParams,
258
122
 
259
123
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
260
124
  await runtime.durableRestore;
261
- // Start config I/O without yielding: every target below must be claimed
262
- // synchronously before a resume preflight can cross its next await.
125
+ // Claim every target before awaiting process cleanup or configuration I/O.
263
126
  const configPromise = loadConfig(runtime.configPath).catch(() => undefined);
264
127
  const completionResults: SingleResult[] = [];
265
128
  const candidateIds = params.all === true
266
129
  ? [...new Set([
267
130
  ...runtime.runControllers.keys(),
268
131
  ...[...runtime.threads.values()]
269
- .filter((thread) =>
270
- thread.lifecycleOperation !== undefined ||
271
- ["queued", "resuming", "running", "interrupting"].includes(thread.state),
272
- )
132
+ .filter((thread) => thread.lifecycleOperation !== undefined || ["queued", "running", "interrupting"].includes(thread.state))
273
133
  .map((thread) => thread.id),
274
134
  ])]
275
135
  : [...runtime.threads.keys()];
276
- const targets =
277
- params.all === true
278
- ? candidateIds
279
- : params.id !== undefined && params.id.trim() !== ""
280
- ? matchRunIds(candidateIds, params.id.trim())
281
- : [];
136
+ const targets = params.all === true
137
+ ? candidateIds
138
+ : params.id?.trim() ? matchRunIds(candidateIds, params.id.trim()) : [];
282
139
 
283
140
  if (targets.length === 0) {
284
141
  const available = [...runtime.threads.keys()].map((id) => `#${id}`).join(", ");
285
142
  return {
286
- content: [{
287
- type: "text",
288
- text: params.all === true
289
- ? "No active subagent runs to stop."
290
- : `No subagent thread matches "${params.id}".${available ? ` Known threads: ${available}.` : ""}`,
291
- }],
143
+ content: [{ type: "text", text: params.all === true
144
+ ? "No active subagent runs to stop."
145
+ : `No subagent thread matches "${params.id}".${available ? ` Known threads: ${available}.` : ""}` }],
292
146
  details: {},
293
147
  };
294
148
  }
@@ -299,7 +153,6 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
299
153
  run: ReturnType<typeof monitor.findRun>;
300
154
  previousState: SubagentThread["state"];
301
155
  wasQueued: boolean;
302
- wasResuming: boolean;
303
156
  wasActive: boolean;
304
157
  generation: number;
305
158
  controller: AbortController | undefined;
@@ -312,39 +165,23 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
312
165
  if (!thread) continue;
313
166
  const previousState = thread.state;
314
167
  const wasQueued = previousState === "queued";
315
- const wasResuming = previousState === "resuming";
316
- const wasActive =
317
- thread.lifecycleOperation !== undefined ||
318
- ["queued", "resuming", "running", "interrupting"].includes(previousState);
168
+ const wasActive = thread.lifecycleOperation !== undefined || ["queued", "running", "interrupting"].includes(previousState);
319
169
  const stopVersion = ++thread.lifecycleVersion;
320
- // Stop-all claims every target before the first await. This invalidates
321
- // all concurrent resume preflights as one synchronous operation.
322
170
  thread.lifecycleOperation = "stop";
323
171
  thread.retired = true;
324
172
  thread.retireOnSettle = true;
325
173
  thread.state = "stopped";
326
174
  const stopMessage = wasQueued
327
175
  ? "Stopped by subagent_stop before the run started."
328
- : wasResuming
329
- ? "Stopped by subagent_stop while resume was preparing."
330
- : wasActive
331
- ? "Stopped by subagent_stop."
332
- : previousState === "parked"
333
- ? "Stopped by subagent_stop from a parked checkpoint."
334
- : "Retired by subagent_stop.";
176
+ : wasActive ? "Stopped by subagent_stop."
177
+ : previousState === "parked" ? "Stopped by subagent_stop from an interrupted checkpoint."
178
+ : "Retired by subagent_stop.";
335
179
  claimed.push({
336
- runId,
337
- thread,
338
- run: monitor.findRun(runId),
339
- previousState,
340
- wasQueued,
341
- wasResuming,
342
- wasActive,
180
+ runId, thread, run: monitor.findRun(runId), previousState, wasQueued, wasActive,
343
181
  generation: thread.generation,
344
182
  controller: thread.queueController,
345
183
  completion: thread.generationCompletion,
346
- stopVersion,
347
- stopMessage,
184
+ stopVersion, stopMessage,
348
185
  });
349
186
  }
350
187
 
@@ -368,7 +205,6 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
368
205
  run,
369
206
  previousState,
370
207
  wasQueued,
371
- wasResuming,
372
208
  wasActive,
373
209
  generation,
374
210
  controller,
@@ -392,7 +228,6 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
392
228
  let stoppedResult: SingleResult | undefined;
393
229
  if (
394
230
  wasQueued ||
395
- wasResuming ||
396
231
  previousState === "parked" ||
397
232
  !runtime.settledRuns.has(runId)
398
233
  ) {
@@ -451,8 +286,8 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
451
286
  error: "subagent_stop timed out waiting for worktree integration; it continues in the background",
452
287
  }),
453
288
  ]).catch(() => undefined);
289
+ pendingIntegration.push(`#${runId}`);
454
290
  }
455
- pendingIntegration.push(`#${runId}`);
456
291
  } else if (finalization.status === "retained") {
457
292
  retainedIntegration.push(`#${runId}`);
458
293
  }