@leo-alvarenga/pi-mini-subagents 0.1.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/LICENSE +21 -0
- package/README.md +83 -0
- package/package.json +46 -0
- package/skills/subagent/SKILL.md +52 -0
- package/src/command.ts +71 -0
- package/src/constants.ts +71 -0
- package/src/core.ts +104 -0
- package/src/index.ts +31 -0
- package/src/spawn.ts +202 -0
- package/src/state.ts +88 -0
- package/src/tool.ts +321 -0
- package/src/types.ts +28 -0
- package/src/utils.ts +98 -0
- package/src/widget.ts +67 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Leonardo A. Alvarenga
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# @leo-alvarenga/pi-mini-subagents
|
|
2
|
+
|
|
3
|
+
Transient subagents for the [Pi coding agent](https://github.com/earendil-works/pi-mono).
|
|
4
|
+
Delegate a task to a separate headless `pi` process (its own context window),
|
|
5
|
+
get its findings back, and watch running/completed subagents live in a panel
|
|
6
|
+
above the input editor.
|
|
7
|
+
|
|
8
|
+
## Features
|
|
9
|
+
|
|
10
|
+
- **`mini_subagent` tool**: single mode (`task`) or parallel mode (`tasks[]`,
|
|
11
|
+
max 8, 4 concurrent). Each subagent is a transient `pi --mode json -p --no-session`
|
|
12
|
+
process, so it has an isolated context window and nothing is persisted.
|
|
13
|
+
- **Read-only by default**: subagents get `read/grep/find/ls`. Set
|
|
14
|
+
`allowWrite: true` to grant `replace/insert/edit/write` (hash-anchored ops
|
|
15
|
+
preferred).
|
|
16
|
+
- **No recursion**: children inherit `PI_SUBAGENT=1` and the extension no-ops
|
|
17
|
+
on it, so a subagent can never spawn its own subagents.
|
|
18
|
+
- **Dynamic minimal prompt**: read-only restriction always; a write clause is
|
|
19
|
+
appended only when `allowWrite` is set.
|
|
20
|
+
- **Question protocol**: a subagent that cannot proceed emits a `NEEDS_INPUT:`
|
|
21
|
+
block in its final message. The tool reports the questions and instructs the
|
|
22
|
+
caller to re-call with `answers` (a fresh subagent is spawned with them).
|
|
23
|
+
- **Live TUI panel** (`Alt+S`): header with running/done counts, Nerd Font
|
|
24
|
+
status glyphs (⏳ running, ✓ done, ✗ failed, ? needs input), an 8-row budget
|
|
25
|
+
with `… +N more`, and hidden entirely while empty.
|
|
26
|
+
- **`/subagents` command**: prints the full list grouped by status.
|
|
27
|
+
- **Session-isolated state**: survives `/reload` and compaction via replay
|
|
28
|
+
from the session branch; no files written by the extension. Running records
|
|
29
|
+
are dropped on replay (their processes do not survive a reload).
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pi install npm:@leo-alvarenga/pi-mini-subagents
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
or add the local path / npm spec to your project `.pi/settings.json`, then run
|
|
38
|
+
`/reload`.
|
|
39
|
+
|
|
40
|
+
## Usage
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
"Use a subagent to find every place we do auth"
|
|
44
|
+
"Run 3 subagents in parallel: one for models, one for providers, one for routes"
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Tool parameters:
|
|
48
|
+
|
|
49
|
+
- `task` (single mode) or `tasks` (parallel mode, array of `{ task, allowWrite?, answers?, cwd? }`)
|
|
50
|
+
- `allowWrite` (single mode, default false)
|
|
51
|
+
- `answers` (single mode re-spawn)
|
|
52
|
+
- `cwd` (single mode working directory)
|
|
53
|
+
|
|
54
|
+
## The NEEDS_INPUT loop
|
|
55
|
+
|
|
56
|
+
1. A subagent ends with:
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
NEEDS_INPUT:
|
|
60
|
+
- which auth provider should this use?
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
2. `mini_subagent` returns `needs_input` with the questions listed.
|
|
64
|
+
3. Answer them (ask the user if you don't know), then call `mini_subagent`
|
|
65
|
+
again with the same `task` and your answers in `answers`. A new subagent is
|
|
66
|
+
spawned with the answers embedded.
|
|
67
|
+
|
|
68
|
+
## Keybinding
|
|
69
|
+
|
|
70
|
+
`Alt+S` toggles the panel. If your terminal intercepts that chord, pick a free
|
|
71
|
+
one and change `PANEL_TOGGLE_CHORD` in `src/constants.ts`.
|
|
72
|
+
|
|
73
|
+
## Skill
|
|
74
|
+
|
|
75
|
+
The package ships a sample `subagent` skill (`skills/subagent/SKILL.md`),
|
|
76
|
+
auto-loaded by pi. It teaches the agent when to delegate to `mini_subagent`,
|
|
77
|
+
how to write self-contained tasks, and when to keep work in the main context.
|
|
78
|
+
To install it manually (e.g. without the extension), copy that `SKILL.md` into
|
|
79
|
+
`~/.pi/agent/skills/subagent/`.
|
|
80
|
+
|
|
81
|
+
## License
|
|
82
|
+
|
|
83
|
+
MIT — see [LICENSE](LICENSE). Copyright (c) 2026 Leonardo A. Alvarenga.
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@leo-alvarenga/pi-mini-subagents",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Transient subagents for Pi: mini_subagent tool (single + parallel), /subagents command, and a live TUI panel",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": "github.com/leo-alvarenga/pi-mono",
|
|
7
|
+
"publishConfig": {
|
|
8
|
+
"access": "public"
|
|
9
|
+
},
|
|
10
|
+
"keywords": [
|
|
11
|
+
"pi-extension",
|
|
12
|
+
"pi-package",
|
|
13
|
+
"pi",
|
|
14
|
+
"subagent"
|
|
15
|
+
],
|
|
16
|
+
"files": [
|
|
17
|
+
"src",
|
|
18
|
+
"skills",
|
|
19
|
+
"README.md",
|
|
20
|
+
"LICENSE",
|
|
21
|
+
"!src/*.test.ts"
|
|
22
|
+
],
|
|
23
|
+
"peerDependencies": {
|
|
24
|
+
"@earendil-works/pi-ai": "*",
|
|
25
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
26
|
+
"@earendil-works/pi-tui": "*",
|
|
27
|
+
"typebox": "*"
|
|
28
|
+
},
|
|
29
|
+
"pi": {
|
|
30
|
+
"extensions": [
|
|
31
|
+
"./src/index.ts"
|
|
32
|
+
],
|
|
33
|
+
"skills": [
|
|
34
|
+
"./skills"
|
|
35
|
+
]
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@types/node": "^22.0.0",
|
|
39
|
+
"tsx": "^4.19.0",
|
|
40
|
+
"typescript": "^7.0.2"
|
|
41
|
+
},
|
|
42
|
+
"scripts": {
|
|
43
|
+
"build": "tsc --noEmit",
|
|
44
|
+
"typecheck": "tsc --noEmit"
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: subagent
|
|
3
|
+
description: Delegate independent or high-output work to transient subagents via the mini_subagent tool. Use when a task can be parallelized or would flood the main context with raw output (repo-wide scans, large files, multi-part research); subagents report back only their findings.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Using Subagents
|
|
7
|
+
|
|
8
|
+
The `mini_subagent` tool runs a transient headless `pi` process with its own
|
|
9
|
+
isolated context window and returns only its findings. Use it to protect your
|
|
10
|
+
main context and to parallelize independent work.
|
|
11
|
+
|
|
12
|
+
## When to delegate
|
|
13
|
+
|
|
14
|
+
- The task produces a lot of output you only need a summary of (large logs,
|
|
15
|
+
repo-wide greps, multi-file analysis).
|
|
16
|
+
- Several independent questions that can run in parallel — one subagent per
|
|
17
|
+
module, provider, route, or issue.
|
|
18
|
+
- Reading or analyzing big files whose raw bytes should not enter the main
|
|
19
|
+
context.
|
|
20
|
+
|
|
21
|
+
## When NOT to delegate
|
|
22
|
+
|
|
23
|
+
- A one-line answer you can get from a single `read`/`grep` yourself.
|
|
24
|
+
- Work that depends on the main session's in-flight edits or reasoning — a
|
|
25
|
+
subagent starts fresh with no session memory.
|
|
26
|
+
- Edits you must review carefully yourself: keep the subagent read-only and
|
|
27
|
+
make the changes in the main context.
|
|
28
|
+
|
|
29
|
+
## Writing a good task
|
|
30
|
+
|
|
31
|
+
- Make it self-contained: state the goal, the files or directories, and the
|
|
32
|
+
exact shape of the answer you want back.
|
|
33
|
+
- Use absolute or unambiguous paths — the subagent shares only a `cwd`, not
|
|
34
|
+
your current reasoning.
|
|
35
|
+
- Ask for a summary, not a raw dump. That is the whole point.
|
|
36
|
+
|
|
37
|
+
## Read-only by default
|
|
38
|
+
|
|
39
|
+
Subagents can only `read`/`grep`/`find`/`ls` unless you set `allowWrite: true`.
|
|
40
|
+
Prefer read-only: have the subagent report findings, then edit in the main
|
|
41
|
+
context yourself.
|
|
42
|
+
|
|
43
|
+
## Parallel mode
|
|
44
|
+
|
|
45
|
+
Use `tasks` (array, max 8) when the work is independent. Run sequentially when
|
|
46
|
+
tasks share state or depend on each other's results.
|
|
47
|
+
|
|
48
|
+
## NEEDS_INPUT loop
|
|
49
|
+
|
|
50
|
+
If a subagent returns `needs_input`, it cannot proceed. Answer its questions
|
|
51
|
+
(ask the user if you don't know), then re-call with the same `task` and your
|
|
52
|
+
answers in `answers`. Never guess.
|
package/src/command.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
3
|
+
|
|
4
|
+
import { REPORT_ENTRY } from "./constants";
|
|
5
|
+
import type { SubagentStore } from "./state";
|
|
6
|
+
import type { SubagentRecord } from "./types";
|
|
7
|
+
import { getStyledSubagent } from "./utils";
|
|
8
|
+
|
|
9
|
+
function renderReport(records: SubagentRecord[], theme: Theme): string {
|
|
10
|
+
if (records.length === 0) return ` ${theme.fg("dim", "No subagents.")}`;
|
|
11
|
+
|
|
12
|
+
const lines: string[] = [];
|
|
13
|
+
const section = (label: string, items: SubagentRecord[]): void => {
|
|
14
|
+
if (items.length === 0) return;
|
|
15
|
+
|
|
16
|
+
lines.push(` ${theme.fg("muted", `${label} (${items.length})`)}`);
|
|
17
|
+
|
|
18
|
+
for (const r of items) {
|
|
19
|
+
lines.push(getStyledSubagent(r, theme));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
lines.push("");
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
section(
|
|
26
|
+
"Running",
|
|
27
|
+
records.filter((r) => r.status === "running"),
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
section(
|
|
31
|
+
"Needs input",
|
|
32
|
+
records.filter((r) => r.status === "needs_input"),
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
section(
|
|
36
|
+
"Completed",
|
|
37
|
+
records.filter((r) => r.status === "completed"),
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
section(
|
|
41
|
+
"Failed",
|
|
42
|
+
records.filter((r) => r.status === "failed"),
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
return lines.join("\n");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function registerSubagentsCommand(
|
|
49
|
+
pi: ExtensionAPI,
|
|
50
|
+
store: SubagentStore,
|
|
51
|
+
): void {
|
|
52
|
+
pi.registerCommand("subagents", {
|
|
53
|
+
description: "Show all subagents grouped by status",
|
|
54
|
+
handler: async (_args, ctx) => {
|
|
55
|
+
if (!ctx.hasUI) {
|
|
56
|
+
ctx.ui.notify("/subagents requires interactive mode", "error");
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
pi.appendEntry(REPORT_ENTRY, {
|
|
61
|
+
records: [...store.getState(ctx).records],
|
|
62
|
+
});
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
pi.registerEntryRenderer<{ records: SubagentRecord[] }>(
|
|
67
|
+
REPORT_ENTRY,
|
|
68
|
+
(entry, _options, theme) =>
|
|
69
|
+
new Text(renderReport(entry.data?.records ?? [], theme), 0, 0),
|
|
70
|
+
);
|
|
71
|
+
}
|
package/src/constants.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { KeyId } from "@earendil-works/pi-tui";
|
|
2
|
+
import type { ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
|
|
4
|
+
import type { SubagentStatus } from "./types";
|
|
5
|
+
|
|
6
|
+
export type SubagentStatusUi = {
|
|
7
|
+
icon: string;
|
|
8
|
+
fg: ThemeColor;
|
|
9
|
+
bold?: boolean;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export const STATUSES = [
|
|
13
|
+
"running",
|
|
14
|
+
"completed",
|
|
15
|
+
"failed",
|
|
16
|
+
"needs_input",
|
|
17
|
+
] as const;
|
|
18
|
+
|
|
19
|
+
export const STATUS_STYLES: Record<SubagentStatus, SubagentStatusUi> = {
|
|
20
|
+
running: { icon: "⏳ ", fg: "accent", bold: true },
|
|
21
|
+
completed: { icon: "✓ ", fg: "success" },
|
|
22
|
+
failed: { icon: "✗ ", fg: "error", bold: true },
|
|
23
|
+
needs_input: { icon: "? ", fg: "warning", bold: true },
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** Tool allowlist for a read-only subagent. */
|
|
27
|
+
export const READ_ONLY_TOOLS = ["read", "grep", "find", "ls"];
|
|
28
|
+
|
|
29
|
+
/** Tool allowlist for a write-capable subagent (hash-anchored ops first). */
|
|
30
|
+
export const WRITE_TOOLS = [
|
|
31
|
+
"read",
|
|
32
|
+
"grep",
|
|
33
|
+
"find",
|
|
34
|
+
"ls",
|
|
35
|
+
"replace",
|
|
36
|
+
"insert",
|
|
37
|
+
"edit",
|
|
38
|
+
"write",
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
/** Marker the subagent emits when it cannot proceed without outside input. */
|
|
42
|
+
export const NEEDS_INPUT_MARKER = "NEEDS_INPUT:";
|
|
43
|
+
|
|
44
|
+
/** Max parallel tasks per call. */
|
|
45
|
+
export const MAX_PARALLEL_TASKS = 8;
|
|
46
|
+
/** Max concurrent subagent processes. */
|
|
47
|
+
export const MAX_CONCURRENCY = 4;
|
|
48
|
+
/** Per-task output byte cap for parallel results. */
|
|
49
|
+
export const PER_TASK_OUTPUT_CAP = 50 * 1024;
|
|
50
|
+
/** Max chars of final output kept in the stored record (TUI summary). */
|
|
51
|
+
export const MAX_STORED_OUTPUT = 2000;
|
|
52
|
+
|
|
53
|
+
/** Max task rows rendered in the expanded TUI widget. */
|
|
54
|
+
export const MAX_PANEL_ROWS = 8;
|
|
55
|
+
|
|
56
|
+
/** Widget key for the panel above the editor. */
|
|
57
|
+
export const WIDGET_KEY = "subagents";
|
|
58
|
+
|
|
59
|
+
/** Custom entry type carrying the durable state snapshot. */
|
|
60
|
+
export const STATE_ENTRY = "subagents.state";
|
|
61
|
+
|
|
62
|
+
/** Custom entry type rendered by the /subagents command. */
|
|
63
|
+
export const REPORT_ENTRY = "subagents.report";
|
|
64
|
+
|
|
65
|
+
/** Chord that toggles the panel. */
|
|
66
|
+
export const PANEL_TOGGLE_CHORD: KeyId = "alt+s";
|
|
67
|
+
|
|
68
|
+
export const PANEL_STATE_ICON = {
|
|
69
|
+
collapsed: "",
|
|
70
|
+
expanded: "",
|
|
71
|
+
};
|
package/src/core.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import {
|
|
2
|
+
MAX_STORED_OUTPUT,
|
|
3
|
+
NEEDS_INPUT_MARKER,
|
|
4
|
+
READ_ONLY_TOOLS,
|
|
5
|
+
WRITE_TOOLS,
|
|
6
|
+
} from "./constants";
|
|
7
|
+
import type { SubagentStatus } from "./types";
|
|
8
|
+
|
|
9
|
+
const ALWAYS = "You are a transient subagent. Complete the task, then stop";
|
|
10
|
+
const READ_ONLY = `You may only READ and EXPLORE. Do not modify files or run mutating commands.`;
|
|
11
|
+
const WRITE_OK = `You may edit files ONLY if strictly necessary, preferring hash-anchored operations (replace/insert) over rewriting.`;
|
|
12
|
+
|
|
13
|
+
const QUESTIONS_SUFFIX = `If the task cannot be completed without information you cannot obtain yourself, end your final message with the exact block below and stop — do not guess:
|
|
14
|
+
|
|
15
|
+
${NEEDS_INPUT_MARKER}
|
|
16
|
+
- <question>
|
|
17
|
+
|
|
18
|
+
Report your findings clearly and concisely.`;
|
|
19
|
+
|
|
20
|
+
/** Dynamic minimal prompt: read-only always; write permission added only when allowed. */
|
|
21
|
+
export function buildSystemPrompt(allowWrite: boolean): string {
|
|
22
|
+
let prompt = ALWAYS;
|
|
23
|
+
|
|
24
|
+
if (!allowWrite) prompt += `\n\n${READ_ONLY}`;
|
|
25
|
+
else prompt += `\n\n${WRITE_OK}`;
|
|
26
|
+
|
|
27
|
+
return `${prompt}\n\n${QUESTIONS_SUFFIX}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Tool allowlist for the spawned child process. */
|
|
31
|
+
export function buildAllowlist(allowWrite: boolean): string[] {
|
|
32
|
+
return allowWrite ? WRITE_TOOLS : READ_ONLY_TOOLS;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Extract the questions the subagent needs answered, if it emitted the
|
|
37
|
+
* NEEDS_INPUT block. Returns `[]` when the marker is present but no bullet
|
|
38
|
+
* lines follow, and `undefined` when the marker is absent.
|
|
39
|
+
*/
|
|
40
|
+
export function parseNeedsInput(text: string): string[] | undefined {
|
|
41
|
+
const lines = text.split("\n");
|
|
42
|
+
const idx = lines.findIndex((l) => l.trim() === NEEDS_INPUT_MARKER);
|
|
43
|
+
if (idx === -1) return undefined;
|
|
44
|
+
|
|
45
|
+
const questions: string[] = [];
|
|
46
|
+
|
|
47
|
+
for (const line of lines.slice(idx + 1)) {
|
|
48
|
+
const trimmed = line.trim();
|
|
49
|
+
|
|
50
|
+
if (trimmed.startsWith("- ")) {
|
|
51
|
+
const q = trimmed.slice(2).trim();
|
|
52
|
+
if (q) questions.push(q);
|
|
53
|
+
} else if (trimmed === "") {
|
|
54
|
+
continue;
|
|
55
|
+
} else {
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return questions;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Classify a finished subagent run into a record status. */
|
|
64
|
+
export function classifyResult(opts: {
|
|
65
|
+
exitCode: number;
|
|
66
|
+
stopReason?: string;
|
|
67
|
+
needsInput: boolean;
|
|
68
|
+
}): SubagentStatus {
|
|
69
|
+
if (opts.needsInput) return "needs_input";
|
|
70
|
+
|
|
71
|
+
if (
|
|
72
|
+
opts.exitCode !== 0 ||
|
|
73
|
+
opts.stopReason === "error" ||
|
|
74
|
+
opts.stopReason === "aborted"
|
|
75
|
+
) {
|
|
76
|
+
return "failed";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return "completed";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Truncate a string to a char budget, appending an ellipsis marker. */
|
|
83
|
+
export function truncateChars(text: string, max: number): string {
|
|
84
|
+
if (text.length <= max) return text;
|
|
85
|
+
|
|
86
|
+
return `${text.slice(0, max)}…`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Truncate to a UTF-8 byte budget (for parallel per-task caps). */
|
|
90
|
+
export function truncateBytes(text: string, maxBytes: number): string {
|
|
91
|
+
if (Buffer.byteLength(text, "utf8") <= maxBytes) return text;
|
|
92
|
+
|
|
93
|
+
let out = text.slice(0, maxBytes);
|
|
94
|
+
while (Buffer.byteLength(out, "utf8") > maxBytes) {
|
|
95
|
+
out = out.slice(0, -1);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return `${out}\n\n[Output truncated: ${Buffer.byteLength(text, "utf8") - Buffer.byteLength(out, "utf8")} bytes omitted.]`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Final output kept in the stored record (TUI summary only). */
|
|
102
|
+
export function summarizeOutput(text: string): string {
|
|
103
|
+
return truncateChars(text, MAX_STORED_OUTPUT);
|
|
104
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
import { registerSubagentsCommand } from "./command";
|
|
4
|
+
import { STATE_ENTRY, WIDGET_KEY } from "./constants";
|
|
5
|
+
import { killAllRunning } from "./spawn";
|
|
6
|
+
import { SubagentStore } from "./state";
|
|
7
|
+
import { registerMiniSubagentTool } from "./tool";
|
|
8
|
+
import { registerSubagentWidget, refreshWidget } from "./widget";
|
|
9
|
+
|
|
10
|
+
export default function (pi: ExtensionAPI): void {
|
|
11
|
+
// Child subagent processes inherit PI_SUBAGENT=1; they must not be able to
|
|
12
|
+
// spawn sub-subagents, so the tool/widget/command never register there.
|
|
13
|
+
if (process.env.PI_SUBAGENT) return;
|
|
14
|
+
|
|
15
|
+
const store = new SubagentStore(
|
|
16
|
+
(snapshot) => pi.appendEntry(STATE_ENTRY, snapshot),
|
|
17
|
+
(ctx) => refreshWidget(ctx, store),
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
registerMiniSubagentTool(pi, store);
|
|
21
|
+
registerSubagentsCommand(pi, store);
|
|
22
|
+
registerSubagentWidget(pi, store);
|
|
23
|
+
|
|
24
|
+
pi.on("session_start", (_event, ctx) => store.replay(ctx));
|
|
25
|
+
pi.on("session_tree", (_event, ctx) => store.replay(ctx));
|
|
26
|
+
pi.on("session_before_compact", (_event, ctx) => store.persistSnapshot(ctx));
|
|
27
|
+
pi.on("session_shutdown", (_event, ctx) => {
|
|
28
|
+
killAllRunning();
|
|
29
|
+
if (ctx.hasUI) ctx.ui.setWidget(WIDGET_KEY, undefined);
|
|
30
|
+
});
|
|
31
|
+
}
|
package/src/spawn.ts
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
|
|
6
|
+
import { buildAllowlist, buildSystemPrompt } from "./core";
|
|
7
|
+
|
|
8
|
+
export interface SubagentUsage {
|
|
9
|
+
input: number;
|
|
10
|
+
output: number;
|
|
11
|
+
cacheRead: number;
|
|
12
|
+
cacheWrite: number;
|
|
13
|
+
cost: number;
|
|
14
|
+
contextTokens: number;
|
|
15
|
+
turns: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SubagentRunResult {
|
|
19
|
+
/** Final assistant text from the subagent. */
|
|
20
|
+
output: string;
|
|
21
|
+
usage: SubagentUsage;
|
|
22
|
+
model?: string;
|
|
23
|
+
stopReason?: string;
|
|
24
|
+
errorMessage?: string;
|
|
25
|
+
exitCode: number;
|
|
26
|
+
stderr: string;
|
|
27
|
+
aborted: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const activeProcesses = new Set<ChildProcess>();
|
|
31
|
+
|
|
32
|
+
/** Signal every still-running subagent process (session shutdown cleanup). */
|
|
33
|
+
export function killAllRunning(): void {
|
|
34
|
+
for (const proc of activeProcesses) {
|
|
35
|
+
try {
|
|
36
|
+
proc.kill("SIGTERM");
|
|
37
|
+
} catch {
|
|
38
|
+
/* already gone */
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Resolve the command that re-invokes pi (handles bun virtual scripts). */
|
|
44
|
+
function getPiInvocation(args: string[]): { command: string; args: string[] } {
|
|
45
|
+
const currentScript = process.argv[1];
|
|
46
|
+
const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/");
|
|
47
|
+
if (currentScript && !isBunVirtualScript && fs.existsSync(currentScript)) {
|
|
48
|
+
return { command: process.execPath, args: [currentScript, ...args] };
|
|
49
|
+
}
|
|
50
|
+
const execName = path.basename(process.execPath).toLowerCase();
|
|
51
|
+
const isGenericRuntime = /^(node|bun)(\.exe)?$/.test(execName);
|
|
52
|
+
if (!isGenericRuntime) return { command: process.execPath, args };
|
|
53
|
+
return { command: "pi", args };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function writePromptTempFile(prompt: string): Promise<{ dir: string; filePath: string }> {
|
|
57
|
+
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-mini-subagent-"));
|
|
58
|
+
const filePath = path.join(dir, "prompt.md");
|
|
59
|
+
await fs.promises.writeFile(filePath, prompt, { encoding: "utf-8", mode: 0o600 });
|
|
60
|
+
return { dir, filePath };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Extract the last text part of an assistant message. */
|
|
64
|
+
function extractText(msg: any): string {
|
|
65
|
+
const content = Array.isArray(msg?.content) ? msg.content : [];
|
|
66
|
+
for (let i = content.length - 1; i >= 0; i--) {
|
|
67
|
+
const part = content[i];
|
|
68
|
+
if (part?.type === "text" && typeof part.text === "string") return part.text;
|
|
69
|
+
}
|
|
70
|
+
return "";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface RunSubagentOptions {
|
|
74
|
+
cwd: string;
|
|
75
|
+
task: string;
|
|
76
|
+
allowWrite: boolean;
|
|
77
|
+
answers?: string;
|
|
78
|
+
signal?: AbortSignal;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Spawn a transient headless pi subprocess, stream its JSON events, and
|
|
83
|
+
* resolve with the final output once it exits.
|
|
84
|
+
*/
|
|
85
|
+
export async function runSubagent(opts: RunSubagentOptions): Promise<SubagentRunResult> {
|
|
86
|
+
const prompt = buildSystemPrompt(opts.allowWrite);
|
|
87
|
+
const { dir: tmpDir, filePath: tmpPath } = await writePromptTempFile(prompt);
|
|
88
|
+
|
|
89
|
+
const taskText = opts.answers
|
|
90
|
+
? `Task: ${opts.task}\n\nAnswers from the user:\n${opts.answers}`
|
|
91
|
+
: `Task: ${opts.task}`;
|
|
92
|
+
|
|
93
|
+
const args = [
|
|
94
|
+
"--mode", "json",
|
|
95
|
+
"-p",
|
|
96
|
+
"--no-session",
|
|
97
|
+
"--tools", buildAllowlist(opts.allowWrite).join(","),
|
|
98
|
+
"--append-system-prompt", tmpPath,
|
|
99
|
+
taskText,
|
|
100
|
+
];
|
|
101
|
+
|
|
102
|
+
const result: SubagentRunResult = {
|
|
103
|
+
output: "",
|
|
104
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
|
|
105
|
+
exitCode: 0,
|
|
106
|
+
stderr: "",
|
|
107
|
+
aborted: false,
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
const exitCode = await new Promise<number>((resolve) => {
|
|
112
|
+
const invocation = getPiInvocation(args);
|
|
113
|
+
const proc = spawn(invocation.command, invocation.args, {
|
|
114
|
+
cwd: opts.cwd,
|
|
115
|
+
shell: false,
|
|
116
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
117
|
+
env: { ...process.env, PI_SUBAGENT: "1" },
|
|
118
|
+
});
|
|
119
|
+
activeProcesses.add(proc);
|
|
120
|
+
|
|
121
|
+
let buffer = "";
|
|
122
|
+
const processLine = (line: string) => {
|
|
123
|
+
if (!line.trim()) return;
|
|
124
|
+
let event: any;
|
|
125
|
+
try {
|
|
126
|
+
event = JSON.parse(line);
|
|
127
|
+
} catch {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (event.type === "message_end" && event.message) {
|
|
132
|
+
const msg = event.message;
|
|
133
|
+
if (msg.role === "assistant") {
|
|
134
|
+
result.usage.turns++;
|
|
135
|
+
const usage = msg.usage;
|
|
136
|
+
if (usage) {
|
|
137
|
+
result.usage.input += usage.input || 0;
|
|
138
|
+
result.usage.output += usage.output || 0;
|
|
139
|
+
result.usage.cacheRead += usage.cacheRead || 0;
|
|
140
|
+
result.usage.cacheWrite += usage.cacheWrite || 0;
|
|
141
|
+
result.usage.cost += usage.cost?.total || 0;
|
|
142
|
+
result.usage.contextTokens = usage.totalTokens || 0;
|
|
143
|
+
}
|
|
144
|
+
if (!result.model && msg.model) result.model = msg.model;
|
|
145
|
+
if (msg.stopReason) result.stopReason = msg.stopReason;
|
|
146
|
+
if (msg.errorMessage) result.errorMessage = msg.errorMessage;
|
|
147
|
+
result.output = extractText(msg);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
proc.stdout.on("data", (data) => {
|
|
153
|
+
buffer += data.toString();
|
|
154
|
+
const lines = buffer.split("\n");
|
|
155
|
+
buffer = lines.pop() || "";
|
|
156
|
+
for (const line of lines) processLine(line);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
proc.stderr.on("data", (data) => {
|
|
160
|
+
result.stderr += data.toString();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
proc.on("close", (code) => {
|
|
164
|
+
activeProcesses.delete(proc);
|
|
165
|
+
if (buffer.trim()) processLine(buffer);
|
|
166
|
+
resolve(code ?? 0);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
proc.on("error", () => {
|
|
170
|
+
activeProcesses.delete(proc);
|
|
171
|
+
resolve(1);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
if (opts.signal) {
|
|
175
|
+
const kill = () => {
|
|
176
|
+
result.aborted = true;
|
|
177
|
+
proc.kill("SIGTERM");
|
|
178
|
+
setTimeout(() => {
|
|
179
|
+
try {
|
|
180
|
+
proc.kill("SIGKILL");
|
|
181
|
+
} catch {
|
|
182
|
+
/* already dead */
|
|
183
|
+
}
|
|
184
|
+
}, 5000);
|
|
185
|
+
};
|
|
186
|
+
if (opts.signal.aborted) kill();
|
|
187
|
+
else opts.signal.addEventListener("abort", kill, { once: true });
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
result.exitCode = exitCode;
|
|
192
|
+
if (result.aborted) result.stopReason = "aborted";
|
|
193
|
+
return result;
|
|
194
|
+
} finally {
|
|
195
|
+
try {
|
|
196
|
+
fs.unlinkSync(tmpPath);
|
|
197
|
+
fs.rmdirSync(tmpDir);
|
|
198
|
+
} catch {
|
|
199
|
+
/* ignore */
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
package/src/state.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
import { STATE_ENTRY } from "./constants";
|
|
4
|
+
import type { SubagentRecord, SubagentState } from "./types";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Session-scoped subagent state.
|
|
8
|
+
*
|
|
9
|
+
* Keyed by session id so forked/parallel sessions never overwrite each other,
|
|
10
|
+
* and replayed from the session branch on session events so it survives
|
|
11
|
+
* /reload and compaction without the extension writing its own files.
|
|
12
|
+
* Running records are dropped on replay — their processes do not survive a reload.
|
|
13
|
+
*/
|
|
14
|
+
export class SubagentStore {
|
|
15
|
+
private readonly stateBySession = new Map<string, SubagentState>();
|
|
16
|
+
|
|
17
|
+
constructor(
|
|
18
|
+
private readonly persist: (snapshot: SubagentState) => void,
|
|
19
|
+
private readonly emitChange: (ctx: ExtensionContext) => void,
|
|
20
|
+
) {}
|
|
21
|
+
|
|
22
|
+
private sid(ctx: ExtensionContext): string {
|
|
23
|
+
return ctx.sessionManager.getSessionId();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
getState(ctx: ExtensionContext): SubagentState {
|
|
27
|
+
const sid = this.sid(ctx);
|
|
28
|
+
let s = this.stateBySession.get(sid);
|
|
29
|
+
if (!s) this.stateBySession.set(sid, (s = { records: [], nextId: 1 }));
|
|
30
|
+
return s;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Commit a snapshot: update memory, persist, refresh the panel. */
|
|
34
|
+
commit(ctx: ExtensionContext, next: SubagentState): void {
|
|
35
|
+
this.stateBySession.set(this.sid(ctx), next);
|
|
36
|
+
this.persist(next);
|
|
37
|
+
this.emitChange(ctx);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Create a `running` record and commit it. */
|
|
41
|
+
start(ctx: ExtensionContext, task: string, allowWrite: boolean, cwd?: string): SubagentRecord {
|
|
42
|
+
const s = this.getState(ctx);
|
|
43
|
+
const record: SubagentRecord = {
|
|
44
|
+
id: s.nextId,
|
|
45
|
+
task,
|
|
46
|
+
status: "running",
|
|
47
|
+
allowWrite,
|
|
48
|
+
cwd,
|
|
49
|
+
startedAt: Date.now(),
|
|
50
|
+
};
|
|
51
|
+
this.commit(ctx, { records: [...s.records, record], nextId: s.nextId + 1 });
|
|
52
|
+
return record;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Patch an existing record in place and commit. */
|
|
56
|
+
finish(ctx: ExtensionContext, id: number, patch: Partial<SubagentRecord>): void {
|
|
57
|
+
const s = this.getState(ctx);
|
|
58
|
+
this.commit(ctx, {
|
|
59
|
+
records: s.records.map((r) => (r.id === id ? { ...r, ...patch } : r)),
|
|
60
|
+
nextId: s.nextId,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Rebuild the current session's state from the branch (no disk writes). */
|
|
65
|
+
replay(ctx: ExtensionContext): void {
|
|
66
|
+
const s = this.getState(ctx);
|
|
67
|
+
s.records = [];
|
|
68
|
+
s.nextId = 1;
|
|
69
|
+
|
|
70
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
71
|
+
if (entry.type === "custom" && entry.customType === STATE_ENTRY) {
|
|
72
|
+
const d = entry.data as SubagentState | undefined;
|
|
73
|
+
if (d) {
|
|
74
|
+
s.records = d.records.filter((r) => r.status !== "running");
|
|
75
|
+
s.nextId = d.nextId;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
this.emitChange(ctx);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Persist the latest snapshot right before compaction so it lands after the cut point. */
|
|
84
|
+
persistSnapshot(ctx: ExtensionContext): void {
|
|
85
|
+
const s = this.getState(ctx);
|
|
86
|
+
if (s.records.length > 0) this.persist(s);
|
|
87
|
+
}
|
|
88
|
+
}
|
package/src/tool.ts
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import { Type } from "typebox";
|
|
2
|
+
import type {
|
|
3
|
+
AgentToolResult,
|
|
4
|
+
ExtensionAPI,
|
|
5
|
+
} from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
MAX_CONCURRENCY,
|
|
10
|
+
MAX_PARALLEL_TASKS,
|
|
11
|
+
PER_TASK_OUTPUT_CAP,
|
|
12
|
+
} from "./constants";
|
|
13
|
+
import {
|
|
14
|
+
classifyResult,
|
|
15
|
+
parseNeedsInput,
|
|
16
|
+
summarizeOutput,
|
|
17
|
+
truncateBytes,
|
|
18
|
+
} from "./core";
|
|
19
|
+
import { runSubagent } from "./spawn";
|
|
20
|
+
import type { SubagentStore } from "./state";
|
|
21
|
+
import type { SubagentDetails, SubagentRecord } from "./types";
|
|
22
|
+
|
|
23
|
+
const TaskItem = Type.Object({
|
|
24
|
+
task: Type.String({ description: "Task to delegate to a subagent" }),
|
|
25
|
+
allowWrite: Type.Optional(
|
|
26
|
+
Type.Boolean({
|
|
27
|
+
description: "Allow the subagent to edit files. Default: false.",
|
|
28
|
+
}),
|
|
29
|
+
),
|
|
30
|
+
answers: Type.Optional(
|
|
31
|
+
Type.String({
|
|
32
|
+
description: "Answers to a prior NEEDS_INPUT, for re-spawn of this task",
|
|
33
|
+
}),
|
|
34
|
+
),
|
|
35
|
+
cwd: Type.Optional(
|
|
36
|
+
Type.String({ description: "Working directory for this subagent" }),
|
|
37
|
+
),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const MiniSubagentParams = Type.Object({
|
|
41
|
+
task: Type.Optional(
|
|
42
|
+
Type.String({ description: "Task to delegate (single mode)" }),
|
|
43
|
+
),
|
|
44
|
+
tasks: Type.Optional(
|
|
45
|
+
Type.Array(TaskItem, {
|
|
46
|
+
description: "Tasks to delegate in parallel (max 8)",
|
|
47
|
+
}),
|
|
48
|
+
),
|
|
49
|
+
allowWrite: Type.Optional(
|
|
50
|
+
Type.Boolean({
|
|
51
|
+
description: "Allow write for single mode. Default: false.",
|
|
52
|
+
}),
|
|
53
|
+
),
|
|
54
|
+
answers: Type.Optional(
|
|
55
|
+
Type.String({
|
|
56
|
+
description: "Answers to a prior NEEDS_INPUT (single mode re-spawn)",
|
|
57
|
+
}),
|
|
58
|
+
),
|
|
59
|
+
cwd: Type.Optional(
|
|
60
|
+
Type.String({ description: "Working directory (single mode)" }),
|
|
61
|
+
),
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
async function mapWithConcurrencyLimit<TIn, TOut>(
|
|
65
|
+
items: TIn[],
|
|
66
|
+
concurrency: number,
|
|
67
|
+
fn: (item: TIn, index: number) => Promise<TOut>,
|
|
68
|
+
): Promise<TOut[]> {
|
|
69
|
+
if (items.length === 0) return [];
|
|
70
|
+
|
|
71
|
+
const limit = Math.max(1, Math.min(concurrency, items.length));
|
|
72
|
+
const results = new Array<TOut>(items.length);
|
|
73
|
+
|
|
74
|
+
let next = 0;
|
|
75
|
+
const workers = new Array(limit).fill(null).map(async () => {
|
|
76
|
+
while (true) {
|
|
77
|
+
const i = next++;
|
|
78
|
+
if (i >= items.length) return;
|
|
79
|
+
results[i] = await fn(items[i], i);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
await Promise.all(workers);
|
|
84
|
+
return results;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function needsInputContent(r: SubagentRecord): string {
|
|
88
|
+
const qs = (r.questions ?? []).map((q) => `- ${q}`).join("\n");
|
|
89
|
+
|
|
90
|
+
return `The subagent needs input to complete this task.\n\nQuestions:\n${qs || "- (unparsed)"}\n\nAsk the user (or answer from context), then call mini_subagent again with the same \`task\` and your answers in \`answers\`.`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function singleContent(r: SubagentRecord): string {
|
|
94
|
+
if (r.status === "needs_input") return needsInputContent(r);
|
|
95
|
+
|
|
96
|
+
if (r.status === "failed") {
|
|
97
|
+
return `Subagent failed: ${r.error ?? r.output ?? "(no output)"}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return r.output ?? "(no output)";
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function registerMiniSubagentTool(
|
|
104
|
+
pi: ExtensionAPI,
|
|
105
|
+
store: SubagentStore,
|
|
106
|
+
): void {
|
|
107
|
+
pi.registerTool({
|
|
108
|
+
name: "mini_subagent",
|
|
109
|
+
label: "Mini Subagent",
|
|
110
|
+
parameters: MiniSubagentParams,
|
|
111
|
+
description:
|
|
112
|
+
"Delegate a task to a transient headless subagent (a separate pi process) and get its findings back. " +
|
|
113
|
+
"Modes: single (task) or parallel (tasks array, max 8). Subagents are read-only by default; set allowWrite to let one edit files. " +
|
|
114
|
+
"If a subagent reports it needs input (NEEDS_INPUT), answer the questions and call again with `answers`.",
|
|
115
|
+
promptSnippet:
|
|
116
|
+
"Delegate a task to a transient read-only subagent (single or parallel)",
|
|
117
|
+
promptGuidelines: [
|
|
118
|
+
"Subagents are read-only unless you set allowWrite: true.",
|
|
119
|
+
"If a result asks for input, answer the questions (ask the user if needed) and re-call with `answers` — do not guess.",
|
|
120
|
+
],
|
|
121
|
+
|
|
122
|
+
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
123
|
+
const hasTasks = (params.tasks?.length ?? 0) > 0;
|
|
124
|
+
const hasSingle = Boolean(params.task);
|
|
125
|
+
const modeCount = Number(hasTasks) + Number(hasSingle);
|
|
126
|
+
|
|
127
|
+
const errorResult = (text: string): AgentToolResult<SubagentDetails> => ({
|
|
128
|
+
content: [{ type: "text", text }],
|
|
129
|
+
details: { mode: hasTasks ? "parallel" : "single", records: [] },
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
if (modeCount !== 1) {
|
|
133
|
+
return errorResult(
|
|
134
|
+
"Provide exactly one of `task` (single) or `tasks` (parallel).",
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// --- single mode ---
|
|
139
|
+
if (hasSingle) {
|
|
140
|
+
const record = store.start(
|
|
141
|
+
ctx,
|
|
142
|
+
params.task!,
|
|
143
|
+
params.allowWrite ?? false,
|
|
144
|
+
params.cwd,
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
const run = await runSubagent({
|
|
148
|
+
cwd: params.cwd ?? ctx.cwd,
|
|
149
|
+
task: params.task!,
|
|
150
|
+
allowWrite: params.allowWrite ?? false,
|
|
151
|
+
answers: params.answers,
|
|
152
|
+
signal,
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
const needsInput = parseNeedsInput(run.output);
|
|
156
|
+
|
|
157
|
+
const status = classifyResult({
|
|
158
|
+
exitCode: run.exitCode,
|
|
159
|
+
stopReason: run.stopReason,
|
|
160
|
+
needsInput: needsInput !== undefined,
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
const patch: Partial<SubagentRecord> = {
|
|
164
|
+
status,
|
|
165
|
+
output: summarizeOutput(run.output),
|
|
166
|
+
tokens: run.usage.contextTokens,
|
|
167
|
+
questions: needsInput,
|
|
168
|
+
error:
|
|
169
|
+
status === "failed"
|
|
170
|
+
? run.errorMessage || run.stderr.slice(0, 500) || undefined
|
|
171
|
+
: undefined,
|
|
172
|
+
finishedAt: Date.now(),
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
store.finish(ctx, record.id, patch);
|
|
176
|
+
const final = { ...record, ...patch };
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
content: [{ type: "text", text: singleContent(final) }],
|
|
180
|
+
details: {
|
|
181
|
+
mode: "single",
|
|
182
|
+
records: [final],
|
|
183
|
+
} satisfies SubagentDetails,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// --- parallel mode ---
|
|
188
|
+
const tasks = params.tasks!;
|
|
189
|
+
if (tasks.length > MAX_PARALLEL_TASKS) {
|
|
190
|
+
return errorResult(
|
|
191
|
+
`Too many parallel tasks (${tasks.length}). Max is ${MAX_PARALLEL_TASKS}.`,
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const results: SubagentRecord[] = tasks.map((t) =>
|
|
196
|
+
store.start(ctx, t.task, t.allowWrite ?? false, t.cwd),
|
|
197
|
+
);
|
|
198
|
+
const makeDetails = (): SubagentDetails => ({
|
|
199
|
+
mode: "parallel",
|
|
200
|
+
records: [...results],
|
|
201
|
+
});
|
|
202
|
+
const emit = () => {
|
|
203
|
+
const running = results.filter((r) => r.status === "running").length;
|
|
204
|
+
const done = results.length - running;
|
|
205
|
+
onUpdate?.({
|
|
206
|
+
content: [
|
|
207
|
+
{
|
|
208
|
+
type: "text",
|
|
209
|
+
text: `Parallel: ${done}/${results.length} done, ${running} running…`,
|
|
210
|
+
},
|
|
211
|
+
],
|
|
212
|
+
details: makeDetails(),
|
|
213
|
+
});
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
await mapWithConcurrencyLimit(
|
|
217
|
+
tasks,
|
|
218
|
+
MAX_CONCURRENCY,
|
|
219
|
+
async (t, index) => {
|
|
220
|
+
const run = await runSubagent({
|
|
221
|
+
cwd: t.cwd ?? ctx.cwd,
|
|
222
|
+
task: t.task,
|
|
223
|
+
allowWrite: t.allowWrite ?? false,
|
|
224
|
+
answers: t.answers,
|
|
225
|
+
signal,
|
|
226
|
+
});
|
|
227
|
+
const needsInput = parseNeedsInput(run.output);
|
|
228
|
+
const status = classifyResult({
|
|
229
|
+
exitCode: run.exitCode,
|
|
230
|
+
stopReason: run.stopReason,
|
|
231
|
+
needsInput: needsInput !== undefined,
|
|
232
|
+
});
|
|
233
|
+
const patch: Partial<SubagentRecord> = {
|
|
234
|
+
status,
|
|
235
|
+
output: summarizeOutput(run.output),
|
|
236
|
+
tokens: run.usage.contextTokens,
|
|
237
|
+
questions: needsInput,
|
|
238
|
+
error:
|
|
239
|
+
status === "failed"
|
|
240
|
+
? run.errorMessage || run.stderr.slice(0, 500) || undefined
|
|
241
|
+
: undefined,
|
|
242
|
+
finishedAt: Date.now(),
|
|
243
|
+
};
|
|
244
|
+
results[index] = { ...results[index], ...patch };
|
|
245
|
+
store.finish(ctx, results[index].id, patch);
|
|
246
|
+
emit();
|
|
247
|
+
},
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
const successCount = results.filter(
|
|
251
|
+
(r) => r.status === "completed",
|
|
252
|
+
).length;
|
|
253
|
+
const sections = results.map((r) => {
|
|
254
|
+
let body: string;
|
|
255
|
+
if (r.status === "completed")
|
|
256
|
+
body = truncateBytes(r.output ?? "", PER_TASK_OUTPUT_CAP);
|
|
257
|
+
else if (r.status === "needs_input")
|
|
258
|
+
body = `Needs input:\n${(r.questions ?? []).map((q) => `- ${q}`).join("\n")}`;
|
|
259
|
+
else body = r.error ?? "(no output)";
|
|
260
|
+
return `### #${r.id} ${r.status}\n${body}`;
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
let content = `Parallel: ${successCount}/${results.length} succeeded\n\n${sections.join("\n\n---\n\n")}`;
|
|
264
|
+
const blocked = results.filter((r) => r.status === "needs_input");
|
|
265
|
+
if (blocked.length > 0) {
|
|
266
|
+
content += `\n\nSome subagents need input. Answer their questions, then call mini_subagent again with \`tasks\` for just those tasks (each with its own \`answers\`):\n`;
|
|
267
|
+
content += blocked
|
|
268
|
+
.map(
|
|
269
|
+
(r) =>
|
|
270
|
+
`- #${r.id}: ${(r.questions ?? []).join(" / ") || "(unparsed)"}`,
|
|
271
|
+
)
|
|
272
|
+
.join("\n");
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return {
|
|
276
|
+
content: [{ type: "text", text: content }],
|
|
277
|
+
details: makeDetails(),
|
|
278
|
+
};
|
|
279
|
+
},
|
|
280
|
+
|
|
281
|
+
renderCall(args, theme) {
|
|
282
|
+
if (args.tasks && args.tasks.length > 0) {
|
|
283
|
+
let text =
|
|
284
|
+
theme.fg("toolTitle", theme.bold("mini_subagent ")) +
|
|
285
|
+
theme.fg("accent", `parallel (${args.tasks.length} tasks)`);
|
|
286
|
+
for (const t of args.tasks.slice(0, 3)) {
|
|
287
|
+
const preview =
|
|
288
|
+
t.task.length > 40 ? `${t.task.slice(0, 40)}…` : t.task;
|
|
289
|
+
text += `\n ${theme.fg("dim", preview)}${t.allowWrite ? theme.fg("warning", " ✎") : ""}`;
|
|
290
|
+
}
|
|
291
|
+
if (args.tasks.length > 3)
|
|
292
|
+
text += `\n ${theme.fg("muted", `… +${args.tasks.length - 3} more`)}`;
|
|
293
|
+
return new Text(text, 0, 0);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const preview = args.task
|
|
297
|
+
? args.task.length > 60
|
|
298
|
+
? `${args.task.slice(0, 60)}…`
|
|
299
|
+
: args.task
|
|
300
|
+
: "...";
|
|
301
|
+
let text =
|
|
302
|
+
theme.fg("toolTitle", theme.bold("mini_subagent ")) +
|
|
303
|
+
theme.fg("dim", preview);
|
|
304
|
+
if (args.allowWrite) text += theme.fg("warning", " ✎");
|
|
305
|
+
return new Text(text, 0, 0);
|
|
306
|
+
},
|
|
307
|
+
|
|
308
|
+
renderResult(result, _options, theme) {
|
|
309
|
+
const details = result.details as SubagentDetails | undefined;
|
|
310
|
+
const text =
|
|
311
|
+
result.content[0]?.type === "text" ? result.content[0].text : "";
|
|
312
|
+
const statuses = details?.records.map((r) => r.status) ?? [];
|
|
313
|
+
const color = statuses.includes("failed")
|
|
314
|
+
? "error"
|
|
315
|
+
: statuses.includes("needs_input")
|
|
316
|
+
? "warning"
|
|
317
|
+
: "muted";
|
|
318
|
+
return new Text(theme.fg(color, text), 0, 0);
|
|
319
|
+
},
|
|
320
|
+
});
|
|
321
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export type SubagentStatus = "running" | "completed" | "failed" | "needs_input";
|
|
2
|
+
|
|
3
|
+
export interface SubagentRecord {
|
|
4
|
+
id: number;
|
|
5
|
+
task: string;
|
|
6
|
+
cwd?: string;
|
|
7
|
+
startedAt: number;
|
|
8
|
+
allowWrite: boolean;
|
|
9
|
+
finishedAt?: number;
|
|
10
|
+
status: SubagentStatus;
|
|
11
|
+
|
|
12
|
+
/** Final output summary (truncated for storage). */
|
|
13
|
+
output?: string;
|
|
14
|
+
tokens?: number;
|
|
15
|
+
error?: string;
|
|
16
|
+
questions?: string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface SubagentState {
|
|
20
|
+
records: SubagentRecord[];
|
|
21
|
+
nextId: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Shape stored in the tool result `details` for rendering. */
|
|
25
|
+
export interface SubagentDetails {
|
|
26
|
+
mode: "single" | "parallel";
|
|
27
|
+
records: SubagentRecord[];
|
|
28
|
+
}
|
package/src/utils.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
|
|
4
|
+
import { MAX_PANEL_ROWS, PANEL_STATE_ICON, STATUS_STYLES } from "./constants";
|
|
5
|
+
import type { SubagentRecord } from "./types";
|
|
6
|
+
|
|
7
|
+
function formatTokens(n: number): string {
|
|
8
|
+
if (n < 1000) return `${n} tokens`;
|
|
9
|
+
if (n < 10000) return `${(n / 1000).toFixed(1)}k tokens`;
|
|
10
|
+
|
|
11
|
+
return `${Math.round(n / 1000)}k tokens`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function getStyledSubagent(r: SubagentRecord, th: Theme): string {
|
|
15
|
+
const style = STATUS_STYLES[r.status] ?? STATUS_STYLES.completed;
|
|
16
|
+
let line = `${th.fg(style.fg, style.icon)} ${th.fg("accent", `#${r.id}`)} ${th.fg(style.fg, r.task)}`;
|
|
17
|
+
|
|
18
|
+
if (r.status === "completed" && r.tokens) {
|
|
19
|
+
line += th.fg("dim", ` · ${formatTokens(r.tokens)}`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (r.status === "failed") line += th.fg("dim", " · failed");
|
|
23
|
+
if (r.status === "needs_input") line += th.fg("dim", " · needs input");
|
|
24
|
+
if (r.allowWrite) line += th.fg("warning", " ✎");
|
|
25
|
+
|
|
26
|
+
return line;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function getStyledSubagentHeader(
|
|
30
|
+
records: SubagentRecord[],
|
|
31
|
+
th: Theme,
|
|
32
|
+
isCollapsed?: boolean,
|
|
33
|
+
): string {
|
|
34
|
+
const running = records.filter((r) => r.status === "running").length;
|
|
35
|
+
|
|
36
|
+
const done = records.length - running;
|
|
37
|
+
const collapseState = isCollapsed ? "collapsed" : "expanded";
|
|
38
|
+
|
|
39
|
+
return th.fg(
|
|
40
|
+
"accent",
|
|
41
|
+
`${PANEL_STATE_ICON[collapseState]} ⏳ Subagents — ${running} running / ${done} done`,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function getStyledSubagentList(
|
|
46
|
+
records: SubagentRecord[],
|
|
47
|
+
th: Theme,
|
|
48
|
+
width: number,
|
|
49
|
+
isCollapsed?: boolean,
|
|
50
|
+
): string[] {
|
|
51
|
+
const indent = (str: string, level = 1) => " ".repeat(Math.abs(level)) + str;
|
|
52
|
+
|
|
53
|
+
const lines: string[] = [
|
|
54
|
+
truncateToWidth(
|
|
55
|
+
indent(getStyledSubagentHeader(records, th, isCollapsed)),
|
|
56
|
+
width,
|
|
57
|
+
),
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
if (!isCollapsed) {
|
|
61
|
+
lines.push("");
|
|
62
|
+
|
|
63
|
+
if (records.length === 0) {
|
|
64
|
+
lines.push(
|
|
65
|
+
truncateToWidth(
|
|
66
|
+
indent(
|
|
67
|
+
th.fg("dim", "No subagents yet. Ask the agent to delegate a task!"),
|
|
68
|
+
2,
|
|
69
|
+
),
|
|
70
|
+
width,
|
|
71
|
+
),
|
|
72
|
+
);
|
|
73
|
+
} else {
|
|
74
|
+
const running = records.filter((r) => r.status === "running");
|
|
75
|
+
const finished = records.filter((r) => r.status !== "running");
|
|
76
|
+
const visible = [...running, ...finished].slice(0, MAX_PANEL_ROWS);
|
|
77
|
+
|
|
78
|
+
for (const r of visible) {
|
|
79
|
+
lines.push(truncateToWidth(indent(getStyledSubagent(r, th), 2), width));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (records.length > visible.length) {
|
|
83
|
+
lines.push(
|
|
84
|
+
truncateToWidth(
|
|
85
|
+
indent(
|
|
86
|
+
th.fg("dim", `… +${records.length - visible.length} more`),
|
|
87
|
+
3,
|
|
88
|
+
),
|
|
89
|
+
width,
|
|
90
|
+
),
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
lines.push("");
|
|
97
|
+
return lines;
|
|
98
|
+
}
|
package/src/widget.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
import { PANEL_TOGGLE_CHORD, WIDGET_KEY } from "./constants";
|
|
4
|
+
import type { SubagentStore } from "./state";
|
|
5
|
+
import type { SubagentState } from "./types";
|
|
6
|
+
import { getStyledSubagentList } from "./utils";
|
|
7
|
+
|
|
8
|
+
let collapsed = true;
|
|
9
|
+
|
|
10
|
+
class SubagentWidget {
|
|
11
|
+
private cachedWidth: number | undefined;
|
|
12
|
+
private cachedLines: string[] | undefined;
|
|
13
|
+
|
|
14
|
+
constructor(
|
|
15
|
+
private readonly theme: Theme,
|
|
16
|
+
private readonly snapshot: () => SubagentState,
|
|
17
|
+
private readonly isCollapsed: () => boolean,
|
|
18
|
+
) {}
|
|
19
|
+
|
|
20
|
+
render(width: number): string[] {
|
|
21
|
+
if (this.snapshot().records.length === 0) return [];
|
|
22
|
+
|
|
23
|
+
if (this.cachedLines !== undefined && this.cachedWidth === width) {
|
|
24
|
+
return this.cachedLines;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const lines = getStyledSubagentList(
|
|
28
|
+
this.snapshot().records,
|
|
29
|
+
this.theme,
|
|
30
|
+
width,
|
|
31
|
+
this.isCollapsed(),
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
this.cachedWidth = width;
|
|
35
|
+
this.cachedLines = lines;
|
|
36
|
+
return lines;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
invalidate(): void {
|
|
40
|
+
this.cachedWidth = undefined;
|
|
41
|
+
this.cachedLines = undefined;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function refreshWidget(ctx: ExtensionContext, store: SubagentStore): void {
|
|
46
|
+
if (!ctx.hasUI) return;
|
|
47
|
+
|
|
48
|
+
ctx.ui.setWidget(
|
|
49
|
+
WIDGET_KEY,
|
|
50
|
+
(_tui, theme) =>
|
|
51
|
+
new SubagentWidget(
|
|
52
|
+
theme,
|
|
53
|
+
() => store.getState(ctx),
|
|
54
|
+
() => collapsed,
|
|
55
|
+
),
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function registerSubagentWidget(pi: ExtensionAPI, store: SubagentStore): void {
|
|
60
|
+
pi.registerShortcut(PANEL_TOGGLE_CHORD, {
|
|
61
|
+
description: "Toggle subagents panel",
|
|
62
|
+
handler: (ctx) => {
|
|
63
|
+
collapsed = !collapsed;
|
|
64
|
+
refreshWidget(ctx, store);
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
}
|