@yagni-app/code 0.2.0 → 0.3.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.
Files changed (77) hide show
  1. package/dist/cli.d.ts +30 -0
  2. package/dist/cli.js +135 -3
  3. package/dist/doctor.d.ts +1 -1
  4. package/dist/doctor.js +1 -1
  5. package/dist/extension/advisor.d.ts +4 -4
  6. package/dist/extension/advisor.js +6 -7
  7. package/dist/extension/approvedPrefixes.d.ts +92 -0
  8. package/dist/extension/approvedPrefixes.js +252 -0
  9. package/dist/extension/askAdvisorTool.d.ts +2 -2
  10. package/dist/extension/askAdvisorTool.js +5 -5
  11. package/dist/extension/askYagniTool.js +49 -0
  12. package/dist/extension/branding.d.ts +24 -3
  13. package/dist/extension/branding.js +71 -10
  14. package/dist/extension/chipEditor.d.ts +30 -9
  15. package/dist/extension/chipEditor.js +173 -59
  16. package/dist/extension/claudeRules.d.ts +0 -2
  17. package/dist/extension/claudeRules.js +0 -8
  18. package/dist/extension/cmux/dispatcher.d.ts +25 -0
  19. package/dist/extension/cmux/dispatcher.js +266 -0
  20. package/dist/extension/cmux/hooks.d.ts +12 -0
  21. package/dist/extension/cmux/hooks.js +192 -0
  22. package/dist/extension/cmux/index.d.ts +3 -0
  23. package/dist/extension/cmux/index.js +155 -0
  24. package/dist/extension/cmux/naming.d.ts +5 -0
  25. package/dist/extension/cmux/naming.js +23 -0
  26. package/dist/extension/cmux/state.d.ts +33 -0
  27. package/dist/extension/cmux/state.js +142 -0
  28. package/dist/extension/config.d.ts +32 -1
  29. package/dist/extension/config.js +36 -4
  30. package/dist/extension/costHud.d.ts +16 -22
  31. package/dist/extension/costHud.js +8 -47
  32. package/dist/extension/crashReport.js +1 -3
  33. package/dist/extension/execPolicy.d.ts +119 -0
  34. package/dist/extension/execPolicy.js +805 -0
  35. package/dist/extension/footer.d.ts +111 -0
  36. package/dist/extension/footer.js +294 -0
  37. package/dist/extension/guardian.d.ts +129 -0
  38. package/dist/extension/guardian.js +213 -0
  39. package/dist/extension/index.d.ts +15 -4
  40. package/dist/extension/index.js +250 -24
  41. package/dist/extension/permission.d.ts +123 -10
  42. package/dist/extension/permission.js +586 -40
  43. package/dist/extension/pipeline/childRegistry.d.ts +41 -0
  44. package/dist/extension/pipeline/childRegistry.js +118 -0
  45. package/dist/extension/pipeline/finish.js +5 -1
  46. package/dist/extension/pipeline/goCommand.d.ts +1 -1
  47. package/dist/extension/pipeline/goCommand.js +35 -6
  48. package/dist/extension/pipeline/goStatusCommands.d.ts +10 -0
  49. package/dist/extension/pipeline/goStatusCommands.js +61 -1
  50. package/dist/extension/pipeline/personas.js +25 -0
  51. package/dist/extension/pipeline/runRegistry.d.ts +14 -0
  52. package/dist/extension/pipeline/runRegistry.js +35 -0
  53. package/dist/extension/pipeline/runner.js +4 -0
  54. package/dist/extension/pipeline/verify.d.ts +4 -0
  55. package/dist/extension/pipeline/verify.js +48 -26
  56. package/dist/extension/redact.d.ts +20 -0
  57. package/dist/extension/redact.js +64 -0
  58. package/dist/extension/rerouteNotice.d.ts +3 -12
  59. package/dist/extension/rerouteNotice.js +36 -15
  60. package/dist/extension/subagentRender.d.ts +129 -0
  61. package/dist/extension/subagentRender.js +441 -0
  62. package/dist/extension/subagents.d.ts +4 -7
  63. package/dist/extension/subagents.js +103 -33
  64. package/dist/extension/ticketTools.d.ts +37 -0
  65. package/dist/extension/ticketTools.js +117 -0
  66. package/dist/extension/tokenProvider.js +46 -5
  67. package/dist/launch.d.ts +7 -0
  68. package/dist/launch.js +24 -12
  69. package/dist/padding.d.ts +22 -0
  70. package/dist/padding.js +25 -0
  71. package/dist/promptEnrichment.d.ts +40 -0
  72. package/dist/promptEnrichment.js +85 -0
  73. package/dist/signalForward.d.ts +60 -0
  74. package/dist/signalForward.js +130 -0
  75. package/package.json +5 -5
  76. package/dist/extension/boostCommand.d.ts +0 -144
  77. package/dist/extension/boostCommand.js +0 -263
@@ -0,0 +1,441 @@
1
+ /**
2
+ * Live progress model + TUI renderers for the `subagent` tool.
3
+ *
4
+ * The model half is PURE (mirrors `pipeline/activity.ts`): `applyChildEvent`
5
+ * folds one NDJSON event from a child into a bounded per-task progress record,
6
+ * and `finalizeTask` stamps the outcome from the runner's `StageResult`. The
7
+ * tool carries the folded records in its `details`, so every partial update the
8
+ * TUI sees is a complete picture of all tasks.
9
+ *
10
+ * The renderer half implements pi's per-tool rendering seam (`renderCall` /
11
+ * `renderResult`). Collapsed-while-running is the two-line pattern: a stable
12
+ * `agent — task` title over a churning `↳ current tool` line. Completion is a
13
+ * one-line receipt (`✓ agent · N tool uses · Xk tokens · Ys`); expanded shows
14
+ * the curated action log and the full report as markdown. String assembly is
15
+ * kept in pure helpers over a minimal {@link RenderTheme} so tests run against
16
+ * plain text — renderer exceptions are swallowed by pi (silently degrading to
17
+ * the bare title bar), so everything here must stay boringly total.
18
+ */
19
+ import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
20
+ import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
21
+ import { narrationHeadline, toolLabel } from "./pipeline/activity.js";
22
+ import { SPINNER_FRAMES } from "./pipeline/activityFeed.js";
23
+ /** Bound on the retained action log so a chatty child cannot grow details unbounded. */
24
+ export const ACTION_LOG_MAX = 120;
25
+ /** How many of the newest actions the expanded view paints. */
26
+ const EXPANDED_ACTIONS_SHOWN = 30;
27
+ /** Collapsed report preview length, in lines. */
28
+ const REPORT_PREVIEW_LINES = 3;
29
+ /** Task preview width on the running title line. */
30
+ const TASK_PREVIEW_MAX = 64;
31
+ /** Spinner cadence; matches the /go feed's SPINNER_TICK_MS. */
32
+ const SPINNER_TICK_MS = 120;
33
+ const EMPTY_USAGE = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 };
34
+ export function newTaskProgress(agent, task, startedAt) {
35
+ return {
36
+ agent,
37
+ task,
38
+ status: "running",
39
+ exitCode: -1,
40
+ startedAt,
41
+ toolCalls: 0,
42
+ toolErrors: 0,
43
+ usage: { ...EMPTY_USAGE },
44
+ actions: [],
45
+ droppedActions: 0,
46
+ };
47
+ }
48
+ function pushAction(p, entry) {
49
+ if (p.actions.length >= ACTION_LOG_MAX) {
50
+ p.actions.shift();
51
+ p.droppedActions += 1;
52
+ }
53
+ p.actions.push(entry);
54
+ }
55
+ /** Last text part of an assistant message, or undefined. */
56
+ function lastMessageText(ev) {
57
+ const parts = ev.message?.content ?? [];
58
+ for (let i = parts.length - 1; i >= 0; i--) {
59
+ const part = parts[i];
60
+ if (part?.type === "text" && typeof part.text === "string")
61
+ return part.text;
62
+ }
63
+ return undefined;
64
+ }
65
+ /**
66
+ * Fold one child NDJSON event into the task's progress. Returns true when the
67
+ * record changed (the tool emits an update), false for events we drop.
68
+ */
69
+ export function applyChildEvent(p, ev) {
70
+ switch (ev.type) {
71
+ case "tool_execution_start": {
72
+ const entry = {
73
+ kind: "action",
74
+ text: toolLabel(ev.toolName ?? "", ev.args),
75
+ state: "running",
76
+ };
77
+ if (ev.toolCallId !== undefined)
78
+ entry.toolCallId = ev.toolCallId;
79
+ pushAction(p, entry);
80
+ return true;
81
+ }
82
+ case "tool_execution_end": {
83
+ p.toolCalls += 1;
84
+ if (ev.isError)
85
+ p.toolErrors += 1;
86
+ for (let i = p.actions.length - 1; i >= 0; i--) {
87
+ const a = p.actions[i];
88
+ if (a.kind !== "action" || a.state !== "running")
89
+ continue;
90
+ if (ev.toolCallId !== undefined && a.toolCallId !== ev.toolCallId)
91
+ continue;
92
+ a.state = ev.isError ? "error" : "done";
93
+ break;
94
+ }
95
+ return true;
96
+ }
97
+ case "message_end": {
98
+ const msg = ev.message;
99
+ if (msg?.role !== "assistant")
100
+ return false;
101
+ p.usage.turns += 1;
102
+ const u = msg.usage;
103
+ if (u) {
104
+ p.usage.input += u.input ?? 0;
105
+ p.usage.output += u.output ?? 0;
106
+ p.usage.cacheRead += u.cacheRead ?? 0;
107
+ p.usage.cacheWrite += u.cacheWrite ?? 0;
108
+ p.usage.cost += u.cost?.total ?? 0;
109
+ }
110
+ const text = lastMessageText(ev);
111
+ const headline = text !== undefined ? narrationHeadline(text) : null;
112
+ if (headline)
113
+ pushAction(p, { kind: "narration", text: headline, state: "done" });
114
+ return true;
115
+ }
116
+ default:
117
+ return false;
118
+ }
119
+ }
120
+ /** Stamp the runner's outcome onto the progress record. */
121
+ export function finalizeTask(p, result, endedAt) {
122
+ // An aborted child can close with exit 0 (SIGTERM close reports a null code),
123
+ // so the stop reason outranks the exit code for the status glyph.
124
+ p.status = result.exitCode === 0 && result.stopReason !== "aborted" ? "done" : "error";
125
+ p.exitCode = result.exitCode;
126
+ p.endedAt = endedAt;
127
+ p.usage = { ...result.usage };
128
+ p.toolCalls = result.toolCalls;
129
+ p.toolErrors = result.toolErrors;
130
+ p.report = result.finalOutput;
131
+ if (result.stopReason)
132
+ p.stopReason = result.stopReason;
133
+ if (result.errorMessage)
134
+ p.errorMessage = result.errorMessage;
135
+ }
136
+ // ── Formatting helpers ────────────────────────────────────────────────────────
137
+ /** Collapse whitespace and clip to `max`, appending an ellipsis when cut. */
138
+ function clip(text, max) {
139
+ const collapsed = text.replace(/\s+/g, " ").trim();
140
+ if (collapsed.length <= max)
141
+ return collapsed;
142
+ return `${collapsed.slice(0, max - 1)}…`;
143
+ }
144
+ function trimTrailingZero(v) {
145
+ const s = v.toFixed(1);
146
+ return s.endsWith(".0") ? s.slice(0, -2) : s;
147
+ }
148
+ /** 532 → "532", 41_234 → "41.2k", 1_240_000 → "1.2M". */
149
+ export function formatTokens(n) {
150
+ if (n < 1000)
151
+ return String(n);
152
+ if (n < 1_000_000)
153
+ return `${trimTrailingZero(n / 1000)}k`;
154
+ return `${trimTrailingZero(n / 1_000_000)}M`;
155
+ }
156
+ /** 42_000 → "42s", 81_000 → "1m 21s", 3_720_000 → "1h 2m". */
157
+ export function formatDuration(ms) {
158
+ const s = Math.max(0, Math.floor(ms / 1000));
159
+ if (s < 60)
160
+ return `${s}s`;
161
+ const m = Math.floor(s / 60);
162
+ if (m < 60)
163
+ return `${m}m ${s % 60}s`;
164
+ return `${Math.floor(m / 60)}h ${m % 60}m`;
165
+ }
166
+ function countToolUses(n) {
167
+ return n === 1 ? "1 tool use" : `${n} tool uses`;
168
+ }
169
+ /** Tokens the child actually generated/consumed (cache reads excluded). */
170
+ function taskTokens(p) {
171
+ return p.usage.input + p.usage.output;
172
+ }
173
+ /** The most recent still-running tool action, if any. */
174
+ function currentActionText(p) {
175
+ for (let i = p.actions.length - 1; i >= 0; i--) {
176
+ const a = p.actions[i];
177
+ if (a.kind === "action" && a.state === "running")
178
+ return a.text;
179
+ }
180
+ return undefined;
181
+ }
182
+ /** What the child is doing right now: current tool → tool-use count → starting. */
183
+ function liveDetailText(p) {
184
+ return currentActionText(p) ?? (p.toolCalls > 0 ? countToolUses(p.toolCalls) : "starting…");
185
+ }
186
+ /**
187
+ * The two-line live status for one running task: a stable `agent — task` title
188
+ * over the churning current-action line with elapsed time and live tokens.
189
+ */
190
+ export function runningLines(p, theme, now, frame) {
191
+ const title = `${frame} ${theme.fg("accent", p.agent)} — ${theme.fg("dim", clip(p.task, TASK_PREVIEW_MAX))}`;
192
+ let detail = ` ${theme.fg("muted", "↳")} ${theme.fg("toolOutput", liveDetailText(p))}`;
193
+ detail += theme.fg("dim", ` · ${formatDuration(now - p.startedAt)}`);
194
+ const tokens = taskTokens(p);
195
+ if (tokens > 0)
196
+ detail += theme.fg("dim", ` · ${formatTokens(tokens)} tokens`);
197
+ return [title, detail];
198
+ }
199
+ /** One-line completion receipt: `✓ agent · N tool uses · Xk tokens · Ys · $c`. */
200
+ export function receiptLine(p, theme) {
201
+ const parts = [
202
+ countToolUses(p.toolCalls),
203
+ `${formatTokens(taskTokens(p))} tokens`,
204
+ formatDuration((p.endedAt ?? p.startedAt) - p.startedAt),
205
+ ];
206
+ if (p.toolErrors > 0)
207
+ parts.push(`${p.toolErrors} tool ${p.toolErrors === 1 ? "error" : "errors"}`);
208
+ if (p.usage.cost > 0)
209
+ parts.push(`$${p.usage.cost.toFixed(2)}`);
210
+ const name = theme.bold(theme.fg("accent", p.agent));
211
+ if (p.status === "error") {
212
+ const reason = p.stopReason ?? (p.exitCode >= 0 ? `exit ${p.exitCode}` : "failed");
213
+ return `${theme.fg("error", "✗")} ${name} ${theme.fg("error", `failed (${reason})`)} ${theme.fg("dim", `· ${parts.join(" · ")}`)}`;
214
+ }
215
+ return `${theme.fg("success", "✓")} ${name} ${theme.fg("dim", `· ${parts.join(" · ")}`)}`;
216
+ }
217
+ /** Aggregate receipt across parallel tasks. */
218
+ function totalLine(tasks, theme) {
219
+ const toolCalls = tasks.reduce((n, p) => n + p.toolCalls, 0);
220
+ const tokens = tasks.reduce((n, p) => n + taskTokens(p), 0);
221
+ const cost = tasks.reduce((n, p) => n + p.usage.cost, 0);
222
+ const start = Math.min(...tasks.map((p) => p.startedAt));
223
+ const end = Math.max(...tasks.map((p) => p.endedAt ?? p.startedAt));
224
+ const parts = [countToolUses(toolCalls), `${formatTokens(tokens)} tokens`, formatDuration(end - start)];
225
+ if (cost > 0)
226
+ parts.push(`$${cost.toFixed(2)}`);
227
+ return theme.fg("dim", `${tasks.length} subagents · ${parts.join(" · ")}`);
228
+ }
229
+ /**
230
+ * Plain-text (no theme) summary for the partial result's `content`, so headless
231
+ * consumers and pi's fallback renderer still see live progress.
232
+ */
233
+ export function progressSummaryText(tasks, now) {
234
+ const plain = { bold: (s) => s, fg: (_c, s) => s };
235
+ return tasks
236
+ .map((p) => p.status === "running"
237
+ ? `${p.agent}: ${liveDetailText(p)} · ${formatDuration(now - p.startedAt)}`
238
+ : receiptLine(p, plain))
239
+ .join("\n");
240
+ }
241
+ /** The harness "Working…" replacement while subagents run. */
242
+ export function formatWorkingMessage(tasks, now) {
243
+ const toolCalls = tasks.reduce((n, p) => n + p.toolCalls, 0);
244
+ const started = Math.min(...tasks.map((p) => p.startedAt));
245
+ const elapsed = formatDuration(now - started);
246
+ if (tasks.length === 1) {
247
+ return `subagent ${tasks[0].agent} · ${countToolUses(toolCalls)} · ${elapsed}`;
248
+ }
249
+ const running = tasks.filter((p) => p.status === "running").length;
250
+ const label = running > 0 && running < tasks.length ? `${running}/${tasks.length} subagents` : `${tasks.length} subagents`;
251
+ return `${label} · ${countToolUses(toolCalls)} · ${elapsed}`;
252
+ }
253
+ /** Title painted the moment the call streams in (before any execution output). */
254
+ export function renderSubagentCall(args, theme, _context) {
255
+ const title = theme.fg("toolTitle", theme.bold("subagent"));
256
+ const a = args ?? {};
257
+ if (a.tasks && a.tasks.length > 0) {
258
+ let text = `${title} ${theme.fg("accent", `${a.tasks.length} parallel tasks`)}`;
259
+ for (const t of a.tasks) {
260
+ text += `\n ${theme.fg("accent", t.agent ?? "general")} ${theme.fg("dim", clip(t.task ?? "…", TASK_PREVIEW_MAX))}`;
261
+ }
262
+ return new Text(text, 0, 0);
263
+ }
264
+ let text = `${title} ${theme.fg("accent", a.agent ?? "general")}`;
265
+ if (a.task)
266
+ text += `\n ${theme.fg("dim", clip(a.task, 2 * TASK_PREVIEW_MAX))}`;
267
+ return new Text(text, 0, 0);
268
+ }
269
+ function fallbackText(result) {
270
+ const first = result.content.find((c) => c.type === "text" && typeof c.text === "string");
271
+ return new Text(first?.text ?? "(no output)", 0, 0);
272
+ }
273
+ function actionGlyph(a, theme) {
274
+ if (a.kind === "narration")
275
+ return theme.fg("muted", "·");
276
+ if (a.state === "error")
277
+ return theme.fg("error", "✗");
278
+ if (a.state === "running")
279
+ return theme.fg("muted", "→");
280
+ return theme.fg("muted", "→");
281
+ }
282
+ function actionLogLines(p, theme, shown) {
283
+ const lines = [];
284
+ const tail = p.actions.slice(-shown);
285
+ const skipped = p.droppedActions + (p.actions.length - tail.length);
286
+ if (skipped > 0)
287
+ lines.push(theme.fg("muted", ` … ${skipped} earlier actions`));
288
+ for (const a of tail) {
289
+ const color = a.kind === "narration" ? "dim" : a.state === "error" ? "error" : "toolOutput";
290
+ lines.push(` ${actionGlyph(a, theme)} ${theme.fg(color, clip(a.text, 110))}`);
291
+ }
292
+ return lines;
293
+ }
294
+ /**
295
+ * Fail-open degradation is still worth seeing: warn (not error — falling back to
296
+ * plain text is recoverable, nothing to page on) so an unexpected markdown
297
+ * failure is observable. Once per component, because the render path repaints on
298
+ * every frame and the "no TUI" case would otherwise flood the log.
299
+ */
300
+ function warnMarkdownFallback(phase, err) {
301
+ const message = err instanceof Error ? err.message : String(err);
302
+ try {
303
+ console.warn(JSON.stringify({
304
+ source: "yagni-subagent-render",
305
+ level: "warning",
306
+ message: "markdown rendering unavailable; falling back to plain text",
307
+ phase,
308
+ error: message,
309
+ }));
310
+ }
311
+ catch {
312
+ console.warn(`[yagni-subagent-render] markdown ${phase} failed: ${message}`);
313
+ }
314
+ }
315
+ /**
316
+ * A prose body as markdown when the TUI's markdown theme is available.
317
+ * `getMarkdownTheme()` hands back a lazy proxy that only throws when a style is
318
+ * first USED, so the fallback must wrap `render`, not construction — otherwise
319
+ * an uninitialized theme would blow up mid-paint and pi would silently degrade
320
+ * the whole row to the bare title bar.
321
+ */
322
+ export function markdownOrPlain(body, theme) {
323
+ const plain = new Text(theme.fg("toolOutput", body.trim()), 0, 0);
324
+ let markdown;
325
+ try {
326
+ markdown = new Markdown(body.trim(), 0, 0, getMarkdownTheme());
327
+ }
328
+ catch (err) {
329
+ warnMarkdownFallback("construct", err);
330
+ return plain;
331
+ }
332
+ let warned = false;
333
+ return {
334
+ render(width) {
335
+ try {
336
+ return markdown.render(width);
337
+ }
338
+ catch (err) {
339
+ if (!warned) {
340
+ warned = true;
341
+ warnMarkdownFallback("render", err);
342
+ }
343
+ return plain.render(width);
344
+ }
345
+ },
346
+ invalidate() {
347
+ markdown.invalidate?.();
348
+ plain.invalidate();
349
+ },
350
+ };
351
+ }
352
+ /**
353
+ * Result renderer: live two-line status per task while partial; receipts plus
354
+ * report preview when collapsed; action log plus full markdown report when
355
+ * expanded. Drives its own refresh while running via an unref'd interval on
356
+ * `context.state` (pi has no unmount hook — the final render clears it).
357
+ */
358
+ export function renderSubagentResult(result, options, theme, context) {
359
+ const details = result.details;
360
+ const tasks = details?.tasks;
361
+ const state = context.state ?? {};
362
+ if (options.isPartial) {
363
+ if (!state.timer) {
364
+ state.timer = setInterval(() => context.invalidate(), SPINNER_TICK_MS);
365
+ state.timer.unref?.();
366
+ }
367
+ }
368
+ else if (state.timer) {
369
+ clearInterval(state.timer);
370
+ delete state.timer;
371
+ }
372
+ if (!tasks || tasks.length === 0)
373
+ return fallbackText(result);
374
+ if (options.isPartial) {
375
+ const now = Date.now();
376
+ const frame = SPINNER_FRAMES[Math.floor(now / SPINNER_TICK_MS) % SPINNER_FRAMES.length];
377
+ const lines = [];
378
+ for (const p of tasks) {
379
+ if (lines.length > 0)
380
+ lines.push("");
381
+ if (p.status === "running")
382
+ lines.push(...runningLines(p, theme, now, frame));
383
+ else
384
+ lines.push(receiptLine(p, theme));
385
+ if (options.expanded)
386
+ lines.push(...actionLogLines(p, theme, EXPANDED_ACTIONS_SHOWN));
387
+ }
388
+ return new Text(lines.join("\n"), 0, 0);
389
+ }
390
+ if (options.expanded) {
391
+ const container = new Container();
392
+ tasks.forEach((p, i) => {
393
+ if (i > 0)
394
+ container.addChild(new Spacer(1));
395
+ container.addChild(new Text(receiptLine(p, theme), 0, 0));
396
+ container.addChild(new Text(theme.fg("dim", ` ${clip(p.task, 2 * TASK_PREVIEW_MAX)}`), 0, 0));
397
+ if (p.errorMessage)
398
+ container.addChild(new Text(theme.fg("error", ` ${clip(p.errorMessage, 200)}`), 0, 0));
399
+ const log = actionLogLines(p, theme, EXPANDED_ACTIONS_SHOWN);
400
+ if (log.length > 0)
401
+ container.addChild(new Text(log.join("\n"), 0, 0));
402
+ const report = (p.report ?? "").trim();
403
+ if (report) {
404
+ container.addChild(new Spacer(1));
405
+ container.addChild(markdownOrPlain(report, theme));
406
+ }
407
+ });
408
+ if (tasks.length > 1) {
409
+ container.addChild(new Spacer(1));
410
+ container.addChild(new Text(totalLine(tasks, theme), 0, 0));
411
+ }
412
+ return container;
413
+ }
414
+ // Collapsed, final: receipts + a short report preview per task.
415
+ const lines = [];
416
+ let truncated = false;
417
+ for (const p of tasks) {
418
+ if (lines.length > 0)
419
+ lines.push("");
420
+ lines.push(receiptLine(p, theme));
421
+ if (p.errorMessage)
422
+ lines.push(theme.fg("error", ` ${clip(p.errorMessage, 160)}`));
423
+ const report = (p.report ?? "").trim();
424
+ if (report) {
425
+ const reportLines = report.split("\n");
426
+ for (const l of reportLines.slice(0, REPORT_PREVIEW_LINES)) {
427
+ lines.push(theme.fg("toolOutput", ` ${l}`));
428
+ }
429
+ if (reportLines.length > REPORT_PREVIEW_LINES)
430
+ truncated = true;
431
+ }
432
+ if (p.actions.length > 0)
433
+ truncated = true;
434
+ }
435
+ if (tasks.length > 1)
436
+ lines.push("", totalLine(tasks, theme));
437
+ if (truncated)
438
+ lines.push(theme.fg("muted", " (ctrl+o to expand)"));
439
+ return new Text(lines.join("\n"), 0, 0);
440
+ }
441
+ //# sourceMappingURL=subagentRender.js.map
@@ -21,6 +21,7 @@ import { type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-cod
21
21
  import { Type } from "typebox";
22
22
  import { runStage } from "./pipeline/runner.js";
23
23
  import type { ModelTier, PipelineStage } from "./pipeline/types.js";
24
+ import { renderSubagentCall, renderSubagentResult } from "./subagentRender.js";
24
25
  export declare const SUBAGENT_TOOL_NAME = "subagent";
25
26
  export declare const GENERAL_AGENT_NAME = "general";
26
27
  export declare const MAX_PARALLEL_SUBAGENTS = 4;
@@ -105,6 +106,8 @@ export declare function makeSubagentTool(deps?: MakeSubagentToolDeps): {
105
106
  agent: Type.TOptional<Type.TString>;
106
107
  }>>>;
107
108
  }>;
109
+ renderCall: typeof renderSubagentCall;
110
+ renderResult: typeof renderSubagentResult;
108
111
  execute(_toolCallId: string, params: SubagentParams, signal?: AbortSignal, onUpdate?: (update: {
109
112
  content: Array<{
110
113
  type: "text";
@@ -125,13 +128,7 @@ export declare function makeSubagentTool(deps?: MakeSubagentToolDeps): {
125
128
  text: string;
126
129
  }[];
127
130
  details: {
128
- tasks: {
129
- agent: string;
130
- task: string;
131
- exitCode: number;
132
- usage: import("./pipeline/types.js").StageUsage;
133
- toolCalls: number;
134
- }[];
131
+ tasks: import("./subagentRender.js").SubagentTaskProgress[];
135
132
  };
136
133
  }>;
137
134
  };
@@ -24,6 +24,7 @@ import { parseFrontmatter } from "@earendil-works/pi-coding-agent";
24
24
  import { Type } from "typebox";
25
25
  import { sanitizeCallerSegment } from "./config.js";
26
26
  import { runStage } from "./pipeline/runner.js";
27
+ import { applyChildEvent, finalizeTask, formatWorkingMessage, newTaskProgress, progressSummaryText, renderSubagentCall, renderSubagentResult, } from "./subagentRender.js";
27
28
  /**
28
29
  * YAG-471 attribution: the `x-yagni-caller` prefix for a subagent invocation.
29
30
  * The sanitized agent name is capped so the WHOLE label (prefix + name) stays
@@ -75,6 +76,53 @@ const GENERAL_AGENT = {
75
76
  body: GENERAL_BODY,
76
77
  source: "builtin",
77
78
  };
79
+ const SEARCHER_BODY = `You are a repo scout. Your job is wide, mechanical reconnaissance:
80
+ find files, map structure, trace usages, and summarize what is there. You do
81
+ not write code and you do not run commands; you read and report.
82
+
83
+ You are grounded in how THIS company works: call ask_yagni before inferring a
84
+ convention, an ownership rule, or anything organization-specific.
85
+
86
+ Your final message is your report back to the driving agent, which has NOT
87
+ seen what you read. Make it compressed and complete: exact file paths, the
88
+ key excerpts, and a one-paragraph map of how the pieces relate. Say what you
89
+ did NOT find as plainly as what you found.`;
90
+ /** Wide search and repo mapping on the cheapest tier: read-only by
91
+ * construction, so a wrong answer costs a re-ask, never a bad edit. */
92
+ const SEARCHER_AGENT = {
93
+ name: "searcher",
94
+ description: "Fast repo reconnaissance: wide searches, structure mapping, usage tracing, " +
95
+ "summarizing files. Read-only. Use for any broad look-around you would " +
96
+ "otherwise do with a chain of grep/read calls.",
97
+ model: "efficient",
98
+ tools: ["read", "grep", "find", "ls", "ask_yagni"],
99
+ body: SEARCHER_BODY,
100
+ source: "builtin",
101
+ };
102
+ const IMPLEMENTER_BODY = `You are a mechanical implementer. You execute a
103
+ well-specified change: apply an edit across files, fix a failing test, rename
104
+ carefully, wire a defined seam. The judgment calls were made before you were
105
+ spawned; if the task turns out to require one, STOP and report the fork in
106
+ your final message instead of guessing.
107
+
108
+ You are grounded in how THIS company works: call ask_yagni before inferring a
109
+ convention, an ownership rule, or anything organization-specific.
110
+
111
+ Your final message is your report back to the driving agent, which has NOT
112
+ seen what you did. List every file you touched, what changed in each, the
113
+ commands you ran with their outcomes, and anything you deliberately left
114
+ undone.`;
115
+ /** Mechanical multi-file execution on the mid tier: the task arrives fully
116
+ * specified, so the premium tiers' judgment is not being paid for. */
117
+ const IMPLEMENTER_AGENT = {
118
+ name: "implementer",
119
+ description: "Mechanical execution of a fully-specified change: multi-file edits, " +
120
+ "test-fix grinds, careful renames. Spawn it with the decision already " +
121
+ "made; it stops and reports rather than improvising.",
122
+ model: "standard",
123
+ body: IMPLEMENTER_BODY,
124
+ source: "builtin",
125
+ };
78
126
  // Concrete tiers a subagent can actually run on. `balanced` is deliberately NOT
79
127
  // a member here even though it is a member of `ModelTier`: a subagent needs
80
128
  // ONE model for its whole run, and balanced is a session-level routing policy,
@@ -162,7 +210,7 @@ function loadAgentsFromDir(dir, source) {
162
210
  export function discoverSubagents(deps) {
163
211
  const home = deps.homeDir ?? homedir();
164
212
  const layers = [
165
- [GENERAL_AGENT],
213
+ [GENERAL_AGENT, SEARCHER_AGENT, IMPLEMENTER_AGENT],
166
214
  ...pluginAgentDirs(deps.env ?? process.env).map((dir) => loadAgentsFromDir(dir, "plugin")),
167
215
  loadAgentsFromDir(join(home, ".claude", "agents"), "user-claude"),
168
216
  loadAgentsFromDir(join(deps.cwd, ".pi", "agents"), "project-pi"),
@@ -229,6 +277,8 @@ export function makeSubagentTool(deps = {}) {
229
277
  "(list them with /agents); omit `agent` for the general-purpose one.",
230
278
  promptSnippet: "subagent: delegate a self-contained task (or parallel tasks) to a fresh-context agent; returns its report.",
231
279
  parameters,
280
+ renderCall: renderSubagentCall,
281
+ renderResult: renderSubagentResult,
232
282
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
233
283
  const fail = (text) => ({
234
284
  content: [{ type: "text", text }],
@@ -257,31 +307,56 @@ export function makeSubagentTool(deps = {}) {
257
307
  }
258
308
  resolved.push({ def, task: req.task });
259
309
  }
260
- onUpdate?.({
261
- content: [
262
- {
263
- type: "text",
264
- text: resolved.length === 1
265
- ? `Running ${resolved[0].def.name} subagent…`
266
- : `Running ${resolved.length} subagents in parallel…`,
310
+ // Live progress: one folded record per task rides every partial update's
311
+ // `details` (rendered by renderSubagentResult), the plain-text summary
312
+ // rides `content` (pi's fallback renderer and headless consumers), and
313
+ // the harness "Working…" line mirrors the aggregate while children run.
314
+ const progresses = resolved.map(({ def, task }) => newTaskProgress(def.name, task, Date.now()));
315
+ const ui = ctx?.hasUI ? ctx.ui : undefined;
316
+ let lastWorking;
317
+ const emit = () => {
318
+ const now = Date.now();
319
+ onUpdate?.({
320
+ content: [{ type: "text", text: progressSummaryText(progresses, now) }],
321
+ details: {
322
+ tasks: progresses.map((p) => ({ ...p, actions: [...p.actions], usage: { ...p.usage } })),
267
323
  },
268
- ],
269
- details: {},
270
- });
271
- const outcomes = await Promise.all(resolved.map(async ({ def, task }) => {
272
- const { stage, ctx: stageCtx } = buildSubagentStage(def, task);
273
- const result = await run(stage, stageCtx, {
274
- cwd,
275
- signal,
276
- personaBody: () => def.body,
277
- // YAG-471: attribute this child's completions to the specific
278
- // subagent, not the generic /go stage label the runner would
279
- // otherwise derive from stage.id ("implement", reused as the
280
- // synthetic subagent stage — see buildSubagentStage).
281
- callerLabel: `${SUBAGENT_CALLER_PREFIX}${sanitizeCallerSegment(def.name, 64 - SUBAGENT_CALLER_PREFIX.length)}`,
282
324
  });
283
- return { agent: def.name, task, result };
284
- }));
325
+ const working = formatWorkingMessage(progresses, now);
326
+ if (ui && working !== lastWorking) {
327
+ lastWorking = working;
328
+ ui.setWorkingMessage?.(working);
329
+ }
330
+ };
331
+ emit();
332
+ let outcomes;
333
+ try {
334
+ outcomes = await Promise.all(resolved.map(async ({ def, task }, index) => {
335
+ const progress = progresses[index];
336
+ const { stage, ctx: stageCtx } = buildSubagentStage(def, task);
337
+ const result = await run(stage, stageCtx, {
338
+ cwd,
339
+ signal,
340
+ personaBody: () => def.body,
341
+ // YAG-471: attribute this child's completions to the specific
342
+ // subagent, not the generic /go stage label the runner would
343
+ // otherwise derive from stage.id ("implement", reused as the
344
+ // synthetic subagent stage — see buildSubagentStage).
345
+ callerLabel: `${SUBAGENT_CALLER_PREFIX}${sanitizeCallerSegment(def.name, 64 - SUBAGENT_CALLER_PREFIX.length)}`,
346
+ onEvent: (ev) => {
347
+ if (applyChildEvent(progress, ev))
348
+ emit();
349
+ },
350
+ });
351
+ finalizeTask(progress, result, Date.now());
352
+ emit();
353
+ return { agent: def.name, task, result };
354
+ }));
355
+ }
356
+ finally {
357
+ // Restore the default "Working…" text whether we resolved or threw.
358
+ ui?.setWorkingMessage?.();
359
+ }
285
360
  const allFailed = outcomes.every((o) => o.result.exitCode !== 0);
286
361
  const sections = outcomes.map((o) => {
287
362
  const output = o.result.finalOutput.trim();
@@ -295,15 +370,10 @@ export function makeSubagentTool(deps = {}) {
295
370
  });
296
371
  return {
297
372
  content: [{ type: "text", text: sections.join("\n\n") }],
298
- details: {
299
- tasks: outcomes.map((o) => ({
300
- agent: o.agent,
301
- task: o.task,
302
- exitCode: o.result.exitCode,
303
- usage: o.result.usage,
304
- toolCalls: o.result.toolCalls,
305
- })),
306
- },
373
+ // The folded progress records ARE the final details: a superset of the
374
+ // old {agent, task, exitCode, usage, toolCalls} shape, plus the action
375
+ // log and report that renderSubagentResult paints.
376
+ details: { tasks: progresses },
307
377
  ...(allFailed ? { isError: true } : {}),
308
378
  };
309
379
  },