pi-subagents 0.34.0 → 0.35.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 (98) hide show
  1. package/CHANGELOG.md +78 -9
  2. package/README.md +213 -32
  3. package/index.ts +1 -0
  4. package/install.mjs +1 -1
  5. package/package.json +23 -8
  6. package/prompts/review-loop.md +3 -1
  7. package/skills/pi-subagents/SKILL.md +87 -25
  8. package/src/agents/agent-management.ts +82 -15
  9. package/src/agents/agent-serializer.ts +19 -0
  10. package/src/agents/agents.ts +91 -49
  11. package/src/agents/frontmatter.ts +67 -13
  12. package/src/agents/skills.ts +25 -12
  13. package/src/api/background-work.ts +197 -0
  14. package/src/api/delegation.ts +158 -0
  15. package/src/extension/chain-validation.ts +165 -0
  16. package/src/extension/doctor.ts +15 -0
  17. package/src/extension/fanout-child.ts +3 -1
  18. package/src/extension/index.ts +65 -124
  19. package/src/extension/rpc.ts +10 -2
  20. package/src/extension/schemas.ts +18 -14
  21. package/src/extension/steering-notices.ts +35 -0
  22. package/src/extension/tool-description.ts +20 -9
  23. package/src/intercom/intercom-bridge.ts +3 -2
  24. package/src/intercom/native-supervisor-channel.ts +9 -1
  25. package/src/intercom/result-intercom.ts +4 -0
  26. package/src/runs/background/async-execution.ts +293 -44
  27. package/src/runs/background/async-job-tracker.ts +56 -9
  28. package/src/runs/background/async-resume.ts +159 -52
  29. package/src/runs/background/async-status.ts +25 -18
  30. package/src/runs/background/auto-drain.ts +67 -0
  31. package/src/runs/background/chain-root-attachment.ts +16 -8
  32. package/src/runs/background/control-channel.ts +260 -13
  33. package/src/runs/background/fleet-view.ts +23 -2
  34. package/src/runs/background/notify.ts +79 -10
  35. package/src/runs/background/result-watcher.ts +12 -9
  36. package/src/runs/background/run-id-resolver.ts +14 -2
  37. package/src/runs/background/run-status.ts +23 -15
  38. package/src/runs/background/scheduled-runs.ts +3 -0
  39. package/src/runs/background/stale-run-reconciler.ts +32 -10
  40. package/src/runs/background/steering.ts +237 -0
  41. package/src/runs/background/subagent-runner.ts +898 -236
  42. package/src/runs/background/subagent-wait.ts +484 -0
  43. package/src/runs/background/top-level-async.ts +2 -1
  44. package/src/runs/background/wait-config.ts +36 -0
  45. package/src/runs/background/wait-tool.ts +26 -0
  46. package/src/runs/foreground/async-steering-action.ts +230 -0
  47. package/src/runs/foreground/chain-clarify.ts +22 -6
  48. package/src/runs/foreground/chain-execution.ts +50 -32
  49. package/src/runs/foreground/execution.ts +308 -94
  50. package/src/runs/foreground/subagent-executor.ts +592 -268
  51. package/src/runs/shared/acceptance.ts +355 -97
  52. package/src/runs/shared/child-protocol.ts +121 -0
  53. package/src/runs/shared/completion-guard.ts +8 -127
  54. package/src/runs/shared/dynamic-fanout.ts +6 -4
  55. package/src/runs/shared/model-fallback.ts +36 -0
  56. package/src/runs/shared/nested-events.ts +9 -4
  57. package/src/runs/shared/nested-render.ts +4 -1
  58. package/src/runs/shared/parallel-utils.ts +7 -0
  59. package/src/runs/shared/pi-args.ts +34 -7
  60. package/src/runs/shared/pi-spawn.ts +18 -12
  61. package/src/runs/shared/session-lease.ts +279 -0
  62. package/src/runs/shared/single-output.ts +61 -6
  63. package/src/runs/shared/spawn-budget.ts +128 -0
  64. package/src/runs/shared/subagent-control.ts +10 -6
  65. package/src/runs/shared/subagent-prompt-runtime.ts +127 -26
  66. package/src/runs/shared/task-intent.ts +176 -0
  67. package/src/runs/shared/tool-availability.ts +65 -0
  68. package/src/runs/shared/turn-budget.ts +49 -4
  69. package/src/shared/atomic-json.ts +4 -1
  70. package/src/shared/fork-context.ts +28 -3
  71. package/src/shared/model-info.ts +7 -4
  72. package/src/shared/status-format.ts +7 -1
  73. package/src/shared/types.ts +203 -25
  74. package/src/shared/utils.ts +35 -7
  75. package/src/slash/delegation-adapters.ts +457 -0
  76. package/src/slash/delegation-request.ts +103 -0
  77. package/src/slash/prompt-template-bridge.ts +167 -344
  78. package/src/slash/slash-commands.ts +239 -6
  79. package/src/slash/subagents-admin.ts +428 -0
  80. package/src/slash/subagents-editor.ts +86 -0
  81. package/src/tui/fleet.ts +405 -0
  82. package/src/tui/render.ts +90 -16
  83. package/src/watchdog/change-signature.ts +127 -0
  84. package/src/watchdog/child-status.ts +205 -0
  85. package/src/watchdog/emission-guard.ts +123 -0
  86. package/src/watchdog/lsp-diagnostics.ts +532 -0
  87. package/src/watchdog/model-selection.ts +167 -0
  88. package/src/watchdog/register-child.ts +117 -0
  89. package/src/watchdog/register-main.ts +433 -0
  90. package/src/watchdog/render.ts +54 -0
  91. package/src/watchdog/review.ts +293 -0
  92. package/src/watchdog/runtime.ts +712 -0
  93. package/src/watchdog/settings.ts +528 -0
  94. package/src/watchdog/tool-actions.ts +155 -0
  95. package/src/watchdog/turn-delta.ts +161 -0
  96. package/src/watchdog/types.ts +188 -0
  97. package/src/watchdog/warning-format.ts +73 -0
  98. package/src/runs/background/wait.ts +0 -394
@@ -19,6 +19,7 @@ import * as fs from "node:fs";
19
19
  import * as path from "node:path";
20
20
  import { writeAtomicJson } from "../../shared/atomic-json.ts";
21
21
  import { POLL_INTERVAL_MS } from "../../shared/types.ts";
22
+ import { resolveWatchPath } from "../../shared/utils.ts";
22
23
 
23
24
  /**
24
25
  * Opportunistic fast-path interrupt signal. On Unix `SIGUSR2` is trapped by the
@@ -27,7 +28,7 @@ import { POLL_INTERVAL_MS } from "../../shared/types.ts";
27
28
  */
28
29
  export const INTERRUPT_SIGNAL: NodeJS.Signals = process.platform === "win32" ? "SIGBREAK" : "SIGUSR2";
29
30
 
30
- export type ControlChannelFs = Pick<typeof fs, "mkdirSync" | "existsSync" | "rmSync" | "watch" | "readdirSync" | "readFileSync">;
31
+ export type ControlChannelFs = Pick<typeof fs, "mkdirSync" | "existsSync" | "rmSync" | "watch" | "readdirSync" | "readFileSync" | "realpathSync">;
31
32
  export type ControlChannelTimers = { setInterval: typeof setInterval; clearInterval: typeof clearInterval };
32
33
  type KillFn = (pid: number, signal?: NodeJS.Signals | 0) => unknown;
33
34
 
@@ -45,17 +46,49 @@ export interface TimeoutRequest {
45
46
  reason?: string;
46
47
  }
47
48
 
49
+ export interface StopRequest {
50
+ type: "stop";
51
+ ts?: number;
52
+ source?: string;
53
+ reason?: string;
54
+ }
55
+
48
56
  export interface SteerRequest {
49
57
  type: "steer";
50
58
  id: string;
51
59
  ts: number;
52
60
  message: string;
53
61
  targetIndex?: number;
62
+ targetIndexes?: number[];
54
63
  source?: string;
55
64
  }
56
65
 
66
+ export interface SteerCapability {
67
+ type: "steer-capability";
68
+ protocolVersion: 1;
69
+ index: number;
70
+ pid: number;
71
+ readyAt: number;
72
+ supported: boolean;
73
+ }
74
+
75
+ export interface SteerAck {
76
+ type: "steer-ack";
77
+ protocolVersion: 1;
78
+ requestId: string;
79
+ index: number;
80
+ ts: number;
81
+ state: "delivered" | "failed";
82
+ message: string;
83
+ }
84
+
57
85
  const STEER_REQUESTS_DIR = "steer-requests";
58
86
  const STEER_TARGETS_DIR = "steer-targets";
87
+ const STEER_CAPABILITIES_DIR = "steer-capabilities";
88
+ const STEER_ACKS_DIR = "steer-acks";
89
+ const STEER_INBOX_CLOSED_FILE = "steer-inbox-closed.json";
90
+ const MAX_STEER_MESSAGE_BYTES = 128 * 1024;
91
+ const MAX_STEER_REQUEST_ID_LENGTH = 256;
59
92
 
60
93
  /** Control inbox directory inside an async run dir. */
61
94
  export function controlInboxDir(asyncDir: string): string {
@@ -72,26 +105,118 @@ export function timeoutRequestPath(asyncDir: string): string {
72
105
  return path.join(controlInboxDir(asyncDir), "timeout.json");
73
106
  }
74
107
 
108
+ /** Path of the portable manual stop request file. */
109
+ export function stopRequestPath(asyncDir: string): string {
110
+ return path.join(controlInboxDir(asyncDir), "stop.json");
111
+ }
112
+
75
113
  /** Directory of parent-to-runner steering requests. */
76
114
  export function steerRequestsDir(asyncDir: string): string {
77
115
  return path.join(controlInboxDir(asyncDir), STEER_REQUESTS_DIR);
78
116
  }
79
117
 
118
+ export function steerInboxClosedPath(asyncDir: string): string {
119
+ return path.join(controlInboxDir(asyncDir), STEER_INBOX_CLOSED_FILE);
120
+ }
121
+
122
+ export function closeSteerInbox(asyncDir: string, state: string): void {
123
+ writeAtomicJson(steerInboxClosedPath(asyncDir), { version: 1, closedAt: Date.now(), state });
124
+ }
125
+
80
126
  /** Per-child inbox consumed by the child prompt runtime inside the Pi process. */
81
127
  export function stepSteerInboxDir(asyncDir: string, index: number): string {
128
+ assertChildIndex(index);
82
129
  return path.join(controlInboxDir(asyncDir), STEER_TARGETS_DIR, String(index));
83
130
  }
84
131
 
132
+ export function steerCapabilitiesDir(asyncDir: string): string {
133
+ return path.join(controlInboxDir(asyncDir), STEER_CAPABILITIES_DIR);
134
+ }
135
+
136
+ export function steerCapabilityPath(asyncDir: string, index: number): string {
137
+ assertChildIndex(index);
138
+ return path.join(steerCapabilitiesDir(asyncDir), `${index}.json`);
139
+ }
140
+
141
+ export function steerAcksDir(asyncDir: string, index: number): string {
142
+ assertChildIndex(index);
143
+ return path.join(controlInboxDir(asyncDir), STEER_ACKS_DIR, String(index));
144
+ }
145
+
146
+ function steerAckFileName(requestId: string): string {
147
+ return `${Buffer.from(requestId).toString("base64url")}.json`;
148
+ }
149
+
150
+ export function steerAckPathFromDir(dir: string, requestId: string): string {
151
+ if (!/^[^\s]+$/.test(requestId) || requestId.length > 256) throw new Error("steer acknowledgment requestId is invalid.");
152
+ return path.join(dir, steerAckFileName(requestId));
153
+ }
154
+
155
+ function assertChildIndex(index: number): void {
156
+ if (!Number.isInteger(index) || index < 0 || index > 1_000_000) throw new Error("steer child index must be a non-negative integer.");
157
+ }
158
+
85
159
  function steerRequestFileName(request: SteerRequest): string {
86
160
  return `${String(request.ts).padStart(13, "0")}-${Buffer.from(request.id).toString("base64url")}.json`;
87
161
  }
88
162
 
163
+ function validSteerRequest(request: Partial<SteerRequest>): request is SteerRequest {
164
+ return request.type === "steer"
165
+ && typeof request.id === "string"
166
+ && /^[^\s]+$/.test(request.id)
167
+ && request.id.length <= MAX_STEER_REQUEST_ID_LENGTH
168
+ && typeof request.ts === "number"
169
+ && Number.isFinite(request.ts)
170
+ && request.ts > 0
171
+ && typeof request.message === "string"
172
+ && Boolean(request.message.trim())
173
+ && Buffer.byteLength(request.message, "utf8") <= MAX_STEER_MESSAGE_BYTES
174
+ && (request.targetIndex === undefined || (Number.isInteger(request.targetIndex) && request.targetIndex >= 0 && request.targetIndex <= 1_000_000))
175
+ && (request.targetIndexes === undefined || (
176
+ request.targetIndex === undefined
177
+ && Array.isArray(request.targetIndexes)
178
+ && request.targetIndexes.length > 0
179
+ && request.targetIndexes.length <= 1_000
180
+ && request.targetIndexes.every((index) => Number.isInteger(index) && index >= 0 && index <= 1_000_000)
181
+ && new Set(request.targetIndexes).size === request.targetIndexes.length
182
+ ))
183
+ && (request.source === undefined || (typeof request.source === "string" && Boolean(request.source.trim()) && request.source.length <= 256));
184
+ }
185
+
89
186
  export function writeSteerRequestToDir(dir: string, request: SteerRequest): string {
187
+ if (!validSteerRequest(request)) throw new Error("steer request is malformed or exceeds transport limits.");
90
188
  const requestPath = path.join(dir, steerRequestFileName(request));
91
189
  writeAtomicJson(requestPath, request);
92
190
  return requestPath;
93
191
  }
94
192
 
193
+ export function writeSteerCapabilityAt(filePath: string, capability: Omit<SteerCapability, "type" | "protocolVersion">): string {
194
+ assertChildIndex(capability.index);
195
+ if (!Number.isInteger(capability.pid) || capability.pid <= 0) throw new Error("steer capability pid must be a positive integer.");
196
+ if (!Number.isFinite(capability.readyAt) || capability.readyAt <= 0) throw new Error("steer capability readyAt must be a finite timestamp.");
197
+ const record: SteerCapability = { type: "steer-capability", protocolVersion: 1, ...capability };
198
+ writeAtomicJson(filePath, record);
199
+ return filePath;
200
+ }
201
+
202
+ export function writeSteerCapability(asyncDir: string, capability: Omit<SteerCapability, "type" | "protocolVersion">): string {
203
+ return writeSteerCapabilityAt(steerCapabilityPath(asyncDir, capability.index), capability);
204
+ }
205
+
206
+ export function writeSteerAckAt(filePath: string, ack: Omit<SteerAck, "type" | "protocolVersion">): string {
207
+ assertChildIndex(ack.index);
208
+ if (!/^[^\s]+$/.test(ack.requestId) || ack.requestId.length > 256) throw new Error("steer acknowledgment requestId is invalid.");
209
+ if (!Number.isFinite(ack.ts) || ack.ts <= 0) throw new Error("steer acknowledgment ts must be a finite timestamp.");
210
+ if (!ack.message.trim() || ack.message.length > 1000) throw new Error("steer acknowledgment message is invalid.");
211
+ const record: SteerAck = { type: "steer-ack", protocolVersion: 1, ...ack, message: ack.message.trim() };
212
+ writeAtomicJson(filePath, record);
213
+ return filePath;
214
+ }
215
+
216
+ export function writeSteerAck(asyncDir: string, ack: Omit<SteerAck, "type" | "protocolVersion">): string {
217
+ return writeSteerAckAt(path.join(steerAcksDir(asyncDir, ack.index), steerAckFileName(ack.requestId)), ack);
218
+ }
219
+
95
220
  /**
96
221
  * Parent side: drop a portable interrupt request the runner's inbox watcher will
97
222
  * pick up regardless of OS. Written atomically (temp + rename), dir auto-created.
@@ -118,46 +243,137 @@ export function requestAsyncTimeout(
118
243
  return requestPath;
119
244
  }
120
245
 
246
+ export function requestAsyncStop(
247
+ asyncDir: string,
248
+ payload: Omit<StopRequest, "type"> = {},
249
+ deps: { now?: () => number } = {},
250
+ ): string {
251
+ const requestPath = stopRequestPath(asyncDir);
252
+ const request: StopRequest = { ...payload, ts: payload.ts ?? deps.now?.() ?? Date.now(), type: "stop" };
253
+ writeAtomicJson(requestPath, request);
254
+ return requestPath;
255
+ }
256
+
121
257
  export function requestAsyncSteer(
122
258
  asyncDir: string,
123
- payload: { message: string; targetIndex?: number; source?: string; id?: string; ts?: number },
259
+ payload: { message: string; targetIndex?: number; targetIndexes?: number[]; source?: string; id?: string; ts?: number },
124
260
  deps: { now?: () => number; randomId?: () => string } = {},
125
261
  ): string {
126
262
  const message = payload.message.trim();
127
263
  if (!message) throw new Error("steer message must not be empty.");
128
- if (payload.targetIndex !== undefined && (!Number.isInteger(payload.targetIndex) || payload.targetIndex < 0)) {
129
- throw new Error("steer targetIndex must be a non-negative integer.");
264
+ if (Buffer.byteLength(message, "utf8") > MAX_STEER_MESSAGE_BYTES) throw new Error(`steer message exceeds ${MAX_STEER_MESSAGE_BYTES} UTF-8 bytes.`);
265
+ if (payload.targetIndex !== undefined && (!Number.isInteger(payload.targetIndex) || payload.targetIndex < 0 || payload.targetIndex > 1_000_000)) {
266
+ throw new Error("steer targetIndex must be an integer between 0 and 1000000.");
130
267
  }
268
+ if (payload.targetIndexes !== undefined && (
269
+ !Array.isArray(payload.targetIndexes)
270
+ || payload.targetIndex !== undefined
271
+ || payload.targetIndexes.length === 0
272
+ || payload.targetIndexes.length > 1_000
273
+ || payload.targetIndexes.some((index) => !Number.isInteger(index) || index < 0 || index > 1_000_000)
274
+ || new Set(payload.targetIndexes).size !== payload.targetIndexes.length
275
+ )) {
276
+ throw new Error("steer targetIndexes must contain 1-1000 unique non-negative integers and cannot be combined with targetIndex.");
277
+ }
278
+ const closedPath = steerInboxClosedPath(asyncDir);
279
+ if (fs.existsSync(closedPath)) throw new Error("Async run no longer accepts steering requests.");
131
280
  const request: SteerRequest = {
132
281
  type: "steer",
133
282
  id: payload.id ?? deps.randomId?.() ?? randomUUID(),
134
283
  ts: payload.ts ?? deps.now?.() ?? Date.now(),
135
284
  message,
136
285
  ...(payload.targetIndex !== undefined ? { targetIndex: payload.targetIndex } : {}),
286
+ ...(payload.targetIndexes !== undefined ? { targetIndexes: [...payload.targetIndexes] } : {}),
137
287
  ...(payload.source ? { source: payload.source } : {}),
138
288
  };
139
- return writeSteerRequestToDir(steerRequestsDir(asyncDir), request);
289
+ const requestPath = writeSteerRequestToDir(steerRequestsDir(asyncDir), request);
290
+ if (fs.existsSync(closedPath)) {
291
+ fs.rmSync(requestPath, { force: true });
292
+ throw new Error("Async run stopped accepting steering before the request was committed.");
293
+ }
294
+ return requestPath;
140
295
  }
141
296
 
142
297
  export function enqueueStepSteer(asyncDir: string, index: number, request: SteerRequest): string {
143
- if (!Number.isInteger(index) || index < 0) throw new Error("steer child index must be a non-negative integer.");
144
- return writeSteerRequestToDir(stepSteerInboxDir(asyncDir, index), { ...request, targetIndex: index, type: "steer" });
298
+ assertChildIndex(index);
299
+ const { targetIndexes: _targetIndexes, ...singleTargetRequest } = request;
300
+ return writeSteerRequestToDir(stepSteerInboxDir(asyncDir, index), { ...singleTargetRequest, targetIndex: index, type: "steer" });
301
+ }
302
+
303
+ function parseSteerCapability(raw: unknown): SteerCapability | undefined {
304
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
305
+ const input = raw as Partial<SteerCapability>;
306
+ if (input.type !== "steer-capability" || input.protocolVersion !== 1) return undefined;
307
+ if (!Number.isInteger(input.index) || input.index < 0 || input.index > 1_000_000) return undefined;
308
+ if (!Number.isInteger(input.pid) || input.pid <= 0 || !Number.isFinite(input.readyAt) || input.readyAt <= 0 || typeof input.supported !== "boolean") return undefined;
309
+ return { type: "steer-capability", protocolVersion: 1, index: input.index, pid: input.pid, readyAt: input.readyAt, supported: input.supported };
310
+ }
311
+
312
+ function parseSteerAck(raw: unknown): SteerAck | undefined {
313
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
314
+ const input = raw as Partial<SteerAck>;
315
+ if (input.type !== "steer-ack" || input.protocolVersion !== 1 || typeof input.requestId !== "string" || !/^[^\s]+$/.test(input.requestId) || input.requestId.length > 256) return undefined;
316
+ if (!Number.isInteger(input.index) || input.index < 0 || input.index > 1_000_000 || !Number.isFinite(input.ts) || input.ts <= 0) return undefined;
317
+ if (input.state !== "delivered" && input.state !== "failed") return undefined;
318
+ if (typeof input.message !== "string" || !input.message.trim() || input.message.length > 1000) return undefined;
319
+ return { type: "steer-ack", protocolVersion: 1, requestId: input.requestId, index: input.index, ts: input.ts, state: input.state, message: input.message.trim() };
320
+ }
321
+
322
+ export function readSteerCapability(asyncDir: string, index: number): SteerCapability | undefined {
323
+ try {
324
+ return parseSteerCapability(JSON.parse(fs.readFileSync(steerCapabilityPath(asyncDir, index), "utf-8")));
325
+ } catch {
326
+ return undefined;
327
+ }
328
+ }
329
+
330
+ export function consumeSteerCapabilities(asyncDir: string, fsImpl: Pick<typeof fs, "existsSync" | "readdirSync" | "readFileSync"> = fs): SteerCapability[] {
331
+ const dir = steerCapabilitiesDir(asyncDir);
332
+ if (!fsImpl.existsSync(dir)) return [];
333
+ const capabilities: SteerCapability[] = [];
334
+ for (const entry of fsImpl.readdirSync(dir).filter((name) => /^\d+\.json$/.test(name)).sort()) {
335
+ try {
336
+ const capability = parseSteerCapability(JSON.parse(fsImpl.readFileSync(path.join(dir, entry), "utf-8")));
337
+ if (capability) capabilities.push(capability);
338
+ } catch {
339
+ // A partially written or malformed capability is ignored until a valid one arrives.
340
+ }
341
+ }
342
+ return capabilities;
343
+ }
344
+
345
+ export function consumeSteerAcks(asyncDir: string, fsImpl: Pick<typeof fs, "existsSync" | "readdirSync" | "readFileSync" | "rmSync"> = fs): SteerAck[] {
346
+ const root = path.join(controlInboxDir(asyncDir), STEER_ACKS_DIR);
347
+ if (!fsImpl.existsSync(root)) return [];
348
+ const acks: SteerAck[] = [];
349
+ let indexNames: string[];
350
+ try { indexNames = fsImpl.readdirSync(root).filter((name) => /^\d+$/.test(name)); } catch { return []; }
351
+ for (const indexName of indexNames) {
352
+ const dir = path.join(root, indexName);
353
+ let entries: string[];
354
+ try { entries = fsImpl.readdirSync(dir).filter((name) => name.endsWith(".json")).sort(); } catch { continue; }
355
+ for (const entry of entries) {
356
+ const target = path.join(dir, entry);
357
+ let ack: SteerAck | undefined;
358
+ try { ack = parseSteerAck(JSON.parse(fsImpl.readFileSync(target, "utf-8"))); } catch { ack = undefined; }
359
+ try { fsImpl.rmSync(target, { force: true }); } catch { continue; }
360
+ if (ack) acks.push(ack);
361
+ }
362
+ }
363
+ return acks;
145
364
  }
146
365
 
147
366
  function parseSteerRequest(raw: unknown): SteerRequest | undefined {
148
367
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
149
368
  const input = raw as Partial<SteerRequest>;
150
- if (input.type !== "steer") return undefined;
151
- if (typeof input.id !== "string" || !input.id.trim()) return undefined;
152
- if (typeof input.ts !== "number" || !Number.isFinite(input.ts)) return undefined;
153
- if (typeof input.message !== "string" || !input.message.trim()) return undefined;
154
- if (input.targetIndex !== undefined && (!Number.isInteger(input.targetIndex) || input.targetIndex < 0)) return undefined;
369
+ if (!validSteerRequest(input)) return undefined;
155
370
  return {
156
371
  type: "steer",
157
372
  id: input.id.trim(),
158
373
  ts: input.ts,
159
374
  message: input.message.trim(),
160
375
  ...(input.targetIndex !== undefined ? { targetIndex: input.targetIndex } : {}),
376
+ ...(input.targetIndexes !== undefined ? { targetIndexes: [...input.targetIndexes] } : {}),
161
377
  ...(typeof input.source === "string" && input.source.trim() ? { source: input.source } : {}),
162
378
  };
163
379
  }
@@ -220,6 +436,20 @@ export function consumeTimeoutRequest(
220
436
  return true;
221
437
  }
222
438
 
439
+ export function consumeStopRequest(
440
+ asyncDir: string,
441
+ fsImpl: Pick<typeof fs, "existsSync" | "rmSync"> = fs,
442
+ ): boolean {
443
+ const requestPath = stopRequestPath(asyncDir);
444
+ if (!fsImpl.existsSync(requestPath)) return false;
445
+ try {
446
+ fsImpl.rmSync(requestPath, { force: true, recursive: true });
447
+ } catch {
448
+ // Already removed by a concurrent check — still counts as consumed.
449
+ }
450
+ return true;
451
+ }
452
+
223
453
  /**
224
454
  * Parent side: portable interrupt = authoritative file request + best-effort OS
225
455
  * signal. The signal is only a latency optimization on Unix; ENOSYS on Windows
@@ -265,6 +495,17 @@ export function deliverTimeoutRequest(input: {
265
495
  requestAsyncTimeout(input.asyncDir, input.source ? { source: input.source } : {}, { now: input.now });
266
496
  }
267
497
 
498
+ export function deliverStopRequest(input: {
499
+ asyncDir: string;
500
+ pid?: number;
501
+ kill?: KillFn;
502
+ signal?: NodeJS.Signals;
503
+ now?: () => number;
504
+ source?: string;
505
+ }): void {
506
+ requestAsyncStop(input.asyncDir, input.source ? { source: input.source } : {}, { now: input.now });
507
+ }
508
+
268
509
  /**
269
510
  * Runner side: watch the control inbox and route interrupt requests into
270
511
  * `onInterrupt`. Uses `fs.watch` when available plus an interval poll as a
@@ -276,7 +517,10 @@ export function watchAsyncControlInbox(
276
517
  opts: {
277
518
  onInterrupt: () => void;
278
519
  onTimeout?: () => void;
520
+ onStop?: () => void;
279
521
  onSteer?: (request: SteerRequest) => void;
522
+ onSteerCapability?: (capability: SteerCapability) => void;
523
+ onSteerAck?: (ack: SteerAck) => void;
280
524
  pollIntervalMs?: number;
281
525
  fs?: ControlChannelFs;
282
526
  timers?: ControlChannelTimers;
@@ -295,9 +539,12 @@ export function watchAsyncControlInbox(
295
539
  const check = (): void => {
296
540
  if (disposed) return;
297
541
  try {
542
+ if (consumeStopRequest(asyncDir, fsImpl)) opts.onStop?.();
298
543
  if (consumeTimeoutRequest(asyncDir, fsImpl)) opts.onTimeout?.();
299
544
  if (consumeInterruptRequest(asyncDir, fsImpl)) opts.onInterrupt();
300
545
  for (const request of consumeSteerRequests(asyncDir, fsImpl)) opts.onSteer?.(request);
546
+ for (const capability of consumeSteerCapabilities(asyncDir, fsImpl)) opts.onSteerCapability?.(capability);
547
+ for (const ack of consumeSteerAcks(asyncDir, fsImpl)) opts.onSteerAck?.(ack);
301
548
  } catch {
302
549
  // Never let inbox errors crash the runner.
303
550
  }
@@ -308,7 +555,7 @@ export function watchAsyncControlInbox(
308
555
 
309
556
  let watcher: fs.FSWatcher | undefined;
310
557
  try {
311
- watcher = fsImpl.watch(dir, () => check());
558
+ watcher = fsImpl.watch(resolveWatchPath(dir, fsImpl.realpathSync.native), () => check());
312
559
  watcher.on?.("error", () => {
313
560
  // fs.watch can emit on transient FS errors; the interval poll keeps us live.
314
561
  });
@@ -23,6 +23,7 @@ const MAX_TRANSCRIPT_LINES = 500;
23
23
  const TRANSCRIPT_TAIL_BYTES = 256 * 1024;
24
24
 
25
25
  type ForegroundControl = SubagentState["foregroundControls"] extends Map<string, infer T> ? T : never;
26
+ type ForegroundRun = NonNullable<SubagentState["foregroundRuns"]> extends Map<string, infer T> ? T : never;
26
27
 
27
28
  interface FleetViewParams {
28
29
  lines?: number;
@@ -254,6 +255,20 @@ function formatForegroundFleetLines(controls: ForegroundControl[]): string[] {
254
255
  return lines;
255
256
  }
256
257
 
258
+ function formatDetachedForegroundFleetLines(runs: ForegroundRun[]): string[] {
259
+ if (runs.length === 0) return [];
260
+ const lines = ["Detached foreground runs:"];
261
+ const ordered = [...runs].sort((left, right) => right.updatedAt - left.updatedAt);
262
+ for (const run of ordered) {
263
+ const detachedChildren = run.children.filter((child) => child.status === "detached");
264
+ const childSummary = detachedChildren.map((child) => `${child.agent} #${child.index}`).join(", ");
265
+ lines.push(`- ${run.runId} | detached | ${run.mode}${childSummary ? ` | ${childSummary}` : ""}`);
266
+ lines.push(` status: subagent({ action: "status", id: "${run.runId}" })`);
267
+ lines.push(` recovery: reply to the supervisor request first, then wait with subagent_wait({ id: "${run.runId}" }); do not resume or launch a replacement while any child remains detached.`);
268
+ }
269
+ return lines;
270
+ }
271
+
257
272
  function formatAsyncFleetLines(runs: AsyncRunSummary[]): string[] {
258
273
  if (runs.length === 0) return [];
259
274
  const lines = ["Async runs:"];
@@ -316,7 +331,11 @@ export function inspectSubagentFleet(_params: FleetViewParams, deps: FleetViewDe
316
331
  }
317
332
 
318
333
  const foregroundControls = deps.state ? [...deps.state.foregroundControls.values()] : [];
319
- const total = foregroundControls.length + asyncRuns.length;
334
+ const activeForegroundIds = new Set(foregroundControls.map((control) => control.runId));
335
+ const detachedForegroundRuns = deps.state?.foregroundRuns
336
+ ? [...deps.state.foregroundRuns.values()].filter((run) => run.sessionId === deps.state?.currentSessionId && !activeForegroundIds.has(run.runId) && run.children.some((child) => child.status === "detached"))
337
+ : [];
338
+ const total = foregroundControls.length + detachedForegroundRuns.length + asyncRuns.length;
320
339
  if (total === 0) {
321
340
  return {
322
341
  content: [{ type: "text", text: "No active subagent fleet. Background runs that already finished are available through completion notifications or subagent({ action: \"status\", id: \"...\" })." }],
@@ -324,9 +343,11 @@ export function inspectSubagentFleet(_params: FleetViewParams, deps: FleetViewDe
324
343
  };
325
344
  }
326
345
 
327
- const lines = [`Subagent fleet: ${total} active`, ""];
346
+ const lines = [`Subagent fleet: ${total} tracked`, ""];
328
347
  const foregroundLines = formatForegroundFleetLines(foregroundControls);
329
348
  if (foregroundLines.length) lines.push(...foregroundLines, "");
349
+ const detachedForegroundLines = formatDetachedForegroundFleetLines(detachedForegroundRuns);
350
+ if (detachedForegroundLines.length) lines.push(...detachedForegroundLines, "");
330
351
  const asyncLines = formatAsyncFleetLines(asyncRuns);
331
352
  if (asyncLines.length) lines.push(...asyncLines, "");
332
353
  lines.push("Commands:");
@@ -16,7 +16,7 @@ import {
16
16
  createCompletionBatcher,
17
17
  resolveCompletionBatchConfig,
18
18
  } from "./completion-batcher.ts";
19
- import { SUBAGENT_ASYNC_COMPLETE_EVENT, type SubagentState } from "../../shared/types.ts";
19
+ import { SUBAGENT_ASYNC_COMPLETE_EVENT, SUBAGENT_FOREGROUND_COMPLETE_EVENT, type SubagentState } from "../../shared/types.ts";
20
20
 
21
21
  interface ChainStepResult {
22
22
  agent: string;
@@ -27,6 +27,7 @@ interface ChainStepResult {
27
27
  export interface SubagentNotifyDetails {
28
28
  agent: string;
29
29
  status: "completed" | "failed" | "paused";
30
+ source?: "async" | "foreground";
30
31
  taskInfo?: string;
31
32
  resultPreview: string;
32
33
  durationMs?: number;
@@ -36,6 +37,7 @@ export interface SubagentNotifyDetails {
36
37
 
37
38
  interface SubagentResult {
38
39
  id: string | null;
40
+ source?: "async" | "foreground";
39
41
  agent: string | null;
40
42
  success: boolean;
41
43
  summary: string;
@@ -72,8 +74,9 @@ function formatSessionLine(details: SubagentNotifyDetails): string | undefined {
72
74
 
73
75
  export function formatSingleCompletion(details: SubagentNotifyDetails): string {
74
76
  const sessionLine = formatSessionLine(details);
77
+ const taskKind = details.source === "foreground" ? "Detached foreground task" : "Background task";
75
78
  return [
76
- `Background task ${details.status}: **${details.agent}**${details.taskInfo ?? ""}`,
79
+ `${taskKind} ${details.status}: **${details.agent}**${details.taskInfo ?? ""}`,
77
80
  "",
78
81
  details.resultPreview.trim() ? details.resultPreview : "(no output)",
79
82
  sessionLine ? "" : undefined,
@@ -83,6 +86,37 @@ export function formatSingleCompletion(details: SubagentNotifyDetails): string {
83
86
  .join("\n");
84
87
  }
85
88
 
89
+ export function parseSubagentNotifyContent(content: string): SubagentNotifyDetails | undefined {
90
+ const lines = content.split("\n");
91
+ const match = (lines[0] ?? "").match(/^(Background task|Detached foreground task) (completed|failed|paused): \*\*(.+?)\*\*(?:\s+(\([^)]*\)))?$/);
92
+ if (!match) return undefined;
93
+ const body = lines.slice(2);
94
+ let sessionIndex = -1;
95
+ for (let i = body.length - 1; i >= 1; i--) {
96
+ if (body[i - 1]?.trim() === "" && /^(Session|Session file|Session share error):\s+/.test(body[i]!)) {
97
+ sessionIndex = i;
98
+ break;
99
+ }
100
+ }
101
+ const sessionLine = sessionIndex >= 0 ? body[sessionIndex] : undefined;
102
+ const resultPreview = (sessionIndex >= 0 ? body.slice(0, sessionIndex) : body).join("\n").trim() || "(no output)";
103
+ let sessionLabel: string | undefined;
104
+ let sessionValue: string | undefined;
105
+ if (sessionLine) {
106
+ const separator = sessionLine.indexOf(":");
107
+ sessionLabel = sessionLine.slice(0, separator).toLowerCase();
108
+ sessionValue = sessionLine.slice(separator + 1).trim();
109
+ }
110
+ return {
111
+ agent: match[3]!,
112
+ status: match[2] as SubagentNotifyDetails["status"],
113
+ ...(match[1] === "Detached foreground task" ? { source: "foreground" as const } : {}),
114
+ ...(match[4] ? { taskInfo: match[4] } : {}),
115
+ resultPreview,
116
+ ...(sessionLabel && sessionValue ? { sessionLabel, sessionValue } : {}),
117
+ };
118
+ }
119
+
86
120
  export function formatGroupedCompletion(details: SubagentNotifyDetails[]): string {
87
121
  const header = `Background tasks completed (${details.length}): ${details.map((d) => `**${d.agent}**${d.taskInfo ?? ""}`).join(", ")}`;
88
122
  const blocks: string[] = [header, ""];
@@ -147,6 +181,7 @@ export function buildCompletionDetails(result: SubagentResult): SubagentNotifyDe
147
181
  return {
148
182
  agent,
149
183
  status,
184
+ ...(result.source ? { source: result.source } : {}),
150
185
  ...(taskInfo ? { taskInfo } : {}),
151
186
  resultPreview: summary,
152
187
  ...(typeof result.durationMs === "number" ? { durationMs: result.durationMs } : {}),
@@ -158,7 +193,7 @@ export default function registerSubagentNotify(
158
193
  pi: ExtensionAPI,
159
194
  state: Pick<SubagentState, "currentSessionId">,
160
195
  options: RegisterSubagentNotifyOptions = {},
161
- ): void {
196
+ ): () => void {
162
197
  const unsubscribeStoreKey = "__pi_subagents_notify_unsubscribe__";
163
198
  const batcherStoreKey = "__pi_subagents_notify_batcher__";
164
199
  const globalStore = globalThis as Record<string, unknown>;
@@ -184,14 +219,10 @@ export default function registerSubagentNotify(
184
219
  const nowFn = options.now ?? Date.now;
185
220
  const batchConfig = resolveCompletionBatchConfig(options.batchConfig);
186
221
  const batchers = new Map<string, CompletionBatcher<SubagentNotifyDetails>>();
187
- globalStore[batcherStoreKey] = {
188
- dispose() {
189
- for (const batcher of batchers.values()) batcher.dispose();
190
- batchers.clear();
191
- },
192
- };
222
+ let disposed = false;
193
223
 
194
224
  const handleComplete = (data: unknown) => {
225
+ if (disposed) return;
195
226
  const result = data as SubagentResult;
196
227
  if (typeof result.sessionId !== "string" || result.sessionId !== state.currentSessionId) return;
197
228
  const now = nowFn();
@@ -199,6 +230,10 @@ export default function registerSubagentNotify(
199
230
  if (markSeenWithTtl(seen, key, now, ttlMs)) return;
200
231
 
201
232
  const details = buildCompletionDetails(result);
233
+ if (result.source === "foreground") {
234
+ sendCompletion(pi, [details]);
235
+ return;
236
+ }
202
237
  const batchKey = completionBatchKey(result);
203
238
  let batcher = batchers.get(batchKey);
204
239
  if (!batcher) {
@@ -221,5 +256,39 @@ export default function registerSubagentNotify(
221
256
  batcher.push(details);
222
257
  };
223
258
 
224
- globalStore[unsubscribeStoreKey] = pi.events.on(SUBAGENT_ASYNC_COMPLETE_EVENT, handleComplete);
259
+ const unsubscribers = [
260
+ pi.events.on(SUBAGENT_ASYNC_COMPLETE_EVENT, handleComplete),
261
+ pi.events.on(SUBAGENT_FOREGROUND_COMPLETE_EVENT, handleComplete),
262
+ ].filter((unsubscribe): unsubscribe is () => void => typeof unsubscribe === "function");
263
+ const batcherRegistration = { dispose };
264
+
265
+ function dispose(): void {
266
+ if (disposed) return;
267
+ disposed = true;
268
+ for (const batcher of batchers.values()) {
269
+ try {
270
+ batcher.dispose();
271
+ } catch {
272
+ // Best effort cleanup must continue through every owned batcher.
273
+ }
274
+ }
275
+ batchers.clear();
276
+ for (const unsubscribe of unsubscribers) {
277
+ try {
278
+ unsubscribe();
279
+ } catch {
280
+ // Best effort cleanup must continue through every owned handler.
281
+ }
282
+ }
283
+ if (globalStore[unsubscribeStoreKey] === dispose) {
284
+ delete globalStore[unsubscribeStoreKey];
285
+ }
286
+ if (globalStore[batcherStoreKey] === batcherRegistration) {
287
+ delete globalStore[batcherStoreKey];
288
+ }
289
+ }
290
+
291
+ globalStore[unsubscribeStoreKey] = dispose;
292
+ globalStore[batcherStoreKey] = batcherRegistration;
293
+ return dispose;
225
294
  }
@@ -17,6 +17,7 @@ import {
17
17
  resolveSubagentResultStatus,
18
18
  } from "../../intercom/result-intercom.ts";
19
19
  import { projectNestedRegistryForRoot, sanitizeSummary } from "../shared/nested-events.ts";
20
+ import { resolveWatchPath } from "../../shared/utils.ts";
20
21
 
21
22
  const WATCHER_RESTART_DELAY_MS = 3000;
22
23
  const POLL_INTERVAL_MS = 3000;
@@ -40,6 +41,8 @@ type ResultFileChild = {
40
41
  output?: string;
41
42
  error?: string;
42
43
  success?: boolean;
44
+ state?: string;
45
+ stopped?: boolean;
43
46
  sessionFile?: string;
44
47
  artifactPaths?: { outputPath?: string };
45
48
  intercomTarget?: string;
@@ -91,13 +94,6 @@ function shouldFallBackToPolling(error: unknown): boolean {
91
94
  return code === "EMFILE" || code === "ENOSPC";
92
95
  }
93
96
 
94
- function resolveNativeWatchDir(fsApi: ResultWatcherFs, resultsDir: string): string {
95
- try {
96
- return fsApi.realpathSync.native(resultsDir);
97
- } catch {
98
- return resultsDir;
99
- }
100
- }
101
97
 
102
98
  export function createResultWatcher(
103
99
  pi: { events: IntercomEventBus },
@@ -155,11 +151,18 @@ export function createResultWatcher(
155
151
  : output;
156
152
  const sessionPath = result.sessionFile ?? (resultChildren.length === 1 ? data.sessionFile : undefined);
157
153
  const childNestedChildren = sanitizeNestedResultChildren(result.children, resultPath, `results[${index}].children`);
154
+ const childState = result.state === "paused" || result.state === "stopped"
155
+ ? result.state
156
+ : result.stopped === true
157
+ ? "stopped"
158
+ : data.state === "paused" || (!hasResultChildren && (data.state === "stopped" || typeof result.success !== "boolean"))
159
+ ? data.state
160
+ : undefined;
158
161
  return {
159
162
  agent: result.agent ?? data.agent ?? `step-${index + 1}`,
160
163
  status: resolveSubagentResultStatus({
161
164
  success: result.success,
162
- state: data.state === "paused" || typeof result.success !== "boolean" ? data.state : undefined,
165
+ state: childState,
163
166
  }),
164
167
  summary,
165
168
  index,
@@ -271,7 +274,7 @@ export function createResultWatcher(
271
274
  state.watcherRestartTimer = null;
272
275
  }
273
276
  try {
274
- const watchDir = resolveNativeWatchDir(fsApi, resultsDir);
277
+ const watchDir = resolveWatchPath(resultsDir, fsApi.realpathSync.native);
275
278
  state.watcher = fsApi.watch(watchDir, (ev, file) => {
276
279
  if (ev !== "rename" || !file) return;
277
280
  const fileName = file.toString();