@narumitw/pi-subagents 3.3.2 → 3.4.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 (42) hide show
  1. package/README.md +73 -24
  2. package/dist/child-communication-bridge.ts +3 -2
  3. package/dist/child-communication-bridge.ts.map +1 -1
  4. package/dist/child-readiness-probe.ts +114 -17
  5. package/dist/child-readiness-probe.ts.map +2 -2
  6. package/dist/chunks/{chunk-FY5HIU6Y.ts → chunk-5OXDVXXO.ts} +1 -1
  7. package/dist/chunks/{chunk-FY5HIU6Y.ts.map → chunk-5OXDVXXO.ts.map} +1 -1
  8. package/dist/chunks/chunk-B3RWNGDX.ts +192 -0
  9. package/dist/chunks/chunk-B3RWNGDX.ts.map +7 -0
  10. package/dist/chunks/{chunk-ZOAFWLDJ.ts → chunk-PRIDTAJL.ts} +4 -16
  11. package/dist/chunks/chunk-PRIDTAJL.ts.map +7 -0
  12. package/dist/chunks/{chunk-YMVSJ5GE.ts → chunk-YV2P6XJX.ts} +46 -17
  13. package/dist/chunks/chunk-YV2P6XJX.ts.map +7 -0
  14. package/dist/chunks/{menu-KAIUEWL7.ts → menu-2UH4ICLL.ts} +74 -3
  15. package/dist/chunks/menu-2UH4ICLL.ts.map +7 -0
  16. package/dist/index.ts +553 -292
  17. package/dist/index.ts.map +3 -3
  18. package/docs/attachments.md +9 -3
  19. package/docs/design-principles.md +3 -1
  20. package/docs/tools.md +15 -1
  21. package/package.json +6 -6
  22. package/src/broker-credentials.ts +44 -13
  23. package/src/child-communication-tools.ts +2 -19
  24. package/src/child-readiness-probe.ts +144 -18
  25. package/src/child-readiness.ts +118 -0
  26. package/src/menu.ts +82 -3
  27. package/src/message-broker.ts +23 -10
  28. package/src/process.ts +59 -167
  29. package/src/resource-attachments.ts +1 -3
  30. package/src/rpc-line-decoder.ts +61 -0
  31. package/src/runtime.ts +21 -9
  32. package/src/settings.ts +2 -2
  33. package/src/subagents.ts +110 -18
  34. package/src/tools.ts +83 -37
  35. package/src/types.ts +1 -0
  36. package/src/widget-renderer.ts +119 -0
  37. package/src/widget.ts +245 -121
  38. package/dist/chunks/chunk-3FCHXUKZ.ts +0 -98
  39. package/dist/chunks/chunk-3FCHXUKZ.ts.map +0 -7
  40. package/dist/chunks/chunk-YMVSJ5GE.ts.map +0 -7
  41. package/dist/chunks/chunk-ZOAFWLDJ.ts.map +0 -7
  42. package/dist/chunks/menu-KAIUEWL7.ts.map +0 -7
@@ -20,7 +20,7 @@ An extension directory must resolve to at least one loadable Pi entrypoint. Ever
20
20
 
21
21
  Extension-package preflight is asynchronous and cancellation-aware. It rejects source globs in Pi resource declarations, recursive resource-directory links, and non-regular manifests or ignore files; its shared discovery budget is 4,096 entries, depth 32, and 1 MiB of metadata.
22
22
 
23
- When an extension is attached, a readiness probe reports source fingerprints for the requested active tools through a private descriptor after extension factory, session, and resource-discovery hooks. The parent matches each requested tool to the specified attachment's resolved entrypoints and observes startup-hook errors through an ordered RPC barrier before sending the task to the model. A missing or misattributed tool, invalid readiness response, startup failure, or cancellation fails the job before a model request. For untrusted projects, the parent also checks Pi's loaded skill and prompt command paths after resource discovery and before sending the task; project-local paths and symlinks into the project fail the job. Headless RPC does not expose themes to the child model.
23
+ Every child uses a readiness probe to confirm its exact registered chat-model pair and current provider availability before receiving the task. When an extension is attached, the probe also reports source fingerprints for the requested active tools through a private descriptor after extension factory, session, and resource-discovery hooks. The parent matches each requested tool to the specified attachment's resolved entrypoints and observes startup-hook errors through an ordered RPC barrier before sending the task to the model. A missing or misattributed tool, invalid readiness response, startup failure, or cancellation fails the job before a model request. For untrusted projects, the parent also checks Pi's loaded skill and prompt command paths after resource discovery and before sending the task; project-local paths and symlinks into the project fail the job. Headless RPC does not expose themes to the child model.
24
24
 
25
25
  An attached extension executes trusted code with the child process's user permissions and can alter prompts, providers, tools, or the active tool list after that initial check. The selected tool list is not a sandbox.
26
26
 
@@ -28,10 +28,16 @@ An attached extension executes trusted code with the child process's user permis
28
28
 
29
29
  Attachment paths may be absolute or relative to the child working directory. They must already refer to regular files or directories and are canonicalized before launch. npm, Git, URL, and other nonlocal sources are not accepted. Repeated extension paths merge their selected tools in first-use order.
30
30
 
31
- A job accepts at most 16 skill paths, 16 extension entries, and 64 selected core and extension tool names. Each path is limited to 4 KiB of UTF-8 text; each extension tool name is limited to 128 characters without commas or control characters. The serialized child bootstrap, including selected and communication tool names, must fit 16 KiB of UTF-8 JSON. On Windows, the child command line must fit the 32,767 UTF-16 code-unit process limit.
31
+ A job accepts at most 16 skill paths, 16 extension entries, and 64 selected core and extension tool names. Each path is limited to 4 KiB of UTF-8 text; each extension tool name is limited to 128 characters without commas or control characters. The serialized child bootstrap, including selected and communication tool names plus the requested provider, model, and thinking level, must fit 16 KiB of UTF-8 JSON. On Windows, the child command line must fit the 32,767 UTF-16 code-unit process limit.
32
32
 
33
33
  When the project is untrusted, both lexical and symlink-resolved paths inside the child working directory are rejected for attachments and resources resolved from their extension packages. Explicit external paths remain available. Only attach code you trust: attachments are not permission boundaries.
34
34
 
35
- The child inherits the main agent's effective provider and model. Unavailable core tools, invalid attachments, or an unavailable session broker also fail before launch. Without an attached extension, a provider registered only in the parent by an extension is unavailable to the child and spawn rejects it. When an extension is attached, the child may register that provider during startup; startup fails if it does not. Process-local runtime API keys, including a parent-only `--api-key`, cannot be forwarded. Attached providers must use stored credentials or inherited environment credentials the child can read independently.
35
+ The child inherits each omitted `provider` or `model` independently from the main agent at spawn time. Explicit values select exact IDs without fallback; see [Model selection](./tools.md#model-selection). Unlisted custom IDs, including inherited ones, require explicit model registration. Unavailable core tools, invalid attachments, or an unavailable session broker also fail before launch.
36
+
37
+ Without an attached extension, a provider registered only in the parent by an extension is unavailable to the child and spawn rejects it. This is a provider portability check, not a model-list lookup. Only the child checks exact model registration and availability. An attached extension can register a pair absent from the parent's catalog. A missing or unavailable pair fails the admitted job before the task is sent. Registered virtual models follow the same rule.
38
+
39
+ The requested pair is sent through the private bootstrap, not Pi's CLI model-pattern parser. Any initial default model is provisional and receives no task. The child binds the exact requested registration through Pi's public API before readiness. Attached code must finish registering the pair before that check; factory, `session_start`, or earlier `resources_discover` hooks can register it.
40
+
41
+ Exact child selection can run `model_select` hooks. Pi does not await asynchronous `thinking_level_select` handlers, so attached code can still change behavior after the initial check. Process-local runtime API keys, including a parent-only `--api-key`, cannot be forwarded. Attached providers must use stored credentials or inherited environment credentials the child can read independently.
36
42
 
37
43
  Canonical attachment paths are passed to Pi as child-process arguments. Parent-generated inspection, completion, and broker metadata omit them; child diagnostic errors redact requested attachment roots before publication. Attached code and model output can still disclose accessible paths.
@@ -12,7 +12,9 @@ That isolation is not a limitation—it is the point.
12
12
 
13
13
  Fresh does not mean empty.
14
14
 
15
- The subagent still receives Pi's standard system prompt, the main agent's effective model, applicable project context, its selected tools, explicit per-job attachments, and one task.
15
+ The subagent still receives Pi's standard system prompt, applicable project context, its selected tools, explicit per-job attachments, and one task.
16
+
17
+ Each omitted `provider` or `model` inherits independently from the main agent at spawn time. Explicit values select another exact pair. Only the child checks that the requested pair is registered and available before it receives the task.
16
18
 
17
19
  The task defines the child's specialization, while selected tools and trusted attachments define its authority.
18
20
 
package/docs/tools.md CHANGED
@@ -5,13 +5,15 @@
5
5
  | Parameter | Type | Required | Constraint / default |
6
6
  | --- | --- | --- | --- |
7
7
  | `task` | `string` | Yes | Self-contained task, up to 50 KiB of UTF-8 text. |
8
+ | `provider` | `string` | No | Exact provider ID; defaults to the main agent's effective provider at spawn time. |
9
+ | `model` | `string` | No | Exact model ID within the selected provider; defaults independently to the main agent's effective model ID at spawn time. |
8
10
  | `tools` | `string[]` | No | Pi tools: `read`, `bash`, `powershell`, `edit`, `write`, `grep`, `find`, `ls`, `codemode`; defaults to `read`, `grep`, `find`, `ls`. |
9
11
  | `skills` | `string[]` | No | Up to 16 local skill files or directories; Pi uses progressive disclosure. |
10
12
  | `extensions` | `{ path: string; tools: string[] }[]` | No | Up to 16 trusted local extensions and their initially selected tools. |
11
13
  | `thinkingLevel` | `string` | No | `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, or `max`; defaults to the main agent's level. |
12
14
  | `timeout` | `number` | No | Seconds; `> 0` through `2,147,483.647`; no default. |
13
15
 
14
- Starts one job and returns its `jobId` immediately. The child inherits the main agent's effective model and always receives `subagent_send` and `subagent_wait`.
16
+ Starts one job and returns its `jobId` without waiting for child startup. The child always receives `subagent_send` and `subagent_wait`.
15
17
 
16
18
  Select `codemode` explicitly with the other required tools, for example `tools: ["read", "grep", "codemode"]`.
17
19
  Pi's built-in codemode extension runs scripts against the child's callable tools, including `subagent_send` and `subagent_wait`; automatic resource discovery stays disabled.
@@ -22,6 +24,18 @@ Skills do not add tools or inject their entire bodies. An extension's `tools` li
22
24
 
23
25
  Attachments must be existing local paths. Every non-ignored declared skill must load successfully, each skill name must be unique, and each skill path must contain at least one loadable Pi skill. Missing or misattributed extension tools fail before the child model receives the task. See [Attachment behavior](./attachments.md) for validation, trust, limits, provider credentials, and startup failures.
24
26
 
27
+ ### Model selection
28
+
29
+ Each omitted field inherits independently from one effective parent-model snapshot after attachment preflight, immediately before job admission. Supplying only `provider` keeps the parent's model ID; it never selects the new provider's default. Supplying only `model` keeps the parent's provider. Supplying both fields does not require a selected parent model. Later parent changes do not alter the admitted pair.
30
+
31
+ IDs are literal and case-sensitive. No trimming, aliases, fuzzy matches, combined provider/model syntax, thinking suffixes, or default fallback are applied. `/` and `:` remain part of a legitimate model ID. Listed IDs that differ only by case or contain provider-like prefixes select their exact entries, not Pi CLI pattern matches. Empty, whitespace-only, non-string, or control-containing values are rejected rather than inherited.
32
+
33
+ Only the child checks model registration and availability. Every job requires the exact pair to be a registered, available chat model in that child. The parent's model list does not limit the selection. Image and classifier models cannot run a subagent. Registered virtual chat models are supported when the child independently registers the same selection; parent-only routing state is not inherited. A trusted attached extension can register the pair before the child's readiness check.
34
+
35
+ The parent validates arguments, session state, attachments, and credential portability before job admission. These errors throw from `subagent_spawn`. Model registration and availability errors occur in the child after `subagent_spawn` returns a `jobId`; the job fails without sending its task to any model. Use `subagent_wait` to read the failure. Availability checks use Pi's current provider authentication and model filtering, not the remote service's entitlement checks; a later provider request can still fail. `thinkingLevel` remains a separate parameter and Pi clamps it to the selected model's capabilities.
36
+
37
+ **Compatibility:** unlisted custom model IDs that Pi's CLI previously accepted now fail, including inherited IDs. Add an exact chat-model entry to Pi's `models.json` or attach a trusted extension that registers it in the child. Reload the extension or start a new session after upgrading so the model receives the new fixed tool schema; per-job choices do not change that schema during ordinary turns.
38
+
25
39
  ## `subagent_inspect`
26
40
 
27
41
  No parameters. Returns privacy-filtered retained-job metadata without task text, output, selected tools, attachment paths, credentials, or messages.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-subagents",
3
- "version": "3.3.2",
3
+ "version": "3.4.0",
4
4
  "description": "Subagent jobs with asynchronous main-agent messaging for Pi.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -39,12 +39,12 @@
39
39
  "typebox": "*"
40
40
  },
41
41
  "devDependencies": {
42
- "@earendil-works/pi-ai": "1.3.0",
43
- "@earendil-works/pi-coding-agent": "1.3.0",
44
- "@earendil-works/pi-tui": "1.3.0",
42
+ "@earendil-works/pi-ai": "1.4.0",
43
+ "@earendil-works/pi-coding-agent": "1.4.0",
44
+ "@earendil-works/pi-tui": "1.4.0",
45
45
  "@types/node": "26.6.5",
46
46
  "esbuild": "0.28.2",
47
- "typebox": "1.4.2",
47
+ "typebox": "1.4.3",
48
48
  "typescript": "7.0.2"
49
49
  },
50
50
  "repository": {
@@ -53,7 +53,7 @@
53
53
  "directory": "packages/pi-subagents"
54
54
  },
55
55
  "dependencies": {
56
- "@narumitw/pi-tui-kit": "^0.66.0",
56
+ "@narumitw/pi-tui-kit": "^0.68.0",
57
57
  "ignore": "^7.0.13"
58
58
  }
59
59
  }
@@ -1,5 +1,5 @@
1
1
  import * as fs from "node:fs";
2
- import type { BrokerCredentials } from "./types.js";
2
+ import { type BrokerCredentials, SUBAGENT_THINKING_LEVELS, type SubagentThinkingLevel } from "./types.js";
3
3
 
4
4
  export const BROKER_CREDENTIAL_FD = 3;
5
5
  export const BROKER_CREDENTIAL_FD_ENV = "PI_SUBAGENT_BROKER_FD";
@@ -7,14 +7,27 @@ export const CHILD_READINESS_FD = 4;
7
7
  export const CHILD_READINESS_FD_ENV = "PI_SUBAGENT_READINESS_FD";
8
8
  export const MAX_READINESS_FRAME_BYTES = 16 * 1024;
9
9
 
10
- const MAX_BOOTSTRAP_BYTES = 16 * 1024;
10
+ export const MAX_BOOTSTRAP_BYTES = 16 * 1024;
11
11
  const MAX_EXPECTED_TOOLS = 66;
12
12
  const MAX_TOOL_NAME_LENGTH = 128;
13
13
  const READINESS_STATE_KEY = Symbol.for("@narumitw/pi-subagents/child-readiness");
14
14
 
15
+ export interface ChildSelection {
16
+ provider: string;
17
+ model: string;
18
+ thinkingLevel: SubagentThinkingLevel;
19
+ }
20
+
15
21
  export interface ChildBootstrap {
16
22
  communication: BrokerCredentials;
17
23
  expectedTools: string[];
24
+ selection: ChildSelection;
25
+ }
26
+
27
+ interface CapturedReadiness {
28
+ fd: number;
29
+ expectedTools: string[];
30
+ selection: ChildSelection;
18
31
  }
19
32
 
20
33
  export interface CapturedChildBootstrap extends ChildBootstrap {
@@ -32,13 +45,16 @@ export function serializeChildBootstrap(bootstrap: ChildBootstrap): string {
32
45
  return JSON.stringify(bootstrap);
33
46
  }
34
47
 
35
- export function assertChildBootstrapCapacity(expectedTools: string[]): void {
48
+ export function assertChildBootstrapCapacity(expectedTools: string[], selection: ChildSelection): void {
36
49
  const serialized = serializeChildBootstrap({
37
50
  communication: { host: "127.0.0.1", port: 65_535, token: "f".repeat(64) },
38
51
  expectedTools,
52
+ selection,
39
53
  });
40
54
  if (Buffer.byteLength(serialized, "utf8") > MAX_BOOTSTRAP_BYTES) {
41
- throw new Error(`Subagent selected tool names exceed the ${MAX_BOOTSTRAP_BYTES}-byte child bootstrap size limit.`);
55
+ throw new Error(
56
+ `Subagent tools and model selection exceed the ${MAX_BOOTSTRAP_BYTES}-byte child bootstrap size limit.`,
57
+ );
42
58
  }
43
59
  }
44
60
 
@@ -78,23 +94,29 @@ export function captureChildBootstrap(
78
94
  if (readinessDescriptor !== String(CHILD_READINESS_FD)) {
79
95
  throw new Error("Invalid pi-subagents readiness descriptor.");
80
96
  }
81
- setCapturedReadiness({ fd: CHILD_READINESS_FD, expectedTools: [...value.expectedTools] });
82
- return { ...value, readinessFd: CHILD_READINESS_FD };
97
+ setCapturedReadiness({
98
+ fd: CHILD_READINESS_FD,
99
+ expectedTools: [...value.expectedTools],
100
+ selection: { ...value.selection },
101
+ });
102
+ return { ...value, selection: { ...value.selection }, readinessFd: CHILD_READINESS_FD };
83
103
  }
84
104
  if (readinessDescriptor !== undefined) throw new Error("Unexpected pi-subagents readiness descriptor.");
85
105
  setCapturedReadiness(undefined);
86
106
  return value;
87
107
  }
88
108
 
89
- export function takeCapturedReadiness(): { fd: number; expectedTools: string[] } | undefined {
90
- const store = globalThis as unknown as Record<symbol, { fd: number; expectedTools: string[] } | undefined>;
109
+ export function takeCapturedReadiness(): CapturedReadiness | undefined {
110
+ const store = globalThis as unknown as Record<symbol, CapturedReadiness | undefined>;
91
111
  const readiness = store[READINESS_STATE_KEY];
92
112
  delete store[READINESS_STATE_KEY];
93
- return readiness ? { fd: readiness.fd, expectedTools: [...readiness.expectedTools] } : undefined;
113
+ return readiness
114
+ ? { fd: readiness.fd, expectedTools: [...readiness.expectedTools], selection: { ...readiness.selection } }
115
+ : undefined;
94
116
  }
95
117
 
96
- function setCapturedReadiness(readiness: { fd: number; expectedTools: string[] } | undefined): void {
97
- const store = globalThis as unknown as Record<symbol, { fd: number; expectedTools: string[] } | undefined>;
118
+ function setCapturedReadiness(readiness: CapturedReadiness | undefined): void {
119
+ const store = globalThis as unknown as Record<symbol, CapturedReadiness | undefined>;
98
120
  if (readiness) store[READINESS_STATE_KEY] = readiness;
99
121
  else delete store[READINESS_STATE_KEY];
100
122
  }
@@ -112,8 +134,8 @@ function readBootstrapPipe(): string {
112
134
  }
113
135
 
114
136
  function isChildBootstrap(value: unknown): value is ChildBootstrap {
115
- if (!isRecord(value) || Object.keys(value).length !== 2) return false;
116
- if (!isBrokerCredentials(value.communication)) return false;
137
+ if (!isRecord(value) || Object.keys(value).length !== 3) return false;
138
+ if (!isBrokerCredentials(value.communication) || !isChildSelection(value.selection)) return false;
117
139
  if (!Array.isArray(value.expectedTools) || value.expectedTools.length > MAX_EXPECTED_TOOLS) return false;
118
140
  return value.expectedTools.every(
119
141
  (tool) =>
@@ -125,6 +147,15 @@ function isChildBootstrap(value: unknown): value is ChildBootstrap {
125
147
  );
126
148
  }
127
149
 
150
+ function isChildSelection(value: unknown): value is ChildSelection {
151
+ if (!isRecord(value) || Object.keys(value).length !== 3) return false;
152
+ return (
153
+ [value.provider, value.model].every(
154
+ (id) => typeof id === "string" && id.trim().length > 0 && !hasControlCharacter(id),
155
+ ) && SUBAGENT_THINKING_LEVELS.includes(value.thinkingLevel as SubagentThinkingLevel)
156
+ );
157
+ }
158
+
128
159
  function isBrokerCredentials(value: unknown): value is BrokerCredentials {
129
160
  if (!isRecord(value) || Object.keys(value).length !== 3) return false;
130
161
  return (
@@ -4,6 +4,8 @@ import {
4
4
  type BrokerSendAcknowledgement,
5
5
  MAX_IDENTIFIER_LENGTH,
6
6
  MAX_MESSAGE_BYTES,
7
+ optionalIdentifier,
8
+ requiredIdentifier,
7
9
  sanitizeTerminalText,
8
10
  validateMessage,
9
11
  } from "./message-broker.js";
@@ -98,22 +100,3 @@ export function createChildCommunicationExtension(client: ChildCommunicationClie
98
100
  function prepareWaitArguments(args: unknown): WaitArguments {
99
101
  return prepareTimeoutArguments(args) as WaitArguments;
100
102
  }
101
-
102
- function optionalIdentifier(value: unknown, field: string): string | undefined {
103
- return value === undefined ? undefined : requiredIdentifier(value, field);
104
- }
105
-
106
- function requiredIdentifier(value: unknown, field: string): string {
107
- if (typeof value !== "string" || !value.trim()) throw new Error(`Subagent ${field} is required.`);
108
- const identifier = value.trim();
109
- if (
110
- identifier.length > MAX_IDENTIFIER_LENGTH ||
111
- [...identifier].some((character) => {
112
- const codePoint = character.codePointAt(0) ?? 0;
113
- return codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f);
114
- })
115
- ) {
116
- throw new Error(`Subagent ${field} is invalid.`);
117
- }
118
- return identifier;
119
- }
@@ -1,11 +1,13 @@
1
1
  import * as fs from "node:fs";
2
2
  import type { ExtensionFactory } from "@earendil-works/pi-coding-agent";
3
3
  import { toolSourceId } from "./attachment-utils.js";
4
- import { MAX_READINESS_FRAME_BYTES, takeCapturedReadiness } from "./broker-credentials.js";
4
+ import { type ChildSelection, MAX_READINESS_FRAME_BYTES, takeCapturedReadiness } from "./broker-credentials.js";
5
+ import { type ChildReadinessFrame, isChildModelPair } from "./child-readiness.js";
5
6
 
6
7
  export interface ChildReadinessState {
7
8
  fd: number;
8
9
  expectedTools: string[];
10
+ selection: ChildSelection;
9
11
  }
10
12
 
11
13
  const captured = takeCapturedReadiness();
@@ -14,6 +16,8 @@ const childReadinessProbe: ExtensionFactory = captured ? createChildReadinessPro
14
16
 
15
17
  export default childReadinessProbe;
16
18
 
19
+ class ReadinessError extends Error {}
20
+
17
21
  export function createChildReadinessProbe(
18
22
  readiness: ChildReadinessState,
19
23
  writeFrame: (fd: number, frame: string) => void = writeReadinessFrame,
@@ -21,13 +25,17 @@ export function createChildReadinessProbe(
21
25
  ): ExtensionFactory {
22
26
  return (pi) => {
23
27
  let settled = false;
24
- const publish = (value: { ok: true; sources: string[] } | { ok: false; error: string }) => {
28
+ let started = false;
29
+ let controller: AbortController | undefined;
30
+ let deadline: NodeJS.Timeout | undefined;
31
+ const publish = (value: ChildReadinessFrame) => {
25
32
  if (settled) return;
26
33
  settled = true;
27
- const frame = `${JSON.stringify(value)}\n`;
34
+ if (deadline) clearTimeout(deadline);
35
+ controller?.abort();
36
+ let frame = `${JSON.stringify(value)}\n`;
28
37
  if (Buffer.byteLength(frame, "utf8") > MAX_READINESS_FRAME_BYTES) {
29
- close(readiness.fd);
30
- throw new Error("Pi-subagents readiness frame exceeded its size limit.");
38
+ frame = `${JSON.stringify({ ok: false, error: "Subagent readiness frame exceeded its size limit." })}\n`;
31
39
  }
32
40
  try {
33
41
  writeFrame(readiness.fd, frame);
@@ -35,22 +43,140 @@ export function createChildReadinessProbe(
35
43
  close(readiness.fd);
36
44
  }
37
45
  };
38
-
39
- pi.on("resources_discover", () => {
40
- const active = new Set(pi.getActiveTools());
41
- const missing = readiness.expectedTools.filter((tool) => !active.has(tool));
46
+ const toolState = () => {
47
+ const active = pi.getActiveTools();
48
+ const missing = readiness.expectedTools.filter((tool) => !active.includes(tool));
42
49
  if (missing.length > 0) {
43
- publish({ ok: false, error: `Unavailable subagent tools: ${missing.join(", ")}.` });
44
- return;
50
+ throw new ReadinessError(`Unavailable subagent tools: ${missing.join(", ").slice(0, 512)}.`);
45
51
  }
46
- const sources = new Map(pi.getAllTools().map((tool) => [tool.name, tool.sourceInfo.path]));
47
- publish({
48
- ok: true,
49
- sources: readiness.expectedTools.map((tool) => {
50
- const source = sources.get(tool);
51
- return source ? toolSourceId(source) : "";
52
- }),
52
+ const all = new Map(pi.getAllTools().map((tool) => [tool.name, tool.sourceInfo.path]));
53
+ const sources = readiness.expectedTools.map((tool) => {
54
+ const source = all.get(tool);
55
+ if (!source) throw new ReadinessError("Subagent child tool provenance is unavailable.");
56
+ return toolSourceId(source);
53
57
  });
58
+ return { active, sources };
59
+ };
60
+
61
+ pi.on("resources_discover", async (_event, ctx) => {
62
+ if (settled || started) return;
63
+ started = true;
64
+ controller = new AbortController();
65
+ const signal = ctx.signal ? AbortSignal.any([controller.signal, ctx.signal]) : controller.signal;
66
+ deadline = setTimeout(() => publish({ ok: false, error: "Subagent child model readiness timed out." }), 30_000);
67
+ deadline.unref();
68
+ try {
69
+ const tools = toolState();
70
+ const initialModel = ctx.model;
71
+ const initialDefinition = JSON.stringify(initialModel);
72
+ const pair = { provider: readiness.selection.provider, id: readiness.selection.model };
73
+ const registry = ctx.modelRegistry;
74
+ const manager = ctx.sessionManager;
75
+ const sessionId = manager.getSessionId();
76
+ const cwd = ctx.cwd;
77
+ const trusted = ctx.isProjectTrusted();
78
+ const nativeProvider = registry.getRegisteredNativeProvider(pair.provider);
79
+ const providerConfig = registry.getRegisteredProviderConfig(pair.provider);
80
+ const assertContext = () => {
81
+ signal.throwIfAborted();
82
+ if (
83
+ ctx.modelRegistry !== registry ||
84
+ ctx.sessionManager !== manager ||
85
+ manager.getSessionId() !== sessionId ||
86
+ ctx.cwd !== cwd ||
87
+ ctx.isProjectTrusted() !== trusted ||
88
+ registry.getRegisteredNativeProvider(pair.provider) !== nativeProvider ||
89
+ registry.getRegisteredProviderConfig(pair.provider) !== providerConfig ||
90
+ JSON.stringify(toolState()) !== JSON.stringify(tools)
91
+ )
92
+ throw new ReadinessError("Subagent child context changed during model readiness.");
93
+ };
94
+ const assertInitialModel = () => {
95
+ assertContext();
96
+ if (ctx.model !== initialModel || JSON.stringify(ctx.model) !== initialDefinition) {
97
+ throw new ReadinessError("Subagent child context changed during model readiness.");
98
+ }
99
+ };
100
+ // The public setter checks the configured-auth snapshot before checking fresh auth.
101
+ // Refresh it without network access; refresh can recompose registered model objects.
102
+ await registry.refresh({ providers: [pair.provider], allowNetwork: false, signal });
103
+ if (settled) return;
104
+ assertInitialModel();
105
+ const registration = registry.find(pair.provider, pair.id);
106
+ if (!registration) {
107
+ throw new ReadinessError("Subagent child model is not registered as an exact chat model.");
108
+ }
109
+ const registeredDefinition = JSON.stringify(registration);
110
+ const assertRegistration = () => {
111
+ if (JSON.stringify(registry.find(pair.provider, pair.id)) !== registeredDefinition) {
112
+ throw new ReadinessError("Subagent child registration changed during model readiness.");
113
+ }
114
+ };
115
+ // Use a fresh provider-scoped availability check, not the cached chat snapshot.
116
+ const available = await registry.getAvailableOfType("chat", pair.provider, { signal });
117
+ if (settled) return;
118
+ assertInitialModel();
119
+ assertRegistration();
120
+ if (!available.some((entry) => entry.provider === pair.provider && entry.id === pair.id)) {
121
+ throw new ReadinessError(
122
+ "Subagent child model is unavailable as an exact chat model with current credentials.",
123
+ );
124
+ }
125
+ // CLI matching is not literal. Bind only the exact available registration before any task.
126
+ // setModel has no signal; the parent startup deadline terminates the isolated child.
127
+ let selectedModel = ctx.model;
128
+ if (
129
+ !isChildModelPair(selectedModel) ||
130
+ selectedModel.provider !== pair.provider ||
131
+ selectedModel.id !== pair.id ||
132
+ JSON.stringify(selectedModel) !== registeredDefinition
133
+ ) {
134
+ const selected = await pi.setModel(registration);
135
+ if (settled) return;
136
+ assertContext();
137
+ assertRegistration();
138
+ if (!selected) throw new ReadinessError("Subagent child could not select the exact requested model.");
139
+ selectedModel = registration;
140
+ }
141
+ const assertSelected = () => {
142
+ assertContext();
143
+ assertRegistration();
144
+ const model = ctx.model;
145
+ if (
146
+ model !== selectedModel ||
147
+ !isChildModelPair(model) ||
148
+ model.provider !== pair.provider ||
149
+ model.id !== pair.id ||
150
+ JSON.stringify(model) !== registeredDefinition
151
+ )
152
+ throw new ReadinessError("Subagent child model changed during exact initial selection.");
153
+ };
154
+ assertSelected();
155
+ // setModel applies model/global defaults and awaits model_select hooks. Restore the request
156
+ // only after verifying their result; Pi clamps it to this exact model's capabilities.
157
+ pi.setThinkingLevel(readiness.selection.thinkingLevel);
158
+ assertSelected();
159
+ // Selection hooks can change credentials or filters without replacing registration. Include
160
+ // synchronous thinking hooks; Pi does not await their later asynchronous continuations.
161
+ const availableAfterSelection = await registry.getAvailableOfType("chat", pair.provider, { signal });
162
+ if (settled) return;
163
+ assertSelected();
164
+ if (!availableAfterSelection.some((entry) => entry.provider === pair.provider && entry.id === pair.id)) {
165
+ throw new ReadinessError(
166
+ "Subagent child model is unavailable as an exact chat model with current credentials.",
167
+ );
168
+ }
169
+ publish({ ok: true, sources: tools.sources, model: pair });
170
+ } catch (error) {
171
+ // Provider/auth errors can contain secrets and paths. Publish only bounded probe-owned diagnostics.
172
+ publish({
173
+ ok: false,
174
+ error: error instanceof ReadinessError ? error.message : "Subagent child model readiness failed.",
175
+ });
176
+ } finally {
177
+ if (deadline) clearTimeout(deadline);
178
+ controller.abort();
179
+ }
54
180
  });
55
181
 
56
182
  pi.on("session_shutdown", () => {
@@ -0,0 +1,118 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { isWithin } from "./attachment-utils.js";
4
+ import { MAX_READINESS_FRAME_BYTES } from "./broker-credentials.js";
5
+ import { sanitizeTerminalText } from "./message-broker.js";
6
+
7
+ export interface ChildModelPair {
8
+ provider: string;
9
+ id: string;
10
+ }
11
+
12
+ export type ChildReadinessFrame = { ok: true; sources: string[]; model: ChildModelPair } | { ok: false; error: string };
13
+
14
+ export function isChildModelPair(value: unknown): value is ChildModelPair {
15
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
16
+ const model = value as Record<string, unknown>;
17
+ return [model.provider, model.id].every(
18
+ (part) =>
19
+ typeof part === "string" &&
20
+ part.trim().length > 0 &&
21
+ ![...part].some((character) => {
22
+ const code = character.codePointAt(0) ?? 0;
23
+ return code <= 0x1f || (code >= 0x7f && code <= 0x9f);
24
+ }),
25
+ );
26
+ }
27
+
28
+ export function assertChildModelPair(value: unknown, expected: { provider: string; model: string }): void {
29
+ if (!isChildModelPair(value)) throw new Error("Subagent child model attestation returned an invalid model.");
30
+ if (value.provider !== expected.provider || value.id !== expected.model) {
31
+ throw new Error("Subagent child model does not match the requested provider and model.");
32
+ }
33
+ }
34
+
35
+ export function assertChildModelState(value: unknown, expected: { provider: string; model: string }): void {
36
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
37
+ throw new Error("Subagent child model attestation returned invalid state.");
38
+ }
39
+ assertChildModelPair((value as { model?: unknown }).model, expected);
40
+ }
41
+
42
+ export function parseReadinessFrame(buffer: Buffer, expectedToolCount: number): ChildReadinessFrame {
43
+ if (buffer.byteLength > MAX_READINESS_FRAME_BYTES) {
44
+ throw new Error("Subagent readiness frame exceeded its size limit.");
45
+ }
46
+ if (buffer.byteLength === 0) throw new Error("Subagent readiness pipe closed without a result.");
47
+ let text: string;
48
+ try {
49
+ text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(buffer);
50
+ } catch {
51
+ throw new Error("Subagent readiness pipe returned invalid UTF-8.");
52
+ }
53
+ const newline = text.indexOf("\n");
54
+ if (newline < 0 || newline !== text.length - 1) {
55
+ throw new Error("Subagent readiness pipe returned an invalid frame.");
56
+ }
57
+ let value: unknown;
58
+ try {
59
+ value = JSON.parse(text.slice(0, newline));
60
+ } catch {
61
+ throw new Error("Subagent readiness pipe returned malformed JSON.");
62
+ }
63
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
64
+ throw new Error("Subagent readiness pipe returned an invalid result.");
65
+ }
66
+ const record = value as Record<string, unknown>;
67
+ if (
68
+ record.ok === true &&
69
+ Object.keys(record).length === 3 &&
70
+ Array.isArray(record.sources) &&
71
+ record.sources.length === expectedToolCount &&
72
+ record.sources.every((source) => typeof source === "string" && /^[a-f0-9]{64}$/u.test(source)) &&
73
+ isChildModelPair(record.model) &&
74
+ Object.keys(record.model).length === 2
75
+ ) {
76
+ return { ok: true, sources: record.sources, model: record.model };
77
+ }
78
+ if (
79
+ record.ok === false &&
80
+ Object.keys(record).length === 2 &&
81
+ typeof record.error === "string" &&
82
+ record.error.length > 0 &&
83
+ Buffer.byteLength(record.error, "utf8") <= 8 * 1024
84
+ ) {
85
+ return { ok: false, error: sanitizeTerminalText(record.error) };
86
+ }
87
+ throw new Error("Subagent readiness pipe returned an invalid result.");
88
+ }
89
+
90
+ export function assertTrustedChildResources(value: unknown, cwd: string): void {
91
+ const invalid = () => new Error("Subagent child resource attestation returned invalid commands.");
92
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw invalid();
93
+ const commands = (value as { commands?: unknown }).commands;
94
+ if (!Array.isArray(commands)) throw invalid();
95
+ let canonicalCwd: string;
96
+ try {
97
+ canonicalCwd = fs.realpathSync(cwd);
98
+ } catch {
99
+ throw invalid();
100
+ }
101
+ for (const command of commands) {
102
+ if (!command || typeof command !== "object" || Array.isArray(command)) throw invalid();
103
+ const entry = command as { source?: unknown; sourceInfo?: { path?: unknown } };
104
+ if (entry.source !== "skill" && entry.source !== "prompt") continue;
105
+ const resourcePath = entry.sourceInfo?.path;
106
+ if (typeof resourcePath !== "string" || !resourcePath) throw invalid();
107
+ const lexicalPath = path.resolve(cwd, resourcePath);
108
+ let canonicalPath: string;
109
+ try {
110
+ canonicalPath = fs.realpathSync(lexicalPath);
111
+ } catch {
112
+ throw invalid();
113
+ }
114
+ if (isWithin(cwd, lexicalPath) || isWithin(canonicalCwd, canonicalPath)) {
115
+ throw new Error("Subagent child cannot load project resources because the project is not trusted.");
116
+ }
117
+ }
118
+ }