@cr1ms0n/pi-subagent 0.8.9 → 0.10.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.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: subagent
3
- description: Delegate work to isolated child agents with the subagent tool model and thinking policy, explore/review/general profiles, parallel fanout with synthesis, worktree isolation and the diff/apply/discard loop, background runs, steering, output_schema, context fork, and backend tradeoffs (pi/codex/claude). Use when delegating exploration or implementation, running tasks in parallel, or when a subagent run needs inspecting, steering, or landing.
3
+ description: Delegate work to isolated child agents with the subagent tool. Jev routes each new dispatch to an execution model and individual tools from the user's configured candidate list; covers explore/review/general profiles, parallel fanout with best-effort synthesis, worktree isolation and the diff/apply/discard loop, background runs, steering, output_schema, context fork, and the Pi-only new-dispatch rule. Use when delegating exploration or implementation, running tasks in parallel, or when a subagent run needs inspecting, steering, or landing.
4
4
  ---
5
5
 
6
6
  # Subagent
@@ -20,24 +20,24 @@ from isolation, parallelism, or a fresh context.
20
20
  ## Core calls
21
21
 
22
22
  ```ts
23
- // Examples use placeholders only. Replace these with the exact model selected
24
- // from the current modelPolicy route; they do not configure a real model.
25
- const routeModel = "<exact model from current modelPolicy route>";
23
+ // Omit model and fallback_models. Jev selects the execution model from the
24
+ // user's configured candidate list and the individual tools from the locally
25
+ // permitted catalog. An explicit model/fallback is rejected on new work.
26
26
 
27
27
  // Single foreground task (default profile: general)
28
- { task: "Find call sites of parseConfig", description: "Map parseConfig", model: routeModel }
28
+ { task: "Find call sites of parseConfig", description: "Map parseConfig" }
29
29
 
30
30
  // Parallel read-only explorers (default profile for tasks[]: explore)
31
31
  {
32
32
  tasks: [
33
- { task: "Map auth middleware", description: "Auth flow", model: routeModel },
34
- { task: "List env vars in server/", description: "Env inventory", model: routeModel }
33
+ { task: "Map auth middleware", description: "Auth flow" },
34
+ { task: "List env vars in server/", description: "Env inventory" }
35
35
  ],
36
36
  synthesis: "Merge into one prioritized brief"
37
37
  }
38
38
 
39
- // Background notified on completion; wait/status still work
40
- { task: "Audit dependency licenses", model: routeModel, async: true }
39
+ // Background: notified on completion; wait/status still work
40
+ { task: "Audit dependency licenses", async: true }
41
41
  { action: "status", id: "abc123" }
42
42
  { action: "wait", id: "abc123" } // interruptible; does not cancel
43
43
  { action: "cancel", id: "abc123" }
@@ -45,69 +45,102 @@ const routeModel = "<exact model from current modelPolicy route>";
45
45
  // subagent_wait { id: "abc123", timeout_ms?: number }
46
46
 
47
47
  // Worktree loop
48
- { task: "Implement feature A", model: routeModel, profile: "general", isolation: "worktree" }
48
+ { task: "Implement feature A", profile: "general", isolation: "worktree" }
49
49
  { action: "diff", id: "abc123", index: 1 }
50
50
  { action: "apply", id: "abc123", index: 1 }
51
51
  { action: "discard", id: "abc123", index: 1 }
52
52
 
53
- // Dry-run validation + resolved plan (no spawn)
54
- // plan is a dry-run, but every task still needs the policy-routed model.
55
- { action: "plan", tasks: [{ task: "…", model: routeModel, isolation: "worktree" }] }
53
+ // Dry-run validation + resolved plan (no spawn).
54
+ // plan calls Jev and incurs selector fees, then a later dispatch selects again.
55
+ { action: "plan", tasks: [{ task: "…", isolation: "worktree" }] }
56
56
  ```
57
57
 
58
58
  ## Profiles
59
59
 
60
60
  | Profile | Tools | Writes |
61
61
  | --------- | --------------------------------------------------------- | ------------------------------------------- |
62
- | `explore` | read/search/ls (+safe) + Pi context tools | no project-file writes |
62
+ | `explore` | locally permitted read-only tools + Pi context tools | no project-file writes |
63
63
  | `review` | same as explore | no project-file writes |
64
- | `general` | inherited active tools + Pi context tools | yes if tools include bash/edit/write |
64
+ | `general` | Jev chooses from the full available locally permitted catalog + Pi context tools | yes if the selected tools include bash/edit/write |
65
+
66
+ Jev picks individual tool names, not a capability bundle. Candidates come from
67
+ the full available locally permitted catalog, not from agent `tools` defaults and
68
+ not from only the parent's active tools. An explicit `tools` list is a ceiling,
69
+ explore/review stay read-only regardless of the answer, and an empty selection
70
+ never means "all tools".
65
71
 
66
72
  For Pi children, `new_context`, `get_context_remaining`, `history`, and
67
- `notes` are control-plane tools. When available in the parent they remain in
68
- the child allowlist—even if a narrower tool list was requested—so Pi's remote
69
- `contextManagement` can stay active. They may update context notes/window
70
- state, but never grant `bash`, `edit`, or `write` access.
73
+ `notes` are added locally when the parent exposes them, so the selector never
74
+ asks about them. They are control-plane tools: they may update context
75
+ notes/window state, but never grant `bash`, `edit`, or `write` access. Route
76
+ metadata reports them as local additions.
77
+
78
+ The finalized tool subset is passed to the child as Pi's `--tools` allowlist
79
+ (`--no-tools` for a true empty set). Pi 0.86.0 is the verified baseline for
80
+ built-in, extension and late-registered tool enforcement; an unsupported host is
81
+ refused rather than silently weakened.
71
82
 
72
83
  Parallel write-capable tasks sharing one checkout are rejected unless each uses
73
84
  `isolation: "worktree"`, a distinct `cwd`, or `allow_shared_writes: true`.
74
85
 
75
86
  ## Backends
76
87
 
77
- `backend: "pi" | "codex" | "claude"` (default `pi`). Unsupported combinations are
78
- **refused**, not silently degraded:
79
-
80
- | | pi | codex | claude |
81
- | ------------------------ | -------------- | ---------------------- | -------------- |
82
- | `max_cost` | yes | refused (tokens only) | yes |
83
- | read-only profile | tool allowlist | OS sandbox | tool allowlist |
84
- | steering / grace wrap-up | yes | no | no |
85
- | `context: "fork"` | yes | refused | yes |
86
- | `thinking` | yes | no | no |
87
- | `output_schema` | yes | yes | yes |
88
+ New dispatch is Pi-only. `backend: "codex"` or `backend: "claude"` on new work
89
+ is **refused** before any selector or provider work, including a backend
90
+ inherited from agent frontmatter, and is never silently switched to Pi. Existing
91
+ Codex/Claude runs remain manageable through `status`/`wait`/`cancel`/`steer`/
92
+ `diff`/`apply`/`discard`.
93
+
94
+ Another provider's execution model is still eligible through Pi when the user
95
+ lists it in their candidate configuration. Unsupported combinations inside the
96
+ Pi path are **refused**, not silently degraded:
97
+
98
+ | | pi |
99
+ | ------------------------ | -------------- |
100
+ | `max_cost` | yes (provider-reported execution only; not selector currency) |
101
+ | read-only profile | tool allowlist |
102
+ | steering / grace wrap-up | yes |
103
+ | `context: "fork"` | yes |
104
+ | `thinking` | yes |
105
+ | `output_schema` | yes |
88
106
 
89
107
  ## Budgets and safety
90
108
 
91
109
  - Prefer `max_turns`, `max_cost`, and/or `timeout_ms` on long or write-capable runs.
110
+ `timeout_ms` is absolute: local preflight, Jev selection, setup, queue and
111
+ runtime all count against it.
92
112
  - `output_schema` asks the child for a fenced `json:result` block (one repair round).
93
- - `context: "fork"` continues from a fork of the parent session (pi/claude).
94
- - Do not poll `status` in a tight loop use `wait` / `subagent_wait`, or let the
113
+ - `context: "fork"` continues from a fork of the parent session.
114
+ - Do not poll `status` in a tight loop. Use `wait` / `subagent_wait`, or let the
95
115
  completion notification arrive for `async: true` runs.
96
116
  - Point the user at `/subagents` for the live inspector and `/subagent-cost` for
97
- the root / subagent / combined ledger.
98
-
99
- ## Model policy
100
-
101
- Every new task must pass a `model` that exactly matches the current
102
- `modelPolicy` mapping in `~/.pi/subagent.json`; agent frontmatter,
103
- `taskDefaults.model`, and parent-session model inheritance are ignored. An
104
- agent route replaces the default route, and configured fallback order is
105
- immutable. Omit `fallback_models` to use the route; if supplied, it must match
106
- exactly. An optional route `thinking` value is an opaque Pi thinking-level
107
- string; common values include `off`, `minimal`, `low`, `medium`, `high`, `xhigh`,
108
- and `max`, but model-specific values are passed through unchanged. It is a
109
- default; explicit task, agent, and profile `taskDefaults.thinking` values
110
- override it. Management actions do not require model. The extension re-reads
111
- this policy on each dispatch and injects it into
112
- the parent prompt. If the policy is missing or invalid, management remains
113
- available but new spawns and synthesis are rejected.
117
+ the root / subagent / routing / combined ledger. Routing cost is reported as
118
+ unreported (tokens only, no currency).
119
+
120
+ ## Routing
121
+
122
+ Omit `model` and `fallback_models` on every new call: both are legacy fields,
123
+ and an explicit value is rejected rather than bypassing selection. Jev chooses
124
+ one execution model from the user's dedicated candidate list plus an individual
125
+ include/exclude decision per eligible tool. The local policy then re-validates
126
+ the answer: unknown or unsafe tools cannot launch, explore/review stay read-only,
127
+ and management actions need no routing config or credential.
128
+
129
+ There are no emergency or fallback models, and low confidence is accepted rather
130
+ than treated as a threshold. A Jev timeout or API failure stops the affected new
131
+ dispatch with an actionable error; existing runs stay queryable and cancellable.
132
+ Transient child failures retry the already selected model and tool set within the
133
+ original deadline, up to `max_retries`; a quality failure never reselects.
134
+
135
+ An optional candidate `thinking` value is an opaque Pi thinking-level string;
136
+ common values include `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, and
137
+ `max`, but model-specific values are passed through unchanged. It is a default:
138
+ explicit task, agent, and profile `taskDefaults.thinking` values override it.
139
+ The extension re-reads `jevRouting` on each dispatch and injects non-secret
140
+ routing guidance into the parent prompt. The user stores the TypeSafe credential
141
+ in `jevRouting.apiKey` in the private `~/.pi/subagent.json`; do not read, display
142
+ or copy the key into task text, prompts or output. Legacy `apiKeyEnv` is rejected
143
+ with migration guidance; there is no environment fallback. If the config or key
144
+ is missing or invalid, management remains available but new spawns, `/btw`, plan,
145
+ resume, fork and synthesis are rejected. This config-file credential contract ships in
146
+ npm 0.10.0; published npm 0.9.0 uses the old environment mechanism.
@@ -1,94 +1,164 @@
1
- /**
2
- * Pi backend — the original and default. Spawns `pi --mode rpc` and speaks
3
- * Pi's documented JSON event stream over stdio.
4
- *
5
- * This is a straight extraction of the logic that lived inline in
6
- * `ChildRunner.run()`; behavior is unchanged. It is the only backend that
7
- * supports every capability, because the protocol was designed for it.
8
- */
9
-
10
- import * as fs from "node:fs/promises";
11
- import * as os from "node:os";
12
- import * as path from "node:path";
13
- import type { BackendAdapter, BackendCapabilities, BackendInvocation, BackendLaunchContext, BackendParser } from "../backend.js";
14
- import { ProtocolParser } from "../protocol.js";
15
- import { schemaContract } from "../structured.js";
16
- import type { TaskSpec } from "../types.js";
17
-
18
- const PI_CAPABILITIES: BackendCapabilities = {
19
- steer: true,
20
- gracefulWrapUp: true,
21
- costReporting: true,
22
- resume: true,
23
- fork: true,
24
- toolRestriction: true,
25
- thinking: true,
26
- outputSchema: true,
27
- };
28
-
29
- export class PiBackend implements BackendAdapter {
30
- readonly name = "pi" as const;
31
- readonly capabilities = PI_CAPABILITIES;
32
-
33
- async buildInvocation(spec: TaskSpec, context: BackendLaunchContext): Promise<BackendInvocation> {
34
- // RPC mode keeps a live stdin command channel so steering messages can be
35
- // injected mid-run. The event stream on stdout is a superset of json mode.
36
- const args = ["--mode", "rpc", "--session-dir", context.sessionDir];
37
- if (spec.forkResume && spec.resume) args.push("--fork", spec.resume);
38
- else if (spec.resume) args.push("--session", spec.resume);
39
- else if (spec.contextFork) {
40
- // Context fork: the child starts from a real branched copy of the
41
- // parent conversation, then receives the task as its next prompt.
42
- // Fail fast rather than silently degrading to a fresh session.
43
- if (!spec.parentSessionFile) {
44
- throw new Error("context:'fork' requires a persisted parent session (none available). Save the session or use context:'fresh'.");
45
- }
46
- await fs.access(spec.parentSessionFile).catch(() => {
47
- throw new Error(`context:'fork' failed: parent session file ${spec.parentSessionFile} is not readable.`);
48
- });
49
- args.push("--fork", spec.parentSessionFile);
50
- }
51
- if (spec.model) args.push("--model", spec.model);
52
- if (spec.thinking) args.push("--thinking", spec.thinking);
53
- if (spec.tools !== undefined) {
54
- const tools = spec.tools.filter((tool) => tool !== "subagent");
55
- if (tools.length === 0) args.push("--no-tools");
56
- else args.push("--tools", tools.join(","));
57
- }
58
- // Persona/system prompt first, structured-output contract last (highest salience).
59
- const appendPrompt = [spec.systemPrompt?.trim(), spec.outputSchema ? schemaContract(spec.outputSchema) : undefined]
60
- .filter(Boolean)
61
- .join("\n\n");
62
- const cleanupDirs: string[] = [];
63
- if (appendPrompt) {
64
- const tempPromptDir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-subagent-prompt-"));
65
- cleanupDirs.push(tempPromptDir);
66
- const promptPath = path.join(tempPromptDir, "system-prompt.md");
67
- await fs.writeFile(promptPath, appendPrompt, { encoding: "utf8", mode: 0o600 });
68
- args.push("--append-system-prompt", promptPath);
69
- }
70
-
71
- const invocation = context.getPiCommand(args);
72
- return { command: invocation.command, args: invocation.args, cleanupDirs };
73
- }
74
-
75
- createParser(): BackendParser {
76
- return new ProtocolParser();
77
- }
78
-
79
- steerCommand(message: string): unknown {
80
- return { type: "steer", message };
81
- }
82
-
83
- promptCommand(message: string): unknown {
84
- return { type: "prompt", message };
85
- }
86
-
87
- uiCancelCommand(id: string): unknown {
88
- return { type: "extension_ui_response", id, cancelled: true };
89
- }
90
-
91
- stateCommand(): unknown {
92
- return { type: "get_state" };
93
- }
94
- }
1
+ /**
2
+ * Pi backend — the original and default. Spawns `pi --mode rpc` and speaks
3
+ * Pi's documented JSON event stream over stdio.
4
+ *
5
+ * This is a straight extraction of the logic that lived inline in
6
+ * `ChildRunner.run()`; behavior is unchanged. It is the only backend that
7
+ * supports every capability, because the protocol was designed for it.
8
+ */
9
+
10
+ import * as fs from "node:fs/promises";
11
+ import * as os from "node:os";
12
+ import * as path from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+ import type { BackendAdapter, BackendCapabilities, BackendInvocation, BackendLaunchContext, BackendParser } from "../backend.js";
15
+ import { ProtocolParser } from "../protocol.js";
16
+ import { schemaContract } from "../structured.js";
17
+ import type { TaskSpec } from "../types.js";
18
+ import {
19
+ PREFLIGHT_MANIFEST_ENV,
20
+ PREFLIGHT_MANIFEST_SCHEMA,
21
+ createPreflightNonce,
22
+ startupFailure,
23
+ type PreflightManifest,
24
+ } from "../startup-check.js";
25
+
26
+ /** Nested dispatch tools whose loaded source the startup check must verify. */
27
+ const NESTED_DISPATCH_TOOLS = ["subagent", "subagent_wait"] as const;
28
+
29
+ const PI_CAPABILITIES: BackendCapabilities = {
30
+ steer: true,
31
+ gracefulWrapUp: true,
32
+ costReporting: true,
33
+ resume: true,
34
+ fork: true,
35
+ toolRestriction: true,
36
+ thinking: true,
37
+ outputSchema: true,
38
+ };
39
+
40
+ export class PiBackend implements BackendAdapter {
41
+ readonly name = "pi" as const;
42
+ readonly capabilities = PI_CAPABILITIES;
43
+
44
+ async buildInvocation(spec: TaskSpec, context: BackendLaunchContext): Promise<BackendInvocation> {
45
+ // A Jev-routed spec requires the provider-free startup check before the real task
46
+ // prompt: Pi silently drops unknown `--tools` names, so parent catalog knowledge is
47
+ // not proof of what the child actually loaded.
48
+ const routed = spec.routing !== undefined;
49
+ if (routed) {
50
+ if (!spec.model?.trim()) {
51
+ throw startupFailure(
52
+ "model_missing",
53
+ "A routed subagent task must carry the Jev-selected execution model.",
54
+ );
55
+ }
56
+ if (!Array.isArray(spec.tools)) {
57
+ throw startupFailure(
58
+ "tools_missing",
59
+ "A routed subagent task must carry the finalized tool allowlist so the child's active set can be verified.",
60
+ );
61
+ }
62
+ }
63
+
64
+ // RPC mode keeps a live stdin command channel so steering messages can be
65
+ // injected mid-run. The event stream on stdout is a superset of json mode.
66
+ const args = ["--mode", "rpc", "--session-dir", context.sessionDir];
67
+ if (spec.forkResume && spec.resume) args.push("--fork", spec.resume);
68
+ else if (spec.resume) args.push("--session", spec.resume);
69
+ else if (spec.contextFork) {
70
+ // Context fork: the child starts from a real branched copy of the
71
+ // parent conversation, then receives the task as its next prompt.
72
+ // Fail fast rather than silently degrading to a fresh session.
73
+ if (!spec.parentSessionFile) {
74
+ throw new Error("context:'fork' requires a persisted parent session (none available). Save the session or use context:'fresh'.");
75
+ }
76
+ await fs.access(spec.parentSessionFile).catch(() => {
77
+ throw new Error(`context:'fork' failed: parent session file ${spec.parentSessionFile} is not readable.`);
78
+ });
79
+ args.push("--fork", spec.parentSessionFile);
80
+ }
81
+ if (spec.model) args.push("--model", spec.model);
82
+ if (spec.thinking) args.push("--thinking", spec.thinking);
83
+ // A routed task's finalized tools are already profile-filtered and include the
84
+ // mandatory Pi control-plane tools; the parent applies depth/spawn/profile gating
85
+ // before `subagent`/`subagent_wait` become candidates, so they are no longer
86
+ // stripped here. Unrouted (trusted SDK) callers keep the historical behaviour.
87
+ let toolList: string[] | undefined;
88
+ if (spec.tools !== undefined) {
89
+ toolList = routed ? [...new Set(spec.tools)] : spec.tools.filter((tool) => tool !== "subagent");
90
+ if (toolList.length === 0) args.push("--no-tools");
91
+ else args.push("--tools", toolList.join(","));
92
+ }
93
+ // Persona/system prompt first, structured-output contract last (highest salience).
94
+ const appendPrompt = [spec.systemPrompt?.trim(), spec.outputSchema ? schemaContract(spec.outputSchema) : undefined]
95
+ .filter(Boolean)
96
+ .join("\n\n");
97
+ const cleanupDirs: string[] = [];
98
+ try {
99
+ if (appendPrompt) {
100
+ const tempPromptDir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-subagent-prompt-"));
101
+ cleanupDirs.push(tempPromptDir);
102
+ const promptPath = path.join(tempPromptDir, "system-prompt.md");
103
+ await fs.writeFile(promptPath, appendPrompt, { encoding: "utf8", mode: 0o600 });
104
+ args.push("--append-system-prompt", promptPath);
105
+ }
106
+
107
+ let env: Record<string, string> | undefined;
108
+ if (routed) {
109
+ env = {};
110
+ const preflightExtension = fileURLToPath(new URL("../child-preflight.ts", import.meta.url));
111
+ await fs.access(preflightExtension).catch(() => {
112
+ throw startupFailure(
113
+ "preflight_extension_missing",
114
+ "The packaged child-preflight extension is missing, so the routed child's model and tools cannot be verified.",
115
+ );
116
+ });
117
+ const nestedTools = (toolList ?? []).filter((tool) => (NESTED_DISPATCH_TOOLS as readonly string[]).includes(tool));
118
+ const manifest: PreflightManifest = {
119
+ schema: PREFLIGHT_MANIFEST_SCHEMA,
120
+ nonce: createPreflightNonce(),
121
+ model: spec.model!,
122
+ tools: toolList ?? [],
123
+ ...(nestedTools.length > 0 ? { nestedTools } : {}),
124
+ };
125
+ const tempPreflightDir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-subagent-preflight-"));
126
+ cleanupDirs.push(tempPreflightDir);
127
+ const manifestPath = path.join(tempPreflightDir, "preflight.json");
128
+ await fs.writeFile(manifestPath, JSON.stringify(manifest), { encoding: "utf8", mode: 0o600 });
129
+ // Bounded, non-secret expectation only: nonce, model ID, tool names, manifest path.
130
+ env[PREFLIGHT_MANIFEST_ENV] = manifestPath;
131
+ // Explicit `-e` pins the package-local extension. `--no-extensions` is deliberately
132
+ // NOT used: the child must still load the extensions that provide Jev-selected tools.
133
+ args.push("-e", preflightExtension);
134
+ }
135
+
136
+ const invocation = context.getPiCommand(args);
137
+ return { command: invocation.command, args: invocation.args, env, cleanupDirs };
138
+ } catch (error) {
139
+ // The runner cannot own cleanupDirs until an invocation is returned.
140
+ for (const dir of cleanupDirs) await fs.rm(dir, { recursive: true, force: true }).catch(() => { /* best-effort cleanup */ });
141
+ throw error;
142
+ }
143
+ }
144
+
145
+ createParser(): BackendParser {
146
+ return new ProtocolParser();
147
+ }
148
+
149
+ steerCommand(message: string): unknown {
150
+ return { type: "steer", message };
151
+ }
152
+
153
+ promptCommand(message: string): unknown {
154
+ return { type: "prompt", message };
155
+ }
156
+
157
+ uiCancelCommand(id: string): unknown {
158
+ return { type: "extension_ui_response", id, cancelled: true };
159
+ }
160
+
161
+ stateCommand(): unknown {
162
+ return { type: "get_state" };
163
+ }
164
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Private, package-local Pi extension that answers the routed startup handshake.
3
+ *
4
+ * Loaded explicitly by the Pi backend (`pi -e <this file>`) for Jev-routed child tasks
5
+ * only. It registers one nonce-specific command and, when invoked, reports the child's
6
+ * *actual* active model and tool set plus nested-tool provenance. The control side
7
+ * (`src/runner.ts`, via `src/startup-check.ts`) is what decides pass/fail — this file
8
+ * never grants anything and never trusts itself.
9
+ *
10
+ * Deliberate properties:
11
+ *
12
+ * - Registers the command unconditionally: nesting/depth/spawn registration rules must
13
+ * never disable the check.
14
+ * - Reads only the temporary manifest path from the environment; the manifest carries
15
+ * the nonce, the expected model ID and tool names — no task text, credentials or
16
+ * paths from the parent conversation.
17
+ * - Sends a bounded custom message with `triggerTurn: false` so no model turn starts.
18
+ * - Uses no imports beyond this package's own handshake module and Node builtins.
19
+ */
20
+
21
+ import * as fs from "node:fs";
22
+ import * as path from "node:path";
23
+ import {
24
+ PREFLIGHT_ACK_SCHEMA,
25
+ PREFLIGHT_ACK_TYPE,
26
+ PREFLIGHT_MANIFEST_ENV,
27
+ parsePreflightManifest,
28
+ preflightCommandBase,
29
+ } from "./startup-check.js";
30
+
31
+ /** Minimal structural view of the child ExtensionContext we rely on. */
32
+ interface PreflightCommandContext {
33
+ model?: { provider?: unknown; id?: unknown } | null;
34
+ }
35
+
36
+ interface PreflightToolMetadata {
37
+ name?: unknown;
38
+ sourceInfo?: { path?: unknown; source?: unknown } | null;
39
+ }
40
+
41
+ /** Minimal structural view of the Pi extension API we rely on. */
42
+ interface PreflightApi {
43
+ registerCommand(
44
+ name: string,
45
+ options: { description?: string; handler: (args: string, ctx: PreflightCommandContext) => unknown },
46
+ ): void;
47
+ getActiveTools(): unknown;
48
+ getAllTools(): unknown;
49
+ sendMessage(
50
+ message: { customType: string; content: string; display?: boolean },
51
+ options?: { triggerTurn?: boolean },
52
+ ): unknown;
53
+ }
54
+
55
+ const HOST_PACKAGE_NAME = "@earendil-works/pi-coding-agent";
56
+ const HOST_SEARCH_DEPTH = 6;
57
+
58
+ function safeActiveTools(pi: PreflightApi): string[] | null {
59
+ try {
60
+ const active = pi.getActiveTools();
61
+ return Array.isArray(active) && active.every((name) => typeof name === "string") ? active : null;
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+
67
+ function safeNestedProvenance(pi: PreflightApi, wanted: ReadonlySet<string>): Array<{
68
+ name: string;
69
+ path: string | null;
70
+ source: string | null;
71
+ }> {
72
+ if (wanted.size === 0) return [];
73
+ try {
74
+ const all = pi.getAllTools();
75
+ if (!Array.isArray(all)) return [];
76
+ return (all as PreflightToolMetadata[])
77
+ .filter((tool) => typeof tool?.name === "string" && wanted.has(tool.name))
78
+ .map((tool) => ({
79
+ name: tool.name as string,
80
+ path: typeof tool.sourceInfo?.path === "string" ? tool.sourceInfo.path : null,
81
+ source: typeof tool.sourceInfo?.source === "string" ? tool.sourceInfo.source : null,
82
+ }));
83
+ } catch {
84
+ return [];
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Best-effort host identification for the version gate. `PI_PACKAGE_DIR` is documented,
90
+ * and `process.argv[1]` is the CLI entry for Node-launched Pi. Anything unresolvable
91
+ * stays `null`; the behavioural handshake, not a guessed version, is the real gate.
92
+ */
93
+ function readHostInfo(): { version: string | null; packageDir: string | null } {
94
+ const candidates: string[] = [];
95
+ try {
96
+ const override = process.env.PI_PACKAGE_DIR;
97
+ if (typeof override === "string" && override.trim()) candidates.push(override.trim());
98
+ const argv1 = process.argv[1];
99
+ if (typeof argv1 === "string" && argv1) {
100
+ let dir = path.dirname(path.resolve(argv1));
101
+ for (let depth = 0; depth < HOST_SEARCH_DEPTH; depth += 1) {
102
+ candidates.push(dir);
103
+ const parent = path.dirname(dir);
104
+ if (parent === dir) break;
105
+ dir = parent;
106
+ }
107
+ }
108
+ } catch {
109
+ /* fall through to unknown */
110
+ }
111
+ for (const dir of candidates) {
112
+ try {
113
+ const raw = fs.readFileSync(path.join(dir, "package.json"), "utf8");
114
+ const parsed = JSON.parse(raw);
115
+ if (parsed?.name === HOST_PACKAGE_NAME && typeof parsed.version === "string") {
116
+ return { version: parsed.version, packageDir: dir };
117
+ }
118
+ } catch {
119
+ /* not a package dir; keep looking */
120
+ }
121
+ }
122
+ return { version: null, packageDir: null };
123
+ }
124
+
125
+ export default function childPreflight(pi: PreflightApi): void {
126
+ const manifestPath = process.env[PREFLIGHT_MANIFEST_ENV];
127
+ if (typeof manifestPath !== "string" || manifestPath.length === 0) return;
128
+
129
+ let manifest;
130
+ try {
131
+ manifest = parsePreflightManifest(fs.readFileSync(manifestPath, "utf8"));
132
+ } catch {
133
+ return;
134
+ }
135
+ if (!manifest.ok) return;
136
+ const expectation = manifest.manifest;
137
+ const nestedWanted = new Set(expectation.nestedTools ?? []);
138
+
139
+ pi.registerCommand(preflightCommandBase(expectation.nonce), {
140
+ description: "private pi-subagent startup check",
141
+ handler: async (_args: string, ctx: PreflightCommandContext) => {
142
+ const model = ctx?.model
143
+ ? {
144
+ provider: typeof ctx.model.provider === "string" ? ctx.model.provider : undefined,
145
+ id: typeof ctx.model.id === "string" ? ctx.model.id : undefined,
146
+ }
147
+ : null;
148
+ const payload = {
149
+ schema: PREFLIGHT_ACK_SCHEMA,
150
+ nonce: expectation.nonce,
151
+ model,
152
+ tools: safeActiveTools(pi),
153
+ nestedToolsWithSource: safeNestedProvenance(pi, nestedWanted),
154
+ host: readHostInfo(),
155
+ };
156
+ pi.sendMessage(
157
+ {
158
+ customType: PREFLIGHT_ACK_TYPE,
159
+ content: JSON.stringify(payload),
160
+ display: false,
161
+ },
162
+ { triggerTurn: false },
163
+ );
164
+ },
165
+ });
166
+ }