@gavin-hu/my-pi-agent 0.13.0 → 0.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -51,7 +51,7 @@ Requires a working [Pi](https://pi.dev) installation.
51
51
  ```bash
52
52
  pi install npm:@gavin-hu/my-pi-agent # install from npm
53
53
  pi install git:github.com/gavin-hu/my-pi-agent # install from git
54
- pi install git:github.com/gavin-hu/my-pi-agent@v0.13.0 # pin a release
54
+ pi install git:github.com/gavin-hu/my-pi-agent@v0.13.1 # pin a release
55
55
  pi install ./ -l # project-local (.pi/settings.json)
56
56
  pi -e . # try it for one run
57
57
  ```
package/README.zh-CN.md CHANGED
@@ -47,7 +47,7 @@ Pi 提供一个精简而锐利的内核。这个包是在其之上的一层,
47
47
  ```bash
48
48
  pi install npm:@gavin-hu/my-pi-agent # 从 npm 安装
49
49
  pi install git:github.com/gavin-hu/my-pi-agent # 从 git 安装
50
- pi install git:github.com/gavin-hu/my-pi-agent@v0.13.0 # 固定到某个版本
50
+ pi install git:github.com/gavin-hu/my-pi-agent@v0.13.1 # 固定到某个版本
51
51
  pi install ./ -l # 仅项目本地(.pi/settings.json)
52
52
  pi -e . # 试运行一次
53
53
  ```
@@ -126,14 +126,26 @@ shows as `¥<0.01` rather than `¥0.00`.
126
126
 
127
127
  DeepSeek needs no config file at all: the bar prices it from the published yuan
128
128
  rates and peak tariff built into [`footer/price/config.ts`](./footer/price/config.ts).
129
- A `bytefly.json` can switch to the published dollar table, add models of your own,
130
- or replace a built-in entry:
129
+ Those tables record the day they were last checked against the published tariff,
130
+ and `doctor` reports how old that is, so a re-pricing shows up as a warning instead
131
+ of a quietly wrong total. A `bytefly.json` can switch to the published dollar table,
132
+ add models of your own, or replace a built-in entry:
131
133
 
132
134
  ```
133
135
  ~/repo/project ⎇ main
134
136
  ▰▰▰▰▰▰▱▱▱▱ 62%/200k · ¥2.50 · ↑42k ↓8.0k · R96k CH 87% opus-4.5 · high
135
137
  ```
136
138
 
139
+ **Session budget.** `price.budget` puts a threshold under that total, in the same
140
+ currency: when the session's cost reaches it, one `bytefly · Session budget
141
+ reached` notice names what was spent against the budget, and one OS notification
142
+ follows when notify is enabled. It is an alert, not a limit — no turn is blocked or
143
+ aborted — and it fires once per session, on the turn where the crossing becomes
144
+ final (the run settling), because a warning per turn after that is noise. `0`, the
145
+ default, turns it off. The check reads the total the footer already priced, and
146
+ the footer exists only in `tui` sessions, so a print or JSON run has no budget
147
+ alert; the footer's own numbers are visible in every interactive session.
148
+
137
149
  **User message.** Every user message opens with a two-row `You` nameplate —
138
150
  the label over a rule that spans the width — above the message:
139
151
 
@@ -585,11 +597,11 @@ installs or fetches a package.
585
597
  without leaving the session.
586
598
 
587
599
  **Settings.** The second section uses the same frame and the same `Tab` scope
588
- switch, with its own cursor. It lists fifteen preferences in six groups:
600
+ switch, with its own cursor. It lists sixteen preferences in six groups:
589
601
 
590
602
  ```
591
603
  bytefly ──────────────────────────────────────────────────────────────
592
- Extensions · Settings Global · 15 settings
604
+ Extensions · Settings Global · 16 settings
593
605
 
594
606
  Startup (1)
595
607
  ❯ ◻ Bytefly startup TUI Off
@@ -604,9 +616,10 @@ bytefly ────────────────────────
604
616
  Diagnostics (2)
605
617
  ◼ Proactive health check On
606
618
  Probe timeout 2000
607
- Pricing (2)
619
+ Pricing (3)
608
620
  Currency CNY (¥)
609
621
  Cost decimals 2
622
+ Session budget 0
610
623
  Transcript (2)
611
624
  Rail glyph ╎
612
625
  Output padding 1
@@ -633,6 +646,7 @@ bytefly ────────────────────────
633
646
  | Diagnostics | Probe timeout | `doctor.timeoutMs` | Number, 100–30000 ms |
634
647
  | Pricing | Currency | `price.currency.code` | CNY, USD |
635
648
  | Pricing | Cost decimals | `price.currency.decimals` | 0, 1, 2, 3, 4 |
649
+ | Pricing | Session budget | `price.budget` | Number, `0` = off |
636
650
  | Transcript | Rail glyph | `chrome.rail` | Text, exactly one column |
637
651
  | Transcript | Output padding | Pi `settings.json` | 0, 1 |
638
652
  | Terminal | Hardware cursor | Pi `settings.json` | Off, On |
@@ -657,7 +671,7 @@ with `true` or `false`.
657
671
 
658
672
  App rows are **Global only**: Pi's setters for them write the personal
659
673
  `settings.json`, and only resource paths have public project setters. Project
660
- scope therefore lists the twelve `bytefly.json` rows and a
674
+ scope therefore lists the thirteen `bytefly.json` rows and a
661
675
  `App settings are Global only.` note rather than promising a write that cannot
662
676
  happen. A `bytefly.json` row writes one leaf in the selected scope (a depth-1
663
677
  key, or a nested one such as `price.currency.decimals`) through a per-key
@@ -667,7 +681,7 @@ malformed document is refused with a notice instead of overwritten.
667
681
  The non-`tui` form prints the headline, the groups, and `Label: Value` rows:
668
682
 
669
683
  ```
670
- bytefly: Settings · Global · 15 settings
684
+ bytefly: Settings · Global · 16 settings
671
685
 
672
686
  Startup (1)
673
687
  Bytefly startup TUI: Off
@@ -691,6 +705,12 @@ means reduced capability — an optional package that is absent, a config file
691
705
  that fails to parse, a login that has not happened. Doctor never changes
692
706
  anything and never installs anything.
693
707
 
708
+ It also reports what has **aged**: the built-in DeepSeek rate tables carry the day
709
+ they were last checked against the published tariff (`PRICE_VERIFIED_AT` in
710
+ `footer/price/config.ts`), and the report says how old that is — `ok` inside the
711
+ 90-day window, `degraded` beyond it with the source URL to re-check. A tariff change
712
+ therefore becomes a visible warning instead of a quietly wrong cost total.
713
+
694
714
  `web-access` publishes what it resolved at `session_start`, so the report names
695
715
  the search provider and why it won (an explicit choice, the `auto` rule, or the
696
716
  keyless default), and — when the provider takes a key and no literal `apiKey` is
@@ -10,6 +10,7 @@
10
10
 
11
11
  import { CONFIG_DIR_NAME, getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
12
  import type { WebSearchProviderEvent } from "../../../lib/events.ts";
13
+ import { PRICE_VERIFIED_AT } from "../footer/price/config.ts";
13
14
  import { readJsonResult } from "../../../lib/config.ts";
14
15
  import { isExtensionEnabled } from "../../../lib/env.ts";
15
16
  import {
@@ -96,6 +97,7 @@ export function createDoctorRuntime(
96
97
  searchProvider: () => searchProvider,
97
98
  reach: probeReach,
98
99
  refresh,
100
+ priceVerifiedAt: () => PRICE_VERIFIED_AT,
99
101
  ...deps.seams,
100
102
  });
101
103
 
@@ -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,12 @@ 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
104
  const MAX_DECIMALS = 4;
105
+ /** Upper bound for `budget`; large enough for any real session, small enough to clamp a typo. */
106
+ const MAX_BUDGET = 10_000_000;
92
107
  const MAX_RATE = 1_000_000;
93
108
  const MAX_RATE_KEYS = 200;
94
109
  const MAX_WINDOWS = 16;
@@ -182,6 +197,7 @@ export function normalizeByteflyConfig(raw: Record<string, unknown> | undefined,
182
197
  currency,
183
198
  rates: ratesValue(raw.rates, rateBase),
184
199
  peak: peakValue(raw.peak, base.peak),
200
+ budget: clampNumber(raw.budget, base.budget, 0, MAX_BUDGET),
185
201
  };
186
202
  }
187
203
 
@@ -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
  });
@@ -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() });
@@ -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
  }
@@ -173,6 +173,24 @@ function integerRule(min: number, max: number): TextRule {
173
173
  };
174
174
  }
175
175
 
176
+ /**
177
+ * A text rule for a money amount inside an inclusive range: a whole number or a
178
+ * decimal with up to four places, matching the loader's clamp for `price.budget`.
179
+ * `0` is a legitimate value (the guardrail is off), so it is not rejected.
180
+ */
181
+ function amountRule(min: number, max: number): TextRule {
182
+ return {
183
+ maxLength: 16,
184
+ validate(raw) {
185
+ const text = raw.trim();
186
+ if (!/^\d+(?:\.\d{1,4})?$/.test(text)) return `Use an amount like 5 or 2.50, ${min} to ${max}.`;
187
+ const value = Number(text);
188
+ if (!Number.isFinite(value) || value < min || value > max) return `Out of range: ${min} to ${max}.`;
189
+ return undefined;
190
+ },
191
+ };
192
+ }
193
+
176
194
  /** The rail's rule: the loader drops anything that is not one column. */
177
195
  const RAIL_RULE: TextRule = {
178
196
  maxLength: 4,
@@ -339,6 +357,19 @@ export const SETTING_ROWS: readonly SettingDescriptor[] = [
339
357
  options: DECIMALS_OPTIONS,
340
358
  config: { section: "price", path: ["currency", "decimals"] },
341
359
  },
360
+ {
361
+ id: "price.budget",
362
+ group: "pricing",
363
+ label: "Session budget",
364
+ description: "Warn once when the session's cost reaches this amount; 0 is off. It never stops a turn.",
365
+ source: "bytefly",
366
+ kind: "text",
367
+ fallback: "0",
368
+ json: "number",
369
+ options: [],
370
+ text: amountRule(0, 10_000_000),
371
+ config: { section: "price", path: ["budget"] },
372
+ },
342
373
  {
343
374
  id: "chrome.rail",
344
375
  group: "transcript",
@@ -13,8 +13,8 @@
13
13
  */
14
14
 
15
15
  import type { Theme } from "@earendil-works/pi-coding-agent";
16
- import { truncateToWidth } from "@earendil-works/pi-tui";
17
16
  import { formatDuration } from "../../lib/format.ts";
17
+ import { clip, LABEL_WIDTH, labelLine } from "../../lib/tui/columns.ts";
18
18
  import { GLYPHS, SEPARATORS } from "../../lib/tui/glyphs.ts";
19
19
  import { paint, type TuiRole } from "../../lib/tui/tokens.ts";
20
20
  import type { JobRecord, JobStatus } from "./types.ts";
@@ -22,9 +22,6 @@ import type { JobRecord, JobStatus } from "./types.ts";
22
22
  /** Longest sanitized log line kept for display; longer lines are clipped. */
23
23
  const MAX_LOG_LINE = 200;
24
24
 
25
- /** Longest label shown in the transcript or `/jobs` screen. */
26
- const MAX_DISPLAY_LABEL = 48;
27
-
28
25
  /** ESC-introduced sequences: OSC, CSI, and single-character escapes. */
29
26
  const OSC = /\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)/g;
30
27
  const CSI = /\u001b\[[0-?]*[ -/]*[@-~]/g;
@@ -111,7 +108,7 @@ function statusWord(job: JobRecord): string {
111
108
 
112
109
  /** A label clipped to one safe display line. */
113
110
  export function clipLabel(text: string): string {
114
- return truncateToWidth(sanitizeLogLine(text), MAX_DISPLAY_LABEL, "…");
111
+ return labelLine(text, sanitizeLogLine);
115
112
  }
116
113
 
117
114
  /** A job's label (falling back to its command) clipped to one safe display line. */
@@ -180,7 +177,7 @@ export function formatCallText(action: string, args: Record<string, unknown>, ar
180
177
  case "start": {
181
178
  const command = typeof args.command === "string" ? sanitizeLogLine(args.command.trim()) : "";
182
179
  if (!command) return argsComplete ? "start" : "start …";
183
- const preview = truncateToWidth(command, MAX_DISPLAY_LABEL, "…");
180
+ const preview = clip(command, LABEL_WIDTH);
184
181
  return `start → ${preview}`;
185
182
  }
186
183
  case "kill":
@@ -188,7 +185,7 @@ export function formatCallText(action: string, args: Record<string, unknown>, ar
188
185
  case "send": {
189
186
  if (!target) return "send";
190
187
  const input = typeof args.input === "string" ? sanitizeLogLine(args.input) : "";
191
- const preview = input ? truncateToWidth(input, MAX_DISPLAY_LABEL, "…") : "";
188
+ const preview = input ? clip(input, LABEL_WIDTH) : "";
192
189
  const parts = [preview, args.eof === true ? "EOF" : ""].filter(Boolean).join(" + ");
193
190
  return parts ? `send ${target} → ${parts}` : `send ${target}`;
194
191
  }
@@ -86,7 +86,11 @@ export function shouldNudge({ mutations, writes, touched, nudged, todos }: Nudge
86
86
  return todos.length === 0 && !touched && writes > 0 && mutations >= EMPTY_LIST_MUTATIONS;
87
87
  }
88
88
 
89
- /** Marker on the injected reminder, so it is recognizable and filterable. */
89
+ /**
90
+ * Marker on the injected reminder, so it is recognizable and filterable.
91
+ *
92
+ * allow:todo the marker is text the model reads, not a to-do for a human.
93
+ */
90
94
  export const TODO_NUDGE_MARKER = "[TODO SYNC]";
91
95
 
92
96
  /**
@@ -33,8 +33,9 @@ How the `watch` call and its result look in the transcript — the call line as
33
33
  match is announced on `my-pi-agent/watch-match`, which bytefly's
34
34
  [notify](../bytefly/README.md#notifications) capability turns into a desktop
35
35
  notification when `notify.enabled` and `notify.matchFound` are on.
36
- - Watchers are session-only: `shutdown` flushes a partial line, kills every
37
- command's process tree, and clears the chips.
36
+ - Watchers are session-only: `shutdown` flushes a partial line, sends SIGTERM to
37
+ every command's process tree (escalating to SIGKILL after a short grace), and
38
+ clears the chips.
38
39
 
39
40
  ## Tool
40
41
 
@@ -42,7 +43,7 @@ How the `watch` call and its result look in the transcript — the call line as
42
43
  |---|---|
43
44
  | `name` | `watch` |
44
45
  | `action` | `add` \| `list` \| `status` \| `matches` \| `logs` \| `stop` \| `clear` |
45
- | `path` | File to tail (`add`); exactly one of `path`/`command`. |
46
+ | `path` | Regular file to tail (`add`); exactly one of `path`/`command`. |
46
47
  | `command` | Shell command whose output to tail (`add`). |
47
48
  | `cwd` | Working directory for a command source; defaults to the effective cwd. |
48
49
  | `pattern` | Regular expression a line must match (`add`, required). |
@@ -131,7 +132,7 @@ value is clamped; a missing or malformed document is ignored.
131
132
 
132
133
  | Key | Default | Meaning |
133
134
  |---|---|---|
134
- | `enabled` | `true` | Track watchers at all. |
135
+ | `enabled` | `true` | Allow `add`; `false` refuses to start a watcher. |
135
136
  | `showStatus` | `true` | Show the two status chips. |
136
137
  | `wakeOnMatch` | `false` | Default for a new watcher's `wake`; a per-watcher `wake` overrides it. |
137
138
  | `maxWatchers` | `20` | Live watchers before `add` is refused. |
@@ -160,6 +161,8 @@ other.
160
161
 
161
162
  - A file watcher polls, so a new line can take up to `pollMs` to surface; it does
162
163
  not use `fs.watch`.
164
+ - `add` requires a path that exists and is a regular file; a directory, FIFO, or
165
+ a file created after the call is refused.
163
166
  - A watcher holds live state in memory and is stopped on shutdown; a detached or
164
167
  resumed session starts with no watchers.
165
168
  - `matches` and `logs` keep bounded histories (`maxMatches`/`maxLogLines`), so
@@ -200,7 +203,10 @@ UTF-8 sequence split across a read boundary is never mangled. State is
200
203
  session-only by design: a watcher is a live process or file position, not
201
204
  transcript state, so it cannot be faithfully rebuilt from a `/tree` fork. Match
202
205
  delivery mirrors `job` — reported at the next turn, with `wake` triggering one
203
- turn — so a match is never dropped when the wake window is busy.
206
+ turn — so a match is never dropped when the wake window is busy. Stopping a
207
+ command sends SIGTERM to its process group and escalates to SIGKILL after a 2 s
208
+ grace; shutdown waits the same grace, so a command that ignores SIGTERM cannot
209
+ outlive the session.
204
210
 
205
211
  ## Files
206
212
 
@@ -14,7 +14,7 @@ import type { WatchRuntime } from "./runtime.ts";
14
14
  import { WatchListComponent } from "./tui.ts";
15
15
 
16
16
  /** How often the open screen re-reads live state. */
17
- const POLL_MS = 500;
17
+ const REFRESH_MS = 500;
18
18
 
19
19
  const SUBCOMMANDS = [
20
20
  { value: "list", label: "list" },
@@ -125,12 +125,13 @@ export function registerCommands(pi: ExtensionAPI, runtime: WatchRuntime): void
125
125
  () => tui.terminal?.rows,
126
126
  );
127
127
 
128
+ // The runtime's own clock owns file polling; this only refreshes
129
+ // what the screen shows, so opening it cannot change the poll cadence.
128
130
  timer = setInterval(() => {
129
- runtime.tick();
130
131
  const open = component.currentLineId();
131
132
  if (open) component.refreshLines(open);
132
133
  tui.requestRender();
133
- }, POLL_MS);
134
+ }, REFRESH_MS);
134
135
  timer.unref?.();
135
136
 
136
137
  return component;
@@ -9,7 +9,7 @@
9
9
  import { clampInteger, loadConfigSection } from "../../lib/config.ts";
10
10
 
11
11
  export interface WatchConfig {
12
- /** Track watchers at all. Default: true. */
12
+ /** Allow starting watchers; false refuses `add`. Default: true. */
13
13
  enabled: boolean;
14
14
  /** Show the status chips while watchers run or a match is unreported. */
15
15
  showStatus: boolean;
@@ -10,15 +10,12 @@
10
10
  */
11
11
 
12
12
  import type { Theme } from "@earendil-works/pi-coding-agent";
13
- import { truncateToWidth } from "@earendil-works/pi-tui";
14
13
  import { sanitizeLine } from "../../lib/format.ts";
14
+ import { clip, labelLine } from "../../lib/tui/columns.ts";
15
15
  import { GLYPHS, SEPARATORS } from "../../lib/tui/glyphs.ts";
16
16
  import { paint, type TuiRole } from "../../lib/tui/tokens.ts";
17
17
  import type { PendingMatch, WatchRecord, WatchStatus } from "./types.ts";
18
18
 
19
- /** Longest label shown in the transcript or `/watch` screen. */
20
- const MAX_DISPLAY_LABEL = 48;
21
-
22
19
  /** Longest sanitized watched line kept for display; longer lines are clipped. */
23
20
  const MAX_WATCH_LINE = 200;
24
21
 
@@ -50,7 +47,7 @@ export function compileRegex(pattern: string, caseSensitive: boolean): RegExp {
50
47
 
51
48
  /** A label clipped to one safe display line. */
52
49
  export function clipLabel(text: string): string {
53
- return truncateToWidth(sanitizeWatchLine(text), MAX_DISPLAY_LABEL, "…");
50
+ return labelLine(text, sanitizeWatchLine);
54
51
  }
55
52
 
56
53
  /** What a watcher reads: a path or a command, clipped for display. */
@@ -177,9 +174,9 @@ export function formatCallText(action: string, args: Record<string, unknown>, ar
177
174
  const command = typeof args.command === "string" ? args.command.trim() : "";
178
175
  const source = path ? path : command ? `$ ${command}` : "";
179
176
  if (!source) return argsComplete ? "add" : "add …";
180
- const preview = truncateToWidth(sanitizeLine(source), MAX_DISPLAY_LABEL, "…");
177
+ const preview = labelLine(source);
181
178
  const pattern = typeof args.pattern === "string" ? args.pattern.trim() : "";
182
- return pattern ? `add ${preview} /${truncateToWidth(sanitizeLine(pattern), 24, "…")}/` : `add ${preview}`;
179
+ return pattern ? `add ${preview} /${clip(sanitizeLine(pattern), 24)}/` : `add ${preview}`;
183
180
  }
184
181
  case "status":
185
182
  case "matches":
@@ -22,6 +22,8 @@ import type { PendingMatch, WatchMatch, WatchRecord, WatchSource } from "./types
22
22
  const MAX_CHUNK = 1024 * 1024;
23
23
  /** A line without a newline is flushed once it grows past this. */
24
24
  const MAX_LINE_BYTES = 1024 * 1024;
25
+ /** Grace period before a stopped command is escalated to SIGKILL. */
26
+ const KILL_GRACE_MS = 2000;
25
27
 
26
28
  /** Internal watcher state: the public record plus non-serializable fields. */
27
29
  interface Internal {
@@ -50,6 +52,7 @@ interface Internal {
50
52
  rawLines: string[];
51
53
  matches: WatchMatch[];
52
54
  child?: SpawnedProcess;
55
+ killTimer?: ReturnType<typeof setTimeout>;
53
56
  notified: boolean;
54
57
  seq: number;
55
58
  }
@@ -95,6 +98,8 @@ export interface WatchRuntimeOptions {
95
98
  config?: WatchConfig;
96
99
  /** When false, never create a real interval; tests call `tick()` directly. */
97
100
  autoClock?: boolean;
101
+ /** Grace before SIGTERM escalates to SIGKILL; default {@link KILL_GRACE_MS}. */
102
+ killGraceMs?: number;
98
103
  }
99
104
 
100
105
  export function createWatchRuntime(options: WatchRuntimeOptions = {}): WatchRuntime {
@@ -103,6 +108,7 @@ export function createWatchRuntime(options: WatchRuntimeOptions = {}): WatchRunt
103
108
  const now = options.now ?? (() => Date.now());
104
109
  const fixedConfig = options.config;
105
110
  const autoClock = options.autoClock ?? true;
111
+ const killGraceMs = options.killGraceMs ?? KILL_GRACE_MS;
106
112
 
107
113
  let config: WatchConfig = fixedConfig ?? DEFAULT_CONFIG;
108
114
  const watches = new Map<string, Internal>();
@@ -243,15 +249,60 @@ export function createWatchRuntime(options: WatchRuntimeOptions = {}): WatchRunt
243
249
  emit(w, rest, time);
244
250
  };
245
251
 
246
- const killChild = (w: Internal): void => {
252
+ const clearKillTimer = (w: Internal): void => {
253
+ if (w.killTimer) {
254
+ clearTimeout(w.killTimer);
255
+ w.killTimer = undefined;
256
+ }
257
+ };
258
+
259
+ const killChild = (w: Internal, signal: "SIGTERM" | "SIGKILL"): void => {
247
260
  const pid = w.pid;
248
261
  try {
249
- if (pid !== undefined) killTree(pid, "SIGTERM");
250
- else w.child?.kill("SIGTERM");
262
+ if (pid !== undefined) killTree(pid, signal, signal === "SIGKILL");
263
+ else w.child?.kill(signal);
251
264
  } catch {
252
265
  // Best-effort: the child may already be gone.
253
266
  }
254
- w.child = undefined;
267
+ };
268
+
269
+ /** SIGTERM a command, escalating to SIGKILL when it has not closed in time. */
270
+ const terminateChild = (w: Internal): void => {
271
+ clearKillTimer(w);
272
+ killChild(w, "SIGTERM");
273
+ if (w.pid === undefined && !w.child) return;
274
+ w.killTimer = setTimeout(() => {
275
+ w.killTimer = undefined;
276
+ if (w.child) killChild(w, "SIGKILL");
277
+ }, killGraceMs);
278
+ w.killTimer.unref?.();
279
+ };
280
+
281
+ /** Resolve when every command has closed, or after `ms`, whichever comes first. */
282
+ const waitForChildren = (watches: Internal[], ms: number): Promise<void> => {
283
+ const alive = watches.filter((w) => w.child !== undefined);
284
+ if (alive.length === 0) return Promise.resolve();
285
+ return new Promise((resolve) => {
286
+ let remaining = alive.length;
287
+ let timer: ReturnType<typeof setTimeout> | undefined;
288
+ const settle = (): void => {
289
+ if (--remaining > 0) return;
290
+ if (timer !== undefined) clearTimeout(timer);
291
+ resolve();
292
+ };
293
+ for (const w of alive) {
294
+ let done = false;
295
+ const once = (): void => {
296
+ if (done) return;
297
+ done = true;
298
+ settle();
299
+ };
300
+ w.child?.on("close", once);
301
+ w.child?.on("error", once);
302
+ }
303
+ timer = setTimeout(resolve, ms);
304
+ timer.unref?.();
305
+ });
255
306
  };
256
307
 
257
308
  const pollFile = (w: Internal, time: number): void => {
@@ -278,6 +329,9 @@ export function createWatchRuntime(options: WatchRuntimeOptions = {}): WatchRunt
278
329
  const read = readSync(fd, buffer, 0, length, w.offset);
279
330
  w.offset += read;
280
331
  consume(w, buffer.subarray(0, read), time);
332
+ } catch {
333
+ // The path can vanish or change type between stat and read (rotation, a
334
+ // replacement); skip this tick and pick it up next time.
281
335
  } finally {
282
336
  if (fd !== undefined) {
283
337
  try {
@@ -305,22 +359,24 @@ export function createWatchRuntime(options: WatchRuntimeOptions = {}): WatchRunt
305
359
  child.stdout?.on("data", onData);
306
360
  child.stderr?.on("data", onData);
307
361
  child.on("close", (code, signal) => {
362
+ clearKillTimer(w);
363
+ w.child = undefined;
308
364
  if (w.status !== "running") return;
309
365
  flushPending(w, now());
310
366
  w.status = "exited";
311
367
  w.exitCode = code;
312
368
  w.signal = signal;
313
369
  w.endedAt = now();
314
- w.child = undefined;
315
370
  ui.paint();
316
371
  if (runningCount() === 0) stopClock();
317
372
  });
318
373
  child.on("error", (error) => {
374
+ clearKillTimer(w);
375
+ w.child = undefined;
319
376
  if (w.status !== "running") return;
320
377
  w.status = "error";
321
378
  w.error = error.message;
322
379
  w.endedAt = now();
323
- w.child = undefined;
324
380
  ui.paint();
325
381
  if (runningCount() === 0) stopClock();
326
382
  });
@@ -337,6 +393,7 @@ export function createWatchRuntime(options: WatchRuntimeOptions = {}): WatchRunt
337
393
  };
338
394
 
339
395
  const add = (opts: AddWatchOptions, ctx: ExtensionContext): WatchRecord => {
396
+ if (!config.enabled) throw new Error("watch is disabled.");
340
397
  if (watches.size >= config.maxWatchers) {
341
398
  throw new Error(`at most ${config.maxWatchers} watchers; stop one first.`);
342
399
  }
@@ -349,11 +406,13 @@ export function createWatchRuntime(options: WatchRuntimeOptions = {}): WatchRunt
349
406
  cwd: opts.cwd ? (isAbsolute(opts.cwd) ? opts.cwd : resolve(base, opts.cwd)) : base,
350
407
  };
351
408
  if (source.kind === "file") {
409
+ let info: ReturnType<typeof statSync>;
352
410
  try {
353
- statSync(source.path ?? "");
411
+ info = statSync(source.path ?? "");
354
412
  } catch {
355
413
  throw new Error(`file not found: ${source.path}`);
356
414
  }
415
+ if (!info.isFile()) throw new Error(`not a regular file: ${source.path}`);
357
416
  }
358
417
  const caseSensitive = opts.caseSensitive ?? false;
359
418
  const regex = compileRegex(opts.pattern, caseSensitive);
@@ -423,7 +482,7 @@ export function createWatchRuntime(options: WatchRuntimeOptions = {}): WatchRunt
423
482
  const w = watches.get(id);
424
483
  if (!w || w.status !== "running") return false;
425
484
  flushPending(w, now());
426
- killChild(w);
485
+ terminateChild(w);
427
486
  w.status = "stopped";
428
487
  w.endedAt = now();
429
488
  ui.paint();
@@ -473,7 +532,13 @@ export function createWatchRuntime(options: WatchRuntimeOptions = {}): WatchRunt
473
532
  const load = (ctx: ExtensionContext): void => {
474
533
  disposed = false;
475
534
  stopClock();
476
- for (const w of watches.values()) if (w.status === "running") killChild(w);
535
+ for (const w of watches.values()) {
536
+ if (w.status !== "running") continue;
537
+ w.status = "stopped";
538
+ clearKillTimer(w);
539
+ killChild(w, "SIGKILL");
540
+ w.child = undefined;
541
+ }
477
542
  watches.clear();
478
543
  counter = 0;
479
544
  config = fixedConfig ?? loadConfig(ctx.cwd);
@@ -484,12 +549,18 @@ export function createWatchRuntime(options: WatchRuntimeOptions = {}): WatchRunt
484
549
  const shutdown = async (): Promise<void> => {
485
550
  disposed = true;
486
551
  stopClock();
487
- for (const w of watches.values()) {
488
- if (w.status !== "running") continue;
552
+ const running = [...watches.values()].filter((w) => w.status === "running");
553
+ for (const w of running) {
489
554
  flushPending(w, now());
490
- killChild(w);
491
555
  w.status = "stopped";
492
556
  w.endedAt = now();
557
+ clearKillTimer(w);
558
+ killChild(w, "SIGTERM");
559
+ }
560
+ await waitForChildren(running, killGraceMs);
561
+ for (const w of running) {
562
+ clearKillTimer(w);
563
+ if (w.child) killChild(w, "SIGKILL");
493
564
  }
494
565
  watches.clear();
495
566
  ui.detach();
@@ -122,9 +122,15 @@ exclude the name here to load alongside it. It is read when the extension
122
122
  loads, from the process working directory; reload after changing it. `/worktree`
123
123
  and `list_worktrees` list any overridden tool that is not re-rooted.
124
124
 
125
- Add the worktree directory to `.gitignore`:
125
+ `.gitignore` must cover the managed directory, or worktrees show up as untracked
126
+ files in the main checkout. `enter_worktree` offers once per session to append
127
+ `.pi/worktrees/`, and skips the offer when `git check-ignore` already reports the
128
+ directory as ignored, so a wider rule is enough and no literal line is needed:
126
129
 
127
130
  ```
131
+ # either, when the rest of `.pi/` is disposable
132
+ .pi/
133
+ # or, when other `.pi/` state must stay tracked
128
134
  .pi/worktrees/
129
135
  ```
130
136
 
@@ -17,6 +17,7 @@ import {
17
17
  currentBranch,
18
18
  fetchPrRef,
19
19
  hasCommits,
20
+ isIgnored,
20
21
  lockWorktree,
21
22
  mergeBase,
22
23
  parsePrReference,
@@ -54,12 +55,19 @@ function generateName(): string {
54
55
  }
55
56
 
56
57
  /** Offer once per session to ignore the managed worktree directory. */
57
- async function maybeIgnoreWorktreeDir(ctx: ExtensionContext, root: string, config: WorktreeConfig): Promise<void> {
58
+ async function maybeIgnoreWorktreeDir(
59
+ pi: ExtensionAPI,
60
+ ctx: ExtensionContext,
61
+ root: string,
62
+ config: WorktreeConfig,
63
+ ): Promise<void> {
58
64
  if (isGitignoreOffered()) return;
59
65
  if (!ctx.hasUI) return;
60
66
  markGitignoreOffered();
61
67
 
62
68
  const entry = `${config.dir.replace(/\/+$/, "")}/`;
69
+ if (await isIgnored(pi, root, entry)) return;
70
+
63
71
  const gitignorePath = join(root, ".gitignore");
64
72
  let content = "";
65
73
  try {
@@ -67,7 +75,6 @@ async function maybeIgnoreWorktreeDir(ctx: ExtensionContext, root: string, confi
67
75
  } catch {
68
76
  return;
69
77
  }
70
- if (content.split(/\r?\n/).some((line) => line.trim() === entry || line.trim() === config.dir)) return;
71
78
 
72
79
  const confirmed = await ctx.ui.confirm(
73
80
  "Add the worktree directory to .gitignore?",
@@ -225,7 +232,7 @@ export async function enterWorktree(
225
232
  let copiedIncludes: string[] = [];
226
233
  if (!existed) {
227
234
  copiedIncludes = await copyIncludes(pi, root, target, includePatterns(root, config.include), config.dir);
228
- await maybeIgnoreWorktreeDir(ctx, root, config);
235
+ await maybeIgnoreWorktreeDir(pi, ctx, root, config);
229
236
  }
230
237
 
231
238
  const state: WorktreeState = {
@@ -7,12 +7,14 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
7
7
  import { loadConfig } from "./config.ts";
8
8
  import { WorktreeCancelledError } from "./errors.ts";
9
9
  import {
10
+ commitsAheadOf,
10
11
  currentBranch,
11
12
  defaultBranch,
12
13
  deleteBranch,
13
14
  inspectWork,
14
15
  isBranchMerged,
15
16
  lockWorktree,
17
+ refExists,
16
18
  unlockWorktree,
17
19
  worktreeRemove,
18
20
  } from "./git.ts";
@@ -32,17 +34,35 @@ export interface ExitOptions {
32
34
  * A failed lookup degrades to the facts already in hand: an exit that succeeded
33
35
  * must not fail because the default branch could not be read.
34
36
  */
35
- async function describeHandoff(pi: ExtensionAPI, state: WorktreeState, ahead: number): Promise<Handoff> {
36
- if (!state.branch) return { branch: null, base: null, ahead, merged: false };
37
+ async function describeHandoff(pi: ExtensionAPI, state: WorktreeState, recorded: number): Promise<Handoff> {
38
+ if (!state.branch) return { branch: null, base: null, ahead: recorded, merged: false };
37
39
  try {
38
40
  // `origin/HEAD` names the merge target when the repository has a remote; a
39
41
  // local-only repository has none, and the main checkout's own branch is the
40
42
  // branch a merge would actually land on.
41
43
  const base = (await defaultBranch(pi, state.repoRoot)) ?? (await currentBranch(pi, state.repoRoot)) ?? null;
42
- const merged = base ? await isBranchMerged(pi, state.repoRoot, state.branch, base) : false;
43
- return { branch: state.branch, base, ahead, merged };
44
+ if (!base) return { branch: state.branch, base: null, ahead: recorded, merged: false };
45
+ // Count against the resolved base branch, not the commit recorded at entry:
46
+ // a worktree branch rebased onto a newer local branch would otherwise report
47
+ // every commit the base gained since, as its own.
48
+ // Every lookup runs in the main checkout: the handoff is read after the exit,
49
+ // when the worktree directory may already be gone, while the branch refs it
50
+ // describes outlive it.
51
+ const root = state.repoRoot;
52
+ const ref = (await refExists(pi, root, base))
53
+ ? base
54
+ : (await refExists(pi, root, `origin/${base}`))
55
+ ? `origin/${base}`
56
+ : null;
57
+ if (!ref) return { branch: state.branch, base: null, ahead: recorded, merged: false };
58
+ return {
59
+ branch: state.branch,
60
+ base,
61
+ ahead: await commitsAheadOf(pi, root, state.branch, ref),
62
+ merged: await isBranchMerged(pi, root, state.branch, ref),
63
+ };
44
64
  } catch {
45
- return { branch: state.branch, base: null, ahead, merged: false };
65
+ return { branch: state.branch, base: null, ahead: recorded, merged: false };
46
66
  }
47
67
  }
48
68
 
@@ -191,6 +191,25 @@ export async function worktreeExists(pi: ExtensionAPI, dir: string): Promise<boo
191
191
  }
192
192
 
193
193
  /** Whether a local branch exists. */
194
+ /**
195
+ * Count of commits on `branch` that `base` does not contain.
196
+ *
197
+ * Takes the branch by name rather than reading `HEAD`, because a handoff is read
198
+ * after the worktree may already be gone while its branch refs outlive it.
199
+ */
200
+ export async function commitsAheadOf(pi: ExtensionAPI, dir: string, branch: string, base: string): Promise<number> {
201
+ const result = await git(pi, ["rev-list", "--count", `${base}..${branch}`], dir);
202
+ if (result.code !== 0) return 0;
203
+ const count = Number.parseInt(result.stdout.trim(), 10);
204
+ return Number.isFinite(count) ? count : 0;
205
+ }
206
+
207
+ /** Whether `ref` resolves here: a branch, a remote-tracking ref, or a tag. */
208
+ export async function refExists(pi: ExtensionAPI, dir: string, ref: string): Promise<boolean> {
209
+ const result = await git(pi, ["rev-parse", "--verify", "--quiet", ref], dir);
210
+ return result.code === 0 && result.stdout.trim() !== "";
211
+ }
212
+
194
213
  export async function branchExists(pi: ExtensionAPI, repoRoot: string, branch: string): Promise<boolean> {
195
214
  const result = await git(pi, ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], repoRoot);
196
215
  return result.code === 0;
@@ -240,6 +259,15 @@ export async function deleteBranch(
240
259
  return { ok: false, error: outcome.error || `Could not delete branch ${branch}` };
241
260
  }
242
261
 
262
+ /**
263
+ * Whether git ignores `path` in `dir`. Asks git rather than matching ignore
264
+ * text, so a directory covered by a wider rule also counts as ignored.
265
+ */
266
+ export async function isIgnored(pi: ExtensionAPI, dir: string, path: string): Promise<boolean> {
267
+ const result = await git(pi, ["check-ignore", "-q", "--", path], dir);
268
+ return result.code === 0;
269
+ }
270
+
243
271
  /** Porcelain status lines for a working tree; empty when clean. */
244
272
  export async function statusEntries(pi: ExtensionAPI, dir: string): Promise<string[]> {
245
273
  const result = await git(pi, ["status", "--porcelain"], dir);
@@ -53,6 +53,7 @@ import {
53
53
  noteUnverifiableCommand,
54
54
  publishWorktree,
55
55
  resetDiagnostics,
56
+ resetGitignoreOffer,
56
57
  setActive,
57
58
  setBaseCwd,
58
59
  setInactiveOverrides,
@@ -91,6 +92,7 @@ export function registerWorktree(pi: ExtensionAPI, { entryPath, createBuiltin }:
91
92
  setActive(null);
92
93
  clearConfigCache();
93
94
  resetDiagnostics();
95
+ resetGitignoreOffer();
94
96
 
95
97
  // A child `pi` process (subagent) inherits an active worktree. Bind to
96
98
  // it without creating, locking, or persisting anything; the parent owns
@@ -133,7 +133,7 @@ export function registerRootTools(pi: ExtensionAPI, host: RootToolHost, deps: Ro
133
133
  signal,
134
134
  onUpdate,
135
135
  withCwd(ctx, root),
136
- )) as AgentToolResult<any>;
136
+ )) as AgentToolResult<unknown>;
137
137
  },
138
138
  });
139
139
  }
@@ -70,6 +70,11 @@ export function markGitignoreOffered(): void {
70
70
  gitignoreOffered = true;
71
71
  }
72
72
 
73
+ /** Re-arm the offer for the next session; `session_start` owns the reset. */
74
+ export function resetGitignoreOffer(): void {
75
+ gitignoreOffered = false;
76
+ }
77
+
73
78
  /**
74
79
  * Count a bash command the guard let through without being able to verify it.
75
80
  * Session-scoped on purpose: it is the one gap a user cannot see in the
package/lib/README.md CHANGED
@@ -4,8 +4,10 @@ Small modules imported by two or more extensions. Pi loads each extension with
4
4
  an **isolated module cache**, so these are *value-only* libraries: pure
5
5
  functions, constants, and classes. Never keep cross-extension state here — a
6
6
  module-level variable would have one copy per extension, not one shared copy.
7
- The single exception is the event-bus pattern in `rails.ts`, where the state
8
- lives in the caller's closure, not in this module.
7
+ `test/structure.test.ts` fails a module-scope `let`, `var`, `Map`, `WeakMap`, or
8
+ empty array, so this is a test and not a convention. The single exception is the
9
+ event-bus pattern in `rails.ts`, where the state lives in the caller's closure,
10
+ not in this module.
9
11
 
10
12
  This directory sits outside `extensions/` on purpose: nothing here is a
11
13
  loadable extension entrypoint, and every module is a contract shared *between*
@@ -111,6 +111,21 @@ export interface SafetyCheck {
111
111
  hint: string;
112
112
  }
113
113
 
114
+ /**
115
+ * A fact that ages: something checked by hand on a date, which is still worth
116
+ * trusting only for a while. The date arrives through a seam because `lib/` cannot
117
+ * import the extension that owns the record.
118
+ */
119
+ export interface AgeCheck {
120
+ kind: "age";
121
+ /** What was verified, phrased for the report, e.g. `the built-in rate tables`. */
122
+ what: string;
123
+ /** Days after which the record counts as stale. */
124
+ maxAgeDays: number;
125
+ enables: string;
126
+ hint: string;
127
+ }
128
+
114
129
  export type RequirementCheck =
115
130
  | BinaryCheck
116
131
  | ModuleCheck
@@ -118,7 +133,8 @@ export type RequirementCheck =
118
133
  | InvocationCheck
119
134
  | NotifyCheck
120
135
  | ProviderCheck
121
- | SafetyCheck;
136
+ | SafetyCheck
137
+ | AgeCheck;
122
138
 
123
139
  /** What an extension needs, split by how badly it needs it. */
124
140
  export interface ExtensionRequirements {
@@ -274,6 +290,13 @@ export const REQUIREMENTS: Readonly<Record<string, ExtensionRequirements>> = {
274
290
  bytefly: {
275
291
  configFiles: [SHARED_CONFIG_FILE],
276
292
  optional: [
293
+ {
294
+ kind: "age",
295
+ what: "the built-in DeepSeek rate tables",
296
+ maxAgeDays: 90,
297
+ enables: "the footer's cost total tracks the published tariff",
298
+ hint: "re-check https://api-docs.deepseek.com/quick_start/pricing against `footer/price/config.ts`, then update `PRICE_VERIFIED_AT`.",
299
+ },
277
300
  {
278
301
  kind: "notify",
279
302
  enables: "the configured notification delivery",
@@ -328,6 +351,10 @@ export interface ProbeSeams {
328
351
  reach?: (url: string) => Promise<{ ok: boolean; error?: string }>;
329
352
  /** True when the caller asked for an explicit refresh, which is when a probe may touch the network. */
330
353
  refresh?: boolean;
354
+ /** The day a hand-checked record was verified, as `YYYY-MM-DD`, when the extension records one. */
355
+ priceVerifiedAt?: () => string | undefined;
356
+ /** The clock the age check reads; defaults to the process clock. */
357
+ now?: () => number;
331
358
  }
332
359
 
333
360
  /**
@@ -494,6 +521,56 @@ function safetyFindings(
494
521
  return [{ extension, check: label, severity: "ok", detail: check.enables }];
495
522
  }
496
523
 
524
+ /**
525
+ * Whole days between an ISO `YYYY-MM-DD` date and `now`, or undefined for a date
526
+ * that does not parse or does not exist on the calendar.
527
+ *
528
+ * A date up to a day in the future reads as zero days old: a record made in a
529
+ * timezone ahead of UTC is not a typo, and reporting it as missing would hide a
530
+ * fresh record. Anything further ahead is a typo, so it is not a date.
531
+ */
532
+ export function ageInDays(date: string, now: number): number | undefined {
533
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return undefined;
534
+ const stamp = Date.parse(`${date}T00:00:00Z`);
535
+ if (!Number.isFinite(stamp)) return undefined;
536
+ // `Date.parse` accepts 2026-02-31 by rolling it into March; reject the roll.
537
+ if (new Date(stamp).toISOString().slice(0, 10) !== date) return undefined;
538
+ const days = Math.floor((now - stamp) / 86_400_000);
539
+ if (days >= 0) return days;
540
+ // One day of skew is a timezone, not a mistake.
541
+ return days >= -1 ? 0 : undefined;
542
+ }
543
+
544
+ /** The findings for a fact that ages. */
545
+ function ageFindings(extension: string, check: AgeCheck, failSeverity: FindingSeverity, seams: ProbeSeams): Finding[] {
546
+ const label = check.what;
547
+ const date = seams.priceVerifiedAt?.();
548
+ const age = date === undefined ? undefined : ageInDays(date, (seams.now ?? Date.now)());
549
+ if (date === undefined || age === undefined) {
550
+ return [
551
+ {
552
+ extension,
553
+ check: label,
554
+ severity: "info",
555
+ detail: `no verification date is recorded for ${label}`,
556
+ },
557
+ ];
558
+ }
559
+ const days = `${age} day${age === 1 ? "" : "s"} ago`;
560
+ if (age <= check.maxAgeDays) {
561
+ return [{ extension, check: label, severity: "ok", detail: `${label} last checked ${days}` }];
562
+ }
563
+ return [
564
+ {
565
+ extension,
566
+ check: label,
567
+ severity: failSeverity,
568
+ detail: `${label} were last checked ${days}, beyond the ${check.maxAgeDays}-day window — ${check.enables}`,
569
+ hint: check.hint,
570
+ },
571
+ ];
572
+ }
573
+
497
574
  /** Run one check. `failSeverity` comes from which list the check sits in. */
498
575
  async function runCheck(
499
576
  extension: string,
@@ -611,6 +688,8 @@ async function runCheck(
611
688
  return providerFindings(extension, check, failSeverity, seams);
612
689
  case "safety":
613
690
  return safetyFindings(extension, check, failSeverity, seams);
691
+ case "age":
692
+ return ageFindings(extension, check, failSeverity, seams);
614
693
  }
615
694
  }
616
695
 
@@ -10,6 +10,10 @@
10
10
  */
11
11
 
12
12
  import { sliceByColumn, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
13
+ import { sanitizeLine } from "../format.ts";
14
+
15
+ /** The columns a label gets before it stops reading as a label. */
16
+ export const LABEL_WIDTH = 48;
13
17
 
14
18
  /** A usable origin: terminal arithmetic starts from one column, never zero. */
15
19
  function columns(width: number): number {
@@ -45,6 +49,19 @@ export function clipMiddle(text: string, width: number, ellipsis = "…"): strin
45
49
  return clip(`${head}${ellipsis}${tail}`, w, ellipsis);
46
50
  }
47
51
 
52
+ /**
53
+ * A single-line label clipped to {@link LABEL_WIDTH}.
54
+ *
55
+ * The sanitizer runs first, then the clip: cutting the raw text first would put
56
+ * a cut inside an escape sequence a sanitizer can no longer recognize, and a
57
+ * newline would be left to break the row in two. The default is
58
+ * {@link sanitizeLine}; an extension with its own log- or file-authored text
59
+ * passes that sanitizer instead.
60
+ */
61
+ export function labelLine(text: string, sanitize: (text: string) => string = sanitizeLine): string {
62
+ return clip(sanitize(text), LABEL_WIDTH);
63
+ }
64
+
48
65
  /** Pad `text` on the right to exactly `width` columns. Never truncates. */
49
66
  export function padTo(text: string, width: number): string {
50
67
  const missing = columns(width) - visibleWidth(text);
package/lib/tui/tool.ts CHANGED
@@ -136,7 +136,8 @@ export function moreHint(theme: Theme, hidden: number, noun = "lines"): string {
136
136
 
137
137
  /** The render context fields the elapsed timer needs. */
138
138
  export interface ToolRenderClock {
139
- state: any;
139
+ /** Pi's per-call render state, opaque here: this module only ever casts it. */
140
+ state: unknown;
140
141
  invalidate(): void;
141
142
  }
142
143
 
@@ -191,7 +192,10 @@ export function elapsedSuffix(theme: Theme, durationMs: number | undefined, minM
191
192
  * (the renderer's `isPartial` branch draws its own running line) and the host
192
193
  * replaces it with the final result.
193
194
  */
194
- export function startToolProgress(onUpdate: ((partial: any) => void) | undefined): void {
195
+ export function startToolProgress(
196
+ // allow:any Pi's partial-result type is generic over each tool's details, so a shared helper cannot name it.
197
+ onUpdate: ((partial: any) => void) | undefined,
198
+ ): void {
195
199
  onUpdate?.({ content: [], details: undefined });
196
200
  }
197
201
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gavin-hu/my-pi-agent",
3
- "version": "0.13.0",
3
+ "version": "0.13.1",
4
4
  "description": "Personal Pi customizations, packaged as a Pi package: worktree isolation and rewind snapshots, question, todo, goal, plan, subagent, job, watch, web-access, file-browse, file-open, document, memory, wechat, and bytefly.",
5
5
  "keywords": [
6
6
  "pi-package",