@narumitw/pi-subagents 3.0.1 → 3.1.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.
@@ -0,0 +1,37 @@
1
+ # Pi Subagents attachment behavior
2
+
3
+ `subagent_spawn` accepts explicit local `skills` and `extensions` for one child job. Automatic resource discovery and inheritance from the parent session remain disabled. See [Tools](./tools.md) for argument shapes and [Security and privacy](../README.md#-security-and-privacy) before attaching executable code.
4
+
5
+ ## Skills
6
+
7
+ Each `skills` path must be an existing local Markdown file or directory with at least one skill Pi can load. Skill names must be unique across explicit paths and skills contributed by attached extension packages. Repeated canonical skill paths are removed in first-use order.
8
+
9
+ Every non-ignored declared skill must load successfully, including skills contributed by an attached extension package: an invalid or unreadable sibling cannot be hidden by a valid one. Pi-ignored draft skills remain excluded. Non-Markdown file paths, directories without a loadable skill, and duplicate skill names fail before launch.
10
+
11
+ Skills use Pi's progressive disclosure. Attaching one does not inject its complete body, add work tools, or force the child to invoke it.
12
+
13
+ Skill preflight is asynchronous and cancellation-aware. It rejects recursive directory links and combined explicit and package-skill scans exceeding 4,096 entries, depth 32, 4 MiB of candidate skill content, or 1 MiB of ignore-file content before Pi's synchronous loader runs.
14
+
15
+ ## Extensions
16
+
17
+ Each `extensions` entry specifies an existing local extension file or directory and the exact extension tools to select initially. An empty `tools` list permits provider or lifecycle behavior without initially exposing extension tools. Tool names cannot overlap Pi core or the built-in `subagent_send` and `subagent_wait` tools; different attachments cannot request the same extension tool name.
18
+
19
+ An extension directory must resolve to at least one loadable Pi entrypoint. Every exact `pi.extensions` declaration must contribute a directly loadable entrypoint. Partially resolved packages and authoritative Pi manifests without extensions are rejected before launch.
20
+
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
+
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.
24
+
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
+
27
+ ## Paths, limits, and providers
28
+
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
+
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.
32
+
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
+
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.
36
+
37
+ 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,9 +12,15 @@ 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, and one explicit task.
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.
16
16
 
17
- The task defines the child's specialization, while the selected tools define its authority.
17
+ The task defines the child's specialization, while selected tools and trusted attachments define its authority.
18
+
19
+ Skills add progressively disclosed instructions without automatically adding tools or injecting their complete bodies.
20
+
21
+ Extensions add fully privileged executable code and may change prompts, providers, tools, or lifecycle behavior, so their initial tool lists are least-privilege loadout declarations rather than a sandbox.
22
+
23
+ Load only explicit local attachments, require project trust for project-local code, and never infer or inherit parent resources automatically.
18
24
 
19
25
  Give each subagent a self-contained task containing only the context needed to complete that task.
20
26
 
@@ -0,0 +1,23 @@
1
+ # Pi Subagents messaging and lifecycle
2
+
3
+ Main and child processes have separate `subagent_send` definitions. The main agent starts a request with an active job ID as `recipient` and answers a child request with its `requestId`. A child omits `requestId` to ask the main agent a question and provides it to answer a pending main-agent request. See [Tools](./tools.md) for the parameter schemas.
4
+
5
+ ## Delivery and waiting
6
+
7
+ A main-originated request to a queued job waits for the child RPC prompt to be accepted, then uses Pi steering to reach the running child. After RPC accepts steering, the runtime interrupts active child response waits without consuming their original requests. The child may retry `subagent_wait` for a response it still needs.
8
+
9
+ Caller cancellation before RPC delivery starts rolls the request back. Once delivery starts, cancellation stops only the caller's wait: the request may still arrive and remains answerable until delivery fails or the job terminates. A wait timeout likewise stops only that wait, not the job or the underlying message request.
10
+
11
+ A child response arrives asynchronously in the main session and interrupts the next active main-agent `subagent_wait`, including when the response arrived immediately before the wait started. The first accepted response wins; repeated responses acknowledge the existing response without replacing it.
12
+
13
+ Each job may have up to four unresolved or answered-but-not-consumed requests across both directions. Requests and responses are limited to 48 KiB and 1,992 lines so their envelopes fit Pi's model-text bounds. Terminal jobs, unknown requests, cross-job responses, responses from the request originator, and stale session credentials throw. Successful sends and responses return `{ requestId, accepted, duplicate }`.
14
+
15
+ ## Job lifecycle
16
+
17
+ A job starts `queued`, becomes `running`, and reaches exactly one terminal state: `completed`, `partial`, `failed`, `timed_out`, or `cancelled`. Job execution timeouts start after Pi accepts the RPC prompt. The runtime retains up to 32 recent terminal records for up to 24 hours in the current extension session; inspection reports removed records through `omitted.jobs`.
18
+
19
+ Cancelling or terminalizing a job revokes its communication token and rejects pending child waits before stale output can replace the terminal state. Session replacement and shutdown cancel active work, suppress stale completion delivery, revoke credentials, close sockets, and stop the broker. An asynchronous job completion does not wake an otherwise idle main-agent model turn.
20
+
21
+ The session starts one loopback TCP broker on `127.0.0.1` with an ephemeral port. Each job receives a random token bound to its job identity and session generation. The parent passes credentials through a private inherited pipe rather than the child's initial environment or command line. Child broker calls use request-scoped connections; response waits use abortable long polling.
22
+
23
+ For authorization boundaries and credential exposure, see [Security and privacy](../README.md#-security-and-privacy).
package/docs/tools.md CHANGED
@@ -5,29 +5,21 @@
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
- | `tools` | `string[]` | No | Up to 64 names from `read`, `bash`, `powershell`, `edit`, `write`, `grep`, `find`, and `ls`; defaults to `read`, `grep`, `find`, and `ls`. |
9
- | `thinkingLevel` | `string` | No | `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, or `max`; defaults to the main agent's effective thinking level. |
10
- | `timeout` | `number` | No | Seconds; `> 0` through `2,147,483.647`; no default timeout. |
8
+ | `tools` | `string[]` | No | Pi core tools: `read`, `bash`, `powershell`, `edit`, `write`, `grep`, `find`, `ls`; defaults to `read`, `grep`, `find`, `ls`. |
9
+ | `skills` | `string[]` | No | Up to 16 local skill files or directories; Pi uses progressive disclosure. |
10
+ | `extensions` | `{ path: string; tools: string[] }[]` | No | Up to 16 trusted local extensions and their initially selected tools. |
11
+ | `thinkingLevel` | `string` | No | `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, or `max`; defaults to the main agent's level. |
12
+ | `timeout` | `number` | No | Seconds; `> 0` through `2,147,483.647`; no default. |
11
13
 
12
- Starts one task-specialized subagent job with the selected tool capabilities and returns its job ID immediately.
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`.
13
15
 
14
- The runtime always adds `subagent_send` and `subagent_wait` to the selected tools.
16
+ Skills do not add tools or inject their entire bodies. An extension's `tools` list selects its initial tools; use an empty list for provider or lifecycle behavior only. An attached extension runs trusted code with full child-process permissions, not in a sandbox.
15
17
 
16
- The child inherits the main agent's effective provider and model at spawn time.
17
-
18
- Providers registered by a parent extension throw before the job is queued because children disable unrelated extensions.
19
-
20
- A process-local runtime API key, including a parent-only `--api-key` value, also throws before queuing.
21
-
22
- Use Pi's stored credentials or environment credentials that the child process can read.
23
-
24
- Unavailable or extension-only tool names throw before the job is queued.
25
-
26
- Throws without launching a child when the session broker is unavailable.
18
+ 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.
27
19
 
28
20
  ## `subagent_inspect`
29
21
 
30
- No parameters.
22
+ No parameters. Returns privacy-filtered retained-job metadata without task text, output, selected tools, attachment paths, credentials, or messages.
31
23
 
32
24
  ## `subagent_cancel`
33
25
 
@@ -35,6 +27,8 @@ No parameters.
35
27
  | --- | --- | --- | --- |
36
28
  | `jobId` | `string` | Yes | Job ID returned by `subagent_spawn`. |
37
29
 
30
+ Cancels a queued or running job; repeating the call is safe.
31
+
38
32
  ## `subagent_wait`
39
33
 
40
34
  ### Main agent
@@ -42,26 +36,22 @@ No parameters.
42
36
  | Parameter | Type | Required | Constraint / default |
43
37
  | --- | --- | --- | --- |
44
38
  | `jobId` | `string` | Yes | Job ID to wait for. |
45
- | `timeout` | `number` | No | Seconds; `> 0` through `2,147,483.647`; no default and does not cancel the job. |
39
+ | `timeout` | `number` | No | Seconds; `> 0` through `2,147,483.647`; no default. |
46
40
 
47
- Returns `{ jobId, state, timedOut: false, interrupted: true, reason: "subagent_message" }` without cancelling the job when a child request or response arrives.
41
+ Returns the job result, or returns early with `reason: "subagent_message"` when a child request or response arrives. A wait timeout does not cancel the job.
48
42
 
49
43
  ### Subagent
50
44
 
51
45
  | Parameter | Type | Required | Constraint / default |
52
46
  | --- | --- | --- | --- |
53
47
  | `requestId` | `string` | Yes | Request ID returned by a child-originated `subagent_send`. |
54
- | `timeout` | `number` | No | Seconds; `> 0` through `2,147,483.647`; no default and does not cancel the request. |
55
-
56
- Returns the main agent's response as plain text.
57
-
58
- A timeout, caller cancellation, or incoming main-agent request throws and stops only that wait, so the child may wait for the same request again.
48
+ | `timeout` | `number` | No | Seconds; `> 0` through `2,147,483.647`; no default. |
59
49
 
60
- The runtime interrupts an active child wait only after Pi RPC accepts the incoming main request for steering.
50
+ Returns the main agent's response as plain text. A timeout, cancellation, or incoming main-agent request throws and stops only this wait; the original request remains active and can be waited on again.
61
51
 
62
52
  ## `subagent_send`
63
53
 
64
- Main and child processes receive separate provider-visible definitions for their own context.
54
+ Main and child processes receive separate tool definitions.
65
55
 
66
56
  ### Main agent
67
57
 
@@ -69,41 +59,17 @@ Main and child processes receive separate provider-visible definitions for their
69
59
  | --- | --- | --- | --- |
70
60
  | `recipient` | `string` | Conditional | Active job ID for a new request. |
71
61
  | `requestId` | `string` | Conditional | Pending child request to answer. |
72
- | `message` | `string` | Yes | Plain-text request or response, up to 48 KiB of UTF-8 text and 1,992 lines. |
73
-
74
- Provide exactly one of `recipient` or `requestId`.
75
-
76
- A new request provides an active queued or running job ID as `recipient` and omits `requestId`.
62
+ | `message` | `string` | Yes | Plain text, up to 48 KiB of UTF-8 text and 1,992 lines. |
77
63
 
78
- A response provides `requestId` and omits `recipient`.
64
+ Provide exactly one of `recipient` (new request) or `requestId` (response).
79
65
 
80
66
  ### Subagent
81
67
 
82
68
  | Parameter | Type | Required | Constraint / default |
83
69
  | --- | --- | --- | --- |
84
- | `requestId` | `string` | No | Pending main-agent request to answer; omit to start a new request to main. |
85
- | `message` | `string` | Yes | Plain-text request or response, up to 48 KiB of UTF-8 text and 1,992 lines. |
86
-
87
- A new request omits `requestId` and returns a request ID immediately for an optional `subagent_wait` call.
88
-
89
- A response provides the pending main-agent `requestId`.
90
-
91
- A main-originated request waits for the child RPC prompt to be accepted and then uses Pi steering to reach the running child.
92
-
93
- After steering is queued, the runtime interrupts active child response waits without consuming their original requests.
94
-
95
- Caller cancellation before RPC delivery starts rolls the request back.
96
-
97
- Once RPC delivery starts, cancellation stops only the caller's wait; the request may still arrive and remains answerable until delivery fails or the job terminates.
98
-
99
- A child response arrives asynchronously in the main session and interrupts the next active main-agent `subagent_wait`, including when the response arrived immediately before the wait started.
100
-
101
- The first accepted response wins, and repeated responses acknowledge the existing response without replacing it.
102
-
103
- Each job may have up to four unresolved or answered-but-not-consumed requests across both directions.
104
-
105
- Requests and responses are limited to 1,992 lines so their protocol envelopes fit Pi's 2,000-line model-text bound.
70
+ | `requestId` | `string` | No | Pending main-agent request to answer; omit to start a request. |
71
+ | `message` | `string` | Yes | Plain text, up to 48 KiB of UTF-8 text and 1,992 lines. |
106
72
 
107
- Terminal jobs, unknown requests, cross-job responses, responses from the request originator, and stale session credentials throw.
73
+ A new request returns a `requestId` for an optional `subagent_wait` call. A successful send or response returns `{ requestId, accepted, duplicate }`.
108
74
 
109
- A successful call returns `{ requestId, accepted, duplicate }`.
75
+ See [Messaging and lifecycle](./messaging.md) for delivery, cancellation, retry, and response behavior.
package/package.json CHANGED
@@ -1,56 +1,58 @@
1
1
  {
2
- "name": "@narumitw/pi-subagents",
3
- "version": "3.0.1",
4
- "description": "Subagent jobs with asynchronous main-agent messaging for Pi.",
5
- "type": "module",
6
- "license": "MIT",
7
- "private": false,
8
- "keywords": [
9
- "pi-package",
10
- "pi-extension",
11
- "pi",
12
- "subagents",
13
- "agents",
14
- "delegation"
15
- ],
16
- "files": [
17
- "src",
18
- "dist",
19
- "docs",
20
- "README.md",
21
- "LICENSE"
22
- ],
23
- "pi": {
24
- "extensions": [
25
- "./dist/index.ts"
26
- ]
27
- },
28
- "scripts": {
29
- "build": "node scripts/build-runtime.mjs",
30
- "check": "npm run build && biome check . && npm run typecheck",
31
- "format": "biome check --write .",
32
- "typecheck": "tsc --noEmit",
33
- "prepack": "npm run build"
34
- },
35
- "peerDependencies": {
36
- "@earendil-works/pi-ai": "*",
37
- "@earendil-works/pi-coding-agent": "*",
38
- "@earendil-works/pi-tui": "*",
39
- "typebox": "*"
40
- },
41
- "devDependencies": {
42
- "@biomejs/biome": "2.5.11",
43
- "@earendil-works/pi-ai": "0.84.4",
44
- "@earendil-works/pi-coding-agent": "0.84.4",
45
- "@earendil-works/pi-tui": "0.84.4",
46
- "@types/node": "26.4.0",
47
- "esbuild": "0.28.2",
48
- "typebox": "1.3.20",
49
- "typescript": "7.0.2"
50
- },
51
- "repository": {
52
- "type": "git",
53
- "url": "https://github.com/narumiruna/pi-extensions",
54
- "directory": "packages/pi-subagents"
55
- }
2
+ "name": "@narumitw/pi-subagents",
3
+ "version": "3.1.0",
4
+ "description": "Subagent jobs with asynchronous main-agent messaging for Pi.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "private": false,
8
+ "keywords": [
9
+ "pi-package",
10
+ "pi-extension",
11
+ "pi",
12
+ "subagents",
13
+ "agents",
14
+ "delegation"
15
+ ],
16
+ "files": [
17
+ "src",
18
+ "dist",
19
+ "docs",
20
+ "README.md",
21
+ "LICENSE"
22
+ ],
23
+ "pi": {
24
+ "extensions": [
25
+ "./dist/index.ts"
26
+ ]
27
+ },
28
+ "scripts": {
29
+ "build": "node scripts/build-runtime.mjs",
30
+ "check": "npm run build && biome check . && npm run typecheck",
31
+ "format": "biome check --write .",
32
+ "typecheck": "tsc --noEmit",
33
+ "prepack": "npm run build"
34
+ },
35
+ "peerDependencies": {
36
+ "@earendil-works/pi-ai": "*",
37
+ "@earendil-works/pi-coding-agent": "*",
38
+ "@earendil-works/pi-tui": "*",
39
+ "typebox": "*"
40
+ },
41
+ "devDependencies": {
42
+ "@earendil-works/pi-ai": "0.86.0",
43
+ "@earendil-works/pi-coding-agent": "0.86.0",
44
+ "@earendil-works/pi-tui": "0.86.0",
45
+ "@types/node": "26.6.2",
46
+ "esbuild": "0.28.2",
47
+ "typebox": "1.3.34",
48
+ "typescript": "7.0.2"
49
+ },
50
+ "repository": {
51
+ "type": "git",
52
+ "url": "https://github.com/narumiruna/pi-extensions",
53
+ "directory": "packages/pi-subagents"
54
+ },
55
+ "dependencies": {
56
+ "ignore": "^7.0.8"
57
+ }
56
58
  }
@@ -0,0 +1,104 @@
1
+ import { createHash } from "node:crypto";
2
+ import { realpathSync } from "node:fs";
3
+ import { readFile, stat } from "node:fs/promises";
4
+ import * as path from "node:path";
5
+ import type ignore from "ignore";
6
+ import { sanitizeTerminalText } from "./message-broker.js";
7
+
8
+ const ATTACHMENT_IGNORE_FILE_NAMES = [".gitignore", ".ignore", ".fdignore"];
9
+
10
+ export type IgnoreMatcher = ReturnType<typeof ignore>;
11
+
12
+ function throwInvalidAttachmentIgnoreFile(): never {
13
+ throw new Error("Subagent attachment ignore files must be regular files.");
14
+ }
15
+
16
+ export function throwIfAttachmentAborted(signal: AbortSignal | undefined): void {
17
+ if (!signal?.aborted) return;
18
+ const error = new Error("Subagent attachment validation was cancelled.");
19
+ error.name = "AbortError";
20
+ throw error;
21
+ }
22
+
23
+ export function isAbortError(error: unknown): boolean {
24
+ return error instanceof Error && error.name === "AbortError";
25
+ }
26
+
27
+ function prefixIgnorePattern(line: string, prefix: string): string | null {
28
+ const trimmed = line.trim();
29
+ if (!trimmed || (trimmed.startsWith("#") && !trimmed.startsWith("\\#"))) return null;
30
+ let pattern = line;
31
+ let negated = false;
32
+ if (pattern.startsWith("!")) {
33
+ negated = true;
34
+ pattern = pattern.slice(1);
35
+ } else if (pattern.startsWith("\\!")) {
36
+ pattern = pattern.slice(1);
37
+ }
38
+ if (pattern.startsWith("/")) pattern = pattern.slice(1);
39
+ const prefixed = prefix ? `${prefix}${pattern}` : pattern;
40
+ return negated ? `!${prefixed}` : prefixed;
41
+ }
42
+
43
+ export async function addAttachmentIgnoreRules(
44
+ ignoreMatcher: IgnoreMatcher,
45
+ directory: string,
46
+ rootDirectory: string,
47
+ signal: AbortSignal | undefined,
48
+ accountForFile: (ignorePath: string, bytes: number) => void,
49
+ ): Promise<void> {
50
+ const relativeDirectory = path.relative(rootDirectory, directory);
51
+ const prefix = relativeDirectory ? `${toPosixPath(relativeDirectory)}/` : "";
52
+ for (const filename of ATTACHMENT_IGNORE_FILE_NAMES) {
53
+ throwIfAttachmentAborted(signal);
54
+ const ignorePath = path.join(directory, filename);
55
+ let ignoreStats: Awaited<ReturnType<typeof stat>>;
56
+ try {
57
+ ignoreStats = await stat(ignorePath);
58
+ } catch (error) {
59
+ if (isAbortError(error)) throw error;
60
+ throwIfAttachmentAborted(signal);
61
+ continue;
62
+ }
63
+ if (!ignoreStats.isFile()) throwInvalidAttachmentIgnoreFile();
64
+ accountForFile(ignorePath, ignoreStats.size);
65
+ try {
66
+ const content = await readFile(ignorePath, { encoding: "utf8", signal });
67
+ throwIfAttachmentAborted(signal);
68
+ const patterns = content
69
+ .split(/\r?\n/u)
70
+ .map((line) => prefixIgnorePattern(line, prefix))
71
+ .filter((line): line is string => line !== null);
72
+ if (patterns.length > 0) ignoreMatcher.add(patterns);
73
+ } catch (error) {
74
+ if (isAbortError(error)) throw error;
75
+ throwIfAttachmentAborted(signal);
76
+ // Pi ignores unreadable ignore files.
77
+ }
78
+ }
79
+ }
80
+
81
+ export function toPosixPath(value: string): string {
82
+ return value.split(path.sep).join("/");
83
+ }
84
+
85
+ export function toolSourceId(value: string): string {
86
+ return createHash("sha256").update(value).digest("hex");
87
+ }
88
+
89
+ export function realpath(value: string, label: string): string {
90
+ try {
91
+ return realpathSync(value);
92
+ } catch {
93
+ throw new Error(`${label} path does not exist: ${sanitizeTerminalText(value).slice(0, 512)}`);
94
+ }
95
+ }
96
+
97
+ export function isWithin(root: string, candidate: string): boolean {
98
+ const relative = path.relative(root, candidate);
99
+ return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));
100
+ }
101
+
102
+ export function isRecord(value: unknown): value is Record<string, unknown> {
103
+ return typeof value === "object" && value !== null && !Array.isArray(value);
104
+ }
@@ -3,69 +3,147 @@ import type { BrokerCredentials } 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";
6
+ export const CHILD_READINESS_FD = 4;
7
+ export const CHILD_READINESS_FD_ENV = "PI_SUBAGENT_READINESS_FD";
8
+ export const MAX_READINESS_FRAME_BYTES = 16 * 1024;
6
9
 
7
- const MAX_CREDENTIAL_BYTES = 1_024;
10
+ const MAX_BOOTSTRAP_BYTES = 16 * 1024;
11
+ const MAX_EXPECTED_TOOLS = 66;
12
+ const MAX_TOOL_NAME_LENGTH = 128;
13
+ const READINESS_STATE_KEY = Symbol.for("@narumitw/pi-subagents/child-readiness");
8
14
 
9
- export function brokerCredentialEnvironment(): NodeJS.ProcessEnv {
10
- return { [BROKER_CREDENTIAL_FD_ENV]: String(BROKER_CREDENTIAL_FD) };
15
+ export interface ChildBootstrap {
16
+ communication: BrokerCredentials;
17
+ expectedTools: string[];
11
18
  }
12
19
 
13
- export function serializeBrokerCredentials(credentials: BrokerCredentials): string {
14
- return JSON.stringify(credentials);
20
+ export interface CapturedChildBootstrap extends ChildBootstrap {
21
+ readinessFd?: number;
15
22
  }
16
23
 
17
- export function captureBrokerCredentials(
18
- readCredentials: () => string = readCredentialPipe,
19
- ): BrokerCredentials | undefined {
20
- const descriptor = process.env[BROKER_CREDENTIAL_FD_ENV];
21
- delete process.env[BROKER_CREDENTIAL_FD_ENV];
22
- if (descriptor === undefined) return undefined;
23
- if (descriptor !== String(BROKER_CREDENTIAL_FD)) {
24
- throw new Error("Invalid pi-subagents broker credential descriptor.");
25
- }
26
- let serialized: string;
27
- try {
28
- serialized = readCredentials();
29
- } catch {
30
- throw new Error("Unable to read pi-subagents broker credentials.");
31
- }
32
- if (Buffer.byteLength(serialized, "utf8") > MAX_CREDENTIAL_BYTES) {
33
- throw new Error("Invalid pi-subagents broker credentials.");
34
- }
35
- let value: unknown;
36
- try {
37
- value = JSON.parse(serialized);
38
- } catch {
39
- throw new Error("Invalid pi-subagents broker credentials.");
40
- }
41
- if (!isBrokerCredentials(value)) {
42
- throw new Error("Invalid pi-subagents broker credentials.");
43
- }
44
- return value;
24
+ export function childBootstrapEnvironment(expectReadiness: boolean): NodeJS.ProcessEnv {
25
+ return {
26
+ [BROKER_CREDENTIAL_FD_ENV]: String(BROKER_CREDENTIAL_FD),
27
+ ...(expectReadiness ? { [CHILD_READINESS_FD_ENV]: String(CHILD_READINESS_FD) } : {}),
28
+ };
45
29
  }
46
30
 
47
- function readCredentialPipe(): string {
48
- try {
49
- return fs.readFileSync(BROKER_CREDENTIAL_FD, "utf8");
50
- } finally {
51
- try {
52
- fs.closeSync(BROKER_CREDENTIAL_FD);
53
- } catch {
54
- // The descriptor may already be closed after a failed read.
55
- }
56
- }
31
+ export function serializeChildBootstrap(bootstrap: ChildBootstrap): string {
32
+ return JSON.stringify(bootstrap);
33
+ }
34
+
35
+ export function assertChildBootstrapCapacity(expectedTools: string[]): void {
36
+ const serialized = serializeChildBootstrap({
37
+ communication: { host: "127.0.0.1", port: 65_535, token: "f".repeat(64) },
38
+ expectedTools,
39
+ });
40
+ 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.`);
42
+ }
43
+ }
44
+
45
+ export function captureChildBootstrap(
46
+ readBootstrap: () => string = readBootstrapPipe,
47
+ ): CapturedChildBootstrap | undefined {
48
+ const credentialDescriptor = process.env[BROKER_CREDENTIAL_FD_ENV];
49
+ const readinessDescriptor = process.env[CHILD_READINESS_FD_ENV];
50
+ delete process.env[BROKER_CREDENTIAL_FD_ENV];
51
+ delete process.env[CHILD_READINESS_FD_ENV];
52
+ setCapturedReadiness(undefined);
53
+ if (credentialDescriptor === undefined) {
54
+ if (readinessDescriptor !== undefined) throw new Error("Unexpected pi-subagents readiness descriptor.");
55
+ return undefined;
56
+ }
57
+ if (credentialDescriptor !== String(BROKER_CREDENTIAL_FD)) {
58
+ throw new Error("Invalid pi-subagents broker credential descriptor.");
59
+ }
60
+ let serialized: string;
61
+ try {
62
+ serialized = readBootstrap();
63
+ } catch {
64
+ throw new Error("Unable to read pi-subagents child bootstrap.");
65
+ }
66
+ if (Buffer.byteLength(serialized, "utf8") > MAX_BOOTSTRAP_BYTES) {
67
+ throw new Error("Invalid pi-subagents child bootstrap.");
68
+ }
69
+ let value: unknown;
70
+ try {
71
+ value = JSON.parse(serialized);
72
+ } catch {
73
+ throw new Error("Invalid pi-subagents child bootstrap.");
74
+ }
75
+ if (!isChildBootstrap(value)) throw new Error("Invalid pi-subagents child bootstrap.");
76
+
77
+ if (value.expectedTools.length > 0) {
78
+ if (readinessDescriptor !== String(CHILD_READINESS_FD)) {
79
+ throw new Error("Invalid pi-subagents readiness descriptor.");
80
+ }
81
+ setCapturedReadiness({ fd: CHILD_READINESS_FD, expectedTools: [...value.expectedTools] });
82
+ return { ...value, readinessFd: CHILD_READINESS_FD };
83
+ }
84
+ if (readinessDescriptor !== undefined) throw new Error("Unexpected pi-subagents readiness descriptor.");
85
+ setCapturedReadiness(undefined);
86
+ return value;
87
+ }
88
+
89
+ export function takeCapturedReadiness(): { fd: number; expectedTools: string[] } | undefined {
90
+ const store = globalThis as unknown as Record<symbol, { fd: number; expectedTools: string[] } | undefined>;
91
+ const readiness = store[READINESS_STATE_KEY];
92
+ delete store[READINESS_STATE_KEY];
93
+ return readiness ? { fd: readiness.fd, expectedTools: [...readiness.expectedTools] } : undefined;
94
+ }
95
+
96
+ function setCapturedReadiness(readiness: { fd: number; expectedTools: string[] } | undefined): void {
97
+ const store = globalThis as unknown as Record<symbol, { fd: number; expectedTools: string[] } | undefined>;
98
+ if (readiness) store[READINESS_STATE_KEY] = readiness;
99
+ else delete store[READINESS_STATE_KEY];
100
+ }
101
+
102
+ function readBootstrapPipe(): string {
103
+ try {
104
+ return fs.readFileSync(BROKER_CREDENTIAL_FD, "utf8");
105
+ } finally {
106
+ try {
107
+ fs.closeSync(BROKER_CREDENTIAL_FD);
108
+ } catch {
109
+ // The descriptor may already be closed after a failed read.
110
+ }
111
+ }
112
+ }
113
+
114
+ 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;
117
+ if (!Array.isArray(value.expectedTools) || value.expectedTools.length > MAX_EXPECTED_TOOLS) return false;
118
+ return value.expectedTools.every(
119
+ (tool) =>
120
+ typeof tool === "string" &&
121
+ tool.length > 0 &&
122
+ tool.length <= MAX_TOOL_NAME_LENGTH &&
123
+ !tool.includes(",") &&
124
+ !hasControlCharacter(tool),
125
+ );
57
126
  }
58
127
 
59
128
  function isBrokerCredentials(value: unknown): value is BrokerCredentials {
60
- if (!value || typeof value !== "object" || Array.isArray(value)) return false;
61
- const candidate = value as Record<string, unknown>;
62
- return (
63
- Object.keys(candidate).length === 3 &&
64
- candidate.host === "127.0.0.1" &&
65
- Number.isSafeInteger(candidate.port) &&
66
- (candidate.port as number) >= 1 &&
67
- (candidate.port as number) <= 65_535 &&
68
- typeof candidate.token === "string" &&
69
- /^[a-f0-9]{64}$/u.test(candidate.token)
70
- );
129
+ if (!isRecord(value) || Object.keys(value).length !== 3) return false;
130
+ return (
131
+ value.host === "127.0.0.1" &&
132
+ Number.isSafeInteger(value.port) &&
133
+ (value.port as number) >= 1 &&
134
+ (value.port as number) <= 65_535 &&
135
+ typeof value.token === "string" &&
136
+ /^[a-f0-9]{64}$/u.test(value.token)
137
+ );
138
+ }
139
+
140
+ function hasControlCharacter(value: string): boolean {
141
+ return [...value].some((character) => {
142
+ const codePoint = character.codePointAt(0) ?? 0;
143
+ return codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f);
144
+ });
145
+ }
146
+
147
+ function isRecord(value: unknown): value is Record<string, unknown> {
148
+ return typeof value === "object" && value !== null && !Array.isArray(value);
71
149
  }