pi-subagents 0.36.0 → 0.37.1

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 (54) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +111 -16
  3. package/agents/delegate.md +2 -0
  4. package/agents/planner.md +2 -1
  5. package/agents/worker.md +2 -0
  6. package/package.json +6 -4
  7. package/skills/pi-subagents/SKILL.md +18 -973
  8. package/skills/pi-subagents/references/constraints-and-recipes.md +256 -0
  9. package/skills/pi-subagents/references/execution-controls.md +411 -0
  10. package/skills/pi-subagents/references/management-authoring-rpc.md +140 -0
  11. package/skills/pi-subagents/references/prompting-and-roles.md +268 -0
  12. package/src/agents/agent-management.ts +7 -1
  13. package/src/agents/agents.ts +99 -12
  14. package/src/api/capability-ceiling.ts +17 -0
  15. package/src/api/delegation.ts +6 -1
  16. package/src/api/preflight.ts +399 -0
  17. package/src/extension/index.ts +16 -7
  18. package/src/extension/rpc.ts +29 -2
  19. package/src/extension/schemas.ts +10 -4
  20. package/src/extension/tool-description.ts +4 -2
  21. package/src/intercom/intercom-bridge.ts +5 -2
  22. package/src/runs/background/async-execution.ts +125 -14
  23. package/src/runs/background/async-job-tracker.ts +19 -12
  24. package/src/runs/background/async-resume.ts +27 -9
  25. package/src/runs/background/async-status.ts +18 -0
  26. package/src/runs/background/notify.ts +3 -0
  27. package/src/runs/background/process-terminal.ts +280 -0
  28. package/src/runs/background/result-watcher.ts +9 -3
  29. package/src/runs/background/run-status.ts +7 -1
  30. package/src/runs/background/scheduled-runs.ts +6 -1
  31. package/src/runs/background/stale-run-reconciler.ts +6 -0
  32. package/src/runs/background/subagent-runner.ts +182 -12
  33. package/src/runs/foreground/chain-execution.ts +5 -0
  34. package/src/runs/foreground/execution.ts +59 -13
  35. package/src/runs/foreground/subagent-executor.ts +142 -47
  36. package/src/runs/shared/acceptance.ts +41 -30
  37. package/src/runs/shared/capability-ceiling.ts +177 -0
  38. package/src/runs/shared/dynamic-fanout.ts +1 -1
  39. package/src/runs/shared/mcp-direct-tool-allowlist.ts +56 -17
  40. package/src/runs/shared/model-fallback.ts +8 -0
  41. package/src/runs/shared/nested-events.ts +8 -1
  42. package/src/runs/shared/parallel-utils.ts +5 -0
  43. package/src/runs/shared/pi-args.ts +141 -58
  44. package/src/runs/shared/session-lease.ts +25 -5
  45. package/src/runs/shared/subagent-prompt-runtime.ts +14 -1
  46. package/src/runs/shared/task-intent.ts +1 -1
  47. package/src/runs/shared/tool-availability.ts +18 -2
  48. package/src/shared/launch-contract.ts +123 -0
  49. package/src/shared/types.ts +118 -8
  50. package/src/shared/utils.ts +17 -42
  51. package/src/slash/delegation-adapters.ts +6 -1
  52. package/src/slash/slash-commands.ts +1 -1
  53. package/src/tui/fleet-status.ts +62 -16
  54. package/src/tui/fleet.ts +1 -1
@@ -48,6 +48,8 @@ export interface CompletionNotification {
48
48
  totalTasks?: number;
49
49
  sessionId?: string | null;
50
50
  triggerTurn?: boolean;
51
+ /** True when an acknowledged grouped intercom relay already delivered this run. */
52
+ intercomDelivered?: boolean;
51
53
  parallelHandoff?: ParallelHandoffReference;
52
54
  }
53
55
 
@@ -250,6 +252,7 @@ export default function registerSubagentNotify(
250
252
 
251
253
  const deliver = (result: CompletionNotification): Promise<boolean> => {
252
254
  if (disposed || typeof result.sessionId !== "string" || result.sessionId !== state.currentSessionId) return Promise.resolve(false);
255
+ if (result.intercomDelivered === true) return Promise.resolve(true);
253
256
  const key = buildCompletionKey(result, "notify");
254
257
  const seenAt = seen.get(key);
255
258
  if (seenAt !== undefined && now() - seenAt <= ttlMs) return Promise.resolve(true);
@@ -0,0 +1,280 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { writeAtomicJson, writePrivateAtomicJson } from "../../shared/atomic-json.ts";
4
+ import {
5
+ SUBAGENT_LIFECYCLE_ARTIFACT_VERSION,
6
+ type AsyncStatus,
7
+ type CanonicalSessionTerminalV1,
8
+ type ProcessInstanceExitV1,
9
+ type ProcessTerminalReason,
10
+ type ProcessTerminalV1,
11
+ } from "../../shared/types.ts";
12
+ import { canonicalSessionId, inspectSessionLease } from "../shared/session-lease.ts";
13
+
14
+ export interface ProcessTerminalCandidate {
15
+ version: 1;
16
+ runId: string;
17
+ runnerProcessInstanceId: string;
18
+ writers: Record<string, ProcessInstanceExitV1[]>;
19
+ expectedWriters?: Record<string, number>;
20
+ sessionFile?: string;
21
+ revivalLeaseToken?: string;
22
+ revivalLeaseReleaseAcknowledged?: boolean;
23
+ }
24
+
25
+ export interface RunnerCloseObservation {
26
+ processInstanceId: string;
27
+ closeObservedAt: number;
28
+ exitCode: number | null;
29
+ signal: string | null;
30
+ }
31
+
32
+ function isRecord(value: unknown): value is Record<string, unknown> {
33
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
34
+ }
35
+
36
+ function validProcessInstance(value: unknown, kind?: "runner" | "pi-writer"): value is ProcessInstanceExitV1 {
37
+ if (!isRecord(value)) return false;
38
+ if (typeof value.processInstanceId !== "string" || value.processInstanceId.length === 0) return false;
39
+ if (kind ? value.kind !== kind : (value.kind !== "runner" && value.kind !== "pi-writer")) return false;
40
+ if (typeof value.closeObservedAt !== "number" || !Number.isFinite(value.closeObservedAt)) return false;
41
+ if (typeof value.exitCode !== "number" && value.exitCode !== null) return false;
42
+ if (typeof value.signal !== "string" && value.signal !== null) return false;
43
+ return value.kind === "runner"
44
+ ? value.attempt === undefined
45
+ : typeof value.attempt === "number" && Number.isInteger(value.attempt) && value.attempt >= 0;
46
+ }
47
+
48
+ function validInstance(value: unknown): value is ProcessInstanceExitV1 {
49
+ return validProcessInstance(value, "pi-writer");
50
+ }
51
+
52
+ export function processTerminalCandidatePath(asyncDir: string): string {
53
+ return path.join(asyncDir, "process-terminal-candidate.json");
54
+ }
55
+
56
+ export function processTerminalPath(asyncDir: string): string {
57
+ return path.join(asyncDir, "process-terminal.json");
58
+ }
59
+
60
+ function errorMessage(error: unknown): string {
61
+ return error instanceof Error ? error.message : String(error);
62
+ }
63
+
64
+ export function readProcessTerminalCandidate(asyncDir: string): ProcessTerminalCandidate | undefined {
65
+ try {
66
+ const raw = JSON.parse(fs.readFileSync(processTerminalCandidatePath(asyncDir), "utf-8")) as unknown;
67
+ if (!isRecord(raw) || raw.version !== 1 || typeof raw.runId !== "string" || typeof raw.runnerProcessInstanceId !== "string" || !isRecord(raw.writers)) {
68
+ throw new Error(`Invalid process-terminal candidate in '${asyncDir}'.`);
69
+ }
70
+ const writers: Record<string, ProcessInstanceExitV1[]> = {};
71
+ for (const [index, entries] of Object.entries(raw.writers)) {
72
+ if (!Array.isArray(entries) || !entries.every(validInstance)) throw new Error(`Invalid writer process records for child '${index}'.`);
73
+ writers[index] = entries;
74
+ }
75
+ let expectedWriters: Record<string, number> | undefined;
76
+ if (raw.expectedWriters !== undefined) {
77
+ if (!isRecord(raw.expectedWriters)) throw new Error("Invalid expected writer process records.");
78
+ expectedWriters = {};
79
+ for (const [index, count] of Object.entries(raw.expectedWriters)) {
80
+ if (typeof count !== "number" || !Number.isInteger(count) || count < 0) throw new Error(`Invalid expected writer count for child '${index}'.`);
81
+ expectedWriters[index] = count;
82
+ }
83
+ }
84
+ if (raw.sessionFile !== undefined && typeof raw.sessionFile !== "string") throw new Error("Invalid process-terminal candidate sessionFile.");
85
+ if (raw.revivalLeaseToken !== undefined && typeof raw.revivalLeaseToken !== "string") throw new Error("Invalid process-terminal candidate lease token.");
86
+ if (raw.revivalLeaseReleaseAcknowledged !== undefined && typeof raw.revivalLeaseReleaseAcknowledged !== "boolean") throw new Error("Invalid process-terminal lease release acknowledgement.");
87
+ return {
88
+ version: 1,
89
+ runId: raw.runId,
90
+ runnerProcessInstanceId: raw.runnerProcessInstanceId,
91
+ writers,
92
+ ...(expectedWriters ? { expectedWriters } : {}),
93
+ ...(raw.sessionFile ? { sessionFile: raw.sessionFile } : {}),
94
+ ...(raw.revivalLeaseToken ? { revivalLeaseToken: raw.revivalLeaseToken } : {}),
95
+ ...(raw.revivalLeaseReleaseAcknowledged !== undefined ? { revivalLeaseReleaseAcknowledged: raw.revivalLeaseReleaseAcknowledged } : {}),
96
+ };
97
+ } catch (error) {
98
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
99
+ throw error;
100
+ }
101
+ }
102
+
103
+ export function writeProcessTerminalCandidate(asyncDir: string, candidate: ProcessTerminalCandidate): void {
104
+ writePrivateAtomicJson(processTerminalCandidatePath(asyncDir), candidate);
105
+ }
106
+
107
+ export function markProcessTerminalCandidateLeaseRelease(asyncDir: string, token: string, acknowledged: boolean): void {
108
+ const candidate = readProcessTerminalCandidate(asyncDir);
109
+ if (!candidate || candidate.revivalLeaseToken !== token) return;
110
+ writeProcessTerminalCandidate(asyncDir, { ...candidate, revivalLeaseReleaseAcknowledged: acknowledged });
111
+ }
112
+
113
+ function unknownProof(runId: string, runnerProcessInstanceId: string, reason: ProcessTerminalReason, diagnostic?: string): ProcessTerminalV1 {
114
+ return { version: 1, state: "unknown", runId, runnerProcessInstanceId, reason, ...(diagnostic ? { diagnostic } : {}) };
115
+ }
116
+
117
+ function resumeDisposition(state: string | undefined, sessionFile: string | undefined): "resumable" | "non-resumable" | "unavailable" {
118
+ if (state === "stopped") return "non-resumable";
119
+ if (state !== "complete" && state !== "completed" && state !== "failed" && state !== "paused") return "unavailable";
120
+ return sessionFile && fs.existsSync(sessionFile) ? "resumable" : "unavailable";
121
+ }
122
+
123
+ function sessionProjection(candidate: ProcessTerminalCandidate, lease: ReturnType<typeof inspectSessionLease>): CanonicalSessionTerminalV1 | undefined {
124
+ if (!candidate.sessionFile || lease.state !== "free") return undefined;
125
+ if (candidate.revivalLeaseToken && candidate.revivalLeaseReleaseAcknowledged !== true) return undefined;
126
+ return {
127
+ canonicalSessionId: canonicalSessionId(candidate.sessionFile),
128
+ leaseDisposition: candidate.revivalLeaseToken ? "released" : "not-held",
129
+ freeAtObservation: true,
130
+ ...(candidate.revivalLeaseToken ? { canonicalSessionLeaseReleased: true } : {}),
131
+ };
132
+ }
133
+
134
+ function validateProof(raw: unknown, asyncDir: string, fallback?: { runId?: string; runnerProcessInstanceId?: string }): raw is ProcessTerminalV1 {
135
+ if (!isRecord(raw) || raw.version !== 1 || !["pending", "observed", "unknown", "not-started"].includes(String(raw.state)) || typeof raw.runId !== "string" || !raw.runId || typeof raw.runnerProcessInstanceId !== "string" || !raw.runnerProcessInstanceId) {
136
+ throw new Error(`Invalid process-terminal proof in '${asyncDir}'.`);
137
+ }
138
+ if (fallback?.runId && raw.runId !== fallback.runId) throw new Error(`Process-terminal proof in '${asyncDir}' belongs to run '${raw.runId}', expected '${fallback.runId}'.`);
139
+ if (fallback?.runnerProcessInstanceId && raw.runnerProcessInstanceId !== fallback.runnerProcessInstanceId) throw new Error(`Process-terminal proof in '${asyncDir}' belongs to runner '${raw.runnerProcessInstanceId}', expected '${fallback.runnerProcessInstanceId}'.`);
140
+ if (raw.instances !== undefined && (!Array.isArray(raw.instances) || !raw.instances.every((entry) => validProcessInstance(entry)))) {
141
+ throw new Error(`Invalid process-terminal instances in '${asyncDir}'.`);
142
+ }
143
+ if (raw.state === "observed") {
144
+ if (typeof raw.observedAt !== "number" || !Number.isFinite(raw.observedAt)) throw new Error(`Observed process-terminal proof in '${asyncDir}' is missing observedAt.`);
145
+ if (!Array.isArray(raw.instances)) throw new Error(`Observed process-terminal proof in '${asyncDir}' is missing instances.`);
146
+ const runner = raw.instances.find((entry) => isRecord(entry) && entry.kind === "runner");
147
+ if (!validProcessInstance(runner, "runner") || runner.processInstanceId !== raw.runnerProcessInstanceId) throw new Error(`Observed process-terminal proof in '${asyncDir}' has no matching runner instance.`);
148
+ }
149
+ if (raw.resumeDisposition !== undefined && !["resumable", "non-resumable", "unavailable"].includes(String(raw.resumeDisposition))) throw new Error(`Invalid process-terminal resume disposition in '${asyncDir}'.`);
150
+ return true;
151
+ }
152
+
153
+ export function sanitizeProcessTerminal(value: unknown, fallback: { runId?: string; runnerProcessInstanceId?: string }, label = "status"): ProcessTerminalV1 | undefined {
154
+ if (value === undefined) return undefined;
155
+ try {
156
+ validateProof(value, label, fallback);
157
+ return value;
158
+ } catch (error) {
159
+ return unknownProof(fallback.runId ?? label, fallback.runnerProcessInstanceId ?? "unknown", "proof-write-failed", errorMessage(error));
160
+ }
161
+ }
162
+
163
+ export function readProcessTerminal(asyncDir: string, fallback?: { runId?: string; runnerProcessInstanceId?: string }): ProcessTerminalV1 | undefined {
164
+ try {
165
+ const raw = JSON.parse(fs.readFileSync(processTerminalPath(asyncDir), "utf-8")) as unknown;
166
+ validateProof(raw, asyncDir, fallback);
167
+ return raw;
168
+ } catch (error) {
169
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
170
+ return unknownProof(fallback?.runId ?? path.basename(asyncDir), fallback?.runnerProcessInstanceId ?? "unknown", "proof-write-failed", errorMessage(error));
171
+ }
172
+ }
173
+
174
+ function stepProcessTerminalProof(
175
+ proof: ProcessTerminalV1,
176
+ childIndex: number,
177
+ state: ProcessTerminalV1["state"],
178
+ records: ProcessInstanceExitV1[],
179
+ resumeDispositionValue: ProcessTerminalV1["resumeDisposition"],
180
+ ): ProcessTerminalV1 {
181
+ const base = {
182
+ version: 1 as const,
183
+ runId: proof.runId,
184
+ childIndex,
185
+ runnerProcessInstanceId: proof.runnerProcessInstanceId,
186
+ ...(resumeDispositionValue ? { resumeDisposition: resumeDispositionValue } : {}),
187
+ };
188
+ if (state === "observed") {
189
+ return { ...base, state, observedAt: proof.state === "observed" ? proof.observedAt : Date.now(), instances: records };
190
+ }
191
+ if (state === "unknown") {
192
+ return { ...base, state, reason: proof.state === "unknown" ? proof.reason : "writer-close-unverified" };
193
+ }
194
+ return { ...base, state };
195
+ }
196
+
197
+ function overlayStatus(asyncDir: string, proof: ProcessTerminalV1, candidate?: ProcessTerminalCandidate): void {
198
+ const statusPath = path.join(asyncDir, "status.json");
199
+ try {
200
+ const status = JSON.parse(fs.readFileSync(statusPath, "utf-8")) as AsyncStatus;
201
+ status.processTerminal = proof;
202
+ if (status.steps) {
203
+ for (const [index, step] of status.steps.entries()) {
204
+ const records = candidate?.writers[String(index)] ?? [];
205
+ const expected = candidate?.expectedWriters?.[String(index)] ?? (records.length > 0 ? records.length : 0);
206
+ const stepState = expected === 0 ? "not-started" : proof.state === "observed" && records.length === expected ? "observed" : proof.state === "pending" ? "pending" : "unknown";
207
+ step.processTerminal = stepProcessTerminalProof(proof, index, stepState, records, resumeDisposition(step.status, step.sessionFile ?? candidate?.sessionFile));
208
+ }
209
+ }
210
+ writeAtomicJson(statusPath, status);
211
+ } catch {
212
+ // The proof sidecar remains authoritative when terminal status is unavailable.
213
+ }
214
+ }
215
+
216
+ export function finalizeProcessTerminal(
217
+ asyncDir: string,
218
+ runId: string,
219
+ runnerClose: RunnerCloseObservation,
220
+ ): ProcessTerminalV1 {
221
+ const existing = readProcessTerminal(asyncDir, { runId, runnerProcessInstanceId: runnerClose.processInstanceId });
222
+ if (existing && fs.existsSync(processTerminalPath(asyncDir))) {
223
+ if (existing.state === "observed" && existing.runId === runId && existing.runnerProcessInstanceId === runnerClose.processInstanceId) return existing;
224
+ if (existing.state === "unknown") return existing;
225
+ }
226
+ let proof: ProcessTerminalV1;
227
+ let candidateForOverlay: ProcessTerminalCandidate | undefined;
228
+ try {
229
+ const candidate = readProcessTerminalCandidate(asyncDir);
230
+ candidateForOverlay = candidate;
231
+ if (!candidate) proof = unknownProof(runId, runnerClose.processInstanceId, "runner-candidate-missing");
232
+ else if (candidate.runId !== runId || candidate.runnerProcessInstanceId !== runnerClose.processInstanceId) proof = unknownProof(runId, runnerClose.processInstanceId, "runner-instance-mismatch");
233
+ else {
234
+ const allWriters = Object.values(candidate.writers).flat();
235
+ const status = (() => {
236
+ try { return JSON.parse(fs.readFileSync(path.join(asyncDir, "status.json"), "utf-8")) as AsyncStatus; } catch { return undefined; }
237
+ })();
238
+ const session = candidate.sessionFile ? inspectSessionLease(candidate.sessionFile) : undefined;
239
+ const writerEntries = Object.entries(candidate.writers);
240
+ const expectedWriters = candidate.expectedWriters ?? Object.fromEntries(writerEntries.map(([index, records]) => [index, records.length]));
241
+ const expectedEntries = Object.entries(expectedWriters);
242
+ const expectedIndexes = new Set(expectedEntries.map(([index]) => index));
243
+ const writerIndexes = new Set(writerEntries.map(([index]) => index));
244
+ const inconsistentWriters = writerEntries.some(([index, records]) => !expectedIndexes.has(index) || records.length !== expectedWriters[index])
245
+ || expectedEntries.some(([index, expected]) => !writerIndexes.has(index) && expected !== 0);
246
+ if (session && session.state !== "free") {
247
+ proof = unknownProof(runId, runnerClose.processInstanceId, session.state === "owned" ? "canonical-session-lease-active" : "canonical-session-unavailable");
248
+ } else if (candidate.revivalLeaseToken && candidate.revivalLeaseReleaseAcknowledged !== true) {
249
+ proof = unknownProof(runId, runnerClose.processInstanceId, "canonical-session-release-unverified");
250
+ } else if (inconsistentWriters || (allWriters.length === 0 && expectedEntries.length === 0)) {
251
+ proof = unknownProof(runId, runnerClose.processInstanceId, "writer-close-unverified");
252
+ } else {
253
+ const runner: ProcessInstanceExitV1 = { kind: "runner", ...runnerClose };
254
+ const canonicalSession = session && sessionProjection(candidate, session);
255
+ proof = {
256
+ version: 1,
257
+ state: "observed",
258
+ runId,
259
+ runnerProcessInstanceId: runnerClose.processInstanceId,
260
+ observedAt: runnerClose.closeObservedAt,
261
+ instances: [runner, ...allWriters],
262
+ resumeDisposition: resumeDisposition(status?.state, candidate.sessionFile ?? status?.sessionFile),
263
+ ...(canonicalSession ? { canonicalSession } : {}),
264
+ };
265
+ }
266
+ }
267
+ } catch (error) {
268
+ proof = unknownProof(runId, runnerClose.processInstanceId, "proof-write-failed", errorMessage(error));
269
+ }
270
+ let durable = false;
271
+ try {
272
+ writeAtomicJson(processTerminalPath(asyncDir), proof);
273
+ durable = true;
274
+ overlayStatus(asyncDir, proof, candidateForOverlay);
275
+ fs.appendFileSync(path.join(asyncDir, "events.jsonl"), `${JSON.stringify({ type: "subagent.run.process_terminal", lifecycleArtifactVersion: SUBAGENT_LIFECYCLE_ARTIFACT_VERSION, ts: Date.now(), runId, processTerminal: proof })}\n`, "utf-8");
276
+ } catch {
277
+ // Do not emit a process-terminal event when the proof sidecar was not durable.
278
+ }
279
+ return durable ? proof : unknownProof(runId, runnerClose.processInstanceId, "proof-write-failed", "Failed to persist process-terminal proof.");
280
+ }
@@ -38,6 +38,8 @@ type ResultWatcherDeps = {
38
38
  fs?: ResultWatcherFs;
39
39
  timers?: ResultWatcherTimers;
40
40
  notifier?: Pick<CompletionNotifier, "deliver">;
41
+ /** External grouped-result transport. Disable when native completion notifications own delivery. */
42
+ deliverIntercomResults?: boolean;
41
43
  };
42
44
 
43
45
  type ResultFileChild = {
@@ -108,6 +110,7 @@ export function createResultWatcher(
108
110
  const fsApi = deps.fs ?? fs;
109
111
  const timers = deps.timers ?? { setTimeout, clearTimeout, setInterval, clearInterval };
110
112
  const notifier = deps.notifier ?? { deliver: async () => true };
113
+ const deliverIntercomResults = deps.deliverIntercomResults !== false;
111
114
  const pendingTriggerTurn = new Map<string, boolean>();
112
115
  const processing = new Set<string>();
113
116
  let deliveryActive = true;
@@ -201,11 +204,12 @@ export function createResultWatcher(
201
204
  }), nestedChildren);
202
205
 
203
206
  const intercomTarget = data.intercomTarget?.trim();
204
- if (intercomTarget && triggerTurn) {
207
+ let intercomDelivered = false;
208
+ if (deliverIntercomResults && intercomTarget && triggerTurn) {
205
209
  const mode = data.mode === "single" || data.mode === "parallel" || data.mode === "chain"
206
210
  ? data.mode
207
211
  : resultChildren.length > 1 ? "chain" : "single";
208
- const delivered = await deliverSubagentResultIntercomEvent(pi.events, buildSubagentResultIntercomPayload({
212
+ intercomDelivered = await deliverSubagentResultIntercomEvent(pi.events, buildSubagentResultIntercomPayload({
209
213
  to: intercomTarget,
210
214
  runId,
211
215
  mode,
@@ -216,7 +220,7 @@ export function createResultWatcher(
216
220
  ...(data.parallelHandoff ? { parallelHandoff: data.parallelHandoff } : {}),
217
221
  }));
218
222
  if (!ownsSession(data.sessionId, epoch)) return;
219
- if (!delivered) console.error(`Subagent async grouped result intercom delivery was not acknowledged for '${resultPath}'.`);
223
+ if (!intercomDelivered) console.error(`Subagent async grouped result intercom delivery was not acknowledged for '${resultPath}'.`);
220
224
  }
221
225
 
222
226
  const accepted = await notifier.deliver({
@@ -224,6 +228,7 @@ export function createResultWatcher(
224
228
  id: data.id ?? runId,
225
229
  runId,
226
230
  triggerTurn,
231
+ intercomDelivered,
227
232
  ...(nestedChildren?.length ? { nestedChildren } : {}),
228
233
  ...(Array.isArray(data.results) ? {
229
234
  results: hasResultChildren ? normalizedChildren.map((child, index) => ({
@@ -249,6 +254,7 @@ export function createResultWatcher(
249
254
  ...data,
250
255
  runId,
251
256
  triggerTurn,
257
+ intercomDelivered,
252
258
  ...(nestedChildren?.length ? { nestedChildren } : {}),
253
259
  ...(Array.isArray(data.results) ? {
254
260
  results: hasResultChildren ? normalizedChildren.map((child, index) => ({
@@ -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,
@@ -402,7 +408,7 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
402
408
  if (fs.existsSync(logPath)) lines.push(`Log: ${logPath}`);
403
409
  if (fs.existsSync(eventsPath)) lines.push(`Events: ${eventsPath}`);
404
410
 
405
- 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 } } : {}) } };
406
412
  }
407
413
  }
408
414
 
@@ -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,