@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 +1 -1
- package/README.zh-CN.md +1 -1
- package/extensions/bytefly/README.md +27 -7
- package/extensions/bytefly/doctor/runtime.ts +2 -0
- package/extensions/bytefly/footer/budget.ts +64 -0
- package/extensions/bytefly/footer/collect/snapshot.ts +22 -4
- package/extensions/bytefly/footer/price/config.ts +16 -0
- package/extensions/bytefly/footer/price/types.ts +5 -0
- package/extensions/bytefly/footer/register.ts +34 -3
- package/extensions/bytefly/index.ts +4 -2
- package/extensions/bytefly/notify/register.ts +15 -1
- package/extensions/bytefly/panel/settings.ts +31 -0
- package/extensions/job/format.ts +4 -7
- package/extensions/todo/nudge.ts +5 -1
- package/extensions/watch/README.md +11 -5
- package/extensions/watch/commands.ts +4 -3
- package/extensions/watch/config.ts +1 -1
- package/extensions/watch/format.ts +4 -7
- package/extensions/watch/runtime.ts +83 -12
- package/extensions/worktree/README.md +7 -1
- package/extensions/worktree/enter.ts +10 -3
- package/extensions/worktree/exit.ts +25 -5
- package/extensions/worktree/git.ts +28 -0
- package/extensions/worktree/index.ts +2 -0
- package/extensions/worktree/root-tools.ts +1 -1
- package/extensions/worktree/runtime.ts +5 -0
- package/lib/README.md +4 -2
- package/lib/requirements.ts +80 -1
- package/lib/tui/columns.ts +17 -0
- package/lib/tui/tool.ts +6 -2
- package/package.json +1 -1
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.
|
|
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.
|
|
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
|
-
|
|
130
|
-
|
|
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
|
|
600
|
+
switch, with its own cursor. It lists sixteen preferences in six groups:
|
|
589
601
|
|
|
590
602
|
```
|
|
591
603
|
bytefly ──────────────────────────────────────────────────────────────
|
|
592
|
-
Extensions · Settings Global ·
|
|
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 (
|
|
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
|
|
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 ·
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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 = {}):
|
|
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",
|
package/extensions/job/format.ts
CHANGED
|
@@ -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
|
|
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 =
|
|
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 ?
|
|
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
|
}
|
package/extensions/todo/nudge.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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,
|
|
37
|
-
command's process tree
|
|
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` |
|
|
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` |
|
|
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
|
|
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
|
-
},
|
|
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
|
-
/**
|
|
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
|
|
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 =
|
|
177
|
+
const preview = labelLine(source);
|
|
181
178
|
const pattern = typeof args.pattern === "string" ? args.pattern.trim() : "";
|
|
182
|
-
return pattern ? `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
|
|
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, "
|
|
250
|
-
else w.child?.kill(
|
|
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
|
-
|
|
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
|
-
|
|
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())
|
|
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
|
-
|
|
488
|
-
|
|
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
|
-
|
|
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(
|
|
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,
|
|
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
|
-
|
|
43
|
-
|
|
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
|
|
@@ -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
|
-
|
|
8
|
-
|
|
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*
|
package/lib/requirements.ts
CHANGED
|
@@ -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
|
|
package/lib/tui/columns.ts
CHANGED
|
@@ -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:
|
|
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(
|
|
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.
|
|
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",
|