@ferris1225/pi-subagents 4.3.8 → 4.3.10

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);
@@ -58,6 +56,9 @@ function createRestoredThread(
58
56
  generation: record.generation,
59
57
  agentName: record.agentName,
60
58
  task: record.task,
59
+ phaseId: record.phaseId,
60
+ scope: record.scope,
61
+ writeCapable: record.writeCapable ?? record.agentName !== "scout",
61
62
  cwd: record.cwd,
62
63
  executionCwd: record.executionCwd,
63
64
  ...(record.thinkingLevel ? { thinkingLevel: record.thinkingLevel as ThinkingLevel } : {}),
@@ -71,31 +72,15 @@ function createRestoredThread(
71
72
  sessionId: record.sessionId,
72
73
  sessionDir: record.sessionDir,
73
74
  lastResult: restoredResultFromSummary(record),
74
- resume: async () => failedStartResult(record.agentName, record.task, "Thread resume was not initialized."),
75
75
  finalizeIsolation: async () => undefined,
76
76
  };
77
- installThreadLifecycle(thread, {
78
- runtime,
79
- startBackground: (...args) => {
80
- const dispatcher = runtime.dispatcher;
81
- if (!dispatcher) {
82
- return Promise.resolve(failedStartResult(
83
- record.agentName,
84
- record.task,
85
- `Run #${record.runId} cannot continue: no dispatch context is available yet. Dispatch any subagent once, then retry.`,
86
- ));
87
- }
88
- return dispatcher(...args);
89
- },
90
- });
77
+ installThreadLifecycle(thread, { runtime });
91
78
  return thread;
92
79
  }
93
80
 
94
- /** Rebuild interrupted (parked) threads from the durable manifest after a
95
- * reload or restart. Orphaned children recorded by the previous process are
96
- * killed first; records whose retained session vanished and settled records
97
- * left by older versions, which hold no work worth resuming — drop out with
98
- * 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. */
99
84
  export async function restoreDurableThreads(runtime: SubagentRuntime): Promise<number[]> {
100
85
  const records = await readThreadRecords(runtime.configPath);
101
86
  const restoredIds: number[] = [];
@@ -118,22 +103,18 @@ export async function restoreDurableThreads(runtime: SubagentRuntime): Promise<n
118
103
  record.sessionId !== undefined &&
119
104
  record.sessionDir !== undefined &&
120
105
  sessionExists(record.sessionDir, record.sessionId);
121
- if (!sessionValid) {
122
- await discardRestoredRecord(runtime, record);
123
- continue;
124
- }
106
+ const restoredRecord = sessionValid ? record : { ...record, sessionId: undefined, sessionDir: undefined };
125
107
  const worktree = record.worktree
126
108
  ? await restoreWorktreeIsolation(record.worktree).catch(() => undefined)
127
109
  : undefined;
128
110
  const restorationFailed = record.isolation === "worktree" && record.worktree !== undefined && !worktree;
129
- const thread = createRestoredThread(runtime, record, worktree, restorationFailed ? "failed" : "parked");
111
+ const thread = createRestoredThread(runtime, restoredRecord, worktree, restorationFailed ? "failed" : "parked");
130
112
  runtime.threads.set(record.runId, thread);
131
- runtime.sessionDirs.add(record.sessionDir!);
113
+ if (thread.sessionDir) runtime.sessionDirs.add(thread.sessionDir);
132
114
  if (restorationFailed) {
133
- 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.`;
134
- 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.`;
135
116
  thread.restorationRecord = record;
136
- const previous = restoredResultFromSummary(record);
117
+ const previous = thread.lastResult;
137
118
  const failed: SingleResult = {
138
119
  agent: record.agentName,
139
120
  task: record.task,
@@ -146,8 +127,8 @@ export async function restoreDurableThreads(runtime: SubagentRuntime): Promise<n
146
127
  stopReason: "error",
147
128
  errorMessage: reason,
148
129
  dispatchFailed: true,
149
- sessionId: record.sessionId,
150
- sessionDir: record.sessionDir,
130
+ sessionId: thread.sessionId,
131
+ sessionDir: thread.sessionDir,
151
132
  projectCwd: record.cwd,
152
133
  runId: record.runId,
153
134
  isolation: "worktree",
@@ -225,7 +206,7 @@ export function bootstrapDurableState(runtime: SubagentRuntime): Promise<void> {
225
206
  }
226
207
  try {
227
208
  // Sessions and worktrees outlive their process on purpose, so only
228
- // ownership separates state a live pi still resumes from state a crash
209
+ // ownership separates a live Pi's state from artifacts a crash
229
210
  // abandoned. Valid thread and recovery records always keep their paths.
230
211
  const records = await readThreadRecords(runtime.configPath);
231
212
  const recoveryRecords = await readRecoveryRecords(runtime.configPath);
@@ -4,6 +4,7 @@ import { realpath } from "node:fs/promises";
4
4
  import { join, resolve } from "node:path";
5
5
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
6
6
  import { isWriteCapableAgent, type AgentConfig } from "../delegation/agents.ts";
7
+ import type { PhaseScope } from "../delegation/phase-scope.ts";
7
8
  import { roleThinkingLevel, type SubagentsConfig, type ThinkingLevel } from "../configuration/config.ts";
8
9
  import { removeThreadRecord, threadRecordFromThread, upsertThreadRecord } from "./durable.ts";
9
10
  import {
@@ -16,7 +17,7 @@ import {
16
17
  } from "../configuration/models.ts";
17
18
  import type { SubagentRuntime, SubagentThread } from "./runtime.ts";
18
19
  import { getProjectRoot, type SingleResult } from "../execution/spawn.ts";
19
- import { resolveRepositoryRoot, type IsolationMode, type WorktreeIsolation } from "../isolation/worktree.ts";
20
+ import { resolveRepositoryRoot, type IsolationMode } from "../isolation/worktree.ts";
20
21
 
21
22
  /** Control operations must never wait forever on a settling generation: the
22
23
  * queue task can legitimately spend minutes in worktree finalization (bounded
@@ -113,42 +114,8 @@ export async function runInManagedRepositoryLane<T>(
113
114
  }
114
115
  }
115
116
 
116
- /** Track resume setup that has claimed a thread but has not yet enqueued
117
- * its next generation. Shutdown invalidates these claims and waits for cleanup. */
118
- export function beginRuntimePreflight(runtime: SubagentRuntime): () => void {
119
- let resolvePreflight!: () => void;
120
- const preflight = new Promise<void>((resolve) => {
121
- resolvePreflight = resolve;
122
- });
123
- runtime.preflightOperations.add(preflight);
124
- return () => {
125
- runtime.preflightOperations.delete(preflight);
126
- resolvePreflight();
127
- };
128
- }
129
-
130
- /** Synchronous CAS used by lifecycle controls across their async preflight. */
131
- export function ownsResumeReservation(
132
- runtime: SubagentRuntime,
133
- thread: SubagentThread,
134
- reservation: { version: number; generation: number; sessionId?: string; sessionDir?: string },
135
- ): boolean {
136
- return (
137
- runtime.sessionActive &&
138
- runtime.threads.get(thread.id) === thread &&
139
- !thread.retired &&
140
- thread.lifecycleOperation === "resume" &&
141
- thread.lifecycleVersion === reservation.version &&
142
- thread.generation === reservation.generation &&
143
- thread.sessionId === reservation.sessionId &&
144
- thread.sessionDir === reservation.sessionDir
145
- );
146
- }
147
-
148
- /** Fire-and-forget durable checkpoint. Parked threads stay resumable across
149
- * reloads; a settled thread drops its record so the manifest only exists
150
- * while unfinished work needs it. The live session keeps working when the
151
- * 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. */
152
119
  export function persistThreadCheckpoint(
153
120
  runtime: SubagentRuntime,
154
121
  thread: SubagentThread,
@@ -182,28 +149,12 @@ export interface DispatchEnvironment {
182
149
  agents: AgentConfig[];
183
150
  }
184
151
 
185
- export interface SessionSeed {
186
- sessionId?: string;
187
- sessionDir?: string;
188
- worktree?: WorktreeIsolation;
189
- }
190
-
191
- export interface ResumeReservation {
192
- version: number;
193
- generation: number;
194
- sessionId?: string;
195
- sessionDir?: string;
196
- }
197
-
198
- /** The dispatcher's full internal entry point; the public tool surface only
199
- * uses the first four parameters. */
152
+ /** Admission metadata for a fresh one-shot run. */
200
153
  export interface StartBackgroundOptions {
201
- /** Resume path only: the thread whose retained context continues. */
202
- existingThread?: SubagentThread;
203
- appendedObjectiveOnResume?: boolean;
204
- environment?: DispatchEnvironment;
205
- seed?: SessionSeed;
206
- resumeReservation?: ResumeReservation;
154
+ phaseId?: string;
155
+ scope?: PhaseScope;
156
+ /** A false hint cannot downgrade a live write-capable role. */
157
+ writeCapable?: boolean;
207
158
  /** Chosen by the tool call before the queue can start a fast child. */
208
159
  deliveryRoute?: "background" | "await";
209
160
  }
@@ -218,12 +169,7 @@ export type StartBackgroundInternal = (
218
169
 
219
170
  export interface ThreadLifecycleDeps {
220
171
  runtime: SubagentRuntime;
221
- /** Fallback context when a control caller supplies none; restored threads
222
- * install without one and rely on the per-call context. */
223
172
  runCtx?: ExtensionContext;
224
- /** Fresh dispatch passes the live dispatcher; restored threads resolve it
225
- * from the runtime at call time so they never pin a stale closure. */
226
- startBackground: StartBackgroundInternal;
227
173
  }
228
174
 
229
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,197 +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 SubagentControlParams = Type.Object({
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.",
41
- }),
42
- id: Type.Integer({ minimum: 1, description: "Stable run id shown by subagent dispatch output." }),
43
- objective: Type.Optional(
44
- Type.String({ description: "Guidance for steer (required and nonblank), or an optional appended objective for resume. Ignored by park." }),
45
- ),
46
- });
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
-
52
29
  pi.registerTool({
53
- name: "subagent_control",
54
- label: "Subagent Control",
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.",
56
- parameters: SubagentControlParams,
57
-
58
- async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
59
- // A thread parked by the previous process exists only once restore has
60
- // 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) {
61
37
  await runtime.durableRestore;
62
- const thread = runtime.threads.get(params.id);
63
- if (!thread) {
64
- return { content: [{ type: "text", text: `No subagent thread matches run #${params.id}.` }], details: {} };
65
- }
66
- const nonBlank = (value: string | undefined): string | undefined => {
67
- const trimmed = value?.trim();
68
- return trimmed ? trimmed : undefined;
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);
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 } : {}),
132
94
  };
133
-
134
- try {
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
- }
161
- case "resume": {
162
- return resumeThread(params.objective);
163
- }
164
- case "park": {
165
- if (thread.retired) {
166
- return textResult(`Run #${thread.id} was retired by subagent_stop and cannot be parked.`);
167
- }
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.`);
171
- }
172
- if (!thread.sessionId || !thread.sessionDir) {
173
- return textResult(`Run #${thread.id} has no retained session yet; steer it or let it settle instead.`);
174
- }
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
- }
215
- }
216
- }
217
- }
218
- } catch (error) {
219
- throw new Error(`Could not ${params.action} run #${thread.id}: ${error instanceof Error ? error.message : String(error)}`);
220
- }
221
95
  },
222
-
223
96
  renderCall(args, theme) {
224
- 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);
225
98
  },
226
- renderResult(result, _options, theme) {
227
- 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);
228
102
  },
229
103
  });
230
104
 
@@ -248,37 +122,27 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
248
122
 
249
123
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
250
124
  await runtime.durableRestore;
251
- // Start config I/O without yielding: every target below must be claimed
252
- // synchronously before a resume preflight can cross its next await.
125
+ // Claim every target before awaiting process cleanup or configuration I/O.
253
126
  const configPromise = loadConfig(runtime.configPath).catch(() => undefined);
254
127
  const completionResults: SingleResult[] = [];
255
128
  const candidateIds = params.all === true
256
129
  ? [...new Set([
257
130
  ...runtime.runControllers.keys(),
258
131
  ...[...runtime.threads.values()]
259
- .filter((thread) =>
260
- thread.lifecycleOperation !== undefined ||
261
- ["queued", "resuming", "running", "interrupting"].includes(thread.state),
262
- )
132
+ .filter((thread) => thread.lifecycleOperation !== undefined || ["queued", "running", "interrupting"].includes(thread.state))
263
133
  .map((thread) => thread.id),
264
134
  ])]
265
135
  : [...runtime.threads.keys()];
266
- const targets =
267
- params.all === true
268
- ? candidateIds
269
- : params.id !== undefined && params.id.trim() !== ""
270
- ? matchRunIds(candidateIds, params.id.trim())
271
- : [];
136
+ const targets = params.all === true
137
+ ? candidateIds
138
+ : params.id?.trim() ? matchRunIds(candidateIds, params.id.trim()) : [];
272
139
 
273
140
  if (targets.length === 0) {
274
141
  const available = [...runtime.threads.keys()].map((id) => `#${id}`).join(", ");
275
142
  return {
276
- content: [{
277
- type: "text",
278
- text: params.all === true
279
- ? "No active subagent runs to stop."
280
- : `No subagent thread matches "${params.id}".${available ? ` Known threads: ${available}.` : ""}`,
281
- }],
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}.` : ""}` }],
282
146
  details: {},
283
147
  };
284
148
  }
@@ -289,7 +153,6 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
289
153
  run: ReturnType<typeof monitor.findRun>;
290
154
  previousState: SubagentThread["state"];
291
155
  wasQueued: boolean;
292
- wasResuming: boolean;
293
156
  wasActive: boolean;
294
157
  generation: number;
295
158
  controller: AbortController | undefined;
@@ -302,39 +165,23 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
302
165
  if (!thread) continue;
303
166
  const previousState = thread.state;
304
167
  const wasQueued = previousState === "queued";
305
- const wasResuming = previousState === "resuming";
306
- const wasActive =
307
- thread.lifecycleOperation !== undefined ||
308
- ["queued", "resuming", "running", "interrupting"].includes(previousState);
168
+ const wasActive = thread.lifecycleOperation !== undefined || ["queued", "running", "interrupting"].includes(previousState);
309
169
  const stopVersion = ++thread.lifecycleVersion;
310
- // Stop-all claims every target before the first await. This invalidates
311
- // all concurrent resume preflights as one synchronous operation.
312
170
  thread.lifecycleOperation = "stop";
313
171
  thread.retired = true;
314
172
  thread.retireOnSettle = true;
315
173
  thread.state = "stopped";
316
174
  const stopMessage = wasQueued
317
175
  ? "Stopped by subagent_stop before the run started."
318
- : wasResuming
319
- ? "Stopped by subagent_stop while resume was preparing."
320
- : wasActive
321
- ? "Stopped by subagent_stop."
322
- : previousState === "parked"
323
- ? "Stopped by subagent_stop from a parked checkpoint."
324
- : "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.";
325
179
  claimed.push({
326
- runId,
327
- thread,
328
- run: monitor.findRun(runId),
329
- previousState,
330
- wasQueued,
331
- wasResuming,
332
- wasActive,
180
+ runId, thread, run: monitor.findRun(runId), previousState, wasQueued, wasActive,
333
181
  generation: thread.generation,
334
182
  controller: thread.queueController,
335
183
  completion: thread.generationCompletion,
336
- stopVersion,
337
- stopMessage,
184
+ stopVersion, stopMessage,
338
185
  });
339
186
  }
340
187
 
@@ -358,7 +205,6 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
358
205
  run,
359
206
  previousState,
360
207
  wasQueued,
361
- wasResuming,
362
208
  wasActive,
363
209
  generation,
364
210
  controller,
@@ -382,7 +228,6 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
382
228
  let stoppedResult: SingleResult | undefined;
383
229
  if (
384
230
  wasQueued ||
385
- wasResuming ||
386
231
  previousState === "parked" ||
387
232
  !runtime.settledRuns.has(runId)
388
233
  ) {
@@ -441,8 +286,8 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
441
286
  error: "subagent_stop timed out waiting for worktree integration; it continues in the background",
442
287
  }),
443
288
  ]).catch(() => undefined);
289
+ pendingIntegration.push(`#${runId}`);
444
290
  }
445
- pendingIntegration.push(`#${runId}`);
446
291
  } else if (finalization.status === "retained") {
447
292
  retainedIntegration.push(`#${runId}`);
448
293
  }
@@ -46,7 +46,7 @@ export function registerAnnouncements(pi: ExtensionAPI, runtime: SubagentRuntime
46
46
  runtime.restoredNotified = true;
47
47
  const ids = runtime.restoredRunIds.map((id) => `#${id}`).join(", ");
48
48
  ctx.ui.notify(
49
- `pi-subagents: restored ${runtime.restoredRunIds.length} interrupted thread${runtime.restoredRunIds.length === 1 ? "" : "s"} (${ids}) with retained context. subagent_control resume continues one.`,
49
+ `pi-subagents: restored ${runtime.restoredRunIds.length} interrupted run${runtime.restoredRunIds.length === 1 ? "" : "s"} (${ids}) for manual recovery. Inspect with subagent_status; main finishes the work.`,
50
50
  "info",
51
51
  );
52
52
  }