pi-subagents 0.64.0 → 0.65.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 (106) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/README.md +2 -2
  3. package/agents/reviewer.md +1 -1
  4. package/agents/scout.md +1 -1
  5. package/docs/agents.md +19 -17
  6. package/docs/configuration.md +23 -15
  7. package/docs/extension-api.md +18 -6
  8. package/docs/missions.md +2 -0
  9. package/docs/observability.md +10 -12
  10. package/docs/tool-reference.md +15 -8
  11. package/docs/watchdog.md +1 -1
  12. package/docs/workflows.md +13 -3
  13. package/package.json +5 -4
  14. package/runner-server-preload.mjs +13 -0
  15. package/skills/pi-subagents/SKILL.md +2 -1
  16. package/skills/pi-subagents/references/constraints-and-recipes.md +1 -1
  17. package/skills/pi-subagents/references/execution-controls.md +7 -3
  18. package/skills/pi-subagents/references/multi-lane-orchestration.md +2 -0
  19. package/src/agents/agent-management.ts +47 -15
  20. package/src/api/capability-ceiling.ts +0 -1
  21. package/src/api/{pi-args.ts → child-tool-plan.ts} +1 -1
  22. package/src/api/preflight.ts +7 -4
  23. package/src/extension/config.ts +4 -2
  24. package/src/extension/doctor.ts +2 -10
  25. package/src/extension/fanout-child.ts +9 -11
  26. package/src/extension/index.ts +58 -7
  27. package/src/extension/public-execution.ts +14 -0
  28. package/src/extension/rpc.ts +3 -2
  29. package/src/extension/schemas.ts +3 -2
  30. package/src/extension/tool-description.ts +13 -8
  31. package/src/integrations/pi-web-session-liveness.ts +73 -0
  32. package/src/intercom/native-supervisor-channel.ts +159 -95
  33. package/src/intercom/supervisor-ui.ts +244 -0
  34. package/src/missions/workflow-state.ts +37 -16
  35. package/src/runs/background/async-execution.ts +51 -25
  36. package/src/runs/background/async-job-tracker.ts +11 -0
  37. package/src/runs/background/async-resume.ts +14 -3
  38. package/src/runs/background/async-retention.ts +9 -0
  39. package/src/runs/background/control-channel.ts +2 -204
  40. package/src/runs/background/notify.ts +2 -0
  41. package/src/runs/background/process-terminal.ts +1 -1
  42. package/src/runs/background/retained-nested-route-tracker.ts +96 -0
  43. package/src/runs/background/run-child-session.ts +614 -0
  44. package/src/runs/background/run-status.ts +0 -1
  45. package/src/runs/background/runner-aliases.ts +152 -0
  46. package/src/runs/background/runner-child-sessions.ts +31 -0
  47. package/src/runs/background/scheduled-runs.ts +18 -4
  48. package/src/runs/background/subagent-runner.ts +203 -901
  49. package/src/runs/foreground/async-steering-action.ts +1 -17
  50. package/src/runs/foreground/execution.ts +204 -378
  51. package/src/runs/foreground/foreground-control.ts +4 -0
  52. package/src/runs/foreground/foreground-history.ts +3 -1
  53. package/src/runs/foreground/prompt-audit.ts +9 -5
  54. package/src/runs/foreground/subagent-executor.ts +161 -91
  55. package/src/runs/foreground/workflow-foreground-steering.ts +24 -98
  56. package/src/runs/shared/abort-recovery.ts +3 -3
  57. package/src/runs/shared/acceptance.ts +14 -1
  58. package/src/runs/shared/capability-ceiling.ts +1 -2
  59. package/src/runs/shared/child-hooks.ts +25 -0
  60. package/src/runs/shared/child-identity.ts +13 -2
  61. package/src/runs/shared/child-launch.ts +314 -0
  62. package/src/runs/shared/child-lifecycle.ts +25 -0
  63. package/src/runs/shared/child-runtime-config.ts +126 -0
  64. package/src/runs/shared/child-session.ts +337 -0
  65. package/src/runs/shared/child-tool-plan.ts +530 -0
  66. package/src/runs/shared/claude-code-adapter.ts +5 -1
  67. package/src/runs/shared/completion-guard.ts +1 -1
  68. package/src/runs/shared/external-cli-preflight.ts +16 -0
  69. package/src/runs/shared/llm-intent-arbiter.ts +20 -11
  70. package/src/runs/shared/mcp-direct-tool-allowlist.ts +5 -4
  71. package/src/runs/shared/model-exclusions.ts +84 -15
  72. package/src/runs/shared/model-fallback.ts +78 -6
  73. package/src/runs/shared/nested-events.ts +32 -48
  74. package/src/runs/shared/nested-path.ts +0 -14
  75. package/src/runs/shared/orca-progress-tabs.ts +12 -7
  76. package/src/runs/shared/parallel-utils.ts +1 -2
  77. package/src/runs/shared/permissions.ts +0 -13
  78. package/src/runs/shared/process-signal.ts +4 -1
  79. package/src/runs/shared/run-fanout-budget.ts +0 -13
  80. package/src/runs/shared/runtime-acknowledged-extensions.ts +0 -27
  81. package/src/runs/shared/structured-output.ts +17 -4
  82. package/src/runs/shared/subagent-control.ts +2 -0
  83. package/src/runs/shared/subagent-prompt-runtime.ts +87 -384
  84. package/src/runs/shared/tool-availability.ts +18 -62
  85. package/src/runs/shared/tool-budget.ts +0 -14
  86. package/src/runs/shared/worktree-cleanup-plan.ts +31 -9
  87. package/src/runs/shared/worktree.ts +192 -40
  88. package/src/shared/child-session-name.ts +1 -1
  89. package/src/shared/jsonl-writer.ts +11 -0
  90. package/src/shared/model-response-aliases.ts +13 -0
  91. package/src/shared/thinking-ceiling.ts +0 -6
  92. package/src/shared/types.ts +63 -28
  93. package/src/shared/utils.ts +6 -4
  94. package/src/shared/watch-strategy.ts +2 -0
  95. package/src/slash/slash-commands.ts +0 -6
  96. package/src/tui/fleet-status.ts +1 -1
  97. package/src/tui/fleet.ts +0 -1
  98. package/src/tui/render.ts +254 -41
  99. package/src/watchdog/child-status.ts +0 -1
  100. package/src/watchdog/register-child.ts +12 -12
  101. package/src/workflows/scripted-workflow.ts +80 -7
  102. package/src/workflows/workflow-checklist.ts +4 -3
  103. package/src/runs/shared/child-protocol.ts +0 -415
  104. package/src/runs/shared/pi-args.ts +0 -1059
  105. package/src/runs/shared/subagent-startup-retry.ts +0 -116
  106. package/src/shared/post-exit-stdio-guard.ts +0 -85
@@ -39,6 +39,8 @@ interface AsyncJobTrackerOptions {
39
39
  watch?: typeof fs.watch;
40
40
  kill?: (pid: number, signal?: NodeJS.Signals | 0) => boolean;
41
41
  now?: () => number;
42
+ /** Resolve native supervisor requests without scanning supervisor mailboxes. */
43
+ supervisorRequestState?: (event: ControlEvent) => "pending" | "resolved" | "unknown";
42
44
  }
43
45
 
44
46
  const CONTROL_EVENT_READ_CHUNK_BYTES = 64 * 1024;
@@ -293,6 +295,15 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
293
295
  if ((parsed as { type?: unknown }).type !== "subagent.control") return;
294
296
  const record = parsed as { event?: ControlEvent; channels?: string[]; childIntercomTarget?: string; noticeText?: string; intercom?: { to?: string; message?: string } };
295
297
  if (!record.event || !Array.isArray(record.channels)) return;
298
+ if (record.event.type === "needs_attention" && record.event.reason === "supervisor_request" && options.supervisorRequestState) {
299
+ let requestState: "pending" | "resolved" | "unknown" = "unknown";
300
+ try {
301
+ requestState = options.supervisorRequestState(record.event);
302
+ } catch (error) {
303
+ console.error(`Failed to resolve supervisor request state for async control event in '${job.asyncDir}':`, error);
304
+ }
305
+ if (requestState === "resolved") return;
306
+ }
296
307
  const payload = {
297
308
  event: record.event,
298
309
  source: "async" as const,
@@ -12,8 +12,10 @@ import { reconcileAsyncRun } from "./stale-run-reconciler.ts";
12
12
  import { resultFilePath, resultPayloadPathForIndexedRun } from "./result-files.ts";
13
13
  import { canScanAsyncRunPrefix, MIN_SAFE_ASYNC_RUN_PREFIX_LENGTH } from "./run-id-query.ts";
14
14
  import { parallelHandoffPath, resolveRetainedWorktreeCwd } from "../shared/parallel-handoff.ts";
15
+ import { normalizeWorktreeBaseRef } from "../shared/worktree.ts";
15
16
  import { intersectThinkingCeilings, parseThinkingLevel, type ThinkingLevel } from "../../shared/thinking-ceiling.ts";
16
17
  import { assertWorkflowGraphHostSteps } from "../shared/host-step-status.ts";
18
+ import { validateModelResponseAliases } from "../../shared/model-response-aliases.ts";
17
19
 
18
20
  export interface AsyncResumeParams {
19
21
  id?: string;
@@ -316,11 +318,11 @@ export function readAsyncRecoveryDescriptor(asyncDir: string | undefined): Steer
316
318
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`Invalid async recovery descriptor '${descriptorPath}': expected an object.`);
317
319
  const parsed = value as Record<string, unknown>;
318
320
  const allowedFields = new Set([
319
- "version", "launchContractDigest", "sourceRunId", "agentContract", "agent", "sessionFile", "cwd", "model", "modelProvider", "modelOverrideFromParent", "modelOrigin", "fallbackModels", "thinking", "thinkingCeiling", "tools", "allowNestedSubagents", "extensions",
321
+ "modelResponseAliases", "version", "launchContractDigest", "sourceRunId", "agentContract", "agent", "sessionFile", "cwd", "model", "modelProvider", "modelOverrideFromParent", "modelOrigin", "fallbackModels", "thinking", "thinkingCeiling", "tools", "allowNestedSubagents", "extensions",
320
322
  "subagentOnlyExtensions", "mcpDirectTools", "excludeTools", "mutationTools", "systemPrompt", "systemPromptMode", "inheritProjectContext", "inheritGlobalContext", "inheritSkills", "skills",
321
323
  "skillPath", "agentFilePath", "completionGuard", "memory", "outputPath", "outputMode", "structuredOutputSchema", "acceptance", "sessionDir", "artifactConfig",
322
324
  "artifactsDir", "maxOutput", "controlConfig", "context", "intercomBridge", "absoluteDeadlineAt", "initialTurnBudget", "initialToolBudget", "maxSubagentDepth", "share", "capabilityCeiling",
323
- "launchResolvedExtensions", "runFanoutBudget", "lane",
325
+ "launchResolvedExtensions", "runFanoutBudget", "lane", "baseRef",
324
326
  "extensionBindings",
325
327
  ]);
326
328
  for (const field of Object.keys(parsed)) {
@@ -336,6 +338,7 @@ export function readAsyncRecoveryDescriptor(asyncDir: string | undefined): Steer
336
338
  } catch (error) {
337
339
  throw new Error(`Invalid async recovery descriptor '${descriptorPath}': ${error instanceof Error ? error.message : String(error)}`);
338
340
  }
341
+ validateModelResponseAliases(parsed.modelResponseAliases, `async recovery descriptor '${descriptorPath}' modelResponseAliases`);
339
342
  if (parsed.capabilityCeiling !== undefined) parsed.capabilityCeiling = parseSubagentCapabilityCeiling(parsed.capabilityCeiling, `async recovery descriptor '${descriptorPath}' capabilityCeiling`);
340
343
  if (parsed.thinkingCeiling !== undefined) parsed.thinkingCeiling = parseThinkingLevel(parsed.thinkingCeiling, `async recovery descriptor '${descriptorPath}' thinkingCeiling`);
341
344
  if (parsed.extensionBindings !== undefined) parsed.extensionBindings = normalizeExtensionBindings(parsed.extensionBindings)!.value;
@@ -366,6 +369,14 @@ export function readAsyncRecoveryDescriptor(asyncDir: string | undefined): Steer
366
369
  for (const field of ["launchContractDigest", "sessionFile", "model", "modelProvider", "thinking", "agentFilePath", "outputPath", "sessionDir", "artifactsDir"] as const) {
367
370
  if (parsed[field] !== undefined && (typeof parsed[field] !== "string" || !(parsed[field] as string).trim())) throw new Error(`Invalid async recovery descriptor '${descriptorPath}': ${field} must be a non-empty string.`);
368
371
  }
372
+ if (parsed.baseRef !== undefined) {
373
+ if (typeof parsed.baseRef !== "string") throw new Error(`Invalid async recovery descriptor '${descriptorPath}': baseRef must be a string.`);
374
+ try {
375
+ parsed.baseRef = normalizeWorktreeBaseRef(parsed.baseRef);
376
+ } catch (error) {
377
+ throw new Error(`Invalid async recovery descriptor '${descriptorPath}': ${error instanceof Error ? error.message : String(error)}`);
378
+ }
379
+ }
369
380
  if (parsed.completionGuard !== undefined && typeof parsed.completionGuard !== "boolean") throw new Error(`Invalid async recovery descriptor '${descriptorPath}': completionGuard must be a boolean.`);
370
381
  if (parsed.structuredOutputSchema !== undefined && (!parsed.structuredOutputSchema || typeof parsed.structuredOutputSchema !== "object" || Array.isArray(parsed.structuredOutputSchema))) throw new Error(`Invalid async recovery descriptor '${descriptorPath}': structuredOutputSchema must be an object.`);
371
382
  if (parsed.memory !== undefined) {
@@ -623,7 +634,7 @@ export function buildRevivedAsyncTask(target: AsyncResumeTarget, message: string
623
634
  `Original agent: ${target.agent}`,
624
635
  target.sessionFile ? `Original session file: ${target.sessionFile}` : undefined,
625
636
  "",
626
- "Use the stored session context as background. Answer the orchestrator's follow-up below. Do not assume the original child process is still alive.",
637
+ "Use the stored session context as background. Answer the orchestrator's follow-up below. Do not assume the original child session is still running.",
627
638
  "",
628
639
  "Follow-up:",
629
640
  message,
@@ -320,7 +320,16 @@ function readCursor(root: string): RetentionCursor {
320
320
  return value?.version === 1 ? value as unknown as RetentionCursor : { version: 1 };
321
321
  }
322
322
 
323
+ let currentProcessStartIdentity: string | undefined | null = null;
324
+
323
325
  function processStartIdentity(pid: number): string | undefined {
326
+ // Foreign PIDs can be reused while this process lives, so resolve them afresh.
327
+ if (pid !== process.pid) return computeProcessStartIdentity(pid);
328
+ if (currentProcessStartIdentity === null) currentProcessStartIdentity = computeProcessStartIdentity(pid);
329
+ return currentProcessStartIdentity;
330
+ }
331
+
332
+ function computeProcessStartIdentity(pid: number): string | undefined {
324
333
  if (process.platform === "linux") {
325
334
  try {
326
335
  const stat = fs.readFileSync(`/proc/${pid}/stat`).toString("utf8");
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Cross-OS control channel for async subagent runs.
3
3
  *
4
- * Background runs use child processes. Unix detaches them from the parent process.
4
+ * Background runs use a detached runner process. Unix detaches it from the parent process.
5
5
  * The original control path delivered an interrupt with
6
6
  * `process.kill(pid, SIGUSR2|SIGBREAK)`, but Windows cannot
7
7
  * deliver those signals cross-process via `process.kill` and throws `ENOSYS`,
@@ -74,33 +74,10 @@ export interface SteerRequest {
74
74
  source?: string;
75
75
  }
76
76
 
77
- export interface SteerCapability {
78
- type: "steer-capability";
79
- protocolVersion: 1;
80
- index: number;
81
- pid: number;
82
- readyAt: number;
83
- supported: boolean;
84
- }
85
-
86
- export interface SteerAck {
87
- type: "steer-ack";
88
- protocolVersion: 1;
89
- requestId: string;
90
- index: number;
91
- ts: number;
92
- state: "delivered" | "queued" | "failed";
93
- deliveryStatus?: SteerDeliveryStatus;
94
- message: string;
95
- }
96
-
97
77
  const STEER_REQUESTS_DIR = "steer-requests";
98
78
  const STOP_REQUESTS_DIR = "stop-requests";
99
79
  const REVIVAL_BRIEFS_DIR = "revival-briefs";
100
80
  export const MAX_STEER_QUEUE_SIZE = 20;
101
- const STEER_TARGETS_DIR = "steer-targets";
102
- const STEER_CAPABILITIES_DIR = "steer-capabilities";
103
- const STEER_ACKS_DIR = "steer-acks";
104
81
  const STEER_INBOX_CLOSED_FILE = "steer-inbox-closed.json";
105
82
  const MAX_STEER_MESSAGE_BYTES = 128 * 1024;
106
83
  const MAX_STEER_REQUEST_ID_LENGTH = 256;
@@ -143,35 +120,6 @@ export function closeSteerInbox(asyncDir: string, state: string, write: (filePat
143
120
  write(steerInboxClosedPath(asyncDir), { version: 1, closedAt: Date.now(), state });
144
121
  }
145
122
 
146
- /** Per-child inbox consumed by the child prompt runtime inside the Pi process. */
147
- export function stepSteerInboxDir(asyncDir: string, index: number): string {
148
- assertChildIndex(index);
149
- return path.join(controlInboxDir(asyncDir), STEER_TARGETS_DIR, String(index));
150
- }
151
-
152
- export function steerCapabilitiesDir(asyncDir: string): string {
153
- return path.join(controlInboxDir(asyncDir), STEER_CAPABILITIES_DIR);
154
- }
155
-
156
- export function steerCapabilityPath(asyncDir: string, index: number): string {
157
- assertChildIndex(index);
158
- return path.join(steerCapabilitiesDir(asyncDir), `${index}.json`);
159
- }
160
-
161
- export function steerAcksDir(asyncDir: string, index: number): string {
162
- assertChildIndex(index);
163
- return path.join(controlInboxDir(asyncDir), STEER_ACKS_DIR, String(index));
164
- }
165
-
166
- function steerAckFileName(requestId: string): string {
167
- return `${Buffer.from(requestId).toString("base64url")}.json`;
168
- }
169
-
170
- export function steerAckPathFromDir(dir: string, requestId: string): string {
171
- if (!/^[^\s]+$/.test(requestId) || requestId.length > 256) throw new Error("steer acknowledgment requestId is invalid.");
172
- return path.join(dir, steerAckFileName(requestId));
173
- }
174
-
175
123
  function assertChildIndex(index: number): void {
176
124
  if (!Number.isInteger(index) || index < 0 || index > 1_000_000) throw new Error("child index must be a non-negative integer.");
177
125
  }
@@ -229,45 +177,6 @@ export function writeSteerRequestToExistingDir(dir: string, request: SteerReques
229
177
  return requestPath;
230
178
  }
231
179
 
232
- export function writeSteerCapabilityAt(filePath: string, capability: Omit<SteerCapability, "type" | "protocolVersion">): string {
233
- assertChildIndex(capability.index);
234
- if (!Number.isInteger(capability.pid) || capability.pid <= 0) throw new Error("steer capability pid must be a positive integer.");
235
- if (!Number.isFinite(capability.readyAt) || capability.readyAt <= 0) throw new Error("steer capability readyAt must be a finite timestamp.");
236
- const record: SteerCapability = { type: "steer-capability", protocolVersion: 1, ...capability };
237
- writeAtomicJson(filePath, record);
238
- return filePath;
239
- }
240
-
241
- export function writeSteerCapability(asyncDir: string, capability: Omit<SteerCapability, "type" | "protocolVersion">): string {
242
- return writeSteerCapabilityAt(steerCapabilityPath(asyncDir, capability.index), capability);
243
- }
244
-
245
- function steerAckWritePath(filePath: string, ack: Omit<SteerAck, "type" | "protocolVersion">): string {
246
- const parsed = path.parse(filePath);
247
- const stateOrder = ack.state === "queued" ? "0" : ack.state === "delivered" ? "1" : "2";
248
- const timestamp = String(Math.trunc(ack.ts)).padStart(13, "0");
249
- for (let suffix = 0; suffix < 1_000; suffix += 1) {
250
- const candidate = path.join(parsed.dir, `${parsed.name}-${timestamp}-${stateOrder}-${ack.state}${suffix === 0 ? "" : `-${suffix}`}${parsed.ext}`);
251
- if (!fs.existsSync(candidate)) return candidate;
252
- }
253
- throw new Error("steer acknowledgment queue is full.");
254
- }
255
-
256
- export function writeSteerAckAt(filePath: string, ack: Omit<SteerAck, "type" | "protocolVersion">): string {
257
- assertChildIndex(ack.index);
258
- if (!/^[^\s]+$/.test(ack.requestId) || ack.requestId.length > 256) throw new Error("steer acknowledgment requestId is invalid.");
259
- if (!Number.isFinite(ack.ts) || ack.ts <= 0) throw new Error("steer acknowledgment ts must be a finite timestamp.");
260
- if (!ack.message.trim() || ack.message.length > 1000) throw new Error("steer acknowledgment message is invalid.");
261
- const record: SteerAck = { type: "steer-ack", protocolVersion: 1, ...ack, message: ack.message.trim() };
262
- const ackPath = steerAckWritePath(filePath, ack);
263
- writeAtomicJson(ackPath, record);
264
- return ackPath;
265
- }
266
-
267
- export function writeSteerAck(asyncDir: string, ack: Omit<SteerAck, "type" | "protocolVersion">): string {
268
- return writeSteerAckAt(path.join(steerAcksDir(asyncDir, ack.index), steerAckFileName(ack.requestId)), ack);
269
- }
270
-
271
180
  /**
272
181
  * Parent side: drop a portable interrupt request the runner's inbox watcher will
273
182
  * pick up regardless of OS. Written atomically (temp + rename), dir auto-created.
@@ -350,97 +259,6 @@ export function requestAsyncSteer(
350
259
  return requestPath;
351
260
  }
352
261
 
353
- export function enqueueStepSteer(asyncDir: string, index: number, request: SteerRequest): string {
354
- assertChildIndex(index);
355
- const { targetIndexes: _targetIndexes, ...singleTargetRequest } = request;
356
- return writeSteerRequestToDir(stepSteerInboxDir(asyncDir, index), { ...singleTargetRequest, targetIndex: index, type: "steer" });
357
- }
358
-
359
- function parseSteerCapability(raw: unknown): SteerCapability | undefined {
360
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
361
- const input = raw as Partial<SteerCapability>;
362
- if (input.type !== "steer-capability" || input.protocolVersion !== 1) return undefined;
363
- const { index, pid, readyAt, supported } = input;
364
- if (typeof index !== "number" || !Number.isInteger(index) || index < 0 || index > 1_000_000) return undefined;
365
- if (typeof pid !== "number" || typeof readyAt !== "number" || !Number.isInteger(pid) || pid <= 0 || !Number.isFinite(readyAt) || readyAt <= 0 || typeof supported !== "boolean") return undefined;
366
- return { type: "steer-capability", protocolVersion: 1, index, pid, readyAt, supported };
367
- }
368
-
369
- function parseSteerAck(raw: unknown): SteerAck | undefined {
370
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
371
- const input = raw as Partial<SteerAck>;
372
- if (input.type !== "steer-ack" || input.protocolVersion !== 1 || typeof input.requestId !== "string" || !/^[^\s]+$/.test(input.requestId) || input.requestId.length > 256) return undefined;
373
- const { index, ts, state, message } = input;
374
- if (typeof index !== "number" || typeof ts !== "number" || !Number.isInteger(index) || index < 0 || index > 1_000_000 || !Number.isFinite(ts) || ts <= 0) return undefined;
375
- if (state !== "delivered" && state !== "queued" && state !== "failed") return undefined;
376
- if (input.deliveryStatus !== undefined && input.deliveryStatus !== "delivered" && input.deliveryStatus !== "queued") return undefined;
377
- if (typeof message !== "string" || !message.trim() || message.length > 1000) return undefined;
378
- return { type: "steer-ack", protocolVersion: 1, requestId: input.requestId, index, ts, state, ...(input.deliveryStatus ? { deliveryStatus: input.deliveryStatus } : {}), message: message.trim() };
379
- }
380
-
381
- export function readSteerCapability(asyncDir: string, index: number): SteerCapability | undefined {
382
- try {
383
- return parseSteerCapability(JSON.parse(fs.readFileSync(steerCapabilityPath(asyncDir, index), "utf-8")));
384
- } catch {
385
- return undefined;
386
- }
387
- }
388
-
389
- export function consumeSteerCapabilities(asyncDir: string, fsImpl: Pick<typeof fs, "existsSync" | "readdirSync" | "readFileSync"> = fs): SteerCapability[] {
390
- const dir = steerCapabilitiesDir(asyncDir);
391
- if (!fsImpl.existsSync(dir)) return [];
392
- const capabilities: SteerCapability[] = [];
393
- for (const entry of fsImpl.readdirSync(dir).filter((name) => /^\d+\.json$/.test(name)).sort()) {
394
- try {
395
- const capability = parseSteerCapability(JSON.parse(fsImpl.readFileSync(path.join(dir, entry), "utf-8")));
396
- if (capability) capabilities.push(capability);
397
- } catch {
398
- // A partially written or malformed capability is ignored until a valid one arrives.
399
- }
400
- }
401
- return capabilities;
402
- }
403
-
404
- export function consumeSteerAckFromDir(
405
- dir: string,
406
- requestId: string,
407
- fsImpl: Pick<typeof fs, "existsSync" | "readdirSync" | "readFileSync" | "rmSync"> = fs,
408
- ): SteerAck | undefined {
409
- if (!fsImpl.existsSync(dir)) return undefined;
410
- let entries: string[];
411
- try { entries = fsImpl.readdirSync(dir).filter((name) => name.endsWith(".json")).sort(); } catch { return undefined; }
412
- for (const entry of entries) {
413
- const target = path.join(dir, entry);
414
- let ack: SteerAck | undefined;
415
- try { ack = parseSteerAck(JSON.parse(fsImpl.readFileSync(target, "utf-8"))); } catch { ack = undefined; }
416
- if (ack?.requestId !== requestId) continue;
417
- try { fsImpl.rmSync(target, { force: true }); } catch { return undefined; }
418
- return ack;
419
- }
420
- return undefined;
421
- }
422
-
423
- export function consumeSteerAcks(asyncDir: string, fsImpl: Pick<typeof fs, "existsSync" | "readdirSync" | "readFileSync" | "rmSync"> = fs): SteerAck[] {
424
- const root = path.join(controlInboxDir(asyncDir), STEER_ACKS_DIR);
425
- if (!fsImpl.existsSync(root)) return [];
426
- const acks: SteerAck[] = [];
427
- let indexNames: string[];
428
- try { indexNames = fsImpl.readdirSync(root).filter((name) => /^\d+$/.test(name)); } catch { return []; }
429
- for (const indexName of indexNames) {
430
- const dir = path.join(root, indexName);
431
- let entries: string[];
432
- try { entries = fsImpl.readdirSync(dir).filter((name) => name.endsWith(".json")).sort(); } catch { continue; }
433
- for (const entry of entries) {
434
- const target = path.join(dir, entry);
435
- let ack: SteerAck | undefined;
436
- try { ack = parseSteerAck(JSON.parse(fsImpl.readFileSync(target, "utf-8"))); } catch { ack = undefined; }
437
- try { fsImpl.rmSync(target, { force: true }); } catch { continue; }
438
- if (ack) acks.push(ack);
439
- }
440
- }
441
- return acks;
442
- }
443
-
444
262
  function parseSteerRequest(raw: unknown): SteerRequest | undefined {
445
263
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
446
264
  const input = raw as Partial<SteerRequest>;
@@ -665,8 +483,6 @@ export function watchAsyncControlInbox(
665
483
  onTimeout?: () => void;
666
484
  onStop?: (request: StopRequest) => void;
667
485
  onSteer?: (request: SteerRequest) => void;
668
- onSteerCapability?: (capability: SteerCapability) => void;
669
- onSteerAck?: (ack: SteerAck) => void;
670
486
  pollIntervalMs?: number;
671
487
  safetyPollIntervalMs?: number;
672
488
  platform?: NodeJS.Platform;
@@ -691,8 +507,6 @@ export function watchAsyncControlInbox(
691
507
  if (consumeTimeoutRequest(asyncDir, fsImpl)) opts.onTimeout?.();
692
508
  if (consumeInterruptRequest(asyncDir, fsImpl)) opts.onInterrupt();
693
509
  for (const request of consumeSteerRequests(asyncDir, fsImpl)) opts.onSteer?.(request);
694
- for (const capability of consumeSteerCapabilities(asyncDir, fsImpl)) opts.onSteerCapability?.(capability);
695
- for (const ack of consumeSteerAcks(asyncDir, fsImpl)) opts.onSteerAck?.(ack);
696
510
  } catch {
697
511
  // Never let inbox errors crash the runner.
698
512
  }
@@ -718,32 +532,16 @@ export function watchAsyncControlInbox(
718
532
  const watchDir = (target: string, create = false): void => {
719
533
  if (disposed || watchedDirs.has(target)) return;
720
534
  if (create) fsImpl.mkdirSync(target, { recursive: true });
721
- const watcher = fsImpl.watch(resolveWatchPath(target, fsImpl.realpathSync.native), () => {
722
- watchExistingSteerAckDirs();
723
- check();
724
- });
535
+ const watcher = fsImpl.watch(resolveWatchPath(target, fsImpl.realpathSync.native), () => check());
725
536
  watcher.on?.("error", startPolling);
726
537
  watchers.push(watcher);
727
538
  watchedDirs.add(target);
728
539
  };
729
- const watchExistingSteerAckDirs = (): void => {
730
- const ackRoot = path.join(dir, STEER_ACKS_DIR);
731
- let entries: string[];
732
- try {
733
- entries = fsImpl.readdirSync(ackRoot).filter((name) => /^\d+$/.test(name));
734
- } catch {
735
- return;
736
- }
737
- for (const entry of entries) watchDir(path.join(ackRoot, entry));
738
- };
739
540
  try {
740
541
  if (shouldUseNativeFsWatch("runner-control-inbox", opts.platform)) {
741
542
  watchDir(dir);
742
543
  watchDir(stopRequestsDir(asyncDir), true);
743
544
  watchDir(steerRequestsDir(asyncDir), true);
744
- watchDir(steerCapabilitiesDir(asyncDir), true);
745
- watchDir(path.join(dir, STEER_ACKS_DIR), true);
746
- watchExistingSteerAckDirs();
747
545
  startSafetyPolling();
748
546
  } else {
749
547
  startPolling();
@@ -123,6 +123,7 @@ export interface RegisterSubagentNotifyOptions {
123
123
 
124
124
  export interface CompletionNotifier {
125
125
  deliver(result: CompletionNotification): Promise<boolean>;
126
+ hasPendingDelivery(): boolean;
126
127
  dispose(): void;
127
128
  }
128
129
 
@@ -628,6 +629,7 @@ export default function registerSubagentNotify(
628
629
 
629
630
  return {
630
631
  deliver,
632
+ hasPendingDelivery: () => pending.size > 0,
631
633
  dispose() {
632
634
  if (disposed) return;
633
635
  disposed = true;
@@ -115,7 +115,7 @@ export function writeProcessTerminalCandidate(asyncDir: string, candidate: Proce
115
115
  writePrivateAtomicJson(processTerminalCandidatePath(asyncDir), candidate);
116
116
  }
117
117
 
118
- /** Establish ownership before authorizing a runner to launch any child process. */
118
+ /** Establish ownership before authorizing a runner to start any child session. */
119
119
  export function initializeProcessTerminal(asyncDir: string, runId: string, runnerProcessInstanceId: string): void {
120
120
  writeProcessTerminalCandidate(asyncDir, {
121
121
  version: 1,
@@ -0,0 +1,96 @@
1
+ import * as fs from "node:fs";
2
+ import type { SubagentState } from "../../shared/types.ts";
3
+ import { shouldUseNativeFsWatch } from "../../shared/watch-strategy.ts";
4
+ import { hasLiveNestedDescendants, projectNestedEvents } from "../shared/nested-events.ts";
5
+
6
+ interface RetainedNestedRouteTrackerOptions {
7
+ pollIntervalMs?: number;
8
+ platform?: NodeJS.Platform;
9
+ watch?: typeof fs.watch;
10
+ }
11
+
12
+ const DEFAULT_POLL_INTERVAL_MS = 5000;
13
+ const REFRESH_DEBOUNCE_MS = 25;
14
+
15
+ export function createRetainedNestedRouteTracker(
16
+ state: Pick<SubagentState, "retainedForegroundNestedRoutes">,
17
+ options: RetainedNestedRouteTrackerOptions = {},
18
+ ): { track: (rootRunId: string) => void; clear: () => void } {
19
+ const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
20
+ const watch = options.watch ?? fs.watch;
21
+ const watchers = new Map<string, fs.FSWatcher>();
22
+ const refreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
23
+ let poller: ReturnType<typeof setInterval> | undefined;
24
+
25
+ const close = (rootRunId: string): void => {
26
+ watchers.get(rootRunId)?.close();
27
+ watchers.delete(rootRunId);
28
+ const timer = refreshTimers.get(rootRunId);
29
+ if (timer) clearTimeout(timer);
30
+ refreshTimers.delete(rootRunId);
31
+ };
32
+
33
+ const refresh = (rootRunId: string): void => {
34
+ const retained = state.retainedForegroundNestedRoutes?.get(rootRunId);
35
+ if (!retained) {
36
+ close(rootRunId);
37
+ return;
38
+ }
39
+ try {
40
+ if (hasLiveNestedDescendants(projectNestedEvents(retained).children)) return;
41
+ state.retainedForegroundNestedRoutes?.delete(rootRunId);
42
+ close(rootRunId);
43
+ } catch (error) {
44
+ console.error(`Failed to refresh retained nested descendants for foreground run '${rootRunId}':`, error);
45
+ }
46
+ };
47
+
48
+ const scheduleRefresh = (rootRunId: string): void => {
49
+ if (refreshTimers.has(rootRunId)) return;
50
+ const timer = setTimeout(() => {
51
+ refreshTimers.delete(rootRunId);
52
+ refresh(rootRunId);
53
+ }, REFRESH_DEBOUNCE_MS);
54
+ timer.unref?.();
55
+ refreshTimers.set(rootRunId, timer);
56
+ };
57
+
58
+ const ensurePoller = (): void => {
59
+ if (poller || (state.retainedForegroundNestedRoutes?.size ?? 0) === 0) return;
60
+ poller = setInterval(() => {
61
+ for (const rootRunId of state.retainedForegroundNestedRoutes?.keys() ?? []) refresh(rootRunId);
62
+ if ((state.retainedForegroundNestedRoutes?.size ?? 0) > 0) return;
63
+ if (poller) clearInterval(poller);
64
+ poller = undefined;
65
+ }, pollIntervalMs);
66
+ poller.unref?.();
67
+ };
68
+
69
+ const track = (rootRunId: string): void => {
70
+ const retained = state.retainedForegroundNestedRoutes?.get(rootRunId);
71
+ if (!retained) return;
72
+ if (shouldUseNativeFsWatch("retained-nested-route-tracker", options.platform) && !watchers.has(rootRunId)) {
73
+ try {
74
+ const watcher = watch(retained.eventSink, () => scheduleRefresh(rootRunId));
75
+ watcher.on("error", () => close(rootRunId));
76
+ watcher.unref?.();
77
+ watchers.set(rootRunId, watcher);
78
+ } catch {
79
+ // The bounded safety poll below covers unsupported or failed watchers.
80
+ }
81
+ }
82
+ scheduleRefresh(rootRunId);
83
+ ensurePoller();
84
+ };
85
+
86
+ const clear = (): void => {
87
+ if (poller) clearInterval(poller);
88
+ poller = undefined;
89
+ for (const rootRunId of watchers.keys()) close(rootRunId);
90
+ for (const timer of refreshTimers.values()) clearTimeout(timer);
91
+ refreshTimers.clear();
92
+ state.retainedForegroundNestedRoutes?.clear();
93
+ };
94
+
95
+ return { track, clear };
96
+ }