@henryqw/pi-subagent 11.1.0 → 12.0.1
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 +23 -5
- package/dist/worktree.d.ts +15 -7
- package/dist/worktree.js +36 -31
- package/docs/delegate-flow.svg +1 -1
- package/docs/orchestration.md +8 -0
- package/extensions/config.ts +1 -1
- package/extensions/result-transport.ts +79 -26
- package/extensions/subagent.ts +68 -31
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
# `@henryqw/pi-subagent`
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Delegate bounded work from Main to isolated Pi Roles, or run independent implementation units through a fixed Git Flow.
|
|
4
4
|
|
|
5
5
|

|
|
6
6
|
|
|
7
7
|
## Why
|
|
8
8
|
|
|
9
|
-
- **Created for**:
|
|
10
|
-
- **Advantage**:
|
|
9
|
+
- **Created for**: Pi users who want focused child work without loading every detail into Main.
|
|
10
|
+
- **Advantage**: One Role system covers research, review, implementation, parallel work, and ordered chains.
|
|
11
11
|
|
|
12
12
|
## Install
|
|
13
13
|
|
|
@@ -16,16 +16,30 @@ pi install npm:@henryqw/pi-task-models
|
|
|
16
16
|
pi install npm:@henryqw/pi-subagent
|
|
17
17
|
```
|
|
18
18
|
|
|
19
|
+
Run `/task-models` and configure the `fast` profile before delegating.
|
|
20
|
+
|
|
19
21
|
## With
|
|
20
22
|
|
|
21
23
|
| Package | Why |
|
|
22
24
|
| --- | --- |
|
|
23
|
-
| `@henryqw/pi-task-models` | Required. Supplies `fast`, `balanced`, `frontier`, and `fav` model routes. |
|
|
25
|
+
| [`@henryqw/pi-task-models`](https://pi.henry.wang/extensions/pi-task-models) | Required. Supplies `fast`, `balanced`, `frontier`, and `fav` model routes. |
|
|
24
26
|
|
|
25
27
|
Routes come from `~/.pi/agent/config/pi-task-models/config.json`. It stores explicit task overrides. An explicit call `modelClass` wins over a Role `modelClass`; without either, pi-subagent uses its configured `pi-subagent/delegateTask` assignment or declared `fast` default. Missing shared model config warns once because delegation needs a route.
|
|
26
28
|
|
|
27
29
|
## Use
|
|
28
30
|
|
|
31
|
+
Start with one read-only delegation:
|
|
32
|
+
|
|
33
|
+
```json
|
|
34
|
+
{
|
|
35
|
+
"role": "scout",
|
|
36
|
+
"name": "Map sign-in flow",
|
|
37
|
+
"task": "Trace the sign-in request from entry point to session creation. Report the relevant files and unresolved risks. Do not edit files."
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
A separate child returns a bounded report to Main. It creates no saved Pi session and makes model requests through the selected route.
|
|
42
|
+
|
|
29
43
|
| Tool | Purpose |
|
|
30
44
|
| --- | --- |
|
|
31
45
|
| `delegate_task` | Run one bounded task, independent tasks in parallel, or dependent tasks in a chain. |
|
|
@@ -51,7 +65,7 @@ Main supplies each `name`. It must be a short description, about five words and
|
|
|
51
65
|
|
|
52
66
|
Parallel tasks start together, settle together, and report in input order. Chains are sequential and fail at the first failure. `{previous}` passes only the immediately preceding successful assistant output. Foreground failures throw after keeping bounded sibling and recovery evidence.
|
|
53
67
|
|
|
54
|
-
One call has one aggregate 50 KiB cap for Main-visible text. Background work belongs to its launching session; shutdown or reload aborts it and may leave only recoverable-work evidence or no follow-up message.
|
|
68
|
+
One call has one aggregate 50 KiB cap for Main-visible text. Live updates show task names and statuses without opaque IDs. Final results show summaries first and full evidence below. Background work belongs to its launching session; shutdown or reload aborts it and may leave only recoverable-work evidence or no follow-up message.
|
|
55
69
|
|
|
56
70
|
Each entry resolves its own Role, resources, route, and optional isolation. A Role with `isolation: worktree` gets a separate deterministic worktree when available. Non-Git and unborn-`HEAD` contexts can use Main's directory. Other setup failures, including unsafe submodule layouts, reject instead of falling back. Siblings and chain steps never share a created worktree.
|
|
57
71
|
|
|
@@ -59,6 +73,8 @@ See the [orchestration guide](./docs/orchestration.md) for full delegation, tran
|
|
|
59
73
|
|
|
60
74
|
### Flow
|
|
61
75
|
|
|
76
|
+

|
|
77
|
+
|
|
62
78
|
Flow requires a clean Main worktree on an attached branch with a committed `HEAD`. Use it only for independent Git changes that can merge in any order. Do not split units that overlap files, APIs, schemas, generated output, package metadata, lockfiles, or invariants.
|
|
63
79
|
|
|
64
80
|
```text
|
|
@@ -141,4 +157,6 @@ The bundled [`pi-subagent-delegated-development`](./skills/pi-subagent-delegated
|
|
|
141
157
|
|
|
142
158
|
The package root exports `loadRoles`, `resolveRoleSkills`, `resolveRoleLaunch`, `createRoleLaunch`, `createEphemeralSubagentExecutor`, and worktree helpers. The executor works only inside the active Pi process; it does not discover or start a standalone Node.js Pi installation.
|
|
143
159
|
|
|
160
|
+
`finalizeChildWorktree` returns the breaking `WorktreePayload` lifecycle union. `pruned` proves zero commits, a clean tree, and removed worktree and branch. `retained` contains measured `commits` and `dirty` values. `recovery` has an actionable `note` and only completed measurements. An omitted recovery measurement is unknown.
|
|
161
|
+
|
|
144
162
|
See the [public Role and executor API](./docs/orchestration.md#public-role-and-executor-api) for contracts and a `prepare` example. Pass `modelClass` to `resolveRoleLaunch` to override a Role default.
|
package/dist/worktree.d.ts
CHANGED
|
@@ -5,15 +5,24 @@ export interface WorktreeInfo {
|
|
|
5
5
|
repoRoot: string;
|
|
6
6
|
baseCommit: string;
|
|
7
7
|
}
|
|
8
|
-
export
|
|
8
|
+
export type WorktreePayload = {
|
|
9
|
+
outcome: "pruned";
|
|
10
|
+
path: string;
|
|
11
|
+
branch: string;
|
|
12
|
+
} | {
|
|
13
|
+
outcome: "retained";
|
|
9
14
|
path: string;
|
|
10
15
|
branch: string;
|
|
11
16
|
commits: number;
|
|
12
17
|
dirty: boolean;
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
18
|
+
} | {
|
|
19
|
+
outcome: "recovery";
|
|
20
|
+
path: string;
|
|
21
|
+
branch: string;
|
|
22
|
+
note: string;
|
|
23
|
+
commits?: number;
|
|
24
|
+
dirty?: boolean;
|
|
25
|
+
};
|
|
17
26
|
export type GitRunner = (args: string[], cwd: string, signal?: AbortSignal) => Promise<{
|
|
18
27
|
code: number;
|
|
19
28
|
stdout: string;
|
|
@@ -49,8 +58,7 @@ export declare function inspectWorktreeDirty(cwd: string, run?: GitRunner): Prom
|
|
|
49
58
|
* names it; the clean-tree proof includes untracked, ignored, and submodule
|
|
50
59
|
* changes despite repository config. A worktree with zero branch commits and a
|
|
51
60
|
* clean tree is removed only when every probe succeeds and base_commit was
|
|
52
|
-
* recorded; any
|
|
53
|
-
* so unmeasured state is never read as empty.
|
|
61
|
+
* recorded; any uncertainty preserves work and omits unknown measurements.
|
|
54
62
|
*/
|
|
55
63
|
export declare function finalizeChildWorktree(info: WorktreeInfo, run?: GitRunner): Promise<WorktreePayload>;
|
|
56
64
|
/** Context block telling the child to work inside its isolated worktree. */
|
package/dist/worktree.js
CHANGED
|
@@ -149,13 +149,20 @@ export async function createChildWorktree(cwd, childId, run = runGit, signal) {
|
|
|
149
149
|
}
|
|
150
150
|
return worktree;
|
|
151
151
|
}
|
|
152
|
-
/**
|
|
153
|
-
function
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
return
|
|
152
|
+
/** Records only measurements that completed before recovery became necessary. */
|
|
153
|
+
function recoveryPayload(info, reason, measurements = {}, action = `Inspect ${info.path} (branch ${info.branch}) before assuming no work.`) {
|
|
154
|
+
const unknown = [
|
|
155
|
+
...(measurements.commits === undefined ? ["commits"] : []),
|
|
156
|
+
...(measurements.dirty === undefined ? ["dirty"] : []),
|
|
157
|
+
].join("/");
|
|
158
|
+
return {
|
|
159
|
+
outcome: "recovery",
|
|
160
|
+
path: info.path,
|
|
161
|
+
branch: info.branch,
|
|
162
|
+
...(measurements.commits === undefined ? {} : { commits: measurements.commits }),
|
|
163
|
+
...(measurements.dirty === undefined ? {} : { dirty: measurements.dirty }),
|
|
164
|
+
note: `Worktree recovery required (${reason})${unknown ? `: ${unknown} UNKNOWN` : ""}. ${action}`,
|
|
165
|
+
};
|
|
159
166
|
}
|
|
160
167
|
export async function inspectIndexFlags(cwd, run = runGit, signal) {
|
|
161
168
|
const flags = await run(["ls-files", "-v", "-z"], cwd, signal);
|
|
@@ -213,56 +220,54 @@ export async function inspectWorktreeDirty(cwd, run = runGit) {
|
|
|
213
220
|
* names it; the clean-tree proof includes untracked, ignored, and submodule
|
|
214
221
|
* changes despite repository config. A worktree with zero branch commits and a
|
|
215
222
|
* clean tree is removed only when every probe succeeds and base_commit was
|
|
216
|
-
* recorded; any
|
|
217
|
-
* so unmeasured state is never read as empty.
|
|
223
|
+
* recorded; any uncertainty preserves work and omits unknown measurements.
|
|
218
224
|
*/
|
|
219
225
|
export async function finalizeChildWorktree(info, run = runGit) {
|
|
220
|
-
const payload = { path: info.path, branch: info.branch, commits: 0, dirty: false, pruned: false };
|
|
221
226
|
const checkoutExists = existsSync(info.path);
|
|
222
227
|
const gitCwd = checkoutExists ? info.path : info.repoRoot || info.path;
|
|
223
228
|
if (!info.baseCommit)
|
|
224
|
-
return
|
|
229
|
+
return recoveryPayload(info, "no base_commit recorded — commit count unmeasurable");
|
|
225
230
|
const counted = await run(["rev-list", "--count", `${info.baseCommit}..${info.branch}`], gitCwd);
|
|
226
231
|
const commits = Number.parseInt(counted.stdout.trim(), 10);
|
|
227
232
|
const countFailure = counted.code !== 0
|
|
228
233
|
? `rev-list exit ${counted.code}: ${counted.stderr.trim().slice(0, 200)}`
|
|
229
234
|
: Number.isNaN(commits) ? "rev-list produced non-numeric output" : undefined;
|
|
230
235
|
const inspection = checkoutExists ? await inspectDirty(run, info.path) : undefined;
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
236
|
+
const measurements = {
|
|
237
|
+
...(countFailure === undefined ? { commits } : {}),
|
|
238
|
+
...(inspection?.failure === undefined && inspection !== undefined ? { dirty: inspection.dirty } : {}),
|
|
239
|
+
};
|
|
235
240
|
if (countFailure || inspection?.failure) {
|
|
236
|
-
return
|
|
241
|
+
return recoveryPayload(info, [countFailure, inspection?.failure].filter(Boolean).join("; "), measurements);
|
|
237
242
|
}
|
|
238
243
|
let forceRemove = false;
|
|
239
244
|
if (checkoutExists) {
|
|
240
|
-
|
|
241
|
-
|
|
245
|
+
const dirty = inspection.dirty;
|
|
246
|
+
if (commits > 0 || dirty)
|
|
247
|
+
return { outcome: "retained", path: info.path, branch: info.branch, commits, dirty };
|
|
242
248
|
const head = await run(["symbolic-ref", "--quiet", "HEAD"], info.path);
|
|
243
249
|
if (head.code !== 0 || head.stdout.trim() !== `refs/heads/${info.branch}`) {
|
|
244
|
-
return
|
|
250
|
+
return recoveryPayload(info, "HEAD is detached, switched, or unreadable", measurements);
|
|
245
251
|
}
|
|
246
252
|
const rechecked = await inspectWorktreeDirty(info.path, run);
|
|
247
253
|
if (rechecked.failure)
|
|
248
|
-
return
|
|
249
|
-
if (rechecked.dirty)
|
|
250
|
-
|
|
251
|
-
return payload;
|
|
252
|
-
}
|
|
254
|
+
return recoveryPayload(info, `final ${rechecked.failure}`, { commits });
|
|
255
|
+
if (rechecked.dirty)
|
|
256
|
+
return { outcome: "retained", path: info.path, branch: info.branch, commits, dirty: true };
|
|
253
257
|
forceRemove = Boolean(rechecked.initializedSubmodules);
|
|
254
258
|
}
|
|
255
|
-
else if (
|
|
256
|
-
return
|
|
259
|
+
else if (commits > 0) {
|
|
260
|
+
return recoveryPayload(info, "worktree checkout is missing", { commits });
|
|
261
|
+
}
|
|
257
262
|
const cleanupCwd = info.repoRoot || info.path;
|
|
258
263
|
const removed = await run(["worktree", "remove", ...(forceRemove ? ["--force"] : []), info.path], cleanupCwd);
|
|
259
264
|
if (removed.code !== 0)
|
|
260
|
-
return
|
|
265
|
+
return recoveryPayload(info, `worktree remove exit ${removed.code}: ${removed.stderr.trim().slice(0, 200)}`);
|
|
261
266
|
const deleted = await run(["update-ref", "-d", `refs/heads/${info.branch}`, info.baseCommit], cleanupCwd);
|
|
262
|
-
if (deleted.code !== 0)
|
|
263
|
-
return
|
|
264
|
-
|
|
265
|
-
return
|
|
267
|
+
if (deleted.code !== 0) {
|
|
268
|
+
return recoveryPayload(info, `branch delete exit ${deleted.code}: ${deleted.stderr.trim().slice(0, 200)}`, {}, `Inspect branch ${info.branch}; recreate ${info.path} from it before assuming no work.`);
|
|
269
|
+
}
|
|
270
|
+
return { outcome: "pruned", path: info.path, branch: info.branch };
|
|
266
271
|
}
|
|
267
272
|
/** Context block telling the child to work inside its isolated worktree. */
|
|
268
273
|
export function worktreeContextNote(info) {
|
package/docs/delegate-flow.svg
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
-
<svg viewBox="0 0 1280 720" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="delegate-flow-title delegate-flow-desc">
|
|
2
|
+
<svg width="1280" height="720" viewBox="0 0 1280 720" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="delegate-flow-title delegate-flow-desc">
|
|
3
3
|
<title id="delegate-flow-title">Delegate Flow lifecycle</title>
|
|
4
4
|
<desc id="delegate-flow-desc">Flowchart showing setup, implementation, validation, optional exact review, one repair attempt, integration, cleanup, and retained-worktree failure outcomes.</desc>
|
|
5
5
|
<defs>
|
package/docs/orchestration.md
CHANGED
|
@@ -155,6 +155,14 @@ The package root exports the following mechanism-level APIs:
|
|
|
155
155
|
| `createEphemeralSubagentExecutor(options)` | Queue and run one prepared no-session child per `run`. |
|
|
156
156
|
| `createChildWorktree` / `finalizeChildWorktree` | Optional caller-managed worktree lifecycle. |
|
|
157
157
|
|
|
158
|
+
`finalizeChildWorktree` returns the breaking `WorktreePayload` lifecycle union:
|
|
159
|
+
|
|
160
|
+
| `outcome` | Fields | Contract |
|
|
161
|
+
| --- | --- | --- |
|
|
162
|
+
| `pruned` | `path`, `branch` | Zero commits and a clean tree were proved. Worktree and branch cleanup completed. |
|
|
163
|
+
| `retained` | `path`, `branch`, `commits`, `dirty` | Work was preserved. Both measurements are known. |
|
|
164
|
+
| `recovery` | `path`, `branch`, `note`, optional `commits`, `dirty` | Recovery needs action. The note tells Main what to inspect. Present measurements completed; omitted values are unknown. |
|
|
165
|
+
|
|
158
166
|
A loaded `Role` contains `name`, `description`, required normalized `tools`, `extensions`, and `skills` arrays, optional `modelClass` and `isolation`, and `systemPrompt`. `resolveRoleLaunch` accepts `role`, a caller-owned `task` Model Task declaration, optional call-level `modelClass`, and optional caller `agentDir`, `extensions`, `tools`, and `env`. At extension load, callers invoke `registerModelTask(pi, task)` from `@henryqw/pi-task-models` once to expose that declaration in the shared control plane. Its result is a `PiLaunch` (`{ env, args }`) plus the selected `model`, `thinkingLevel`, and `missingSkills`.
|
|
159
167
|
|
|
160
168
|
`createEphemeralSubagentExecutor` requires:
|
package/extensions/config.ts
CHANGED
|
@@ -98,7 +98,7 @@ function parseSubagentConfig(parsed: unknown, path: string): ParsedSubagentConfi
|
|
|
98
98
|
}
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
-
return { config, error: problems.length ? `${path}: ${problems.join("; ")};
|
|
101
|
+
return { config, error: problems.length ? `${path}: ${problems.join("; ")}; invalid settings use defaults while valid settings still apply.` : undefined };
|
|
102
102
|
}
|
|
103
103
|
|
|
104
104
|
/**
|
|
@@ -14,6 +14,7 @@ export type WorkflowTransportStatus = "pending" | "running" | "succeeded" | "fai
|
|
|
14
14
|
type TransportEntryBase = {
|
|
15
15
|
id: WorkflowEntry["id"];
|
|
16
16
|
index: WorkflowEntry["index"];
|
|
17
|
+
name: WorkflowEntry["delegation"]["name"];
|
|
17
18
|
role: WorkflowEntry["delegation"]["role"];
|
|
18
19
|
model?: string;
|
|
19
20
|
thinkingLevel?: string;
|
|
@@ -33,6 +34,7 @@ export type WorkflowTransportEntry =
|
|
|
33
34
|
export type WorkflowTransportEntryDetails = {
|
|
34
35
|
id: string;
|
|
35
36
|
index: number;
|
|
37
|
+
name: string;
|
|
36
38
|
role: string;
|
|
37
39
|
status: WorkflowTransportStatus;
|
|
38
40
|
summary?: string;
|
|
@@ -46,6 +48,13 @@ export type WorkflowTransportDetails = {
|
|
|
46
48
|
entries: WorkflowTransportEntryDetails[];
|
|
47
49
|
};
|
|
48
50
|
|
|
51
|
+
export type BackgroundWorkflowTransportDetails = WorkflowTransportDetails & {
|
|
52
|
+
taskId: string;
|
|
53
|
+
outcome: "completed" | "failed" | "aborted";
|
|
54
|
+
recovery?: true;
|
|
55
|
+
usage?: Usage;
|
|
56
|
+
};
|
|
57
|
+
|
|
49
58
|
export type WorkflowTransport = {
|
|
50
59
|
text: string;
|
|
51
60
|
details: WorkflowTransportDetails;
|
|
@@ -65,24 +74,63 @@ function splitEvidence(text: string): [string, string] {
|
|
|
65
74
|
return [preview, text.slice(preview.length)];
|
|
66
75
|
}
|
|
67
76
|
|
|
68
|
-
function
|
|
69
|
-
if (
|
|
70
|
-
if (
|
|
71
|
-
|
|
72
|
-
|
|
77
|
+
function workflowTitle(mode: WorkflowMode): string {
|
|
78
|
+
if (mode === "single") return "Delegation";
|
|
79
|
+
if (mode === "parallel") return "Parallel delegation";
|
|
80
|
+
return "Delegation chain";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function statusCounts(entries: readonly WorkflowTransportEntry[], kind: TransportKind): string[] {
|
|
84
|
+
const count = (statuses: WorkflowTransportStatus[]) => entries.filter(({ status }) => statuses.includes(status)).length;
|
|
85
|
+
const labels = kind === "update"
|
|
86
|
+
? [["running"], ["succeeded"], ["failed", "rejected"], ["pending"], ["skipped"]] as const
|
|
87
|
+
: [["failed", "rejected"], ["succeeded"], ["skipped"]] as const;
|
|
88
|
+
const words = kind === "update" ? ["running", "completed", "failed", "queued", "skipped"] : ["failed", "completed", "skipped"];
|
|
89
|
+
return labels.flatMap((statuses, index) => {
|
|
90
|
+
const total = count([...statuses]);
|
|
91
|
+
return total ? [`${total} ${words[index]}`] : [];
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function heading(mode: WorkflowMode, entries: readonly WorkflowTransportEntry[], kind: TransportKind, failed: boolean): string {
|
|
96
|
+
const title = kind === "background" ? `Background ${workflowTitle(mode).toLowerCase()}` : workflowTitle(mode);
|
|
97
|
+
if (kind === "update") return [title, ...statusCounts(entries, kind)].join(" · ");
|
|
98
|
+
const outcome = kind === "abort" ? "stopped" : failed ? "failed" : "completed";
|
|
99
|
+
return [`${title} ${outcome}`, ...statusCounts(entries, kind)].join(" · ");
|
|
73
100
|
}
|
|
74
101
|
|
|
75
102
|
export function displaySummary(text: string): string {
|
|
76
103
|
const line = text.split(/\r?\n/).find((candidate) => candidate.trim()) ?? "";
|
|
77
|
-
|
|
104
|
+
const normalized = line.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").trim().replace(/^[-*+]\s+/, "").split(/\s+/).join(" ");
|
|
105
|
+
return Array.from(normalized).slice(0, 160).join("");
|
|
78
106
|
}
|
|
79
107
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
108
|
+
export type WorkflowEntryStatusPresentation = { glyph: string; fallback: string };
|
|
109
|
+
|
|
110
|
+
const ENTRY_STATUS_PRESENTATION = {
|
|
111
|
+
pending: { glyph: "○", fallback: "queued" },
|
|
112
|
+
running: { glyph: "◌", fallback: "working" },
|
|
113
|
+
succeeded: { glyph: "✓", fallback: "completed" },
|
|
114
|
+
failed: { glyph: "✗", fallback: "failed" },
|
|
115
|
+
rejected: { glyph: "✗", fallback: "failed" },
|
|
116
|
+
skipped: { glyph: "–", fallback: "skipped" },
|
|
117
|
+
} as const satisfies Record<WorkflowTransportEntryDetails["status"], WorkflowEntryStatusPresentation>;
|
|
118
|
+
|
|
119
|
+
export function presentWorkflowEntryStatus(status: WorkflowTransportEntryDetails["status"]): WorkflowEntryStatusPresentation {
|
|
120
|
+
return ENTRY_STATUS_PRESENTATION[status];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function sourceFor(entry: WorkflowTransportEntry): string | undefined {
|
|
124
|
+
if (entry.status === "failed" || entry.status === "rejected") return entry.failure;
|
|
125
|
+
if (entry.status === "running" || entry.status === "succeeded") return entry.assistantOutput;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function evidenceFor(entry: WorkflowTransportEntry, position: number, total: number): Evidence | undefined {
|
|
129
|
+
const source = sourceFor(entry);
|
|
130
|
+
if (source === undefined || !source) return;
|
|
131
|
+
const [preview, remainder] = splitEvidence(source);
|
|
84
132
|
return {
|
|
85
|
-
heading: `- [${entry.
|
|
133
|
+
heading: `- [${position}/${total}] ${entry.name} · ${entry.role} · ${entry.status === "failed" || entry.status === "rejected" ? "failure" : "result"}:`,
|
|
86
134
|
preview,
|
|
87
135
|
remainder,
|
|
88
136
|
};
|
|
@@ -98,27 +146,32 @@ function formatWorkflowTransport(
|
|
|
98
146
|
throw new TypeError("Final workflow transport requires terminal entry states.");
|
|
99
147
|
}
|
|
100
148
|
const failed = ordered.some(({ status }) => status === "failed" || status === "rejected");
|
|
101
|
-
const
|
|
102
|
-
const
|
|
103
|
-
const
|
|
149
|
+
const positioned = ordered.map((entry, index) => ({ entry, position: index + 1 }));
|
|
150
|
+
const recoveries = positioned.flatMap(({ entry, position }) => {
|
|
151
|
+
const worktree = entry.worktreePayload;
|
|
152
|
+
return worktree === undefined || worktree.outcome === "pruned" ? [] : [{ entry, position, worktree }];
|
|
153
|
+
});
|
|
154
|
+
const evidence = kind === "update" ? [] : positioned.flatMap(({ entry, position }) => {
|
|
155
|
+
const value = evidenceFor(entry, position, ordered.length);
|
|
104
156
|
return value ? [value] : [];
|
|
105
157
|
});
|
|
106
158
|
const lines = [
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
159
|
+
heading(mode, ordered, kind, failed),
|
|
160
|
+
...positioned.map(({ entry, position }) => {
|
|
161
|
+
const { glyph, fallback } = presentWorkflowEntryStatus(entry.status);
|
|
162
|
+
const summary = displaySummary(sourceFor(entry) ?? "") || fallback;
|
|
163
|
+
return `${glyph} [${position}/${ordered.length}] ${entry.name} · ${entry.role} — ${summary}`;
|
|
164
|
+
}),
|
|
112
165
|
...(recoveries.length ? [
|
|
113
|
-
"
|
|
114
|
-
...recoveries.map((entry) =>
|
|
115
|
-
`- [${
|
|
166
|
+
"Recovery:",
|
|
167
|
+
...recoveries.map(({ entry, position, worktree }) =>
|
|
168
|
+
`- [${position}/${ordered.length}] ${entry.name} · worktree ${JSON.stringify(worktree.path)} · branch ${JSON.stringify(worktree.branch)}`),
|
|
116
169
|
] : []),
|
|
117
170
|
...(evidence.length ? [
|
|
118
|
-
"
|
|
171
|
+
"Results:",
|
|
119
172
|
...evidence.flatMap(({ heading, preview }) => [heading, preview]),
|
|
120
173
|
...(evidence.some(({ remainder }) => remainder) ? [
|
|
121
|
-
"
|
|
174
|
+
"More detail:",
|
|
122
175
|
...evidence.flatMap(({ heading, remainder }) => remainder ? [heading, remainder] : []),
|
|
123
176
|
] : []),
|
|
124
177
|
] : []),
|
|
@@ -130,11 +183,11 @@ function formatWorkflowTransport(
|
|
|
130
183
|
details: {
|
|
131
184
|
mode,
|
|
132
185
|
entries: ordered.map((entry) => {
|
|
133
|
-
const source = entry
|
|
134
|
-
: entry.status === "running" || entry.status === "succeeded" ? entry.assistantOutput : undefined;
|
|
186
|
+
const source = sourceFor(entry);
|
|
135
187
|
return {
|
|
136
188
|
id: entry.id,
|
|
137
189
|
index: entry.index,
|
|
190
|
+
name: entry.name,
|
|
138
191
|
role: entry.role,
|
|
139
192
|
status: entry.status,
|
|
140
193
|
...(source === undefined ? {} : { summary: displaySummary(source) }),
|
package/extensions/subagent.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { basename } from "node:path";
|
|
2
2
|
import type { Usage } from "@earendil-works/pi-ai";
|
|
3
3
|
import { type ExtensionAPI, type ExtensionContext, type Theme } from "@earendil-works/pi-coding-agent";
|
|
4
|
-
import { type Component, type TUI, truncateToWidth } from "@earendil-works/pi-tui";
|
|
4
|
+
import { type Component, Text, type TUI, truncateToWidth } from "@earendil-works/pi-tui";
|
|
5
5
|
import {
|
|
6
6
|
availableTaskModels,
|
|
7
7
|
loadTaskModelsConfig,
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
formatDuration,
|
|
24
24
|
loadRoles,
|
|
25
25
|
resolveRoleLaunch,
|
|
26
|
+
WorktreeSetupError,
|
|
26
27
|
worktreeContextNote,
|
|
27
28
|
type EphemeralSubagentActivityEvent,
|
|
28
29
|
type EphemeralSubagentResult,
|
|
@@ -38,8 +39,10 @@ import {
|
|
|
38
39
|
formatBackgroundWorkflowResult,
|
|
39
40
|
formatWorkflowResult,
|
|
40
41
|
formatWorkflowUpdate,
|
|
42
|
+
presentWorkflowEntryStatus,
|
|
41
43
|
WorkflowAbortedError,
|
|
42
44
|
WorkflowFailureError,
|
|
45
|
+
type BackgroundWorkflowTransportDetails,
|
|
43
46
|
type WorkflowTransportEntry,
|
|
44
47
|
} from "./result-transport.ts";
|
|
45
48
|
import {
|
|
@@ -222,6 +225,30 @@ export default function subagentExtension(
|
|
|
222
225
|
overrideTimeoutPolicy?: TimeoutPolicy,
|
|
223
226
|
): void {
|
|
224
227
|
registerModelTask(pi, DELEGATE_TASK);
|
|
228
|
+
pi.registerMessageRenderer(BACKGROUND_RESULT_TYPE, (message, { expanded, outputPad }, theme) => {
|
|
229
|
+
const details = message.details as BackgroundWorkflowTransportDetails | undefined;
|
|
230
|
+
const content = typeof message.content === "string"
|
|
231
|
+
? message.content
|
|
232
|
+
: message.content.flatMap((part) => part.type === "text" ? [part.text] : []).join("\n");
|
|
233
|
+
if (!details?.entries) return new Text(content, outputPad, 0);
|
|
234
|
+
const count = details.entries.length;
|
|
235
|
+
const subject = count === 1 ? "Background subagent" : `${count} background subagents`;
|
|
236
|
+
const state = details.recovery ? "stopped; recovery needed"
|
|
237
|
+
: details.outcome === "completed" ? "completed"
|
|
238
|
+
: details.outcome === "failed" ? "failed" : "stopped";
|
|
239
|
+
const glyph = details.recovery || details.outcome === "aborted" ? "■" : details.outcome === "completed" ? "✓" : "✗";
|
|
240
|
+
const color = details.recovery || details.outcome === "aborted" ? "warning" : details.outcome === "completed" ? "success" : "error";
|
|
241
|
+
const rows = details.entries.map(({ name, role, status, summary }) => {
|
|
242
|
+
const { glyph, fallback } = presentWorkflowEntryStatus(status);
|
|
243
|
+
return `${glyph} ${name} · ${role} — ${details.recovery ? fallback : summary || fallback}`;
|
|
244
|
+
});
|
|
245
|
+
const raw = content.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g, " ");
|
|
246
|
+
return new Text([
|
|
247
|
+
theme.fg(color, `${glyph} ${subject} ${state}`),
|
|
248
|
+
...rows,
|
|
249
|
+
...(expanded ? ["", raw] : []),
|
|
250
|
+
].join("\n"), outputPad, 0);
|
|
251
|
+
});
|
|
225
252
|
const widgetItems = new Map<string, WidgetItem>();
|
|
226
253
|
// Each child is a full Pi process issuing its own model calls; cap parallel
|
|
227
254
|
// spend. Precedence: PI_SUBAGENT_MAX_SUBAGENTS env > config/pi-subagent/config.json
|
|
@@ -450,26 +477,35 @@ export default function subagentExtension(
|
|
|
450
477
|
setupRecoveries: ReadonlyMap<string, string>,
|
|
451
478
|
): void => {
|
|
452
479
|
const stale = launchEpoch !== sessionEpoch;
|
|
453
|
-
const
|
|
454
|
-
|
|
480
|
+
const recoveries = entries.flatMap((entry) => {
|
|
481
|
+
const worktree = entry.worktreePayload;
|
|
482
|
+
return worktree === undefined || worktree.outcome === "pruned" ? [] : [{ entry, worktree }];
|
|
483
|
+
});
|
|
484
|
+
if (stale && !recoveries.length && !setupRecoveries.size) return;
|
|
455
485
|
const transport = formatBackgroundWorkflowResult(mode, entries);
|
|
456
|
-
const outcome = stale ? "aborted" : transport.failed ? "failed" : "completed";
|
|
486
|
+
const outcome: BackgroundWorkflowTransportDetails["outcome"] = stale ? "aborted" : transport.failed ? "failed" : "completed";
|
|
457
487
|
const content = stale
|
|
458
488
|
? capOutput([
|
|
459
489
|
"Background workflow left recoverable isolated work after session shutdown.",
|
|
460
490
|
`Task ID: ${taskId}`,
|
|
461
491
|
`Mode: ${mode}`,
|
|
462
492
|
"Recovery locations:",
|
|
463
|
-
...
|
|
464
|
-
`- [${entry.index}] worktree path=${JSON.stringify(
|
|
493
|
+
...recoveries.map(({ entry, worktree }) =>
|
|
494
|
+
`- [${entry.index}] worktree path=${JSON.stringify(worktree.path)} branch=${JSON.stringify(worktree.branch)}`),
|
|
465
495
|
...[...setupRecoveries].map(([id, recovery]) => {
|
|
466
496
|
const entry = entries.find((candidate) => candidate.id === id)!;
|
|
467
497
|
return `- [${entry.index}] setup state: ${recovery}`;
|
|
468
498
|
}),
|
|
469
499
|
"Evidence:",
|
|
470
|
-
...
|
|
471
|
-
const
|
|
472
|
-
|
|
500
|
+
...recoveries.flatMap(({ entry, worktree }) => {
|
|
501
|
+
const measurements = [
|
|
502
|
+
...(worktree.commits === undefined ? [] : [`commits=${worktree.commits}`]),
|
|
503
|
+
...(worktree.dirty === undefined ? [] : [`dirty=${worktree.dirty}`]),
|
|
504
|
+
];
|
|
505
|
+
return [
|
|
506
|
+
`- [${entry.index}] ${worktree.outcome} worktree${measurements.length ? ` ${measurements.join(" ")}` : ""}`,
|
|
507
|
+
...(worktree.outcome === "recovery" ? [` ${worktree.note}`] : []),
|
|
508
|
+
];
|
|
473
509
|
}),
|
|
474
510
|
...[...setupRecoveries].map(([id, recovery]) => {
|
|
475
511
|
const entry = entries.find((candidate) => candidate.id === id)!;
|
|
@@ -477,6 +513,13 @@ export default function subagentExtension(
|
|
|
477
513
|
}),
|
|
478
514
|
].join("\n"))
|
|
479
515
|
: transport.text;
|
|
516
|
+
const details: BackgroundWorkflowTransportDetails = {
|
|
517
|
+
...transport.details,
|
|
518
|
+
taskId,
|
|
519
|
+
outcome,
|
|
520
|
+
...(transport.usage === undefined ? {} : { usage: transport.usage }),
|
|
521
|
+
...(stale ? { recovery: true } : {}),
|
|
522
|
+
};
|
|
480
523
|
try {
|
|
481
524
|
// Custom messages convert to user-role LLM messages, so the parent agent
|
|
482
525
|
// sees the aggregate on its next turn without forcing one now.
|
|
@@ -484,13 +527,7 @@ export default function subagentExtension(
|
|
|
484
527
|
customType: BACKGROUND_RESULT_TYPE,
|
|
485
528
|
content,
|
|
486
529
|
display: true,
|
|
487
|
-
details
|
|
488
|
-
...transport.details,
|
|
489
|
-
taskId,
|
|
490
|
-
outcome,
|
|
491
|
-
...(transport.usage === undefined ? {} : { usage: transport.usage }),
|
|
492
|
-
...(stale ? { recovery: true } : {}),
|
|
493
|
-
},
|
|
530
|
+
details,
|
|
494
531
|
}, { triggerTurn: false });
|
|
495
532
|
} catch (error) {
|
|
496
533
|
// Delivery can disappear during teardown; only an active UI gets a visible failure.
|
|
@@ -592,6 +629,7 @@ export default function subagentExtension(
|
|
|
592
629
|
const states = new Map<string, WorkflowTransportEntry>(entries.map((entry) => [entry.id, {
|
|
593
630
|
id: entry.id,
|
|
594
631
|
index: entry.index,
|
|
632
|
+
name: entry.delegation.name,
|
|
595
633
|
role: entry.delegation.role,
|
|
596
634
|
status: "pending",
|
|
597
635
|
}]));
|
|
@@ -627,6 +665,7 @@ export default function subagentExtension(
|
|
|
627
665
|
const base = {
|
|
628
666
|
id: entry.id,
|
|
629
667
|
index: entry.index,
|
|
668
|
+
name: entry.delegation.name,
|
|
630
669
|
role: role.name,
|
|
631
670
|
...(model === undefined ? {} : { model }),
|
|
632
671
|
...(thinkingLevel === undefined ? {} : { thinkingLevel }),
|
|
@@ -680,7 +719,7 @@ export default function subagentExtension(
|
|
|
680
719
|
? (error as EphemeralSubagentError & { usage?: Usage }).usage
|
|
681
720
|
: undefined;
|
|
682
721
|
const cause = error instanceof EphemeralSubagentError ? error.cause : error;
|
|
683
|
-
if (cause instanceof
|
|
722
|
+
if (cause instanceof WorktreeSetupError) {
|
|
684
723
|
setupRecoveries.set(entry.id, cause.message);
|
|
685
724
|
}
|
|
686
725
|
text = capOutput(error instanceof Error ? error.message : String(error));
|
|
@@ -693,17 +732,14 @@ export default function subagentExtension(
|
|
|
693
732
|
status = "rejected";
|
|
694
733
|
text = capOutput(error instanceof Error ? error.message : String(error));
|
|
695
734
|
worktreePayload = worktree ? {
|
|
735
|
+
outcome: "recovery",
|
|
696
736
|
path: worktree.path,
|
|
697
737
|
branch: worktree.branch,
|
|
698
|
-
|
|
699
|
-
dirty: false,
|
|
700
|
-
pruned: false,
|
|
701
|
-
inspection_failed: true,
|
|
702
|
-
note: capOutput(`Worktree finalization failed (${text}); commits/dirty UNKNOWN. Inspect retained work before assuming no changes.`),
|
|
738
|
+
note: capOutput(`Worktree finalization failed (${text}); commits/dirty UNKNOWN. Inspect ${worktree.path} (branch ${worktree.branch}) before assuming no work.`),
|
|
703
739
|
} : undefined;
|
|
704
740
|
}
|
|
705
|
-
if (worktreePayload?.
|
|
706
|
-
const note = capOutput(worktreePayload.note
|
|
741
|
+
if (worktreePayload?.outcome === "recovery") {
|
|
742
|
+
const note = capOutput(worktreePayload.note);
|
|
707
743
|
worktreePayload = { ...worktreePayload, note };
|
|
708
744
|
rejected = new Error(note, rejected === undefined ? undefined : { cause: rejected });
|
|
709
745
|
status = "rejected";
|
|
@@ -741,6 +777,7 @@ export default function subagentExtension(
|
|
|
741
777
|
states.set(target.id, {
|
|
742
778
|
id: target.id,
|
|
743
779
|
index: target.index,
|
|
780
|
+
name: target.name,
|
|
744
781
|
role: target.role,
|
|
745
782
|
...(target.model === undefined ? {} : { model: target.model }),
|
|
746
783
|
...(target.thinkingLevel === undefined ? {} : { thinkingLevel: target.thinkingLevel }),
|
|
@@ -775,13 +812,13 @@ export default function subagentExtension(
|
|
|
775
812
|
})();
|
|
776
813
|
backgroundTasks.set(taskId, { controller, settled });
|
|
777
814
|
void settled;
|
|
815
|
+
const title = workflow.mode === "single" ? "Background delegation"
|
|
816
|
+
: workflow.mode === "parallel" ? "Background parallel delegation" : "Background delegation chain";
|
|
778
817
|
const acknowledgement = capOutput([
|
|
779
|
-
`
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
`- [${entry.index}] id=${JSON.stringify(entry.id)} role=${JSON.stringify(entry.delegation.role)}`),
|
|
784
|
-
"The aggregate outcome arrives as one message; keep working or end your turn.",
|
|
818
|
+
`${title} started${entries.length === 1 ? "" : ` · ${entries.length} tasks`}`,
|
|
819
|
+
...entries.map((entry, index) =>
|
|
820
|
+
`○ [${index + 1}/${entries.length}] ${entry.delegation.name} · ${entry.delegation.role}`),
|
|
821
|
+
"Results will arrive in one message.",
|
|
785
822
|
].join("\n"));
|
|
786
823
|
return {
|
|
787
824
|
content: [{ type: "text" as const, text: acknowledgement }],
|
|
@@ -789,7 +826,7 @@ export default function subagentExtension(
|
|
|
789
826
|
taskId,
|
|
790
827
|
background: true,
|
|
791
828
|
mode: workflow.mode,
|
|
792
|
-
entries: entries.map((entry) => ({ id: entry.id, index: entry.index, role: entry.delegation.role })),
|
|
829
|
+
entries: entries.map((entry) => ({ id: entry.id, index: entry.index, name: entry.delegation.name, role: entry.delegation.role })),
|
|
793
830
|
},
|
|
794
831
|
};
|
|
795
832
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@henryqw/pi-subagent",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "12.0.1",
|
|
4
4
|
"description": "Delegate bounded single, parallel, or chained tasks to isolated Pi roles.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -66,7 +66,7 @@
|
|
|
66
66
|
},
|
|
67
67
|
"dependencies": {
|
|
68
68
|
"@henryqw/pi-config-store": "^0.1.0",
|
|
69
|
-
"@henryqw/pi-multi-codex": "^0.
|
|
69
|
+
"@henryqw/pi-multi-codex": "^0.4.1",
|
|
70
70
|
"@henryqw/pi-task-models": "^4.0.0"
|
|
71
71
|
}
|
|
72
72
|
}
|