pi-subagents 0.63.0 → 0.65.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 (107) hide show
  1. package/CHANGELOG.md +57 -1
  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 +18 -16
  6. package/docs/configuration.md +7 -15
  7. package/docs/extension-api.md +15 -6
  8. package/docs/missions.md +2 -0
  9. package/docs/observability.md +10 -12
  10. package/docs/tool-reference.md +7 -6
  11. package/docs/watchdog.md +92 -114
  12. package/docs/workflows.md +5 -3
  13. package/package.json +3 -4
  14. package/skills/pi-subagents/references/constraints-and-recipes.md +1 -1
  15. package/skills/pi-subagents/references/execution-controls.md +6 -5
  16. package/src/agents/agent-management.ts +47 -15
  17. package/src/api/capability-ceiling.ts +0 -1
  18. package/src/api/{pi-args.ts → child-tool-plan.ts} +1 -1
  19. package/src/api/preflight.ts +2 -3
  20. package/src/extension/doctor.ts +2 -10
  21. package/src/extension/fanout-child.ts +9 -11
  22. package/src/extension/index.ts +27 -5
  23. package/src/extension/public-execution.ts +14 -0
  24. package/src/extension/rpc.ts +3 -2
  25. package/src/extension/schemas.ts +2 -1
  26. package/src/extension/tool-description.ts +9 -8
  27. package/src/intercom/native-supervisor-channel.ts +138 -60
  28. package/src/intercom/supervisor-ui.ts +243 -0
  29. package/src/runs/background/async-execution.ts +46 -24
  30. package/src/runs/background/async-job-tracker.ts +11 -0
  31. package/src/runs/background/async-resume.ts +11 -2
  32. package/src/runs/background/control-channel.ts +2 -204
  33. package/src/runs/background/notify.ts +41 -2
  34. package/src/runs/background/process-terminal.ts +1 -1
  35. package/src/runs/background/run-child-session.ts +613 -0
  36. package/src/runs/background/run-status.ts +0 -1
  37. package/src/runs/background/runner-aliases.ts +125 -0
  38. package/src/runs/background/runner-child-sessions.ts +31 -0
  39. package/src/runs/background/scheduled-runs.ts +18 -4
  40. package/src/runs/background/subagent-runner.ts +203 -898
  41. package/src/runs/foreground/async-steering-action.ts +1 -17
  42. package/src/runs/foreground/execution.ts +196 -378
  43. package/src/runs/foreground/foreground-control.ts +4 -0
  44. package/src/runs/foreground/subagent-executor.ts +121 -58
  45. package/src/runs/foreground/workflow-foreground-steering.ts +24 -98
  46. package/src/runs/shared/abort-recovery.ts +3 -3
  47. package/src/runs/shared/acceptance.ts +10 -0
  48. package/src/runs/shared/async-status-projection.ts +11 -43
  49. package/src/runs/shared/capability-ceiling.ts +1 -2
  50. package/src/runs/shared/child-hooks.ts +25 -0
  51. package/src/runs/shared/child-identity.ts +13 -2
  52. package/src/runs/shared/child-launch.ts +314 -0
  53. package/src/runs/shared/child-lifecycle.ts +25 -0
  54. package/src/runs/shared/child-runtime-config.ts +126 -0
  55. package/src/runs/shared/child-session.ts +342 -0
  56. package/src/runs/shared/child-tool-plan.ts +530 -0
  57. package/src/runs/shared/claude-code-adapter.ts +5 -1
  58. package/src/runs/shared/completion-guard.ts +1 -1
  59. package/src/runs/shared/external-cli-preflight.ts +16 -0
  60. package/src/runs/shared/mcp-direct-tool-allowlist.ts +5 -4
  61. package/src/runs/shared/model-exclusions.ts +82 -14
  62. package/src/runs/shared/model-fallback.ts +47 -4
  63. package/src/runs/shared/nested-events.ts +29 -45
  64. package/src/runs/shared/nested-path.ts +0 -14
  65. package/src/runs/shared/orca-progress-tabs.ts +12 -7
  66. package/src/runs/shared/parallel-utils.ts +0 -2
  67. package/src/runs/shared/permissions.ts +0 -13
  68. package/src/runs/shared/process-signal.ts +4 -1
  69. package/src/runs/shared/run-fanout-budget.ts +0 -13
  70. package/src/runs/shared/runtime-acknowledged-extensions.ts +0 -27
  71. package/src/runs/shared/structured-output.ts +17 -4
  72. package/src/runs/shared/subagent-control.ts +6 -2
  73. package/src/runs/shared/subagent-prompt-runtime.ts +87 -384
  74. package/src/runs/shared/tool-availability.ts +18 -62
  75. package/src/runs/shared/tool-budget.ts +0 -14
  76. package/src/runs/shared/worktree-cleanup-plan.ts +25 -6
  77. package/src/runs/shared/worktree.ts +117 -30
  78. package/src/shared/child-session-name.ts +1 -1
  79. package/src/shared/jsonl-writer.ts +11 -0
  80. package/src/shared/thinking-ceiling.ts +0 -6
  81. package/src/shared/types.ts +64 -30
  82. package/src/shared/utils.ts +3 -4
  83. package/src/slash/slash-commands.ts +0 -6
  84. package/src/tui/fleet.ts +0 -1
  85. package/src/tui/render.ts +238 -31
  86. package/src/watchdog/child-status.ts +54 -34
  87. package/src/watchdog/diff-tool.ts +77 -0
  88. package/src/watchdog/emission-guard.ts +5 -3
  89. package/src/watchdog/guidance.ts +20 -0
  90. package/src/watchdog/register-child.ts +28 -25
  91. package/src/watchdog/register-main.ts +10 -9
  92. package/src/watchdog/render.ts +4 -5
  93. package/src/watchdog/review.ts +15 -4
  94. package/src/watchdog/rules.ts +70 -0
  95. package/src/watchdog/runtime.ts +75 -92
  96. package/src/watchdog/scope.ts +0 -11
  97. package/src/watchdog/settings.ts +48 -104
  98. package/src/watchdog/types.ts +18 -32
  99. package/src/watchdog/warning-format.ts +0 -1
  100. package/src/workflows/chat-progress.ts +3 -2
  101. package/src/workflows/scripted-workflow.ts +48 -1
  102. package/src/workflows/workflow-checklist.ts +10 -12
  103. package/src/workflows/workflow-preflight.ts +28 -1
  104. package/src/runs/shared/child-protocol.ts +0 -415
  105. package/src/runs/shared/pi-args.ts +0 -1062
  106. package/src/runs/shared/subagent-startup-retry.ts +0 -116
  107. package/src/shared/post-exit-stdio-guard.ts +0 -85
@@ -4,17 +4,17 @@ import * as path from "node:path";
4
4
  import type { AgentToolResult } from "@earendil-works/pi-agent-core";
5
5
  import type { ExtensionAPI, ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
6
6
  import { Type } from "typebox";
7
- import {
8
- SUBAGENT_CHILD_AGENT_ENV,
9
- SUBAGENT_CHILD_INDEX_ENV,
10
- SUBAGENT_ORCHESTRATOR_SESSION_ID_ENV,
11
- SUBAGENT_ORCHESTRATOR_TARGET_ENV,
12
- SUBAGENT_RUN_ID_ENV,
13
- SUBAGENT_SUPERVISOR_CHANNEL_DIR_ENV,
14
- } from "../runs/shared/pi-args.ts";
15
- import { INTERCOM_DETACH_REQUEST_EVENT, POLL_INTERVAL_MS, TEMP_ROOT_DIR, type IntercomEventBus, type SubagentState } from "../shared/types.ts";
7
+ import type { ChildSupervisorMetadata } from "../runs/shared/child-runtime-config.ts";
8
+ import { INTERCOM_DETACH_REQUEST_EVENT, POLL_INTERVAL_MS, TEMP_ROOT_DIR, type ControlEvent, type IntercomEventBus, type SubagentState } from "../shared/types.ts";
16
9
  import { writeAtomicJson } from "../shared/atomic-json.ts";
17
10
  import { shouldUseNativeFsWatch } from "../shared/watch-strategy.ts";
11
+ import {
12
+ SUPERVISOR_REQUEST_MESSAGE_TYPE,
13
+ SUPERVISOR_REPLY_ENTRY_TYPE,
14
+ supervisorReplyHint,
15
+ type SupervisorReason,
16
+ type SupervisorReplyEntryData,
17
+ } from "./supervisor-ui.ts";
18
18
 
19
19
  const SUPERVISOR_CHANNEL_ROOT = path.join(TEMP_ROOT_DIR, "supervisor-channels");
20
20
  const REQUESTS_DIR = "requests";
@@ -26,8 +26,10 @@ const CHANNEL_POLL_MS = Math.min(POLL_INTERVAL_MS, 500);
26
26
  const CHANNEL_SAFETY_POLL_MS = 5000;
27
27
  const STALE_EMPTY_CHANNEL_AGE_MS = 60 * 1000;
28
28
  const STALE_EMPTY_CHANNEL_CLEANUP_INTERVAL_MS = 60 * 1000;
29
+ const MAX_SUPERVISOR_REQUEST_CORRELATIONS = 256;
30
+ const SUPERVISOR_REQUEST_CORRELATION_RETENTION_MS = 10 * 60 * 1000;
29
31
 
30
- type SupervisorReason = "need_decision" | "interview_request" | "progress_update";
32
+ export type SupervisorRequestState = "pending" | "resolved" | "unknown";
31
33
 
32
34
  interface SupervisorRequest {
33
35
  type: "subagent.supervisor.request";
@@ -42,6 +44,7 @@ interface SupervisorRequest {
42
44
  runId: string;
43
45
  agent: string;
44
46
  childIndex: number;
47
+ toolCallId?: string;
45
48
  childTarget?: string;
46
49
  interview?: unknown;
47
50
  }
@@ -51,6 +54,12 @@ interface PendingSupervisorRequest extends SupervisorRequest {
51
54
  requestFile: string;
52
55
  }
53
56
 
57
+ interface SupervisorRequestCorrelation {
58
+ request: PendingSupervisorRequest;
59
+ state: Exclude<SupervisorRequestState, "unknown">;
60
+ updatedAt: number;
61
+ }
62
+
54
63
  interface SupervisorReply {
55
64
  type: "subagent.supervisor.reply";
56
65
  requestId: string;
@@ -113,37 +122,6 @@ function replyPath(channelDir: string, requestId: string): string {
113
122
  return path.join(channelDir, REPLIES_DIR, `${safeSegment(requestId)}.json`);
114
123
  }
115
124
 
116
- function readTextEnv(name: string): string | undefined {
117
- const value = process.env[name]?.trim();
118
- return value ? value : undefined;
119
- }
120
-
121
- function readChildMetadata(): {
122
- channelDir: string;
123
- runId: string;
124
- agent: string;
125
- childIndex: number;
126
- orchestratorTarget?: string;
127
- orchestratorSessionId?: string;
128
- childTarget?: string;
129
- } | undefined {
130
- const channelDir = readTextEnv(SUBAGENT_SUPERVISOR_CHANNEL_DIR_ENV);
131
- const runId = readTextEnv(SUBAGENT_RUN_ID_ENV);
132
- const agent = readTextEnv(SUBAGENT_CHILD_AGENT_ENV);
133
- const rawIndex = readTextEnv(SUBAGENT_CHILD_INDEX_ENV);
134
- const orchestratorSessionId = readTextEnv(SUBAGENT_ORCHESTRATOR_SESSION_ID_ENV);
135
- if (!channelDir || !runId || !agent || !orchestratorSessionId || rawIndex === undefined || !/^\d+$/.test(rawIndex)) return undefined;
136
- return {
137
- channelDir,
138
- runId,
139
- agent,
140
- childIndex: Number(rawIndex),
141
- orchestratorTarget: readTextEnv(SUBAGENT_ORCHESTRATOR_TARGET_ENV),
142
- orchestratorSessionId,
143
- childTarget: readTextEnv("PI_SUBAGENT_INTERCOM_SESSION_NAME"),
144
- };
145
- }
146
-
147
125
  function reasonHeading(reason: SupervisorReason): string {
148
126
  if (reason === "interview_request") return "Subagent requests a structured supervisor interview.";
149
127
  if (reason === "progress_update") return "Subagent progress update.";
@@ -231,9 +209,7 @@ async function waitForReply(channelDir: string, requestId: string, deadline: num
231
209
  throw new Error("Timed out waiting for supervisor reply.");
232
210
  }
233
211
 
234
- async function sendSupervisorRequest(params: ContactSupervisorParams, signal?: AbortSignal): Promise<AgentToolResult<Record<string, unknown>>> {
235
- const metadata = readChildMetadata();
236
- if (!metadata) throw new Error("Native supervisor channel is not available for this subagent.");
212
+ async function sendSupervisorRequest(params: ContactSupervisorParams, metadata: ChildSupervisorMetadata, signal?: AbortSignal, toolCallId?: string): Promise<AgentToolResult<Record<string, unknown>>> {
237
213
  if (params.reason !== "progress_update" && !params.message?.trim() && params.reason !== "interview_request") {
238
214
  throw new Error("message is required for supervisor decisions.");
239
215
  }
@@ -244,6 +220,7 @@ async function sendSupervisorRequest(params: ContactSupervisorParams, signal?: A
244
220
  const replyDeadline = createdAt + askTimeoutMs();
245
221
  const expiresAt = expectsReply ? replyDeadline : undefined;
246
222
  const message = formatChildMessage({ ...metadata, reason: params.reason, message: params.message, interview: params.interview });
223
+ const requestToolCallId = typeof toolCallId === "string" && toolCallId.length > 0 ? toolCallId : undefined;
247
224
  const request: SupervisorRequest = {
248
225
  type: "subagent.supervisor.request",
249
226
  id: requestId,
@@ -257,6 +234,7 @@ async function sendSupervisorRequest(params: ContactSupervisorParams, signal?: A
257
234
  runId: metadata.runId,
258
235
  agent: metadata.agent,
259
236
  childIndex: metadata.childIndex,
237
+ ...(requestToolCallId ? { toolCallId: requestToolCallId } : {}),
260
238
  ...(metadata.childTarget ? { childTarget: metadata.childTarget } : {}),
261
239
  ...(params.interview !== undefined ? { interview: params.interview } : {}),
262
240
  };
@@ -297,15 +275,19 @@ function hasTool(pi: ExtensionAPI, name: string): boolean {
297
275
  }
298
276
  }
299
277
 
300
- export function registerNativeSupervisorClient(pi: ExtensionAPI): void {
301
- if (!readChildMetadata() || hasTool(pi, "contact_supervisor")) return;
278
+ /**
279
+ * Register the child-side `contact_supervisor` tool. The host passes the
280
+ * channel metadata in the child runtime config.
281
+ */
282
+ export function registerNativeSupervisorClient(pi: ExtensionAPI, metadata: ChildSupervisorMetadata | undefined): void {
283
+ if (!metadata || hasTool(pi, "contact_supervisor")) return;
302
284
  const tool: ToolDefinition<typeof ContactSupervisorParamsSchema, Record<string, unknown>> = {
303
285
  name: "contact_supervisor",
304
286
  label: "Contact Supervisor",
305
287
  description: "Contact the parent/supervisor session for a blocking decision, structured interview, or progress update.",
306
288
  parameters: ContactSupervisorParamsSchema,
307
- execute(_id, params, signal) {
308
- return sendSupervisorRequest(params as ContactSupervisorParams, signal);
289
+ execute(id, params, signal) {
290
+ return sendSupervisorRequest(params as ContactSupervisorParams, metadata, signal, id);
309
291
  },
310
292
  };
311
293
  pi.registerTool(tool);
@@ -319,7 +301,12 @@ function parseRequestFile(file: string, channelDir: string): PendingSupervisorRe
319
301
  if (parsed.reason !== "need_decision" && parsed.reason !== "interview_request" && parsed.reason !== "progress_update") return undefined;
320
302
  if (typeof parsed.message !== "string" || !parsed.message) return undefined;
321
303
  if (typeof parsed.runId !== "string" || typeof parsed.agent !== "string" || typeof parsed.childIndex !== "number") return undefined;
322
- return { ...parsed as SupervisorRequest, channelDir, requestFile: file };
304
+ return {
305
+ ...parsed as SupervisorRequest,
306
+ ...(typeof parsed.toolCallId === "string" && parsed.toolCallId.length > 0 ? { toolCallId: parsed.toolCallId } : { toolCallId: undefined }),
307
+ channelDir,
308
+ requestFile: file,
309
+ };
323
310
  } catch {
324
311
  return undefined;
325
312
  }
@@ -483,6 +470,7 @@ function removeRequestFile(file: string): void {
483
470
  }
484
471
 
485
472
  type SupervisorRequestLifecycle = "pending" | "resolved" | "expired" | "inactive" | "missing" | "wrong-session";
473
+ type SupervisorRequestLifecycleObserver = (request: PendingSupervisorRequest, lifecycle: SupervisorRequestLifecycle) => void;
486
474
 
487
475
  function requestExpiresAt(request: SupervisorRequest, now: number): number {
488
476
  const expiresAt = (request as { expiresAt?: unknown }).expiresAt;
@@ -515,30 +503,31 @@ function cleanupRequestLifecycle(request: PendingSupervisorRequest, lifecycle: S
515
503
  if (lifecycle === "resolved" || lifecycle === "expired" || lifecycle === "inactive") removeRequestFile(request.requestFile);
516
504
  }
517
505
 
518
- function refreshPendingRequests(pending: Map<string, PendingSupervisorRequest>, state: SubagentState, ctx: ExtensionContext | undefined): void {
506
+ function refreshPendingRequests(pending: Map<string, PendingSupervisorRequest>, state: SubagentState, ctx: ExtensionContext | undefined, onLifecycle: SupervisorRequestLifecycleObserver): void {
519
507
  const now = Date.now();
520
508
  for (const request of pending.values()) {
521
509
  const lifecycle = requestLifecycle(request, state, ctx, now);
522
510
  if (lifecycle === "pending") continue;
523
511
  pending.delete(request.id);
512
+ onLifecycle(request, lifecycle);
524
513
  cleanupRequestLifecycle(request, lifecycle);
525
514
  }
526
515
  }
527
516
 
528
517
  function formatPendingLine(request: PendingSupervisorRequest): string {
529
- const replyHint = request.expectsReply ? ` Reply: ${NATIVE_SUPERVISOR_TOOL_NAME}({ action: "reply", replyTo: "${request.id}", message: "..." })` : "";
518
+ const replyHint = request.expectsReply ? ` Reply: ${supervisorReplyHint(request.id)}` : "";
530
519
  return `- ${request.id}: ${request.agent} [${request.runId}#${request.childIndex}] ${request.reason}.${replyHint}`;
531
520
  }
532
521
 
533
522
  function requestVisibleText(request: PendingSupervisorRequest): string {
534
523
  const lines = [request.message];
535
524
  if (request.expectsReply) {
536
- lines.push("", `Reply with: ${NATIVE_SUPERVISOR_TOOL_NAME}({ action: "reply", replyTo: "${request.id}", message: "..." })`);
525
+ lines.push("", `Reply with: ${supervisorReplyHint(request.id)}`);
537
526
  }
538
527
  return lines.join("\n");
539
528
  }
540
529
 
541
- function writeReply(request: PendingSupervisorRequest, message: string): void {
530
+ function writeReply(request: PendingSupervisorRequest, message: string): SupervisorReply {
542
531
  if (!message.trim()) throw new Error("message is required for supervisor replies.");
543
532
  const reply: SupervisorReply = {
544
533
  type: "subagent.supervisor.reply",
@@ -548,6 +537,29 @@ function writeReply(request: PendingSupervisorRequest, message: string): void {
548
537
  };
549
538
  writeAtomicJson(replyPath(request.channelDir, request.id), reply);
550
539
  removeRequestFile(request.requestFile);
540
+ return reply;
541
+ }
542
+
543
+ function appendSupervisorReplyEntry(pi: ExtensionAPI, request: PendingSupervisorRequest, reply: SupervisorReply): void {
544
+ const appendEntry = (pi as unknown as {
545
+ appendEntry?: (customType: string, data?: SupervisorReplyEntryData) => void;
546
+ }).appendEntry;
547
+ if (typeof appendEntry !== "function") return;
548
+ try {
549
+ appendEntry.call(pi, SUPERVISOR_REPLY_ENTRY_TYPE, {
550
+ requestId: request.id,
551
+ reason: request.reason,
552
+ runId: request.runId,
553
+ agent: request.agent,
554
+ childIndex: request.childIndex,
555
+ ...(request.childTarget ? { childTarget: request.childTarget } : {}),
556
+ message: reply.message,
557
+ createdAt: reply.createdAt,
558
+ });
559
+ } catch (error) {
560
+ // The reply file is authoritative; a UI journal failure must not undo a delivered reply.
561
+ console.error("Failed to journal native supervisor reply:", error);
562
+ }
551
563
  }
552
564
 
553
565
  function resolvePendingRequest(pending: Map<string, PendingSupervisorRequest>, params: IntercomParams): PendingSupervisorRequest {
@@ -583,14 +595,14 @@ function publicPendingRequests(pending: Map<string, PendingSupervisorRequest>):
583
595
  }));
584
596
  }
585
597
 
586
- function buildParentSupervisorTool(pending: Map<string, PendingSupervisorRequest>, state: SubagentState): ToolDefinition<typeof IntercomParamsSchema, Record<string, unknown>> {
598
+ function buildParentSupervisorTool(pi: ExtensionAPI, pending: Map<string, PendingSupervisorRequest>, state: SubagentState, onLifecycle: SupervisorRequestLifecycleObserver): ToolDefinition<typeof IntercomParamsSchema, Record<string, unknown>> {
587
599
  return {
588
600
  name: NATIVE_SUPERVISOR_TOOL_NAME,
589
601
  label: "Subagent Supervisor",
590
602
  description: "Native pi-subagents supervisor channel. Use reply/pending/status to answer child subagent requests without overriding pi-intercom.",
591
603
  parameters: IntercomParamsSchema,
592
604
  async execute(_id, params) {
593
- refreshPendingRequests(pending, state, state.lastUiContext ?? undefined);
605
+ refreshPendingRequests(pending, state, state.lastUiContext ?? undefined, onLifecycle);
594
606
  const input = params as IntercomParams;
595
607
  if (input.action === "status") {
596
608
  return { content: [{ type: "text", text: `Native supervisor channel active. Pending replies: ${pending.size}.` }], details: { active: true, pending: pending.size, root: SUPERVISOR_CHANNEL_ROOT } };
@@ -601,7 +613,9 @@ function buildParentSupervisorTool(pending: Map<string, PendingSupervisorRequest
601
613
  }
602
614
  if (input.action === "reply") {
603
615
  const request = resolvePendingRequest(pending, input);
604
- writeReply(request, input.message ?? "");
616
+ const reply = writeReply(request, input.message ?? "");
617
+ appendSupervisorReplyEntry(pi, request, reply);
618
+ onLifecycle(request, "resolved");
605
619
  pending.delete(request.id);
606
620
  clearForegroundSupervisorAttention(request, pending, state);
607
621
  return { content: [{ type: "text", text: `Replied to supervisor request ${request.id}.` }], details: { replyTo: request.id, runId: request.runId, agent: request.agent } };
@@ -614,10 +628,66 @@ function buildParentSupervisorTool(pending: Map<string, PendingSupervisorRequest
614
628
  };
615
629
  }
616
630
 
617
- export function createNativeSupervisorChannel(pi: ExtensionAPI, state: SubagentState, deps: NativeSupervisorChannelDeps = {}): { start: () => void; activateTransport: () => void; dispose: () => void; pending: Map<string, PendingSupervisorRequest> } {
631
+ export function createNativeSupervisorChannel(pi: ExtensionAPI, state: SubagentState, deps: NativeSupervisorChannelDeps = {}): {
632
+ start: () => void;
633
+ activateTransport: () => void;
634
+ dispose: () => void;
635
+ pending: Map<string, PendingSupervisorRequest>;
636
+ getSupervisorRequestState: (event: ControlEvent) => SupervisorRequestState;
637
+ } {
618
638
  const watch = deps.watch ?? fs.watch;
619
639
  const timers = deps.timers ?? globalThis;
620
640
  const pending = new Map<string, PendingSupervisorRequest>();
641
+ const requestCorrelations = new Map<string, SupervisorRequestCorrelation>();
642
+ const correlationKey = (request: { runId: string; agent: string; childIndex: number; toolCallId?: string }): string | undefined => {
643
+ if (!request.toolCallId) return undefined;
644
+ return JSON.stringify([request.runId, request.agent, request.childIndex, request.toolCallId]);
645
+ };
646
+ const pruneRequestCorrelations = (now = Date.now()): void => {
647
+ for (const [key, correlation] of requestCorrelations) {
648
+ if (correlation.state === "resolved" && now - correlation.updatedAt > SUPERVISOR_REQUEST_CORRELATION_RETENTION_MS) requestCorrelations.delete(key);
649
+ }
650
+ while (requestCorrelations.size > MAX_SUPERVISOR_REQUEST_CORRELATIONS) {
651
+ const oldest = requestCorrelations.keys().next().value;
652
+ if (oldest === undefined) break;
653
+ requestCorrelations.delete(oldest);
654
+ }
655
+ };
656
+ const rememberPendingRequest = (request: PendingSupervisorRequest): void => {
657
+ const key = correlationKey(request);
658
+ if (!key || !request.expectsReply) return;
659
+ requestCorrelations.set(key, { request, state: "pending", updatedAt: Date.now() });
660
+ pruneRequestCorrelations();
661
+ };
662
+ const rememberResolvedRequest = (request: PendingSupervisorRequest): void => {
663
+ const key = correlationKey(request);
664
+ if (!key || !request.expectsReply) return;
665
+ requestCorrelations.set(key, { request, state: "resolved", updatedAt: Date.now() });
666
+ pruneRequestCorrelations();
667
+ };
668
+ const observeRequestLifecycle: SupervisorRequestLifecycleObserver = (request, lifecycle) => {
669
+ if (lifecycle !== "wrong-session") rememberResolvedRequest(request);
670
+ };
671
+ const getSupervisorRequestState = (event: ControlEvent): SupervisorRequestState => {
672
+ if (event.currentTool === "intercom" || event.index === undefined) return "unknown";
673
+ const toolCallId = typeof event.toolCallId === "string" && event.toolCallId.length > 0 ? event.toolCallId : undefined;
674
+ if (!toolCallId) return "unknown";
675
+ pruneRequestCorrelations();
676
+ const key = correlationKey({ runId: event.runId, agent: event.agent, childIndex: event.index, toolCallId });
677
+ if (!key) return "unknown";
678
+ const correlation = requestCorrelations.get(key);
679
+ if (!correlation) return "unknown";
680
+ if (correlation.state === "resolved") return "resolved";
681
+ const now = Date.now();
682
+ if (!fs.existsSync(correlation.request.requestFile)
683
+ || fs.existsSync(replyPath(correlation.request.channelDir, correlation.request.id))
684
+ || now > requestExpiresAt(correlation.request, now)
685
+ || requestRunInactive(correlation.request, state)) {
686
+ rememberResolvedRequest(correlation.request);
687
+ return "resolved";
688
+ }
689
+ return "pending";
690
+ };
621
691
  const seenFiles = new Set<string>();
622
692
  const requestWatchers = new Map<string, fs.FSWatcher>();
623
693
  let rootWatcher: fs.FSWatcher | undefined;
@@ -635,7 +705,7 @@ export function createNativeSupervisorChannel(pi: ExtensionAPI, state: SubagentS
635
705
  };
636
706
 
637
707
  const registerParentTools = (): void => {
638
- if (!hasTool(pi, NATIVE_SUPERVISOR_TOOL_NAME)) pi.registerTool(buildParentSupervisorTool(pending, state));
708
+ if (!hasTool(pi, NATIVE_SUPERVISOR_TOOL_NAME)) pi.registerTool(buildParentSupervisorTool(pi, pending, state, observeRequestLifecycle));
639
709
  };
640
710
 
641
711
  const cleanupStaleChannelsIfDue = (): void => {
@@ -653,7 +723,7 @@ export function createNativeSupervisorChannel(pi: ExtensionAPI, state: SubagentS
653
723
  cleanupStaleChannelsIfDue();
654
724
  const ctx = state.lastUiContext;
655
725
  if (!ctx) return;
656
- refreshPendingRequests(pending, state, ctx);
726
+ refreshPendingRequests(pending, state, ctx, observeRequestLifecycle);
657
727
  const now = Date.now();
658
728
  for (const { channelDir, file } of listRequestFiles()) {
659
729
  if (seenFiles.has(file)) continue;
@@ -662,11 +732,13 @@ export function createNativeSupervisorChannel(pi: ExtensionAPI, state: SubagentS
662
732
  const lifecycle = requestLifecycle(request, state, undefined, now);
663
733
  if (lifecycle !== "pending") {
664
734
  seenFiles.add(file);
735
+ observeRequestLifecycle(request, lifecycle);
665
736
  cleanupRequestLifecycle(request, lifecycle);
666
737
  continue;
667
738
  }
668
739
  seenFiles.add(file);
669
740
  if (request.expectsReply) {
741
+ rememberPendingRequest(request);
670
742
  pending.set(request.id, request);
671
743
  markForegroundSupervisorAttention(request, state);
672
744
  }
@@ -674,16 +746,20 @@ export function createNativeSupervisorChannel(pi: ExtensionAPI, state: SubagentS
674
746
  removeRequestFile(request.requestFile);
675
747
  }
676
748
  pi.sendMessage({
677
- customType: "subagent_supervisor_request",
749
+ customType: SUPERVISOR_REQUEST_MESSAGE_TYPE,
678
750
  content: requestVisibleText(request),
679
751
  display: true,
680
752
  details: {
681
753
  id: request.id,
754
+ requestId: request.id,
682
755
  reason: request.reason,
683
756
  expectsReply: request.expectsReply,
684
757
  runId: request.runId,
685
758
  agent: request.agent,
686
759
  childIndex: request.childIndex,
760
+ ...(request.childTarget ? { childTarget: request.childTarget } : {}),
761
+ ...(request.interview !== undefined ? { interview: request.interview } : {}),
762
+ ...(request.expectsReply ? { replyHint: supervisorReplyHint(request.id) } : {}),
687
763
  },
688
764
  }, { triggerTurn: true });
689
765
  if (request.expectsReply) {
@@ -805,8 +881,10 @@ export function createNativeSupervisorChannel(pi: ExtensionAPI, state: SubagentS
805
881
  if (deferredWatcherRefresh) timers.clearImmediate(deferredWatcherRefresh);
806
882
  deferredWatcherRefresh = undefined;
807
883
  pending.clear();
884
+ requestCorrelations.clear();
808
885
  seenFiles.clear();
809
886
  },
810
887
  pending,
888
+ getSupervisorRequestState,
811
889
  };
812
890
  }
@@ -0,0 +1,243 @@
1
+ import type { EntryRenderOptions, MessageRenderOptions, Theme } from "@earendil-works/pi-coding-agent";
2
+ import { truncateToWidth, visibleWidth, wrapTextWithAnsi, type Component } from "@earendil-works/pi-tui";
3
+ import { safeTerminalText } from "../shared/display-text.ts";
4
+
5
+ export const SUPERVISOR_REQUEST_MESSAGE_TYPE = "subagent_supervisor_request";
6
+ export const SUPERVISOR_REPLY_ENTRY_TYPE = "subagent_supervisor_reply";
7
+
8
+ export type SupervisorReason = "need_decision" | "interview_request" | "progress_update";
9
+
10
+ export interface SupervisorRequestMessageDetails {
11
+ id?: string;
12
+ requestId?: string;
13
+ reason?: SupervisorReason;
14
+ expectsReply?: boolean;
15
+ runId?: string;
16
+ agent?: string;
17
+ childIndex?: number;
18
+ childTarget?: string;
19
+ interview?: unknown;
20
+ replyHint?: string;
21
+ }
22
+
23
+ export interface SupervisorReplyEntryData {
24
+ requestId: string;
25
+ reason?: SupervisorReason;
26
+ runId: string;
27
+ agent: string;
28
+ childIndex: number;
29
+ childTarget?: string;
30
+ message: string;
31
+ createdAt: number;
32
+ }
33
+
34
+ interface SupervisorMessageLike {
35
+ content: unknown;
36
+ details?: unknown;
37
+ }
38
+
39
+ interface SupervisorEntryLike {
40
+ data?: unknown;
41
+ }
42
+
43
+ const MAX_FIELD_CHARS = 512;
44
+ const MAX_BODY_CHARS = 8_000;
45
+ const MAX_INTERVIEW_CHARS = 4_000;
46
+ const MAX_RENDER_LINES = 36;
47
+ const TRUNCATION_MARKER = "[truncated]";
48
+
49
+ export function supervisorReplyHint(requestId: string): string {
50
+ return `subagent_supervisor({ action: "reply", replyTo: "${requestId}", message: "..." })`;
51
+ }
52
+
53
+ function boundedText(value: string, maxChars: number): string {
54
+ const safe = safeTerminalText(value);
55
+ if (safe.length <= maxChars) return safe;
56
+ const prefixLength = Math.max(0, maxChars - TRUNCATION_MARKER.length - 1);
57
+ let prefix = "";
58
+ for (const character of safe) {
59
+ if (prefix.length + character.length > prefixLength) break;
60
+ prefix += character;
61
+ }
62
+ return `${prefix} ${TRUNCATION_MARKER}`;
63
+ }
64
+
65
+ function displayText(value: string, maxChars: number, expanded: boolean): string {
66
+ return expanded ? safeTerminalText(value) : boundedText(value, maxChars);
67
+ }
68
+
69
+ function boundedField(value: unknown, fallback = "unknown"): string {
70
+ return boundedText(typeof value === "string" ? value : value === undefined ? fallback : String(value), MAX_FIELD_CHARS);
71
+ }
72
+
73
+ function contentText(content: unknown): string {
74
+ if (typeof content === "string") return content;
75
+ if (!Array.isArray(content)) return "";
76
+ return content
77
+ .map((part) => {
78
+ if (!part || typeof part !== "object") return "";
79
+ const text = (part as { text?: unknown }).text;
80
+ return typeof text === "string" ? text : "";
81
+ })
82
+ .filter(Boolean)
83
+ .join("\n");
84
+ }
85
+
86
+ function interviewText(interview: unknown, expanded: boolean): string {
87
+ let serialized: string;
88
+ try {
89
+ serialized = JSON.stringify(interview, null, 2) ?? String(interview);
90
+ } catch {
91
+ serialized = "[unavailable]";
92
+ }
93
+ return displayText(serialized, MAX_INTERVIEW_CHARS, expanded);
94
+ }
95
+
96
+ function isRecord(value: unknown): value is Record<string, unknown> {
97
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
98
+ }
99
+
100
+ function isSupervisorReason(value: unknown): value is SupervisorReason {
101
+ return value === "need_decision" || value === "interview_request" || value === "progress_update";
102
+ }
103
+
104
+ function optionalString(value: unknown): boolean {
105
+ return value === undefined || typeof value === "string";
106
+ }
107
+
108
+ function requestDetails(value: unknown): SupervisorRequestMessageDetails | undefined {
109
+ 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 (value.reason !== undefined && !isSupervisorReason(value.reason)) return undefined;
112
+ if (value.expectsReply !== undefined && typeof value.expectsReply !== "boolean") return undefined;
113
+ if (value.childIndex !== undefined && (typeof value.childIndex !== "number" || !Number.isFinite(value.childIndex))) return undefined;
114
+ return value;
115
+ }
116
+
117
+ function replyData(value: unknown): SupervisorReplyEntryData | undefined {
118
+ if (!isRecord(value)) return undefined;
119
+ if (typeof value.requestId !== "string" || typeof value.runId !== "string" || typeof value.agent !== "string" || typeof value.message !== "string") return undefined;
120
+ if (value.reason !== undefined && !isSupervisorReason(value.reason)) return undefined;
121
+ if (value.childTarget !== undefined && typeof value.childTarget !== "string") return undefined;
122
+ if (typeof value.childIndex !== "number" || !Number.isFinite(value.childIndex) || typeof value.createdAt !== "number" || !Number.isFinite(value.createdAt)) return undefined;
123
+ return {
124
+ requestId: value.requestId,
125
+ ...(value.reason === undefined ? {} : { reason: value.reason }),
126
+ runId: value.runId,
127
+ agent: value.agent,
128
+ childIndex: value.childIndex,
129
+ ...(value.childTarget === undefined ? {} : { childTarget: value.childTarget }),
130
+ message: value.message,
131
+ createdAt: value.createdAt,
132
+ };
133
+ }
134
+
135
+ function withRequestId(details: SupervisorRequestMessageDetails): string {
136
+ return boundedField(details.requestId ?? details.id);
137
+ }
138
+
139
+ function requestHeading(reason: SupervisorReason | undefined): string {
140
+ if (reason === "interview_request") return "⚠ Supervisor interview request";
141
+ if (reason === "progress_update") return "ℹ Supervisor progress update";
142
+ return "⚠ Supervisor decision request";
143
+ }
144
+
145
+ function requestLines(message: SupervisorMessageLike, details: SupervisorRequestMessageDetails, expanded: boolean): string[] {
146
+ const requestId = withRequestId(details);
147
+ const lines = [
148
+ `Reason: ${boundedField(details.reason)}`,
149
+ `Run: ${boundedField(details.runId)}`,
150
+ `Agent: ${boundedField(details.agent)}`,
151
+ `Child index: ${boundedField(details.childIndex)}`,
152
+ ];
153
+ if (details.childTarget) lines.push(`Child target: ${boundedField(details.childTarget)}`);
154
+ lines.push(`Request ID: ${requestId}`);
155
+ 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
+ if (details.interview !== undefined) lines.push("", "Interview shape:", interviewText(details.interview, expanded));
158
+ return lines;
159
+ }
160
+
161
+ function replyLines(data: SupervisorReplyEntryData, expanded: boolean): string[] {
162
+ const requestId = boundedField(data.requestId);
163
+ const lines = [
164
+ ...(data.reason ? [`Reason: ${boundedField(data.reason)}`] : []),
165
+ `Run: ${boundedField(data.runId)}`,
166
+ `Agent: ${boundedField(data.agent)}`,
167
+ `Child index: ${boundedField(data.childIndex)}`,
168
+ ];
169
+ if (data.childTarget) lines.push(`Child target: ${boundedField(data.childTarget)}`);
170
+ lines.push(`Reply to: ${requestId}`, "", "Reply:", displayText(data.message || "(empty reply)", MAX_BODY_CHARS, expanded));
171
+ return lines;
172
+ }
173
+
174
+ function renderCard(lines: string[], heading: string, theme: Theme, width: number, expanded: boolean): string[] {
175
+ const safeWidth = Math.max(0, Math.floor(width));
176
+ if (safeWidth < 3) return [truncateToWidth(heading, safeWidth, "")];
177
+ const bodyWidth = safeWidth - 2;
178
+ const headerText = truncateToWidth(` ${heading} `, bodyWidth, "");
179
+ const headerPadding = Math.max(0, bodyWidth - visibleWidth(headerText));
180
+ const border = (text: string) => theme.fg("accent", text);
181
+ const rendered = [border(`╭${headerText}${"─".repeat(headerPadding)}╮`)];
182
+ let hidden = false;
183
+ let interiorLines = 0;
184
+ for (const line of lines) {
185
+ const wrapped = wrapTextWithAnsi(line, Math.max(1, bodyWidth));
186
+ for (const wrappedLine of wrapped.length > 0 ? wrapped : [""]) {
187
+ if (!expanded && interiorLines >= MAX_RENDER_LINES) {
188
+ hidden = true;
189
+ break;
190
+ }
191
+ const text = truncateToWidth(wrappedLine, bodyWidth, "");
192
+ rendered.push(border(`│${text}${" ".repeat(Math.max(0, bodyWidth - visibleWidth(text)))}│`));
193
+ interiorLines++;
194
+ }
195
+ if (hidden) break;
196
+ }
197
+ if (hidden) {
198
+ const text = truncateToWidth(TRUNCATION_MARKER, bodyWidth, "");
199
+ rendered.push(border(`│${text}${" ".repeat(Math.max(0, bodyWidth - visibleWidth(text)))}│`));
200
+ }
201
+ rendered.push(border(`╰${"─".repeat(bodyWidth)}╯`));
202
+ return rendered;
203
+ }
204
+
205
+ class SupervisorCardComponent implements Component {
206
+ private readonly expanded: boolean;
207
+ private readonly heading: string;
208
+ private readonly lines: string[];
209
+ private readonly theme: Theme;
210
+
211
+ constructor(heading: string, lines: string[], theme: Theme, expanded: boolean) {
212
+ this.expanded = expanded;
213
+ this.heading = heading;
214
+ this.lines = lines;
215
+ this.theme = theme;
216
+ }
217
+
218
+ invalidate(): void {}
219
+
220
+ render(width: number): string[] {
221
+ return renderCard(this.lines, this.heading, this.theme, width, this.expanded);
222
+ }
223
+ }
224
+
225
+ export function renderSupervisorRequest(
226
+ message: SupervisorMessageLike,
227
+ options: MessageRenderOptions,
228
+ theme: Theme,
229
+ ): Component | undefined {
230
+ const details = requestDetails(message.details);
231
+ if (!details) return undefined;
232
+ return new SupervisorCardComponent(requestHeading(details.reason), requestLines(message, details, options.expanded), theme, options.expanded);
233
+ }
234
+
235
+ export function renderSupervisorReply(
236
+ entry: SupervisorEntryLike,
237
+ options: EntryRenderOptions,
238
+ theme: Theme,
239
+ ): Component | undefined {
240
+ const data = replyData(entry.data);
241
+ if (!data) return undefined;
242
+ return new SupervisorCardComponent("↩ Supervisor reply to child", replyLines(data, options.expanded), theme, options.expanded);
243
+ }