@gavin-hu/my-pi-agent 0.12.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 +28 -21
- package/README.zh-CN.md +30 -22
- package/extensions/bytefly/README.md +162 -70
- package/extensions/bytefly/chrome.ts +7 -2
- package/extensions/bytefly/doctor/register.ts +6 -0
- package/extensions/bytefly/doctor/runtime.ts +38 -6
- package/extensions/bytefly/footer/budget.ts +64 -0
- package/extensions/bytefly/footer/collect/snapshot.ts +33 -5
- package/extensions/bytefly/footer/config.ts +7 -1
- package/extensions/bytefly/footer/delegation.ts +100 -0
- package/extensions/bytefly/footer/price/config.ts +16 -0
- package/extensions/bytefly/footer/price/types.ts +5 -0
- package/extensions/bytefly/footer/register.ts +46 -4
- package/extensions/bytefly/footer/render/lines.ts +2 -0
- package/extensions/bytefly/footer/render/segments.ts +27 -0
- package/extensions/bytefly/footer/types.ts +11 -0
- package/extensions/bytefly/index.ts +8 -5
- package/extensions/bytefly/notify/config.ts +2 -0
- package/extensions/bytefly/notify/format.ts +19 -1
- package/extensions/bytefly/notify/register.ts +52 -14
- package/extensions/bytefly/notify/types.ts +2 -0
- package/extensions/bytefly/panel/commands.ts +34 -13
- package/extensions/bytefly/panel/settings.ts +129 -16
- package/extensions/bytefly/update/cache.ts +73 -0
- package/extensions/bytefly/update/format.ts +65 -10
- package/extensions/bytefly/update/register.ts +99 -13
- package/extensions/bytefly/update/state.ts +13 -1
- package/extensions/document/README.md +22 -12
- package/extensions/document/config.ts +4 -0
- package/extensions/document/deadline.ts +61 -0
- package/extensions/document/tool.ts +13 -2
- package/extensions/file-browse/README.md +12 -8
- package/extensions/file-browse/client.ts +6 -7
- package/extensions/file-browse/files.ts +0 -4
- package/extensions/file-browse/git.ts +9 -10
- package/extensions/file-browse/html.ts +17 -7
- package/extensions/file-browse/router.ts +24 -15
- package/extensions/goal/README.md +7 -1
- package/extensions/goal/schema.ts +6 -2
- package/extensions/goal/types.ts +6 -2
- package/extensions/job/README.md +16 -12
- package/extensions/job/format.ts +4 -7
- package/extensions/job/runtime.ts +23 -6
- package/extensions/job/status.ts +4 -2
- package/extensions/memory/README.md +55 -13
- package/extensions/memory/commands.ts +20 -2
- package/extensions/memory/doctor.ts +137 -0
- package/extensions/memory/format.ts +24 -7
- package/extensions/memory/runtime.ts +49 -0
- package/extensions/memory/store.ts +31 -0
- package/extensions/memory/tui.ts +136 -12
- package/extensions/plan/README.md +60 -29
- package/extensions/plan/commands.ts +67 -8
- package/extensions/plan/index.ts +35 -11
- package/extensions/plan/plan-index.ts +234 -0
- package/extensions/plan/plan-text.ts +95 -0
- package/extensions/plan/plans.ts +90 -1
- package/extensions/plan/review.ts +12 -9
- package/extensions/plan/runtime.ts +72 -27
- package/extensions/plan/steps.ts +35 -25
- package/extensions/plan/todos.ts +24 -2
- package/extensions/plan/tools.ts +124 -24
- package/extensions/plan/tui.ts +7 -28
- package/extensions/plan/types.ts +9 -0
- package/extensions/plan/web-page.ts +2 -2
- package/extensions/plan/web.ts +12 -11
- package/extensions/rewind/README.md +28 -8
- package/extensions/rewind/commands.ts +1 -1
- package/extensions/rewind/format.ts +4 -4
- package/extensions/rewind/git.ts +56 -18
- package/extensions/rewind/index.ts +3 -5
- package/extensions/rewind/path-picker.ts +198 -0
- package/extensions/rewind/paths.ts +128 -0
- package/extensions/rewind/restore.ts +69 -28
- package/extensions/rewind/rewind.ts +80 -3
- package/extensions/rewind/runtime.ts +13 -5
- package/extensions/rewind/snapshot-run.ts +5 -2
- package/extensions/rewind/types.ts +2 -0
- package/extensions/subagent/README.md +7 -1
- package/extensions/subagent/accounting.ts +34 -0
- package/extensions/subagent/index.ts +23 -1
- package/extensions/subagent/stream.ts +2 -0
- package/extensions/subagent/types.ts +2 -0
- package/extensions/todo/nudge.ts +5 -1
- package/extensions/watch/README.md +19 -8
- package/extensions/watch/commands.ts +4 -3
- package/extensions/watch/config.ts +1 -1
- package/extensions/watch/format.ts +4 -7
- package/extensions/watch/index.ts +19 -0
- package/extensions/watch/runtime.ts +83 -12
- package/extensions/web-access/README.md +43 -13
- package/extensions/web-access/charset.ts +58 -0
- package/extensions/web-access/fetch/find.ts +11 -7
- package/extensions/web-access/fetch/format.ts +64 -18
- package/extensions/web-access/fetch/page.ts +5 -1
- package/extensions/web-access/fetch/tool.ts +19 -18
- package/extensions/web-access/http.ts +49 -8
- package/extensions/web-access/search/facts.ts +38 -0
- package/extensions/web-access/search/tool.ts +13 -0
- package/extensions/web-access/text.ts +50 -11
- package/extensions/worktree/README.md +81 -16
- package/extensions/worktree/config.ts +11 -0
- package/extensions/worktree/enter.ts +10 -3
- package/extensions/worktree/exit.ts +55 -3
- package/extensions/worktree/gaps.ts +74 -0
- package/extensions/worktree/git.ts +28 -0
- package/extensions/worktree/guard.ts +50 -4
- package/extensions/worktree/handoff.ts +55 -0
- package/extensions/worktree/index.ts +14 -2
- package/extensions/worktree/root-tools.ts +1 -1
- package/extensions/worktree/runtime.ts +25 -0
- package/extensions/worktree/status.ts +15 -2
- package/extensions/worktree/tools.ts +3 -2
- package/lib/README.md +14 -10
- package/lib/events.ts +86 -1
- package/lib/goal-state.ts +95 -0
- package/lib/requirements.ts +361 -62
- package/lib/tui/README.md +91 -0
- package/lib/tui/columns.ts +17 -0
- package/lib/tui/glyphs.ts +1 -0
- package/lib/tui/tool.ts +6 -2
- package/lib/web/layout.ts +12 -2
- package/package.json +5 -3
- package/themes/bytefly-dark.json +1 -1
- package/assets/chrome.png +0 -0
- package/assets/file-browse.png +0 -0
- package/assets/job.png +0 -0
- package/assets/plan-review.png +0 -0
- package/assets/question.png +0 -0
- package/assets/rewind.png +0 -0
- package/assets/todo.png +0 -0
- package/assets/tool-renderings.png +0 -0
- package/assets/tools/ask_user_question-cancelled.png +0 -0
- package/assets/tools/ask_user_question.png +0 -0
- package/assets/tools/bash.png +0 -0
- package/assets/tools/edit.png +0 -0
- package/assets/tools/enter_plan_mode.png +0 -0
- package/assets/tools/enter_worktree.png +0 -0
- package/assets/tools/exit_plan_mode.png +0 -0
- package/assets/tools/exit_worktree.png +0 -0
- package/assets/tools/find.png +0 -0
- package/assets/tools/goal.png +0 -0
- package/assets/tools/grep.png +0 -0
- package/assets/tools/job.png +0 -0
- package/assets/tools/list_worktrees.png +0 -0
- package/assets/tools/ls.png +0 -0
- package/assets/tools/memory-long-subject.png +0 -0
- package/assets/tools/memory.png +0 -0
- package/assets/tools/notice.png +0 -0
- package/assets/tools/open_path.png +0 -0
- package/assets/tools/prune_worktrees.png +0 -0
- package/assets/tools/read.png +0 -0
- package/assets/tools/read_doc-running.png +0 -0
- package/assets/tools/read_doc.png +0 -0
- package/assets/tools/run_doctor.png +0 -0
- package/assets/tools/send_wechat.png +0 -0
- package/assets/tools/set_plan_steps.png +0 -0
- package/assets/tools/subagent.png +0 -0
- package/assets/tools/todo.png +0 -0
- package/assets/tools/turn-rule.png +0 -0
- package/assets/tools/user-message.png +0 -0
- package/assets/tools/watch.png +0 -0
- package/assets/tools/web_fetch-rejected.png +0 -0
- package/assets/tools/web_fetch.png +0 -0
- package/assets/tools/web_search.png +0 -0
- package/assets/tools/write.png +0 -0
- package/assets/tools/write_plan.png +0 -0
|
@@ -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
|
+
}
|
|
@@ -7,12 +7,17 @@
|
|
|
7
7
|
* hold thousands of entries and the footer renders on every frame. The config is
|
|
8
8
|
* captured when the reader is built, so re-reading the file means building a new
|
|
9
9
|
* reader (which is what installing the footer does).
|
|
10
|
+
*
|
|
11
|
+
* The child-run ledger's version joins the memo key: a finished subagent invalidates
|
|
12
|
+
* the same way an appended entry does, so the roll-up appears on the next frame
|
|
13
|
+
* without re-pricing every frame.
|
|
10
14
|
*/
|
|
11
15
|
|
|
12
16
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
17
|
+
import type { DelegationLedger } from "../delegation.ts";
|
|
13
18
|
import { createPricer } from "../price/price.ts";
|
|
14
19
|
import type { ByteflyConfig } from "../price/types.ts";
|
|
15
|
-
import type { ContextInfo, FooterData, StatusSnapshot } from "../types.ts";
|
|
20
|
+
import type { ContextInfo, FooterData, StatusSnapshot, UsageTotals } from "../types.ts";
|
|
16
21
|
import { emptyUsage, usageRecords } from "./entries.ts";
|
|
17
22
|
|
|
18
23
|
/**
|
|
@@ -23,22 +28,41 @@ import { emptyUsage, usageRecords } from "./entries.ts";
|
|
|
23
28
|
* are stale; this avoids copying the full entry list and rebuilding the session
|
|
24
29
|
* projection per frame. Pi's own footer memoizes its stats on the same idea.
|
|
25
30
|
*/
|
|
26
|
-
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 {
|
|
27
45
|
const price = createPricer(config);
|
|
28
46
|
let key: string | null = null;
|
|
29
47
|
let usage = emptyUsage();
|
|
48
|
+
let delegationTotals = { runs: 0, usage: emptyUsage() };
|
|
30
49
|
let cacheHitRate: number | null = null;
|
|
31
50
|
let context: ContextInfo = { tokens: null, contextWindow: 0, percent: null };
|
|
32
51
|
|
|
33
|
-
|
|
52
|
+
let last: UsageTotals | undefined;
|
|
53
|
+
|
|
54
|
+
const reader = ((ctx: ExtensionContext, footerData: FooterData): StatusSnapshot => {
|
|
34
55
|
const session = ctx.sessionManager;
|
|
35
56
|
const current = ctx.model;
|
|
36
|
-
const nextKey = `${session.getSessionId()}:${session.getLeafId() ?? ""}:${current?.provider ?? ""}/${current?.id ?? ""}`;
|
|
57
|
+
const nextKey = `${session.getSessionId()}:${session.getLeafId() ?? ""}:${current?.provider ?? ""}/${current?.id ?? ""}:${delegation.version()}`;
|
|
37
58
|
if (nextKey !== key) {
|
|
38
59
|
const active = current ? { provider: current.provider, model: current.id } : undefined;
|
|
39
60
|
const scanned = usageRecords(session.getEntries(), active);
|
|
40
61
|
usage = price(scanned.records);
|
|
41
62
|
cacheHitRate = scanned.cacheHitRate;
|
|
63
|
+
// The child runs are priced by the same pricer, so a currency or rate
|
|
64
|
+
// change moves both numbers together — and neither is folded into the other.
|
|
65
|
+
delegationTotals = { runs: delegation.runs(), usage: price(delegation.records()) };
|
|
42
66
|
|
|
43
67
|
const usageInfo = ctx.getContextUsage();
|
|
44
68
|
context = {
|
|
@@ -47,6 +71,7 @@ export function createSnapshotReader(config: ByteflyConfig) {
|
|
|
47
71
|
percent: usageInfo?.percent ?? null,
|
|
48
72
|
};
|
|
49
73
|
key = nextKey;
|
|
74
|
+
last = usage;
|
|
50
75
|
}
|
|
51
76
|
|
|
52
77
|
const model = current
|
|
@@ -65,6 +90,9 @@ export function createSnapshotReader(config: ByteflyConfig) {
|
|
|
65
90
|
currency: config.currency,
|
|
66
91
|
cacheHitRate,
|
|
67
92
|
providerCount: footerData.getAvailableProviderCount(),
|
|
93
|
+
delegation: delegationTotals,
|
|
68
94
|
};
|
|
69
|
-
};
|
|
95
|
+
}) as SnapshotReader;
|
|
96
|
+
reader.peek = () => last;
|
|
97
|
+
return reader;
|
|
70
98
|
}
|
|
@@ -16,7 +16,13 @@ export const CONFIG = {
|
|
|
16
16
|
gauge: { full: GLYPHS.gaugeFull, empty: GLYPHS.gaugeEmpty, widths: [10, 5, 3, 0] as const },
|
|
17
17
|
/** Separators printed before grouped and other segments. */
|
|
18
18
|
separators: { group: SEPARATORS.group, item: SEPARATORS.item },
|
|
19
|
-
icons: {
|
|
19
|
+
icons: {
|
|
20
|
+
branch: GLYPHS.branch,
|
|
21
|
+
detached: GLYPHS.detached,
|
|
22
|
+
worktree: GLYPHS.worktree,
|
|
23
|
+
browse: GLYPHS.browse,
|
|
24
|
+
delegation: GLYPHS.delegation,
|
|
25
|
+
},
|
|
20
26
|
/** Status key the worktree extension uses; routed to line 1's right zone. */
|
|
21
27
|
worktreeStatusKey: STATUS_KEYS.worktree,
|
|
22
28
|
/** Status key the file-browse extension uses; routed to line 1's right zone. */
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The child-run ledger: what this session's delegated runs spent.
|
|
3
|
+
*
|
|
4
|
+
* `subagent` publishes one event per finished child process on
|
|
5
|
+
* `SUBAGENT_FINISHED_CHANNEL`. The footer shows that spend as its own number, so
|
|
6
|
+
* the session's total keeps meaning what it always did.
|
|
7
|
+
*
|
|
8
|
+
* The ledger keeps *pricing inputs*, not a total: `currency.code` can change
|
|
9
|
+
* between sessions (a settings edit re-installs the footer and rebuilds the
|
|
10
|
+
* pricer), and a stored figure would then be in the wrong unit. It also keeps the
|
|
11
|
+
* peak tariff honest, because each record carries the time the run finished.
|
|
12
|
+
*
|
|
13
|
+
* Bounded on purpose: the last {@link MAX_DELEGATION_RECORDS} runs are retained —
|
|
14
|
+
* each is a full child `pi` process, so hundreds in one session is already
|
|
15
|
+
* extraordinary — while the run count keeps counting past the window. Every field
|
|
16
|
+
* of an event is re-validated, because the bus is not a trusted channel.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { SubagentFinishedEvent } from "../../../lib/events.ts";
|
|
20
|
+
import type { UsageRecord } from "./types.ts";
|
|
21
|
+
|
|
22
|
+
/** Runs whose pricing inputs are retained. */
|
|
23
|
+
export const MAX_DELEGATION_RECORDS = 500;
|
|
24
|
+
|
|
25
|
+
export interface DelegationLedger {
|
|
26
|
+
/** Note one finished child run; a malformed payload is ignored. */
|
|
27
|
+
note(event: unknown): void;
|
|
28
|
+
/** How many runs finished this session, including ones past the window. */
|
|
29
|
+
runs(): number;
|
|
30
|
+
/** Pricing inputs for the retained runs, oldest first. */
|
|
31
|
+
records(): UsageRecord[];
|
|
32
|
+
/** Changes on every noted run, so a reader can tell its totals are stale. */
|
|
33
|
+
version(): number;
|
|
34
|
+
/** Forget everything; called at session start. */
|
|
35
|
+
reset(): void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A finite, non-negative number, or `null` when the field is not one. */
|
|
39
|
+
function count(value: unknown): number | null {
|
|
40
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A non-empty string, or `null`. */
|
|
44
|
+
function text(value: unknown): string | null {
|
|
45
|
+
return typeof value === "string" && value.trim() !== "" ? value : null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The event as a usage record, or `null` when it is not one we can price. */
|
|
49
|
+
function toRecord(event: SubagentFinishedEvent): UsageRecord | null {
|
|
50
|
+
if (text(event.agent) === null) return null;
|
|
51
|
+
const input = count(event.input);
|
|
52
|
+
const output = count(event.output);
|
|
53
|
+
const cacheRead = count(event.cacheRead);
|
|
54
|
+
const cacheWrite = count(event.cacheWrite);
|
|
55
|
+
const recordedCost = count(event.recordedCost);
|
|
56
|
+
// A type-mismatched field is a malformed event, not a zero: refuse the whole
|
|
57
|
+
// record rather than counting a run whose spend is unknown.
|
|
58
|
+
if (input === null || output === null || cacheRead === null || cacheWrite === null || recordedCost === null) {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
provider: text(event.provider),
|
|
63
|
+
model: text(event.model),
|
|
64
|
+
input,
|
|
65
|
+
output,
|
|
66
|
+
cacheRead,
|
|
67
|
+
cacheWrite,
|
|
68
|
+
recordedCost,
|
|
69
|
+
at: typeof event.at === "number" && Number.isFinite(event.at) ? event.at : null,
|
|
70
|
+
isAssistant: true,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function createDelegationLedger(): DelegationLedger {
|
|
75
|
+
let retained: UsageRecord[] = [];
|
|
76
|
+
let total = 0;
|
|
77
|
+
let version = 0;
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
note(event) {
|
|
81
|
+
if (!event || typeof event !== "object") return;
|
|
82
|
+
const record = toRecord(event as SubagentFinishedEvent);
|
|
83
|
+
if (!record) return;
|
|
84
|
+
total += 1;
|
|
85
|
+
version += 1;
|
|
86
|
+
retained.push(record);
|
|
87
|
+
// Keep the window at the cap without re-allocating the whole array per run
|
|
88
|
+
// until it is actually exceeded.
|
|
89
|
+
if (retained.length > MAX_DELEGATION_RECORDS) retained = retained.slice(-MAX_DELEGATION_RECORDS);
|
|
90
|
+
},
|
|
91
|
+
runs: () => total,
|
|
92
|
+
records: () => [...retained],
|
|
93
|
+
version: () => version,
|
|
94
|
+
reset() {
|
|
95
|
+
retained = [];
|
|
96
|
+
total = 0;
|
|
97
|
+
version = 0;
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
@@ -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
|
}
|
|
@@ -11,16 +11,36 @@
|
|
|
11
11
|
|
|
12
12
|
import { homedir } from "node:os";
|
|
13
13
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
14
|
-
import {
|
|
14
|
+
import { SUBAGENT_FINISHED_CHANNEL } from "../../../lib/events.ts";
|
|
15
|
+
import { budgetBody, budgetNotice, budgetState } from "./budget.ts";
|
|
16
|
+
import { createSnapshotReader, type SnapshotReader } from "./collect/snapshot.ts";
|
|
17
|
+
import { createDelegationLedger } from "./delegation.ts";
|
|
15
18
|
import { loadByteflyConfig } from "./price/config.ts";
|
|
16
19
|
import { createFooter } from "./render/footer.ts";
|
|
17
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
|
+
|
|
18
27
|
/** Register the footer install/restore lifecycle. */
|
|
19
|
-
export function registerFooter(pi: ExtensionAPI): void {
|
|
28
|
+
export function registerFooter(pi: ExtensionAPI, deps: FooterDeps = {}): void {
|
|
29
|
+
// One ledger per load: it is this session's child-run accounting, priced by
|
|
30
|
+
// whichever reader is installed (a config change installs a new one).
|
|
31
|
+
const delegation = createDelegationLedger();
|
|
32
|
+
pi.events.on(SUBAGENT_FINISHED_CHANNEL, (event) => delegation.note(event));
|
|
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
|
+
|
|
20
39
|
const install = (ctx: ExtensionContext): void => {
|
|
21
40
|
if (ctx.mode !== "tui") return;
|
|
22
41
|
const config = loadByteflyConfig(ctx.cwd);
|
|
23
|
-
const read = createSnapshotReader(config);
|
|
42
|
+
const read = createSnapshotReader(config, delegation);
|
|
43
|
+
budget = { limit: config.budget, read };
|
|
24
44
|
|
|
25
45
|
ctx.ui.setFooter((tui, _theme, footerData) =>
|
|
26
46
|
createFooter(
|
|
@@ -33,9 +53,31 @@ export function registerFooter(pi: ExtensionAPI): void {
|
|
|
33
53
|
);
|
|
34
54
|
};
|
|
35
55
|
|
|
36
|
-
pi.on("session_start", (_event, ctx) =>
|
|
56
|
+
pi.on("session_start", (_event, ctx) => {
|
|
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.
|
|
59
|
+
delegation.reset();
|
|
60
|
+
warned = false;
|
|
61
|
+
install(ctx);
|
|
62
|
+
});
|
|
37
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
|
+
|
|
38
79
|
pi.on("session_shutdown", (_event, ctx) => {
|
|
80
|
+
budget = undefined;
|
|
39
81
|
if (ctx.mode !== "tui") return;
|
|
40
82
|
ctx.ui.setFooter(undefined);
|
|
41
83
|
});
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
cacheSegment,
|
|
13
13
|
contextSegment,
|
|
14
14
|
costSegment,
|
|
15
|
+
delegationSegment,
|
|
15
16
|
modelSegment,
|
|
16
17
|
modesSegment,
|
|
17
18
|
pwdSegment,
|
|
@@ -39,6 +40,7 @@ export function buildLines(snapshot: StatusSnapshot, theme: Theme, home: string
|
|
|
39
40
|
contextSegment(snapshot, theme),
|
|
40
41
|
windowSegment(snapshot, theme),
|
|
41
42
|
costSegment(snapshot, theme),
|
|
43
|
+
delegationSegment(snapshot, theme),
|
|
42
44
|
tokensSegment(snapshot, theme),
|
|
43
45
|
cacheSegment(snapshot, theme),
|
|
44
46
|
modesSegment(snapshot, theme),
|
|
@@ -182,6 +182,33 @@ export function costSegment(snapshot: StatusSnapshot, theme: Theme): Segment | n
|
|
|
182
182
|
};
|
|
183
183
|
}
|
|
184
184
|
|
|
185
|
+
/**
|
|
186
|
+
* Child-run spend, next to the session's own cost and never inside it: a
|
|
187
|
+
* subagent runs in its own process, so its tokens are not the session's. The
|
|
188
|
+
* roll-up covers the ledger's retained window (the last 500 runs).
|
|
189
|
+
*/
|
|
190
|
+
export function delegationSegment(snapshot: StatusSnapshot, theme: Theme): Segment | null {
|
|
191
|
+
const { runs, usage } = snapshot.delegation;
|
|
192
|
+
if (runs === 0) return null;
|
|
193
|
+
const icon = CONFIG.icons.delegation;
|
|
194
|
+
const cost = formatCost(usage.cost, snapshot.currency);
|
|
195
|
+
// Detailed first; the run count survives longer than the price, because "some
|
|
196
|
+
// delegation happened" is the part a narrow bar can still say.
|
|
197
|
+
const forms = [
|
|
198
|
+
`${paint(theme, "meta", `${icon} ${runs} · ${cost}`)}`,
|
|
199
|
+
`${paint(theme, "meta", `${icon} ${cost}`)}`,
|
|
200
|
+
paint(theme, "meta", `${icon} ${runs}`),
|
|
201
|
+
paint(theme, "meta", icon),
|
|
202
|
+
];
|
|
203
|
+
return {
|
|
204
|
+
id: "delegation",
|
|
205
|
+
weight: 4,
|
|
206
|
+
droppable: true,
|
|
207
|
+
separator: faint(theme, CONFIG.separators.item),
|
|
208
|
+
forms,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
185
212
|
export function tokensSegment(snapshot: StatusSnapshot, theme: Theme): Segment | null {
|
|
186
213
|
const { input, output } = snapshot.usage;
|
|
187
214
|
if (input === 0 && output === 0) return null;
|
|
@@ -70,6 +70,17 @@ export interface StatusSnapshot {
|
|
|
70
70
|
/** Latest cache hit rate as a percentage, or `null` when unknown. */
|
|
71
71
|
cacheHitRate: number | null;
|
|
72
72
|
providerCount: number;
|
|
73
|
+
/**
|
|
74
|
+
* What this session's delegated child runs spent, priced like `usage` but kept
|
|
75
|
+
* apart from it: the session total never includes child spend.
|
|
76
|
+
*/
|
|
77
|
+
delegation: DelegationTotals;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Child-run totals: how many runs finished, and what the retained window spent. */
|
|
81
|
+
export interface DelegationTotals {
|
|
82
|
+
runs: number;
|
|
83
|
+
usage: UsageTotals;
|
|
73
84
|
}
|
|
74
85
|
|
|
75
86
|
/** One renderable piece of a line. */
|
|
@@ -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,8 +75,8 @@ 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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
78
|
+
// The returned handle is what `/bytefly update` re-runs, so the update surface is
|
|
79
|
+
// registered before the panel that offers the command.
|
|
80
|
+
const update = registerUpdate(pi, updates, { ...deps.update, onUpdate: () => header.refresh() });
|
|
81
|
+
registerPanel(pi, { doctor, update, discover: deps.extensions?.discover });
|
|
79
82
|
}
|
|
@@ -25,6 +25,7 @@ export const DEFAULT_NOTIFY_CONFIG: NotifyConfig = {
|
|
|
25
25
|
turnFinished: true,
|
|
26
26
|
needsInput: true,
|
|
27
27
|
jobFinished: true,
|
|
28
|
+
matchFound: true,
|
|
28
29
|
method: "auto",
|
|
29
30
|
title: "Pi",
|
|
30
31
|
};
|
|
@@ -49,6 +50,7 @@ export function normalizeNotifyConfig(raw: Record<string, unknown> | undefined,
|
|
|
49
50
|
turnFinished: bool(raw.turnFinished, base.turnFinished),
|
|
50
51
|
needsInput: bool(raw.needsInput, base.needsInput),
|
|
51
52
|
jobFinished: bool(raw.jobFinished, base.jobFinished),
|
|
53
|
+
matchFound: bool(raw.matchFound, base.matchFound),
|
|
52
54
|
method: method(raw.method, base.method),
|
|
53
55
|
title: cleanString(raw.title, base.title).slice(0, MAX_TITLE_LENGTH),
|
|
54
56
|
};
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { basename } from "node:path";
|
|
11
|
-
import type { JobFinishedEvent } from "../../../lib/events.ts";
|
|
11
|
+
import type { JobFinishedEvent, WatchMatchEvent } from "../../../lib/events.ts";
|
|
12
12
|
import { sanitizeLine } from "../../../lib/format.ts";
|
|
13
13
|
|
|
14
14
|
/** A short session label, so concurrent unattended runs can be told apart. */
|
|
@@ -37,3 +37,21 @@ export function formatJobBody(job: JobFinishedEvent): string {
|
|
|
37
37
|
const label = sanitizeLine(job.label);
|
|
38
38
|
return `Job ${job.id} ${verb}${code}${label ? ` · ${label}` : ""}`;
|
|
39
39
|
}
|
|
40
|
+
|
|
41
|
+
/** How much of a matched line an OS notification carries. */
|
|
42
|
+
export const MAX_WATCH_LINE = 80;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Body for a `watch` wake match: the newest line that matched, and how many did.
|
|
46
|
+
*
|
|
47
|
+
* The line is watched output rather than something this package wrote, so it is
|
|
48
|
+
* sanitized and clipped here — the one place a notification's text is shaped.
|
|
49
|
+
*/
|
|
50
|
+
export function formatWatchBody(event: WatchMatchEvent): string {
|
|
51
|
+
const count = Math.max(1, event.count);
|
|
52
|
+
const label = sanitizeLine(event.label);
|
|
53
|
+
const line = sanitizeLine(event.line).slice(0, MAX_WATCH_LINE).trimEnd();
|
|
54
|
+
const head = `${count} watched line${count === 1 ? "" : "s"} matched`;
|
|
55
|
+
const source = `${sanitizeLine(event.watcherId)}${label ? ` ${label}` : ""}`;
|
|
56
|
+
return line ? `${head} · ${source}: ${line}` : `${head} · ${source}`;
|
|
57
|
+
}
|
|
@@ -9,8 +9,9 @@
|
|
|
9
9
|
* asking for input. A prompt while idle is a command the user typed, so it
|
|
10
10
|
* does not fire;
|
|
11
11
|
* - a finished background job, announced by the `job` extension on
|
|
12
|
-
* {@link JOB_FINISHED_CHANNEL}
|
|
13
|
-
*
|
|
12
|
+
* {@link JOB_FINISHED_CHANNEL}; and a `watch` wake match, announced by the
|
|
13
|
+
* `watch` extension on {@link WATCH_MATCH_CHANNEL}. The three extensions never
|
|
14
|
+
* import each other; the bus and its payload are the whole contract.
|
|
14
15
|
*
|
|
15
16
|
* The config is read at `session_start` and the bus subscription is a
|
|
16
17
|
* long-lived resource, so it is created there too and released in an
|
|
@@ -18,10 +19,15 @@
|
|
|
18
19
|
*/
|
|
19
20
|
|
|
20
21
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
21
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
JOB_FINISHED_CHANNEL,
|
|
24
|
+
type JobFinishedEvent,
|
|
25
|
+
WATCH_MATCH_CHANNEL,
|
|
26
|
+
type WatchMatchEvent,
|
|
27
|
+
} from "../../../lib/events.ts";
|
|
22
28
|
import { DEFAULT_NOTIFY_CONFIG, loadNotifyConfig } from "./config.ts";
|
|
23
29
|
import { defaultSeams, type NotifySeams, sendNotification } from "./dispatch.ts";
|
|
24
|
-
import { formatInputBody, formatJobBody, formatTurnBody, sessionLabel } from "./format.ts";
|
|
30
|
+
import { formatInputBody, formatJobBody, formatTurnBody, formatWatchBody, sessionLabel } from "./format.ts";
|
|
25
31
|
import type { NotifyConfig } from "./types.ts";
|
|
26
32
|
|
|
27
33
|
/** Host seams, injected by tests. */
|
|
@@ -37,12 +43,31 @@ function isJobFinished(value: unknown): value is JobFinishedEvent {
|
|
|
37
43
|
return typeof event.id === "string" && typeof event.label === "string" && typeof event.status === "string";
|
|
38
44
|
}
|
|
39
45
|
|
|
40
|
-
/**
|
|
41
|
-
|
|
46
|
+
/** Narrow an untrusted bus payload to a wake match. */
|
|
47
|
+
function isWatchMatch(value: unknown): value is WatchMatchEvent {
|
|
48
|
+
if (!value || typeof value !== "object") return false;
|
|
49
|
+
const event = value as Partial<WatchMatchEvent>;
|
|
50
|
+
return (
|
|
51
|
+
typeof event.count === "number" &&
|
|
52
|
+
typeof event.watcherId === "string" &&
|
|
53
|
+
typeof event.label === "string" &&
|
|
54
|
+
typeof event.line === "string"
|
|
55
|
+
);
|
|
56
|
+
}
|
|
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
|
+
|
|
64
|
+
/** Register the notification triggers. */
|
|
65
|
+
export function registerNotify(pi: ExtensionAPI, deps: NotifyDeps = {}): NotifyHandle {
|
|
42
66
|
const seams = deps.seams ?? defaultSeams();
|
|
43
67
|
let config: NotifyConfig = DEFAULT_NOTIFY_CONFIG;
|
|
44
68
|
let busy = false;
|
|
45
|
-
|
|
69
|
+
/** Bus subscriptions: one per emitting extension, all created at session start. */
|
|
70
|
+
const unsubscribes: Array<() => void> = [];
|
|
46
71
|
|
|
47
72
|
const fire = (body: string): void => {
|
|
48
73
|
sendNotification(config, config.title, body, seams);
|
|
@@ -59,11 +84,17 @@ export function registerNotify(pi: ExtensionAPI, deps: NotifyDeps = {}): void {
|
|
|
59
84
|
pi.on("session_start", (_event, ctx) => {
|
|
60
85
|
config = loadNotifyConfig(ctx.cwd);
|
|
61
86
|
busy = false;
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
87
|
+
while (unsubscribes.length > 0) unsubscribes.pop()?.();
|
|
88
|
+
unsubscribes.push(
|
|
89
|
+
pi.events.on(JOB_FINISHED_CHANNEL, (data) => {
|
|
90
|
+
if (!config.jobFinished || !isJobFinished(data)) return;
|
|
91
|
+
fire(formatJobBody(data));
|
|
92
|
+
}),
|
|
93
|
+
pi.events.on(WATCH_MATCH_CHANNEL, (data) => {
|
|
94
|
+
if (!config.matchFound || !isWatchMatch(data)) return;
|
|
95
|
+
fire(formatWatchBody(data));
|
|
96
|
+
}),
|
|
97
|
+
);
|
|
67
98
|
});
|
|
68
99
|
|
|
69
100
|
pi.on("agent_settled", (event, ctx) => {
|
|
@@ -78,8 +109,15 @@ export function registerNotify(pi: ExtensionAPI, deps: NotifyDeps = {}): void {
|
|
|
78
109
|
});
|
|
79
110
|
|
|
80
111
|
pi.on("session_shutdown", () => {
|
|
81
|
-
|
|
82
|
-
unsubscribe = undefined;
|
|
112
|
+
while (unsubscribes.length > 0) unsubscribes.pop()?.();
|
|
83
113
|
busy = false;
|
|
84
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
|
+
};
|
|
85
123
|
}
|
|
@@ -21,6 +21,8 @@ export interface NotifyConfig {
|
|
|
21
21
|
needsInput: boolean;
|
|
22
22
|
/** Notify when a background `job` finishes. */
|
|
23
23
|
jobFinished: boolean;
|
|
24
|
+
/** Notify when a `watch` wake match fires while the agent is idle. */
|
|
25
|
+
matchFound: boolean;
|
|
24
26
|
/** Delivery method; `auto` chooses by terminal and platform. */
|
|
25
27
|
method: NotifyMethod;
|
|
26
28
|
/** Notification title, e.g. `Pi`. */
|