@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 CHANGED
@@ -1,13 +1,13 @@
1
1
  # `@henryqw/pi-subagent`
2
2
 
3
- Main, the parent Pi session, can delegate bounded single, parallel, and chained tasks to isolated child Roles. It also provides a fixed Git Flow for independent implementation work.
3
+ Delegate bounded work from Main to isolated Pi Roles, or run independent implementation units through a fixed Git Flow.
4
4
 
5
5
  ![Pi showing six delegated tasks running in parallel](./example.png)
6
6
 
7
7
  ## Why
8
8
 
9
- - **Created for**: Delegate bounded work to isolated child Pi processes without losing Main's context.
10
- - **Advantage**: Generic delegation and Git Flow use the same Role settings.
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
+ ![Delegate Flow lifecycle](./docs/delegate-flow.svg)
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.
@@ -5,15 +5,24 @@ export interface WorktreeInfo {
5
5
  repoRoot: string;
6
6
  baseCommit: string;
7
7
  }
8
- export interface WorktreePayload {
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
- pruned: boolean;
14
- inspection_failed?: boolean;
15
- note?: string;
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 probe failure keeps everything and reports `inspection_failed`
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
- /** Flags a payload whose state could not be measured (#88113): unmeasured is not zero. */
153
- function markUnproven(payload, reason, unmeasured = "commits/dirty") {
154
- payload.inspection_failed = true;
155
- payload.note =
156
- `git inspection failed (${reason}): ${unmeasured} UNKNOWN not proven zero/clean. `
157
- + `Any remaining worktree or branch was preserved — inspect ${payload.path} (branch ${payload.branch}) before assuming no work.`;
158
- return payload;
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 probe failure keeps everything and reports `inspection_failed`
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 markUnproven(payload, "no base_commit recorded — commit count unmeasurable", "commits");
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
- if (!countFailure)
232
- payload.commits = commits;
233
- if (inspection)
234
- payload.dirty = inspection.dirty;
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 markUnproven(payload, [countFailure, inspection?.failure].filter(Boolean).join("; "), [countFailure && "commits", inspection?.failure && "dirty"].filter(Boolean).join("/"));
241
+ return recoveryPayload(info, [countFailure, inspection?.failure].filter(Boolean).join("; "), measurements);
237
242
  }
238
243
  let forceRemove = false;
239
244
  if (checkoutExists) {
240
- if (payload.commits > 0 || payload.dirty)
241
- return payload;
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 markUnproven(payload, "HEAD is detached, switched, or unreadable", "checked-out commits");
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 markUnproven(payload, `final ${rechecked.failure}`, "dirty");
249
- if (rechecked.dirty) {
250
- payload.dirty = true;
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 (payload.commits > 0)
256
- return payload;
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 markUnproven(payload, `worktree remove exit ${removed.code}: ${removed.stderr.trim().slice(0, 200)}`, "cleanup");
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 markUnproven(payload, `branch delete exit ${deleted.code}: ${deleted.stderr.trim().slice(0, 200)}`, "cleanup");
264
- payload.pruned = true;
265
- return payload;
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) {
@@ -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>
@@ -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:
@@ -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("; ")}; using defaults.` : undefined };
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 label(kind: TransportKind, failed: boolean): string {
69
- if (kind === "update") return "Workflow update.";
70
- if (kind === "background") return `Background workflow ${failed ? "failed" : "succeeded"}.`;
71
- if (kind === "abort") return "Workflow aborted.";
72
- return `Workflow ${failed ? "failed" : "succeeded"}.`;
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
- return Array.from(line.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").trim().split(/\s+/).join(" ")).slice(0, 160).join("");
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
- function evidenceFor(entry: WorkflowTransportEntry): Evidence | undefined {
81
- if (entry.status === "pending" || entry.status === "skipped") return;
82
- const source = entry.status === "failed" || entry.status === "rejected" ? entry.failure : entry.assistantOutput;
83
- const [preview, remainder] = splitEvidence(source || (entry.status === "running" ? "(no output yet)" : "(no output)"));
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.index}] ${JSON.stringify(entry.id)} ${entry.status === "failed" || entry.status === "rejected" ? "failure" : "assistant"}:`,
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 recoveries = ordered.filter(({ worktreePayload }) => worktreePayload && !worktreePayload.pruned);
102
- const evidence = ordered.flatMap((entry) => {
103
- const value = evidenceFor(entry);
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
- label(kind, failed),
108
- `Mode: ${mode}`,
109
- "Entries:",
110
- ...ordered.map((entry) =>
111
- `- [${entry.index}] id=${JSON.stringify(entry.id)} role=${JSON.stringify(entry.role)} status=${entry.status}`),
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
- "Retained worktrees:",
114
- ...recoveries.map((entry) =>
115
- `- [${entry.index}] path=${JSON.stringify(entry.worktreePayload!.path)} branch=${JSON.stringify(entry.worktreePayload!.branch)}`),
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
- "Evidence:",
171
+ "Results:",
119
172
  ...evidence.flatMap(({ heading, preview }) => [heading, preview]),
120
173
  ...(evidence.some(({ remainder }) => remainder) ? [
121
- "Continued evidence:",
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.status === "failed" || entry.status === "rejected" ? entry.failure
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) }),
@@ -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 retained = entries.filter(({ worktreePayload }) => worktreePayload && !worktreePayload.pruned);
454
- if (stale && !retained.length && !setupRecoveries.size) return;
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
- ...retained.map((entry) =>
464
- `- [${entry.index}] worktree path=${JSON.stringify(entry.worktreePayload!.path)} branch=${JSON.stringify(entry.worktreePayload!.branch)}`),
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
- ...retained.map((entry) => {
471
- const payload = entry.worktreePayload!;
472
- return `- [${entry.index}] retained worktree commits=${payload.commits} dirty=${payload.dirty} inspection_failed=${payload.inspection_failed === true}`;
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 Error && cause.name === "WorktreeSetupError") {
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
- commits: 0,
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?.inspection_failed) {
706
- const note = capOutput(worktreePayload.note ?? `Worktree inspection failed; inspect ${worktreePayload.path} before assuming no work.`);
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
- `Background workflow ${taskId} accepted.`,
780
- `Mode: ${workflow.mode}`,
781
- "Entries:",
782
- ...entries.map((entry) =>
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": "11.1.0",
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.3.8",
69
+ "@henryqw/pi-multi-codex": "^0.4.1",
70
70
  "@henryqw/pi-task-models": "^4.0.0"
71
71
  }
72
72
  }