@gavin-hu/my-pi-agent 0.13.0 → 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 (64) hide show
  1. package/README.md +14 -11
  2. package/README.zh-CN.md +12 -12
  3. package/extensions/bytefly/README.md +163 -61
  4. package/extensions/bytefly/doctor/config.ts +4 -1
  5. package/extensions/bytefly/doctor/runtime.ts +2 -0
  6. package/extensions/bytefly/footer/budget.ts +64 -0
  7. package/extensions/bytefly/footer/collect/snapshot.ts +22 -4
  8. package/extensions/bytefly/footer/price/config.ts +18 -1
  9. package/extensions/bytefly/footer/price/types.ts +5 -0
  10. package/extensions/bytefly/footer/register.ts +34 -3
  11. package/extensions/bytefly/header/component.ts +30 -8
  12. package/extensions/bytefly/header/config.ts +22 -0
  13. package/extensions/bytefly/header/register.ts +2 -1
  14. package/extensions/bytefly/index.ts +7 -5
  15. package/extensions/bytefly/notify/config.ts +1 -1
  16. package/extensions/bytefly/notify/register.ts +15 -1
  17. package/extensions/bytefly/panel/commands.ts +1 -1
  18. package/extensions/bytefly/panel/format.ts +12 -19
  19. package/extensions/bytefly/panel/settings-runtime.ts +62 -18
  20. package/extensions/bytefly/panel/settings.ts +269 -42
  21. package/extensions/bytefly/panel/tui/settings-view.ts +49 -3
  22. package/extensions/bytefly/panel/tui.ts +87 -19
  23. package/extensions/bytefly/separator/component.ts +7 -6
  24. package/extensions/bytefly/separator/config.ts +15 -0
  25. package/extensions/bytefly/separator/format.ts +60 -6
  26. package/extensions/bytefly/separator/register.ts +68 -9
  27. package/extensions/bytefly/separator/types.ts +8 -0
  28. package/extensions/bytefly/working/config.ts +4 -5
  29. package/extensions/bytefly/working/format.ts +24 -7
  30. package/extensions/bytefly/working/register.ts +34 -3
  31. package/extensions/job/format.ts +4 -7
  32. package/extensions/memory/README.md +2 -0
  33. package/extensions/plan/README.md +12 -0
  34. package/extensions/subagent/README.md +60 -5
  35. package/extensions/subagent/config.ts +58 -0
  36. package/extensions/subagent/index.ts +27 -1
  37. package/extensions/subagent/orchestrate.ts +16 -3
  38. package/extensions/subagent/render.ts +12 -0
  39. package/extensions/subagent/run.ts +100 -37
  40. package/extensions/subagent/transcripts.ts +202 -0
  41. package/extensions/subagent/types.ts +12 -0
  42. package/extensions/todo/nudge.ts +5 -1
  43. package/extensions/watch/README.md +13 -5
  44. package/extensions/watch/commands.ts +4 -3
  45. package/extensions/watch/config.ts +1 -1
  46. package/extensions/watch/format.ts +4 -7
  47. package/extensions/watch/runtime.ts +83 -12
  48. package/extensions/wechat/README.md +2 -0
  49. package/extensions/worktree/README.md +7 -1
  50. package/extensions/worktree/enter.ts +10 -3
  51. package/extensions/worktree/exit.ts +25 -5
  52. package/extensions/worktree/git.ts +28 -0
  53. package/extensions/worktree/index.ts +2 -0
  54. package/extensions/worktree/root-tools.ts +1 -1
  55. package/extensions/worktree/runtime.ts +5 -0
  56. package/lib/README.md +5 -3
  57. package/lib/config.ts +33 -9
  58. package/lib/format.ts +6 -0
  59. package/lib/requirements.ts +80 -1
  60. package/lib/tui/columns.ts +17 -0
  61. package/lib/tui/tool.ts +6 -2
  62. package/package.json +2 -1
  63. package/themes/bytefly-dark.json +2 -3
  64. package/themes/bytefly-light.json +2 -3
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The session spend guardrail: one warning when the session's cost reaches the
3
+ * configured budget.
4
+ *
5
+ * A budget is an alert, not a limit. Nothing is blocked or aborted — a hard stop
6
+ * loses work, and the footer already shows the running cost — so this decides only
7
+ * whether the threshold has been crossed and what the one notice says.
8
+ *
9
+ * Pure: the caller has the priced spend and the currency, this shapes the answer.
10
+ * The amount formatting reuses the footer's own `formatCost`, so the notice and the
11
+ * status bar cannot disagree about precision or about a sub-minor-unit amount.
12
+ */
13
+
14
+ import { noticeMessage, type NoticeMessage } from "../../../lib/notice.ts";
15
+ import { formatCost } from "./render/format.ts";
16
+ import type { Currency } from "./price/types.ts";
17
+
18
+ /** Where the session stands against its budget. */
19
+ export interface BudgetState {
20
+ /** The configured budget, in `currency`; `0` means the guardrail is off. */
21
+ limit: number;
22
+ /** What the session has spent so far, in `currency`. */
23
+ spent: number;
24
+ /** Whether the budget is on and has been reached. Inclusive: exactly `limit` counts. */
25
+ exceeded: boolean;
26
+ }
27
+
28
+ /** The session's position against its budget. */
29
+ export function budgetState(limit: number, spent: number): BudgetState {
30
+ const safeLimit = Number.isFinite(limit) && limit > 0 ? limit : 0;
31
+ const safeSpent = Number.isFinite(spent) && spent > 0 ? spent : 0;
32
+ return { limit: safeLimit, spent: safeSpent, exceeded: safeLimit > 0 && safeSpent >= safeLimit };
33
+ }
34
+
35
+ /**
36
+ * The one notice a crossing posts.
37
+ *
38
+ * `warn` rather than `bad`: nothing failed, and nothing will be stopped. The
39
+ * content names the figures so the model reads the same sentence the user does,
40
+ * and says plainly that the session keeps running.
41
+ */
42
+ export function budgetNotice(state: BudgetState, currency: Currency): NoticeMessage {
43
+ const spent = formatCost(state.spent, currency);
44
+ const limit = formatCost(state.limit, currency);
45
+ return noticeMessage({
46
+ content:
47
+ `Session spend reached the configured budget: ${spent} of ${limit}. ` +
48
+ "This is a warning only — the session continues; raise or clear `price.budget` to silence it.",
49
+ details: {
50
+ source: "bytefly",
51
+ title: "Session budget reached",
52
+ severity: "warn",
53
+ items: [
54
+ { label: "spent", detail: spent },
55
+ { label: "budget", detail: limit },
56
+ ],
57
+ },
58
+ });
59
+ }
60
+
61
+ /** The one-line body an OS notification carries. */
62
+ export function budgetBody(state: BudgetState, currency: Currency): string {
63
+ return `Session budget reached · ${formatCost(state.spent, currency)} of ${formatCost(state.limit, currency)}`;
64
+ }
@@ -17,7 +17,7 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
17
17
  import type { DelegationLedger } from "../delegation.ts";
18
18
  import { createPricer } from "../price/price.ts";
19
19
  import type { ByteflyConfig } from "../price/types.ts";
20
- import type { ContextInfo, FooterData, StatusSnapshot } from "../types.ts";
20
+ import type { ContextInfo, FooterData, StatusSnapshot, UsageTotals } from "../types.ts";
21
21
  import { emptyUsage, usageRecords } from "./entries.ts";
22
22
 
23
23
  /**
@@ -28,7 +28,20 @@ import { emptyUsage, usageRecords } from "./entries.ts";
28
28
  * are stale; this avoids copying the full entry list and rebuilding the session
29
29
  * projection per frame. Pi's own footer memoizes its stats on the same idea.
30
30
  */
31
- export function createSnapshotReader(config: ByteflyConfig, delegation: DelegationLedger) {
31
+ export interface SnapshotReader {
32
+ /** Read a snapshot, re-pricing only when the session moved. */
33
+ (ctx: ExtensionContext, footerData: FooterData): StatusSnapshot;
34
+ /**
35
+ * The usage totals from the last read, or undefined before the first frame.
36
+ *
37
+ * The budget guardrail reads the number the footer already priced instead of
38
+ * walking the session a second time; if no frame has rendered yet there is
39
+ * nothing stale to act on.
40
+ */
41
+ peek(): UsageTotals | undefined;
42
+ }
43
+
44
+ export function createSnapshotReader(config: ByteflyConfig, delegation: DelegationLedger): SnapshotReader {
32
45
  const price = createPricer(config);
33
46
  let key: string | null = null;
34
47
  let usage = emptyUsage();
@@ -36,7 +49,9 @@ export function createSnapshotReader(config: ByteflyConfig, delegation: Delegati
36
49
  let cacheHitRate: number | null = null;
37
50
  let context: ContextInfo = { tokens: null, contextWindow: 0, percent: null };
38
51
 
39
- return (ctx: ExtensionContext, footerData: FooterData): StatusSnapshot => {
52
+ let last: UsageTotals | undefined;
53
+
54
+ const reader = ((ctx: ExtensionContext, footerData: FooterData): StatusSnapshot => {
40
55
  const session = ctx.sessionManager;
41
56
  const current = ctx.model;
42
57
  const nextKey = `${session.getSessionId()}:${session.getLeafId() ?? ""}:${current?.provider ?? ""}/${current?.id ?? ""}:${delegation.version()}`;
@@ -56,6 +71,7 @@ export function createSnapshotReader(config: ByteflyConfig, delegation: Delegati
56
71
  percent: usageInfo?.percent ?? null,
57
72
  };
58
73
  key = nextKey;
74
+ last = usage;
59
75
  }
60
76
 
61
77
  const model = current
@@ -76,5 +92,7 @@ export function createSnapshotReader(config: ByteflyConfig, delegation: Delegati
76
92
  providerCount: footerData.getAvailableProviderCount(),
77
93
  delegation: delegationTotals,
78
94
  };
79
- };
95
+ }) as SnapshotReader;
96
+ reader.peek = () => last;
97
+ return reader;
80
98
  }
@@ -79,6 +79,18 @@ export const CURRENCY_DEFAULTS: Readonly<Record<CurrencyCode, Currency>> = {
79
79
  USD: { code: "USD", decimals: 3 },
80
80
  };
81
81
 
82
+ /**
83
+ * The day the built-in tables were last checked against the published page.
84
+ *
85
+ * `doctor` reports how old this is, so a tariff change is visible instead of
86
+ * silently wrong. What was checked on this date (UTC): DeepSeek's published USD figures
87
+ * (flash $0.3 in / $1.2 out per 1M, cache hit $0.006; v4-pro $1.32 / $3.96, cache
88
+ * hit $0.044), the off-peak rule (half the peak rate), and the peak windows (01:00
89
+ * - 04:00 and 06:00 - 10:00 UTC, Monday to Friday). The yuan table is the same
90
+ * page's CNY view. Re-check the page, then update this date.
91
+ */
92
+ export const PRICE_VERIFIED_AT = "2026-10-18";
93
+
82
94
  /** Yuan, the currency `DEFAULT_RATES` is stated in. */
83
95
  export const DEFAULT_CURRENCY: Currency = CURRENCY_DEFAULTS.CNY;
84
96
 
@@ -86,9 +98,13 @@ export const DEFAULT_BYTEFLY_CONFIG: ByteflyConfig = {
86
98
  currency: DEFAULT_CURRENCY,
87
99
  rates: DEFAULT_RATES,
88
100
  peak: DEFAULT_PEAK,
101
+ budget: 0,
89
102
  };
90
103
 
91
- const MAX_DECIMALS = 4;
104
+ /** The highest `currency.decimals` the screen and the loader both accept. */
105
+ export const MAX_DECIMALS = 4;
106
+ /** Upper bound for `budget`; large enough for any real session, small enough to clamp a typo. */
107
+ export const MAX_BUDGET = 10_000_000;
92
108
  const MAX_RATE = 1_000_000;
93
109
  const MAX_RATE_KEYS = 200;
94
110
  const MAX_WINDOWS = 16;
@@ -182,6 +198,7 @@ export function normalizeByteflyConfig(raw: Record<string, unknown> | undefined,
182
198
  currency,
183
199
  rates: ratesValue(raw.rates, rateBase),
184
200
  peak: peakValue(raw.peak, base.peak),
201
+ budget: clampNumber(raw.budget, base.budget, 0, MAX_BUDGET),
185
202
  };
186
203
  }
187
204
 
@@ -56,4 +56,9 @@ export interface ByteflyConfig {
56
56
  /** Rates keyed by `provider/model`, matched through wildcards. */
57
57
  rates: Readonly<Record<string, ModelRates>>;
58
58
  peak: PeakSchedule;
59
+ /**
60
+ * Session spend that triggers one warning, in `currency`; `0` turns the
61
+ * guardrail off. It warns, it does not stop anything.
62
+ */
63
+ budget: number;
59
64
  }
@@ -12,22 +12,35 @@
12
12
  import { homedir } from "node:os";
13
13
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
14
14
  import { SUBAGENT_FINISHED_CHANNEL } from "../../../lib/events.ts";
15
- import { createSnapshotReader } from "./collect/snapshot.ts";
15
+ import { budgetBody, budgetNotice, budgetState } from "./budget.ts";
16
+ import { createSnapshotReader, type SnapshotReader } from "./collect/snapshot.ts";
16
17
  import { createDelegationLedger } from "./delegation.ts";
17
18
  import { loadByteflyConfig } from "./price/config.ts";
18
19
  import { createFooter } from "./render/footer.ts";
19
20
 
21
+ /** Host seams, injected by the composition root and by tests. */
22
+ export interface FooterDeps {
23
+ /** Raise one OS notification through bytefly's notify capability. */
24
+ notify?: (body: string) => void;
25
+ }
26
+
20
27
  /** Register the footer install/restore lifecycle. */
21
- export function registerFooter(pi: ExtensionAPI): void {
28
+ export function registerFooter(pi: ExtensionAPI, deps: FooterDeps = {}): void {
22
29
  // One ledger per load: it is this session's child-run accounting, priced by
23
30
  // whichever reader is installed (a config change installs a new one).
24
31
  const delegation = createDelegationLedger();
25
32
  pi.events.on(SUBAGENT_FINISHED_CHANNEL, (event) => delegation.note(event));
26
33
 
34
+ /** The installed reader and the budget it was priced with, while a footer is up. */
35
+ let budget: { limit: number; read: SnapshotReader } | undefined;
36
+ /** One warning per session: the notice stays in scrollback, and a re-install must not repeat it. */
37
+ let warned = false;
38
+
27
39
  const install = (ctx: ExtensionContext): void => {
28
40
  if (ctx.mode !== "tui") return;
29
41
  const config = loadByteflyConfig(ctx.cwd);
30
42
  const read = createSnapshotReader(config, delegation);
43
+ budget = { limit: config.budget, read };
31
44
 
32
45
  ctx.ui.setFooter((tui, _theme, footerData) =>
33
46
  createFooter(
@@ -41,12 +54,30 @@ export function registerFooter(pi: ExtensionAPI): void {
41
54
  };
42
55
 
43
56
  pi.on("session_start", (_event, ctx) => {
44
- // A new session in this process starts from zero: the ledger is not branch state.
57
+ // A new session in this process starts from zero: the ledger is not branch state,
58
+ // and a budget that was already reported is unreported again.
45
59
  delegation.reset();
60
+ warned = false;
46
61
  install(ctx);
47
62
  });
48
63
  pi.on("session_tree", (_event, ctx) => install(ctx));
64
+
65
+ // A turn's cost is final when the run settles, and that is when the user can act
66
+ // on it. Rendering never posts anything: the footer is derived state.
67
+ pi.on("agent_settled", (_event, ctx) => {
68
+ if (warned || !budget || budget.limit <= 0 || ctx.mode !== "tui") return;
69
+ const usage = budget.read.peek();
70
+ if (!usage) return;
71
+ const state = budgetState(budget.limit, usage.cost);
72
+ if (!state.exceeded) return;
73
+ warned = true;
74
+ const currency = loadByteflyConfig(ctx.cwd).currency;
75
+ pi.sendMessage(budgetNotice(state, currency));
76
+ deps.notify?.(budgetBody(state, currency));
77
+ });
78
+
49
79
  pi.on("session_shutdown", (_event, ctx) => {
80
+ budget = undefined;
50
81
  if (ctx.mode !== "tui") return;
51
82
  ctx.ui.setFooter(undefined);
52
83
  });
@@ -18,9 +18,9 @@ import { BODY_INDENT } from "../../../lib/tui/indent.ts";
18
18
  import { RULE_GLYPH } from "../../../lib/tui/rules.ts";
19
19
  import { paint } from "../../../lib/tui/tokens.ts";
20
20
  import { updateMarker } from "../update/format.ts";
21
- import { HEADER_CONFIG, type HelpEntry } from "./config.ts";
21
+ import { HEADER_CONFIG, type HeaderHeight, type HelpEntry } from "./config.ts";
22
22
 
23
- /** The header's shape, resolved once per apply so `render` stays cheap. */
23
+ /** The header's shape: everything but the live terminal height is resolved once per apply. */
24
24
  export interface HeaderView {
25
25
  /** Print Pi's version on the agent line. */
26
26
  showVersion: boolean;
@@ -28,6 +28,8 @@ export interface HeaderView {
28
28
  halfBlocks: boolean;
29
29
  /** Columns of left margin: Pi's output pad, so the nameplate sits on the transcript margin. */
30
30
  pad: number;
31
+ /** Terminal rows, when the host reports them; short terminals shed help rows. */
32
+ rows?: number;
31
33
  /** A newer published version of this package, when one is known. */
32
34
  latest?: string;
33
35
  }
@@ -35,6 +37,15 @@ export interface HeaderView {
35
37
  /** The mark block is four columns on both rows, so the identity column aligns. */
36
38
  const MARK_WIDTH = 4;
37
39
 
40
+ /**
41
+ * The tallest shape whose threshold the terminal meets. An unknown or impossible
42
+ * height keeps the full header, so a host without terminal rows still reads well.
43
+ */
44
+ function heightShape(rows: number | undefined): HeaderHeight {
45
+ if (rows === undefined || !Number.isFinite(rows) || rows <= 0) return HEADER_CONFIG.heights[0];
46
+ return HEADER_CONFIG.heights.find((shape) => rows >= shape.minRows) ?? HEADER_CONFIG.heights[0];
47
+ }
48
+
38
49
  /** Pad a themed mark line to the block width with plain spaces. */
39
50
  function markBlock(line: string): string {
40
51
  return line + " ".repeat(Math.max(0, MARK_WIDTH - visibleWidth(line)));
@@ -81,7 +92,7 @@ export class ByteflyHeader implements Component {
81
92
  const w = Math.max(1, width);
82
93
  const theme = this.getTheme();
83
94
  const view = this.view();
84
- const viewKey = `${view.showVersion}:${view.halfBlocks}:${view.pad}:${view.latest ?? ""}`;
95
+ const viewKey = `${view.showVersion}:${view.halfBlocks}:${view.pad}:${view.latest ?? ""}:${view.rows ?? ""}`;
85
96
  const keys = this.keySignature();
86
97
  if (
87
98
  this.cachedWidth === w &&
@@ -111,6 +122,7 @@ export class ByteflyHeader implements Component {
111
122
  private build(w: number, theme: Theme, view: HeaderView): string[] {
112
123
  const { nameRole, chromeRole, detailRole, updateRole, chromeTagline, chromeName, chromeVersion, help } =
113
124
  HEADER_CONFIG;
125
+ const shape = heightShape(view.rows);
114
126
  const padWidth = Math.max(0, Math.min(Math.floor(view.pad), Math.max(0, w - 1)));
115
127
  const pad = " ".repeat(padWidth);
116
128
  const [markTop, markBottom] = markLines(theme, view.halfBlocks);
@@ -145,13 +157,17 @@ export class ByteflyHeader implements Component {
145
157
 
146
158
  // The header owns its leading blank: Pi mounts the built-in header inside
147
159
  // `Spacer(1)`s, but the `quietStartup: true` branch that hands this header the
148
- // screen adds none, and `setHeader` swaps only the header child.
160
+ // screen adds none, and `setHeader` swaps only the header child. A short
161
+ // terminal gives that row back, then sheds whole help groups.
149
162
  return [
150
- "",
163
+ ...(shape.blank ? [""] : []),
151
164
  this.identityLine(pad, markTop, agentCell, w),
152
165
  this.identityLine(pad, markBottom, chromeCell, w),
153
166
  this.rule(theme, w, pad),
154
- ...help.map((row) => this.helpLine(theme, w, row, padWidth)),
167
+ ...shape.helpRows.flatMap((index) => {
168
+ const group = help[index];
169
+ return group ? [this.helpLine(theme, w, group, padWidth)] : [];
170
+ }),
155
171
  ];
156
172
  }
157
173
 
@@ -193,6 +209,12 @@ export class ByteflyHeader implements Component {
193
209
  }
194
210
 
195
211
  /** Header factory passed to `ctx.ui.setHeader`. */
196
- export function createHeader(getTheme: () => Theme, view: () => HeaderView): (tui: TUI, theme: Theme) => Component {
197
- return () => new ByteflyHeader(getTheme, view, VERSION);
212
+ export function createHeader(
213
+ getTheme: () => Theme,
214
+ view: (rows: number | undefined) => HeaderView,
215
+ ): (tui: TUI, theme: Theme) => Component {
216
+ // Read the height on every render, so a terminal resize reflows the header
217
+ // instead of keeping the shape it was installed with. Tests may pass a bare
218
+ // object as the host, so the terminal is optional at the boundary.
219
+ return (tui) => new ByteflyHeader(getTheme, () => view(tui.terminal?.rows), VERSION);
198
220
  }
@@ -19,6 +19,16 @@ export type HelpEntry =
19
19
  | { readonly binding: AppKeybinding; readonly description: string }
20
20
  | { readonly raw: string; readonly description: string };
21
21
 
22
+ /** How much of the help block a terminal height can carry. */
23
+ export interface HeaderHeight {
24
+ /** Smallest terminal height, in rows, this shape applies to. Taller tiers come first. */
25
+ readonly minRows: number;
26
+ /** Help groups to print, by index in `help`; a group is dropped whole. */
27
+ readonly helpRows: readonly number[];
28
+ /** Open with a blank row above the nameplate. */
29
+ readonly blank: boolean;
30
+ }
31
+
22
32
  /** The header's shape and content. */
23
33
  export interface HeaderConfig {
24
34
  /** The agent's name, shown before its version. */
@@ -49,6 +59,12 @@ export interface HeaderConfig {
49
59
  readonly chromeVersion: string;
50
60
  /** Grouped help rows, printed one line each under the rule. */
51
61
  readonly help: readonly (readonly HelpEntry[])[];
62
+ /**
63
+ * The height ladder, tallest first: the first tier the terminal meets decides
64
+ * which help groups print and whether the leading blank row survives. The
65
+ * floor is `minRows: 0`, so a short terminal keeps the nameplate and rule.
66
+ */
67
+ readonly heights: readonly HeaderHeight[];
52
68
  }
53
69
 
54
70
  export const HEADER_CONFIG: HeaderConfig = {
@@ -89,4 +105,10 @@ export const HEADER_CONFIG: HeaderConfig = {
89
105
  { binding: "app.clipboard.pasteImage", description: "paste files" },
90
106
  ],
91
107
  ],
108
+ heights: [
109
+ { minRows: 28, helpRows: [0, 1, 2], blank: true },
110
+ { minRows: 22, helpRows: [0, 2], blank: true },
111
+ { minRows: 16, helpRows: [2], blank: true },
112
+ { minRows: 0, helpRows: [], blank: false },
113
+ ],
92
114
  };
@@ -55,11 +55,12 @@ export function registerHeader(pi: ExtensionAPI, updates: UpdateSource = createU
55
55
  // value the tool frame and the notice inset by. An older host without
56
56
  // `getSettings` keeps the default margin.
57
57
  const pad = pi.getSettings?.().outputPad ?? DEFAULT_HOST_PAD;
58
- const view = (): HeaderView => ({
58
+ const view = (rows: number | undefined): HeaderView => ({
59
59
  showVersion: HEADER_CONFIG.showVersion,
60
60
  halfBlocks,
61
61
  pad,
62
62
  latest: updates.latest(),
63
+ rows,
63
64
  });
64
65
  ctx.ui.setHeader(createHeader(() => ctx.ui.theme, view));
65
66
  };
@@ -4,13 +4,13 @@
4
4
  * (the two-line, width-adaptive status bar with its price calculation),
5
5
  * `message/` (the user-message nameplate), `notice/` (the shared transcript
6
6
  * notice renderer), `block/` (the unified tool-block frame), `separator/` (the
7
- * transcript turn rule), `working/` (the live elapsed counter on Pi's working
8
- * row), `panel/` (the unified `/bytefly` screen and its
7
+ * transcript turn rule), `working/` (the live run line on Pi's working row),
8
+ * `panel/` (the unified `/bytefly` screen and its
9
9
  * `doctor` report), `doctor/` (the `run_doctor` tool and its session-scoped
10
10
  * probe runtime), `notify/` (OS notifications for unattended runs), and
11
11
  * `update/` (the published-version check behind the header's update marker).
12
12
  *
13
- * The header, footer, turn rule, and working timer are installed only in
13
+ * The header, footer, turn rule, and working line are installed only in
14
14
  * interactive (`tui`) sessions, and are always on; the user-message marker,
15
15
  * notice, and tool blocks register at load and take effect wherever a
16
16
  * transcript renders. `pi config` turns bytefly's chrome off. The panel and doctor work
@@ -59,7 +59,10 @@ export default function bytefly(pi: ExtensionAPI, deps: ByteflyDeps = {}): void
59
59
  // the header reads it, so the marker and every re-install agree.
60
60
  const updates = createUpdateState();
61
61
  const header = registerHeader(pi, updates);
62
- registerFooter(pi);
62
+ // Notify first, so the footer's budget guardrail can raise one OS notification
63
+ // through the same capability the triggers use instead of a second dispatch path.
64
+ const notifications = registerNotify(pi, deps.notify);
65
+ registerFooter(pi, { notify: notifications.notify });
63
66
  registerUserMessage(pi);
64
67
  // One store per load: the tool frame and the notice record Pi's output pad and
65
68
  // the turn separator reads it, so bytefly's transcript blocks share one margin.
@@ -72,7 +75,6 @@ export default function bytefly(pi: ExtensionAPI, deps: ByteflyDeps = {}): void
72
75
  registerTurnSeparator(pi, pad);
73
76
  registerWorkingTimer(pi);
74
77
  const doctor = registerDoctor(pi, deps.doctor);
75
- registerNotify(pi, deps.notify);
76
78
  // The returned handle is what `/bytefly update` re-runs, so the update surface is
77
79
  // registered before the panel that offers the command.
78
80
  const update = registerUpdate(pi, updates, { ...deps.update, onUpdate: () => header.refresh() });
@@ -18,7 +18,7 @@ import { cleanString, loadConfigSection } from "../../../lib/config.ts";
18
18
  import { NOTIFY_METHODS, type NotifyConfig, type NotifyMethod } from "./types.ts";
19
19
 
20
20
  /** A notification title longer than this is clamped; it is a label, not a message. */
21
- const MAX_TITLE_LENGTH = 40;
21
+ export const MAX_TITLE_LENGTH = 40;
22
22
 
23
23
  export const DEFAULT_NOTIFY_CONFIG: NotifyConfig = {
24
24
  enabled: false,
@@ -55,8 +55,14 @@ function isWatchMatch(value: unknown): value is WatchMatchEvent {
55
55
  );
56
56
  }
57
57
 
58
+ /** What the notify capability offers the rest of bytefly. */
59
+ export interface NotifyHandle {
60
+ /** Raise one notification; inert while `notify.enabled` is off. */
61
+ notify(body: string): void;
62
+ }
63
+
58
64
  /** Register the notification triggers. */
59
- export function registerNotify(pi: ExtensionAPI, deps: NotifyDeps = {}): void {
65
+ export function registerNotify(pi: ExtensionAPI, deps: NotifyDeps = {}): NotifyHandle {
60
66
  const seams = deps.seams ?? defaultSeams();
61
67
  let config: NotifyConfig = DEFAULT_NOTIFY_CONFIG;
62
68
  let busy = false;
@@ -106,4 +112,12 @@ export function registerNotify(pi: ExtensionAPI, deps: NotifyDeps = {}): void {
106
112
  while (unsubscribes.length > 0) unsubscribes.pop()?.();
107
113
  busy = false;
108
114
  });
115
+
116
+ return {
117
+ notify(body) {
118
+ // The master switch governs every path, including one another capability asks for.
119
+ if (!config.enabled) return;
120
+ fire(body);
121
+ },
122
+ };
109
123
  }
@@ -172,7 +172,7 @@ export function registerPanel(pi: ExtensionAPI, deps: PanelCommandDeps): void {
172
172
  if (section === "settings" && ctx.mode !== "tui") {
173
173
  const stores = createSettingsStores(cwd, agentDir, trusted);
174
174
  const settings = makeSettings({ stores, cwd, agentDir, trusted, onError: settingsNotice });
175
- ctx.ui.notify(formatSettingsListing(settings.views("global"), "global"), "info");
175
+ ctx.ui.notify(formatSettingsListing(settings.views("global")), "info");
176
176
  return;
177
177
  }
178
178
 
@@ -31,7 +31,7 @@ export interface SummarySegment {
31
31
  }
32
32
 
33
33
  /** Row and problem counts over a model. */
34
- export interface PanelCounts {
34
+ interface PanelCounts {
35
35
  extensions: number;
36
36
  /** Rows switched off in settings (the picker's checkbox), not the env flag. */
37
37
  off: number;
@@ -113,28 +113,21 @@ export function sectionSegments(active: PanelSection): SummarySegment[] {
113
113
  export function settingsSummarySegments(scope: PanelScope, count: number): SummarySegment[] {
114
114
  return [
115
115
  { text: scope === "global" ? "Global" : "Project", color: "accent" },
116
- { text: `${count} ${count === 1 ? "setting" : "settings"}`, color: "meta" },
116
+ { text: `${count} settings`, color: "meta" },
117
117
  ];
118
118
  }
119
119
 
120
- /** The Settings headline for non-TUI modes, e.g. `bytefly: Settings · Global · 5 settings`. */
121
- export function formatSettingsHeadline(scope: PanelScope, count: number): string {
122
- return `bytefly: Settings · ${settingsSummarySegments(scope, count)
123
- .map((segment) => segment.text)
124
- .join(" · ")}`;
125
- }
126
-
127
120
  /**
128
- * The grouped Settings listing for non-TUI modes. The project form names the
129
- * missing app rows rather than leaving their absence unexplained.
121
+ * The Settings headline for non-TUI modes, e.g. `bytefly: Settings · Global · 16 settings`.
122
+ * The listing only ever reads the personal document, so the scope is not a parameter.
130
123
  */
131
- export function formatSettingsListing(views: readonly SettingView[], scope: PanelScope): string {
132
- const lines = [formatSettingsHeadline(scope, views.length)];
133
- if (scope === "project") lines.push("", " App settings are Global only.");
134
- if (views.length === 0) {
135
- lines.push("", "No settings found.");
136
- return lines.join("\n");
137
- }
124
+ export function formatSettingsHeadline(count: number): string {
125
+ return `bytefly: Settings · Global · ${count} settings`;
126
+ }
127
+
128
+ /** The grouped Settings listing for non-TUI modes. */
129
+ export function formatSettingsListing(views: readonly SettingView[]): string {
130
+ const lines = [formatSettingsHeadline(views.length)];
138
131
  for (const group of groupViews(views)) {
139
132
  lines.push("", `${group.label} (${group.views.length})`);
140
133
  for (const view of group.views) {
@@ -152,7 +145,7 @@ export function formatHeadline(model: PanelModel, scope: PanelScope, options: Pa
152
145
  }
153
146
 
154
147
  /** A row's glyph and verdict, drawn from its enablement and health axes. */
155
- export interface RowBadge {
148
+ interface RowBadge {
156
149
  /** The status glyph, or `undefined` when the row has nothing to say. */
157
150
  glyph?: string;
158
151
  /** The role the glyph is painted in. */