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 +2 -2
- package/docs/EXTENSIONS.md +1 -1
- package/extensions/shared/child-session.ts +7 -0
- package/extensions/subagents/README.md +54 -4
- package/extensions/subagents/index.ts +59 -9
- package/extensions/subagents/src/backends/pi-process.ts +364 -0
- package/extensions/subagents/src/backends/pi.ts +112 -117
- package/extensions/subagents/src/child-bridge.ts +107 -0
- package/extensions/subagents/src/config.ts +135 -13
- package/extensions/subagents/src/domain.ts +3 -1
- package/extensions/subagents/src/fusion.ts +96 -0
- package/package.json +2 -1
- package/skills/fusion/SKILL.md +60 -0
- package/skills/subagents/SKILL.md +3 -3
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,
|
|
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
|
|
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, 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
|
-
|
|
49
|
+
## Runtimes and review options
|
|
50
50
|
|
|
51
|
-
Each tool call inside
|
|
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:
|
|
482
|
-
reasoningEffort:
|
|
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
|
|
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
|
+
});
|