pi-subagents 0.65.0 → 0.66.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/README.md +1 -1
  3. package/agents/researcher.md +23 -13
  4. package/docs/agents.md +17 -3
  5. package/docs/configuration.md +34 -0
  6. package/docs/extension-api.md +94 -0
  7. package/docs/models.md +58 -1
  8. package/docs/observability.md +42 -2
  9. package/docs/tool-reference.md +11 -5
  10. package/docs/workflows.md +22 -7
  11. package/package.json +4 -1
  12. package/runner-server-preload.mjs +13 -0
  13. package/skills/pi-subagents/SKILL.md +2 -1
  14. package/skills/pi-subagents/references/execution-controls.md +19 -2
  15. package/skills/pi-subagents/references/management-authoring-rpc.md +2 -1
  16. package/skills/pi-subagents/references/multi-lane-orchestration.md +2 -0
  17. package/src/agents/advertised-agent-prompt.ts +63 -0
  18. package/src/agents/agent-management.ts +14 -1
  19. package/src/agents/agent-serializer.ts +2 -0
  20. package/src/agents/agents.ts +8 -0
  21. package/src/api/preflight.ts +5 -1
  22. package/src/api/shared-types.ts +1 -1
  23. package/src/api/workflow-resources.ts +6 -0
  24. package/src/extension/config.ts +4 -2
  25. package/src/extension/index.ts +71 -4
  26. package/src/extension/public-execution.ts +0 -1
  27. package/src/extension/rpc.ts +4 -21
  28. package/src/extension/schemas.ts +9 -7
  29. package/src/extension/tool-description.ts +10 -5
  30. package/src/integrations/pi-web-session-liveness.ts +73 -0
  31. package/src/intercom/native-supervisor-channel.ts +102 -88
  32. package/src/intercom/supervisor-ui.ts +3 -2
  33. package/src/missions/workflow-state.ts +37 -16
  34. package/src/runs/background/active-async-capacity.ts +18 -18
  35. package/src/runs/background/async-execution.ts +8 -1
  36. package/src/runs/background/async-job-tracker.ts +35 -3
  37. package/src/runs/background/async-resume.ts +3 -1
  38. package/src/runs/background/async-retention.ts +9 -0
  39. package/src/runs/background/async-status-snapshot.ts +10 -12
  40. package/src/runs/background/async-status.ts +17 -9
  41. package/src/runs/background/auto-drain.ts +40 -29
  42. package/src/runs/background/chain-root-attachment.ts +8 -0
  43. package/src/runs/background/control-channel.ts +78 -44
  44. package/src/runs/background/notify.ts +88 -12
  45. package/src/runs/background/owned-process-tree.ts +6 -6
  46. package/src/runs/background/process-terminal.ts +23 -23
  47. package/src/runs/background/retained-nested-route-tracker.ts +96 -0
  48. package/src/runs/background/run-child-session.ts +62 -33
  49. package/src/runs/background/run-status.ts +75 -5
  50. package/src/runs/background/runner-aliases.ts +46 -8
  51. package/src/runs/background/runner-child-launch.ts +86 -0
  52. package/src/runs/background/stale-run-reconciler.ts +3 -1
  53. package/src/runs/background/subagent-runner.ts +430 -208
  54. package/src/runs/background/subagent-wait.ts +3 -0
  55. package/src/runs/background/wait-completions.ts +4 -0
  56. package/src/runs/foreground/async-steering-action.ts +19 -0
  57. package/src/runs/foreground/execution.ts +116 -26
  58. package/src/runs/foreground/foreground-history.ts +3 -1
  59. package/src/runs/foreground/prompt-audit.ts +9 -5
  60. package/src/runs/foreground/subagent-executor.ts +531 -227
  61. package/src/runs/foreground/workflow-detach-reconcile.ts +8 -5
  62. package/src/runs/foreground/workflow-foreground-steering.ts +56 -2
  63. package/src/runs/shared/acceptance.ts +16 -3
  64. package/src/runs/shared/agent-contract.ts +1 -1
  65. package/src/runs/shared/async-status-projection.ts +47 -47
  66. package/src/runs/shared/child-hooks.ts +151 -2
  67. package/src/runs/shared/child-launch.ts +18 -13
  68. package/src/runs/shared/child-session.ts +55 -24
  69. package/src/runs/shared/child-tool-plan.ts +2 -2
  70. package/src/runs/shared/completion-evidence.ts +2 -2
  71. package/src/runs/shared/completion-guard.ts +1 -0
  72. package/src/runs/shared/host-step-status.ts +11 -11
  73. package/src/runs/shared/llm-intent-arbiter.ts +30 -20
  74. package/src/runs/shared/model-exclusions.ts +2 -1
  75. package/src/runs/shared/model-fallback.ts +41 -8
  76. package/src/runs/shared/nested-events.ts +8 -8
  77. package/src/runs/shared/orca-progress-tabs.ts +6 -0
  78. package/src/runs/shared/parallel-handoff.ts +57 -12
  79. package/src/runs/shared/parallel-utils.ts +3 -2
  80. package/src/runs/shared/readonly-drain-observation.ts +42 -0
  81. package/src/runs/shared/readonly-model-continuation.ts +69 -0
  82. package/src/runs/shared/readonly-session-evidence.ts +307 -0
  83. package/src/runs/shared/run-fanout-budget.ts +8 -8
  84. package/src/runs/shared/runtime-acknowledged-extensions.ts +3 -3
  85. package/src/runs/shared/subagent-prompt-runtime.ts +13 -3
  86. package/src/runs/shared/worktree-cleanup-plan.ts +6 -3
  87. package/src/runs/shared/worktree-setup-command.ts +190 -0
  88. package/src/runs/shared/worktree.ts +403 -210
  89. package/src/shared/model-response-aliases.ts +13 -0
  90. package/src/shared/types.ts +89 -60
  91. package/src/shared/utils.ts +10 -2
  92. package/src/shared/watch-strategy.ts +2 -0
  93. package/src/shared/workflow-child-permit.ts +18 -13
  94. package/src/tui/fleet-status.ts +1 -1
  95. package/src/tui/fleet.ts +11 -5
  96. package/src/tui/render.ts +44 -15
  97. package/src/workflows/chat-progress.ts +3 -3
  98. package/src/workflows/scripted-workflow.ts +70 -16
  99. package/src/workflows/workflow-checklist.ts +15 -18
  100. package/src/workflows/workflow-child-summary.ts +57 -8
  101. package/src/workflows/workflow-preflight.ts +19 -19
  102. package/src/workflows/workflow-receipt.ts +3 -3
  103. package/src/workflows/workflow-resources.ts +96 -21
  104. package/src/workflows/workflow-settlement.ts +3 -0
@@ -1,8 +1,14 @@
1
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { listBackgroundWorkProviders } from "../../api/background-work.ts";
3
+ import { ReadonlyDrainObservation } from "./readonly-drain-observation.ts";
2
4
  import registerFanoutChildSubagentExtension from "../../extension/fanout-child.ts";
3
5
  import registerSubagentFastModeExtension from "./fast-mode-extension.ts";
4
6
  import registerSubagentPromptRuntime from "./subagent-prompt-runtime.ts";
5
7
  import type { ChildRuntimeConfig } from "./child-runtime-config.ts";
8
+ import type { ChildToolDiagnostic } from "./tool-availability.ts";
9
+ import type { ChildSessionLaunch } from "./child-session.ts";
10
+ import type { ChildTranscriptWriter } from "../../shared/child-transcript.ts";
11
+ import { projectRuntimeAcknowledgedExtensions } from "./runtime-acknowledged-extensions.ts";
6
12
 
7
13
  /** Inline extension shape accepted by pi's resource loader (`extensionFactories`). */
8
14
  export interface ChildHookExtension {
@@ -10,15 +16,158 @@ export interface ChildHookExtension {
10
16
  factory: (pi: ExtensionAPI) => void;
11
17
  }
12
18
 
19
+ type OwnedCapture = Required<Pick<ChildRuntimeConfig, "toolDiagnostic" | "runtimeAcknowledgements">>;
20
+ type PromptProof = { config: ChildRuntimeConfig; snapshot: string; factories?: ChildHookExtension["factory"][]; observeNext?: ReadonlyDrainObservation; observation?: ReadonlyDrainObservation; capture?: OwnedCapture; reporting?: { launch: ChildSessionLaunch; callback: ChildSessionLaunch["onExtensionError"] } };
21
+ const promptProofs = new WeakMap<ChildHookExtension["factory"], PromptProof>();
22
+
23
+ function ownedProof(hooks: ChildHookExtension[]): PromptProof | undefined {
24
+ const proof = hooks[0] && promptProofs.get(hooks[0].factory);
25
+ return proof?.factories?.length === hooks.length && proof.factories.every((factory, index) => hooks[index]?.factory === factory) ? proof : undefined;
26
+ }
27
+
28
+ /** The host's mandatory transcript reporting, owned by the existing hook certificate. */
29
+ export function withChildSessionErrorReporting(session: Omit<ChildSessionLaunch, "onExtensionError">, transcriptWriter: ChildTranscriptWriter | undefined): ChildSessionLaunch {
30
+ const launch: ChildSessionLaunch = { ...session, onExtensionError: (error) => {
31
+ transcriptWriter?.writeStderrLine(`Extension error (${error.extensionPath}, ${error.event}): ${error.error instanceof Error ? error.error.message : String(error.error)}`);
32
+ } };
33
+ const proof = ownedProof(launch.hooks);
34
+ if (proof?.capture && proof.config === launch.runtime) proof.reporting = { launch, callback: launch.onExtensionError };
35
+ return launch;
36
+ }
37
+
38
+ export function isReadonlyChildSessionReporting(launch: ChildSessionLaunch): boolean {
39
+ const proof = ownedProof(launch.hooks);
40
+ const descriptor = Object.getOwnPropertyDescriptor(launch, "onExtensionError");
41
+ if (descriptor && (!descriptor.enumerable || !("value" in descriptor))) return false;
42
+ return proof?.reporting ? proof.reporting.launch === launch && proof.reporting.callback === descriptor?.value : !descriptor && !("onExtensionError" in launch);
43
+ }
44
+
45
+ /** Admit only own enumerable data: inspecting descriptors must not invoke getters. */
46
+ function dataKeys(value: unknown): string[] | undefined {
47
+ if (!value || typeof value !== "object" || Object.getPrototypeOf(value) !== Object.prototype) return undefined;
48
+ const keys = Reflect.ownKeys(value);
49
+ for (const key of keys) {
50
+ const descriptor = Object.getOwnPropertyDescriptor(value, key)!;
51
+ if (typeof key !== "string" || !descriptor.enumerable || !("value" in descriptor)) return undefined;
52
+ }
53
+ return keys as string[];
54
+ }
55
+
56
+ /** Only reviewed data and the capture callbacks constructed below; no caller callback opt-in. */
57
+ function readonlyConfig(config: ChildRuntimeConfig, capture?: OwnedCapture): string | undefined {
58
+ const strings = ["runId", "agent", "sessionName", "forkCacheKey", "parentSessionId", "orchestratorSessionId"];
59
+ const numbers = ["childIndex", "depth", "maxDepth"];
60
+ const booleans = ["inheritProjectContext", "inheritGlobalContext", "inheritSkills"];
61
+ const keys = dataKeys(config);
62
+ if (!keys) return undefined;
63
+ if (capture && (config.toolDiagnostic !== capture.toolDiagnostic || config.runtimeAcknowledgements !== capture.runtimeAcknowledgements)) return undefined;
64
+ const waitKeys = dataKeys(config.waitTool);
65
+ if (!waitKeys || waitKeys.some((key) => key !== "enabled")) return undefined;
66
+ if (config.fast !== false || config.fanoutChild !== false || config.waitTool.enabled !== false) return undefined;
67
+ for (const key of keys) {
68
+ const value = Object.getOwnPropertyDescriptor(config, key)!.value;
69
+ if (["fast", "fanoutChild", "waitTool"].includes(key)) continue;
70
+ if (capture && (key === "toolDiagnostic" || key === "runtimeAcknowledgements")) continue;
71
+ if (capture && key === "requiredTools" && Array.isArray(value) && Object.getPrototypeOf(value) === Array.prototype) {
72
+ const descriptors = Object.getOwnPropertyDescriptors(value);
73
+ if (Reflect.ownKeys(descriptors).length !== value.length + 1) return undefined;
74
+ for (let i = 0; i < value.length; i++) {
75
+ const descriptor = descriptors[String(i)];
76
+ if (!descriptor?.enumerable || !("value" in descriptor) || !["read", "ls"].includes(descriptor.value)) return undefined;
77
+ }
78
+ continue;
79
+ }
80
+ if (strings.includes(key) && (value === undefined || typeof value === "string")) continue;
81
+ if (numbers.includes(key) && (value === undefined || (typeof value === "number" && Number.isInteger(value) && value >= 0))) continue;
82
+ if (booleans.includes(key) && (value === undefined || typeof value === "boolean")) continue;
83
+ return undefined;
84
+ }
85
+ return JSON.stringify(config);
86
+ }
87
+
88
+ function noBackgroundProviders(): boolean {
89
+ try {
90
+ return listBackgroundWorkProviders().length === 0;
91
+ } catch { return false; }
92
+ }
93
+
94
+ /** Internal proof of the captured closure/config, not its caller-controlled display name. */
95
+ export function isReadonlyChildHookProfile(hooks: ChildHookExtension[], config: ChildRuntimeConfig): boolean {
96
+ const proof = ownedProof(hooks);
97
+ if (!proof) return false;
98
+ const valid = proof.config === config && readonlyConfig(config, proof.capture) === proof.snapshot && noBackgroundProviders();
99
+ if (!valid) proof.observation?.deny();
100
+ return valid && (proof.observation?.check() ?? true);
101
+ }
102
+
103
+ /** Arm just the next installation; ordinary installations retain the original API/handlers. */
104
+ export function observeReadonlyChildHookDrain(hooks: ChildHookExtension[], enabled: boolean, sessionFile: string): void {
105
+ const proof = ownedProof(hooks);
106
+ if (!proof) return;
107
+ if (enabled) {
108
+ proof.observation?.deny();
109
+ proof.observation = undefined;
110
+ }
111
+ proof.observeNext = enabled ? new ReadonlyDrainObservation(sessionFile, () => readonlyConfig(proof.config, proof.capture) === proof.snapshot && noBackgroundProviders()) : undefined;
112
+ }
113
+
114
+ export function captureReadonlyChildDrain(hooks: ChildHookExtension[]): (() => boolean) {
115
+ const proof = ownedProof(hooks);
116
+ const observation = proof?.observation;
117
+ return () => !!observation && proof?.observation === observation && observation.settled();
118
+ }
119
+
13
120
  /**
14
121
  * The child-side hooks pi-subagents installs in every child, keyed off the
15
122
  * launch config. The registrations live in `subagent-prompt-runtime.ts`,
16
123
  * `fast-mode-extension.ts`, and `fanout-child.ts`.
17
124
  */
18
125
  export function createChildHooks(config: ChildRuntimeConfig): ChildHookExtension[] {
126
+ return childHooks(config);
127
+ }
128
+
129
+ /** Launch-owned bookkeeping, paired with the same private hook certificate (no callback registration API). */
130
+ export function createCapturedChildHooks(config: ChildRuntimeConfig, runner = false) {
131
+ let diagnostic: ChildToolDiagnostic | undefined;
132
+ let acknowledgedIds: string[] | undefined;
133
+ let completionIntentContext: Pick<ExtensionContext, "model" | "modelRegistry"> | undefined;
134
+ const capture: OwnedCapture = {
135
+ toolDiagnostic: (value) => { diagnostic = value; },
136
+ runtimeAcknowledgements: (ids) => { acknowledgedIds = ids; },
137
+ };
138
+ Object.assign(config, capture);
139
+ const hooks = childHooks(config, capture);
140
+ if (runner) {
141
+ hooks.push({ name: "pi-subagents:completion-intent", factory: (pi) => pi.on("session_start", (_event, childCtx) => {
142
+ // Retain only attempt model services, not the live child session.
143
+ completionIntentContext = { model: childCtx.model, modelRegistry: childCtx.modelRegistry };
144
+ }) });
145
+ const proof = promptProofs.get(hooks[0]!.factory);
146
+ if (proof) proof.factories = hooks.map((hook) => hook.factory);
147
+ }
148
+ return {
149
+ hooks,
150
+ completionIntentContext: () => completionIntentContext,
151
+ toolDiagnostic: () => diagnostic,
152
+ runtimeAcknowledgedExtensions: () => acknowledgedIds ? projectRuntimeAcknowledgedExtensions(acknowledgedIds) : undefined,
153
+ };
154
+ }
155
+
156
+ function childHooks(config: ChildRuntimeConfig, capture?: OwnedCapture): ChildHookExtension[] {
157
+ const snapshot = readonlyConfig(config, capture);
158
+ const proof: PromptProof | undefined = snapshot === undefined ? undefined : { config, snapshot, capture };
19
159
  const hooks: ChildHookExtension[] = [
20
- { name: "pi-subagents:prompt-runtime", factory: (pi) => registerSubagentPromptRuntime(pi, config) },
160
+ { name: "pi-subagents:prompt-runtime", factory: function promptRuntime(pi) {
161
+ if (!proof?.observeNext) return registerSubagentPromptRuntime(pi, config);
162
+ proof.observation = proof.observeNext;
163
+ proof.observeNext = undefined;
164
+ registerSubagentPromptRuntime(pi, config, proof.observation);
165
+ } },
21
166
  ];
167
+ if (proof) {
168
+ proof.factories = hooks.map((hook) => hook.factory);
169
+ promptProofs.set(hooks[0]!.factory, proof);
170
+ }
22
171
  if (config.fast) hooks.push({ name: "pi-subagents:fast-mode", factory: (pi) => registerSubagentFastModeExtension(pi) });
23
172
  if (config.fanoutChild) hooks.push({ name: "pi-subagents:fanout-child", factory: (pi) => registerFanoutChildSubagentExtension(pi, config) });
24
173
  return hooks;
@@ -6,12 +6,13 @@
6
6
  */
7
7
  import * as fs from "node:fs";
8
8
  import * as path from "node:path";
9
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
9
10
  import type { ChildWatchdogConfig, ChildWatchdogStatusEvent } from "../../watchdog/child-status.ts";
10
11
  import type { ThinkingLevel } from "../../shared/model-info.ts";
11
12
  import { intersectThinkingCeilings } from "../../shared/thinking-ceiling.ts";
12
13
  import {
13
14
  resolveChildDepth,
14
- type LaunchResolvedChildExtensionsV1,
15
+ type LaunchResolvedChildExtensions,
15
16
  type ResolvedToolBudget,
16
17
  type RunFanoutBudgetDescriptor,
17
18
  } from "../../shared/types.ts";
@@ -20,8 +21,7 @@ import type { McpRuntimeSnapshotHost } from "./mcp-direct-tool-allowlist.ts";
20
21
  import type { PermissionRules } from "./permissions.ts";
21
22
  import type { StructuredOutputRuntime } from "./structured-output.ts";
22
23
  import type { ChildToolDiagnostic } from "./tool-availability.ts";
23
- import type { RuntimeAcknowledgedChildExtensionsV1 } from "../../shared/types.ts";
24
- import { projectRuntimeAcknowledgedExtensions } from "./runtime-acknowledged-extensions.ts";
24
+ import type { RuntimeAcknowledgedChildExtensions } from "../../shared/types.ts";
25
25
  import { encodeExtensionBindings, PI_SUBAGENT_EXTENSION_BINDINGS_ENV, type ExtensionBindings } from "./extension-bindings.ts";
26
26
  import type { ResolvedSubagentCapabilityCeiling, SubagentCapabilityAudit } from "./capability-ceiling.ts";
27
27
  import {
@@ -32,7 +32,8 @@ import {
32
32
  type PiLaunchToolPlan,
33
33
  } from "./child-tool-plan.ts";
34
34
  import type { ChildRuntimeConfig } from "./child-runtime-config.ts";
35
- import { createChildHooks } from "./child-hooks.ts";
35
+ import { createCapturedChildHooks, withChildSessionErrorReporting } from "./child-hooks.ts";
36
+ import type { ChildTranscriptWriter } from "../../shared/child-transcript.ts";
36
37
  import type { ChildSessionLaunch, ChildSessionStorage } from "./child-session.ts";
37
38
 
38
39
  /** Environment variable pi-mcp-adapter reads for the tools a child may expose. */
@@ -114,9 +115,10 @@ export interface BuildInProcessChildLaunchInput {
114
115
  }
115
116
 
116
117
  export interface InProcessChildCapture {
118
+ completionIntentContext?(): Pick<ExtensionContext, "model" | "modelRegistry"> | undefined;
117
119
  structuredOutput(): { called: boolean; value?: unknown; acceptanceReport?: unknown; acceptanceReportProvided: boolean };
118
120
  toolDiagnostic(): ChildToolDiagnostic | undefined;
119
- runtimeAcknowledgedExtensions(): RuntimeAcknowledgedChildExtensionsV1 | undefined;
121
+ runtimeAcknowledgedExtensions(): RuntimeAcknowledgedChildExtensions | undefined;
120
122
  }
121
123
 
122
124
  export interface InProcessChildLaunch {
@@ -124,11 +126,16 @@ export interface InProcessChildLaunch {
124
126
  config: ChildRuntimeConfig;
125
127
  session: Omit<ChildSessionLaunch, "onExtensionError">;
126
128
  capture: InProcessChildCapture;
127
- launchResolvedExtensions: LaunchResolvedChildExtensionsV1;
129
+ launchResolvedExtensions: LaunchResolvedChildExtensions;
128
130
  warnings: string[];
129
131
  capabilityAudit?: SubagentCapabilityAudit;
130
132
  }
131
133
 
134
+ /** Actual host create-input boundary. Evidence remains explicitly opt-in and dormant in production. */
135
+ export function createReportedChildSessionInput(launch: InProcessChildLaunch, transcriptWriter?: ChildTranscriptWriter): ChildSessionLaunch {
136
+ return withChildSessionErrorReporting(launch.session, transcriptWriter);
137
+ }
138
+
132
139
  /** Escape XML-significant characters in a string for safe attribute interpolation. */
133
140
  function escapeXmlAttr(value: string): string {
134
141
  return value
@@ -214,8 +221,6 @@ export function buildInProcessChildLaunch(input: BuildInProcessChildLaunchInput)
214
221
  let structuredAcceptanceReport: unknown;
215
222
  let structuredCalled = false;
216
223
  let structuredAcceptanceProvided = false;
217
- let toolDiagnostic: ChildToolDiagnostic | undefined;
218
- let acknowledgedIds: string[] | undefined;
219
224
 
220
225
  const config: ChildRuntimeConfig = {
221
226
  ...(input.runId ? { runId: input.runId } : {}),
@@ -264,10 +269,9 @@ export function buildInProcessChildLaunch(input: BuildInProcessChildLaunchInput)
264
269
  : {}),
265
270
  ...(toolPlan.requiredChildTools.length > 0 ? { requiredTools: toolPlan.requiredChildTools } : {}),
266
271
  ...(toolPlan.effectiveMcpTools.length > 0 ? { mcpDirectTools: toolPlan.effectiveMcpTools } : {}),
267
- toolDiagnostic: (diagnostic) => { toolDiagnostic = diagnostic; },
268
- runtimeAcknowledgements: (ids) => { acknowledgedIds = ids; },
269
272
  fast: input.fast === true,
270
273
  };
274
+ const capturedHooks = createCapturedChildHooks(config, input.host === "runner");
271
275
 
272
276
  const extensionPaths = toolPlan.extensionArgs.filter((extensionPath) => !isSubagentRuntimeExtensionPath(extensionPath));
273
277
  const ambientExtensions = input.host === "runner" && !toolPlan.disableAmbientExtensions;
@@ -288,7 +292,7 @@ export function buildInProcessChildLaunch(input: BuildInProcessChildLaunchInput)
288
292
  ...(!toolPlan.explicitToolAllowlist && toolPlan.excludeTools.length > 0 ? { excludeTools: toolPlan.excludeTools } : {}),
289
293
  extensionPaths,
290
294
  ambientExtensions,
291
- hooks: createChildHooks(config),
295
+ hooks: capturedHooks.hooks,
292
296
  ...(input.host === "runner" ? { processEnv: childProcessEnv(input, toolPlan) } : {}),
293
297
  runtime: config,
294
298
  noSkills: !input.inheritSkills,
@@ -303,9 +307,10 @@ export function buildInProcessChildLaunch(input: BuildInProcessChildLaunchInput)
303
307
  config,
304
308
  session,
305
309
  capture: {
310
+ completionIntentContext: capturedHooks.completionIntentContext,
306
311
  structuredOutput: () => ({ called: structuredCalled, value: structuredValue, acceptanceReport: structuredAcceptanceReport, acceptanceReportProvided: structuredAcceptanceProvided }),
307
- toolDiagnostic: () => toolDiagnostic,
308
- runtimeAcknowledgedExtensions: () => (acknowledgedIds ? projectRuntimeAcknowledgedExtensions(acknowledgedIds) : undefined),
312
+ toolDiagnostic: capturedHooks.toolDiagnostic,
313
+ runtimeAcknowledgedExtensions: capturedHooks.runtimeAcknowledgedExtensions,
309
314
  },
310
315
  launchResolvedExtensions,
311
316
  warnings: toolPlan.warnings,
@@ -12,6 +12,14 @@ import type { AgentMessage } from "@earendil-works/pi-agent-core";
12
12
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
13
13
  import { getAgentDir } from "../../shared/utils.ts";
14
14
  import type { ChildRuntimeConfig } from "./child-runtime-config.ts";
15
+ import { prepareReadonlySessionEvidence } from "./readonly-session-evidence.ts";
16
+ import { toModelInfo, type ModelInfo } from "../../shared/model-info.ts";
17
+
18
+ // Private runtime authority for host continuation planning; injected factories have none.
19
+ const readonlyModels = new WeakMap<ChildSession, { current: ModelInfo; resolve(reference: string): ModelInfo | undefined; requestBytes: number }>();
20
+ export function getReadonlyChildModels(child: ChildSession) {
21
+ return readonlyModels.get(child);
22
+ }
15
23
 
16
24
  export interface ChildSessionEvent {
17
25
  type: string;
@@ -115,17 +123,6 @@ export interface DefaultChildSessionFactoryOptions {
115
123
  }
116
124
 
117
125
  type ModelRuntimeInstance = Awaited<ReturnType<PiCodingAgentModule["ModelRuntime"]["create"]>>;
118
- type QueuedProviderRegistration = { name: string; config: Parameters<ModelRuntimeInstance["registerProvider"]>[1]; extensionPath: string };
119
- type QueuedNativeProviderRegistration = { provider: Parameters<ModelRuntimeInstance["registerNativeProvider"]>[0]; extensionPath: string };
120
-
121
- interface LoaderWithExtensions {
122
- getExtensions(): {
123
- runtime: {
124
- pendingProviderRegistrations: QueuedProviderRegistration[];
125
- pendingNativeProviderRegistrations: QueuedNativeProviderRegistration[];
126
- };
127
- };
128
- }
129
126
 
130
127
  /** One launch at a time from env application through `session_start`, so parallel launches never observe each other's `processEnv` while their extensions load and start. */
131
128
  let loading: Promise<unknown> = Promise.resolve();
@@ -151,25 +148,29 @@ function applyProcessEnv(values: Record<string, string | undefined> | undefined)
151
148
  }
152
149
  }
153
150
 
154
- function flushQueuedProviderRegistrations(loader: object, modelRuntime: ModelRuntimeInstance, onError: ((error: ChildSessionExtensionError) => void) | undefined): void {
151
+ async function flushQueuedProviderRegistrations(loader: InstanceType<PiCodingAgentModule["DefaultResourceLoader"]>, modelRuntime: ModelRuntimeInstance, onError: ((error: ChildSessionExtensionError) => void) | undefined): Promise<void> {
155
152
  if (!("getExtensions" in loader) || typeof loader.getExtensions !== "function") return;
156
- const { runtime } = (loader as LoaderWithExtensions).getExtensions();
157
- for (const { name, config, extensionPath } of runtime.pendingProviderRegistrations) {
153
+ const { runtime } = loader.getExtensions();
154
+ let registered = false;
155
+ for (const { name, config, extensionPath } of runtime.pendingProviderRegistrations ?? []) {
158
156
  try {
159
157
  modelRuntime.registerProvider(name, config);
158
+ registered = true;
160
159
  } catch (error) {
161
160
  onError?.({ extensionPath, event: "register_provider", error });
162
161
  }
163
162
  }
164
- runtime.pendingProviderRegistrations = [];
165
- for (const { provider, extensionPath } of runtime.pendingNativeProviderRegistrations) {
163
+ if (Array.isArray(runtime.pendingProviderRegistrations)) runtime.pendingProviderRegistrations = [];
164
+ for (const { provider, extensionPath } of runtime.pendingNativeProviderRegistrations ?? []) {
166
165
  try {
167
166
  modelRuntime.registerNativeProvider(provider);
167
+ registered = true;
168
168
  } catch (error) {
169
169
  onError?.({ extensionPath, event: "register_provider", error });
170
170
  }
171
171
  }
172
- runtime.pendingNativeProviderRegistrations = [];
172
+ if (Array.isArray(runtime.pendingNativeProviderRegistrations)) runtime.pendingNativeProviderRegistrations = [];
173
+ if (registered) await modelRuntime.refresh({ allowNetwork: false });
173
174
  }
174
175
 
175
176
  /**
@@ -192,10 +193,13 @@ export function createDefaultChildSessionFactory(options: DefaultChildSessionFac
192
193
  };
193
194
  return {
194
195
  async create(launch) {
196
+ const observeReadonly = prepareReadonlySessionEvidence(launch);
195
197
  const pi = await loadPiCodingAgent();
196
198
  const modelRuntime = await sharedRuntime(pi);
197
199
  const agentDir = getAgentDir();
198
200
  const settingsManager = pi.SettingsManager.create(launch.cwd, agentDir);
201
+ // Headless sessions skip Pi's CLI theme setup; extensions still need ctx.ui.theme.
202
+ if (typeof pi.initTheme === "function") pi.initTheme(settingsManager.getTheme());
199
203
  const loader = new pi.DefaultResourceLoader({
200
204
  cwd: launch.cwd,
201
205
  agentDir,
@@ -213,8 +217,11 @@ export function createDefaultChildSessionFactory(options: DefaultChildSessionFac
213
217
  const open = async () => {
214
218
  applyProcessEnv(launch.processEnv);
215
219
  if (!resetExtensionCacheOnReload(loader) && (launch.ambientExtensions || launch.extensionPaths.length)) launch.onExtensionError?.({ extensionPath: "<loader>", event: "load", error: new Error("pi's extension cache reset is unavailable; extensions loaded into this child share module state with other sessions in this process.") });
216
- await loader.reload();
217
- flushQueuedProviderRegistrations(loader, modelRuntime, launch.onExtensionError);
220
+ observeReadonly?.loadingHooks(true);
221
+ try { await loader.reload(); } finally { observeReadonly?.loadingHooks(false); }
222
+ await flushQueuedProviderRegistrations(loader, modelRuntime, launch.onExtensionError);
223
+ // No await between receipt validation and the SDK's permissive file open.
224
+ observeReadonly?.beforeOpen();
218
225
  const sessionManager = launch.storage.kind === "file"
219
226
  ? pi.SessionManager.open(launch.storage.sessionFile, undefined, launch.cwd)
220
227
  : launch.storage.kind === "dir"
@@ -222,6 +229,7 @@ export function createDefaultChildSessionFactory(options: DefaultChildSessionFac
222
229
  : launch.storage.kind === "memory"
223
230
  ? pi.SessionManager.inMemory(launch.cwd)
224
231
  : pi.SessionManager.create(launch.cwd);
232
+ observeReadonly?.opened(sessionManager);
225
233
  const resolvedModel = launch.model
226
234
  ? pi.resolveCliModel({ cliModel: launch.model, modelRuntime })
227
235
  : undefined;
@@ -253,6 +261,9 @@ export function createDefaultChildSessionFactory(options: DefaultChildSessionFac
253
261
  const opened = loading.catch(() => {}).then(open);
254
262
  loading = opened;
255
263
  const session = await opened;
264
+ let evidence: ReturnType<NonNullable<typeof observeReadonly>["observe"]>;
265
+ try { evidence = observeReadonly?.observe(pi, modelRuntime, session); }
266
+ catch (error) { session.dispose(); throw error; }
256
267
  let pending: Promise<void> | undefined;
257
268
  // pi's own hosts emit `session_shutdown` before disposing a session so the
258
269
  // extensions loaded into it (ambient extensions included) release their
@@ -260,19 +271,29 @@ export function createDefaultChildSessionFactory(options: DefaultChildSessionFac
260
271
  const shutdown = async (): Promise<void> => {
261
272
  try {
262
273
  const runner = session.extensionRunner;
263
- if (runner.hasHandlers("session_shutdown")) await Promise.race([runner.emit({ type: "session_shutdown", reason: "quit" }), new Promise((resolve) => setTimeout(resolve, shutdownTimeoutMs).unref?.())]);
274
+ if (runner.hasHandlers("session_shutdown")) {
275
+ evidence?.beforeShutdown();
276
+ const settled = await Promise.race([runner.emit({ type: "session_shutdown", reason: "quit" }).then(() => true), new Promise<false>((resolve) => setTimeout(() => resolve(false), shutdownTimeoutMs).unref?.())]);
277
+ if (!settled) evidence?.invalidate();
278
+ }
264
279
  } catch (error) {
280
+ evidence?.invalidate();
265
281
  launch.onExtensionError?.({ extensionPath: "<session>", event: "session_shutdown", error });
266
282
  } finally {
267
283
  session.dispose();
284
+ evidence?.finish(child);
268
285
  }
269
286
  };
270
287
  const child: ChildSession = {
271
288
  subscribe: (listener) => session.subscribe((event) => listener(event as unknown as ChildSessionEvent)),
272
- prompt: (text) => session.prompt(text),
273
- steer: (text) => session.steer(text),
274
- followUp: (text) => session.followUp(text),
275
- abort: () => session.abort(),
289
+ prompt: (text) => {
290
+ if (!evidence) return session.prompt(text);
291
+ try { evidence.start(); } catch (error) { return Promise.reject(error); }
292
+ return session.prompt(text).then(() => evidence?.settled(), (error) => { evidence?.invalidate(); throw error; });
293
+ },
294
+ steer: (text) => { evidence?.invalidate(); return session.steer(text); },
295
+ followUp: (text) => { evidence?.invalidate(); return session.followUp(text); },
296
+ abort: () => { evidence?.invalidate(); return session.abort(); },
276
297
  dispose: () => {
277
298
  if (!pending) {
278
299
  live.delete(child);
@@ -288,6 +309,16 @@ export function createDefaultChildSessionFactory(options: DefaultChildSessionFac
288
309
  get sessionId() { return session.sessionId; },
289
310
  get modelId() { return session.model ? `${session.model.provider}/${session.model.id}` : undefined; },
290
311
  };
312
+ if (evidence && session.model) readonlyModels.set(child, {
313
+ current: toModelInfo(session.model),
314
+ requestBytes: Buffer.byteLength(session.systemPrompt) + Buffer.byteLength(JSON.stringify(session.agent.state.tools)),
315
+ resolve(reference) {
316
+ try {
317
+ const resolved = pi.resolveCliModel({ cliModel: reference, modelRuntime });
318
+ return !resolved.error && resolved.model ? toModelInfo(resolved.model) : undefined;
319
+ } catch { return undefined; }
320
+ },
321
+ });
291
322
  live.add(child);
292
323
  return child;
293
324
  },
@@ -16,7 +16,7 @@ import {
16
16
  import {
17
17
  TEMP_ROOT_DIR,
18
18
  type JsonSchemaObject,
19
- type LaunchResolvedChildExtensionsV1,
19
+ type LaunchResolvedChildExtensions,
20
20
  } from "../../shared/types.ts";
21
21
  import { THINKING_LEVELS } from "../../shared/model-info.ts";
22
22
  import { getAgentDir } from "../../shared/utils.ts";
@@ -214,7 +214,7 @@ export function projectLaunchResolvedChildExtensions(
214
214
  | "extensionArgs"
215
215
  | "disableAmbientExtensions"
216
216
  >,
217
- ): LaunchResolvedChildExtensionsV1 {
217
+ ): LaunchResolvedChildExtensions {
218
218
  const runtime = boundedExtensionIdentifiers(toolPlan.runtimeExtensions);
219
219
  const configured = boundedExtensionIdentifiers(toolPlan.configuredExtensions);
220
220
  const effective = boundedExtensionIdentifiers(toolPlan.extensionArgs);
@@ -26,7 +26,7 @@ export function planCompletionEvidence(input: {
26
26
  mutationAttemptObserved: boolean;
27
27
  mutationEvidence?: TrackedMutationEvidence;
28
28
  arbiterRescued?: boolean;
29
- agentContractV1: boolean;
29
+ agentContractEnabled: boolean;
30
30
  }): CompletionEvidencePlan {
31
31
  const guardBlocked = input.guard?.blocked === true;
32
32
  const guardTriggered = input.guardTriggered
@@ -59,7 +59,7 @@ export function planCompletionEvidence(input: {
59
59
  mutationExpected,
60
60
  mutationAttempted,
61
61
  fileMutation,
62
- legacyFailureError: guardTriggered && !input.agentContractV1
62
+ legacyFailureError: guardTriggered && !input.agentContractEnabled
63
63
  ? MISSING_IMPLEMENTATION_MUTATION_ERROR
64
64
  : undefined,
65
65
  };
@@ -13,6 +13,7 @@ const READ_ONLY_BUILTIN_TOOLS = new Set([
13
13
  "web_search",
14
14
  "fetch_content",
15
15
  "get_search_content",
16
+ "source_check",
16
17
  "intercom",
17
18
  "contact_supervisor",
18
19
  "structured_output",
@@ -1,6 +1,6 @@
1
1
  import type {
2
2
  AsyncStatus,
3
- HostStepNodeV1,
3
+ HostStepNode,
4
4
  HostStepState,
5
5
  HostStepVerdict,
6
6
  WorkflowGraphNode,
@@ -57,7 +57,7 @@ function assertTimestamp(value: unknown, field: string, source: string, required
57
57
  if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) throw new Error(`Invalid host step '${source}': ${field} must be a non-negative safe integer.`);
58
58
  }
59
59
 
60
- function assertFreshness(value: unknown, source: string): asserts value is HostStepNodeV1["freshness"] {
60
+ function assertFreshness(value: unknown, source: string): asserts value is HostStepNode["freshness"] {
61
61
  if (!isRecord(value)) throw new Error(`Invalid host step '${source}': freshness must be an object.`);
62
62
  const unknownFields = Object.keys(value).filter((field) => !FRESHNESS_FIELDS.has(field));
63
63
  if (unknownFields.length > 0) throw new Error(`Invalid host step '${source}': freshness has unsupported fields: ${unknownFields.join(", ")}.`);
@@ -70,7 +70,7 @@ function assertFreshness(value: unknown, source: string): asserts value is HostS
70
70
  * Validate the persisted host-step contract. This deliberately rejects unknown
71
71
  * fields and unbounded values so status and receipt loaders fail closed.
72
72
  */
73
- export function assertHostStepNode(value: unknown, source = "status"): asserts value is HostStepNodeV1 {
73
+ export function assertHostStepNode(value: unknown, source = "status"): asserts value is HostStepNode {
74
74
  if (!isRecord(value)) throw new Error(`Invalid host step '${source}': expected an object.`);
75
75
  const unknownFields = Object.keys(value).filter((field) => !HOST_STEP_FIELDS.has(field));
76
76
  if (unknownFields.length > 0) throw new Error(`Invalid host step '${source}': unsupported fields: ${unknownFields.join(", ")}.`);
@@ -105,7 +105,7 @@ export function assertHostStepNode(value: unknown, source = "status"): asserts v
105
105
  assertTimestamp(value.deadlineAt, "deadlineAt", source);
106
106
  }
107
107
 
108
- export function parseHostStepNode(value: unknown, source = "status"): HostStepNodeV1 {
108
+ export function parseHostStepNode(value: unknown, source = "status"): HostStepNode {
109
109
  assertHostStepNode(value, source);
110
110
  return {
111
111
  ...value,
@@ -113,7 +113,7 @@ export function parseHostStepNode(value: unknown, source = "status"): HostStepNo
113
113
  };
114
114
  }
115
115
 
116
- export function assertUniqueHostStepIds(hostSteps: readonly HostStepNodeV1[], source = "status"): void {
116
+ export function assertUniqueHostStepIds(hostSteps: readonly HostStepNode[], source = "status"): void {
117
117
  const ids = new Set<string>();
118
118
  for (const hostStep of hostSteps) {
119
119
  if (ids.has(hostStep.id)) throw new Error(`Invalid host step '${source}': duplicate host step id '${hostStep.id}'.`);
@@ -122,11 +122,11 @@ export function assertUniqueHostStepIds(hostSteps: readonly HostStepNodeV1[], so
122
122
  }
123
123
 
124
124
  /** Return only valid host nodes so an untrusted in-memory projection fails closed. */
125
- export function validHostStepNodes(graph: WorkflowGraphSnapshot | undefined): HostStepNodeV1[] {
125
+ export function validHostStepNodes(graph: WorkflowGraphSnapshot | undefined): HostStepNode[] {
126
126
  const nodes = graph?.nodes ?? [];
127
127
  const nodeIdCounts = new Map<string, number>();
128
128
  for (const node of nodes) nodeIdCounts.set(node.id, (nodeIdCounts.get(node.id) ?? 0) + 1);
129
- const hostSteps: HostStepNodeV1[] = [];
129
+ const hostSteps: HostStepNode[] = [];
130
130
  for (const [index, node] of nodes.entries()) {
131
131
  if (node.kind !== "host-step") continue;
132
132
  try {
@@ -147,7 +147,7 @@ export function assertWorkflowGraphHostSteps(graph: WorkflowGraphSnapshot | unde
147
147
  if (!Array.isArray(graph.nodes)) throw new Error(`Invalid host step '${source}.workflowGraph': nodes must be an array.`);
148
148
  const hostStepCount = graph.nodes.filter((node) => node.kind === "host-step").length;
149
149
  if (hostStepCount > HOST_STEP_MAX_COUNT) throw new Error(`Invalid host step '${source}': workflowGraph contains more than ${HOST_STEP_MAX_COUNT} host steps.`);
150
- const hostSteps: HostStepNodeV1[] = [];
150
+ const hostSteps: HostStepNode[] = [];
151
151
  for (const [index, node] of graph.nodes.entries()) {
152
152
  if (node.kind !== "host-step") continue;
153
153
  const hostStep = parseHostStepNode(node.hostStep, `${source}.workflowGraph.nodes[${index}].hostStep`);
@@ -161,7 +161,7 @@ export function assertWorkflowGraphHostSteps(graph: WorkflowGraphSnapshot | unde
161
161
  }
162
162
  }
163
163
 
164
- export function hostStepWorkflowNode(hostStep: HostStepNodeV1): WorkflowGraphNode {
164
+ export function hostStepWorkflowNode(hostStep: HostStepNode): WorkflowGraphNode {
165
165
  assertHostStepNode(hostStep, hostStep.id);
166
166
  return {
167
167
  id: hostStep.id,
@@ -172,7 +172,7 @@ export function hostStepWorkflowNode(hostStep: HostStepNodeV1): WorkflowGraphNod
172
172
  };
173
173
  }
174
174
 
175
- function workflowNodeStatus(hostStep: HostStepNodeV1): WorkflowNodeStatus {
175
+ function workflowNodeStatus(hostStep: HostStepNode): WorkflowNodeStatus {
176
176
  if (hostStep.state === "pending") return "pending";
177
177
  if (hostStep.state === "running") return "running";
178
178
  if (hostStep.state === "cancelled") return "stopped";
@@ -187,7 +187,7 @@ function workflowNodeStatus(hostStep: HostStepNodeV1): WorkflowNodeStatus {
187
187
  */
188
188
  export function upsertHostStep(input: {
189
189
  status: AsyncStatus;
190
- hostStep: HostStepNodeV1;
190
+ hostStep: HostStepNode;
191
191
  persist: (status: AsyncStatus) => void;
192
192
  }): AsyncStatus {
193
193
  const hostStep = parseHostStepNode(input.hostStep, input.hostStep.id);