@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.
- package/README.md +14 -11
- package/README.zh-CN.md +12 -12
- package/extensions/bytefly/README.md +140 -58
- package/extensions/bytefly/doctor/config.ts +4 -1
- package/extensions/bytefly/footer/price/config.ts +3 -2
- package/extensions/bytefly/header/component.ts +30 -8
- package/extensions/bytefly/header/config.ts +22 -0
- package/extensions/bytefly/header/register.ts +2 -1
- package/extensions/bytefly/index.ts +3 -3
- package/extensions/bytefly/notify/config.ts +1 -1
- package/extensions/bytefly/panel/commands.ts +1 -1
- package/extensions/bytefly/panel/format.ts +12 -19
- package/extensions/bytefly/panel/settings-runtime.ts +62 -18
- package/extensions/bytefly/panel/settings.ts +239 -43
- package/extensions/bytefly/panel/tui/settings-view.ts +49 -3
- package/extensions/bytefly/panel/tui.ts +87 -19
- package/extensions/bytefly/separator/component.ts +7 -6
- package/extensions/bytefly/separator/config.ts +15 -0
- package/extensions/bytefly/separator/format.ts +60 -6
- package/extensions/bytefly/separator/register.ts +68 -9
- package/extensions/bytefly/separator/types.ts +8 -0
- package/extensions/bytefly/working/config.ts +4 -5
- package/extensions/bytefly/working/format.ts +24 -7
- package/extensions/bytefly/working/register.ts +34 -3
- package/extensions/memory/README.md +2 -0
- package/extensions/plan/README.md +12 -0
- package/extensions/subagent/README.md +60 -5
- package/extensions/subagent/config.ts +58 -0
- package/extensions/subagent/index.ts +27 -1
- package/extensions/subagent/orchestrate.ts +16 -3
- package/extensions/subagent/render.ts +12 -0
- package/extensions/subagent/run.ts +100 -37
- package/extensions/subagent/transcripts.ts +202 -0
- package/extensions/subagent/types.ts +12 -0
- package/extensions/watch/README.md +2 -0
- package/extensions/wechat/README.md +2 -0
- package/lib/README.md +1 -1
- package/lib/config.ts +33 -9
- package/lib/format.ts +6 -0
- package/package.json +2 -1
- package/themes/bytefly-dark.json +2 -3
- 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
|
|
5
|
-
*
|
|
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;
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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(
|
|
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
|
|
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:
|
|
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(
|
|
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
|
|
33
|
-
if (typeof turn !== "number" || !Number.isFinite(turn)) return undefined;
|
|
34
|
-
return new SeparatorComponent(
|
|
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
|
-
|
|
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")
|
|
85
|
+
if (ctx.mode !== "tui") {
|
|
86
|
+
reset();
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
40
89
|
const turn = countSeparators(ctx.sessionManager.getBranch()) + 1;
|
|
41
|
-
|
|
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
|
|
2
|
+
* Tuning for bytefly's live working line.
|
|
3
3
|
*
|
|
4
|
-
* While Pi's working row is up, the
|
|
5
|
-
*
|
|
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
|
|
2
|
+
* Pure text for the live working line.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
23
|
-
export function workingCounter(ms: number): string {
|
|
24
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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.**
|
|
236
|
-
|
|
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
|
|
257
|
-
| Lifecycle |
|
|
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
|
+
}
|