@cr1ms0n/pi-subagent 0.8.8 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -6
- package/README.md +218 -128
- package/docs/ARCHITECTURE.md +168 -132
- package/docs/COST-ACCOUNTING.md +116 -66
- package/docs/RELEASING.md +32 -32
- package/docs/SECURITY.md +125 -97
- package/docs/UX.md +158 -141
- package/package.json +2 -2
- package/skills/subagent/SKILL.md +142 -121
- package/src/agents.ts +282 -288
- package/src/backends/pi.ts +164 -94
- package/src/child-preflight.ts +166 -0
- package/src/config.ts +254 -252
- package/src/dispatch-preflight.ts +87 -0
- package/src/dispatch-routing.ts +56 -0
- package/src/extension.ts +368 -187
- package/src/format.ts +436 -365
- package/src/jev-router.ts +1036 -0
- package/src/orchestrator.ts +303 -312
- package/src/persistence.ts +643 -335
- package/src/policy.ts +562 -561
- package/src/process-lock.ts +730 -687
- package/src/protocol.ts +320 -290
- package/src/registry.ts +730 -632
- package/src/routing-policy.ts +268 -0
- package/src/routing-types.ts +217 -0
- package/src/runner.ts +1299 -850
- package/src/schema.ts +189 -189
- package/src/startup-check.ts +481 -0
- package/src/types.ts +208 -198
- package/src/usage.ts +316 -274
- package/src/context-policy.ts +0 -169
- package/src/model-policy.ts +0 -169
package/src/backends/pi.ts
CHANGED
|
@@ -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
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
+
}
|