pi-subagents 0.35.1 → 0.37.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 (89) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/README.md +224 -31
  3. package/agents/advisor.md +73 -0
  4. package/agents/delegate.md +2 -0
  5. package/agents/worker.md +2 -0
  6. package/package.json +11 -13
  7. package/skills/pi-subagents/SKILL.md +42 -13
  8. package/src/agents/agent-management.ts +7 -1
  9. package/src/agents/agents.ts +121 -17
  10. package/src/api/capability-ceiling.ts +17 -0
  11. package/src/api/delegation.ts +127 -0
  12. package/src/api/preflight.ts +399 -0
  13. package/src/extension/config.ts +7 -1
  14. package/src/extension/index.ts +52 -38
  15. package/src/extension/rpc.ts +31 -2
  16. package/src/extension/schemas.ts +29 -3
  17. package/src/extension/tool-description.ts +4 -2
  18. package/src/intercom/intercom-bridge.ts +1 -1
  19. package/src/intercom/native-supervisor-channel.ts +45 -6
  20. package/src/intercom/result-intercom.ts +7 -0
  21. package/src/runs/background/async-execution.ts +158 -17
  22. package/src/runs/background/async-job-tracker.ts +4 -0
  23. package/src/runs/background/async-resume.ts +47 -8
  24. package/src/runs/background/async-status.ts +94 -3
  25. package/src/runs/background/chain-append.ts +2 -0
  26. package/src/runs/background/completion-batcher.ts +6 -4
  27. package/src/runs/background/completion-dedupe.ts +2 -11
  28. package/src/runs/background/fleet-view.ts +9 -4
  29. package/src/runs/background/notify.ts +132 -120
  30. package/src/runs/background/process-terminal.ts +280 -0
  31. package/src/runs/background/result-watcher.ts +138 -78
  32. package/src/runs/background/run-status.ts +10 -2
  33. package/src/runs/background/scheduled-runs.ts +6 -1
  34. package/src/runs/background/stale-run-reconciler.ts +6 -0
  35. package/src/runs/background/subagent-runner.ts +406 -54
  36. package/src/runs/background/subagent-wait.ts +130 -4
  37. package/src/runs/background/wait-tool.ts +2 -2
  38. package/src/runs/foreground/chain-execution.ts +181 -111
  39. package/src/runs/foreground/execution.ts +147 -47
  40. package/src/runs/foreground/foreground-control.ts +90 -0
  41. package/src/runs/foreground/subagent-executor.ts +426 -165
  42. package/src/runs/shared/acceptance.ts +94 -41
  43. package/src/runs/shared/agent-contract.ts +38 -0
  44. package/src/runs/shared/capability-ceiling.ts +177 -0
  45. package/src/runs/shared/child-protocol.ts +1 -1
  46. package/src/runs/shared/completion-guard.ts +36 -5
  47. package/src/runs/shared/context-mode.ts +44 -0
  48. package/src/runs/shared/dynamic-fanout.ts +5 -5
  49. package/src/runs/shared/long-running-guard.ts +4 -0
  50. package/src/runs/shared/mcp-direct-tool-allowlist.ts +12 -6
  51. package/src/runs/shared/nested-events.ts +35 -3
  52. package/src/runs/shared/parallel-handoff.ts +154 -0
  53. package/src/runs/shared/parallel-utils.ts +11 -0
  54. package/src/runs/shared/pi-args.ts +148 -56
  55. package/src/runs/shared/run-history.ts +90 -5
  56. package/src/runs/shared/session-lease.ts +25 -5
  57. package/src/runs/shared/structured-output.ts +112 -7
  58. package/src/runs/shared/subagent-control.ts +4 -0
  59. package/src/runs/shared/subagent-prompt-runtime.ts +31 -19
  60. package/src/runs/shared/task-intent.ts +10 -5
  61. package/src/runs/shared/tool-availability.ts +21 -3
  62. package/src/runs/shared/tool-budget.ts +11 -5
  63. package/src/runs/shared/turn-budget.ts +2 -1
  64. package/src/runs/shared/worktree.ts +63 -14
  65. package/src/shared/accessible-dir.ts +25 -0
  66. package/src/shared/artifacts.ts +37 -7
  67. package/src/shared/atomic-json.ts +14 -42
  68. package/src/shared/child-transcript.ts +52 -0
  69. package/src/shared/file-system-retry.ts +47 -0
  70. package/src/shared/launch-contract.ts +123 -0
  71. package/src/shared/settings.ts +9 -1
  72. package/src/shared/types.ts +314 -28
  73. package/src/shared/utils.ts +17 -42
  74. package/src/slash/delegation-adapters.ts +153 -6
  75. package/src/slash/delegation-json.ts +108 -0
  76. package/src/slash/delegation-request.ts +182 -36
  77. package/src/slash/prompt-template-bridge.ts +222 -37
  78. package/src/slash/selector.ts +147 -0
  79. package/src/slash/slash-commands.ts +15 -6
  80. package/src/slash/slash-live-state.ts +2 -2
  81. package/src/slash/subagents-admin.ts +42 -42
  82. package/src/tui/fleet-status.ts +362 -0
  83. package/src/tui/fleet-transcript.ts +472 -0
  84. package/src/tui/fleet.ts +318 -59
  85. package/src/tui/render.ts +17 -15
  86. package/src/watchdog/change-signature.ts +105 -12
  87. package/src/watchdog/review.ts +7 -2
  88. package/src/watchdog/runtime.ts +5 -3
  89. package/src/slash/subagents-editor.ts +0 -86
@@ -6,6 +6,7 @@ import {
6
6
  SUBAGENT_ASYNC_COMPLETE_EVENT,
7
7
  type IntercomEventBus,
8
8
  type NestedRunSummary,
9
+ type ParallelHandoffReference,
9
10
  type SubagentResultIntercomChild,
10
11
  type SubagentState,
11
12
  } from "../../shared/types.ts";
@@ -18,9 +19,11 @@ import {
18
19
  } from "../../intercom/result-intercom.ts";
19
20
  import { projectNestedRegistryForRoot, sanitizeSummary } from "../shared/nested-events.ts";
20
21
  import { resolveWatchPath } from "../../shared/utils.ts";
22
+ import type { CompletionNotifier, CompletionNotification } from "./notify.ts";
21
23
 
22
24
  const WATCHER_RESTART_DELAY_MS = 3000;
23
25
  const POLL_INTERVAL_MS = 3000;
26
+ const RETRY_DELAY_MS = 100;
24
27
 
25
28
  type ResultWatcherFs = Pick<typeof fs, "existsSync" | "readFileSync" | "unlinkSync" | "readdirSync" | "mkdirSync" | "realpathSync" | "watch">;
26
29
 
@@ -34,6 +37,7 @@ type ResultWatcherTimers = {
34
37
  type ResultWatcherDeps = {
35
38
  fs?: ResultWatcherFs;
36
39
  timers?: ResultWatcherTimers;
40
+ notifier?: Pick<CompletionNotifier, "deliver">;
37
41
  };
38
42
 
39
43
  type ResultFileChild = {
@@ -49,21 +53,14 @@ type ResultFileChild = {
49
53
  children?: unknown;
50
54
  };
51
55
 
52
- type ResultFileData = {
53
- id?: string;
56
+ type ResultFileData = CompletionNotification & {
54
57
  runId?: string;
55
- agent?: string;
56
- success?: boolean;
57
- state?: string;
58
58
  mode?: string;
59
- summary?: string;
60
59
  results?: ResultFileChild[];
61
60
  nestedChildren?: unknown;
62
- sessionId?: string;
63
- cwd?: string;
64
- sessionFile?: string;
65
61
  asyncDir?: string;
66
62
  intercomTarget?: string;
63
+ parallelHandoff?: ParallelHandoffReference;
67
64
  };
68
65
 
69
66
  function sanitizeNestedResultChildren(value: unknown, resultPath: string, label: string): NestedRunSummary[] | undefined {
@@ -79,22 +76,24 @@ function sanitizeNestedResultChildren(value: unknown, resultPath: string, label:
79
76
  return children.length ? children : undefined;
80
77
  }
81
78
 
82
- function getErrorCode(error: unknown): string | undefined {
83
- return typeof error === "object" && error !== null && "code" in error
84
- ? (error as NodeJS.ErrnoException).code
85
- : undefined;
79
+ function errorCode(error: unknown): string | undefined {
80
+ return typeof error === "object" && error !== null && "code" in error ? (error as NodeJS.ErrnoException).code : undefined;
86
81
  }
87
82
 
88
- function isNotFoundError(error: unknown): boolean {
89
- return getErrorCode(error) === "ENOENT";
83
+ function isNotFound(error: unknown): boolean {
84
+ return errorCode(error) === "ENOENT";
90
85
  }
91
86
 
92
- function shouldFallBackToPolling(error: unknown): boolean {
93
- const code = getErrorCode(error);
87
+ function shouldPoll(error: unknown): boolean {
88
+ const code = errorCode(error);
94
89
  return code === "EMFILE" || code === "ENOSPC";
95
90
  }
96
91
 
97
-
92
+ /**
93
+ * Watches persisted async results for the session currently owned by this
94
+ * runtime. `stopResultWatcher()` revokes ownership before closing resources,
95
+ * so old callbacks can never emit or delete after reload/session replacement.
96
+ */
98
97
  export function createResultWatcher(
99
98
  pi: { events: IntercomEventBus },
100
99
  state: SubagentState,
@@ -103,18 +102,41 @@ export function createResultWatcher(
103
102
  deps: ResultWatcherDeps = {},
104
103
  ): {
105
104
  startResultWatcher: () => void;
106
- primeExistingResults: () => void;
105
+ primeExistingResults: (options?: { triggerTurn?: boolean }) => void;
107
106
  stopResultWatcher: () => void;
108
107
  } {
109
108
  const fsApi = deps.fs ?? fs;
110
109
  const timers = deps.timers ?? { setTimeout, clearTimeout, setInterval, clearInterval };
110
+ const notifier = deps.notifier ?? { deliver: async () => true };
111
+ const pendingTriggerTurn = new Map<string, boolean>();
112
+ const processing = new Set<string>();
113
+ let deliveryActive = true;
114
+ let deliveryEpoch = 0;
115
+ // The sole in-memory ownership lease. It is acquired for one active session
116
+ // and revoked before the watcher, queues, or callbacks are torn down.
117
+ let activeSessionId: string | null = null;
111
118
 
112
- const handleResult = async (file: string) => {
119
+ const ownsSession = (sessionId: string, epoch: number) => {
120
+ if (!deliveryActive || epoch !== deliveryEpoch) return false;
121
+ if (!activeSessionId && state.currentSessionId) activeSessionId = state.currentSessionId;
122
+ return activeSessionId === sessionId && state.currentSessionId === sessionId;
123
+ };
124
+
125
+ const scheduleResult = (file: string, triggerTurn: boolean, delayMs = 0) => {
126
+ const pendingMode = pendingTriggerTurn.get(file);
127
+ pendingTriggerTurn.set(file, pendingMode === false || !triggerTurn ? false : true);
128
+ state.resultFileCoalescer.schedule(file, delayMs);
129
+ };
130
+
131
+ const handleResult = async (file: string, triggerTurn: boolean) => {
113
132
  const resultPath = path.join(resultsDir, file);
114
- if (!fsApi.existsSync(resultPath)) return;
133
+ if (processing.has(file) || !fsApi.existsSync(resultPath)) return;
134
+ processing.add(file);
115
135
  try {
116
136
  const data = JSON.parse(fsApi.readFileSync(resultPath, "utf-8")) as ResultFileData;
117
- if (typeof data.sessionId !== "string" || data.sessionId !== state.currentSessionId) return;
137
+ if (typeof data.sessionId !== "string" || !data.sessionId) return;
138
+ const epoch = deliveryEpoch;
139
+ if (!ownsSession(data.sessionId, epoch)) return;
118
140
 
119
141
  const runId = data.runId ?? data.id ?? file.replace(/\.json$/i, "");
120
142
  const hasExplicitNestedChildren = data.nestedChildren !== undefined;
@@ -124,24 +146,32 @@ export function createResultWatcher(
124
146
  nestedChildren = compactNestedResultChildren(projectNestedRegistryForRoot(runId)?.children);
125
147
  } catch (error) {
126
148
  console.error(`Failed to enrich subagent result file '${resultPath}' with nested registry children; will retry later:`, error);
149
+ scheduleResult(file, triggerTurn, RETRY_DELAY_MS);
127
150
  return;
128
151
  }
129
152
  }
130
- const now = Date.now();
153
+
131
154
  const completionKey = buildCompletionKey(data, `result:${file}`);
132
- if (markSeenWithTtl(state.completionSeen, completionKey, now, completionTtlMs)) {
133
- fsApi.unlinkSync(resultPath);
155
+ const lastSeenAt = state.completionSeen.get(completionKey);
156
+ if (lastSeenAt !== undefined && Date.now() - lastSeenAt > completionTtlMs) {
157
+ state.completionSeen.delete(completionKey);
158
+ } else if (lastSeenAt !== undefined) {
159
+ if (!ownsSession(data.sessionId, epoch) || !fsApi.existsSync(resultPath)) return;
160
+ try {
161
+ fsApi.unlinkSync(resultPath);
162
+ } catch (error) {
163
+ if (!isNotFound(error)) {
164
+ console.error(`Failed to remove delivered subagent result '${resultPath}'; will retry:`, error);
165
+ scheduleResult(file, triggerTurn, RETRY_DELAY_MS);
166
+ }
167
+ }
134
168
  return;
135
169
  }
136
170
 
137
171
  const hasResultChildren = Array.isArray(data.results) && data.results.length > 0;
138
- const resultChildren = hasResultChildren
172
+ const resultChildren: ResultFileChild[] = hasResultChildren
139
173
  ? data.results!
140
- : [{
141
- agent: data.agent,
142
- output: data.summary,
143
- success: data.success,
144
- }];
174
+ : [{ agent: data.agent ?? undefined, output: data.summary, success: data.success }];
145
175
  const normalizedChildren = attachNestedChildrenToResultChildren(runId, resultChildren.map((result = {}, index): SubagentResultIntercomChild => {
146
176
  const baseOutput = result.output ?? data.summary;
147
177
  const hasRealOutput = typeof baseOutput === "string" && baseOutput.trim().length > 0;
@@ -160,10 +190,7 @@ export function createResultWatcher(
160
190
  : undefined;
161
191
  return {
162
192
  agent: result.agent ?? data.agent ?? `step-${index + 1}`,
163
- status: resolveSubagentResultStatus({
164
- success: result.success,
165
- state: childState,
166
- }),
193
+ status: resolveSubagentResultStatus({ success: result.success, state: childState }),
167
194
  summary,
168
195
  index,
169
196
  artifactPath: result.artifactPaths?.outputPath,
@@ -174,11 +201,11 @@ export function createResultWatcher(
174
201
  }), nestedChildren);
175
202
 
176
203
  const intercomTarget = data.intercomTarget?.trim();
177
- if (intercomTarget) {
204
+ if (intercomTarget && triggerTurn) {
178
205
  const mode = data.mode === "single" || data.mode === "parallel" || data.mode === "chain"
179
206
  ? data.mode
180
207
  : resultChildren.length > 1 ? "chain" : "single";
181
- const payload = buildSubagentResultIntercomPayload({
208
+ const delivered = await deliverSubagentResultIntercomEvent(pi.events, buildSubagentResultIntercomPayload({
182
209
  to: intercomTarget,
183
210
  runId,
184
211
  mode,
@@ -186,20 +213,45 @@ export function createResultWatcher(
186
213
  children: normalizedChildren,
187
214
  asyncId: data.id,
188
215
  asyncDir: data.asyncDir,
189
- });
190
- const delivered = await deliverSubagentResultIntercomEvent(pi.events, payload);
191
- if (!delivered) {
192
- console.error(`Subagent async grouped result intercom delivery was not acknowledged for '${resultPath}'.`);
193
- }
216
+ ...(data.parallelHandoff ? { parallelHandoff: data.parallelHandoff } : {}),
217
+ }));
218
+ if (!ownsSession(data.sessionId, epoch)) return;
219
+ if (!delivered) console.error(`Subagent async grouped result intercom delivery was not acknowledged for '${resultPath}'.`);
194
220
  }
195
221
 
196
- pi.events.emit(SUBAGENT_ASYNC_COMPLETE_EVENT, {
222
+ const accepted = await notifier.deliver({
197
223
  ...data,
224
+ id: data.id ?? runId,
198
225
  runId,
226
+ triggerTurn,
199
227
  ...(nestedChildren?.length ? { nestedChildren } : {}),
200
228
  ...(Array.isArray(data.results) ? {
201
- results: hasResultChildren
202
- ? normalizedChildren.map((child, index) => ({
229
+ results: hasResultChildren ? normalizedChildren.map((child, index) => ({
230
+ ...data.results![index],
231
+ agent: child.agent,
232
+ status: child.status,
233
+ summary: child.summary,
234
+ index: child.index,
235
+ artifactPath: child.artifactPath,
236
+ sessionPath: child.sessionPath,
237
+ children: child.children,
238
+ })) : [],
239
+ } : {}),
240
+ });
241
+ if (!ownsSession(data.sessionId, epoch)) return;
242
+ if (!accepted) {
243
+ scheduleResult(file, triggerTurn, RETRY_DELAY_MS);
244
+ return;
245
+ }
246
+ markSeenWithTtl(state.completionSeen, completionKey, Date.now(), completionTtlMs);
247
+ try {
248
+ pi.events.emit(SUBAGENT_ASYNC_COMPLETE_EVENT, {
249
+ ...data,
250
+ runId,
251
+ triggerTurn,
252
+ ...(nestedChildren?.length ? { nestedChildren } : {}),
253
+ ...(Array.isArray(data.results) ? {
254
+ results: hasResultChildren ? normalizedChildren.map((child, index) => ({
203
255
  ...data.results![index],
204
256
  agent: child.agent,
205
257
  status: child.status,
@@ -208,40 +260,50 @@ export function createResultWatcher(
208
260
  artifactPath: child.artifactPath,
209
261
  sessionPath: child.sessionPath,
210
262
  children: child.children,
211
- }))
212
- : [],
213
- } : {}),
214
- });
215
- fsApi.unlinkSync(resultPath);
263
+ })) : [],
264
+ } : {}),
265
+ });
266
+ } catch (error) {
267
+ console.error(`Completion observer failed for '${resultPath}':`, error);
268
+ }
269
+ if (!ownsSession(data.sessionId, epoch) || !fsApi.existsSync(resultPath)) return;
270
+ try {
271
+ fsApi.unlinkSync(resultPath);
272
+ } catch (error) {
273
+ if (!isNotFound(error)) {
274
+ console.error(`Failed to remove delivered subagent result '${resultPath}'; will retry:`, error);
275
+ scheduleResult(file, triggerTurn, RETRY_DELAY_MS);
276
+ }
277
+ }
216
278
  } catch (error) {
217
- if (isNotFoundError(error)) return;
218
- console.error(`Failed to process subagent result file '${resultPath}':`, error);
279
+ if (!isNotFound(error)) console.error(`Failed to process subagent result file '${resultPath}':`, error);
280
+ } finally {
281
+ processing.delete(file);
219
282
  }
220
283
  };
221
284
 
222
285
  state.resultFileCoalescer = createFileCoalescer((file) => {
223
- void handleResult(file);
286
+ const triggerTurn = pendingTriggerTurn.get(file) !== false;
287
+ pendingTriggerTurn.delete(file);
288
+ void handleResult(file, triggerTurn);
224
289
  }, 50);
225
290
 
226
- const primeExistingResults = () => {
291
+ const primeExistingResults = (options: { triggerTurn?: boolean } = {}) => {
227
292
  try {
293
+ const triggerTurn = options.triggerTurn !== false;
228
294
  fsApi.readdirSync(resultsDir)
229
295
  .filter((f) => f.endsWith(".json"))
230
- .forEach((file) => state.resultFileCoalescer.schedule(file, 0));
296
+ .forEach((file) => scheduleResult(file, triggerTurn));
231
297
  } catch (error) {
232
- if (isNotFoundError(error)) return;
233
- console.error(`Failed to scan subagent result directory '${resultsDir}':`, error);
298
+ if (!isNotFound(error)) console.error(`Failed to scan subagent result directory '${resultsDir}':`, error);
234
299
  }
235
300
  };
236
301
 
237
- const startPollingFallback = (reason: unknown) => {
302
+ const startPolling = (reason: unknown) => {
238
303
  state.watcher?.close();
239
304
  state.watcher = null;
240
305
  if (state.watcherRestartTimer) return;
241
-
242
- console.error(
243
- `Subagent result watcher for '${resultsDir}' fell back to polling because native fs.watch is unavailable (${getErrorCode(reason) ?? "unknown error"}).`,
244
- );
306
+ console.error(`Subagent result watcher for '${resultsDir}' fell back to polling because native fs.watch is unavailable (${errorCode(reason) ?? "unknown error"}).`);
245
307
  primeExistingResults();
246
308
  state.watcherRestartTimer = timers.setInterval(primeExistingResults, POLL_INTERVAL_MS);
247
309
  state.watcherRestartTimer.unref?.();
@@ -255,10 +317,7 @@ export function createResultWatcher(
255
317
  fsApi.mkdirSync(resultsDir, { recursive: true });
256
318
  startResultWatcher();
257
319
  } catch (error) {
258
- if (shouldFallBackToPolling(error)) {
259
- startPollingFallback(error);
260
- return;
261
- }
320
+ if (shouldPoll(error)) return startPolling(error);
262
321
  console.error(`Failed to restart subagent result watcher for '${resultsDir}':`, error);
263
322
  scheduleRestart();
264
323
  }
@@ -268,6 +327,9 @@ export function createResultWatcher(
268
327
 
269
328
  const startResultWatcher = () => {
270
329
  if (state.watcher) return;
330
+ activeSessionId = state.currentSessionId;
331
+ deliveryActive = true;
332
+ deliveryEpoch += 1;
271
333
  if (state.watcherRestartTimer) {
272
334
  timers.clearTimeout(state.watcherRestartTimer);
273
335
  timers.clearInterval(state.watcherRestartTimer);
@@ -275,17 +337,13 @@ export function createResultWatcher(
275
337
  }
276
338
  try {
277
339
  const watchDir = resolveWatchPath(resultsDir, fsApi.realpathSync.native);
278
- state.watcher = fsApi.watch(watchDir, (ev, file) => {
279
- if (ev !== "rename" || !file) return;
340
+ state.watcher = fsApi.watch(watchDir, (event, file) => {
341
+ if (event !== "rename" || !file) return;
280
342
  const fileName = file.toString();
281
- if (!fileName.endsWith(".json")) return;
282
- state.resultFileCoalescer.schedule(fileName);
343
+ if (fileName.endsWith(".json")) scheduleResult(fileName, true);
283
344
  });
284
345
  state.watcher.on("error", (error) => {
285
- if (shouldFallBackToPolling(error)) {
286
- startPollingFallback(error);
287
- return;
288
- }
346
+ if (shouldPoll(error)) return startPolling(error);
289
347
  console.error(`Subagent result watcher failed for '${resultsDir}':`, error);
290
348
  state.watcher?.close();
291
349
  state.watcher = null;
@@ -293,10 +351,7 @@ export function createResultWatcher(
293
351
  });
294
352
  state.watcher.unref?.();
295
353
  } catch (error) {
296
- if (shouldFallBackToPolling(error)) {
297
- startPollingFallback(error);
298
- return;
299
- }
354
+ if (shouldPoll(error)) return startPolling(error);
300
355
  console.error(`Failed to start subagent result watcher for '${resultsDir}':`, error);
301
356
  state.watcher = null;
302
357
  scheduleRestart();
@@ -304,6 +359,9 @@ export function createResultWatcher(
304
359
  };
305
360
 
306
361
  const stopResultWatcher = () => {
362
+ deliveryActive = false;
363
+ activeSessionId = null;
364
+ deliveryEpoch += 1;
307
365
  state.watcher?.close();
308
366
  state.watcher = null;
309
367
  if (state.watcherRestartTimer) {
@@ -312,6 +370,8 @@ export function createResultWatcher(
312
370
  }
313
371
  state.watcherRestartTimer = null;
314
372
  state.resultFileCoalescer.clear();
373
+ pendingTriggerTurn.clear();
374
+ processing.clear();
315
375
  };
316
376
 
317
377
  return { startResultWatcher, primeExistingResults, stopResultWatcher };
@@ -8,6 +8,7 @@ import { formatModelThinking } from "../../shared/formatters.ts";
8
8
  import { formatActivityLabel } from "../../shared/status-format.ts";
9
9
  import { ASYNC_DIR, RESULTS_DIR, type AsyncStatus, type Details, type ForegroundResumeRun, type NestedRunSummary, type SteeringStatus, type SubagentState } from "../../shared/types.ts";
10
10
  import { resolveSubagentIntercomTarget } from "../../intercom/intercom-bridge.ts";
11
+ import { readProcessTerminal, sanitizeProcessTerminal } from "./process-terminal.ts";
11
12
  import { resolveAsyncRunLocation } from "./async-resume.ts";
12
13
  import { resolveSubagentRunId } from "./run-id-resolver.ts";
13
14
  import { flatToLogicalStepIndex, normalizeParallelGroups } from "./parallel-groups.ts";
@@ -351,10 +352,15 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
351
352
  const updated = status.lastUpdate ? new Date(status.lastUpdate).toISOString() : "n/a";
352
353
  const statusActivityText = status.state === "running" ? formatActivityLabel(status.lastActivityAt, status.activityState) : undefined;
353
354
  const steeringText = formatSteeringSummary(status);
355
+ const processTerminal = readProcessTerminal(asyncDir, { runId: status.runId, runnerProcessInstanceId: status.processTerminal?.runnerProcessInstanceId })
356
+ ?? sanitizeProcessTerminal(status.processTerminal, { runId: status.runId, runnerProcessInstanceId: status.processTerminal?.runnerProcessInstanceId }, path.join(asyncDir, "status.json"));
354
357
 
355
358
  const lines = [
356
359
  `Run: ${status.runId}`,
357
360
  `State: ${status.state}`,
361
+ processTerminal ? `Process terminal: ${processTerminal.state}${processTerminal.reason ? ` (${processTerminal.reason})` : ""}` : undefined,
362
+ status.capabilityCeiling ? `Capability ceiling: ${status.capabilityCeiling.allowedTools === undefined ? "names unrestricted" : status.capabilityCeiling.allowedTools.length === 0 ? "none" : status.capabilityCeiling.allowedTools.join(", ")}\nExtensions denied: ${status.capabilityCeiling.denyExtensions ? "yes" : "no"} (sources: ${status.capabilityCeiling.sources.join(", ")})` : undefined,
363
+ status.capabilityAudit ? `Capability audit: ${status.capabilityAudit.removedTools.length} tools removed, ${status.capabilityAudit.removedExtensionCount} extension entries removed` : undefined,
358
364
  status.error ? `Error: ${status.error}` : undefined,
359
365
  statusActivityText ? `Activity: ${statusActivityText}` : undefined,
360
366
  steeringText ? `Steering: ${steeringText}` : undefined,
@@ -366,6 +372,7 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
366
372
  status.turnBudget ? `Turn budget: ${status.turnBudget.turnCount}/${status.turnBudget.maxTurns}+${status.turnBudget.graceTurns} (${status.turnBudget.outcome})` : undefined,
367
373
  `Dir: ${asyncDir}`,
368
374
  outputPath ? `Output: ${outputPath}` : undefined,
375
+ status.parallelHandoff ? `Parallel handoff: ${status.parallelHandoff.path}` : undefined,
369
376
  reconciliation.message ? `Diagnosis: ${reconciliation.message}` : undefined,
370
377
  reconciliation.resultPath && fs.existsSync(reconciliation.resultPath) ? `Result: ${reconciliation.resultPath}` : undefined,
371
378
  ].filter((line): line is string => Boolean(line));
@@ -401,14 +408,14 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
401
408
  if (fs.existsSync(logPath)) lines.push(`Log: ${logPath}`);
402
409
  if (fs.existsSync(eventsPath)) lines.push(`Events: ${eventsPath}`);
403
410
 
404
- return { content: [{ type: "text", text: lines.join("\n") }], details: { mode: "single", results: [] } };
411
+ return { content: [{ type: "text", text: lines.join("\n") }], details: { mode: "single", results: [], ...(processTerminal ? { lifecycleStatus: { processTerminal } } : {}) } };
405
412
  }
406
413
  }
407
414
 
408
415
  if (resultPath) {
409
416
  try {
410
417
  const raw = fs.readFileSync(resultPath, "utf-8");
411
- const data = JSON.parse(raw) as { id?: string; runId?: string; agent?: string; success?: boolean; summary?: string; output?: string; exitCode?: number; state?: string; sessionFile?: string; results?: Array<{ agent?: string; output?: string; summary?: string; sessionFile?: string; state?: string; success?: boolean; exitCode?: number | null }> };
418
+ const data = JSON.parse(raw) as { id?: string; runId?: string; agent?: string; success?: boolean; summary?: string; output?: string; exitCode?: number; state?: string; sessionFile?: string; parallelHandoff?: { path?: string }; results?: Array<{ agent?: string; output?: string; summary?: string; sessionFile?: string; state?: string; success?: boolean; exitCode?: number | null }> };
412
419
  if (params.view === "transcript") {
413
420
  try {
414
421
  return { content: [{ type: "text", text: formatAsyncResultTranscript(data, resultPath, { index: params.index, lines: params.lines }) }], details: { mode: "single", results: [] } };
@@ -420,6 +427,7 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
420
427
  const status = data.state === "stopped" ? "stopped" : data.success ? "complete" : data.state === "paused" || data.exitCode === 0 ? "paused" : "failed";
421
428
  const runId = data.runId ?? data.id ?? resolvedId;
422
429
  const lines = [`Run: ${runId}`, `State: ${status}`, `Result: ${resultPath}`];
430
+ if (data.parallelHandoff?.path) lines.push(`Parallel handoff: ${data.parallelHandoff.path}`);
423
431
  const children = Array.isArray(data.results) ? data.results : data.agent ? [{ agent: data.agent, sessionFile: data.sessionFile }] : [];
424
432
  lines.push(formatResumeGuidance(runId, children, data.sessionFile, { stopped: status === "stopped" }));
425
433
  if (data.summary) lines.push("", data.summary);
@@ -13,6 +13,7 @@ import {
13
13
  } from "../../shared/types.ts";
14
14
  import type { SubagentParamsLike } from "../foreground/subagent-executor.ts";
15
15
  import { validateExecutionAcceptance } from "../shared/acceptance.ts";
16
+ import type { ResolvedSubagentCapabilityCeiling } from "../shared/capability-ceiling.ts";
16
17
 
17
18
  export const SCHEDULED_RUNS_DIR = path.join(TEMP_ROOT_DIR, "scheduled-subagent-runs");
18
19
  export const SCHEDULED_RUN_ACTIONS = ["schedule", "schedule-list", "schedule-status", "schedule-cancel"] as const;
@@ -57,6 +58,7 @@ type ScheduledRunManagerDeps = {
57
58
  storeRoot?: string;
58
59
  now?: () => number;
59
60
  randomId?: () => string;
61
+ resolveCapabilityCeiling?: (sessionId: string) => ResolvedSubagentCapabilityCeiling | undefined;
60
62
  timers?: ScheduledRunTimers;
61
63
  };
62
64
 
@@ -310,12 +312,15 @@ export class ScheduledRunManager {
310
312
  const sanitized = sanitizeScheduledParams(params);
311
313
  if (sanitized.error) return textResult(sanitized.error, true);
312
314
  const scheduleInput = params.schedule!.trim();
315
+ const sessionId = resolveCurrentSessionId(ctx.sessionManager);
316
+ if (this.deps.resolveCapabilityCeiling?.(sessionId)) {
317
+ return textResult("Cannot schedule a capability-ceiling-restricted run because this store does not yet persist ceilings. Remove the active parent restriction or run it immediately.", true);
318
+ }
313
319
  const runAt = parseScheduledRunTime(scheduleInput, this.now());
314
320
  const pendingCount = store.list().filter((job) => job.state === "scheduled" || job.state === "running").length;
315
321
  const maxPending = resolveMaxPending(this.deps.config);
316
322
  if (pendingCount >= maxPending) return textResult(`Scheduled subagent limit reached (${pendingCount}/${maxPending} pending or running). Cancel an existing scheduled run before adding another.`, true);
317
323
  const id = this.randomId();
318
- const sessionId = resolveCurrentSessionId(ctx.sessionManager);
319
324
  const scheduleName = params.scheduleName?.trim();
320
325
  const executionParams = sanitized.params!;
321
326
  const now = this.now();
@@ -175,6 +175,9 @@ function terminalStatusFromResult(status: AsyncStatus, resultPath: string, now:
175
175
  return {
176
176
  ...status,
177
177
  state: repair.state,
178
+ ...(status.lifecycleArtifactVersion === 3 && (!status.processTerminal || status.processTerminal.state === "pending") ? {
179
+ processTerminal: { version: 1 as const, state: "unknown" as const, runId: status.runId, runnerProcessInstanceId: "observer-unavailable", reason: "observer-unavailable" as const },
180
+ } : {}),
178
181
  ...(repair.state === "stopped" ? { stopped: true } : {}),
179
182
  activityState: undefined,
180
183
  lastUpdate: now,
@@ -231,6 +234,9 @@ function buildFailedRepair(status: AsyncStatus, asyncDir: string, now: number, r
231
234
  const repairedStatus: AsyncStatus = {
232
235
  ...status,
233
236
  state: "failed",
237
+ ...(status.lifecycleArtifactVersion === 3 && (!status.processTerminal || status.processTerminal.state === "pending") ? {
238
+ processTerminal: { version: 1 as const, state: "unknown" as const, runId, runnerProcessInstanceId: "observer-unavailable", reason: "stale-repair" as const },
239
+ } : {}),
234
240
  activityState: undefined,
235
241
  lastUpdate: now,
236
242
  endedAt: now,