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.
@@ -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, use `model-only` exposure, and running terminals appear in the shared **Active work** dock with elapsed time and the latest output line.
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
- exposure: "model-only",
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`. The session-only `task_list` tool is allowed in every mode.
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
- Each tool call inside a child times out after three minutes.
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 restrictive child inside its capability for its whole life. A child-only
101
- * tool_call blocker refuses every unclassified tool, including tools registered
102
- * after startup and nested codemode calls; the loadout filter keeps them out of
103
- * the model's view. Tool definitions are never mutated because Pi shares them
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(capability: SpawnTask["capability"]): ExtensionFactory {
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 (filterToolsForCapability([event.toolName], capability).length) return undefined;
111
- return { block: true, reason: `Tool "${event.toolName}" is not permitted for a ${capability} subagent.` };
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 = filterToolsForCapability(active, capability);
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
- session: AgentSession,
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(session: AgentSession, fromIndex = 0): string {
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
- function boundedError(error: unknown) {
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
- // Only this run's messages count; an earlier answer must never become
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 "message_update": {
535
- const streamEvent = event.assistantMessageEvent;
536
- if (streamEvent.type === "text_delta") {
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
- spawn: makePiSession,
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 { ...profile, instructions: profile.instructions };
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shariq-pi-extensions",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Cross-platform extension suite for the Pi coding agent.",
5
5
  "license": "MIT",
6
6
  "author": "Shariq Riaz",