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
@@ -0,0 +1,42 @@
1
+ const knownStates = new Set(["queued", "running", "complete", "failed", "partial", "paused", "stopped", "rejected"]);
2
+
3
+ /**
4
+ * Private, installation-owned evidence. Never changes drain/query behavior or retains statuses.
5
+ * Observes the ordinary indexed drain, not historical ownership or outside work appearing later.
6
+ */
7
+ export class ReadonlyDrainObservation {
8
+ private state: "pending" | "empty" | "denied" = "pending";
9
+ private started = false;
10
+ private first = false;
11
+ private readonly file: string;
12
+ private readonly guard: () => boolean;
13
+ constructor(file: string, guard: () => boolean) { this.file = file; this.guard = guard; }
14
+ deny(): void { this.state = "denied"; }
15
+ check(): boolean {
16
+ try { if (!this.guard()) this.deny(); } catch { this.deny(); }
17
+ return this.state !== "denied";
18
+ }
19
+ begin(file: string | null, native: boolean): void {
20
+ if (this.started || file !== this.file || !native) this.deny();
21
+ this.started = true;
22
+ this.check();
23
+ }
24
+ /** Called at the existing initial read, before reconciliation or filtering. */
25
+ readonly status: RawDrainStatusObserver = (status) => {
26
+ if (!status || typeof status.sessionId !== "string" || !status.sessionId
27
+ || !knownStates.has(status.state as string)) this.deny();
28
+ else if (status.sessionId === this.file && (status.state === "queued" || status.state === "running")) this.deny();
29
+ };
30
+ predicate(hasWork: boolean): void {
31
+ if (this.first) return;
32
+ this.first = true;
33
+ if (hasWork) this.deny();
34
+ }
35
+ complete(): void {
36
+ if (this.started && this.first && this.check()) this.state = "empty";
37
+ }
38
+ settled(): boolean { return this.check() && this.state === "empty"; }
39
+ }
40
+
41
+ /** Internal synchronous sink; null means an existing query encountered uncertainty. */
42
+ export type RawDrainStatusObserver = (status: { sessionId?: unknown; state?: unknown } | null) => void;
@@ -0,0 +1,69 @@
1
+ import type { ChildSession } from "./child-session.ts";
2
+ import { getReadonlySessionEvidence, type SettledReadonlyEvidence } from "./readonly-session-evidence.ts";
3
+
4
+ /** Owned by the logical host run, shared with abort recovery; never reset per attempt. */
5
+ export type LogicalRecoveryState = "unused" | "abort-recovery" | "readonly-continuation";
6
+
7
+ export interface ReadonlyContinuationCandidate {
8
+ /** Actual resolved identity, not an alias or a parsed display reference. */
9
+ readonly resolved: { readonly provider: string; readonly model: string; readonly api: string } | undefined;
10
+ readonly tried: boolean;
11
+ /** Host assessment of retained input support AND context capacity, including prompt overhead. */
12
+ readonly compatibility: "compatible" | "incompatible" | "unknown";
13
+ }
14
+
15
+ export interface ReadonlyContinuationInput {
16
+ /** Retain the source child privately: its live accessor detects revoked receipts. */
17
+ readonly source: ChildSession | undefined;
18
+ readonly recoveryState: LogicalRecoveryState;
19
+ /** Ordered, already authorized and exclusion-filtered. This planner never resolves models. */
20
+ readonly candidates: readonly ReadonlyContinuationCandidate[];
21
+ readonly currentIndex: number;
22
+ /** False includes success, stop/interrupt/detach/handoff, deadline, or workflow-permit veto. */
23
+ readonly lifecycleAllowsContinuation: boolean;
24
+ /** False includes completion/structured/acceptance failures, pending input or other effects. */
25
+ readonly effectsAllowContinuation: boolean;
26
+ /** Configured tool budgets are unsupported; unknown authoritative usage allowance denies. */
27
+ readonly budget: "unconfigured" | "available" | "exhausted" | "unknown" | "tool-budget-configured";
28
+ readonly knownContextOverflow: boolean;
29
+ }
30
+
31
+ export const READONLY_CONTINUATION_PROMPT = "The previous provider request failed with HTTP 429 after read-only progress. Continue from the retained transcript and completed tool results. Do not restart or repeat completed work. Use only the existing read-only tools and finish the requested response.";
32
+
33
+ export type ReadonlyContinuationPlan =
34
+ | { readonly kind: "deny"; readonly reason: "recovery-consumed" | "veto" | "no-evidence" | "unresolved-identity" | "incompatible" | "no-sibling" }
35
+ | { readonly kind: "continue"; readonly candidateIndex: number; readonly expected: SettledReadonlyEvidence;
36
+ readonly prompt: typeof READONLY_CONTINUATION_PROMPT; readonly recoveryState: "readonly-continuation" };
37
+
38
+ /**
39
+ * Pure decision: no disk reads, dispatch, receipt minting, or state mutation.
40
+ * The host MUST recheck live source proof and lifecycle/budget at handoff, then store the
41
+ * returned consumed state BEFORE creation (even if creation subsequently fails),
42
+ * and pass expected to requestReadonlySessionEvidence on the exact-file sibling launch.
43
+ * That factory guard owns checkpoint/configured-provider revalidation before open/prompt/dispatch.
44
+ * The host must also verify the actual sibling model matches the selected resolved identity;
45
+ * a plan is not a dispatch authorization and any create/guard failure terminates recovery.
46
+ */
47
+ export function planReadonlyModelContinuation(input: ReadonlyContinuationInput): ReadonlyContinuationPlan {
48
+ if (input.recoveryState !== "unused") return { kind: "deny", reason: "recovery-consumed" };
49
+ if (!input.lifecycleAllowsContinuation || !input.effectsAllowContinuation || input.knownContextOverflow
50
+ || (input.budget !== "unconfigured" && input.budget !== "available")) return { kind: "deny", reason: "veto" };
51
+ const expected = input.source && getReadonlySessionEvidence(input.source);
52
+ if (!expected || input.source?.detached || input.source?.shutDown) return { kind: "deny", reason: "no-evidence" };
53
+ const current = Number.isInteger(input.currentIndex) && input.currentIndex >= 0 ? input.candidates[input.currentIndex]?.resolved : undefined;
54
+ if (!current || current.provider !== expected.provider || current.model !== expected.model || current.api !== expected.api) {
55
+ return { kind: "deny", reason: "unresolved-identity" };
56
+ }
57
+ for (let index = input.currentIndex + 1; index < input.candidates.length; index++) {
58
+ const candidate = input.candidates[index];
59
+ if (!candidate) return { kind: "deny", reason: "unresolved-identity" };
60
+ if (candidate.tried) continue;
61
+ const resolved = candidate.resolved;
62
+ if (!resolved?.provider || !resolved.model || !resolved.api) return { kind: "deny", reason: "unresolved-identity" };
63
+ if (resolved.provider !== expected.provider || resolved.model === expected.model) continue;
64
+ if (input.candidates.some((other) => other.tried && other.resolved?.provider === resolved.provider && other.resolved.model === resolved.model)) continue;
65
+ if (resolved.api !== expected.api || candidate.compatibility !== "compatible") return { kind: "deny", reason: "incompatible" };
66
+ return { kind: "continue", candidateIndex: index, expected, prompt: READONLY_CONTINUATION_PROMPT, recoveryState: "readonly-continuation" };
67
+ }
68
+ return { kind: "deny", reason: "no-sibling" };
69
+ }
@@ -0,0 +1,307 @@
1
+ /** Internal, opt-in factory evidence. No runner enables continuation through this module. */
2
+ import { createHash } from "node:crypto";
3
+ import { lstatSync, readFileSync } from "node:fs";
4
+ import { resolve } from "node:path";
5
+ import { isDeepStrictEqual } from "node:util";
6
+ import type { AgentSession, SessionEntry, SessionHeader } from "@earendil-works/pi-coding-agent";
7
+ import type { ChildSession, ChildSessionLaunch, PiCodingAgentModule } from "./child-session.ts";
8
+ import { captureReadonlyChildDrain, isReadonlyChildHookProfile, isReadonlyChildSessionReporting, observeReadonlyChildHookDrain } from "./child-hooks.ts";
9
+
10
+ type Runtime = Awaited<ReturnType<PiCodingAgentModule["ModelRuntime"]["create"]>>;
11
+ type Model = NonNullable<AgentSession["model"]>;
12
+
13
+ export interface SettledReadonlyEvidence {
14
+ readonly sessionFile: string;
15
+ readonly sessionId: string;
16
+ readonly leafId: string;
17
+ readonly provider: string;
18
+ readonly model: string;
19
+ readonly api: string;
20
+ readonly status: 429;
21
+ /** Complete active context, not the host's attempt-only event projection. */
22
+ readonly contextJson: string;
23
+ readonly completedToolResults: number;
24
+ readonly fileDigest: string;
25
+ }
26
+
27
+ const requested = new WeakMap<ChildSessionLaunch, SettledReadonlyEvidence | null>();
28
+ const receipts = new WeakMap<ChildSession, SettledReadonlyEvidence>();
29
+ // Opaque configured-provider continuity, never an implementation-origin attestation.
30
+ const providers = new WeakMap<SettledReadonlyEvidence, object>();
31
+
32
+ /** Internal integration seam: opt in before factory creation. Ordinary launches do no evidence I/O. */
33
+ export function requestReadonlySessionEvidence(launch: ChildSessionLaunch, expected?: SettledReadonlyEvidence): void {
34
+ requested.set(launch, expected ?? null);
35
+ }
36
+
37
+ /** Only the default factory can produce a receipt; duck-typed fake child properties are ignored. */
38
+ export function getReadonlySessionEvidence(child: ChildSession): SettledReadonlyEvidence | undefined {
39
+ return receipts.get(child);
40
+ }
41
+
42
+ function digest(bytes: string): string {
43
+ return createHash("sha256").update(bytes).digest("hex");
44
+ }
45
+
46
+ function absent(file: string): boolean {
47
+ try { lstatSync(file); return false; }
48
+ catch (error) { return (error as NodeJS.ErrnoException).code === "ENOENT"; }
49
+ }
50
+
51
+ function sameStored(disk: unknown, live: unknown): boolean {
52
+ // The SDK keeps optional undefined fields in memory which JSONL intentionally omits.
53
+ return isDeepStrictEqual(disk, JSON.parse(JSON.stringify(live)));
54
+ }
55
+
56
+ /** Must run before opening a retained file. Never repairs, truncates, or creates it. */
57
+ export function validateReadonlySessionCheckpoint(evidence: SettledReadonlyEvidence): boolean {
58
+ if (!providers.has(evidence)) return false;
59
+ try { return digest(readFileSync(evidence.sessionFile, "utf8")) === evidence.fileDigest; }
60
+ catch { return false; }
61
+ }
62
+
63
+ function readHistory(file: string): { header: SessionHeader; entries: SessionEntry[]; bytes: string } {
64
+ const bytes = readFileSync(file, "utf8");
65
+ if (!bytes.endsWith("\n")) throw new Error("Incomplete session file");
66
+ const rows = bytes.slice(0, -1).split("\n").map((line) => JSON.parse(line));
67
+ const [header, ...entries] = rows;
68
+ if (header?.type !== "session" || header.version !== 3 || typeof header.id !== "string" || !header.id || typeof header.cwd !== "string" || !entries.length) throw new Error("Unsupported session header");
69
+ const ids = new Set<string>();
70
+ for (const entry of entries) {
71
+ if (!entry || typeof entry.id !== "string" || !entry.id || ids.has(entry.id) || typeof entry.type !== "string" || typeof entry.timestamp !== "string" || (entry.parentId !== null && !ids.has(entry.parentId))) throw new Error("Broken session chain");
72
+ ids.add(entry.id);
73
+ }
74
+ return { header, entries, bytes };
75
+ }
76
+
77
+ function activeMessages(branch: SessionEntry[]): AgentSession["messages"] {
78
+ const messages = branch.flatMap((entry) => {
79
+ if (entry.type === "message") return [entry.message];
80
+ // Compaction/branch summaries and custom context need their own proof; do not reconstruct them.
81
+ if (!["model_change", "thinking_level_change", "session_info", "label"].includes(entry.type)) throw new Error("Unsupported active context");
82
+ return [];
83
+ });
84
+ // Snapshot the persisted representation, not mutable SDK message references.
85
+ return JSON.parse(JSON.stringify(messages));
86
+ }
87
+
88
+ function completedResults(messages: AgentSession["messages"]): number {
89
+ const pending = new Map<string, string>();
90
+ const seen = new Set<string>();
91
+ let completed = 0;
92
+ for (const message of messages) {
93
+ if (message.role === "toolResult") {
94
+ if (typeof message.toolCallId !== "string" || !pending.has(message.toolCallId) || pending.get(message.toolCallId) !== message.toolName || !Array.isArray(message.content)) throw new Error("Unmatched tool result");
95
+ pending.delete(message.toolCallId);
96
+ if (!message.isError && message.content.length) completed++;
97
+ } else {
98
+ if (pending.size) throw new Error("Unresolved tool call");
99
+ if (message.role === "assistant") {
100
+ if (!Array.isArray(message.content)) throw new Error("Invalid assistant content");
101
+ for (const block of message.content) {
102
+ if (block.type !== "toolCall") continue;
103
+ if (!block.id || typeof block.id !== "string" || seen.has(block.id) || !["read", "ls"].includes(block.name)) throw new Error("Uncertified tool call");
104
+ seen.add(block.id);
105
+ pending.set(block.id, block.name);
106
+ }
107
+ } else if (message.role !== "user") throw new Error("Unsupported context message");
108
+ }
109
+ }
110
+ if (pending.size) throw new Error("Unresolved tool call");
111
+ return completed;
112
+ }
113
+
114
+ function eligibleLaunch(launch: ChildSessionLaunch): boolean {
115
+ const r = launch.runtime;
116
+ return launch.ambientExtensions === false && !launch.extensionPaths.length
117
+ && isReadonlyChildSessionReporting(launch)
118
+ && (!launch.hooks.length || (launch.storage.kind === "file" && isReadonlyChildHookProfile(launch.hooks, r)))
119
+ && launch.tools !== undefined && launch.tools.every((tool) => tool === "read" || tool === "ls")
120
+ && !r.toolBudget && !r.permissions && !r.childWatchdog && !r.watchdogStatus && !r.structuredOutput
121
+ && !r.waitTool.enabled && !r.fanoutChild && !r.fast && !r.nestedRoute && !r.nestedParent && !r.runFanoutBudget
122
+ && !r.supervisorChannelDir && !r.mcpDirectTools?.length;
123
+ }
124
+
125
+ export interface ReadonlyEvidenceObserver {
126
+ start(): void;
127
+ settled(): void;
128
+ invalidate(): void;
129
+ beforeShutdown(): void;
130
+ finish(child: ChildSession): void;
131
+ }
132
+
133
+ /** Called only on the explicit opt-in path, before SessionManager.open can permissively repair input. */
134
+ export function prepareReadonlySessionEvidence(launch: ChildSessionLaunch): { loadingHooks(enabled: boolean): void; beforeOpen(): void; opened(manager: AgentSession["sessionManager"]): void; observe(pi: PiCodingAgentModule, runtime: Runtime, session: AgentSession): ReadonlyEvidenceObserver | undefined } | undefined {
135
+ if (!requested.has(launch)) return undefined;
136
+ const expected = requested.get(launch);
137
+ requested.delete(launch);
138
+ const deny = (): undefined => {
139
+ if (expected) throw new Error("Unsupported read-only continuation session");
140
+ return undefined;
141
+ };
142
+ const checkExpected = (): void => {
143
+ if (expected && (launch.storage.kind !== "file" || resolve(launch.storage.sessionFile) !== expected.sessionFile || !validateReadonlySessionCheckpoint(expected))) throw new Error("Read-only continuation checkpoint changed");
144
+ };
145
+ checkExpected();
146
+ if (!eligibleLaunch(launch) || launch.storage.kind !== "file") return deny();
147
+ const file = resolve(launch.storage.sessionFile);
148
+ // Only an initially absent *assigned exact file* may use SDK initialization.
149
+ // Empty files, dangling symlinks, inaccessible and corrupt inputs are not fresh.
150
+ let fresh = !expected && absent(file);
151
+ let initialHeader: SessionHeader | undefined;
152
+ try { if (!fresh) initialHeader = readHistory(file).header; }
153
+ catch { return deny(); }
154
+ const beforeOpen = (): void => {
155
+ checkExpected();
156
+ if (fresh) fresh = absent(file);
157
+ };
158
+ const opened = (manager: AgentSession["sessionManager"]): void => {
159
+ if (fresh && manager.getSessionFile() === file && manager.getEntries().length === 0 && manager.getLeafId() === null) {
160
+ // Capture the real SDK-generated identity immediately after open, before hooks.
161
+ initialHeader = JSON.parse(JSON.stringify(manager.getHeader()));
162
+ }
163
+ };
164
+ const observe = (pi: PiCodingAgentModule, runtime: Runtime, session: AgentSession): ReadonlyEvidenceObserver | undefined => {
165
+ const drainSettled = captureReadonlyChildDrain(launch.hooks);
166
+ // A deliberately tested capability floor, not an attestation of installed code origin.
167
+ if (!initialHeader || pi.VERSION !== "0.85.1" || !session.model || !session.agent.streamFunction) return deny();
168
+ const header = initialHeader;
169
+ const model = { ...session.model };
170
+ const provider = runtime.getProvider(model.provider);
171
+ if (expected && provider !== providers.get(expected)) return deny();
172
+ // Coverage is namespace/API plus the observed POST topology below, not builtin provider origin.
173
+ const supported = (m: Model): boolean => m.provider === "baseten" && m.api === "openai-completions"
174
+ && runtime.getProvider(m.provider) === provider
175
+ && !runtime.getRegisteredNativeProvider(m.provider) && !runtime.getRegisteredProviderConfig(m.provider);
176
+ const allowed = launch.tools!.filter((name) => !launch.excludeTools?.includes(name)).sort();
177
+ const toolsMatch = (): boolean => {
178
+ const tools = session.getAllTools();
179
+ return isDeepStrictEqual(session.getActiveToolNames().sort(), allowed)
180
+ && isDeepStrictEqual(tools.map((tool) => tool.name).sort(), allowed)
181
+ && tools.every((tool) => tool.sourceInfo?.source === "builtin");
182
+ };
183
+ const idle = (): boolean => session.isIdle && !session.isRetrying && !session.agent.hasQueuedMessages()
184
+ && session.agent.state.pendingToolCalls.size === 0 && !session.hasPendingBashMessages;
185
+ const continuity = (): boolean => eligibleLaunch(launch) && toolsMatch() && session.model !== undefined
186
+ && session.model.id === model.id && session.model.provider === model.provider && session.model.api === model.api && supported(session.model);
187
+ const history = () => {
188
+ const disk = readHistory(file);
189
+ const manager = session.sessionManager;
190
+ const leaf = manager.getLeafId();
191
+ if (session.sessionFile !== file || session.sessionId !== header.id || !leaf || leaf !== disk.entries.at(-1)?.id
192
+ || !isDeepStrictEqual(disk.header, header) || !sameStored(disk.header, manager.getHeader())
193
+ || !sameStored(disk.entries, manager.getEntries())) throw new Error("Session checkpoint mismatch");
194
+ // The disk chain and live entries already agree; use the SDK's raw branch walk.
195
+ const messages = activeMessages(manager.getBranch());
196
+ if (!sameStored(messages, session.messages)) throw new Error("Incomplete active context");
197
+ return { ...disk, messages, leaf, completed: completedResults(messages) };
198
+ };
199
+ const startingHistory = () => {
200
+ // SDK defers persistence until its first assistant response. This exception
201
+ // is for the empty source baseline only; settlement always reads strict disk history.
202
+ if (fresh && absent(file)) {
203
+ const manager = session.sessionManager;
204
+ if (session.sessionFile !== file || session.sessionId !== header.id || !sameStored(header, manager.getHeader())
205
+ || session.messages.length
206
+ || manager.getEntries().some((entry) => !["model_change", "thinking_level_change", "session_info", "label"].includes(entry.type))) throw new Error("Fresh session initialization changed");
207
+ return { messages: [] as AgentSession["messages"], completed: 0 };
208
+ }
209
+ return history();
210
+ };
211
+ if (!provider || !eligibleLaunch(launch) || !supported(model) || !toolsMatch() || !idle()) return deny();
212
+ try {
213
+ const restored = startingHistory();
214
+ if (expected && (model.provider !== expected.provider || JSON.stringify(restored.messages) !== expected.contextJson)) throw new Error("Restored continuation context changed");
215
+ } catch (error) {
216
+ if (expected) throw error;
217
+ return undefined;
218
+ }
219
+ let invalid = false;
220
+ let phase: "new" | "running" | "settled" = "new";
221
+ let owner: ChildSession | undefined;
222
+ let baseline = 0;
223
+ let before: AgentSession["messages"] = [];
224
+ let inFlight = 0;
225
+ type Invocation = { status?: number; pending: number; ambiguous: boolean; result?: Awaited<ReturnType<Awaited<ReturnType<typeof session.agent.streamFunction>>["result"]>> };
226
+ let latest: Invocation | undefined;
227
+ const original = session.agent.streamFunction;
228
+ const wrapped: typeof original = async (m, context, options) => {
229
+ const invocation: Invocation = { pending: 0, ambiguous: false };
230
+ latest = invocation;
231
+ if (inFlight++ || phase !== "running" || !eligibleLaunch(launch) || !supported(m) || m.id !== model.id || !toolsMatch()) invalid = true;
232
+ if (expected && (!continuity() || session.agent.streamFunction !== wrapped)) invalid = true;
233
+ // The repository's older agent-core types omit this documented 0.85.1 request option.
234
+ const transport = (options as (typeof options & { fetch?: typeof fetch }))?.fetch ?? globalThis.fetch;
235
+ const observedFetch: typeof fetch = async (...args) => {
236
+ invocation.status = undefined; // Starting even a rejected retry invalidates any earlier 429.
237
+ if (invocation.pending++ || invocation.result) invocation.ambiguous = true;
238
+ try {
239
+ try {
240
+ const [input, init] = args;
241
+ const url = new URL(input instanceof Request ? input.url : String(input));
242
+ // Only the tested completions POST topology. Radius /messages and auxiliary requests deny.
243
+ if (init?.method !== "POST" || !url.pathname.endsWith("/chat/completions") || typeof init.body !== "string") invocation.ambiguous = true;
244
+ } catch { invocation.ambiguous = true; }
245
+ const response = await transport(...args);
246
+ invocation.status = response.status;
247
+ return response;
248
+ } catch (error) {
249
+ invocation.status = undefined;
250
+ throw error;
251
+ } finally { invocation.pending--; }
252
+ };
253
+ try {
254
+ // Guarded continuations must veto known changes before entering configured SDK dispatch.
255
+ if (expected && invalid) throw new Error("Read-only continuation changed before dispatch");
256
+ const stream = await original.call(session.agent, m, context, { ...options, fetch: observedFetch });
257
+ // Observe result without consuming or replacing the event stream.
258
+ void stream.result().then((result) => {
259
+ invocation.result = result;
260
+ inFlight--;
261
+ }, () => { invalid = true; inFlight--; });
262
+ return stream;
263
+ } catch (error) { invalid = true; inFlight--; throw error; }
264
+ };
265
+ session.agent.streamFunction = wrapped;
266
+ return {
267
+ start() {
268
+ if (owner) receipts.delete(owner);
269
+ if (phase !== "new" || !idle()) invalid = true;
270
+ if (expected && (!continuity() || session.agent.streamFunction !== wrapped)) invalid = true;
271
+ phase = "running";
272
+ latest = undefined;
273
+ try {
274
+ const h = startingHistory(); baseline = h.completed; before = h.messages;
275
+ if (expected && JSON.stringify(h.messages) !== expected.contextJson) invalid = true;
276
+ } catch { invalid = true; }
277
+ if (expected && invalid) throw new Error("Read-only continuation changed before prompt");
278
+ },
279
+ settled() { phase = "settled"; },
280
+ invalidate() { invalid = true; if (owner) receipts.delete(owner); },
281
+ beforeShutdown() {
282
+ // Only the identity-certified prompt runtime may finalize owned acknowledgements.
283
+ if (!launch.hooks.length || !continuity()) invalid = true;
284
+ },
285
+ finish(child) {
286
+ owner = child;
287
+ if (!drainSettled()) return;
288
+ if (invalid || child.detached || child.shutDown || phase !== "settled" || inFlight || !eligibleLaunch(launch) || !idle() || !supported(model) || !toolsMatch() || session.agent.streamFunction !== wrapped
289
+ || session.model?.id !== model.id || session.model.provider !== model.provider || session.model.api !== model.api) return;
290
+ const invocation = latest;
291
+ const terminal = invocation?.result;
292
+ if (!invocation || invocation.ambiguous || invocation.pending || invocation.status !== 429 || terminal?.stopReason !== "error"
293
+ || terminal.content.length || terminal.provider !== model.provider || terminal.model !== model.id || terminal.api !== model.api) return;
294
+ try {
295
+ const h = history();
296
+ if (h.completed <= baseline || !isDeepStrictEqual(h.messages.slice(0, before.length), before) || !sameStored(h.messages.at(-1), terminal)) return;
297
+ const receipt: SettledReadonlyEvidence = Object.freeze({ sessionFile: file, sessionId: session.sessionId, leafId: h.leaf,
298
+ provider: model.provider, model: model.id, api: model.api, status: 429, contextJson: JSON.stringify(h.messages),
299
+ completedToolResults: h.completed, fileDigest: digest(h.bytes) });
300
+ providers.set(receipt, provider);
301
+ receipts.set(child, receipt);
302
+ } catch { /* Strict checkpoint failures only deny evidence; never repair storage. */ }
303
+ },
304
+ };
305
+ };
306
+ return { beforeOpen, opened, observe, loadingHooks: (enabled) => observeReadonlyChildHookDrain(launch.hooks, enabled, file) };
307
+ }
@@ -11,14 +11,14 @@ import {
11
11
 
12
12
  const RUN_FANOUT_ROOT = path.join(TEMP_ROOT_DIR, "run-fanout-budgets");
13
13
 
14
- interface ManifestV1 {
14
+ interface FanoutBudgetManifest {
15
15
  version: 1;
16
16
  rootRunId: string;
17
17
  limit: number;
18
18
  createdAt: number;
19
19
  }
20
20
 
21
- interface ClaimV1 {
21
+ interface FanoutBudgetClaim {
22
22
  version: 1;
23
23
  claimId: string;
24
24
  path: string;
@@ -41,16 +41,16 @@ function safeRootRunId(rootRunId: string): string {
41
41
  return rootRunId.replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 120) || randomUUID();
42
42
  }
43
43
 
44
- function parseManifest(value: unknown): ManifestV1 | undefined {
44
+ function parseManifest(value: unknown): FanoutBudgetManifest | undefined {
45
45
  if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
46
- const manifest = value as Partial<ManifestV1>;
46
+ const manifest = value as Partial<FanoutBudgetManifest>;
47
47
  if (manifest.version !== 1 || typeof manifest.rootRunId !== "string" || !manifest.rootRunId
48
48
  || !Number.isInteger(manifest.limit) || (manifest.limit ?? 0) <= 0
49
49
  || typeof manifest.createdAt !== "number" || !Number.isFinite(manifest.createdAt)) return undefined;
50
- return manifest as ManifestV1;
50
+ return manifest as FanoutBudgetManifest;
51
51
  }
52
52
 
53
- function readManifest(directory: string): ManifestV1 {
53
+ function readManifest(directory: string): FanoutBudgetManifest {
54
54
  let parsed: unknown;
55
55
  try {
56
56
  parsed = JSON.parse(fs.readFileSync(path.join(directory, "manifest.json"), "utf-8"));
@@ -84,7 +84,7 @@ export function createRunFanoutBudget(rootRunId: string, limit: number): RunFano
84
84
  do directory = path.join(RUN_FANOUT_ROOT, `${safeRootRunId(rootRunId)}-${randomUUID()}`);
85
85
  while (fs.existsSync(directory));
86
86
  fs.mkdirSync(path.join(directory, "claims"), { recursive: true, mode: 0o700 });
87
- const manifest: ManifestV1 = { version: 1, rootRunId, limit, createdAt: Date.now() };
87
+ const manifest: FanoutBudgetManifest = { version: 1, rootRunId, limit, createdAt: Date.now() };
88
88
  fs.writeFileSync(path.join(directory, "manifest.json"), `${JSON.stringify(manifest)}\n`, { mode: 0o600, flag: "wx" });
89
89
  return { version: 1, rootRunId, directory, limit };
90
90
  }
@@ -230,7 +230,7 @@ function commitRunFanoutBatch<T>(descriptor: RunFanoutBudgetDescriptor, paths: s
230
230
  const fd = fs.openSync(slotPath, "wx", 0o600);
231
231
  created.push(slotPath);
232
232
  try {
233
- const claim: ClaimV1 = { version: 1, claimId: randomUUID(), path: claimPath, claimedAt: Date.now() };
233
+ const claim: FanoutBudgetClaim = { version: 1, claimId: randomUUID(), path: claimPath, claimedAt: Date.now() };
234
234
  fs.writeFileSync(fd, `${JSON.stringify(claim)}\n`, "utf-8");
235
235
  } finally { fs.closeSync(fd); }
236
236
  break;
@@ -1,4 +1,4 @@
1
- import type { RuntimeAcknowledgedChildExtensionsV1 } from "../../shared/types.ts";
1
+ import type { RuntimeAcknowledgedChildExtensions } from "../../shared/types.ts";
2
2
 
3
3
  export const RUNTIME_EXTENSION_ACK_EVENT = "subagent:acknowledge-extension";
4
4
  export const MAX_RUNTIME_ACKNOWLEDGED_EXTENSION_IDS = 32;
@@ -14,7 +14,7 @@ export function isRuntimeAcknowledgedExtensionId(value: unknown): value is strin
14
14
  && !value.includes("\\");
15
15
  }
16
16
 
17
- export function projectRuntimeAcknowledgedExtensions(ids: Iterable<unknown>): RuntimeAcknowledgedChildExtensionsV1 | undefined {
17
+ export function projectRuntimeAcknowledgedExtensions(ids: Iterable<unknown>): RuntimeAcknowledgedChildExtensions | undefined {
18
18
  const unique: string[] = [];
19
19
  const seen = new Set<string>();
20
20
  for (const id of ids) {
@@ -31,7 +31,7 @@ export function projectRuntimeAcknowledgedExtensions(ids: Iterable<unknown>): Ru
31
31
  };
32
32
  }
33
33
 
34
- export function sanitizeRuntimeAcknowledgedExtensions(value: unknown): RuntimeAcknowledgedChildExtensionsV1 | undefined {
34
+ export function sanitizeRuntimeAcknowledgedExtensions(value: unknown): RuntimeAcknowledgedChildExtensions | undefined {
35
35
  if (!value || typeof value !== "object") return undefined;
36
36
  const raw = value as Record<string, unknown>;
37
37
  if (raw.version !== 1 || raw.source !== "child-runtime" || !Array.isArray(raw.ids)) return undefined;
@@ -441,7 +441,12 @@ function registerStructuredOutputTool(pi: ExtensionAPI, structured: NonNullable<
441
441
  }
442
442
 
443
443
  /** Register every child-side hook the prompt runtime owns for one child session. */
444
- export default function registerSubagentPromptRuntime(pi: ExtensionAPI, config: ChildRuntimeConfig): void {
444
+ export default function registerSubagentPromptRuntime(pi: ExtensionAPI, config?: ChildRuntimeConfig, drainObservation?: import("./readonly-drain-observation.ts").ReadonlyDrainObservation): void {
445
+ // A path-based load has no ChildRuntimeConfig. This can happen if an
446
+ // ambient-extension discovery path finds the runtime module in addition to
447
+ // the configured inline factory. It must be inert rather than crashing the
448
+ // child before startup; the inline factory remains the real registration path.
449
+ if (!config) return;
445
450
  registerRuntimeExtensionAcknowledgements(pi, config.runtimeAcknowledgements);
446
451
  registerPermissionGate(pi, config.permissions, config.childWatchdog);
447
452
  registerToolBudget(pi, config.toolBudget);
@@ -481,8 +486,13 @@ export default function registerSubagentPromptRuntime(pi: ExtensionAPI, config:
481
486
  if (diagnostic) throw new Error(formatChildToolDiagnostic(diagnostic));
482
487
  });
483
488
  onRuntimeEvent("agent_end", async (_event: unknown, ctx: unknown) => {
484
- if ((ctx as { hasUI?: boolean } | undefined)?.hasUI === true) return;
485
- await drainOutstandingWork({ state: waitState, events: pi.events });
489
+ if ((ctx as { hasUI?: boolean } | undefined)?.hasUI === true) { drainObservation?.deny(); return; }
490
+ if (drainObservation) {
491
+ try {
492
+ if ((ctx as ExtensionContext)?.sessionManager?.getSessionFile() !== waitState.currentSessionId) drainObservation.deny();
493
+ } catch { drainObservation.deny(); }
494
+ }
495
+ await drainOutstandingWork({ state: waitState, events: pi.events }, drainObservation);
486
496
  });
487
497
  if (config.structuredOutput) registerStructuredOutputTool(pi, config.structuredOutput);
488
498