@tranhoangnguyen0310/pi-flow-external 3.0.0-external.0 → 3.1.0-external.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.
package/AGENTS.md CHANGED
@@ -43,6 +43,8 @@ This fork changes the original pi-flow contract: `Agent` is not a generic Pi sub
43
43
 
44
44
  ## Receipt and evidence invariants
45
45
 
46
+ - Agent public contract v1 uses a discriminated `ok` envelope with `isError === !ok`; renderer details remain independent. Expected errors are coded at their source, not parsed from prose. Canonical output is inline only within 16 KiB UTF-8 JSON after redaction, with `output_redacted` disclosed when changed. Refs must be resolvable, liveness must be observed after evidence I/O, and unavailable evidence must not discard a settled result. Native codemode tests must assert script output and hook-captured flags outside hooks (the SDK catches hook exceptions).
47
+
46
48
  - Success requires a recognized backend terminal-success event, a zero process exit, and a non-empty result.
47
49
  - A backend failure with a complete local record is different from an incomplete or damaged record; preserve that distinction in reports.
48
50
  - Normal runs write private best-effort evidence under `~/.pi/agent/pi-flow-external/runs/` or `PI_FLOW_EXTERNAL_RUNS_DIR`. Records may still contain sensitive prompts, excerpts, and tool output despite redaction.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  All notable changes to pi-flow external are documented here.
4
4
 
5
+ ## [3.1.0-external.0] - 2026-10-02
6
+
7
+ ### Added
8
+ - Versioned Agent receipts (`outputSchema`, `structuredContent`, and accurate `isError`) for native Pi codemode. Scripts must check `ok`; expected failures are data, while host dispatch failures and unexpected exceptions still throw.
9
+ - Registry-backed lifecycle/output/evidence projections, a 16 KiB UTF-8 JSON inline cap, inspect references, and explicit output-redaction warnings. Human renderer details remain separate. Workflow and supervision contracts remain follow-up work.
10
+
11
+ ### Changed
12
+ - Require Pi `~0.99.2`, with development packages pinned to 0.99.2; older hosts are no longer claimed as supported. Pi children inherit registered providers and runtime API keys through ModelRuntime. Flow retains six concrete thinking levels (not SDK `max`); virtual models are rejected because their registrations cannot be inherited.
13
+ - Port isolated faux-provider and E2E harnesses to Pi 0.99.2. Native codemode regressions cover returned errors, host rejection/throws, redaction, declaration discovery, and background settlement during evidence reads.
14
+
5
15
  ## [3.0.0-external.0] - 2026-10-01
6
16
 
7
17
  ### Breaking: configuration v5 (issue #73)
package/README.md CHANGED
@@ -14,7 +14,7 @@ Six built-in roles are available in memory on every one of those harnesses. A fr
14
14
 
15
15
  The ordinary driver has four tools (`workflow` can be disabled):
16
16
 
17
- - `Agent` resolves and runs one external role.
17
+ - `Agent` resolves and runs one external role. Its [versioned public receipt](docs/agent-public-contract.md) is available to native Pi codemode; scripts must check `ok`.
18
18
  - `workflow` orchestrates multiple external roles with trusted JavaScript.
19
19
  - `external_help` returns the usage playbook, role details, permission behavior, or workflow guidance on demand.
20
20
  - `external_runs` lists, inspects, waits for, and cancels session-owned runs.
@@ -23,6 +23,8 @@ The ordinary driver has four tools (`workflow` can be disabled):
23
23
 
24
24
  ## Install
25
25
 
26
+ Host baseline: **Pi 0.99.2**, with Pi peers restricted to `~0.99.2` (0.99.x patches from 0.99.2). The offline suite is pinned to exactly 0.99.2; older hosts and later minor versions are not claimed as supported.
27
+
26
28
  Global installation:
27
29
 
28
30
  ```bash
@@ -370,7 +372,7 @@ Disable a role everywhere with `/external config role disable reviewer`, or add
370
372
 
371
373
  ### Named Pi harness configurations
372
374
 
373
- A named Pi harness runs in-process through Pi's own SDK, for any model Pi can already resolve (built-in, self-hosted, or a custom-registered provider). Register it with **Add a Pi agent…** in `/external`, or with `/external config harness create pi-NAME --model provider/model`: a `pi-<label>` name, a `provider/model` id, a reasoning policy (`off`, `minimal`, `low`, `medium`, `high`, `xhigh`, or `parent`, stored explicitly on creation), and a resource preset (`minimal` or `skills`). `minimal` is the default and leaves skills unloaded. `skills` loads installed skills, and project skills only when the project is trusted. Registration is saved into the `harnesses` object of `settings.json` without sending a request. `/external config harness assist` is the model-assisted alternative; its smoke test uses the selected preset and runs before saving. A legacy entry that omits `preset` is `minimal`. The six CLI harnesses are built in and do not need entries there.
375
+ A named Pi harness runs in-process through Pi's own SDK, for physical models Pi can already resolve (built-in, self-hosted, or a custom-registered provider). Children inherit in-memory provider registrations and the selected provider's runtime API key; stored authentication uses the same agent directory. Virtual model registrations cannot be transferred through the SDK registry and are rejected. Flow retains six concrete thinking levels; SDK `max` remains unsupported. Register it with **Add a Pi agent…** in `/external`, or with `/external config harness create pi-NAME --model provider/model`: a `pi-<label>` name, a `provider/model` id, a reasoning policy (`off`, `minimal`, `low`, `medium`, `high`, `xhigh`, or `parent`, stored explicitly on creation), and a resource preset (`minimal` or `skills`). `minimal` is the default and leaves skills unloaded. `skills` loads installed skills, and project skills only when the project is trusted. Registration is saved into the `harnesses` object of `settings.json` without sending a request. `/external config harness assist` is the model-assisted alternative; its smoke test uses the selected preset and runs before saving. A legacy entry that omits `preset` is `minimal`. The six CLI harnesses are built in and do not need entries there.
374
376
 
375
377
  ```json
376
378
  {
@@ -0,0 +1,21 @@
1
+ # Agent public contract v1
2
+
3
+ `Agent` declares `outputSchema` and returns matching `structuredContent`. Human `content` and renderer `details` remain separate. Native Pi codemode receives the structured receipt, including failures: **check `ok`**, not just whether the call threw.
4
+
5
+ ```js
6
+ const receipt = await tools.Agent({ role: "worker", description: "Check build", prompt: "Run the build and report." });
7
+ if (!receipt.ok) return receipt.error;
8
+ return receipt.data.run;
9
+ ```
10
+
11
+ The envelope carries `contractVersion: 1`, `tool: "Agent"`, `action: "delegate"`, ISO `observedAt`, `ok`, `data: {run}`, and `warnings`. `error: {code,message}` exists only on failure. Unknown fields must be ignored by v1 consumers; removing fields or changing their meaning requires a new contract version. The host uses the schema for declarations, not runtime output validation; tests validate actual receipts.
12
+
13
+ Pre-registration failures have `run:null`. Run receipts expose task identity, registry-confirmed liveness, lifecycle state/outcome, timing, evidence integrity (`complete`, `incomplete`, `damaged`, or `unknown`), output availability/delivery, and inspect references when resolvable. Missing evidence does not turn a settled successful run into an uncertain execution. While a run is live, `incomplete` means evidence has not yet been finalized, not that execution failed. Unreadable evidence reports `unknown` without discarding the child result. Private evidence paths and launch/configuration are not included.
14
+
15
+ Only complete canonical results are inline, within **16 KiB of UTF-8 JSON after secret redaction**. Larger values use references. False, null, zero, and empty strings are valid results. Partial narration is never the canonical value. Both model-visible channels use the existing secret redactor; `warnings` includes `output_redacted` when it changes canonical output. this is best-effort pattern redaction, not a guarantee that arbitrary sensitive prose is identified.
16
+
17
+ Codes include `configuration_invalid`, `harness_unavailable`, `selection_invalid`, `model_unavailable`, `context_invalid`, `session_closed`, and terminal outcomes `failed`, `cancelled`, `timed_out`. Unexpected exceptions remain host errors, as do predispatch schema/permission failures. A content-replacing host redaction hook drops structured content unless it explicitly supplies a replacement.
18
+
19
+ Background `ok:true` means accepted, not completed. Await foreground calls; use `background:true` for work that must outlive a codemode script. Inspect/wait through the existing `external_runs` API; its versioned contract is separate work in #77.
20
+
21
+ Pi 0.99.2 generates a useful discriminated return type (native `describeTool` result: 2941 UTF-8 bytes). Its input declaration currently loses all input properties because Pi renders the existing root `anyOf` constraints before properties; input validation itself remains intact. This is a known input-discovery limitation, not a runtime validation bypass.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tranhoangnguyen0310/pi-flow-external",
3
- "version": "3.0.0-external.0",
3
+ "version": "3.1.0-external.0",
4
4
  "description": "External Claude Code, Codex CLI, Antigravity, Grok Build CLI, Muse Code, and OpenCode delegation for pi.",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -9,6 +9,7 @@
9
9
  "index.ts",
10
10
  "src",
11
11
  "scripts/field-report.mjs",
12
+ "docs/agent-public-contract.md",
12
13
  "docs/ARCHITECTURE_SNAPSHOT.md",
13
14
  "docs/field-testing.md",
14
15
  "docs/releasing.md",
@@ -42,17 +43,17 @@
42
43
  "test": "vitest run"
43
44
  },
44
45
  "peerDependencies": {
45
- "@earendil-works/pi-agent-core": "*",
46
- "@earendil-works/pi-ai": "*",
47
- "@earendil-works/pi-coding-agent": "*",
48
- "@earendil-works/pi-tui": "*",
46
+ "@earendil-works/pi-agent-core": "~0.99.2",
47
+ "@earendil-works/pi-ai": "~0.99.2",
48
+ "@earendil-works/pi-coding-agent": "~0.99.2",
49
+ "@earendil-works/pi-tui": "~0.99.2",
49
50
  "typebox": "*"
50
51
  },
51
52
  "devDependencies": {
52
- "@earendil-works/pi-agent-core": "0.79.4",
53
- "@earendil-works/pi-ai": "0.79.4",
54
- "@earendil-works/pi-coding-agent": "0.79.4",
55
- "@earendil-works/pi-tui": "0.79.4",
53
+ "@earendil-works/pi-agent-core": "0.99.2",
54
+ "@earendil-works/pi-ai": "0.99.2",
55
+ "@earendil-works/pi-coding-agent": "0.99.2",
56
+ "@earendil-works/pi-tui": "0.99.2",
56
57
  "@types/node": "^24.12.4",
57
58
  "typebox": "1.1.38",
58
59
  "typescript": "^5.9.3",
@@ -0,0 +1,10 @@
1
+ export const FLOW_ERROR_CODES = ["configuration_invalid", "harness_unavailable", "selection_invalid", "model_unavailable", "context_invalid", "session_closed", "failed", "cancelled", "timed_out"] as const;
2
+ export type FlowErrorCode = (typeof FLOW_ERROR_CODES)[number];
3
+
4
+ /** Expected caller/configuration failure, distinct from a programming error. */
5
+ export class ExpectedFlowError extends Error {
6
+ constructor(readonly code: FlowErrorCode, message: string) {
7
+ super(message);
8
+ this.name = "ExpectedFlowError";
9
+ }
10
+ }
@@ -1,3 +1,4 @@
1
+ import { ExpectedFlowError } from "./errors.ts";
1
2
  import { buildSessionContext, type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
3
  import { Type, type Static } from "typebox";
3
4
 
@@ -20,12 +21,12 @@ export interface ParentContextReceipt {
20
21
 
21
22
  export function parseParentContext(value: unknown): ParentContext | undefined {
22
23
  if (value === undefined) return undefined;
23
- if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("context must be an object");
24
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new ExpectedFlowError("context_invalid", "context must be an object");
24
25
  const { mode, turns } = value as Record<string, unknown>;
25
26
  if (!["none", "recent", "full"].includes(mode as string) ||
26
27
  Object.keys(value).some((key) => key !== "mode" && !(mode === "recent" && key === "turns")) ||
27
28
  (mode === "recent" && (typeof turns !== "number" || !Number.isSafeInteger(turns) || turns < 1))) {
28
- throw new Error("context must be {mode:'none'}, {mode:'recent',turns:positive integer}, or {mode:'full'}");
29
+ throw new ExpectedFlowError("context_invalid", "context must be {mode:'none'}, {mode:'recent',turns:positive integer}, or {mode:'full'}");
29
30
  }
30
31
  return mode === "recent" ? { mode, turns: turns as number } : { mode: mode as "none" | "full" };
31
32
  }
@@ -46,8 +47,8 @@ export function prepareParentContext(
46
47
  const context = parseParentContext(selection);
47
48
  if (!context || context.mode === "none") return { prompt };
48
49
  const resumeId = typeof resume === "string" && resume.trim() !== "" ? resume.trim() : undefined;
49
- if (resumeId !== undefined) throw new Error("context sharing cannot be combined with resume; continue the child or start a new one");
50
- if (!messages) throw new Error("Parent context is unavailable; use context:none and a self-contained prompt");
50
+ if (resumeId !== undefined) throw new ExpectedFlowError("context_invalid", "context sharing cannot be combined with resume; continue the child or start a new one");
51
+ if (!messages) throw new ExpectedFlowError("context_invalid", "Parent context is unavailable; use context:none and a self-contained prompt");
51
52
  const compacted = messages.some((message) => message.role === "compactionSummary");
52
53
  let start = 0;
53
54
  if (context.mode === "recent") {
@@ -64,6 +65,8 @@ export function prepareParentContext(
64
65
  const transcript: unknown[] = [];
65
66
  let sharedTurns = 0;
66
67
  for (const message of selected) {
68
+ // SDK transcripts include system messages; sharing excludes their content.
69
+ if (message.role === "system") continue;
67
70
  if (message.role === "bashExecution") {
68
71
  if (!message.excludeFromContext) transcript.push({ role: message.role, command: message.command, output: message.output, exitCode: message.exitCode, truncated: message.truncated });
69
72
  continue;
@@ -73,7 +76,7 @@ export function prepareParentContext(
73
76
  continue;
74
77
  }
75
78
  if (message.role !== "user" && message.role !== "assistant" && message.role !== "toolResult" && message.role !== "custom") {
76
- throw new Error("Unsupported parent context message role");
79
+ throw new ExpectedFlowError("context_invalid", "Unsupported parent context message role");
77
80
  }
78
81
  if (message.role === "toolResult" && message.toolCallId === toolCallId) continue;
79
82
  if (message.role === "toolResult") {
@@ -91,7 +94,7 @@ export function prepareParentContext(
91
94
  if (block.type === "text") blocks.push({ type: "text", text: block.text });
92
95
  else if (block.type === "toolCall") {
93
96
  if (completed.has(block.id)) blocks.push({ type: "toolCall", id: block.id, name: block.name, arguments: block.arguments });
94
- } else throw new Error(`Unsupported parent context content: ${block.type}; share fewer turns or provide a text briefing`);
97
+ } else throw new ExpectedFlowError("context_invalid", `Unsupported parent context content: ${block.type}; share fewer turns or provide a text briefing`);
95
98
  }
96
99
  if (blocks.length) transcript.push({ role: message.role, content: blocks,
97
100
  ...(message.role === "toolResult" ? { toolCallId: message.toolCallId, toolName: message.toolName, isError: message.isError } : {}),
@@ -101,7 +104,7 @@ export function prepareParentContext(
101
104
  const text = JSON.stringify(transcript);
102
105
  const bytes = Buffer.byteLength(text, "utf8");
103
106
  // ponytail: fixed transport ceiling, backend-specific token budgeting if needed.
104
- if (bytes > 1024 * 1024) throw new Error("Parent context is too large (over 1 MiB); share fewer turns or provide a text briefing. Nothing was truncated.");
107
+ if (bytes > 1024 * 1024) throw new ExpectedFlowError("context_invalid", "Parent context is too large (over 1 MiB); share fewer turns or provide a text briefing. Nothing was truncated.");
105
108
  const receipt: ParentContextReceipt = { mode: context.mode, ...(context.mode === "recent" ? { requestedTurns: context.turns } : {}), sharedTurns, messages: transcript.length, bytes, compacted };
106
109
  return {
107
110
  context: receipt,
@@ -1,3 +1,4 @@
1
+ import { ExpectedFlowError } from "./errors.ts";
1
2
  export type RegisteredRunKind = "agent" | "workflow";
2
3
  export type RegisteredRunState = "running" | "terminal";
3
4
  export type RegisteredRunStatus = "done" | "error" | "aborted";
@@ -82,7 +83,7 @@ export class RunRegistry {
82
83
 
83
84
  start<T>(params: StartRegisteredRun<T>): RegisteredRunHandle<T> {
84
85
  if (this.closedSessions.has(params.sessionId) || (params.sessionVersion !== undefined && params.sessionVersion !== this.sessionVersion(params.sessionId))) {
85
- throw new Error(`Session ${params.sessionId} is closed; run was not started`);
86
+ throw new ExpectedFlowError("session_closed", `Session ${params.sessionId} is closed; run was not started`);
86
87
  }
87
88
  if (this.entries.has(params.runId)) {
88
89
  throw new Error(`Run is already registered: ${params.runId}`);
package/src/core/spawn.ts CHANGED
@@ -2,6 +2,7 @@ import {
2
2
  createAgentSession,
3
3
  DefaultResourceLoader,
4
4
  getAgentDir,
5
+ ModelRuntime,
5
6
  SessionManager,
6
7
  SettingsManager,
7
8
  type ExtensionContext,
@@ -925,12 +926,33 @@ async function spawnSubagentRuntime(params: SpawnSubagentRuntimeParams): Promise
925
926
  // markModified()/save(), so this still never reaches the settings file.
926
927
  settingsManager.applyOverrides({ retry: { enabled: false } });
927
928
 
929
+ // The extension context exposes a registry, not its owning runtime. Rebuild
930
+ // from the same files and carry over in-memory provider/auth registrations.
931
+ if (model.api === "pi-virtual") {
932
+ throw new Error("Named Pi harnesses require a physical model; virtual model registrations cannot be inherited through the SDK registry.");
933
+ }
934
+ const modelRuntime = await ModelRuntime.create({
935
+ authPath: join(agentDir, "auth.json"),
936
+ modelsPath: join(agentDir, "models.json"),
937
+ });
938
+ for (const id of ctx.modelRegistry.getRegisteredProviderIds()) {
939
+ const native = ctx.modelRegistry.getRegisteredNativeProvider(id);
940
+ if (native) modelRuntime.registerNativeProvider(native);
941
+ const config = ctx.modelRegistry.getRegisteredProviderConfig(id);
942
+ if (config) modelRuntime.registerProvider(id, config);
943
+ }
944
+ if (ctx.modelRegistry.getProviderAuthStatus(model.provider).source === "runtime") {
945
+ const key = await ctx.modelRegistry.getApiKeyForProvider(model.provider);
946
+ if (key) await modelRuntime.setRuntimeApiKey(model.provider, key);
947
+ }
948
+ if (signal?.aborted) throw new Error("Subagent aborted before prompt start");
949
+
928
950
  ({ session } = await createAgentSession({
929
951
  cwd,
930
952
  agentDir,
931
953
  model,
932
954
  thinkingLevel: thinkingLevel as NonNullable<Parameters<typeof createAgentSession>[0]>["thinkingLevel"],
933
- modelRegistry: ctx.modelRegistry,
955
+ modelRuntime,
934
956
  settingsManager,
935
957
  sessionManager: SessionManager.inMemory(cwd),
936
958
  resourceLoader,
@@ -1,5 +1,5 @@
1
1
  import { StringEnum } from "@earendil-works/pi-ai";
2
- import { defineTool, type ExtensionContext, type Theme, type ToolDefinition } from "@earendil-works/pi-coding-agent";
2
+ import { type ExtensionContext, type Theme, type ToolDefinition } from "@earendil-works/pi-coding-agent";
3
3
  import { Container, Text } from "@earendil-works/pi-tui";
4
4
  import { Type, type Static } from "typebox";
5
5
  import { createHash } from "node:crypto";
@@ -655,16 +655,25 @@ function normalizeInspectSelectors(params: ExternalRunsParams): ExternalRunsPara
655
655
  return { ...params, runId, runIds };
656
656
  }
657
657
 
658
+ // Commands and tools share this executor; it needs no tool-only host capabilities.
659
+ type ExternalRunsTool = Omit<ToolDefinition<typeof externalRunsParameters, ExternalRunsDetails>, "execute"> & {
660
+ execute: (
661
+ toolCallId: string, params: ExternalRunsParams, signal: AbortSignal | undefined,
662
+ onUpdate: Parameters<ToolDefinition<typeof externalRunsParameters, ExternalRunsDetails>["execute"]>[3],
663
+ ctx: ExtensionContext,
664
+ ) => ReturnType<ToolDefinition<typeof externalRunsParameters, ExternalRunsDetails>["execute"]>;
665
+ };
666
+
658
667
  export function createExternalRunsTool(
659
668
  options: CreateExternalRunsToolOptions,
660
- ): ToolDefinition<typeof externalRunsParameters, ExternalRunsDetails> {
661
- return defineTool({
669
+ ): ExternalRunsTool {
670
+ return {
662
671
  name: "external_runs",
663
672
  label: "External Runs",
664
673
  description: "List, inspect (single or batched summaries), wait for, or cancel session-owned external runs.",
665
674
  promptSnippet: EXTERNAL_RUNS_PROMPT_SNIPPET,
666
675
  parameters: externalRunsParameters,
667
- async execute(_toolCallId, params: ExternalRunsParams, signal, onUpdate, ctx) {
676
+ async execute(_toolCallId, params: ExternalRunsParams, signal, onUpdate, ctx: ExtensionContext) {
668
677
  if (params.action === "inspect") {
669
678
  params = normalizeInspectSelectors(params);
670
679
  if (params.runId === undefined && params.runIds !== undefined && params.runIds.length === 1 && params.view !== undefined && params.view !== "summary") {
@@ -981,5 +990,5 @@ export function createExternalRunsTool(
981
990
  renderResult(toolResult, { expanded }, theme) {
982
991
  return renderExternalRunsResult(toolResult, theme, expanded);
983
992
  },
984
- });
993
+ };
985
994
  }
package/src/harnesses.ts CHANGED
@@ -3,24 +3,10 @@ import { loadExternalSettings, saveExternalSettings, externalSettingsPath, DEFAU
3
3
  import type { ThinkingLevel as SdkThinkingLevel } from "@earendil-works/pi-agent-core";
4
4
  import { PI_RESOURCE_PRESETS, type PiResourcePreset } from "./types.ts";
5
5
 
6
- /**
7
- * The pinned SDK exposes its thinking-level union only as a TypeScript type,
8
- * not a runtime-exported constant array, so this list is hand-maintained here.
9
- * The `as const satisfies` + AssertNever guards below make drift a compile
10
- * error rather than a procedural "re-check at upgrade time" reminder: if the
11
- * SDK's ThinkingLevel union gains or loses a member, `npm run check` fails
12
- * until this list is updated.
13
- */
6
+ /** Flow deliberately supports six levels; SDK additions do not expand settings. */
14
7
  export const VALID_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"] as const satisfies readonly SdkThinkingLevel[];
15
8
 
16
- type AssertNever<T extends never> = T;
17
- // Both directions must collapse to never: every SDK level is listed (no missing
18
- // members) and every listed value is a real SDK level (no extras — also
19
- // enforced by `satisfies`, but asserted symmetrically for clarity).
20
- type _MissingThinkingLevels = AssertNever<Exclude<SdkThinkingLevel, (typeof VALID_THINKING_LEVELS)[number]>>;
21
- type _ExtraThinkingLevels = AssertNever<Exclude<(typeof VALID_THINKING_LEVELS)[number], SdkThinkingLevel>>;
22
-
23
- export function isValidThinkingLevel(value: unknown): value is SdkThinkingLevel {
9
+ export function isValidThinkingLevel(value: unknown): value is (typeof VALID_THINKING_LEVELS)[number] {
24
10
  return typeof value === "string" && (VALID_THINKING_LEVELS as readonly string[]).includes(value);
25
11
  }
26
12
 
@@ -1,3 +1,7 @@
1
+ import { ExpectedFlowError } from "./core/errors.ts";
2
+ import { agentOutputSchema, agentReceipt, type PublicError } from "./public-contract.ts";
3
+ import { getRunRecord } from "./core/run-inspection.ts";
4
+ import { redactSecrets } from "./core/run-record.ts";
1
5
  import {
2
6
  defineTool,
3
7
  getAgentDir,
@@ -37,7 +41,7 @@ import { captureParentContext, parentContextSchema, prepareParentContext } from
37
41
  import { resolvePermission, permissionLabel, resolveEffectivePermissionTier } from "./core/permissions.ts";
38
42
  import { pruneRunRecords, runRecordsDirectory } from "./core/retention.ts";
39
43
  import { createProgressNode, textResult, type AgentToolResult } from "./core/progress.ts";
40
- import { RunRegistry } from "./core/run-registry.ts";
44
+ import { RunRegistry, type RegisteredRunEntry } from "./core/run-registry.ts";
41
45
  import { formatUsage, renderSubagentNode } from "./core/subagent-render.ts";
42
46
  import { SPINNER_INTERVAL_MS } from "./core/spinner.ts";
43
47
  import { createWorkflowTool } from "./workflow/tool.ts";
@@ -359,19 +363,34 @@ function createAgentTool(
359
363
  description: "Delegate one task to an external Claude Code, Codex CLI, Antigravity, Grok CLI, Muse Code, OpenCode, or registered Pi harness role.",
360
364
  promptSnippet: AGENT_PROMPT_SNIPPET,
361
365
  parameters: agentToolParameters,
366
+ outputSchema: agentOutputSchema,
362
367
  executionMode: "parallel",
363
368
  async execute(toolCallId, params, signal, onUpdate, ctx) {
364
- const resume = typeof params.resume === "string" && params.resume.trim() !== "" ? params.resume.trim() : undefined;
365
- const briefing = prepareParentContext(params.prompt, params.context,
366
- params.context && params.context.mode !== "none" ? captureParentContext(ctx.sessionManager) : undefined,
367
- toolCallId, resume);
368
369
  const state = getState();
370
+ const receipt = async (result: AgentToolResult, error?: PublicError, settled?: RegisteredRunEntry) => {
371
+ const details = result.details as SubagentToolDetails;
372
+ const durable = details.runId ? await getRunRecord(runRecordsDirectory(), details.runId).catch(() => undefined) : undefined;
373
+ // Evidence I/O may outlast the child: observe live state only after it.
374
+ const entry = settled ?? (details.runId ? state.registry.get(details.runId) : undefined);
375
+ const structuredContent = agentReceipt({ entry, integrity: durable?.integrity, error, inspectable: !!durable || !!(entry && state.registry.get(entry.runId)) });
376
+ return { ...result, content: redactSecrets(result.content) as typeof result.content, structuredContent, isError: !structuredContent.ok };
377
+ };
378
+ const resume = typeof params.resume === "string" && params.resume.trim() !== "" ? params.resume.trim() : undefined;
379
+ let briefing: ReturnType<typeof prepareParentContext>;
380
+ try {
381
+ briefing = prepareParentContext(params.prompt, params.context,
382
+ params.context && params.context.mode !== "none" ? captureParentContext(ctx.sessionManager) : undefined,
383
+ toolCallId, resume);
384
+ } catch (error) {
385
+ if (!(error instanceof ExpectedFlowError)) throw error;
386
+ return receipt(textResult(error.message, { description: params.description, subagentType: "unknown", status: "error", error: error.message }), error);
387
+ }
369
388
  const effectiveState: DelegationState = {
370
389
  ...state,
371
390
  progressEnabled: state.progressEnabled || shouldEnableProgress(ctx),
372
391
  };
373
392
  const catalog = loadExternalCatalog(getAgentDir());
374
- if (catalog.blocked) return textResult(catalog.diagnostics.join(" "), { description: params.description, subagentType: "unknown", status: "error", error: catalog.diagnostics.join(" ") });
393
+ if (catalog.blocked) return receipt(textResult(catalog.diagnostics.join(" "), { description: params.description, subagentType: "unknown", status: "error", error: catalog.diagnostics.join(" ") }), { code: "configuration_invalid", message: catalog.diagnostics.join(" ") });
375
394
  const allProfiles = catalog.profiles;
376
395
  const harnessConfigs = catalog.harnessConfigs;
377
396
  const configuredHarnessNames: ReadonlySet<string> = new Set([...EXTERNAL_HARNESSES, ...harnessConfigs.keys()]);
@@ -388,12 +407,12 @@ function createAgentTool(
388
407
  const defaultHarness = requestedDefault.harness;
389
408
  if (params.role && !params.harness && !configuredHarnessNames.has(defaultHarness)) {
390
409
  const error = `Default harness "${defaultHarness}" is not registered (missing from settings.json); pass harness explicitly or recreate it via /external config harness create.`;
391
- return textResult(error, {
410
+ return receipt(textResult(error, {
392
411
  description: params.description,
393
412
  subagentType: "unknown",
394
413
  status: "error",
395
414
  error,
396
- });
415
+ }), { code: "harness_unavailable", message: error });
397
416
  }
398
417
  let profile: SubagentProfile;
399
418
  try {
@@ -403,8 +422,9 @@ function createAgentTool(
403
422
  subagentType: params.subagent_type,
404
423
  }, defaultHarness, { configuredHarnessNames, harnessConfigs, disabledHarnesses: catalog.disabledHarnesses });
405
424
  } catch (error) {
406
- const message = error instanceof Error ? error.message : String(error);
407
- return textResult(
425
+ if (!(error instanceof ExpectedFlowError)) throw error;
426
+ const message = error.message;
427
+ return receipt(textResult(
408
428
  message,
409
429
  {
410
430
  description: params.description,
@@ -412,7 +432,7 @@ function createAgentTool(
412
432
  status: "error",
413
433
  error: message,
414
434
  },
415
- );
435
+ ), { code: error.code, message });
416
436
  }
417
437
  const subagentType = profile.name;
418
438
  profile = resolveExecutionProfile(profile, options.getThinkingLevel(), state.defaultMaxBudgetUsd);
@@ -420,14 +440,14 @@ function createAgentTool(
420
440
  const model = resolveProfileModel(profile, ctx);
421
441
  if (usesPiBackend(profile) && !model) {
422
442
  const error = describeMissingModel(profile, ctx.modelRegistry);
423
- return textResult(`Cannot launch subagent: ${error}.`, {
443
+ return receipt(textResult(`Cannot launch subagent: ${error}.`, {
424
444
  description: params.description,
425
445
  subagentType,
426
446
  backend: profile.backend,
427
447
  harness: selectorHarness(profile),
428
448
  status: "error",
429
449
  error,
430
- });
450
+ }), { code: "model_unavailable", message: error });
431
451
  }
432
452
 
433
453
  const queuedAt = Date.now();
@@ -565,7 +585,9 @@ function createAgentTool(
565
585
  }
566
586
  };
567
587
 
568
- const registered = state.registry.start({
588
+ let registered;
589
+ try {
590
+ registered = state.registry.start({
569
591
  runId: runRecord.runId,
570
592
  kind: "agent",
571
593
  sessionId,
@@ -585,8 +607,20 @@ function createAgentTool(
585
607
  };
586
608
  },
587
609
  });
588
- if (!background) return await registered.result;
589
- return textResult(
610
+ } catch (error) {
611
+ if (!(error instanceof ExpectedFlowError)) throw error;
612
+ await runRecord.finish({ status: "error", error: error.message, backendStarted: false });
613
+ return receipt(textResult(error.message, { description: params.description, subagentType, status: "error", error: error.message }), error);
614
+ }
615
+ if (!background) {
616
+ const result = await registered.result;
617
+ const outcome = await registered.terminal;
618
+ return receipt(result, undefined, {
619
+ runId: runRecord.runId, kind: "agent", sessionId, project, state: "terminal",
620
+ observation: (result.details as SubagentToolDetails).progress ?? progress, outcome,
621
+ });
622
+ }
623
+ return receipt(textResult(
590
624
  `Subagent "${params.description}" (${subagentType}) queued as ${runRecord.runId}. Use external_runs to inspect, wait, or cancel it.`,
591
625
  {
592
626
  description: params.description,
@@ -599,7 +633,7 @@ function createAgentTool(
599
633
  progress,
600
634
  backgroundReceipt: true,
601
635
  },
602
- );
636
+ ));
603
637
  },
604
638
  renderCall(args, theme, context) {
605
639
  const defaultHarness = options.getDefaultHarness(context.cwd);
package/src/profiles.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { ExpectedFlowError } from "./core/errors.ts";
1
2
  import { existsSync, readdirSync, readFileSync, lstatSync } from "node:fs";
2
3
  import { basename, join } from "node:path";
3
4
  import { getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
@@ -431,7 +432,7 @@ export function reconcilePiProfileWithHarness(
431
432
  harnessConfigs: ReadonlyMap<string, HarnessConfig>,
432
433
  ): SubagentProfile {
433
434
  const { profile: reconciled, conflict } = computeReconciledPiProfile(profile, harnessConfigs);
434
- if (conflict) throw new Error(conflict);
435
+ if (conflict) throw new ExpectedFlowError("selection_invalid", conflict);
435
436
  return reconciled;
436
437
  }
437
438
 
@@ -522,36 +523,36 @@ export function resolveExternalProfile(
522
523
 
523
524
  if (subagentType) {
524
525
  if (role || harness) {
525
- throw new Error("Choose either role (with optional harness) or legacy subagent_type; do not combine them.");
526
+ throw new ExpectedFlowError("selection_invalid", "Choose either role (with optional harness) or legacy subagent_type; do not combine them.");
526
527
  }
527
528
  const matches = [...profiles.values()].filter(p => p.configVersion === 5 && p.role && `${selectorHarness(p)}-${p.role}` === subagentType);
528
- if (!profiles.has(subagentType) && matches.length > 1) throw new Error(`Ambiguous legacy selector "${subagentType}"; use role and harness explicitly.`);
529
+ if (!profiles.has(subagentType) && matches.length > 1) throw new ExpectedFlowError("selection_invalid", `Ambiguous legacy selector "${subagentType}"; use role and harness explicitly.`);
529
530
  const profile = profiles.get(subagentType) ?? matches[0];
530
531
  if (!profile) {
531
- throw new Error(unknownExternalProfileMessage(subagentType, profiles.keys()));
532
+ throw new ExpectedFlowError("selection_invalid", unknownExternalProfileMessage(subagentType, profiles.keys()));
532
533
  }
533
- if (profile.configurationError) throw new Error(profile.configurationError);
534
+ if (profile.configurationError) throw new ExpectedFlowError("selection_invalid", profile.configurationError);
534
535
  return reconcilePiProfileWithHarness(profile, harnessConfigs);
535
536
  }
536
537
 
537
538
  if (!role) {
538
- throw new Error(harness
539
+ throw new ExpectedFlowError("selection_invalid", harness
539
540
  ? "role is required when harness is provided; otherwise provide role or legacy subagent_type."
540
541
  : "Either role or legacy subagent_type is required.");
541
542
  }
542
543
  const selectedHarness = harness || defaultHarness;
543
544
  if (!configuredHarnessNames.has(selectedHarness)) {
544
- throw new Error(`Unknown external harness "${selectedHarness}". Choose one of: ${[...configuredHarnessNames].join(", ") || "none"}.`);
545
+ throw new ExpectedFlowError("selection_invalid", `Unknown external harness "${selectedHarness}". Choose one of: ${[...configuredHarnessNames].join(", ") || "none"}.`);
545
546
  }
546
547
  if ((options.disabledHarnesses ?? DEFAULT_RESOLVE_OPTIONS.disabledHarnesses).has(selectedHarness)) {
547
- throw new Error(disabledHarnessMessage(selectedHarness, !harness));
548
+ throw new ExpectedFlowError("selection_invalid", disabledHarnessMessage(selectedHarness, !harness));
548
549
  }
549
550
 
550
551
  const legacy = profiles.get(`${selectedHarness}-${role}`);
551
552
  const exact = profiles.get(bindingKey(selectedHarness, role)) ?? (legacy?.configVersion === 5 ? undefined : legacy);
552
553
  if (exact) {
553
- if (exact.configurationError) throw new Error(exact.configurationError);
554
- if (selectorHarness(exact) !== selectedHarness || externalProfileRole(exact) !== role) throw new Error(`Override "${exact.name}" does not match selected harness "${selectedHarness}".`);
554
+ if (exact.configurationError) throw new ExpectedFlowError("selection_invalid", exact.configurationError);
555
+ if (selectorHarness(exact) !== selectedHarness || externalProfileRole(exact) !== role) throw new ExpectedFlowError("selection_invalid", `Override "${exact.name}" does not match selected harness "${selectedHarness}".`);
555
556
  return reconcilePiProfileWithHarness(exact, harnessConfigs);
556
557
  }
557
558
 
@@ -566,12 +567,12 @@ export function resolveExternalProfile(
566
567
  const availability = externalRoleAvailability(profiles);
567
568
  const supported = availability.get(role);
568
569
  if (supported?.length) {
569
- throw new Error(
570
+ throw new ExpectedFlowError("selection_invalid",
570
571
  `Role "${role}" is unavailable for harness "${selectedHarness}". Supported harnesses for this role: ${supported.join(", ")}. Choose one of those harnesses or add profile "${selectedHarness}-${role}".`,
571
572
  );
572
573
  }
573
574
  const knownRoles = new Set([...availability.keys(), ...(EXTERNAL_HARNESSES.includes(selectedHarness as ExternalHarness) ? [] : defaultRoleNames())]);
574
- throw new Error(
575
+ throw new ExpectedFlowError("selection_invalid",
575
576
  `Unknown external role "${role}". Available roles: ${[...knownRoles].join(", ") || "none"}. Nonstandard profile names must be selected with legacy subagent_type.`,
576
577
  );
577
578
  }
@@ -0,0 +1,73 @@
1
+ import { StringEnum, type JsonObject } from "@earendil-works/pi-ai";
2
+ import { Type, type Static } from "typebox";
3
+ import { projectLiveAgent } from "./core/run-projection.ts";
4
+ import type { RegisteredRunEntry } from "./core/run-registry.ts";
5
+ import { FLOW_ERROR_CODES } from "./core/errors.ts";
6
+ import { redactSecrets } from "./core/run-record.ts";
7
+
8
+ export const INLINE_RESULT_BYTES = 16 * 1024;
9
+ const strings = <T extends string[]>(...values: T) => StringEnum(values);
10
+ const errorSchema = Type.Object({ code: StringEnum(FLOW_ERROR_CODES), message: Type.String() });
11
+ const inspectRef = Type.Object({ tool: Type.Literal("external_runs"), action: Type.Literal("inspect"), runIds: Type.Array(Type.String()), view: strings("summary", "final", "diagnostics") });
12
+ const runSchema = Type.Object({
13
+ runId: Type.String(), kind: Type.Literal("agent"), live: Type.Boolean(),
14
+ task: Type.Object({ description: Type.Optional(Type.String()), profile: Type.Optional(Type.String()), backend: Type.Optional(Type.String()), harness: Type.Optional(Type.String()) }),
15
+ state: Type.Object({ status: strings("queued", "running", "done", "error", "aborted"), outcome: Type.Optional(strings("succeeded", "failed", "cancelled", "timed_out")) }),
16
+ timing: Type.Object({
17
+ queuedAt: Type.Optional(Type.String()), executionStartedAt: Type.Optional(Type.String()),
18
+ processStartedAt: Type.Optional(Type.String()), firstActivityAt: Type.Optional(Type.String()),
19
+ lastActivityAt: Type.Optional(Type.String()), finishedAt: Type.Optional(Type.String()),
20
+ queueDelayMs: Type.Optional(Type.Number()), elapsedMs: Type.Optional(Type.Number()),
21
+ activityAgeMs: Type.Optional(Type.Number()), processDurationMs: Type.Optional(Type.Number()),
22
+ }),
23
+ output: Type.Object({ available: Type.Boolean(), finalAvailable: Type.Boolean(), delivery: strings("inline", "reference", "none"), value: Type.Optional(Type.Unknown()) }),
24
+ evidence: Type.Object({ integrity: strings("complete", "incomplete", "damaged", "unknown") }),
25
+ refs: Type.Optional(Type.Object({ summary: inspectRef, final: inspectRef, diagnostics: inspectRef })),
26
+ });
27
+ const common = {
28
+ contractVersion: Type.Literal(1), tool: Type.Literal("Agent"), action: Type.Literal("delegate"),
29
+ observedAt: Type.String(), data: Type.Object({ run: Type.Union([Type.Null(), runSchema]) }),
30
+ warnings: Type.Array(Type.String()),
31
+ };
32
+ export const agentOutputSchema = Type.Union([
33
+ Type.Object({ ...common, ok: Type.Literal(true), error: Type.Optional(Type.Never()) }),
34
+ Type.Object({ ...common, ok: Type.Literal(false), error: errorSchema }),
35
+ ]);
36
+ export type AgentReceipt = Static<typeof agentOutputSchema>;
37
+ export type PublicError = Static<typeof errorSchema>;
38
+
39
+ /** Only registry-confirmed entries may supply a public handle. Never infer liveness from disk. */
40
+ export function agentReceipt({ entry, integrity = "unknown", error, inspectable = true }: {
41
+ entry?: RegisteredRunEntry;
42
+ integrity?: "complete" | "incomplete" | "damaged" | "unknown";
43
+ error?: PublicError;
44
+ inspectable?: boolean;
45
+ }): AgentReceipt & JsonObject {
46
+ const warnings: string[] = [];
47
+ let run: AgentReceipt["data"]["run"] = null;
48
+ if (entry) {
49
+ const projected = projectLiveAgent(entry);
50
+ const value = entry.outcome?.result;
51
+ const finalAvailable = projected.output.finalAvailable;
52
+ let encoded: string | undefined;
53
+ try { encoded = finalAvailable ? JSON.stringify(value) : undefined; } catch { /* Non-JSON values are never presented as complete. */ }
54
+ const redacted = encoded === undefined ? undefined : redactSecrets(JSON.parse(encoded));
55
+ const original = encoded;
56
+ encoded = encoded === undefined ? undefined : JSON.stringify(redacted);
57
+ if (encoded !== original) warnings.push("output_redacted");
58
+ const inline = encoded !== undefined && Buffer.byteLength(encoded, "utf8") <= INLINE_RESULT_BYTES;
59
+ const ref = (view: "summary" | "final" | "diagnostics") => ({ tool: "external_runs" as const, action: "inspect" as const, runIds: [entry.runId], view });
60
+ run = {
61
+ runId: entry.runId, kind: "agent", live: projected.live, task: projected.task,
62
+ state: { status: projected.state.status as NonNullable<AgentReceipt["data"]["run"]>["state"]["status"], ...(entry.outcome ? { outcome: entry.outcome.outcome } : {}) },
63
+ timing: projected.timing,
64
+ output: { available: projected.output.available, finalAvailable, delivery: inline ? "inline" : finalAvailable && inspectable ? "reference" : "none", ...(inline ? { value: redacted } : {}) },
65
+ evidence: { integrity }, ...(inspectable ? { refs: { summary: ref("summary"), final: ref("final"), diagnostics: ref("diagnostics") } } : {}),
66
+ };
67
+ if (!error && entry.outcome && entry.outcome.status !== "done") {
68
+ error = { code: entry.outcome.outcome === "succeeded" ? "failed" : entry.outcome.outcome, message: entry.outcome.error ?? "Child execution failed" };
69
+ }
70
+ }
71
+ const base = { contractVersion: 1 as const, tool: "Agent" as const, action: "delegate" as const, observedAt: new Date().toISOString(), data: { run }, warnings };
72
+ return JSON.parse(JSON.stringify(redactSecrets(error ? { ...base, ok: false, error: { code: error.code, message: error.message } } : { ...base, ok: true }))) as AgentReceipt & JsonObject;
73
+ }