shariq-pi-extensions 0.5.0 → 0.6.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/docs/EXTENSIONS.md +2 -2
- package/extensions/background-terminals/README.md +1 -1
- package/extensions/background-terminals/index.ts +1 -2
- package/extensions/shared/child-session.ts +7 -0
- package/extensions/subagents/README.md +24 -3
- package/extensions/subagents/index.ts +4 -0
- package/extensions/subagents/src/backends/pi-process.ts +364 -0
- package/extensions/subagents/src/backends/pi.ts +111 -117
- package/extensions/subagents/src/child-bridge.ts +105 -0
- package/extensions/subagents/src/config.ts +54 -1
- package/extensions/subagents/src/domain.ts +3 -1
- package/package.json +1 -1
package/docs/EXTENSIONS.md
CHANGED
|
@@ -18,7 +18,7 @@ Persistent, branch-safe objectives with an evidence checklist, token budgets, pa
|
|
|
18
18
|
|
|
19
19
|
### [Subagents](../extensions/subagents/README.md)
|
|
20
20
|
|
|
21
|
-
Flat Pi child agents with profiles, capability policies, context forks, resumable sessions, optional git worktree isolation, peer messaging, and a takeover dashboard. Children cannot start agents. The model delegates only when the user asks. Results are delivered as custom messages that start a parent turn when the parent is idle. Configuration lives in `<agent-dir>/subagents.json`, with trusted-project overrides in `.pi/subagents.json`.
|
|
21
|
+
Flat Pi child agents with profiles, capability policies, an optional per-profile process runtime that gives children Pi's built-in extensions (codemode, MCP, tool search), review options that drop skills or `AGENTS.md` files, context forks, resumable sessions, optional git worktree isolation, peer messaging, and a takeover dashboard. Children cannot start agents. The model delegates only when the user asks. Results are delivered as custom messages that start a parent turn when the parent is idle. Configuration lives in `<agent-dir>/subagents.json`, with trusted-project overrides in `.pi/subagents.json`.
|
|
22
22
|
|
|
23
23
|
### [Smart Compaction](../extensions/smart-compaction/README.md)
|
|
24
24
|
|
|
@@ -58,4 +58,4 @@ Adds `/exit` as an alias for `/quit`.
|
|
|
58
58
|
|
|
59
59
|
## Tool exposure
|
|
60
60
|
|
|
61
|
-
Pi 1.0 lets each tool choose how the model reaches it. Tools that ask the user, manage lifecycles, or change session state (questions, goals, task list, terminals, subagents) use `model-only` exposure: they stay declared to the model even with `codemode.mode: "only"`, and codemode scripts cannot call them. The read-only web tools keep the default `direct` exposure so scripts can call them in parallel. Read-only tools also declare `readOnlyHint` annotations for permission extensions.
|
|
61
|
+
Pi 1.0 lets each tool choose how the model reaches it. Tools that ask the user, manage lifecycles, or change session state (questions, goals, task list, starting, writing to, or stopping terminals, subagents) use `model-only` exposure: they stay declared to the model even with `codemode.mode: "only"`, and codemode scripts cannot call them. The read-only web tools and `read_terminal` and `list_terminals` keep the default `direct` exposure so scripts can call them in parallel and filter their output. Read-only tools also declare `readOnlyHint` annotations for permission extensions.
|
|
@@ -10,7 +10,7 @@ Session-scoped background pseudo-terminals for Pi. The extension combines Codex-
|
|
|
10
10
|
- `list_terminals` — list running and settled terminals.
|
|
11
11
|
- `stop_terminal` — stop complete process groups with TERM-to-KILL escalation.
|
|
12
12
|
|
|
13
|
-
Each output response carries a byte cursor; pass it to the next read or write to get only new output. The tools render as compact cards with expandable output,
|
|
13
|
+
`read_terminal` and `list_terminals` are read-only and callable from codemode scripts, so a script can filter a long log down to what matters; the tools that start, write to, or stop processes use `model-only` exposure. Each output response carries a byte cursor; pass it to the next read or write to get only new output. The tools render as compact cards with expandable output, and running terminals appear in the shared **Active work** dock with elapsed time and the latest output line.
|
|
14
14
|
|
|
15
15
|
When a model-started terminal exits, its status and a bounded output tail are delivered as a custom message. An idle parent starts a new turn for it; a busy parent receives it, together with any other pending results, when its run settles. The message never appears as user-authored or follow-up input, and reading the terminal does not suppress it.
|
|
16
16
|
|
|
@@ -230,7 +230,7 @@ export default function backgroundTerminals(pi: ExtensionAPI) {
|
|
|
230
230
|
description:
|
|
231
231
|
"Read a background terminal's output since a cursor; omit the cursor for the retained tail. " +
|
|
232
232
|
"Use it when the user asks for progress or the process is waiting for input. Completion output arrives on its own.",
|
|
233
|
-
|
|
233
|
+
// Read-only, so codemode scripts may call it to filter large logs.
|
|
234
234
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
235
235
|
parameters: Type.Object({
|
|
236
236
|
id: Type.String({ description: 'Terminal id, such as "term-1".' }),
|
|
@@ -317,7 +317,6 @@ export default function backgroundTerminals(pi: ExtensionAPI) {
|
|
|
317
317
|
name: "list_terminals",
|
|
318
318
|
label: "List Background Terminals",
|
|
319
319
|
description: "List background terminals with id, status, pid, elapsed time, and working directory.",
|
|
320
|
-
exposure: "model-only",
|
|
321
320
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
322
321
|
parameters: Type.Object({}),
|
|
323
322
|
async execute() {
|
|
@@ -38,6 +38,10 @@ export interface ChildResourceOptions {
|
|
|
38
38
|
agentDir?: string;
|
|
39
39
|
/** Child-only extensions, such as a capability guard, loaded beside the normal ones. */
|
|
40
40
|
extensionFactories?: InlineExtension[];
|
|
41
|
+
/** Start without discovered skills, AGENTS.md files, or extensions (inline factories still load). */
|
|
42
|
+
noSkills?: boolean;
|
|
43
|
+
noContextFiles?: boolean;
|
|
44
|
+
noExtensions?: boolean;
|
|
41
45
|
}
|
|
42
46
|
|
|
43
47
|
/** Load normal global/package resources and trust-gated project resources. */
|
|
@@ -54,6 +58,9 @@ export async function createChildResources(options: ChildResourceOptions) {
|
|
|
54
58
|
? { appendSystemPrompt: options.appendSystemPrompt }
|
|
55
59
|
: {}),
|
|
56
60
|
...(options.extensionFactories ? { extensionFactories: options.extensionFactories } : {}),
|
|
61
|
+
noSkills: options.noSkills,
|
|
62
|
+
noContextFiles: options.noContextFiles,
|
|
63
|
+
noExtensions: options.noExtensions,
|
|
57
64
|
});
|
|
58
65
|
await loader.reload();
|
|
59
66
|
return { loader, settingsManager };
|
|
@@ -44,9 +44,22 @@ Capability modes:
|
|
|
44
44
|
- `execute`: the read-only set plus `bash` and background terminals, without direct edits;
|
|
45
45
|
- `all`: every tool the child loads.
|
|
46
46
|
|
|
47
|
-
Restrictive modes fail closed: an extension tool is unavailable until it is classified in `src/backends/pi.ts
|
|
47
|
+
Restrictive modes fail closed: an extension tool is unavailable until it is classified in `src/backends/pi.ts`, and a child-only `tool_call` check refuses unclassified tools at call time, including tools registered later. The session-only `task_list` tool is allowed in every mode.
|
|
48
48
|
|
|
49
|
-
|
|
49
|
+
## Runtimes and review options
|
|
50
|
+
|
|
51
|
+
Children run in-process by default: fast to start and light on memory. In-process children load the same packages and extensions as the parent but not Pi's built-in extensions (`codemode`, MCP, and tool search), because Pi does not export them to extensions. Each tool call inside an in-process child times out after three minutes.
|
|
52
|
+
|
|
53
|
+
A profile with `"runtime": "process"` runs each child as its own `pi --mode rpc` process instead. The child gets the full Pi setup, including the built-in extensions, and a crash or memory blowup stays in the child. The cost is a second or two of startup and a separate process per child. A child-only extension loaded with `-e` enforces the capability inside the process and carries `message_parent`, `ask_parent`, `list_peers`, and `message_peer` over Pi's RPC dialog protocol. Closing the parent closes the child's input, and the child shuts down.
|
|
54
|
+
|
|
55
|
+
`codemode` and `tool_search` are allowed in every capability mode; each tool a codemode script calls passes the same check, so a script cannot widen a child's capability.
|
|
56
|
+
|
|
57
|
+
Profiles can also shape what the child starts with. None of these settings reach the model:
|
|
58
|
+
|
|
59
|
+
- `"tools": ["read", "grep"]`: an exact allowlist on top of the capability;
|
|
60
|
+
- `"skills": false`: start without skills;
|
|
61
|
+
- `"contextFiles": false`: start without `AGENTS.md` and `CLAUDE.md` files, for an unbiased review;
|
|
62
|
+
- `"extensions": false`: start without discovered extensions.
|
|
50
63
|
|
|
51
64
|
User profiles and personas live in `<agent-dir>/subagents.json`. Trusted projects can override them in `.pi/subagents.json`:
|
|
52
65
|
|
|
@@ -58,6 +71,12 @@ User profiles and personas live in `<agent-dir>/subagents.json`. Trusted project
|
|
|
58
71
|
"description": "Review changes without editing",
|
|
59
72
|
"instructions": "Find correctness and regression risks. Cite files and lines.",
|
|
60
73
|
"capability": "execute"
|
|
74
|
+
},
|
|
75
|
+
"fresh-eyes": {
|
|
76
|
+
"description": "Independent review that ignores repository instructions",
|
|
77
|
+
"capability": "execute",
|
|
78
|
+
"runtime": "process",
|
|
79
|
+
"contextFiles": false
|
|
61
80
|
}
|
|
62
81
|
},
|
|
63
82
|
"personas": {
|
|
@@ -98,7 +117,9 @@ Lifecycle tools render as compact cards with expandable detail. Running children
|
|
|
98
117
|
## Layout
|
|
99
118
|
|
|
100
119
|
- `src/manager.ts`: Effect service, lifecycle, snapshots, cancellation, and retention
|
|
101
|
-
- `src/backends/pi.ts`: in-process Pi child sessions, capability filtering, context seeding, resume, and the parent bridge
|
|
120
|
+
- `src/backends/pi.ts`: in-process Pi child sessions, capability filtering, shared event translation, context seeding, resume, and the parent bridge
|
|
121
|
+
- `src/backends/pi-process.ts`: process-runtime children over Pi's RPC protocol
|
|
122
|
+
- `src/child-bridge.ts`: the extension loaded into process-runtime children for capability enforcement and the parent bridge
|
|
102
123
|
- `src/config.ts`: profiles, personas, validation, and limits
|
|
103
124
|
- `src/catalog.ts`: global resumable-agent catalog
|
|
104
125
|
- `src/context.ts`: context forks and profile prompt assembly
|
|
@@ -102,6 +102,7 @@ import {
|
|
|
102
102
|
upsertSubagentCatalog,
|
|
103
103
|
type ArchivedSubagent,
|
|
104
104
|
} from "./src/catalog.ts";
|
|
105
|
+
import { CHILD_ENV } from "./src/child-bridge.ts";
|
|
105
106
|
|
|
106
107
|
const SUBAGENT_OUTPUT_MAX_BYTES = 24 * 1024;
|
|
107
108
|
const WAIT_OUTPUT_MAX_BYTES = 48 * 1024;
|
|
@@ -175,6 +176,8 @@ function truncatedOutput(
|
|
|
175
176
|
}
|
|
176
177
|
|
|
177
178
|
export default function (pi: ExtensionAPI) {
|
|
179
|
+
// A process-runtime child is managed by its parent; it must not manage children itself.
|
|
180
|
+
if (process.env[CHILD_ENV] === "1") return;
|
|
178
181
|
const deliverSettlement = settlementDelivery(pi);
|
|
179
182
|
let runtime: SubagentRuntime | undefined;
|
|
180
183
|
let managerPromise: Promise<SubagentManagerShape> | undefined;
|
|
@@ -481,6 +484,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
481
484
|
model: resumeSessionFile ? options.model : profile.model,
|
|
482
485
|
reasoningEffort: resumeSessionFile ? options.thinking : profile.thinking,
|
|
483
486
|
capability: profile.capability,
|
|
487
|
+
childOptions: profile.childOptions,
|
|
484
488
|
agentType: profile.agentType,
|
|
485
489
|
persona: profile.persona,
|
|
486
490
|
isolation: profile.isolation,
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process runtime for Pi subagents: each child is a separate `pi --mode rpc`
|
|
3
|
+
* process driven over Pi's documented JSONL RPC protocol. Children get the
|
|
4
|
+
* full CLI setup, including Pi's built-in extensions (codemode, MCP, tool
|
|
5
|
+
* search), and a crash stays in the child. The child-bridge extension enforces
|
|
6
|
+
* capability inside the child and carries parent questions over RPC dialogs.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { type ChildProcess, spawn } from "node:child_process";
|
|
10
|
+
import * as fs from "node:fs";
|
|
11
|
+
import { StringDecoder } from "node:string_decoder";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
import type { AssistantMessage, Model } from "@earendil-works/pi-ai";
|
|
14
|
+
import { SessionManager } from "@earendil-works/pi-coding-agent";
|
|
15
|
+
import type { Cause, Scope } from "effect";
|
|
16
|
+
import { Effect, Queue, Stream } from "effect";
|
|
17
|
+
import type { SubagentSession } from "../backend.ts";
|
|
18
|
+
import type { SpawnTask, SubagentEvent, SubagentMeta } from "../domain.ts";
|
|
19
|
+
import { SendError, SpawnError } from "../domain.ts";
|
|
20
|
+
import {
|
|
21
|
+
BRIDGE_MARK,
|
|
22
|
+
CHILD_BRIDGE_ENV,
|
|
23
|
+
CHILD_CAPABILITY_ENV,
|
|
24
|
+
CHILD_ENV,
|
|
25
|
+
CHILD_TOOLS_ENV,
|
|
26
|
+
type BridgeRequest,
|
|
27
|
+
} from "../child-bridge.ts";
|
|
28
|
+
import { boundedError, resolvePiModel, settledOutcome, translateSessionEvent } from "./pi.ts";
|
|
29
|
+
|
|
30
|
+
const STARTUP_TIMEOUT_MS = 60_000;
|
|
31
|
+
const SHUTDOWN_TIMEOUT_MS = 5_000;
|
|
32
|
+
const STDERR_TAIL_CHARS = 4_000;
|
|
33
|
+
const CHILD_BRIDGE_PATH = fileURLToPath(new URL("../child-bridge.ts", import.meta.url));
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The command that starts this same Pi build. Under Node or Bun the entry
|
|
37
|
+
* script (often a `pi` symlink to cli.js) follows the runtime; a compiled
|
|
38
|
+
* binary is its own executable.
|
|
39
|
+
*/
|
|
40
|
+
export function piCommand(argv = process.argv, execPath = process.execPath): { file: string; args: string[] } {
|
|
41
|
+
const script = argv[1];
|
|
42
|
+
try {
|
|
43
|
+
if (script) {
|
|
44
|
+
const resolved = fs.realpathSync(script);
|
|
45
|
+
if (resolved !== fs.realpathSync(execPath) && fs.statSync(resolved).isFile()) {
|
|
46
|
+
return { file: execPath, args: [resolved] };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
} catch {
|
|
50
|
+
// Fall through to the executable itself.
|
|
51
|
+
}
|
|
52
|
+
return { file: execPath, args: [] };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** CLI arguments for one child; exported for tests. */
|
|
56
|
+
export function childArgs(task: SpawnTask, model: Model<any> | undefined, sessionFile: string | undefined): string[] {
|
|
57
|
+
const args = ["--mode", "rpc"];
|
|
58
|
+
if (sessionFile) args.push("--session", sessionFile);
|
|
59
|
+
if (model) args.push("--provider", model.provider, "--model", model.id);
|
|
60
|
+
const thinking = task.reasoningEffort ?? task.parent.inheritedThinkingLevel;
|
|
61
|
+
if (thinking) args.push("--thinking", thinking);
|
|
62
|
+
args.push(task.parent.projectTrusted ? "--approve" : "--no-approve");
|
|
63
|
+
if (task.childOptions?.skills === false) args.push("--no-skills");
|
|
64
|
+
if (task.childOptions?.contextFiles === false) args.push("--no-context-files");
|
|
65
|
+
if (task.childOptions?.extensions === false) args.push("--no-extensions");
|
|
66
|
+
args.push("-e", CHILD_BRIDGE_PATH);
|
|
67
|
+
return args;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Seed a new child session with the forked parent context, as the in-process runtime does. */
|
|
71
|
+
function prepareSessionFile(task: SpawnTask): string | undefined {
|
|
72
|
+
if (task.resumeSessionFile) return task.resumeSessionFile;
|
|
73
|
+
const inherited = task.parent.inheritedMessages ?? [];
|
|
74
|
+
if (inherited.length === 0) return undefined;
|
|
75
|
+
const manager = SessionManager.create(task.cwd, undefined, { parentSession: task.parent.parentSessionFile });
|
|
76
|
+
for (const message of inherited) manager.appendMessage(message);
|
|
77
|
+
const file = manager.getSessionFile();
|
|
78
|
+
return file && fs.existsSync(file) ? file : undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
type RpcRecord = { type?: string; id?: string; [key: string]: any };
|
|
82
|
+
|
|
83
|
+
export const makeProcessSession = (
|
|
84
|
+
task: SpawnTask,
|
|
85
|
+
): Effect.Effect<SubagentSession, SpawnError, Scope.Scope> =>
|
|
86
|
+
Effect.gen(function* () {
|
|
87
|
+
const registry = task.parent.modelRegistry;
|
|
88
|
+
const model = yield* Effect.try({
|
|
89
|
+
try: () => (registry ? resolvePiModel(registry, task.model, task.parent.inheritedModel) : task.parent.inheritedModel),
|
|
90
|
+
catch: (error) => new SpawnError({ message: boundedError(error) }),
|
|
91
|
+
});
|
|
92
|
+
const sessionFile = yield* Effect.try({
|
|
93
|
+
try: () => prepareSessionFile(task),
|
|
94
|
+
catch: (error) => new SpawnError({ message: boundedError(error) }),
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const events = yield* Queue.make<SubagentEvent, Cause.Done>();
|
|
98
|
+
const emit = (event: SubagentEvent) => {
|
|
99
|
+
Queue.offerUnsafe(events, event);
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const state = {
|
|
103
|
+
closed: false,
|
|
104
|
+
exited: false,
|
|
105
|
+
streaming: false,
|
|
106
|
+
settled: false,
|
|
107
|
+
runError: undefined as string | undefined,
|
|
108
|
+
runStartIndex: 0,
|
|
109
|
+
messages: [] as unknown[],
|
|
110
|
+
sessionFile,
|
|
111
|
+
sessionId: undefined as string | undefined,
|
|
112
|
+
stderr: "",
|
|
113
|
+
nextId: 0,
|
|
114
|
+
};
|
|
115
|
+
const pending = new Map<string, { resolve: (data: unknown) => void; reject: (error: Error) => void }>();
|
|
116
|
+
|
|
117
|
+
const command = piCommand();
|
|
118
|
+
const child: ChildProcess = spawn(command.file, [...command.args, ...childArgs(task, model, sessionFile)], {
|
|
119
|
+
cwd: task.cwd,
|
|
120
|
+
env: {
|
|
121
|
+
...process.env,
|
|
122
|
+
[CHILD_ENV]: "1",
|
|
123
|
+
[CHILD_CAPABILITY_ENV]: task.capability,
|
|
124
|
+
...(task.childOptions?.tools ? { [CHILD_TOOLS_ENV]: JSON.stringify(task.childOptions.tools) } : {}),
|
|
125
|
+
...(task.parent.bridge ? { [CHILD_BRIDGE_ENV]: "1" } : {}),
|
|
126
|
+
},
|
|
127
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const failAll = (error: Error) => {
|
|
131
|
+
for (const entry of pending.values()) entry.reject(error);
|
|
132
|
+
pending.clear();
|
|
133
|
+
};
|
|
134
|
+
const exitError = () => new Error(`The subagent process exited.${state.stderr.trim() ? ` ${state.stderr.trim().slice(-STDERR_TAIL_CHARS)}` : ""}`);
|
|
135
|
+
|
|
136
|
+
const write = (record: RpcRecord) => {
|
|
137
|
+
if (state.exited || !child.stdin?.writable) throw exitError();
|
|
138
|
+
child.stdin.write(`${JSON.stringify(record)}\n`);
|
|
139
|
+
};
|
|
140
|
+
const request = (record: RpcRecord) => new Promise<unknown>((resolve, reject) => {
|
|
141
|
+
const id = `req-${++state.nextId}`;
|
|
142
|
+
pending.set(id, { resolve, reject });
|
|
143
|
+
try {
|
|
144
|
+
write({ ...record, id });
|
|
145
|
+
} catch (error) {
|
|
146
|
+
pending.delete(id);
|
|
147
|
+
reject(error as Error);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const settle = () => {
|
|
152
|
+
if (state.settled) return;
|
|
153
|
+
state.settled = true;
|
|
154
|
+
emit({ _tag: "RunSettled", outcome: settledOutcome(state.messages, state.runStartIndex, state.runError) });
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const currentMeta = (): SubagentMeta => {
|
|
158
|
+
const last = [...state.messages].reverse().find((message) => (message as { role?: string })?.role === "assistant") as AssistantMessage | undefined;
|
|
159
|
+
const label = last ? `${last.provider}/${last.model}` : model ? `${model.provider}/${model.id}` : undefined;
|
|
160
|
+
return {
|
|
161
|
+
backend: "pi",
|
|
162
|
+
origin: task.origin,
|
|
163
|
+
modelLabel: label,
|
|
164
|
+
contextWindow: model?.contextWindow,
|
|
165
|
+
sessionFilePath: state.sessionFile,
|
|
166
|
+
nativeSessionId: state.sessionId,
|
|
167
|
+
agentType: task.agentType,
|
|
168
|
+
persona: task.persona,
|
|
169
|
+
capability: task.capability,
|
|
170
|
+
isolation: task.isolation,
|
|
171
|
+
worktree: task.worktree,
|
|
172
|
+
concurrencyGroup: task.concurrencyGroup,
|
|
173
|
+
resumedFrom: task.resumeSessionFile,
|
|
174
|
+
};
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
// Answer bridge dialogs; dismiss every other dialog, since no person is watching the child.
|
|
178
|
+
const answerUi = async (record: RpcRecord) => {
|
|
179
|
+
const reply = (body: Record<string, unknown>) => {
|
|
180
|
+
try {
|
|
181
|
+
write({ type: "extension_ui_response", id: record.id, ...body });
|
|
182
|
+
} catch {
|
|
183
|
+
// The child exited; nothing is waiting for the answer.
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
const marked = (text: unknown) => typeof text === "string" && text.startsWith(BRIDGE_MARK)
|
|
187
|
+
? JSON.parse(text.slice(BRIDGE_MARK.length)) as BridgeRequest
|
|
188
|
+
: undefined;
|
|
189
|
+
if (record.method === "notify") {
|
|
190
|
+
const body = marked(record.message);
|
|
191
|
+
if (body?.kind === "notify") task.parent.bridge?.notify(body.message);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (!["select", "confirm", "input", "editor"].includes(record.method)) return;
|
|
195
|
+
const body = record.method === "input" ? marked(record.title) : undefined;
|
|
196
|
+
const bridge = task.parent.bridge;
|
|
197
|
+
if (!body || !bridge) return reply({ cancelled: true });
|
|
198
|
+
try {
|
|
199
|
+
if (body.kind === "ask") reply({ value: await bridge.ask(body.question) });
|
|
200
|
+
else if (body.kind === "peers") reply({ value: JSON.stringify(bridge.listPeers()) });
|
|
201
|
+
else if (body.kind === "peer") {
|
|
202
|
+
await bridge.messagePeer(body.id, body.message);
|
|
203
|
+
reply({ value: "ok" });
|
|
204
|
+
} else reply({ cancelled: true });
|
|
205
|
+
} catch (error) {
|
|
206
|
+
reply({ value: `error: ${boundedError(error)}` });
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const handleRecord = (record: RpcRecord) => {
|
|
211
|
+
if (record.type === "response") {
|
|
212
|
+
const entry = record.id ? pending.get(record.id) : undefined;
|
|
213
|
+
if (!entry) return;
|
|
214
|
+
pending.delete(record.id!);
|
|
215
|
+
if (record.success === false) entry.reject(new Error(String(record.error ?? "RPC command failed")));
|
|
216
|
+
else entry.resolve(record.data);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
if (record.type === "extension_ui_request") {
|
|
220
|
+
void answerUi(record);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
if (state.closed) return;
|
|
224
|
+
switch (record.type) {
|
|
225
|
+
case "agent_start":
|
|
226
|
+
state.streaming = true;
|
|
227
|
+
state.settled = false;
|
|
228
|
+
emit({ _tag: "RunStarted" });
|
|
229
|
+
break;
|
|
230
|
+
case "message_end":
|
|
231
|
+
state.messages.push(record.message);
|
|
232
|
+
translateSessionEvent(record as { type: string }, emit);
|
|
233
|
+
if ((record.message as { role?: string })?.role === "assistant") {
|
|
234
|
+
const usage = (record.message as AssistantMessage).usage;
|
|
235
|
+
emit({ _tag: "UsageChanged", tokens: usage?.totalTokens, contextWindow: model?.contextWindow });
|
|
236
|
+
emit({ _tag: "MetaChanged", meta: currentMeta() });
|
|
237
|
+
}
|
|
238
|
+
break;
|
|
239
|
+
case "agent_settled":
|
|
240
|
+
state.streaming = false;
|
|
241
|
+
settle();
|
|
242
|
+
break;
|
|
243
|
+
default:
|
|
244
|
+
translateSessionEvent(record as { type: string }, emit);
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
// Strict JSONL: split only on LF, never on Unicode line separators.
|
|
249
|
+
const decoder = new StringDecoder("utf8");
|
|
250
|
+
let buffer = "";
|
|
251
|
+
child.stdout!.on("data", (chunk: Buffer) => {
|
|
252
|
+
buffer += decoder.write(chunk);
|
|
253
|
+
let newline = buffer.indexOf("\n");
|
|
254
|
+
while (newline >= 0) {
|
|
255
|
+
const line = buffer.slice(0, newline).replace(/\r$/, "");
|
|
256
|
+
buffer = buffer.slice(newline + 1);
|
|
257
|
+
if (line.trim()) {
|
|
258
|
+
try {
|
|
259
|
+
handleRecord(JSON.parse(line) as RpcRecord);
|
|
260
|
+
} catch {
|
|
261
|
+
// Ignore a malformed record rather than losing the session.
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
newline = buffer.indexOf("\n");
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
child.stderr!.on("data", (chunk: Buffer) => {
|
|
268
|
+
state.stderr = (state.stderr + chunk.toString("utf8")).slice(-STDERR_TAIL_CHARS);
|
|
269
|
+
});
|
|
270
|
+
const exited = new Promise<void>((resolve) => {
|
|
271
|
+
const onExit = () => {
|
|
272
|
+
if (state.exited) return;
|
|
273
|
+
state.exited = true;
|
|
274
|
+
failAll(exitError());
|
|
275
|
+
if (!state.closed && !state.settled) {
|
|
276
|
+
state.runError ??= exitError().message;
|
|
277
|
+
state.streaming = false;
|
|
278
|
+
settle();
|
|
279
|
+
}
|
|
280
|
+
resolve();
|
|
281
|
+
};
|
|
282
|
+
child.once("exit", onExit);
|
|
283
|
+
child.once("error", (error) => {
|
|
284
|
+
state.stderr += `\n${error.message}`;
|
|
285
|
+
onExit();
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
yield* Effect.addFinalizer(() =>
|
|
290
|
+
Effect.promise(async () => {
|
|
291
|
+
state.closed = true;
|
|
292
|
+
// Closing stdin asks Pi for an orderly shutdown; escalate if it lingers.
|
|
293
|
+
child.stdin?.end();
|
|
294
|
+
const timeout = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms).unref?.());
|
|
295
|
+
await Promise.race([exited, timeout(SHUTDOWN_TIMEOUT_MS)]);
|
|
296
|
+
if (!state.exited) {
|
|
297
|
+
child.kill("SIGTERM");
|
|
298
|
+
await Promise.race([exited, timeout(1_000)]);
|
|
299
|
+
if (!state.exited) child.kill("SIGKILL");
|
|
300
|
+
}
|
|
301
|
+
failAll(new Error("Subagent session is closed."));
|
|
302
|
+
Queue.endUnsafe(events);
|
|
303
|
+
}),
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
// Wait until the child answers before handing the session to the manager.
|
|
307
|
+
const ready = yield* Effect.tryPromise({
|
|
308
|
+
try: () => Promise.race([
|
|
309
|
+
request({ type: "get_state" }),
|
|
310
|
+
new Promise((_resolve, reject) => setTimeout(() => reject(new Error("The subagent process did not start in time.")), STARTUP_TIMEOUT_MS).unref?.()),
|
|
311
|
+
]) as Promise<{ sessionFile?: string; sessionId?: string }>,
|
|
312
|
+
catch: (error) => new SpawnError({ message: boundedError(error) }),
|
|
313
|
+
});
|
|
314
|
+
state.sessionFile = ready.sessionFile ?? state.sessionFile;
|
|
315
|
+
state.sessionId = ready.sessionId;
|
|
316
|
+
|
|
317
|
+
const startRun = (text: string) => {
|
|
318
|
+
state.runError = undefined;
|
|
319
|
+
state.settled = false;
|
|
320
|
+
state.runStartIndex = state.messages.length;
|
|
321
|
+
emit({ _tag: "RunStarted" });
|
|
322
|
+
request({ type: "prompt", message: text }).then(
|
|
323
|
+
(data) => {
|
|
324
|
+
// A handled prompt (for example an extension command) starts no run.
|
|
325
|
+
if ((data as { disposition?: string } | undefined)?.disposition === "handled" && !state.streaming) settle();
|
|
326
|
+
},
|
|
327
|
+
(error) => {
|
|
328
|
+
state.runError = boundedError(error);
|
|
329
|
+
if (!state.streaming) settle();
|
|
330
|
+
},
|
|
331
|
+
);
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
emit({ _tag: "MetaChanged", meta: currentMeta() });
|
|
335
|
+
startRun(task.prompt);
|
|
336
|
+
|
|
337
|
+
return {
|
|
338
|
+
meta: Effect.sync(currentMeta),
|
|
339
|
+
events: Stream.fromQueue(events),
|
|
340
|
+
send: (text) =>
|
|
341
|
+
Effect.suspend((): Effect.Effect<void, SendError> => {
|
|
342
|
+
if (state.closed || state.exited) return new SendError({ message: "Subagent session is closed." });
|
|
343
|
+
if (state.streaming) {
|
|
344
|
+
return Effect.tryPromise({
|
|
345
|
+
try: () => request({ type: "steer", message: text }),
|
|
346
|
+
catch: (error) => new SendError({ message: boundedError(error) }),
|
|
347
|
+
}).pipe(Effect.asVoid);
|
|
348
|
+
}
|
|
349
|
+
return Effect.sync(() => startRun(text));
|
|
350
|
+
}),
|
|
351
|
+
interrupt: Effect.promise(async () => {
|
|
352
|
+
if (state.closed || state.exited) return;
|
|
353
|
+
await request({ type: "clear_queue" }).catch(() => undefined);
|
|
354
|
+
await request({ type: "abort" }).catch(() => undefined);
|
|
355
|
+
while (!state.closed && !state.exited && state.streaming) {
|
|
356
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
357
|
+
}
|
|
358
|
+
if (!state.closed && !state.settled) {
|
|
359
|
+
state.settled = true;
|
|
360
|
+
emit({ _tag: "RunSettled", outcome: { _tag: "Interrupted" } });
|
|
361
|
+
}
|
|
362
|
+
}),
|
|
363
|
+
} satisfies SubagentSession;
|
|
364
|
+
});
|
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
shutdownAndDisposeChildSession,
|
|
38
38
|
} from "../../../shared/child-session.ts";
|
|
39
39
|
import { createToolCallTimeoutGuard } from "../../../shared/tool-call-timeout.ts";
|
|
40
|
+
import { makeProcessSession } from "./pi-process.ts";
|
|
40
41
|
|
|
41
42
|
const CHILD_SHUTDOWN_TIMEOUT_MS = 5_000;
|
|
42
43
|
|
|
@@ -70,6 +71,11 @@ const READ_CAPABILITY_TOOLS = new Set([
|
|
|
70
71
|
"ask_parent",
|
|
71
72
|
"list_peers",
|
|
72
73
|
"message_peer",
|
|
74
|
+
// Pi built-ins that process-runtime children load. Each tool a codemode
|
|
75
|
+
// script calls passes the same tool_call check, and tool_search only finds
|
|
76
|
+
// tools, so neither can widen a child's capability.
|
|
77
|
+
"codemode",
|
|
78
|
+
"tool_search",
|
|
73
79
|
]);
|
|
74
80
|
|
|
75
81
|
const WRITE_CAPABILITY_TOOLS = new Set(["write", "edit"]);
|
|
@@ -96,27 +102,40 @@ export function filterToolsForCapability(
|
|
|
96
102
|
});
|
|
97
103
|
}
|
|
98
104
|
|
|
105
|
+
/** True when a child with this capability and optional exact allowlist may use the tool. */
|
|
106
|
+
export function childToolAllowed(
|
|
107
|
+
name: string,
|
|
108
|
+
capability: SpawnTask["capability"],
|
|
109
|
+
allowlist?: ReadonlyArray<string>,
|
|
110
|
+
): boolean {
|
|
111
|
+
if (allowlist && !allowlist.includes(name)) return false;
|
|
112
|
+
return filterToolsForCapability([name], capability).length > 0;
|
|
113
|
+
}
|
|
114
|
+
|
|
99
115
|
/**
|
|
100
|
-
* Keep a
|
|
101
|
-
* tool_call blocker refuses every
|
|
102
|
-
* after startup and nested codemode calls; the loadout filter keeps
|
|
103
|
-
* the model's view. Tool definitions are never mutated because Pi
|
|
104
|
-
* by reference with the parent session.
|
|
116
|
+
* Keep a child inside its capability and allowlist for its whole life. A
|
|
117
|
+
* child-only tool_call blocker refuses every disallowed tool, including tools
|
|
118
|
+
* registered after startup and nested codemode calls; the loadout filter keeps
|
|
119
|
+
* them out of the model's view. Tool definitions are never mutated because Pi
|
|
120
|
+
* shares them by reference with the parent session.
|
|
105
121
|
*/
|
|
106
|
-
export function capabilityGuardExtension(
|
|
122
|
+
export function capabilityGuardExtension(
|
|
123
|
+
capability: SpawnTask["capability"],
|
|
124
|
+
allowlist?: ReadonlyArray<string>,
|
|
125
|
+
): ExtensionFactory {
|
|
107
126
|
return (pi) => {
|
|
108
|
-
if (capability === "all") return;
|
|
127
|
+
if (capability === "all" && !allowlist) return;
|
|
109
128
|
pi.on("tool_call", (event) => {
|
|
110
|
-
if (
|
|
111
|
-
return { block: true, reason: `Tool "${event.toolName}" is not permitted for
|
|
129
|
+
if (childToolAllowed(event.toolName, capability, allowlist)) return undefined;
|
|
130
|
+
return { block: true, reason: `Tool "${event.toolName}" is not permitted for this subagent.` };
|
|
112
131
|
});
|
|
113
132
|
};
|
|
114
133
|
}
|
|
115
134
|
|
|
116
|
-
function restrictActiveTools(session: AgentSession, capability: SpawnTask["capability"]) {
|
|
117
|
-
if (capability === "all") return;
|
|
135
|
+
function restrictActiveTools(session: AgentSession, capability: SpawnTask["capability"], allowlist?: ReadonlyArray<string>) {
|
|
136
|
+
if (capability === "all" && !allowlist) return;
|
|
118
137
|
const active = session.getActiveToolNames();
|
|
119
|
-
const allowed =
|
|
138
|
+
const allowed = active.filter((name) => childToolAllowed(name, capability, allowlist));
|
|
120
139
|
if (allowed.length !== active.length) session.setActiveToolsByName(allowed);
|
|
121
140
|
}
|
|
122
141
|
|
|
@@ -249,11 +268,10 @@ function messageRole(msg: unknown): Message["role"] | undefined {
|
|
|
249
268
|
return undefined;
|
|
250
269
|
}
|
|
251
270
|
|
|
252
|
-
function lastAssistantMessage(
|
|
253
|
-
|
|
271
|
+
export function lastAssistantMessage(
|
|
272
|
+
messages: ReadonlyArray<unknown>,
|
|
254
273
|
fromIndex = 0,
|
|
255
274
|
): AssistantMessage | undefined {
|
|
256
|
-
const messages = session.messages;
|
|
257
275
|
for (let i = messages.length - 1; i >= fromIndex; i--) {
|
|
258
276
|
const msg = messages[i];
|
|
259
277
|
if (messageRole(msg) === "assistant") return msg as AssistantMessage;
|
|
@@ -262,8 +280,7 @@ function lastAssistantMessage(
|
|
|
262
280
|
}
|
|
263
281
|
|
|
264
282
|
/** Final assistant text output (last assistant message with text), v1 semantics. */
|
|
265
|
-
function finalOutput(
|
|
266
|
-
const messages = session.messages;
|
|
283
|
+
export function finalOutput(messages: ReadonlyArray<unknown>, fromIndex = 0): string {
|
|
267
284
|
for (let i = messages.length - 1; i >= fromIndex; i--) {
|
|
268
285
|
const msg = messages[i];
|
|
269
286
|
if (messageRole(msg) !== "assistant") continue;
|
|
@@ -347,7 +364,68 @@ function userText(msg: Message): string {
|
|
|
347
364
|
|
|
348
365
|
// --- The session ------------------------------------------------------------------
|
|
349
366
|
|
|
350
|
-
|
|
367
|
+
/**
|
|
368
|
+
* Translate the Pi session events both runtimes share into subagent events.
|
|
369
|
+
* Run lifecycle, usage, and metadata stay with each runtime.
|
|
370
|
+
*/
|
|
371
|
+
export function translateSessionEvent(
|
|
372
|
+
event: { type: string; [key: string]: any },
|
|
373
|
+
emit: (event: SubagentEvent) => void,
|
|
374
|
+
): void {
|
|
375
|
+
switch (event.type) {
|
|
376
|
+
case "message_update": {
|
|
377
|
+
const streamEvent = event.assistantMessageEvent;
|
|
378
|
+
if (streamEvent?.type === "text_delta") emit({ _tag: "AssistantDelta", kind: "text", delta: streamEvent.delta });
|
|
379
|
+
else if (streamEvent?.type === "thinking_delta") emit({ _tag: "AssistantDelta", kind: "thinking", delta: streamEvent.delta });
|
|
380
|
+
break;
|
|
381
|
+
}
|
|
382
|
+
case "message_end": {
|
|
383
|
+
const role = messageRole(event.message);
|
|
384
|
+
if (role === "user") {
|
|
385
|
+
const text = userText(event.message as Message);
|
|
386
|
+
if (text.trim()) emit({ _tag: "UserMessage", text });
|
|
387
|
+
} else if (role === "assistant") {
|
|
388
|
+
emit({ _tag: "AssistantMessage", parts: assistantParts(event.message as AssistantMessage) });
|
|
389
|
+
}
|
|
390
|
+
// toolResult messages are covered by tool_execution_end.
|
|
391
|
+
break;
|
|
392
|
+
}
|
|
393
|
+
case "tool_execution_start":
|
|
394
|
+
emit({ _tag: "ToolStart", toolId: event.toolCallId, name: event.toolName, argsPreview: safeJson(event.args) });
|
|
395
|
+
break;
|
|
396
|
+
case "tool_execution_update":
|
|
397
|
+
emit({ _tag: "ToolUpdate", toolId: event.toolCallId, outputPreview: toolPreview(event.partialResult) });
|
|
398
|
+
break;
|
|
399
|
+
case "tool_execution_end":
|
|
400
|
+
emit({ _tag: "ToolEnd", toolId: event.toolCallId, name: event.toolName, isError: event.isError, outputPreview: toolPreview(event.result) });
|
|
401
|
+
break;
|
|
402
|
+
case "queue_update":
|
|
403
|
+
emit({
|
|
404
|
+
_tag: "QueueChanged",
|
|
405
|
+
queued: [
|
|
406
|
+
...(event.steering as string[]).map((text) => ({ text, kind: "steer" as const })),
|
|
407
|
+
...(event.followUp as string[]).map((text) => ({ text, kind: "follow-up" as const })),
|
|
408
|
+
],
|
|
409
|
+
});
|
|
410
|
+
break;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** Settle one run from its own messages; an earlier run's answer never counts. */
|
|
415
|
+
export function settledOutcome(
|
|
416
|
+
messages: ReadonlyArray<unknown>,
|
|
417
|
+
fromIndex: number,
|
|
418
|
+
runError: string | undefined,
|
|
419
|
+
): Extract<SubagentEvent, { _tag: "RunSettled" }>["outcome"] {
|
|
420
|
+
const last = lastAssistantMessage(messages, fromIndex);
|
|
421
|
+
const partialText = finalOutput(messages, fromIndex) || undefined;
|
|
422
|
+
if (last?.stopReason === "aborted") return { _tag: "Interrupted", partialText };
|
|
423
|
+
const errorText = runError ?? (last?.stopReason === "error" ? (last.errorMessage ?? "Run failed") : undefined);
|
|
424
|
+
if (errorText !== undefined) return { _tag: "Failed", errorText: boundedError(errorText), partialText };
|
|
425
|
+
return { _tag: "Completed", finalText: finalOutput(messages, fromIndex) };
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
export function boundedError(error: unknown) {
|
|
351
429
|
return (error instanceof Error ? error.message : String(error)).slice(
|
|
352
430
|
0,
|
|
353
431
|
4096,
|
|
@@ -379,7 +457,10 @@ const makePiSession = (
|
|
|
379
457
|
const { loader, settingsManager } = await createChildResources({
|
|
380
458
|
cwd: task.cwd,
|
|
381
459
|
projectTrusted: task.parent.projectTrusted,
|
|
382
|
-
extensionFactories: [capabilityGuardExtension(task.capability)],
|
|
460
|
+
extensionFactories: [capabilityGuardExtension(task.capability, task.childOptions?.tools)],
|
|
461
|
+
noSkills: task.childOptions?.skills === false,
|
|
462
|
+
noContextFiles: task.childOptions?.contextFiles === false,
|
|
463
|
+
noExtensions: task.childOptions?.extensions === false,
|
|
383
464
|
});
|
|
384
465
|
const sessionManager = task.resumeSessionFile
|
|
385
466
|
? SessionManager.open(task.resumeSessionFile, undefined, task.cwd)
|
|
@@ -409,7 +490,7 @@ const makePiSession = (
|
|
|
409
490
|
// the scope finalizer that owns cleanup is only registered later.
|
|
410
491
|
try {
|
|
411
492
|
await session.bindExtensions({ mode: "print" });
|
|
412
|
-
restrictActiveTools(session, task.capability);
|
|
493
|
+
restrictActiveTools(session, task.capability, task.childOptions?.tools);
|
|
413
494
|
} catch (error) {
|
|
414
495
|
await shutdownAndDisposeChildSession(session);
|
|
415
496
|
throw error;
|
|
@@ -441,7 +522,7 @@ const makePiSession = (
|
|
|
441
522
|
|
|
442
523
|
const activeModel = (): Model<any> | undefined => {
|
|
443
524
|
const sessionModel = session.model;
|
|
444
|
-
const last = lastAssistantMessage(session);
|
|
525
|
+
const last = lastAssistantMessage(session.messages);
|
|
445
526
|
if (!last) return sessionModel;
|
|
446
527
|
if (
|
|
447
528
|
sessionModel &&
|
|
@@ -488,37 +569,7 @@ const makePiSession = (
|
|
|
488
569
|
const settle = () => {
|
|
489
570
|
if (state.settled) return;
|
|
490
571
|
state.settled = true;
|
|
491
|
-
|
|
492
|
-
// the output of a later run that failed before responding.
|
|
493
|
-
const last = lastAssistantMessage(session, state.runStartIndex);
|
|
494
|
-
const partialText = finalOutput(session, state.runStartIndex) || undefined;
|
|
495
|
-
if (last?.stopReason === "aborted") {
|
|
496
|
-
emit({
|
|
497
|
-
_tag: "RunSettled",
|
|
498
|
-
outcome: { _tag: "Interrupted", partialText },
|
|
499
|
-
});
|
|
500
|
-
return;
|
|
501
|
-
}
|
|
502
|
-
const errorText =
|
|
503
|
-
state.runError ??
|
|
504
|
-
(last?.stopReason === "error"
|
|
505
|
-
? (last.errorMessage ?? "Run failed")
|
|
506
|
-
: undefined);
|
|
507
|
-
if (errorText !== undefined) {
|
|
508
|
-
emit({
|
|
509
|
-
_tag: "RunSettled",
|
|
510
|
-
outcome: {
|
|
511
|
-
_tag: "Failed",
|
|
512
|
-
errorText: boundedError(errorText),
|
|
513
|
-
partialText,
|
|
514
|
-
},
|
|
515
|
-
});
|
|
516
|
-
return;
|
|
517
|
-
}
|
|
518
|
-
emit({
|
|
519
|
-
_tag: "RunSettled",
|
|
520
|
-
outcome: { _tag: "Completed", finalText: finalOutput(session, state.runStartIndex) },
|
|
521
|
-
});
|
|
572
|
+
emit({ _tag: "RunSettled", outcome: settledOutcome(session.messages, state.runStartIndex, state.runError) });
|
|
522
573
|
};
|
|
523
574
|
|
|
524
575
|
const handleEvent = (event: AgentSessionEvent) => {
|
|
@@ -526,82 +577,24 @@ const makePiSession = (
|
|
|
526
577
|
switch (event.type) {
|
|
527
578
|
case "agent_start":
|
|
528
579
|
// Extensions may register tools between runs; guard new ones too.
|
|
529
|
-
restrictActiveTools(session, task.capability);
|
|
580
|
+
restrictActiveTools(session, task.capability, task.childOptions?.tools);
|
|
530
581
|
toolTimeout.apply(session);
|
|
531
582
|
state.settled = false;
|
|
532
583
|
emit({ _tag: "RunStarted" });
|
|
533
584
|
break;
|
|
534
|
-
case "
|
|
535
|
-
|
|
536
|
-
if (
|
|
537
|
-
emit({
|
|
538
|
-
_tag: "AssistantDelta",
|
|
539
|
-
kind: "text",
|
|
540
|
-
delta: streamEvent.delta,
|
|
541
|
-
});
|
|
542
|
-
} else if (streamEvent.type === "thinking_delta") {
|
|
543
|
-
emit({
|
|
544
|
-
_tag: "AssistantDelta",
|
|
545
|
-
kind: "thinking",
|
|
546
|
-
delta: streamEvent.delta,
|
|
547
|
-
});
|
|
548
|
-
}
|
|
549
|
-
break;
|
|
550
|
-
}
|
|
551
|
-
case "message_end": {
|
|
552
|
-
const role = messageRole(event.message);
|
|
553
|
-
if (role === "user") {
|
|
554
|
-
const text = userText(event.message as Message);
|
|
555
|
-
if (text.trim()) emit({ _tag: "UserMessage", text });
|
|
556
|
-
} else if (role === "assistant") {
|
|
557
|
-
emit({
|
|
558
|
-
_tag: "AssistantMessage",
|
|
559
|
-
parts: assistantParts(event.message as AssistantMessage),
|
|
560
|
-
});
|
|
585
|
+
case "message_end":
|
|
586
|
+
translateSessionEvent(event, emit);
|
|
587
|
+
if (messageRole(event.message) === "assistant") {
|
|
561
588
|
emitUsage();
|
|
562
589
|
emit({ _tag: "MetaChanged", meta: currentMeta() });
|
|
563
590
|
}
|
|
564
|
-
// toolResult messages are covered by tool_execution_end.
|
|
565
591
|
break;
|
|
566
|
-
|
|
592
|
+
case "message_update":
|
|
567
593
|
case "tool_execution_start":
|
|
568
|
-
emit({
|
|
569
|
-
_tag: "ToolStart",
|
|
570
|
-
toolId: event.toolCallId,
|
|
571
|
-
name: event.toolName,
|
|
572
|
-
argsPreview: safeJson(event.args),
|
|
573
|
-
});
|
|
574
|
-
break;
|
|
575
594
|
case "tool_execution_update":
|
|
576
|
-
emit({
|
|
577
|
-
_tag: "ToolUpdate",
|
|
578
|
-
toolId: event.toolCallId,
|
|
579
|
-
outputPreview: toolPreview(event.partialResult),
|
|
580
|
-
});
|
|
581
|
-
break;
|
|
582
595
|
case "tool_execution_end":
|
|
583
|
-
emit({
|
|
584
|
-
_tag: "ToolEnd",
|
|
585
|
-
toolId: event.toolCallId,
|
|
586
|
-
name: event.toolName,
|
|
587
|
-
isError: event.isError,
|
|
588
|
-
outputPreview: toolPreview(event.result),
|
|
589
|
-
});
|
|
590
|
-
break;
|
|
591
596
|
case "queue_update":
|
|
592
|
-
emit
|
|
593
|
-
_tag: "QueueChanged",
|
|
594
|
-
queued: [
|
|
595
|
-
...event.steering.map((text) => ({
|
|
596
|
-
text,
|
|
597
|
-
kind: "steer" as const,
|
|
598
|
-
})),
|
|
599
|
-
...event.followUp.map((text) => ({
|
|
600
|
-
text,
|
|
601
|
-
kind: "follow-up" as const,
|
|
602
|
-
})),
|
|
603
|
-
],
|
|
604
|
-
});
|
|
597
|
+
translateSessionEvent(event, emit);
|
|
605
598
|
break;
|
|
606
599
|
case "agent_settled":
|
|
607
600
|
settle();
|
|
@@ -696,5 +689,6 @@ export const piBackend: SubagentBackend = {
|
|
|
696
689
|
capabilities: { steering: true, modelSelection: true, reasoningEffort: true },
|
|
697
690
|
// In-process SDK: always available.
|
|
698
691
|
available: Effect.succeed(true),
|
|
699
|
-
|
|
692
|
+
// Profiles choose the runtime; "process" runs the child as its own Pi process.
|
|
693
|
+
spawn: (task) => (task.childOptions?.runtime === "process" ? makeProcessSession(task) : makePiSession(task)),
|
|
700
694
|
};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loaded with `-e` into a process-runtime subagent (`pi --mode rpc`). It keeps
|
|
3
|
+
* the child inside its capability and carries the parent bridge tools over
|
|
4
|
+
* Pi's RPC extension-UI subprotocol: notifications for one-way updates and
|
|
5
|
+
* input dialogs for questions that need an answer. It is not a suite
|
|
6
|
+
* extension and does nothing outside a child process.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { Type } from "typebox";
|
|
11
|
+
import { CHILD_EXCLUDED_TOOL_NAMES } from "../../shared/child-session.ts";
|
|
12
|
+
import { childToolAllowed } from "./backends/pi.ts";
|
|
13
|
+
import type { CapabilityMode } from "./config.ts";
|
|
14
|
+
|
|
15
|
+
export const CHILD_ENV = "PI_SUBAGENT_CHILD";
|
|
16
|
+
export const CHILD_CAPABILITY_ENV = "PI_SUBAGENT_CAPABILITY";
|
|
17
|
+
export const CHILD_TOOLS_ENV = "PI_SUBAGENT_TOOLS";
|
|
18
|
+
export const CHILD_BRIDGE_ENV = "PI_SUBAGENT_BRIDGE";
|
|
19
|
+
/** Prefix that marks bridge traffic among ordinary extension UI requests. */
|
|
20
|
+
export const BRIDGE_MARK = "pi-subagent-bridge:";
|
|
21
|
+
|
|
22
|
+
export type BridgeRequest =
|
|
23
|
+
| { kind: "notify"; message: string }
|
|
24
|
+
| { kind: "ask"; question: string }
|
|
25
|
+
| { kind: "peers" }
|
|
26
|
+
| { kind: "peer"; id: string; message: string };
|
|
27
|
+
|
|
28
|
+
const excluded = new Set<string>(CHILD_EXCLUDED_TOOL_NAMES);
|
|
29
|
+
|
|
30
|
+
async function request(ctx: ExtensionContext, body: BridgeRequest, signal?: AbortSignal): Promise<string> {
|
|
31
|
+
const reply = await ctx.ui.input(`${BRIDGE_MARK}${JSON.stringify(body)}`, "", { signal });
|
|
32
|
+
if (reply === undefined) throw new Error("The parent session did not answer.");
|
|
33
|
+
if (reply.startsWith("error:")) throw new Error(reply.slice("error:".length).trim());
|
|
34
|
+
return reply;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export default function childBridge(pi: ExtensionAPI) {
|
|
38
|
+
if (process.env[CHILD_ENV] !== "1") return;
|
|
39
|
+
const capability = (process.env[CHILD_CAPABILITY_ENV] ?? "all") as CapabilityMode;
|
|
40
|
+
const allowlist = process.env[CHILD_TOOLS_ENV] ? (JSON.parse(process.env[CHILD_TOOLS_ENV]!) as string[]) : undefined;
|
|
41
|
+
const allowed = (name: string) => !excluded.has(name) && childToolAllowed(name, capability, allowlist);
|
|
42
|
+
|
|
43
|
+
pi.on("tool_call", (event) => (allowed(event.toolName)
|
|
44
|
+
? undefined
|
|
45
|
+
: { block: true, reason: `Tool "${event.toolName}" is not permitted for this subagent.` }));
|
|
46
|
+
const restrict = () => {
|
|
47
|
+
const active = pi.getActiveTools();
|
|
48
|
+
const next = active.filter(allowed);
|
|
49
|
+
if (next.length !== active.length) pi.setActiveTools(next);
|
|
50
|
+
};
|
|
51
|
+
pi.on("session_start", restrict);
|
|
52
|
+
pi.on("agent_start", restrict);
|
|
53
|
+
|
|
54
|
+
if (process.env[CHILD_BRIDGE_ENV] !== "1") return;
|
|
55
|
+
pi.registerTool({
|
|
56
|
+
name: "message_parent",
|
|
57
|
+
label: "Message Parent",
|
|
58
|
+
description: "Send the parent a concise non-blocking update or warning.",
|
|
59
|
+
exposure: "model-only",
|
|
60
|
+
parameters: Type.Object({ message: Type.String({ description: "Parent update" }) }),
|
|
61
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
62
|
+
ctx.ui.notify(`${BRIDGE_MARK}${JSON.stringify({ kind: "notify", message: params.message } satisfies BridgeRequest)}`, "info");
|
|
63
|
+
return { content: [{ type: "text", text: "Update sent to parent." }], details: {} };
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
pi.registerTool({
|
|
67
|
+
name: "ask_parent",
|
|
68
|
+
label: "Ask Parent",
|
|
69
|
+
description: "Ask the parent a blocking question required to continue.",
|
|
70
|
+
exposure: "model-only",
|
|
71
|
+
parameters: Type.Object({ question: Type.String({ description: "Parent question" }) }),
|
|
72
|
+
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
73
|
+
const reply = await request(ctx, { kind: "ask", question: params.question }, signal);
|
|
74
|
+
return { content: [{ type: "text", text: `Parent reply:\n${reply}` }], details: {} };
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
pi.registerTool({
|
|
78
|
+
name: "list_peers",
|
|
79
|
+
label: "List Peer Agents",
|
|
80
|
+
description: "List sibling agents managed by the main thread. This cannot create agents.",
|
|
81
|
+
exposure: "model-only",
|
|
82
|
+
parameters: Type.Object({}),
|
|
83
|
+
async execute(_id, _params, signal, _onUpdate, ctx) {
|
|
84
|
+
const peers = JSON.parse(await request(ctx, { kind: "peers" }, signal)) as Array<{ id: string; status: string; title: string }>;
|
|
85
|
+
const text = peers.length
|
|
86
|
+
? peers.map((peer) => `${peer.id}\t${peer.status}\t${peer.title}`).join("\n")
|
|
87
|
+
: "No peer agents are currently tracked.";
|
|
88
|
+
return { content: [{ type: "text", text }], details: { peers } };
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
pi.registerTool({
|
|
92
|
+
name: "message_peer",
|
|
93
|
+
label: "Message Peer Agent",
|
|
94
|
+
description: "Route a message to an existing sibling agent through the main-thread manager. This cannot create agents.",
|
|
95
|
+
exposure: "model-only",
|
|
96
|
+
parameters: Type.Object({
|
|
97
|
+
id: Type.String({ description: "Target peer id from list_peers" }),
|
|
98
|
+
message: Type.String({ description: "Message for the peer" }),
|
|
99
|
+
}),
|
|
100
|
+
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
101
|
+
await request(ctx, { kind: "peer", id: params.id, message: params.message }, signal);
|
|
102
|
+
return { content: [{ type: "text", text: `Message sent to ${params.id}.` }], details: { id: params.id } };
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
}
|
|
@@ -6,9 +6,11 @@ import type { ReasoningEffort } from "./domain.ts";
|
|
|
6
6
|
export const AGENT_TYPES = ["general-purpose", "explore", "plan"] as const;
|
|
7
7
|
export const CAPABILITY_MODES = ["read-only", "read-write", "execute", "all"] as const;
|
|
8
8
|
export const ISOLATION_MODES = ["none", "worktree"] as const;
|
|
9
|
+
export const RUNTIME_MODES = ["in-process", "process"] as const;
|
|
9
10
|
|
|
10
11
|
export type CapabilityMode = (typeof CAPABILITY_MODES)[number];
|
|
11
12
|
export type IsolationMode = (typeof ISOLATION_MODES)[number];
|
|
13
|
+
export type RuntimeMode = (typeof RUNTIME_MODES)[number];
|
|
12
14
|
|
|
13
15
|
export interface AgentProfile {
|
|
14
16
|
description?: string;
|
|
@@ -17,6 +19,23 @@ export interface AgentProfile {
|
|
|
17
19
|
model?: string;
|
|
18
20
|
thinking?: ReasoningEffort;
|
|
19
21
|
isolation?: IsolationMode;
|
|
22
|
+
/** "process" runs the child as a separate `pi --mode rpc` process with Pi's built-in extensions. */
|
|
23
|
+
runtime?: RuntimeMode;
|
|
24
|
+
/** Exact tool allowlist, applied on top of the capability. */
|
|
25
|
+
tools?: string[];
|
|
26
|
+
/** Set false to start the child without skills, AGENTS.md files, or extensions. */
|
|
27
|
+
skills?: boolean;
|
|
28
|
+
contextFiles?: boolean;
|
|
29
|
+
extensions?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Child session options resolved from a profile; none of them reach the model. */
|
|
33
|
+
export interface ChildSessionOptions {
|
|
34
|
+
runtime: RuntimeMode;
|
|
35
|
+
tools?: readonly string[];
|
|
36
|
+
skills: boolean;
|
|
37
|
+
contextFiles: boolean;
|
|
38
|
+
extensions: boolean;
|
|
20
39
|
}
|
|
21
40
|
|
|
22
41
|
export interface PersonaProfile {
|
|
@@ -93,13 +112,27 @@ function parseProfile(value: unknown): AgentProfile | undefined {
|
|
|
93
112
|
if (thinking) profile.thinking = thinking;
|
|
94
113
|
const isolation = enumValue(value.isolation, ISOLATION_MODES);
|
|
95
114
|
if (isolation) profile.isolation = isolation;
|
|
115
|
+
const runtime = enumValue(value.runtime, RUNTIME_MODES);
|
|
116
|
+
if (runtime) profile.runtime = runtime;
|
|
117
|
+
if (Array.isArray(value.tools) && value.tools.every((name) => typeof name === "string" && name.trim())) {
|
|
118
|
+
profile.tools = value.tools.map((name) => (name as string).trim());
|
|
119
|
+
}
|
|
120
|
+
for (const key of ["skills", "contextFiles", "extensions"] as const) {
|
|
121
|
+
if (typeof value[key] === "boolean") profile[key] = value[key] as boolean;
|
|
122
|
+
}
|
|
96
123
|
return profile;
|
|
97
124
|
}
|
|
98
125
|
|
|
99
126
|
function parsePersona(value: unknown): PersonaProfile | undefined {
|
|
100
127
|
const profile = parseProfile(value);
|
|
101
128
|
if (!profile?.instructions) return undefined;
|
|
102
|
-
return {
|
|
129
|
+
return {
|
|
130
|
+
description: profile.description,
|
|
131
|
+
instructions: profile.instructions,
|
|
132
|
+
model: profile.model,
|
|
133
|
+
thinking: profile.thinking,
|
|
134
|
+
isolation: profile.isolation,
|
|
135
|
+
};
|
|
103
136
|
}
|
|
104
137
|
|
|
105
138
|
function mergeConfig(base: SubagentConfig, raw: Record<string, unknown> | undefined): SubagentConfig {
|
|
@@ -168,6 +201,19 @@ function validateProfileDocument(name: string, value: unknown, persona: boolean)
|
|
|
168
201
|
) {
|
|
169
202
|
throw new Error(`${name}.thinking is invalid.`);
|
|
170
203
|
}
|
|
204
|
+
if (persona) return;
|
|
205
|
+
if (value.runtime !== undefined && !enumValue(value.runtime, RUNTIME_MODES)) {
|
|
206
|
+
throw new Error(`${name}.runtime must be one of ${RUNTIME_MODES.join(", ")}.`);
|
|
207
|
+
}
|
|
208
|
+
if (
|
|
209
|
+
value.tools !== undefined &&
|
|
210
|
+
(!Array.isArray(value.tools) || value.tools.length === 0 || !value.tools.every((tool) => typeof tool === "string" && tool.trim()))
|
|
211
|
+
) {
|
|
212
|
+
throw new Error(`${name}.tools must be a non-empty array of tool names.`);
|
|
213
|
+
}
|
|
214
|
+
for (const key of ["skills", "contextFiles", "extensions"]) {
|
|
215
|
+
if (value[key] !== undefined && typeof value[key] !== "boolean") throw new Error(`${name}.${key} must be true or false.`);
|
|
216
|
+
}
|
|
171
217
|
}
|
|
172
218
|
|
|
173
219
|
export function saveConfigDocument(scope: ConfigScope, cwd: string, text: string) {
|
|
@@ -242,5 +288,12 @@ export function resolveProfile(
|
|
|
242
288
|
model: options.model ?? profile.model ?? persona?.model,
|
|
243
289
|
thinking: options.thinking ?? profile.thinking ?? persona?.thinking,
|
|
244
290
|
isolation: options.isolation ?? profile.isolation ?? persona?.isolation ?? "none",
|
|
291
|
+
childOptions: {
|
|
292
|
+
runtime: profile.runtime ?? "in-process",
|
|
293
|
+
tools: profile.tools,
|
|
294
|
+
skills: profile.skills ?? true,
|
|
295
|
+
contextFiles: profile.contextFiles ?? true,
|
|
296
|
+
extensions: profile.extensions ?? true,
|
|
297
|
+
} satisfies ChildSessionOptions,
|
|
245
298
|
} as const;
|
|
246
299
|
}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import type { Message, Model } from "@earendil-works/pi-ai";
|
|
10
10
|
import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
11
11
|
import { Data } from "effect";
|
|
12
|
-
import type { CapabilityMode, IsolationMode } from "./config.ts";
|
|
12
|
+
import type { CapabilityMode, ChildSessionOptions, IsolationMode } from "./config.ts";
|
|
13
13
|
import type { WorktreeInfo } from "./worktree.ts";
|
|
14
14
|
|
|
15
15
|
export type BackendName = "pi";
|
|
@@ -71,6 +71,8 @@ export interface SpawnTask {
|
|
|
71
71
|
readonly reasoningEffort?: ReasoningEffort;
|
|
72
72
|
/** Coarse tool capability policy, resolved from profile/persona/override. */
|
|
73
73
|
readonly capability: CapabilityMode;
|
|
74
|
+
/** Runtime, tool allowlist, and resource switches from the profile. */
|
|
75
|
+
readonly childOptions?: ChildSessionOptions;
|
|
74
76
|
readonly agentType: string;
|
|
75
77
|
readonly persona?: string;
|
|
76
78
|
readonly isolation: IsolationMode;
|