pi-subagents 0.65.0 → 0.66.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 (104) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/README.md +1 -1
  3. package/agents/researcher.md +23 -13
  4. package/docs/agents.md +17 -3
  5. package/docs/configuration.md +34 -0
  6. package/docs/extension-api.md +94 -0
  7. package/docs/models.md +58 -1
  8. package/docs/observability.md +42 -2
  9. package/docs/tool-reference.md +11 -5
  10. package/docs/workflows.md +22 -7
  11. package/package.json +4 -1
  12. package/runner-server-preload.mjs +13 -0
  13. package/skills/pi-subagents/SKILL.md +2 -1
  14. package/skills/pi-subagents/references/execution-controls.md +19 -2
  15. package/skills/pi-subagents/references/management-authoring-rpc.md +2 -1
  16. package/skills/pi-subagents/references/multi-lane-orchestration.md +2 -0
  17. package/src/agents/advertised-agent-prompt.ts +63 -0
  18. package/src/agents/agent-management.ts +14 -1
  19. package/src/agents/agent-serializer.ts +2 -0
  20. package/src/agents/agents.ts +8 -0
  21. package/src/api/preflight.ts +5 -1
  22. package/src/api/shared-types.ts +1 -1
  23. package/src/api/workflow-resources.ts +6 -0
  24. package/src/extension/config.ts +4 -2
  25. package/src/extension/index.ts +71 -4
  26. package/src/extension/public-execution.ts +0 -1
  27. package/src/extension/rpc.ts +4 -21
  28. package/src/extension/schemas.ts +9 -7
  29. package/src/extension/tool-description.ts +10 -5
  30. package/src/integrations/pi-web-session-liveness.ts +73 -0
  31. package/src/intercom/native-supervisor-channel.ts +102 -88
  32. package/src/intercom/supervisor-ui.ts +3 -2
  33. package/src/missions/workflow-state.ts +37 -16
  34. package/src/runs/background/active-async-capacity.ts +18 -18
  35. package/src/runs/background/async-execution.ts +8 -1
  36. package/src/runs/background/async-job-tracker.ts +35 -3
  37. package/src/runs/background/async-resume.ts +3 -1
  38. package/src/runs/background/async-retention.ts +9 -0
  39. package/src/runs/background/async-status-snapshot.ts +10 -12
  40. package/src/runs/background/async-status.ts +17 -9
  41. package/src/runs/background/auto-drain.ts +40 -29
  42. package/src/runs/background/chain-root-attachment.ts +8 -0
  43. package/src/runs/background/control-channel.ts +78 -44
  44. package/src/runs/background/notify.ts +88 -12
  45. package/src/runs/background/owned-process-tree.ts +6 -6
  46. package/src/runs/background/process-terminal.ts +23 -23
  47. package/src/runs/background/retained-nested-route-tracker.ts +96 -0
  48. package/src/runs/background/run-child-session.ts +62 -33
  49. package/src/runs/background/run-status.ts +75 -5
  50. package/src/runs/background/runner-aliases.ts +46 -8
  51. package/src/runs/background/runner-child-launch.ts +86 -0
  52. package/src/runs/background/stale-run-reconciler.ts +3 -1
  53. package/src/runs/background/subagent-runner.ts +430 -208
  54. package/src/runs/background/subagent-wait.ts +3 -0
  55. package/src/runs/background/wait-completions.ts +4 -0
  56. package/src/runs/foreground/async-steering-action.ts +19 -0
  57. package/src/runs/foreground/execution.ts +116 -26
  58. package/src/runs/foreground/foreground-history.ts +3 -1
  59. package/src/runs/foreground/prompt-audit.ts +9 -5
  60. package/src/runs/foreground/subagent-executor.ts +531 -227
  61. package/src/runs/foreground/workflow-detach-reconcile.ts +8 -5
  62. package/src/runs/foreground/workflow-foreground-steering.ts +56 -2
  63. package/src/runs/shared/acceptance.ts +16 -3
  64. package/src/runs/shared/agent-contract.ts +1 -1
  65. package/src/runs/shared/async-status-projection.ts +47 -47
  66. package/src/runs/shared/child-hooks.ts +151 -2
  67. package/src/runs/shared/child-launch.ts +18 -13
  68. package/src/runs/shared/child-session.ts +55 -24
  69. package/src/runs/shared/child-tool-plan.ts +2 -2
  70. package/src/runs/shared/completion-evidence.ts +2 -2
  71. package/src/runs/shared/completion-guard.ts +1 -0
  72. package/src/runs/shared/host-step-status.ts +11 -11
  73. package/src/runs/shared/llm-intent-arbiter.ts +30 -20
  74. package/src/runs/shared/model-exclusions.ts +2 -1
  75. package/src/runs/shared/model-fallback.ts +41 -8
  76. package/src/runs/shared/nested-events.ts +8 -8
  77. package/src/runs/shared/orca-progress-tabs.ts +6 -0
  78. package/src/runs/shared/parallel-handoff.ts +57 -12
  79. package/src/runs/shared/parallel-utils.ts +3 -2
  80. package/src/runs/shared/readonly-drain-observation.ts +42 -0
  81. package/src/runs/shared/readonly-model-continuation.ts +69 -0
  82. package/src/runs/shared/readonly-session-evidence.ts +307 -0
  83. package/src/runs/shared/run-fanout-budget.ts +8 -8
  84. package/src/runs/shared/runtime-acknowledged-extensions.ts +3 -3
  85. package/src/runs/shared/subagent-prompt-runtime.ts +13 -3
  86. package/src/runs/shared/worktree-cleanup-plan.ts +6 -3
  87. package/src/runs/shared/worktree-setup-command.ts +190 -0
  88. package/src/runs/shared/worktree.ts +403 -210
  89. package/src/shared/model-response-aliases.ts +13 -0
  90. package/src/shared/types.ts +89 -60
  91. package/src/shared/utils.ts +10 -2
  92. package/src/shared/watch-strategy.ts +2 -0
  93. package/src/shared/workflow-child-permit.ts +18 -13
  94. package/src/tui/fleet-status.ts +1 -1
  95. package/src/tui/fleet.ts +11 -5
  96. package/src/tui/render.ts +44 -15
  97. package/src/workflows/chat-progress.ts +3 -3
  98. package/src/workflows/scripted-workflow.ts +70 -16
  99. package/src/workflows/workflow-checklist.ts +15 -18
  100. package/src/workflows/workflow-child-summary.ts +57 -8
  101. package/src/workflows/workflow-preflight.ts +19 -19
  102. package/src/workflows/workflow-receipt.ts +3 -3
  103. package/src/workflows/workflow-resources.ts +96 -21
  104. package/src/workflows/workflow-settlement.ts +3 -0
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
4
  import type { AgentToolResult } from "@earendil-works/pi-agent-core";
5
- import type { ExtensionAPI, ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
5
+ import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-agent";
6
6
  import { Type } from "typebox";
7
7
  import type { ChildSupervisorMetadata } from "../runs/shared/child-runtime-config.ts";
8
8
  import { INTERCOM_DETACH_REQUEST_EVENT, POLL_INTERVAL_MS, TEMP_ROOT_DIR, type ControlEvent, type IntercomEventBus, type SubagentState } from "../shared/types.ts";
@@ -74,7 +74,7 @@ interface ContactSupervisorParams {
74
74
  }
75
75
 
76
76
  interface IntercomParams {
77
- action: "list" | "send" | "ask" | "reply" | "pending" | "status";
77
+ action: "list" | "pending" | "status" | "reply";
78
78
  to?: string;
79
79
  message?: string;
80
80
  replyTo?: string;
@@ -83,6 +83,8 @@ interface IntercomParams {
83
83
  type SupervisorWatch = (filename: fs.PathLike, listener: fs.WatchListener<string>) => fs.FSWatcher;
84
84
 
85
85
  interface NativeSupervisorChannelDeps {
86
+ /** Retained scheduled states for the current runtime owner, never foreign owners. */
87
+ getCurrentOwnerStates?: () => Iterable<SubagentState>;
86
88
  platform?: NodeJS.Platform;
87
89
  watch?: SupervisorWatch;
88
90
  timers?: Pick<typeof globalThis, "setInterval" | "clearInterval" | "setImmediate" | "clearImmediate">;
@@ -95,7 +97,7 @@ const ContactSupervisorParamsSchema = Type.Object({
95
97
  }, { additionalProperties: false });
96
98
 
97
99
  const IntercomParamsSchema = Type.Object({
98
- action: Type.String({ enum: ["list", "send", "ask", "reply", "pending", "status"] }),
100
+ action: Type.String({ enum: ["list", "pending", "status", "reply"] }),
99
101
  to: Type.Optional(Type.String()),
100
102
  message: Type.Optional(Type.String()),
101
103
  replyTo: Type.Optional(Type.String()),
@@ -128,34 +130,6 @@ function reasonHeading(reason: SupervisorReason): string {
128
130
  return "Subagent needs a supervisor decision.";
129
131
  }
130
132
 
131
- function formatChildMessage(input: {
132
- reason: SupervisorReason;
133
- message?: string;
134
- interview?: unknown;
135
- runId: string;
136
- agent: string;
137
- childIndex: number;
138
- childTarget?: string;
139
- }): string {
140
- const lines = [
141
- reasonHeading(input.reason),
142
- `Run: ${input.runId}`,
143
- `Agent: ${input.agent}`,
144
- `Child index: ${input.childIndex}`,
145
- ];
146
- if (input.childTarget) lines.push(`Child intercom target: ${input.childTarget}`);
147
- lines.push("");
148
- if (input.message?.trim()) lines.push(input.message.trim());
149
- if (input.reason === "interview_request") {
150
- lines.push(
151
- "",
152
- "Structured response requested. Reply with JSON, optionally fenced in ```json, matching the requested interview shape.",
153
- );
154
- if (input.interview !== undefined) lines.push(JSON.stringify(input.interview, null, "\t"));
155
- }
156
- return lines.join("\n").trimEnd();
157
- }
158
-
159
133
  function parseStructuredReply(message: string): { value?: unknown; error?: string } {
160
134
  const trimmed = message.trim();
161
135
  const fenced = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i)?.[1]?.trim();
@@ -210,8 +184,8 @@ async function waitForReply(channelDir: string, requestId: string, deadline: num
210
184
  }
211
185
 
212
186
  async function sendSupervisorRequest(params: ContactSupervisorParams, metadata: ChildSupervisorMetadata, signal?: AbortSignal, toolCallId?: string): Promise<AgentToolResult<Record<string, unknown>>> {
213
- if (params.reason !== "progress_update" && !params.message?.trim() && params.reason !== "interview_request") {
214
- throw new Error("message is required for supervisor decisions.");
187
+ if (!params.message?.trim() && params.reason !== "interview_request") {
188
+ throw new Error("message is required for supervisor decisions and progress updates.");
215
189
  }
216
190
  ensureSupervisorChannelDir(metadata.channelDir);
217
191
  const requestId = randomUUID();
@@ -219,7 +193,7 @@ async function sendSupervisorRequest(params: ContactSupervisorParams, metadata:
219
193
  const createdAt = Date.now();
220
194
  const replyDeadline = createdAt + askTimeoutMs();
221
195
  const expiresAt = expectsReply ? replyDeadline : undefined;
222
- const message = formatChildMessage({ ...metadata, reason: params.reason, message: params.message, interview: params.interview });
196
+ const message = params.message?.trim() ?? "";
223
197
  const requestToolCallId = typeof toolCallId === "string" && toolCallId.length > 0 ? toolCallId : undefined;
224
198
  const request: SupervisorRequest = {
225
199
  type: "subagent.supervisor.request",
@@ -299,7 +273,7 @@ function parseRequestFile(file: string, channelDir: string): PendingSupervisorRe
299
273
  if (parsed.type !== "subagent.supervisor.request") return undefined;
300
274
  if (typeof parsed.id !== "string" || !parsed.id) return undefined;
301
275
  if (parsed.reason !== "need_decision" && parsed.reason !== "interview_request" && parsed.reason !== "progress_update") return undefined;
302
- if (typeof parsed.message !== "string" || !parsed.message) return undefined;
276
+ if (typeof parsed.message !== "string" || (!parsed.message.trim() && parsed.reason !== "interview_request")) return undefined;
303
277
  if (typeof parsed.runId !== "string" || typeof parsed.agent !== "string" || typeof parsed.childIndex !== "number") return undefined;
304
278
  return {
305
279
  ...parsed as SupervisorRequest,
@@ -409,18 +383,8 @@ function cleanupStaleEmptySupervisorChannels(nowMs = Date.now()): number {
409
383
  return removed;
410
384
  }
411
385
 
412
- function currentContextSessionId(state: Pick<SubagentState, "currentSessionId">, ctx: ExtensionContext): string | undefined {
413
- try {
414
- const sessionId = ctx.sessionManager.getSessionId();
415
- if (sessionId) return sessionId;
416
- } catch {
417
- // Fall through to the last known identity.
418
- }
419
- return state.currentSessionId ?? undefined;
420
- }
421
-
422
- function requestMatchesContext(request: SupervisorRequest, state: Pick<SubagentState, "currentSessionId">, ctx: ExtensionContext): boolean {
423
- const currentSessionId = currentContextSessionId(state, ctx);
386
+ function requestMatchesOwner(request: SupervisorRequest, state: Pick<SubagentState, "supervisorOwnerSessionId">): boolean {
387
+ const currentSessionId = state.supervisorOwnerSessionId;
424
388
  return Boolean(currentSessionId && request.orchestratorSessionId === currentSessionId);
425
389
  }
426
390
 
@@ -490,12 +454,12 @@ function requestRunInactive(request: SupervisorRequest, state: SubagentState): b
490
454
  return stepStatus === "complete" || stepStatus === "completed" || stepStatus === "failed" || stepStatus === "paused";
491
455
  }
492
456
 
493
- function requestLifecycle(request: PendingSupervisorRequest, state: SubagentState, ctx: ExtensionContext | undefined, now: number): SupervisorRequestLifecycle {
494
- if (ctx && !requestMatchesContext(request, state, ctx)) return "wrong-session";
457
+ function requestLifecycle(request: PendingSupervisorRequest, state: SubagentState, now: number, runState: SubagentState): SupervisorRequestLifecycle {
458
+ if (!requestMatchesOwner(request, state)) return "wrong-session";
495
459
  if (!fs.existsSync(request.requestFile)) return "missing";
496
460
  if (request.expectsReply && fs.existsSync(replyPath(request.channelDir, request.id))) return "resolved";
497
461
  if (request.expectsReply && now > requestExpiresAt(request, now)) return "expired";
498
- if (request.expectsReply && requestRunInactive(request, state)) return "inactive";
462
+ if (request.expectsReply && requestRunInactive(request, runState)) return "inactive";
499
463
  return "pending";
500
464
  }
501
465
 
@@ -503,10 +467,10 @@ function cleanupRequestLifecycle(request: PendingSupervisorRequest, lifecycle: S
503
467
  if (lifecycle === "resolved" || lifecycle === "expired" || lifecycle === "inactive") removeRequestFile(request.requestFile);
504
468
  }
505
469
 
506
- function refreshPendingRequests(pending: Map<string, PendingSupervisorRequest>, state: SubagentState, ctx: ExtensionContext | undefined, onLifecycle: SupervisorRequestLifecycleObserver): void {
470
+ function refreshPendingRequests(pending: Map<string, PendingSupervisorRequest>, state: SubagentState, onLifecycle: SupervisorRequestLifecycleObserver, runState: (request: SupervisorRequest) => SubagentState): void {
507
471
  const now = Date.now();
508
472
  for (const request of pending.values()) {
509
- const lifecycle = requestLifecycle(request, state, ctx, now);
473
+ const lifecycle = requestLifecycle(request, state, now, runState(request));
510
474
  if (lifecycle === "pending") continue;
511
475
  pending.delete(request.id);
512
476
  onLifecycle(request, lifecycle);
@@ -520,11 +484,24 @@ function formatPendingLine(request: PendingSupervisorRequest): string {
520
484
  }
521
485
 
522
486
  function requestVisibleText(request: PendingSupervisorRequest): string {
523
- const lines = [request.message];
524
- if (request.expectsReply) {
525
- lines.push("", `Reply with: ${supervisorReplyHint(request.id)}`);
487
+ const lines = [
488
+ reasonHeading(request.reason),
489
+ `Run: ${request.runId}`,
490
+ `Agent: ${request.agent}`,
491
+ `Child index: ${request.childIndex}`,
492
+ ];
493
+ lines.push("");
494
+ if (request.message) lines.push(request.message);
495
+ if (request.reason === "interview_request") {
496
+ lines.push(
497
+ "",
498
+ "Structured response requested. Reply with JSON, optionally fenced in ```json, matching the requested interview shape.",
499
+ );
500
+ if (request.interview !== undefined) lines.push(JSON.stringify(request.interview, null, "\t"));
526
501
  }
527
- return lines.join("\n");
502
+ if (request.expectsReply) lines.push("", `Reply with: ${supervisorReplyHint(request.id)}`);
503
+ lines.push("", `Live guidance: subagent({ action: "steer", id: ${JSON.stringify(request.runId)}, index: ${request.childIndex}, message: "..." })${request.expectsReply ? " (Reply to the pending request first.)" : ""}`);
504
+ return lines.join("\n").trimEnd();
528
505
  }
529
506
 
530
507
  function writeReply(request: PendingSupervisorRequest, message: string): SupervisorReply {
@@ -578,6 +555,7 @@ function resolvePendingRequest(pending: Map<string, PendingSupervisorRequest>, p
578
555
  );
579
556
  if (matches.length === 1) return matches[0]!;
580
557
  if (matches.length > 1) throw new Error(`Multiple pending supervisor requests match '${params.to}'. Use replyTo.`);
558
+ throw new Error(`No pending supervisor request matches '${params.to}'. Use replyTo.`);
581
559
  }
582
560
  if (requests.length === 1) return requests[0]!;
583
561
  if (requests.length === 0) throw new Error("No pending supervisor requests need a reply.");
@@ -595,14 +573,16 @@ function publicPendingRequests(pending: Map<string, PendingSupervisorRequest>):
595
573
  }));
596
574
  }
597
575
 
598
- function buildParentSupervisorTool(pi: ExtensionAPI, pending: Map<string, PendingSupervisorRequest>, state: SubagentState, onLifecycle: SupervisorRequestLifecycleObserver): ToolDefinition<typeof IntercomParamsSchema, Record<string, unknown>> {
576
+ function buildParentSupervisorTool(pi: ExtensionAPI, pending: Map<string, PendingSupervisorRequest>, state: SubagentState, onLifecycle: SupervisorRequestLifecycleObserver, discover: () => void, runState: (request: SupervisorRequest) => SubagentState): ToolDefinition<typeof IntercomParamsSchema, Record<string, unknown>> {
599
577
  return {
600
578
  name: NATIVE_SUPERVISOR_TOOL_NAME,
601
579
  label: "Subagent Supervisor",
602
580
  description: "Native pi-subagents supervisor channel. Use reply/pending/status to answer child subagent requests without overriding pi-intercom.",
603
581
  parameters: IntercomParamsSchema,
604
582
  async execute(_id, params) {
605
- refreshPendingRequests(pending, state, state.lastUiContext ?? undefined, onLifecycle);
583
+ // Discover new request files even when demand-gated polling is idle.
584
+ discover();
585
+ refreshPendingRequests(pending, state, onLifecycle, runState);
606
586
  const input = params as IntercomParams;
607
587
  if (input.action === "status") {
608
588
  return { content: [{ type: "text", text: `Native supervisor channel active. Pending replies: ${pending.size}.` }], details: { active: true, pending: pending.size, root: SUPERVISOR_CHANNEL_ROOT } };
@@ -620,9 +600,6 @@ function buildParentSupervisorTool(pi: ExtensionAPI, pending: Map<string, Pendin
620
600
  clearForegroundSupervisorAttention(request, pending, state);
621
601
  return { content: [{ type: "text", text: `Replied to supervisor request ${request.id}.` }], details: { replyTo: request.id, runId: request.runId, agent: request.agent } };
622
602
  }
623
- if (input.action === "send" || input.action === "ask") {
624
- throw new Error("The native subagent supervisor handles replies only. Child agents initiate asks with contact_supervisor.");
625
- }
626
603
  throw new Error(`Unsupported supervisor action: ${input.action}`);
627
604
  },
628
605
  };
@@ -631,12 +608,19 @@ function buildParentSupervisorTool(pi: ExtensionAPI, pending: Map<string, Pendin
631
608
  export function createNativeSupervisorChannel(pi: ExtensionAPI, state: SubagentState, deps: NativeSupervisorChannelDeps = {}): {
632
609
  start: () => void;
633
610
  activateTransport: () => void;
611
+ findPendingAsks: (target: { runId: string; agent: string; childIndex: number }) => string[];
634
612
  dispose: () => void;
635
613
  pending: Map<string, PendingSupervisorRequest>;
636
614
  getSupervisorRequestState: (event: ControlEvent) => SupervisorRequestState;
637
615
  } {
638
616
  const watch = deps.watch ?? fs.watch;
639
617
  const timers = deps.timers ?? globalThis;
618
+ const runState = (request: SupervisorRequest): SubagentState => {
619
+ for (const ownerState of deps.getCurrentOwnerStates?.() ?? []) {
620
+ if (ownerState.asyncJobs.has(request.runId)) return ownerState;
621
+ }
622
+ return state;
623
+ };
640
624
  const pending = new Map<string, PendingSupervisorRequest>();
641
625
  const requestCorrelations = new Map<string, SupervisorRequestCorrelation>();
642
626
  const correlationKey = (request: { runId: string; agent: string; childIndex: number; toolCallId?: string }): string | undefined => {
@@ -682,7 +666,7 @@ export function createNativeSupervisorChannel(pi: ExtensionAPI, state: SubagentS
682
666
  if (!fs.existsSync(correlation.request.requestFile)
683
667
  || fs.existsSync(replyPath(correlation.request.channelDir, correlation.request.id))
684
668
  || now > requestExpiresAt(correlation.request, now)
685
- || requestRunInactive(correlation.request, state)) {
669
+ || requestRunInactive(correlation.request, runState(correlation.request))) {
686
670
  rememberResolvedRequest(correlation.request);
687
671
  return "resolved";
688
672
  }
@@ -701,11 +685,17 @@ export function createNativeSupervisorChannel(pi: ExtensionAPI, state: SubagentS
701
685
  const hasTransportDemand = () => {
702
686
  if (pending.size > 0) return true;
703
687
  if (state.foregroundControls.size > 0) return true;
704
- return [...state.asyncJobs.values()].some((job) => job.status === "queued" || job.status === "running");
688
+ if ([...state.asyncJobs.values()].some((job) => job.status === "queued" || job.status === "running")) return true;
689
+ for (const ownerState of deps.getCurrentOwnerStates?.() ?? []) {
690
+ for (const job of ownerState.asyncJobs.values()) {
691
+ if (job.status === "queued" || job.status === "running") return true;
692
+ }
693
+ }
694
+ return false;
705
695
  };
706
696
 
707
697
  const registerParentTools = (): void => {
708
- if (!hasTool(pi, NATIVE_SUPERVISOR_TOOL_NAME)) pi.registerTool(buildParentSupervisorTool(pi, pending, state, observeRequestLifecycle));
698
+ if (!hasTool(pi, NATIVE_SUPERVISOR_TOOL_NAME)) pi.registerTool(buildParentSupervisorTool(pi, pending, state, observeRequestLifecycle, () => poll(), runState));
709
699
  };
710
700
 
711
701
  const cleanupStaleChannelsIfDue = (): void => {
@@ -721,15 +711,14 @@ export function createNativeSupervisorChannel(pi: ExtensionAPI, state: SubagentS
721
711
 
722
712
  const poll = (): void => {
723
713
  cleanupStaleChannelsIfDue();
724
- const ctx = state.lastUiContext;
725
- if (!ctx) return;
726
- refreshPendingRequests(pending, state, ctx, observeRequestLifecycle);
714
+ // Only display notifications require a live UI context, not request registration.
715
+ refreshPendingRequests(pending, state, observeRequestLifecycle, runState);
727
716
  const now = Date.now();
728
717
  for (const { channelDir, file } of listRequestFiles()) {
729
718
  if (seenFiles.has(file)) continue;
730
719
  const request = parseRequestFile(file, channelDir);
731
- if (!request || !requestMatchesContext(request, state, ctx)) continue;
732
- const lifecycle = requestLifecycle(request, state, undefined, now);
720
+ if (!request || !requestMatchesOwner(request, state)) continue;
721
+ const lifecycle = requestLifecycle(request, state, now, runState(request));
733
722
  if (lifecycle !== "pending") {
734
723
  seenFiles.add(file);
735
724
  observeRequestLifecycle(request, lifecycle);
@@ -742,26 +731,34 @@ export function createNativeSupervisorChannel(pi: ExtensionAPI, state: SubagentS
742
731
  pending.set(request.id, request);
743
732
  markForegroundSupervisorAttention(request, state);
744
733
  }
745
- else {
746
- removeRequestFile(request.requestFile);
734
+ // The ask is already queued above. A sendMessage failure (no UI, stale context) must not
735
+ // lose it, and must not abort the loop before the remaining asks register.
736
+ try {
737
+ pi.sendMessage({
738
+ customType: SUPERVISOR_REQUEST_MESSAGE_TYPE,
739
+ content: requestVisibleText(request),
740
+ display: true,
741
+ details: {
742
+ id: request.id,
743
+ requestId: request.id,
744
+ reason: request.reason,
745
+ expectsReply: request.expectsReply,
746
+ runId: request.runId,
747
+ agent: request.agent,
748
+ childIndex: request.childIndex,
749
+ ...(request.childTarget ? { childTarget: request.childTarget } : {}),
750
+ ...(request.interview !== undefined ? { interview: request.interview } : {}),
751
+ requestBody: request.message,
752
+ ...(request.expectsReply ? { replyHint: supervisorReplyHint(request.id) } : {}),
753
+ },
754
+ }, { triggerTurn: true });
755
+ // sendMessage accepts synchronously; one-way updates stay on disk until it returns.
756
+ if (!request.expectsReply) removeRequestFile(request.requestFile);
757
+ } catch (error) {
758
+ // Allow an existing later scan to retry an unaccepted one-way update.
759
+ if (!request.expectsReply) seenFiles.delete(file);
760
+ console.error(`Failed to surface supervisor request ${request.id} as a user turn:`, error);
747
761
  }
748
- pi.sendMessage({
749
- customType: SUPERVISOR_REQUEST_MESSAGE_TYPE,
750
- content: requestVisibleText(request),
751
- display: true,
752
- details: {
753
- id: request.id,
754
- requestId: request.id,
755
- reason: request.reason,
756
- expectsReply: request.expectsReply,
757
- runId: request.runId,
758
- agent: request.agent,
759
- childIndex: request.childIndex,
760
- ...(request.childTarget ? { childTarget: request.childTarget } : {}),
761
- ...(request.interview !== undefined ? { interview: request.interview } : {}),
762
- ...(request.expectsReply ? { replyHint: supervisorReplyHint(request.id) } : {}),
763
- },
764
- }, { triggerTurn: true });
765
762
  if (request.expectsReply) {
766
763
  (pi as { events?: IntercomEventBus }).events?.emit(INTERCOM_DETACH_REQUEST_EVENT, {
767
764
  requestId: request.id,
@@ -838,6 +835,23 @@ export function createNativeSupervisorChannel(pi: ExtensionAPI, state: SubagentS
838
835
  poll();
839
836
  if (!useNativeWatcher() && hasTransportDemand()) startPolling();
840
837
  },
838
+ findPendingAsks: (target) => {
839
+ // Receipt-only discovery: no registration, notification, cleanup or reply writes.
840
+ const channelDir = resolveSupervisorChannelDir(target.runId, target.agent, target.childIndex);
841
+ let files: string[];
842
+ try { files = fs.readdirSync(path.join(channelDir, REQUESTS_DIR)); }
843
+ catch (error) {
844
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
845
+ throw error;
846
+ }
847
+ const now = Date.now();
848
+ return files.filter(file => file.endsWith(".json")).flatMap(file => {
849
+ const request = parseRequestFile(path.join(channelDir, REQUESTS_DIR, file), channelDir);
850
+ return request?.expectsReply && request.runId === target.runId
851
+ && request.agent === target.agent && request.childIndex === target.childIndex
852
+ && requestLifecycle(request, state, now, runState(request)) === "pending" ? [request.id] : [];
853
+ }).sort();
854
+ },
841
855
  start: () => {
842
856
  if (started) return;
843
857
  started = true;
@@ -17,6 +17,7 @@ export interface SupervisorRequestMessageDetails {
17
17
  childIndex?: number;
18
18
  childTarget?: string;
19
19
  interview?: unknown;
20
+ requestBody?: string;
20
21
  replyHint?: string;
21
22
  }
22
23
 
@@ -107,7 +108,7 @@ function optionalString(value: unknown): boolean {
107
108
 
108
109
  function requestDetails(value: unknown): SupervisorRequestMessageDetails | undefined {
109
110
  if (!isRecord(value)) return undefined;
110
- if (!optionalString(value.id) || !optionalString(value.requestId) || !optionalString(value.replyHint) || !optionalString(value.runId) || !optionalString(value.agent) || !optionalString(value.childTarget)) return undefined;
111
+ if (!optionalString(value.id) || !optionalString(value.requestId) || !optionalString(value.replyHint) || !optionalString(value.requestBody) || !optionalString(value.runId) || !optionalString(value.agent) || !optionalString(value.childTarget)) return undefined;
111
112
  if (value.reason !== undefined && !isSupervisorReason(value.reason)) return undefined;
112
113
  if (value.expectsReply !== undefined && typeof value.expectsReply !== "boolean") return undefined;
113
114
  if (value.childIndex !== undefined && (typeof value.childIndex !== "number" || !Number.isFinite(value.childIndex))) return undefined;
@@ -153,7 +154,7 @@ function requestLines(message: SupervisorMessageLike, details: SupervisorRequest
153
154
  if (details.childTarget) lines.push(`Child target: ${boundedField(details.childTarget)}`);
154
155
  lines.push(`Request ID: ${requestId}`);
155
156
  if (details.expectsReply) lines.push(`Reply with: ${displayText(details.replyHint ?? supervisorReplyHint(requestId), MAX_BODY_CHARS, expanded)}`);
156
- lines.push("", "Request:", displayText(contentText(message.content) || "(no request body)", MAX_BODY_CHARS, expanded));
157
+ lines.push("", "Request:", displayText((details.requestBody ?? contentText(message.content)) || "(no request body)", MAX_BODY_CHARS, expanded));
157
158
  if (details.interview !== undefined) lines.push("", "Interview shape:", interviewText(details.interview, expanded));
158
159
  return lines;
159
160
  }
@@ -18,6 +18,12 @@ export interface MissionWorkflowState {
18
18
  set(key: string, value: unknown): void;
19
19
  }
20
20
 
21
+ export interface MissionWorkflowStateOptions {
22
+ isProcessAlive?: (pid: number) => boolean;
23
+ getProcessStartKey?: (pid: number) => string | undefined;
24
+ retryDelaysMs?: readonly number[];
25
+ }
26
+
21
27
  export function missionStatePath(location: MissionStoreLocation, missionId: string): string {
22
28
  return path.join(location.missionDir, validateMissionId(missionId), "state.json");
23
29
  }
@@ -66,13 +72,22 @@ function windowsProcessStartKey(pid: number): string | undefined {
66
72
  }
67
73
  }
68
74
 
75
+ let currentProcessKey: string | undefined | null = null;
76
+
69
77
  function processStartKey(pid: number): string | undefined {
78
+ // Foreign PIDs can be reused while this process lives, so resolve them afresh.
79
+ if (pid !== process.pid) return computeProcessStartKey(pid);
80
+ if (currentProcessKey === null) currentProcessKey = computeProcessStartKey(pid);
81
+ return currentProcessKey;
82
+ }
83
+
84
+ function computeProcessStartKey(pid: number): string | undefined {
70
85
  if (process.platform === "linux") return linuxProcessStartKey(pid) ?? psProcessStartKey(pid);
71
86
  if (process.platform === "win32") return windowsProcessStartKey(pid);
72
87
  return undefined;
73
88
  }
74
89
 
75
- const CURRENT_PROCESS_KEY = processStartKey(process.pid);
90
+ type StateLockOptions = Required<MissionWorkflowStateOptions>;
76
91
 
77
92
  function readStateLockOwner(lockPath: string): StateLockOwner | undefined {
78
93
  try {
@@ -91,13 +106,13 @@ function readStateLockOwner(lockPath: string): StateLockOwner | undefined {
91
106
  return undefined;
92
107
  }
93
108
 
94
- function stateLockIsStale(lockPath: string, now = Date.now()): boolean {
109
+ function stateLockIsStale(lockPath: string, options: StateLockOptions, now = Date.now()): boolean {
95
110
  const owner = readStateLockOwner(lockPath);
96
111
  if (owner) {
97
- if (!isProcessAlive(owner.pid)) return true;
112
+ if (!options.isProcessAlive(owner.pid)) return true;
98
113
  if (owner.processKey) {
99
- const currentProcessKey = owner.pid === process.pid ? CURRENT_PROCESS_KEY : processStartKey(owner.pid);
100
- if (currentProcessKey) return owner.processKey !== currentProcessKey;
114
+ const observedProcessKey = options.getProcessStartKey(owner.pid);
115
+ if (observedProcessKey) return owner.processKey !== observedProcessKey;
101
116
  if (owner.pid === process.pid) return true;
102
117
  }
103
118
  return false;
@@ -140,11 +155,11 @@ function waitForStateLock(delayMs: number | undefined, lockPath: string): void {
140
155
  waitForFileSystemRetry(delayMs);
141
156
  }
142
157
 
143
- function reclaimStaleStateLock(lockPath: string, reclaimPath: string): boolean {
144
- if (!stateLockIsStale(lockPath)) return false;
158
+ function reclaimStaleStateLock(lockPath: string, reclaimPath: string, options: StateLockOptions): boolean {
159
+ if (!stateLockIsStale(lockPath, options)) return false;
145
160
  if (!tryMakeDirectory(reclaimPath, 0o700)) return false;
146
161
  try {
147
- if (!stateLockIsStale(lockPath)) return false;
162
+ if (!stateLockIsStale(lockPath, options)) return false;
148
163
  fs.rmSync(lockPath, { recursive: true, force: true });
149
164
  return true;
150
165
  } finally {
@@ -152,7 +167,7 @@ function reclaimStaleStateLock(lockPath: string, reclaimPath: string): boolean {
152
167
  }
153
168
  }
154
169
 
155
- function withStateFileLock<T>(filePath: string, operation: () => T): T {
170
+ function withStateFileLock<T>(filePath: string, operation: () => T, options: StateLockOptions): T {
156
171
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
157
172
  const lockPath = `${filePath}.lock`;
158
173
  const reclaimPath = `${lockPath}.reclaim`;
@@ -163,7 +178,7 @@ function withStateFileLock<T>(filePath: string, operation: () => T): T {
163
178
  fs.rmSync(reclaimPath, { recursive: true, force: true });
164
179
  continue;
165
180
  }
166
- waitForStateLock(DEFAULT_FILE_SYSTEM_RETRY_DELAYS_MS[attempt], lockPath);
181
+ waitForStateLock(options.retryDelaysMs[attempt], lockPath);
167
182
  continue;
168
183
  }
169
184
  let acquired = false;
@@ -171,17 +186,18 @@ function withStateFileLock<T>(filePath: string, operation: () => T): T {
171
186
  acquired = tryMakeDirectory(lockPath, 0o700);
172
187
  } catch (error) {
173
188
  if (isRetryableFileSystemError(error)) {
174
- waitForStateLock(DEFAULT_FILE_SYSTEM_RETRY_DELAYS_MS[attempt], lockPath);
189
+ waitForStateLock(options.retryDelaysMs[attempt], lockPath);
175
190
  continue;
176
191
  }
177
192
  throw new Error(`Failed to acquire mission state lock '${lockPath}': ${error instanceof Error ? error.message : String(error)}`);
178
193
  }
179
194
  if (!acquired) {
180
- if (reclaimStaleStateLock(lockPath, reclaimPath)) continue;
181
- waitForStateLock(DEFAULT_FILE_SYSTEM_RETRY_DELAYS_MS[attempt], lockPath);
195
+ if (reclaimStaleStateLock(lockPath, reclaimPath, options)) continue;
196
+ waitForStateLock(options.retryDelaysMs[attempt], lockPath);
182
197
  continue;
183
198
  }
184
- owner = { pid: process.pid, token: randomUUID(), createdAt: Date.now(), ...(CURRENT_PROCESS_KEY ? { processKey: CURRENT_PROCESS_KEY } : {}) };
199
+ const key = options.getProcessStartKey(process.pid);
200
+ owner = { pid: process.pid, token: randomUUID(), createdAt: Date.now(), ...(key ? { processKey: key } : {}) };
185
201
  try {
186
202
  fs.writeFileSync(path.join(lockPath, "owner.json"), JSON.stringify(owner), { encoding: "utf-8", mode: 0o600 });
187
203
  } catch (error) {
@@ -205,8 +221,13 @@ function validateStateKey(value: unknown): string {
205
221
  return value;
206
222
  }
207
223
 
208
- export function createMissionWorkflowState(location: MissionStoreLocation, missionId: string): MissionWorkflowState {
224
+ export function createMissionWorkflowState(location: MissionStoreLocation, missionId: string, options: MissionWorkflowStateOptions = {}): MissionWorkflowState {
209
225
  const filePath = missionStatePath(location, missionId);
226
+ const lockOptions: StateLockOptions = {
227
+ isProcessAlive: options.isProcessAlive ?? isProcessAlive,
228
+ getProcessStartKey: options.getProcessStartKey ?? processStartKey,
229
+ retryDelaysMs: options.retryDelaysMs ?? DEFAULT_FILE_SYSTEM_RETRY_DELAYS_MS,
230
+ };
210
231
  let loaded = false;
211
232
  let values: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
212
233
 
@@ -254,7 +275,7 @@ export function createMissionWorkflowState(location: MissionStoreLocation, missi
254
275
  writePrivateAtomicJson(filePath, next);
255
276
  values = next;
256
277
  loaded = true;
257
- });
278
+ }, lockOptions);
258
279
  },
259
280
  };
260
281
  }