pi-subagents 0.65.1 → 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 (85) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +1 -1
  3. package/agents/researcher.md +23 -13
  4. package/docs/agents.md +16 -2
  5. package/docs/configuration.md +18 -0
  6. package/docs/extension-api.md +91 -0
  7. package/docs/models.md +58 -1
  8. package/docs/observability.md +42 -2
  9. package/docs/tool-reference.md +3 -3
  10. package/docs/workflows.md +14 -7
  11. package/package.json +2 -1
  12. package/skills/pi-subagents/references/execution-controls.md +14 -1
  13. package/skills/pi-subagents/references/management-authoring-rpc.md +2 -1
  14. package/src/agents/advertised-agent-prompt.ts +63 -0
  15. package/src/agents/agent-management.ts +14 -1
  16. package/src/agents/agent-serializer.ts +2 -0
  17. package/src/agents/agents.ts +8 -0
  18. package/src/api/shared-types.ts +1 -1
  19. package/src/api/workflow-resources.ts +6 -0
  20. package/src/extension/index.ts +40 -2
  21. package/src/extension/public-execution.ts +0 -1
  22. package/src/extension/rpc.ts +4 -21
  23. package/src/extension/schemas.ts +8 -6
  24. package/src/extension/tool-description.ts +6 -5
  25. package/src/intercom/native-supervisor-channel.ts +82 -54
  26. package/src/runs/background/active-async-capacity.ts +18 -18
  27. package/src/runs/background/async-job-tracker.ts +35 -3
  28. package/src/runs/background/async-status-snapshot.ts +10 -12
  29. package/src/runs/background/async-status.ts +17 -9
  30. package/src/runs/background/auto-drain.ts +40 -29
  31. package/src/runs/background/chain-root-attachment.ts +8 -0
  32. package/src/runs/background/control-channel.ts +78 -44
  33. package/src/runs/background/notify.ts +86 -12
  34. package/src/runs/background/owned-process-tree.ts +6 -6
  35. package/src/runs/background/process-terminal.ts +23 -23
  36. package/src/runs/background/run-child-session.ts +60 -32
  37. package/src/runs/background/run-status.ts +75 -5
  38. package/src/runs/background/runner-aliases.ts +18 -7
  39. package/src/runs/background/runner-child-launch.ts +86 -0
  40. package/src/runs/background/stale-run-reconciler.ts +3 -1
  41. package/src/runs/background/subagent-runner.ts +412 -209
  42. package/src/runs/background/subagent-wait.ts +3 -0
  43. package/src/runs/background/wait-completions.ts +4 -0
  44. package/src/runs/foreground/async-steering-action.ts +19 -0
  45. package/src/runs/foreground/execution.ts +101 -25
  46. package/src/runs/foreground/subagent-executor.ts +474 -197
  47. package/src/runs/foreground/workflow-detach-reconcile.ts +8 -5
  48. package/src/runs/foreground/workflow-foreground-steering.ts +56 -2
  49. package/src/runs/shared/acceptance.ts +2 -2
  50. package/src/runs/shared/agent-contract.ts +1 -1
  51. package/src/runs/shared/async-status-projection.ts +47 -47
  52. package/src/runs/shared/child-hooks.ts +151 -2
  53. package/src/runs/shared/child-launch.ts +18 -13
  54. package/src/runs/shared/child-session.ts +42 -6
  55. package/src/runs/shared/child-tool-plan.ts +2 -2
  56. package/src/runs/shared/completion-evidence.ts +2 -2
  57. package/src/runs/shared/completion-guard.ts +1 -0
  58. package/src/runs/shared/host-step-status.ts +11 -11
  59. package/src/runs/shared/llm-intent-arbiter.ts +10 -9
  60. package/src/runs/shared/model-fallback.ts +10 -6
  61. package/src/runs/shared/nested-events.ts +5 -5
  62. package/src/runs/shared/orca-progress-tabs.ts +6 -0
  63. package/src/runs/shared/parallel-handoff.ts +57 -12
  64. package/src/runs/shared/parallel-utils.ts +2 -2
  65. package/src/runs/shared/readonly-drain-observation.ts +42 -0
  66. package/src/runs/shared/readonly-model-continuation.ts +69 -0
  67. package/src/runs/shared/readonly-session-evidence.ts +307 -0
  68. package/src/runs/shared/run-fanout-budget.ts +8 -8
  69. package/src/runs/shared/runtime-acknowledged-extensions.ts +3 -3
  70. package/src/runs/shared/subagent-prompt-runtime.ts +13 -3
  71. package/src/runs/shared/worktree-setup-command.ts +190 -0
  72. package/src/runs/shared/worktree.ts +332 -204
  73. package/src/shared/types.ts +81 -60
  74. package/src/shared/utils.ts +7 -2
  75. package/src/shared/workflow-child-permit.ts +18 -13
  76. package/src/tui/fleet.ts +11 -5
  77. package/src/tui/render.ts +23 -5
  78. package/src/workflows/chat-progress.ts +3 -3
  79. package/src/workflows/scripted-workflow.ts +38 -10
  80. package/src/workflows/workflow-checklist.ts +11 -15
  81. package/src/workflows/workflow-child-summary.ts +57 -8
  82. package/src/workflows/workflow-preflight.ts +19 -19
  83. package/src/workflows/workflow-receipt.ts +3 -3
  84. package/src/workflows/workflow-resources.ts +96 -21
  85. 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()),
@@ -381,18 +383,8 @@ function cleanupStaleEmptySupervisorChannels(nowMs = Date.now()): number {
381
383
  return removed;
382
384
  }
383
385
 
384
- function currentContextSessionId(state: Pick<SubagentState, "currentSessionId">, ctx: ExtensionContext): string | undefined {
385
- try {
386
- const sessionId = ctx.sessionManager.getSessionId();
387
- if (sessionId) return sessionId;
388
- } catch {
389
- // Fall through to the last known identity.
390
- }
391
- return state.currentSessionId ?? undefined;
392
- }
393
-
394
- function requestMatchesContext(request: SupervisorRequest, state: Pick<SubagentState, "currentSessionId">, ctx: ExtensionContext): boolean {
395
- const currentSessionId = currentContextSessionId(state, ctx);
386
+ function requestMatchesOwner(request: SupervisorRequest, state: Pick<SubagentState, "supervisorOwnerSessionId">): boolean {
387
+ const currentSessionId = state.supervisorOwnerSessionId;
396
388
  return Boolean(currentSessionId && request.orchestratorSessionId === currentSessionId);
397
389
  }
398
390
 
@@ -462,12 +454,12 @@ function requestRunInactive(request: SupervisorRequest, state: SubagentState): b
462
454
  return stepStatus === "complete" || stepStatus === "completed" || stepStatus === "failed" || stepStatus === "paused";
463
455
  }
464
456
 
465
- function requestLifecycle(request: PendingSupervisorRequest, state: SubagentState, ctx: ExtensionContext | undefined, now: number): SupervisorRequestLifecycle {
466
- 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";
467
459
  if (!fs.existsSync(request.requestFile)) return "missing";
468
460
  if (request.expectsReply && fs.existsSync(replyPath(request.channelDir, request.id))) return "resolved";
469
461
  if (request.expectsReply && now > requestExpiresAt(request, now)) return "expired";
470
- if (request.expectsReply && requestRunInactive(request, state)) return "inactive";
462
+ if (request.expectsReply && requestRunInactive(request, runState)) return "inactive";
471
463
  return "pending";
472
464
  }
473
465
 
@@ -475,10 +467,10 @@ function cleanupRequestLifecycle(request: PendingSupervisorRequest, lifecycle: S
475
467
  if (lifecycle === "resolved" || lifecycle === "expired" || lifecycle === "inactive") removeRequestFile(request.requestFile);
476
468
  }
477
469
 
478
- 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 {
479
471
  const now = Date.now();
480
472
  for (const request of pending.values()) {
481
- const lifecycle = requestLifecycle(request, state, ctx, now);
473
+ const lifecycle = requestLifecycle(request, state, now, runState(request));
482
474
  if (lifecycle === "pending") continue;
483
475
  pending.delete(request.id);
484
476
  onLifecycle(request, lifecycle);
@@ -498,7 +490,6 @@ function requestVisibleText(request: PendingSupervisorRequest): string {
498
490
  `Agent: ${request.agent}`,
499
491
  `Child index: ${request.childIndex}`,
500
492
  ];
501
- if (request.childTarget) lines.push(`Child intercom target: ${request.childTarget}`);
502
493
  lines.push("");
503
494
  if (request.message) lines.push(request.message);
504
495
  if (request.reason === "interview_request") {
@@ -509,6 +500,7 @@ function requestVisibleText(request: PendingSupervisorRequest): string {
509
500
  if (request.interview !== undefined) lines.push(JSON.stringify(request.interview, null, "\t"));
510
501
  }
511
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.)" : ""}`);
512
504
  return lines.join("\n").trimEnd();
513
505
  }
514
506
 
@@ -563,6 +555,7 @@ function resolvePendingRequest(pending: Map<string, PendingSupervisorRequest>, p
563
555
  );
564
556
  if (matches.length === 1) return matches[0]!;
565
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.`);
566
559
  }
567
560
  if (requests.length === 1) return requests[0]!;
568
561
  if (requests.length === 0) throw new Error("No pending supervisor requests need a reply.");
@@ -580,14 +573,16 @@ function publicPendingRequests(pending: Map<string, PendingSupervisorRequest>):
580
573
  }));
581
574
  }
582
575
 
583
- 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>> {
584
577
  return {
585
578
  name: NATIVE_SUPERVISOR_TOOL_NAME,
586
579
  label: "Subagent Supervisor",
587
580
  description: "Native pi-subagents supervisor channel. Use reply/pending/status to answer child subagent requests without overriding pi-intercom.",
588
581
  parameters: IntercomParamsSchema,
589
582
  async execute(_id, params) {
590
- 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);
591
586
  const input = params as IntercomParams;
592
587
  if (input.action === "status") {
593
588
  return { content: [{ type: "text", text: `Native supervisor channel active. Pending replies: ${pending.size}.` }], details: { active: true, pending: pending.size, root: SUPERVISOR_CHANNEL_ROOT } };
@@ -605,9 +600,6 @@ function buildParentSupervisorTool(pi: ExtensionAPI, pending: Map<string, Pendin
605
600
  clearForegroundSupervisorAttention(request, pending, state);
606
601
  return { content: [{ type: "text", text: `Replied to supervisor request ${request.id}.` }], details: { replyTo: request.id, runId: request.runId, agent: request.agent } };
607
602
  }
608
- if (input.action === "send" || input.action === "ask") {
609
- throw new Error("The native subagent supervisor handles replies only. Child agents initiate asks with contact_supervisor.");
610
- }
611
603
  throw new Error(`Unsupported supervisor action: ${input.action}`);
612
604
  },
613
605
  };
@@ -616,12 +608,19 @@ function buildParentSupervisorTool(pi: ExtensionAPI, pending: Map<string, Pendin
616
608
  export function createNativeSupervisorChannel(pi: ExtensionAPI, state: SubagentState, deps: NativeSupervisorChannelDeps = {}): {
617
609
  start: () => void;
618
610
  activateTransport: () => void;
611
+ findPendingAsks: (target: { runId: string; agent: string; childIndex: number }) => string[];
619
612
  dispose: () => void;
620
613
  pending: Map<string, PendingSupervisorRequest>;
621
614
  getSupervisorRequestState: (event: ControlEvent) => SupervisorRequestState;
622
615
  } {
623
616
  const watch = deps.watch ?? fs.watch;
624
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
+ };
625
624
  const pending = new Map<string, PendingSupervisorRequest>();
626
625
  const requestCorrelations = new Map<string, SupervisorRequestCorrelation>();
627
626
  const correlationKey = (request: { runId: string; agent: string; childIndex: number; toolCallId?: string }): string | undefined => {
@@ -667,7 +666,7 @@ export function createNativeSupervisorChannel(pi: ExtensionAPI, state: SubagentS
667
666
  if (!fs.existsSync(correlation.request.requestFile)
668
667
  || fs.existsSync(replyPath(correlation.request.channelDir, correlation.request.id))
669
668
  || now > requestExpiresAt(correlation.request, now)
670
- || requestRunInactive(correlation.request, state)) {
669
+ || requestRunInactive(correlation.request, runState(correlation.request))) {
671
670
  rememberResolvedRequest(correlation.request);
672
671
  return "resolved";
673
672
  }
@@ -686,11 +685,17 @@ export function createNativeSupervisorChannel(pi: ExtensionAPI, state: SubagentS
686
685
  const hasTransportDemand = () => {
687
686
  if (pending.size > 0) return true;
688
687
  if (state.foregroundControls.size > 0) return true;
689
- 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;
690
695
  };
691
696
 
692
697
  const registerParentTools = (): void => {
693
- 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));
694
699
  };
695
700
 
696
701
  const cleanupStaleChannelsIfDue = (): void => {
@@ -706,15 +711,14 @@ export function createNativeSupervisorChannel(pi: ExtensionAPI, state: SubagentS
706
711
 
707
712
  const poll = (): void => {
708
713
  cleanupStaleChannelsIfDue();
709
- const ctx = state.lastUiContext;
710
- if (!ctx) return;
711
- refreshPendingRequests(pending, state, ctx, observeRequestLifecycle);
714
+ // Only display notifications require a live UI context, not request registration.
715
+ refreshPendingRequests(pending, state, observeRequestLifecycle, runState);
712
716
  const now = Date.now();
713
717
  for (const { channelDir, file } of listRequestFiles()) {
714
718
  if (seenFiles.has(file)) continue;
715
719
  const request = parseRequestFile(file, channelDir);
716
- if (!request || !requestMatchesContext(request, state, ctx)) continue;
717
- const lifecycle = requestLifecycle(request, state, undefined, now);
720
+ if (!request || !requestMatchesOwner(request, state)) continue;
721
+ const lifecycle = requestLifecycle(request, state, now, runState(request));
718
722
  if (lifecycle !== "pending") {
719
723
  seenFiles.add(file);
720
724
  observeRequestLifecycle(request, lifecycle);
@@ -727,27 +731,34 @@ export function createNativeSupervisorChannel(pi: ExtensionAPI, state: SubagentS
727
731
  pending.set(request.id, request);
728
732
  markForegroundSupervisorAttention(request, state);
729
733
  }
730
- else {
731
- 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);
732
761
  }
733
- pi.sendMessage({
734
- customType: SUPERVISOR_REQUEST_MESSAGE_TYPE,
735
- content: requestVisibleText(request),
736
- display: true,
737
- details: {
738
- id: request.id,
739
- requestId: request.id,
740
- reason: request.reason,
741
- expectsReply: request.expectsReply,
742
- runId: request.runId,
743
- agent: request.agent,
744
- childIndex: request.childIndex,
745
- ...(request.childTarget ? { childTarget: request.childTarget } : {}),
746
- ...(request.interview !== undefined ? { interview: request.interview } : {}),
747
- requestBody: request.message,
748
- ...(request.expectsReply ? { replyHint: supervisorReplyHint(request.id) } : {}),
749
- },
750
- }, { triggerTurn: true });
751
762
  if (request.expectsReply) {
752
763
  (pi as { events?: IntercomEventBus }).events?.emit(INTERCOM_DETACH_REQUEST_EVENT, {
753
764
  requestId: request.id,
@@ -824,6 +835,23 @@ export function createNativeSupervisorChannel(pi: ExtensionAPI, state: SubagentS
824
835
  poll();
825
836
  if (!useNativeWatcher() && hasTransportDemand()) startPolling();
826
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
+ },
827
855
  start: () => {
828
856
  if (started) return;
829
857
  started = true;
@@ -12,7 +12,7 @@ export const DEFAULT_ABANDONED_SLOT_RELEASE_AFTER_MS = 20 * 60 * 1000;
12
12
  export const MIN_ABANDONED_SLOT_RELEASE_AFTER_MS = 5 * 60 * 1000;
13
13
  export const MAX_ABANDONED_SLOT_RELEASE_AFTER_MS = 24 * 60 * 60 * 1000;
14
14
 
15
- export interface ActiveAsyncCapacityOwnerV1 {
15
+ export interface ActiveAsyncCapacityOwner {
16
16
  version: 1;
17
17
  reservationToken: string;
18
18
  ownerSessionId: string;
@@ -29,7 +29,7 @@ export interface ActiveAsyncCapacityOwnerV1 {
29
29
  }
30
30
 
31
31
  export interface ActiveAsyncCapacityHandle {
32
- readonly owner: ActiveAsyncCapacityOwnerV1;
32
+ readonly owner: ActiveAsyncCapacityOwner;
33
33
  markStarted(runnerProcessInstanceId: string): void;
34
34
  markWorkflowStarted(): void;
35
35
  rollback(): boolean;
@@ -44,7 +44,7 @@ interface CapacityOptions {
44
44
  abandonedSlotReleaseAfterMs?: number | false;
45
45
  pidLiveness?: (pid: number) => PidLiveness;
46
46
  afterSlotRename?: (releasedDir: string) => void;
47
- writeOwner?: (filePath: string, owner: ActiveAsyncCapacityOwnerV1) => void;
47
+ writeOwner?: (filePath: string, owner: ActiveAsyncCapacityOwner) => void;
48
48
  }
49
49
 
50
50
  export interface ActiveAsyncCapacityReleaseEvidence {
@@ -61,7 +61,7 @@ export type ActiveAsyncCapacityReleaseVerdict =
61
61
  | { state: "not-owned"; reason: string };
62
62
 
63
63
  export interface ActiveAsyncCapacityInspection {
64
- owner?: ActiveAsyncCapacityOwnerV1;
64
+ owner?: ActiveAsyncCapacityOwner;
65
65
  relation: "current" | "source" | "none";
66
66
  slotDir?: string;
67
67
  release: ActiveAsyncCapacityReleaseVerdict;
@@ -100,9 +100,9 @@ function slotDir(poolDir: string, slot: number): string {
100
100
  return path.join(poolDir, `slot-${slot}`);
101
101
  }
102
102
 
103
- function parseOwner(value: unknown): ActiveAsyncCapacityOwnerV1 | undefined {
103
+ function parseOwner(value: unknown): ActiveAsyncCapacityOwner | undefined {
104
104
  if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
105
- const owner = value as Partial<ActiveAsyncCapacityOwnerV1>;
105
+ const owner = value as Partial<ActiveAsyncCapacityOwner>;
106
106
  if (owner.version !== 1
107
107
  || typeof owner.reservationToken !== "string" || !owner.reservationToken
108
108
  || typeof owner.ownerSessionId !== "string" || !owner.ownerSessionId
@@ -116,10 +116,10 @@ function parseOwner(value: unknown): ActiveAsyncCapacityOwnerV1 | undefined {
116
116
  if (owner.sourceRunId !== undefined && typeof owner.sourceRunId !== "string") return undefined;
117
117
  if (owner.runnerProcessInstanceId !== undefined && (typeof owner.runnerProcessInstanceId !== "string" || !owner.runnerProcessInstanceId)) return undefined;
118
118
  if (owner.runnerStartedAt !== undefined && (typeof owner.runnerStartedAt !== "number" || !Number.isFinite(owner.runnerStartedAt))) return undefined;
119
- return owner as ActiveAsyncCapacityOwnerV1;
119
+ return owner as ActiveAsyncCapacityOwner;
120
120
  }
121
121
 
122
- function readOwner(dir: string): ActiveAsyncCapacityOwnerV1 | undefined {
122
+ function readOwner(dir: string): ActiveAsyncCapacityOwner | undefined {
123
123
  try {
124
124
  return parseOwner(JSON.parse(fs.readFileSync(path.join(dir, "owner.json"), "utf-8")));
125
125
  } catch {
@@ -142,7 +142,7 @@ function snapshotFor(sessionId: string, limit: number | undefined, rootDir: stri
142
142
  return { used: occupiedSlots(sessionDir(sessionId, rootDir)).length, limit: limit ?? 0 };
143
143
  }
144
144
 
145
- function matchingOwner(dir: string, expected: ActiveAsyncCapacityOwnerV1): ActiveAsyncCapacityOwnerV1 | undefined {
145
+ function matchingOwner(dir: string, expected: ActiveAsyncCapacityOwner): ActiveAsyncCapacityOwner | undefined {
146
146
  const owner = readOwner(dir);
147
147
  return owner?.reservationToken === expected.reservationToken
148
148
  && owner.runId === expected.runId
@@ -176,7 +176,7 @@ function withSlotClaim<T>(dir: string, operation: () => T): { acquired: true; va
176
176
  }
177
177
  }
178
178
 
179
- function removeOwnedSlot(dir: string, expected: ActiveAsyncCapacityOwnerV1, options: CapacityOptions, requireUnstarted = false, release?: ActiveAsyncCapacityReleaseVerdict): boolean {
179
+ function removeOwnedSlot(dir: string, expected: ActiveAsyncCapacityOwner, options: CapacityOptions, requireUnstarted = false, release?: ActiveAsyncCapacityReleaseVerdict): boolean {
180
180
  if (requireUnstarted && (expected.runnerProcessInstanceId || expected.runnerStartedAt)) return false;
181
181
  const claimed = withSlotClaim(dir, () => {
182
182
  const current = matchingOwner(dir, expected);
@@ -193,7 +193,7 @@ function removeOwnedSlot(dir: string, expected: ActiveAsyncCapacityOwnerV1, opti
193
193
  return claimed.acquired && claimed.value;
194
194
  }
195
195
 
196
- function appendAbandonedReleaseEvent(asyncDir: string, owner: ActiveAsyncCapacityOwnerV1, evidence: ActiveAsyncCapacityReleaseEvidence, now: number): void {
196
+ function appendAbandonedReleaseEvent(asyncDir: string, owner: ActiveAsyncCapacityOwner, evidence: ActiveAsyncCapacityReleaseEvidence, now: number): void {
197
197
  try {
198
198
  const eventsPath = path.join(asyncDir, "events.jsonl");
199
199
  fs.mkdirSync(path.dirname(eventsPath), { recursive: true });
@@ -213,7 +213,7 @@ function terminalState(state: AsyncStatus["state"]): boolean {
213
213
  return state !== "queued" && state !== "running" && state !== "paused";
214
214
  }
215
215
 
216
- function runnerReleaseVerdict(owner: ActiveAsyncCapacityOwnerV1, status: AsyncStatus | null, options: CapacityOptions): ActiveAsyncCapacityReleaseVerdict {
216
+ function runnerReleaseVerdict(owner: ActiveAsyncCapacityOwner, status: AsyncStatus | null, options: CapacityOptions): ActiveAsyncCapacityReleaseVerdict {
217
217
  if (!status) return { state: "retained", reason: "status file is missing or unreadable" };
218
218
  if (!owner.runnerProcessInstanceId) return { state: "retained", reason: "runner process identity has not been recorded" };
219
219
  if (status.sessionId !== owner.ownerSessionId) return { state: "retained", reason: `status session ${status.sessionId ?? "unknown"} does not match owner session ${owner.ownerSessionId}` };
@@ -262,7 +262,7 @@ function abandonedRunnerReleaseVerdict(status: AsyncStatus, proofState: string,
262
262
  };
263
263
  }
264
264
 
265
- function workflowReleaseVerdict(owner: ActiveAsyncCapacityOwnerV1, status: AsyncStatus | null, liveWorkflowRunIds: ReadonlySet<string>): ActiveAsyncCapacityReleaseVerdict {
265
+ function workflowReleaseVerdict(owner: ActiveAsyncCapacityOwner, status: AsyncStatus | null, liveWorkflowRunIds: ReadonlySet<string>): ActiveAsyncCapacityReleaseVerdict {
266
266
  if (!status) return { state: "retained", reason: "status file is missing or unreadable" };
267
267
  if (status.sessionId !== owner.ownerSessionId) return { state: "retained", reason: `status session ${status.sessionId ?? "unknown"} does not match owner session ${owner.ownerSessionId}` };
268
268
  if (status.runId !== owner.runId) return { state: "retained", reason: `status run ${status.runId} does not match owner run ${owner.runId}` };
@@ -289,7 +289,7 @@ function workflowReleaseVerdict(owner: ActiveAsyncCapacityOwnerV1, status: Async
289
289
  return { state: "releasable", reason: "workflow is terminal, controller is gone, and async children have observed proof" };
290
290
  }
291
291
 
292
- function ownerReleaseVerdict(owner: ActiveAsyncCapacityOwnerV1, liveWorkflowRunIds: ReadonlySet<string>, options: CapacityOptions): ActiveAsyncCapacityReleaseVerdict {
292
+ function ownerReleaseVerdict(owner: ActiveAsyncCapacityOwner, liveWorkflowRunIds: ReadonlySet<string>, options: CapacityOptions): ActiveAsyncCapacityReleaseVerdict {
293
293
  const status = readStatus(owner.asyncDir);
294
294
  return owner.kind === "runner"
295
295
  ? runnerReleaseVerdict(owner, status, options)
@@ -364,7 +364,7 @@ export function getActiveAsyncCapacitySnapshot(
364
364
  return reconcileActiveAsyncCapacity(sessionId, limit, options);
365
365
  }
366
366
 
367
- function createSlot(poolDir: string, owner: ActiveAsyncCapacityOwnerV1): boolean {
367
+ function createSlot(poolDir: string, owner: ActiveAsyncCapacityOwner): boolean {
368
368
  const destination = slotDir(poolDir, owner.slot);
369
369
  fs.mkdirSync(poolDir, { recursive: true, mode: 0o700 });
370
370
  try {
@@ -379,7 +379,7 @@ function createSlot(poolDir: string, owner: ActiveAsyncCapacityOwnerV1): boolean
379
379
  return true;
380
380
  }
381
381
 
382
- function handleFor(owner: ActiveAsyncCapacityOwnerV1, limit: number, options: CapacityOptions, rollbackOwner?: ActiveAsyncCapacityOwnerV1): ActiveAsyncCapacityHandle {
382
+ function handleFor(owner: ActiveAsyncCapacityOwner, limit: number, options: CapacityOptions, rollbackOwner?: ActiveAsyncCapacityOwner): ActiveAsyncCapacityHandle {
383
383
  const rootDir = options.rootDir ?? ACTIVE_ASYNC_CAPACITY_DIR;
384
384
  const writeOwner = options.writeOwner ?? writePrivateAtomicJson;
385
385
  const dir = slotDir(sessionDir(owner.ownerSessionId, rootDir), owner.slot);
@@ -462,7 +462,7 @@ export function acquireActiveAsyncCapacity(
462
462
  const poolDir = sessionDir(input.sessionId, rootDir);
463
463
  const token = options.token?.() ?? randomUUID();
464
464
  for (let slot = 0; slot < input.limit; slot++) {
465
- const owner: ActiveAsyncCapacityOwnerV1 = {
465
+ const owner: ActiveAsyncCapacityOwner = {
466
466
  version: 1,
467
467
  reservationToken: token,
468
468
  ownerSessionId: input.sessionId,
@@ -495,7 +495,7 @@ export function transferActiveAsyncCapacity(
495
495
  if (!current || !status || status.runId !== input.sourceRunId || status.state === "queued" || status.state === "running") {
496
496
  throw new Error(`Active async capacity source '${input.sourceRunId}' is not transferable.`);
497
497
  }
498
- const next: ActiveAsyncCapacityOwnerV1 = {
498
+ const next: ActiveAsyncCapacityOwner = {
499
499
  ...current,
500
500
  runId: input.runId,
501
501
  sourceRunId: input.sourceRunId,
@@ -26,6 +26,7 @@ import { EXTERNAL_JOB_BRIDGE_REQUEST_DIR, serviceExternalJobBridgeRequests } fro
26
26
  import { shouldUseNativeFsWatch } from "../../shared/watch-strategy.ts";
27
27
  import { parseWorkflowChildSummary } from "../../workflows/workflow-child-summary.ts";
28
28
  import { validHostStepNodes } from "../shared/host-step-status.ts";
29
+ import { readProcessTerminal } from "./process-terminal.ts";
29
30
  import { withCachedUiContext } from "../../shared/extension-context.ts";
30
31
 
31
32
  interface AsyncJobTrackerOptions {
@@ -78,6 +79,9 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
78
79
  let widgetRerenderTimer: ReturnType<typeof setTimeout> | undefined;
79
80
  const runningJobIds = new Set<string>();
80
81
  const externalJobBridgeRuns = new Set<string>();
82
+ // Early native failure is visible before its publisher finishes. Retain only
83
+ // that scoped observation, using the existing liveness sweep to renew delivery.
84
+ const terminalPublications = new Map<string, { instanceId: string; pending: true } | { pending: false }>();
81
85
  const externalJobBridgeEligibility = (steps: AsyncJobState["steps"]): "required" | "not-required" | "unknown" => {
82
86
  if (!Array.isArray(steps)) return "unknown";
83
87
  for (const step of steps) {
@@ -380,6 +384,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
380
384
  refreshTimers.delete(asyncId);
381
385
  runningJobIds.delete(asyncId);
382
386
  externalJobBridgeRuns.delete(asyncId);
387
+ terminalPublications.delete(asyncId);
383
388
  };
384
389
 
385
390
  const refreshJob = (job: AsyncJobState): boolean => {
@@ -440,6 +445,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
440
445
  if (status) {
441
446
  const previousStatus = job.status;
442
447
  job.status = status.state;
448
+ if (!terminalStatus(job.status)) terminalPublications.delete(job.asyncId);
443
449
  if (job.status === "running") runningJobIds.add(job.asyncId);
444
450
  else runningJobIds.delete(job.asyncId);
445
451
  if (job.status !== "complete" && job.status !== "failed" && job.status !== "paused" && job.status !== "stopped") cancelCleanup(job.asyncId);
@@ -497,9 +503,30 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
497
503
  job.wrapUpRequested = status.wrapUpRequested ?? job.wrapUpRequested;
498
504
  job.sessionFile = status.sessionFile ?? job.sessionFile;
499
505
  if (terminalStatus(job.status)) {
500
- if (!terminalStatus(previousStatus)) options.onJobTerminal?.();
506
+ let publication = terminalPublications.get(job.asyncId);
507
+ if (!publication && status.mode !== "workflow" && status.processTerminal?.state === "pending"
508
+ && status.runId === job.asyncId && status.sessionId === state.currentSessionId
509
+ && status.completionOwnerId && status.completionOwnerId === state.completionOwnerId) {
510
+ publication = { instanceId: status.processTerminal.runnerProcessInstanceId, pending: true };
511
+ terminalPublications.set(job.asyncId, publication);
512
+ }
513
+ const wasPending = publication?.pending;
514
+ if (publication?.pending) {
515
+ // Reconciliation resolves pending as well as public payloads. The
516
+ // watcher already discovers exact tracked run IDs without promotion.
517
+ const published = reconciliation.resultPath && fs.existsSync(reconciliation.resultPath);
518
+ const closed = !published && readProcessTerminal(job.asyncDir, {
519
+ runId: job.asyncId, runnerProcessInstanceId: publication.instanceId,
520
+ })?.state === "observed";
521
+ if (published || closed) {
522
+ publication = { pending: false };
523
+ terminalPublications.set(job.asyncId, publication);
524
+ } else cancelCleanup(job.asyncId);
525
+ }
526
+ // Scan on close too: publication may have raced the payload check.
527
+ if (!terminalStatus(previousStatus) || (wasPending && !publication?.pending)) options.onJobTerminal?.();
501
528
  rememberFleetJob(state, job);
502
- if (!nestedRefreshFailed && !hasLiveNestedDescendants(job.nestedChildren) && (previousStatus !== job.status || !state.cleanupTimers.has(job.asyncId))) {
529
+ if (!publication?.pending && !nestedRefreshFailed && !hasLiveNestedDescendants(job.nestedChildren) && (previousStatus !== job.status || !state.cleanupTimers.has(job.asyncId))) {
503
530
  scheduleCleanup(job.asyncId);
504
531
  }
505
532
  }
@@ -526,7 +553,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
526
553
  }
527
554
  runningJobIds.delete(job.asyncId);
528
555
  rememberFleetJob(state, job);
529
- if (!hasLiveNestedDescendants(job.nestedChildren) && !state.cleanupTimers.has(job.asyncId)) scheduleCleanup(job.asyncId);
556
+ if (!terminalPublications.get(job.asyncId)?.pending && !hasLiveNestedDescendants(job.nestedChildren) && !state.cleanupTimers.has(job.asyncId)) scheduleCleanup(job.asyncId);
530
557
  }
531
558
  return widgetRenderKey(job, widgetExpanded) !== widgetStateBefore;
532
559
  };
@@ -668,6 +695,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
668
695
  const sessionRoot = state.liveAsyncSessionRoots?.get(info.id);
669
696
  state.liveAsyncSessionRoots?.delete(info.id);
670
697
  externalJobBridgeRuns.delete(info.id);
698
+ terminalPublications.delete(info.id);
671
699
  state.asyncJobs.set(info.id, {
672
700
  asyncId: info.id,
673
701
  asyncDir,
@@ -721,6 +749,9 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
721
749
  job.asyncDir = result.asyncDir;
722
750
  watchJob(job);
723
751
  }
752
+ // Delivery can precede the first terminal status refresh. Remember its
753
+ // settlement even when no pending publication has been observed yet.
754
+ terminalPublications.set(asyncId, { pending: false });
724
755
  try {
725
756
  updateAsyncJobNestedProjection(job);
726
757
  } catch (error) {
@@ -745,6 +776,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
745
776
  widgetRerenderTimer = undefined;
746
777
  runningJobIds.clear();
747
778
  externalJobBridgeRuns.clear();
779
+ terminalPublications.clear();
748
780
  };
749
781
 
750
782
  const resetJobs = (ctx?: ExtensionContext) => {
@@ -1,8 +1,8 @@
1
1
  import type { AsyncJobState, SubagentState } from "../../shared/types.ts";
2
2
  import {
3
- projectAsyncStatusSnapshot,
3
+ projectAsyncStatusSnapshot as buildAsyncStatusSnapshot,
4
4
  type AsyncStatusSnapshotOptions,
5
- type AsyncStatusSnapshotV1,
5
+ type AsyncStatusSnapshot,
6
6
  } from "../shared/async-status-projection.ts";
7
7
 
8
8
  export {
@@ -10,22 +10,20 @@ export {
10
10
  ASYNC_STATUS_SNAPSHOT_VERSION,
11
11
  } from "../shared/async-status-projection.ts";
12
12
  export type {
13
- AsyncStatusSnapshotActivityV1,
14
- AsyncStatusSnapshotCapsV1,
15
- AsyncStatusSnapshotHostStepV1,
13
+ AsyncStatusSnapshotActivity,
14
+ AsyncStatusSnapshotCaps,
15
+ AsyncStatusSnapshotHostStep,
16
16
  AsyncStatusSnapshotKind,
17
- AsyncStatusSnapshotNodeV1,
18
- AsyncStatusSnapshotOmittedV1,
17
+ AsyncStatusSnapshotNode,
18
+ AsyncStatusSnapshotOmitted,
19
19
  AsyncStatusSnapshotOptions,
20
20
  AsyncStatusSnapshotState,
21
- AsyncStatusSnapshotV1,
21
+ AsyncStatusSnapshot,
22
22
  } from "../shared/async-status-projection.ts";
23
23
 
24
24
  export const ASYNC_STATUS_SNAPSHOT_WIDGET_PREFIX = "PI_SUBAGENT_ASYNC_JSON:";
25
25
 
26
- export function buildAsyncStatusSnapshot(jobs: Iterable<AsyncJobState>, options: AsyncStatusSnapshotOptions = {}): AsyncStatusSnapshotV1 {
27
- return projectAsyncStatusSnapshot(jobs, options);
28
- }
26
+ export { buildAsyncStatusSnapshot };
29
27
 
30
28
  export function asyncStatusSnapshotJobsForState(state: SubagentState | undefined, sessionId: string | null | undefined): AsyncJobState[] {
31
29
  if (!state || !sessionId || state.currentSessionId !== sessionId) return [];
@@ -39,7 +37,7 @@ export function asyncStatusSnapshotJobsForState(state: SubagentState | undefined
39
37
  return [...jobs.values()];
40
38
  }
41
39
 
42
- export function buildAsyncStatusSnapshotForState(state: SubagentState | undefined, sessionId: string | null | undefined, options: AsyncStatusSnapshotOptions = {}): AsyncStatusSnapshotV1 {
40
+ export function buildAsyncStatusSnapshotForState(state: SubagentState | undefined, sessionId: string | null | undefined, options: AsyncStatusSnapshotOptions = {}): AsyncStatusSnapshot {
43
41
  return buildAsyncStatusSnapshot(asyncStatusSnapshotJobsForState(state, sessionId), options);
44
42
  }
45
43