@gavin-hu/my-pi-agent 0.13.1 → 0.14.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 (42) hide show
  1. package/README.md +14 -11
  2. package/README.zh-CN.md +12 -12
  3. package/extensions/bytefly/README.md +140 -58
  4. package/extensions/bytefly/doctor/config.ts +4 -1
  5. package/extensions/bytefly/footer/price/config.ts +3 -2
  6. package/extensions/bytefly/header/component.ts +30 -8
  7. package/extensions/bytefly/header/config.ts +22 -0
  8. package/extensions/bytefly/header/register.ts +2 -1
  9. package/extensions/bytefly/index.ts +3 -3
  10. package/extensions/bytefly/notify/config.ts +1 -1
  11. package/extensions/bytefly/panel/commands.ts +1 -1
  12. package/extensions/bytefly/panel/format.ts +12 -19
  13. package/extensions/bytefly/panel/settings-runtime.ts +62 -18
  14. package/extensions/bytefly/panel/settings.ts +239 -43
  15. package/extensions/bytefly/panel/tui/settings-view.ts +49 -3
  16. package/extensions/bytefly/panel/tui.ts +87 -19
  17. package/extensions/bytefly/separator/component.ts +7 -6
  18. package/extensions/bytefly/separator/config.ts +15 -0
  19. package/extensions/bytefly/separator/format.ts +60 -6
  20. package/extensions/bytefly/separator/register.ts +68 -9
  21. package/extensions/bytefly/separator/types.ts +8 -0
  22. package/extensions/bytefly/working/config.ts +4 -5
  23. package/extensions/bytefly/working/format.ts +24 -7
  24. package/extensions/bytefly/working/register.ts +34 -3
  25. package/extensions/memory/README.md +2 -0
  26. package/extensions/plan/README.md +12 -0
  27. package/extensions/subagent/README.md +60 -5
  28. package/extensions/subagent/config.ts +58 -0
  29. package/extensions/subagent/index.ts +27 -1
  30. package/extensions/subagent/orchestrate.ts +16 -3
  31. package/extensions/subagent/render.ts +12 -0
  32. package/extensions/subagent/run.ts +100 -37
  33. package/extensions/subagent/transcripts.ts +202 -0
  34. package/extensions/subagent/types.ts +12 -0
  35. package/extensions/watch/README.md +2 -0
  36. package/extensions/wechat/README.md +2 -0
  37. package/lib/README.md +1 -1
  38. package/lib/config.ts +33 -9
  39. package/lib/format.ts +6 -0
  40. package/package.json +2 -1
  41. package/themes/bytefly-dark.json +2 -3
  42. package/themes/bytefly-light.json +2 -3
@@ -1,27 +1,28 @@
1
1
  /**
2
2
  * The transcript component that draws one separator entry.
3
3
  *
4
- * It is stateless: the line is derived from the stored turn number and the
5
- * width passed to `render`, so there is nothing to invalidate.
4
+ * It is stateless: the line is derived from the stored entry data and the width
5
+ * passed to `render`, so there is nothing to invalidate.
6
6
  */
7
7
 
8
8
  import type { Theme } from "@earendil-works/pi-coding-agent";
9
9
  import type { Component } from "@earendil-works/pi-tui";
10
10
  import { separatorLine } from "./format.ts";
11
+ import type { TurnSeparatorData } from "./types.ts";
11
12
 
12
- /** Renders one separator entry; `turn` comes from the stored entry data. */
13
+ /** Renders one separator entry; the data comes from the stored entry. */
13
14
  export class SeparatorComponent implements Component {
14
15
  constructor(
15
- private readonly turn: number,
16
+ private readonly data: TurnSeparatorData,
16
17
  private readonly theme: Theme,
17
18
  private readonly pad = 0,
18
19
  ) {}
19
20
 
20
21
  invalidate(): void {
21
- // Stateless: the line is derived from `turn` and the width passed to render.
22
+ // Stateless: the line is derived from the data and the width passed to render.
22
23
  }
23
24
 
24
25
  render(width: number): string[] {
25
- return separatorLine(this.turn, width, this.theme, this.pad);
26
+ return separatorLine(this.data, width, this.theme, this.pad);
26
27
  }
27
28
  }
@@ -22,6 +22,16 @@ export const CONFIG = {
22
22
  dashRole: "ruleStrong",
23
23
  /** Role for the label. */
24
24
  labelRole: "meta",
25
+ /** Role for a turn with a failed tool. */
26
+ failureRole: "bad",
27
+ /** Role for an aborted turn; escape is a warning, not an error. */
28
+ abortedRole: "warn",
29
+ /** Smallest run duration printed on the receipt; shorter turns keep the label quiet. */
30
+ minDurationMs: 1000,
31
+ /** Word appended when the run was aborted. */
32
+ abortedLabel: "aborted",
33
+ /** Glyph before a failed tool count. */
34
+ failureGlyph: GLYPHS.cross,
25
35
  } as const satisfies {
26
36
  customType: string;
27
37
  glyph: string;
@@ -29,4 +39,9 @@ export const CONFIG = {
29
39
  pad: number;
30
40
  dashRole: TuiRole;
31
41
  labelRole: TuiRole;
42
+ failureRole: TuiRole;
43
+ abortedRole: TuiRole;
44
+ minDurationMs: number;
45
+ abortedLabel: string;
46
+ failureGlyph: string;
32
47
  };
@@ -1,14 +1,67 @@
1
1
  /**
2
2
  * Pure formatting for bytefly's turn separator line.
3
3
  *
4
- * The centered `╌╌╌ turn N ╌╌╌` layout comes from the design system's
5
- * `centeredRule`, so this module only names the turn; the shared rule owns the
6
- * centering, the degradation, and the colors.
4
+ * The line is a receipt for the settled run: the turn number, then as much of
5
+ * the duration, the top-level tool count, and the outcome as the width can
6
+ * carry. The centered `╌╌╌ turn N ╌╌╌` layout comes from the design system's
7
+ * `centeredRule`, so this module only builds the label and picks its role; the
8
+ * shared rule owns the centering and the dashes.
7
9
  */
8
10
 
9
11
  import type { Theme } from "@earendil-works/pi-coding-agent";
12
+ import { visibleWidth } from "@earendil-works/pi-tui";
13
+ import { formatDuration, formatToolCount } from "../../../lib/format.ts";
14
+ import { SEPARATORS } from "../../../lib/tui/glyphs.ts";
10
15
  import { centeredRule } from "../../../lib/tui/rules.ts";
16
+ import type { TuiRole } from "../../../lib/tui/tokens.ts";
11
17
  import { CONFIG } from "./config.ts";
18
+ import type { TurnSeparatorData } from "./types.ts";
19
+
20
+ /** Whether the label and `centeredRule`'s two pads leave room for a dash. */
21
+ function fits(label: string, width: number): boolean {
22
+ return visibleWidth(label) + CONFIG.pad * 2 < width;
23
+ }
24
+
25
+ /**
26
+ * The widest receipt that fits, shed one field at a time.
27
+ *
28
+ * The tool count goes first, then the duration; the outcome stays beside the
29
+ * turn as long as it fits, because a failed or aborted run is the part the rule
30
+ * should not hide. The turn number itself is never dropped here — a terminal
31
+ * too narrow even for that falls back to `centeredRule`'s plain dashed rule.
32
+ */
33
+ export function receiptLabel(data: TurnSeparatorData, width: number): string {
34
+ const turn = `${CONFIG.labelPrefix} ${data.turn}`;
35
+ const duration =
36
+ data.durationMs !== undefined && data.durationMs >= CONFIG.minDurationMs
37
+ ? formatDuration(data.durationMs)
38
+ : undefined;
39
+ const tools = data.tools !== undefined && data.tools > 0 ? formatToolCount(data.tools) : undefined;
40
+ const outcome = data.aborted
41
+ ? CONFIG.abortedLabel
42
+ : data.failures !== undefined && data.failures > 0
43
+ ? `${CONFIG.failureGlyph}${data.failures}`
44
+ : undefined;
45
+
46
+ const steps: Array<Array<string | undefined>> = [
47
+ [turn, duration, tools, outcome],
48
+ [turn, duration, outcome],
49
+ [turn, outcome],
50
+ [turn],
51
+ ];
52
+ for (const parts of steps) {
53
+ const label = parts.filter((part): part is string => part !== undefined).join(SEPARATORS.item);
54
+ if (fits(label, width)) return label;
55
+ }
56
+ return turn;
57
+ }
58
+
59
+ /** The label's role: an aborted turn warns, a failed tool is an error. */
60
+ export function receiptRole(data: TurnSeparatorData): TuiRole {
61
+ if (data.aborted) return CONFIG.abortedRole;
62
+ if (data.failures !== undefined && data.failures > 0) return CONFIG.failureRole;
63
+ return CONFIG.labelRole;
64
+ }
12
65
 
13
66
  /**
14
67
  * One rendered line: the centered turn rule, colored by role.
@@ -17,14 +70,15 @@ import { CONFIG } from "./config.ts";
17
70
  * component raw, so the rule insets itself symmetrically by that many columns,
18
71
  * keeping its left edge on the transcript's margin.
19
72
  */
20
- export function separatorLine(turn: number, width: number, theme: Theme, pad = 0): string[] {
73
+ export function separatorLine(data: TurnSeparatorData, width: number, theme: Theme, pad = 0): string[] {
21
74
  const w = Math.max(1, Math.floor(width));
22
75
  const inset = Math.max(0, Math.min(Math.floor(pad), Math.floor((w - 1) / 2)));
23
- const line = centeredRule(theme, Math.max(1, w - inset * 2), `${CONFIG.labelPrefix} ${turn}`, {
76
+ const ruleWidth = Math.max(1, w - inset * 2);
77
+ const line = centeredRule(theme, ruleWidth, receiptLabel(data, ruleWidth), {
24
78
  glyph: CONFIG.glyph,
25
79
  pad: CONFIG.pad,
26
80
  fillRole: CONFIG.dashRole,
27
- labelRole: CONFIG.labelRole,
81
+ labelRole: receiptRole(data),
28
82
  });
29
83
  return [`${" ".repeat(inset)}${line}`];
30
84
  }
@@ -4,11 +4,15 @@
4
4
  * Pi has no built-in turn separator. On every fully settled agent run
5
5
  * (`agent_settled`, one per user turn) this appends an inert custom entry that
6
6
  * is excluded from LLM context, and a registered entry renderer draws it as a
7
- * centered `╌╌╌ turn N ╌╌╌` rule in the interactive transcript.
7
+ * centered `╌╌╌ turn N ╌╌╌` rule in the interactive transcript. The entry also
8
+ * carries the run's receipt — duration, top-level tool count, failed tools, and
9
+ * whether the user aborted it — so the rule can read as a flight log.
8
10
  *
9
11
  * `agent_settled` is deliberate: `turn_end` fires once per assistant message,
10
12
  * so tool-calling rounds would each get a line, while a settled run is exactly
11
- * one user turn.
13
+ * one user turn. The run's first `agent_start` opens the receipt and later
14
+ * starts (a retry or a continuation) stay in it, so the duration covers the
15
+ * whole settled run rather than one attempt.
12
16
  */
13
17
 
14
18
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
@@ -17,6 +21,12 @@ import { SeparatorComponent } from "./component.ts";
17
21
  import { CONFIG } from "./config.ts";
18
22
  import type { TurnSeparatorData } from "./types.ts";
19
23
 
24
+ /** Host seams, injected by tests so the duration is deterministic. */
25
+ export interface SeparatorDeps {
26
+ /** The clock the duration is read from. */
27
+ now?: () => number;
28
+ }
29
+
20
30
  /** Count separators already on the branch, so numbering survives resume and fork. */
21
31
  function countSeparators(entries: readonly { type: string; customType?: string }[]): number {
22
32
  let count = 0;
@@ -27,17 +37,66 @@ function countSeparators(entries: readonly { type: string; customType?: string }
27
37
  }
28
38
 
29
39
  /** Register the separator renderer and append one on every settled turn. */
30
- export function registerTurnSeparator(pi: ExtensionAPI, pad: PadSource = createPadStore()): void {
40
+ export function registerTurnSeparator(
41
+ pi: ExtensionAPI,
42
+ pad: PadSource = createPadStore(),
43
+ deps: SeparatorDeps = {},
44
+ ): void {
45
+ const now = deps.now ?? (() => Date.now());
46
+ // The current run's receipt. `startedAt` is the first `agent_start`; the
47
+ // counters span every top-level tool in the settled run.
48
+ let startedAt: number | undefined;
49
+ let tools = 0;
50
+ let failures = 0;
51
+
52
+ const reset = (): void => {
53
+ startedAt = undefined;
54
+ tools = 0;
55
+ failures = 0;
56
+ };
57
+
31
58
  pi.registerEntryRenderer<TurnSeparatorData>(CONFIG.customType, (entry, _options, theme) => {
32
- const turn = entry.data?.turn;
33
- if (typeof turn !== "number" || !Number.isFinite(turn)) return undefined;
34
- return new SeparatorComponent(turn, theme, pad.resolve());
59
+ const data = entry.data;
60
+ if (!data || typeof data.turn !== "number" || !Number.isFinite(data.turn)) return undefined;
61
+ return new SeparatorComponent(data, theme, pad.resolve());
62
+ });
63
+
64
+ pi.on("agent_start", (_event, ctx) => {
65
+ if (ctx.mode !== "tui" || startedAt !== undefined) return;
66
+ startedAt = now();
67
+ tools = 0;
68
+ failures = 0;
35
69
  });
36
70
 
37
- pi.on("agent_settled", (_event, ctx) => {
71
+ // A nested tool is an implementation detail of its parent, not a transcript
72
+ // block, so it neither counts nor contributes a failure.
73
+ pi.on("tool_execution_start", (event) => {
74
+ if (startedAt === undefined || event.parentToolCallId) return;
75
+ tools += 1;
76
+ });
77
+
78
+ pi.on("tool_execution_end", (event) => {
79
+ if (startedAt === undefined || event.parentToolCallId || !event.isError) return;
80
+ failures += 1;
81
+ });
82
+
83
+ pi.on("agent_settled", (event, ctx) => {
38
84
  // The transcript is interactive-only; keep RPC/JSON/print sessions clean.
39
- if (ctx.mode !== "tui") return;
85
+ if (ctx.mode !== "tui") {
86
+ reset();
87
+ return;
88
+ }
40
89
  const turn = countSeparators(ctx.sessionManager.getBranch()) + 1;
41
- pi.appendEntry<TurnSeparatorData>(CONFIG.customType, { turn });
90
+ const data: TurnSeparatorData = { turn };
91
+ if (startedAt !== undefined) {
92
+ data.durationMs = Math.max(0, now() - startedAt);
93
+ if (tools > 0) data.tools = tools;
94
+ if (failures > 0) data.failures = failures;
95
+ }
96
+ if (event.aborted) data.aborted = true;
97
+ pi.appendEntry<TurnSeparatorData>(CONFIG.customType, data);
98
+ reset();
42
99
  });
100
+
101
+ pi.on("session_shutdown", () => reset());
43
102
  }
@@ -9,4 +9,12 @@
9
9
  export interface TurnSeparatorData {
10
10
  /** 1-based index of the completed user turn this separator follows. */
11
11
  turn: number;
12
+ /** From the run's first `agent_start` to `agent_settled`, in milliseconds. */
13
+ durationMs?: number;
14
+ /** Top-level tool calls the run started. */
15
+ tools?: number;
16
+ /** Top-level tool calls that ended in error. */
17
+ failures?: number;
18
+ /** Whether the run ended because the user aborted it. */
19
+ aborted?: boolean;
12
20
  }
@@ -1,13 +1,12 @@
1
1
  /**
2
- * Tuning for bytefly's live working timer.
2
+ * Tuning for bytefly's live working line.
3
3
  *
4
- * While Pi's working row is up, the counter is written into the working message
5
- * Pi renders beside its spinner. Edit this file to change the word or how often
6
- * the count advances.
4
+ * While Pi's working row is up, the message is written into the row beside Pi's
5
+ * spinner. Edit this file to change the words or how often the count advances.
7
6
  */
8
7
 
9
8
  export const WORKING_CONFIG = {
10
- /** Word before the count. */
9
+ /** Word before the count, before the first tool starts. */
11
10
  working: "Working",
12
11
  /** Between the word and the count. */
13
12
  ellipsis: "…",
@@ -1,13 +1,24 @@
1
1
  /**
2
- * Pure text for the live working timer.
2
+ * Pure text for the live working line.
3
3
  *
4
- * Whole seconds, because the counter advances on a one-second tick: `0s` at the
5
- * start, `42s`, `1m05s`, `1h02m`. Negative and non-finite input reads as `0s`,
6
- * so a clock that steps backwards cannot print a negative count.
4
+ * Before the first tool starts it is a plain count (`Working… 42s`). After that
5
+ * it names the last top-level tool and how many this run has started, then the
6
+ * elapsed time (`edit · 3 tools · 42s`). Whole seconds, because the tick
7
+ * advances once a second; negative and non-finite input reads as `0s`.
7
8
  */
8
9
 
10
+ import { sanitizeLine, formatToolCount } from "../../../lib/format.ts";
11
+ import { SEPARATORS } from "../../../lib/tui/glyphs.ts";
9
12
  import { WORKING_CONFIG } from "./config.ts";
10
13
 
14
+ /** What the current run has done so far, for the live line. */
15
+ export interface WorkingRun {
16
+ /** The last top-level tool started, or `null` before the first one. */
17
+ readonly tool: string | null;
18
+ /** Top-level tool calls started in this run. */
19
+ readonly tools: number;
20
+ }
21
+
11
22
  /** Compact elapsed time in whole seconds: `0s`, `42s`, `1m05s`, `1h02m`. */
12
23
  export function formatElapsed(ms: number): string {
13
24
  const total = Number.isFinite(ms) && ms > 0 ? Math.floor(ms / 1000) : 0;
@@ -19,7 +30,13 @@ export function formatElapsed(ms: number): string {
19
30
  return `${seconds}s`;
20
31
  }
21
32
 
22
- /** The working-row message for an elapsed time, e.g. `Working… 4s`. */
23
- export function workingCounter(ms: number): string {
24
- return `${WORKING_CONFIG.working}${WORKING_CONFIG.ellipsis} ${formatElapsed(ms)}`;
33
+ /** The working-row message: `Working… 4s` until a tool starts, then the run line. */
34
+ export function workingCounter(ms: number, run?: WorkingRun): string {
35
+ const elapsed = formatElapsed(ms);
36
+ const tools = run?.tools ?? 0;
37
+ const tool = run?.tool ? sanitizeLine(run.tool) : "";
38
+ if (tools > 0) {
39
+ return [tool, formatToolCount(tools), elapsed].filter((part) => part !== "").join(SEPARATORS.item);
40
+ }
41
+ return `${WORKING_CONFIG.working}${WORKING_CONFIG.ellipsis} ${elapsed}`;
25
42
  }
@@ -1,9 +1,11 @@
1
1
  /**
2
- * bytefly's live working timer.
2
+ * bytefly's live working line.
3
3
  *
4
4
  * Pi's working row shows a static `Working`, so a long run reads the same as a
5
5
  * short one. This surface advances the message once a second while the row is
6
- * up, and hands it back to Pi when the row comes down.
6
+ * up, and hands it back to Pi when the row comes down. Once a top-level tool
7
+ * starts, the message also names that tool and how many this run has started,
8
+ * turning the row into a live run line (`edit · 3 tools · 12s`).
7
9
  *
8
10
  * The timer is tied to the row's visibility. The host shows the working
9
11
  * indicator in its own `turn_start` handler and clears it at `agent_end`
@@ -12,6 +14,11 @@
12
14
  * one run do not restart it: the host leaves the same indicator up across
13
15
  * turns, and `start` is a no-op while it is counting.
14
16
  *
17
+ * A nested tool call (one carrying `parentToolCallId`, such as a codemode
18
+ * script's inner call) is not counted and does not take over the line, so the
19
+ * number matches the tool blocks the transcript shows. The tool events repaint
20
+ * the row immediately; the one-second tick is still the only timer.
21
+ *
15
22
  * Compaction and auto-retry also replace the row, but both run outside this
16
23
  * window (`auto_retry_start` follows `agent_end`; the before-settle compaction
17
24
  * runs after it), and neither is an extension event, so there is nothing to
@@ -49,6 +56,17 @@ export function registerWorkingTimer(pi: ExtensionAPI, deps: WorkingDeps = {}):
49
56
  let startedAt = 0;
50
57
  // A boolean, not `startedAt === 0`: an injected clock is allowed to start at 0.
51
58
  let active = false;
59
+ // The last top-level tool and how many this run has started. Reset with the run.
60
+ let tool: string | null = null;
61
+ let tools = 0;
62
+
63
+ /** The current row text, from the run state and the elapsed time. */
64
+ const message = (): string => workingCounter(now() - startedAt, { tool, tools });
65
+
66
+ /** Repaint an active row now, so a tool start does not wait for the next tick. */
67
+ const refresh = (ctx: ExtensionContext): void => {
68
+ if (active) ctx.ui.setWorkingMessage(message());
69
+ };
52
70
 
53
71
  /** Stop the tick and hand the working message back to Pi. Idempotent. */
54
72
  const stop = (ctx: ExtensionContext): void => {
@@ -56,6 +74,8 @@ export function registerWorkingTimer(pi: ExtensionAPI, deps: WorkingDeps = {}):
56
74
  cancel = undefined;
57
75
  if (!active) return;
58
76
  active = false;
77
+ tool = null;
78
+ tools = 0;
59
79
  ctx.ui.setWorkingMessage(undefined);
60
80
  };
61
81
 
@@ -64,8 +84,10 @@ export function registerWorkingTimer(pi: ExtensionAPI, deps: WorkingDeps = {}):
64
84
  if (active) return;
65
85
  startedAt = now();
66
86
  active = true;
87
+ tool = null;
88
+ tools = 0;
67
89
  const tick = (): void => {
68
- ctx.ui.setWorkingMessage(workingCounter(now() - startedAt));
90
+ ctx.ui.setWorkingMessage(message());
69
91
  };
70
92
  tick();
71
93
  cancel = schedule(tick, WORKING_CONFIG.intervalMs);
@@ -78,6 +100,15 @@ export function registerWorkingTimer(pi: ExtensionAPI, deps: WorkingDeps = {}):
78
100
  start(ctx);
79
101
  });
80
102
 
103
+ // A nested tool is an implementation detail of its parent run, not a
104
+ // transcript block, so it neither counts nor takes over the line.
105
+ pi.on("tool_execution_start", (event, ctx) => {
106
+ if (ctx.mode !== "tui" || !active || event.parentToolCallId) return;
107
+ tool = event.toolName;
108
+ tools += 1;
109
+ refresh(ctx);
110
+ });
111
+
81
112
  // The host clears the working indicator at `agent_end`.
82
113
  pi.on("agent_end", (_event, ctx) => {
83
114
  if (ctx.mode !== "tui") return;
@@ -7,6 +7,8 @@ live as human-editable markdown, not in the session branch, so they survive
7
7
  `AGENTS.md`; `memory` is for the one-liners the model should not have to be
8
8
  reminded of twice.
9
9
 
10
+ <img src="../../assets/memory.png" alt="The /memory picker: a Memory title rule, an `All · 3 project, 2 global` summary, five notes tagged by scope with the focused one marked, and the key hints" width="100%">
11
+
10
12
  ```bash
11
13
  pi --extension ./extensions/memory # load just this extension
12
14
  pi -e . # load the whole @gavin-hu/my-pi-agent package
@@ -311,6 +311,18 @@ agent file that overrides it.
311
311
  - `readOnly` is also a normal `subagent` parameter; outside plan mode a caller
312
312
  may set it to run any agent read-only on purpose.
313
313
 
314
+ ## Review screen
315
+
316
+ <img src="../../assets/plan.png" alt="The terminal plan review: a Plan Review title rule, a notice that one section changed since the last review, the plan body with its headings and ◼/◻ task checkboxes, a scroll gauge naming the current section and the visible line range, and the Esc/a/r/w, scroll, and section-jump hints" width="100%">
317
+
318
+ `exit_plan_mode` opens this screen on the plan file it just wrote. The body is
319
+ Pi's Markdown renderer with the active theme, so headings, lists, code, and
320
+ links read as they do in the transcript; the checkboxes in `Steps` use the
321
+ shared `◼`/`◻` vocabulary. A plan taller than the terminal scrolls under a gauge
322
+ that names the section at the top of the viewport and the line range it shows,
323
+ and the footer lists only the keys that apply. `Esc` keeps planning, `a` approves
324
+ and runs, `r` refines inline, and `w` opens the browser review below.
325
+
314
326
  ## Browser review
315
327
 
316
328
  <img src="../../assets/plan-review.png" alt="The browser-based plan review: a sticky header with the plan title, a status chip, and the Approve & run, Save feedback, and Back to Pi actions, a side panel with step progress and a section outline, and the plan as one measured column with a comment rail and an inline review thread" width="100%">
@@ -29,6 +29,10 @@ How the `subagent` call and its result look in the transcript — the call line
29
29
  - **Streaming.** Tool calls and progress update live in the transcript while the
30
30
  subagent runs; the `app.tools.expand` key (`ctrl+o` by default) expands the
31
31
  full task, tool calls, Markdown output, and usage.
32
+ - **Bounded runs.** A per-run deadline kills a child that hangs, and reports it
33
+ as a failed run instead of rejecting the whole call.
34
+ - **Optional transcripts.** Opt-in per-run stream under `.pi/subagents/`,
35
+ written by the parent and pruned by age and count.
32
36
  - **Inherited model.** Subagents use the dispatching session's model and thinking
33
37
  level unless an agent overrides them (none do).
34
38
 
@@ -88,6 +92,42 @@ at most 8 steps (like the parallel task cap).
88
92
  }
89
93
  ```
90
94
 
95
+ ## Configuration
96
+
97
+ Read from the shared `bytefly.json` section `subagent` (global then project),
98
+ once per session. Keys and bounds live in the
99
+ [configuration reference](../../docs/references/bytefly-config.md#subagent--delegated-runs);
100
+ the behaviour is here.
101
+
102
+ - **`timeoutMs`** (default ten minutes, `0` disables) bounds one child run. The
103
+ limit is per run, not per call, so a chain is bounded step by step and a
104
+ parallel batch bounds each task separately. On expiry the child gets the same
105
+ `SIGTERM`, then `SIGKILL` after 5 s, as a user abort — but the run fails
106
+ instead of throwing: a chain stops at that step, a parallel call counts a
107
+ failure, and the model sees `Timed out after 10m00s`.
108
+ - **`transcripts`** (`off`, `failed`, or `all`; default `off`) keeps finished
109
+ runs on disk, all of them or only the failed ones.
110
+ - **`retentionDays`** (default 14) and **`maxRuns`** (default 100) bound the
111
+ store. It is pruned at `session_start`, and only while transcripts are on.
112
+
113
+ ### Transcripts
114
+
115
+ ```
116
+ .pi/subagents/20261015-143012-a1b2c3/
117
+ meta.json agent, task, cwd, model, timings, exit, status, usage
118
+ events.jsonl the child's raw stdout lines, verbatim
119
+ stderr.log only when the child wrote any
120
+ ```
121
+
122
+ The store is resolved against the **session** cwd, so inside a worktree it lands
123
+ in the main checkout's `.pi/` and outlives that worktree — the anchoring the
124
+ `job` registry uses. Outside a repository it falls back to
125
+ `<agent-dir>/subagents`. The parent writes it and the child never learns it
126
+ exists, so `readOnly`, the `--tools` allowlist, and the project-agent trust gate
127
+ are unchanged. Writing is best-effort: an unwritable checkout records the failure
128
+ in the result and never fails a run. The stream is buffered in the parent up to
129
+ 32 MiB, then truncated with a marker rather than silently dropped.
130
+
91
131
  ## Security
92
132
 
93
133
  A subagent is a real `pi` process and shares Pi's OS permissions. The built-in
@@ -206,7 +246,10 @@ Markdown, and per-step/per-task usage with a total.
206
246
 
207
247
  A failed or aborted result always shows its `errorMessage`, falling back to the
208
248
  subprocess `stderr`, in both the collapsed and expanded views, in every mode. A
209
- failure with neither falls back to `Error: Subprocess exited with code N.` and an
249
+ run that exceeded its deadline reports `Timed out after …` the same way, and the
250
+ expanded view adds the transcript path, the truncation marker, or why the
251
+ transcript could not be written. A failure with neither falls back to
252
+ `Error: Subprocess exited with code N.` and an
210
253
  `[exit N]` bracket, so a non-zero exit is never silent. A child terminated by a
211
254
  signal (an OOM kill, a crash) has no exit code, so it is recorded as
212
255
  `Terminated by <signal>` and reported as a failure. Parallel and chain
@@ -232,8 +275,10 @@ step's `Output`.
232
275
  does not reimplement tool execution or model streaming.
233
276
  - **No pinned models.** Built-in agents set no `model`, so a subprocess inherits
234
277
  the dispatching session's provider/model and thinking level.
235
- - **No persistence.** Subagent transcripts live only in the tool result details;
236
- they are not written to the parent session.
278
+ - **No persistence by default.** A run's transcript lives in the tool result
279
+ details; keeping it on disk is opt-in, parent-written, and bounded by age and
280
+ count. It is an audit and handoff record, not a resume point: replaying a run
281
+ whose writes already landed is not attempted.
237
282
  - **No cost attribution to the parent session.** A subagent's tokens and cost are
238
283
  captured in the tool result and shown in the transcript, but they belong to a
239
284
  separate process and are not added to the dispatching session's totals. Instead,
@@ -253,8 +298,8 @@ step's `Output`.
253
298
  | Annotations | `readOnlyHint: false`, `openWorldHint: true` |
254
299
  | Output schema | none; the full result lives in tool-result `details` |
255
300
  | Execution | spawns `pi --mode json` subprocesses; external agents discovered from `<agent-dir>/agents` and `.pi/agents` |
256
- | State storage | tool-result `details` only (no session persistence) |
257
- | Lifecycle | none; the tool is stateless and spawns per call |
301
+ | State storage | tool-result `details`; optional per-run transcripts under `.pi/subagents/`, never the session |
302
+ | Lifecycle | `session_start` loads the config and prunes expired transcripts; the tool itself is stateless |
258
303
 
259
304
  ## Design notes
260
305
 
@@ -284,6 +329,14 @@ step's `Output`.
284
329
  results arrive as `message_end` (messages, usage, model, stop reason) and
285
330
  failures as `tool_execution_end`. A tool error does not by itself fail the run,
286
331
  because the agent may recover.
332
+ - **A deadline reuses the abort kill path but not its outcome.** One kill
333
+ closure serves both triggers, so the SIGTERM → 5 s SIGKILL escalation is
334
+ identical; only the reporting differs, because a deadline must leave the
335
+ finished steps in `details` while a user abort rejects the call.
336
+ - **The transcript is the parent's.** Teeing the child's stdout and stderr in
337
+ `spawnAndCollect` and flushing in `runSingleAgent`'s `finally` means the
338
+ artifact exists even on the abort path, and the child needs no tool, no prompt
339
+ line, and no writable store.
287
340
  - **Bounded parallel output.** Parallel mode runs at most 4 of up to 8 tasks at
288
341
  once and caps each model-facing section at 50 KiB, appending an omission notice
289
342
  pointing at the tool details. The full results stay in `details` for rendering.
@@ -318,6 +371,8 @@ step's `Output`.
318
371
  | `schema.ts` | TypeBox params, limits, and `resolveMode`. |
319
372
  | `invocation.ts` | `pi` subprocess invocation and argument construction. |
320
373
  | `stream.ts` | JSON-event parsing, usage accounting, output helpers (pure). |
374
+ | `transcripts.ts` | Per-run transcript buffer, run ids, the store directory, and pruning. |
375
+ | `config.ts` | The `subagent` section of `bytefly.json`: deadline, transcripts, retention. |
321
376
  | `run.ts` | Runs one subagent process and captures its result. |
322
377
  | `format.ts` | Token/usage/tool-call formatting (pure). |
323
378
  | `render.ts` | `renderCall` / `renderResult` transcript rendering. |
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Config-file support for subagent runs.
3
+ *
4
+ * The `subagent` section of the shared `bytefly.json` (global then project,
5
+ * project wins). Only the container is configurable — a run's deadline and
6
+ * whether it keeps a transcript on disk — never an agent's prompt or tools.
7
+ */
8
+
9
+ import { clampInteger, loadConfigSection } from "../../lib/config.ts";
10
+
11
+ /** Which finished runs keep a transcript on disk. */
12
+ export const TRANSCRIPT_MODES = ["off", "failed", "all"] as const;
13
+
14
+ /** One of {@link TRANSCRIPT_MODES}. */
15
+ export type TranscriptMode = (typeof TRANSCRIPT_MODES)[number];
16
+
17
+ export interface SubagentConfig {
18
+ /** Wall-clock limit for one child run in ms; `0` disables it. */
19
+ timeoutMs: number;
20
+ /** Which runs write a transcript under `.pi/subagents/`. */
21
+ transcripts: TranscriptMode;
22
+ /** Age in days after which a run directory is pruned at session start. */
23
+ retentionDays: number;
24
+ /** Most run directories kept, oldest pruned first. */
25
+ maxRuns: number;
26
+ }
27
+
28
+ export const DEFAULT_SUBAGENT_CONFIG: SubagentConfig = {
29
+ timeoutMs: 600_000,
30
+ transcripts: "off",
31
+ retentionDays: 14,
32
+ maxRuns: 100,
33
+ };
34
+
35
+ /** An unknown value read as one of {@link TRANSCRIPT_MODES}, or `fallback`. */
36
+ function asTranscriptMode(value: unknown, fallback: TranscriptMode): TranscriptMode {
37
+ if (typeof value !== "string") return fallback;
38
+ return TRANSCRIPT_MODES.find((mode) => mode === value) ?? fallback;
39
+ }
40
+
41
+ /** Validate a raw config object over `base`. */
42
+ export function normalizeSubagentConfig(
43
+ raw: Record<string, unknown> | undefined,
44
+ base: SubagentConfig,
45
+ ): SubagentConfig {
46
+ if (!raw) return base;
47
+ return {
48
+ timeoutMs: clampInteger(raw.timeoutMs, base.timeoutMs, 0, 3_600_000),
49
+ transcripts: asTranscriptMode(raw.transcripts, base.transcripts),
50
+ retentionDays: clampInteger(raw.retentionDays, base.retentionDays, 1, 3650),
51
+ maxRuns: clampInteger(raw.maxRuns, base.maxRuns, 1, 1000),
52
+ };
53
+ }
54
+
55
+ /** Effective subagent config from the shared `subagent` section. */
56
+ export function loadSubagentConfig(cwd: string): SubagentConfig {
57
+ return loadConfigSection(cwd, "subagent", DEFAULT_SUBAGENT_CONFIG, normalizeSubagentConfig);
58
+ }