shariq-pi-extensions 0.5.1 → 0.7.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/README.md CHANGED
@@ -23,7 +23,7 @@ Run `/reload` in an existing Pi session after installation; new sessions load th
23
23
  - structured user questions (`ask_user`)
24
24
  - persistent goals with evidence checklists and budgets
25
25
  - a task list for multi-deliverable work, with a live progress dock
26
- - Pi subagents with profiles, worktree isolation, and a takeover dashboard
26
+ - Pi subagents with profiles, worktree isolation, a takeover dashboard, and an optional Fusion mode (lead plus persistent sidekick across light, medium, and heavy model tiers)
27
27
  - Smart Compaction: structured checkpoints with file and worktree state
28
28
  - managed background terminals
29
29
  - Firecrawl web search, scraping, and developer search
@@ -33,7 +33,7 @@ Run `/reload` in an existing Pi session after installation; new sessions load th
33
33
  - configurable steer, interrupt, or follow-up Enter behavior
34
34
  - `/exit` as an alias for `/quit`
35
35
 
36
- The package also ships the `ember-warm-dark` theme and the `background-terminals` and `subagents` skills. Pi loads the skills from the installed package; copying them into `<agent-dir>/skills` would leave stale duplicates after removal.
36
+ The package also ships the `ember-warm-dark` theme and the `background-terminals`, `fusion`, and `subagents` skills. Pi loads the skills from the installed package; copying them into `<agent-dir>/skills` would leave stale duplicates after removal.
37
37
 
38
38
  The suite is developed and tested against Pi 1.0. See [`docs/EXTENSIONS.md`](docs/EXTENSIONS.md) for commands, tools, configuration, and external dependencies.
39
39
 
@@ -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, unless optional Fusion mode is on: then the main session leads, a persistent `sidekick` implements decided work, and light `worker` and `verifier` profiles take routine edits and checks, each on a user-configured model tier. 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
 
@@ -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 };
@@ -46,9 +46,20 @@ Capability modes:
46
46
 
47
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
- 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.
49
+ ## Runtimes and review options
50
50
 
51
- Each tool call inside a child times out after three minutes.
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.
52
63
 
53
64
  User profiles and personas live in `<agent-dir>/subagents.json`. Trusted projects can override them in `.pi/subagents.json`:
54
65
 
@@ -60,6 +71,12 @@ User profiles and personas live in `<agent-dir>/subagents.json`. Trusted project
60
71
  "description": "Review changes without editing",
61
72
  "instructions": "Find correctness and regression risks. Cite files and lines.",
62
73
  "capability": "execute"
74
+ },
75
+ "fresh-eyes": {
76
+ "description": "Independent review that ignores repository instructions",
77
+ "capability": "execute",
78
+ "runtime": "process",
79
+ "contextFiles": false
63
80
  }
64
81
  },
65
82
  "personas": {
@@ -72,11 +89,42 @@ User profiles and personas live in `<agent-dir>/subagents.json`. Trusted project
72
89
 
73
90
  Project configuration is ignored for untrusted projects. `maxConcurrent` is bounded to 1–50 and defaults to 50. `/subagents profiles` lists profiles, and `/subagents config` opens a validated editor for the global or trusted-project file.
74
91
 
92
+ ## Fusion mode
93
+
94
+ Fusion mode is optional and off by default. When it is on, the main session acts as the lead and delegates without being asked: one persistent `sidekick` per workstream implements decided work, and light profiles take routine edits and checks. The lead keeps intent, planning, architecture, security, and final review. Turn it on with `/subagents fusion on`, or set `"fusion": true` in either configuration file; a trusted project's setting wins over the global one.
95
+
96
+ Fusion adds these profiles and tiers:
97
+
98
+ | Profile | Tier | Capability | Use |
99
+ |---|---|---|---|
100
+ | `sidekick` | medium | all | decided implementation and debugging from a concrete failure |
101
+ | `explore` | medium | execute | substantive read-only tracing |
102
+ | `reviewer` | medium | execute | one fresh review of a final diff |
103
+ | `worker` | light | all | fully specified mechanical edits, docs, narrow tests |
104
+ | `verifier` | light | execute | builds, tests, and reproductions; reports failures only |
105
+ | `general-purpose` | heavy | all | hard decided work after the sidekick is blocked |
106
+
107
+ Each tier maps to a model and thinking level for children; the lead keeps the model you selected in Pi. An unset tier, or an unset field, inherits the parent session's, so with no `tiers` block every child runs on the parent's model:
108
+
109
+ ```json
110
+ {
111
+ "fusion": true,
112
+ "tiers": {
113
+ "light": { "model": "provider/fast-model", "thinking": "medium" },
114
+ "medium": { "model": "provider/coding-model", "thinking": "xhigh" }
115
+ }
116
+ }
117
+ ```
118
+
119
+ Any profile can set `"tier"`, with or without Fusion mode. A profile's own `model` and `thinking` win over its tier, and a call's overrides win over both. Profiles you define with the same names are merged over the Fusion defaults, so you can change one field without restating the rest. `list_agent_profiles` and `/subagents profiles` show the mode, the tiers, and each profile's tier. A child started from a Fusion profile can still be resumed after the mode is turned off.
120
+
121
+ Only the lead sees the Fusion guideline and the `fusion` skill; children keep the default rules and cannot start agents.
122
+
75
123
  ## Context and continuation
76
124
 
77
125
  New children start without parent context. `fork_turns` can be `all` or a positive number of recent user turns; the fork keeps user messages and final assistant text and drops thinking, tool calls, and tool results.
78
126
 
79
- Every child has a persistent Pi session file. `resume_from` continues a finished or cancelled child with its transcript, tool state, and id, including after a parent reload or restart. An archived child resumes with its stored profile, persona, capability, and worktree unless the call overrides them. A child still loaded in this session keeps its settings, so a resume call that passes different ones is rejected. A run interrupted by cancellation or reload is recorded as `cancelled`, not as a failure.
127
+ Every child has a persistent Pi session file. `resume_from` continues a finished or cancelled child with its transcript, tool state, and id, including after a parent reload or restart. An archived child resumes with its stored profile, persona, capability, and worktree unless the call overrides them, and takes its model and thinking level from that profile, its tier, or the call. A child still loaded in this session keeps its settings, so a resume call that passes different ones is rejected. A run interrupted by cancellation or reload is recorded as `cancelled`, not as a failure.
80
128
 
81
129
  A child running in another Pi process cannot be resumed, and its worktree cannot be changed, until that process finishes or exits; a crashed owner is detected and its child becomes resumable.
82
130
 
@@ -100,7 +148,9 @@ Lifecycle tools render as compact cards with expandable detail. Running children
100
148
  ## Layout
101
149
 
102
150
  - `src/manager.ts`: Effect service, lifecycle, snapshots, cancellation, and retention
103
- - `src/backends/pi.ts`: in-process Pi child sessions, capability filtering, context seeding, resume, and the parent bridge
151
+ - `src/backends/pi.ts`: in-process Pi child sessions, capability filtering, shared event translation, context seeding, resume, and the parent bridge
152
+ - `src/backends/pi-process.ts`: process-runtime children over Pi's RPC protocol
153
+ - `src/child-bridge.ts`: the extension loaded into process-runtime children for capability enforcement and the parent bridge
104
154
  - `src/config.ts`: profiles, personas, validation, and limits
105
155
  - `src/catalog.ts`: global resumable-agent catalog
106
156
  - `src/context.ts`: context forks and profile prompt assembly
@@ -68,6 +68,7 @@ import {
68
68
  type IsolationMode,
69
69
  } from "./src/config.ts";
70
70
  import { buildTaskPrompt, forkConversation, parseForkTurns } from "./src/context.ts";
71
+ import { FUSION_LEAD_GUIDELINE, isFusionSkill } from "./src/fusion.ts";
71
72
  import {
72
73
  applyAgentWorktree,
73
74
  applyAgentWorktreeTo,
@@ -102,6 +103,7 @@ import {
102
103
  upsertSubagentCatalog,
103
104
  type ArchivedSubagent,
104
105
  } from "./src/catalog.ts";
106
+ import { CHILD_ENV } from "./src/child-bridge.ts";
105
107
 
106
108
  const SUBAGENT_OUTPUT_MAX_BYTES = 24 * 1024;
107
109
  const WAIT_OUTPUT_MAX_BYTES = 48 * 1024;
@@ -175,6 +177,8 @@ function truncatedOutput(
175
177
  }
176
178
 
177
179
  export default function (pi: ExtensionAPI) {
180
+ // A process-runtime child is managed by its parent; it must not manage children itself.
181
+ if (process.env[CHILD_ENV] === "1") return;
178
182
  const deliverSettlement = settlementDelivery(pi);
179
183
  let runtime: SubagentRuntime | undefined;
180
184
  let managerPromise: Promise<SubagentManagerShape> | undefined;
@@ -372,7 +376,7 @@ export default function (pi: ExtensionAPI) {
372
376
  ctx: ExtensionContext,
373
377
  options: SpawnOptions,
374
378
  ): Promise<PreparedSpawn> => {
375
- const config = loadSubagentConfig(ctx.cwd, ctx.isProjectTrusted());
379
+ const config = loadSubagentConfig(ctx.cwd, ctx.isProjectTrusted(), { resumable: Boolean(options.resumeFrom) });
376
380
  const liveSource = options.resumeFrom ? manager.view.get(options.resumeFrom) : undefined;
377
381
  if (liveSource?.status === "running") {
378
382
  throw new Error(`Cannot resume running subagent "${options.resumeFrom}"; use send_message instead.`);
@@ -478,9 +482,10 @@ export default function (pi: ExtensionAPI) {
478
482
  title,
479
483
  origin: options.origin ?? "model",
480
484
  cwd,
481
- model: resumeSessionFile ? options.model : profile.model,
482
- reasoningEffort: resumeSessionFile ? options.thinking : profile.thinking,
485
+ model: profile.model,
486
+ reasoningEffort: profile.thinking,
483
487
  capability: profile.capability,
488
+ childOptions: profile.childOptions,
484
489
  agentType: profile.agentType,
485
490
  persona: profile.persona,
486
491
  isolation: profile.isolation,
@@ -692,6 +697,20 @@ export default function (pi: ExtensionAPI) {
692
697
  await closing?.dispose();
693
698
  });
694
699
 
700
+ // Fusion mode swaps the lead's activation rule and reveals the fusion skill.
701
+ // Children never have spawn_agent; they also hide the skill through hideFusionSkill.
702
+ pi.on("before_agent_start", (event, ctx) => {
703
+ const options = event.systemPromptOptions;
704
+ const lead = options.selectedTools.includes("spawn_agent");
705
+ const fusion = lead && loadSubagentConfig(ctx.cwd, ctx.isProjectTrusted()).fusion;
706
+ if (fusion && options.toolGuidelines.spawn_agent) {
707
+ options.toolGuidelines.spawn_agent = options.toolGuidelines.spawn_agent.map((guideline) =>
708
+ guideline === SUBAGENT_SPAWN_PROMPT_GUIDELINES[0] ? FUSION_LEAD_GUIDELINE : guideline,
709
+ );
710
+ }
711
+ if (!fusion) options.skills = options.skills.filter((skill) => !isFusionSkill(skill.baseDir));
712
+ });
713
+
695
714
  // --- Tools -------------------------------------------------------------
696
715
 
697
716
  pi.registerTool({
@@ -1025,7 +1044,10 @@ export default function (pi: ExtensionAPI) {
1025
1044
  const config = loadSubagentConfig(ctx.cwd, ctx.isProjectTrusted());
1026
1045
  const profileLines = Object.entries(config.profiles).map(
1027
1046
  ([name, profile]) =>
1028
- `profile ${name}: ${profile.description ?? "no description"}; capability=${profile.capability ?? "all"}; isolation=${profile.isolation ?? "none"}`,
1047
+ `profile ${name}: ${profile.description ?? "no description"}; capability=${profile.capability ?? "all"}; isolation=${profile.isolation ?? "none"}${profile.tier ? `; tier=${profile.tier}` : ""}`,
1048
+ );
1049
+ const tierLines = Object.entries(config.tiers).map(
1050
+ ([tier, setting]) => `tier ${tier}: model=${setting?.model ?? "parent"}; thinking=${setting?.thinking ?? "parent"}`,
1029
1051
  );
1030
1052
  const personaLines = Object.entries(config.personas).map(
1031
1053
  ([name, persona]) => `persona ${name}: ${persona.description ?? "no description"}`,
@@ -1033,9 +1055,9 @@ export default function (pi: ExtensionAPI) {
1033
1055
  return {
1034
1056
  content: [{
1035
1057
  type: "text",
1036
- text: [`maxConcurrent=${config.maxConcurrent}`, ...profileLines, ...personaLines].join("\n"),
1058
+ text: [`maxConcurrent=${config.maxConcurrent}`, `fusion=${config.fusion ? "on" : "off"}`, ...tierLines, ...profileLines, ...personaLines].join("\n"),
1037
1059
  }],
1038
- details: { maxConcurrent: config.maxConcurrent, profiles: config.profiles, personas: config.personas },
1060
+ details: { maxConcurrent: config.maxConcurrent, fusion: config.fusion, tiers: config.tiers, profiles: config.profiles, personas: config.personas },
1039
1061
  };
1040
1062
  },
1041
1063
  renderCall(_args, theme) {
@@ -1427,9 +1449,9 @@ export default function (pi: ExtensionAPI) {
1427
1449
  });
1428
1450
 
1429
1451
  pi.registerCommand("subagents", {
1430
- description: "Inspect agents, peer messages, profiles, or configuration",
1452
+ description: "Inspect agents, peer messages, profiles, or configuration, or switch Fusion mode",
1431
1453
  getArgumentCompletions: (prefix) =>
1432
- ["agents", "peers", "profiles", "config"]
1454
+ ["agents", "peers", "profiles", "config", "fusion", "fusion on", "fusion off"]
1433
1455
  .filter((value) => value.startsWith(prefix.trim()))
1434
1456
  .map((value) => ({ value, label: value })),
1435
1457
  handler: async (args, ctx) => {
@@ -1458,6 +1480,33 @@ export default function (pi: ExtensionAPI) {
1458
1480
  }
1459
1481
  return;
1460
1482
  }
1483
+ if (action === "fusion" || action.startsWith("fusion ")) {
1484
+ const value = action.slice("fusion".length).trim();
1485
+ const enabled = () => loadSubagentConfig(ctx.cwd, ctx.isProjectTrusted()).fusion;
1486
+ if (!value) {
1487
+ ctx.ui.notify(`Fusion mode is ${enabled() ? "on" : "off"}. Use /subagents fusion on or off.`, "info");
1488
+ return;
1489
+ }
1490
+ if (value !== "on" && value !== "off") {
1491
+ ctx.ui.notify("Use /subagents fusion on or off", "error");
1492
+ return;
1493
+ }
1494
+ try {
1495
+ const document = JSON.parse(loadConfigDocument("global", ctx.cwd)) as Record<string, unknown>;
1496
+ document.fusion = value === "on";
1497
+ saveConfigDocument("global", ctx.cwd, JSON.stringify(document));
1498
+ const overridden = enabled() !== (value === "on");
1499
+ ctx.ui.notify(
1500
+ overridden
1501
+ ? `Saved fusion ${value} globally, but this project's .pi/subagents.json overrides it`
1502
+ : `Fusion mode ${value}; it applies from your next message`,
1503
+ overridden ? "warning" : "info",
1504
+ );
1505
+ } catch (error) {
1506
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
1507
+ }
1508
+ return;
1509
+ }
1461
1510
  const manager = await getManager();
1462
1511
  if (action === "peers") {
1463
1512
  await openPeerMessageViewer(ctx, manager.view);
@@ -1466,6 +1515,7 @@ export default function (pi: ExtensionAPI) {
1466
1515
  if (action === "profiles") {
1467
1516
  const config = loadSubagentConfig(ctx.cwd, ctx.isProjectTrusted());
1468
1517
  const entries = [
1518
+ { label: `fusion: ${config.fusion ? "on" : "off"} · tiers`, text: JSON.stringify({ fusion: config.fusion, tiers: config.tiers }, null, 2) },
1469
1519
  ...Object.entries(config.profiles).map(([name, value]) => ({
1470
1520
  label: `profile: ${name}`,
1471
1521
  text: JSON.stringify(value, null, 2),
@@ -1481,7 +1531,7 @@ export default function (pi: ExtensionAPI) {
1481
1531
  return;
1482
1532
  }
1483
1533
  if (action !== "agents") {
1484
- ctx.ui.notify('Use /subagents agents, peers, profiles, or config', "error");
1534
+ ctx.ui.notify('Use /subagents agents, peers, profiles, config, or fusion', "error");
1485
1535
  return;
1486
1536
  }
1487
1537
  if (manager.view.size() === 0) {
@@ -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
+ });