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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (197) hide show
  1. package/README.md +38 -26
  2. package/README.zh-CN.md +33 -23
  3. package/extensions/bytefly/README.md +381 -225
  4. package/extensions/bytefly/chrome.ts +7 -2
  5. package/extensions/bytefly/doctor/register.ts +6 -0
  6. package/extensions/bytefly/doctor/runtime.ts +52 -6
  7. package/extensions/bytefly/doctor/tools.ts +1 -3
  8. package/extensions/bytefly/footer/collect/snapshot.ts +12 -2
  9. package/extensions/bytefly/footer/config.ts +7 -1
  10. package/extensions/bytefly/footer/delegation.ts +100 -0
  11. package/extensions/bytefly/footer/price/config.ts +54 -15
  12. package/extensions/bytefly/footer/price/price.ts +20 -7
  13. package/extensions/bytefly/footer/price/types.ts +13 -7
  14. package/extensions/bytefly/footer/register.ts +13 -2
  15. package/extensions/bytefly/footer/render/format.ts +3 -2
  16. package/extensions/bytefly/footer/render/lines.ts +2 -0
  17. package/extensions/bytefly/footer/render/segments.ts +30 -2
  18. package/extensions/bytefly/footer/types.ts +12 -0
  19. package/extensions/bytefly/header/component.ts +21 -6
  20. package/extensions/bytefly/header/config.ts +3 -0
  21. package/extensions/bytefly/header/register.ts +34 -3
  22. package/extensions/bytefly/index.ts +15 -4
  23. package/extensions/bytefly/notify/config.ts +2 -0
  24. package/extensions/bytefly/notify/format.ts +19 -1
  25. package/extensions/bytefly/notify/register.ts +37 -13
  26. package/extensions/bytefly/notify/types.ts +2 -0
  27. package/extensions/bytefly/panel/commands.ts +34 -13
  28. package/extensions/bytefly/panel/settings-runtime.ts +71 -28
  29. package/extensions/bytefly/panel/settings.ts +293 -48
  30. package/extensions/bytefly/panel/tui/entries.ts +58 -0
  31. package/extensions/bytefly/panel/tui/extensions-view.ts +86 -0
  32. package/extensions/bytefly/panel/tui/frame.ts +71 -0
  33. package/extensions/bytefly/panel/tui/nav.ts +75 -0
  34. package/extensions/bytefly/panel/tui/settings-view.ts +115 -0
  35. package/extensions/bytefly/panel/tui.ts +205 -319
  36. package/extensions/bytefly/picker/runtime.ts +61 -2
  37. package/extensions/bytefly/picker/toggle.ts +56 -0
  38. package/extensions/bytefly/update/cache.ts +73 -0
  39. package/extensions/bytefly/update/check.ts +59 -0
  40. package/extensions/bytefly/update/format.ts +87 -0
  41. package/extensions/bytefly/update/register.ts +158 -0
  42. package/extensions/bytefly/update/state.ts +40 -0
  43. package/extensions/bytefly/update/version.ts +83 -0
  44. package/extensions/document/README.md +24 -14
  45. package/extensions/document/config.ts +4 -0
  46. package/extensions/document/deadline.ts +61 -0
  47. package/extensions/document/tool.ts +14 -5
  48. package/extensions/file-browse/README.md +12 -8
  49. package/extensions/file-browse/client.ts +6 -7
  50. package/extensions/file-browse/files.ts +0 -4
  51. package/extensions/file-browse/git.ts +9 -10
  52. package/extensions/file-browse/html.ts +17 -7
  53. package/extensions/file-browse/router.ts +24 -15
  54. package/extensions/file-open/README.md +30 -15
  55. package/extensions/file-open/index.ts +4 -3
  56. package/extensions/file-open/open.ts +52 -21
  57. package/extensions/goal/README.md +45 -13
  58. package/extensions/goal/clarify-prompt.ts +5 -36
  59. package/extensions/goal/clarify.ts +55 -86
  60. package/extensions/goal/commands.ts +41 -35
  61. package/extensions/goal/format.ts +12 -4
  62. package/extensions/goal/schema.ts +6 -2
  63. package/extensions/goal/tools.ts +28 -8
  64. package/extensions/goal/tui.ts +1 -1
  65. package/extensions/goal/types.ts +6 -2
  66. package/extensions/job/README.md +16 -12
  67. package/extensions/job/runtime.ts +23 -6
  68. package/extensions/job/status.ts +4 -2
  69. package/extensions/memory/README.md +56 -14
  70. package/extensions/memory/commands.ts +20 -2
  71. package/extensions/memory/doctor.ts +137 -0
  72. package/extensions/memory/format.ts +24 -7
  73. package/extensions/memory/runtime.ts +49 -0
  74. package/extensions/memory/store.ts +31 -0
  75. package/extensions/memory/tui.ts +136 -12
  76. package/extensions/plan/README.md +83 -36
  77. package/extensions/plan/blocks.ts +1 -35
  78. package/extensions/plan/commands.ts +84 -19
  79. package/extensions/plan/index.ts +35 -11
  80. package/extensions/plan/plan-index.ts +234 -0
  81. package/extensions/plan/plan-text.ts +95 -0
  82. package/extensions/plan/plans.ts +91 -2
  83. package/extensions/plan/policy.ts +4 -3
  84. package/extensions/plan/review.ts +246 -0
  85. package/extensions/plan/runtime.ts +73 -24
  86. package/extensions/plan/steps.ts +60 -39
  87. package/extensions/plan/todos.ts +29 -60
  88. package/extensions/plan/tools.ts +188 -244
  89. package/extensions/plan/tui.ts +18 -31
  90. package/extensions/plan/types.ts +12 -0
  91. package/extensions/plan/web-client.ts +51 -18
  92. package/extensions/plan/web-page.ts +7 -2
  93. package/extensions/plan/web-router.ts +11 -3
  94. package/extensions/plan/web.ts +106 -41
  95. package/extensions/question/README.md +1 -1
  96. package/extensions/question/tools.ts +1 -1
  97. package/extensions/rewind/README.md +28 -8
  98. package/extensions/rewind/commands.ts +1 -1
  99. package/extensions/rewind/format.ts +4 -4
  100. package/extensions/rewind/git.ts +56 -18
  101. package/extensions/rewind/index.ts +3 -5
  102. package/extensions/rewind/path-picker.ts +198 -0
  103. package/extensions/rewind/paths.ts +128 -0
  104. package/extensions/rewind/restore.ts +69 -28
  105. package/extensions/rewind/rewind.ts +80 -3
  106. package/extensions/rewind/runtime.ts +13 -5
  107. package/extensions/rewind/snapshot-run.ts +5 -2
  108. package/extensions/rewind/types.ts +2 -0
  109. package/extensions/subagent/README.md +7 -1
  110. package/extensions/subagent/accounting.ts +34 -0
  111. package/extensions/subagent/index.ts +23 -3
  112. package/extensions/subagent/stream.ts +2 -0
  113. package/extensions/subagent/types.ts +2 -0
  114. package/extensions/todo/README.md +19 -11
  115. package/extensions/todo/index.ts +5 -3
  116. package/extensions/todo/nudge.ts +58 -9
  117. package/extensions/todo/runtime.ts +28 -17
  118. package/extensions/watch/README.md +8 -3
  119. package/extensions/watch/index.ts +19 -0
  120. package/extensions/web-access/README.md +46 -16
  121. package/extensions/web-access/charset.ts +58 -0
  122. package/extensions/web-access/fetch/find.ts +11 -7
  123. package/extensions/web-access/fetch/format.ts +64 -18
  124. package/extensions/web-access/fetch/page.ts +5 -1
  125. package/extensions/web-access/fetch/tool.ts +20 -21
  126. package/extensions/web-access/http.ts +49 -8
  127. package/extensions/web-access/search/facts.ts +38 -0
  128. package/extensions/web-access/search/tool.ts +14 -3
  129. package/extensions/web-access/text.ts +50 -11
  130. package/extensions/wechat/README.md +5 -4
  131. package/extensions/wechat/tools.ts +0 -2
  132. package/extensions/worktree/README.md +74 -15
  133. package/extensions/worktree/config.ts +11 -0
  134. package/extensions/worktree/exit.ts +35 -3
  135. package/extensions/worktree/gaps.ts +74 -0
  136. package/extensions/worktree/guard.ts +50 -4
  137. package/extensions/worktree/handoff.ts +55 -0
  138. package/extensions/worktree/index.ts +12 -2
  139. package/extensions/worktree/runtime.ts +20 -0
  140. package/extensions/worktree/status.ts +15 -2
  141. package/extensions/worktree/tools.ts +3 -2
  142. package/lib/README.md +12 -9
  143. package/lib/config.ts +43 -1
  144. package/lib/env.ts +12 -0
  145. package/lib/events.ts +86 -1
  146. package/lib/goal-state.ts +95 -0
  147. package/lib/requirements.ts +340 -46
  148. package/lib/todo-state.ts +62 -0
  149. package/lib/tui/README.md +91 -0
  150. package/lib/tui/glyphs.ts +2 -0
  151. package/lib/web/layout.ts +12 -2
  152. package/package.json +5 -3
  153. package/themes/bytefly-dark.json +1 -1
  154. package/assets/bytefly.schema.json +0 -785
  155. package/assets/chrome.png +0 -0
  156. package/assets/file-browse.png +0 -0
  157. package/assets/job.png +0 -0
  158. package/assets/plan-review.png +0 -0
  159. package/assets/question.png +0 -0
  160. package/assets/rewind.png +0 -0
  161. package/assets/todo.png +0 -0
  162. package/assets/tool-renderings.png +0 -0
  163. package/assets/tools/ask_user_question-cancelled.png +0 -0
  164. package/assets/tools/ask_user_question.png +0 -0
  165. package/assets/tools/bash.png +0 -0
  166. package/assets/tools/edit.png +0 -0
  167. package/assets/tools/enter_plan_mode.png +0 -0
  168. package/assets/tools/enter_worktree.png +0 -0
  169. package/assets/tools/exit_plan_mode.png +0 -0
  170. package/assets/tools/exit_worktree.png +0 -0
  171. package/assets/tools/find.png +0 -0
  172. package/assets/tools/goal.png +0 -0
  173. package/assets/tools/grep.png +0 -0
  174. package/assets/tools/job.png +0 -0
  175. package/assets/tools/list_worktrees.png +0 -0
  176. package/assets/tools/ls.png +0 -0
  177. package/assets/tools/memory-long-subject.png +0 -0
  178. package/assets/tools/memory.png +0 -0
  179. package/assets/tools/notice.png +0 -0
  180. package/assets/tools/open_path.png +0 -0
  181. package/assets/tools/prune_worktrees.png +0 -0
  182. package/assets/tools/read.png +0 -0
  183. package/assets/tools/read_doc-running.png +0 -0
  184. package/assets/tools/read_doc.png +0 -0
  185. package/assets/tools/run_doctor.png +0 -0
  186. package/assets/tools/send_wechat.png +0 -0
  187. package/assets/tools/set_plan_steps.png +0 -0
  188. package/assets/tools/subagent.png +0 -0
  189. package/assets/tools/todo.png +0 -0
  190. package/assets/tools/turn-rule.png +0 -0
  191. package/assets/tools/user-message.png +0 -0
  192. package/assets/tools/watch.png +0 -0
  193. package/assets/tools/web_fetch-rejected.png +0 -0
  194. package/assets/tools/web_fetch.png +0 -0
  195. package/assets/tools/web_search.png +0 -0
  196. package/assets/tools/write.png +0 -0
  197. package/assets/tools/write_plan.png +0 -0
@@ -50,8 +50,13 @@ export const DEFAULT_RAIL_GLYPH: string = GLYPHS.divider;
50
50
 
51
51
  export const DEFAULT_CHROME_CONFIG: ChromeConfig = { rail: DEFAULT_RAIL_GLYPH };
52
52
 
53
- /** Whether `value` is a single, unstyled terminal column. */
54
- function isRailGlyph(value: string): boolean {
53
+ /**
54
+ * Whether `value` is a single, unstyled terminal column.
55
+ *
56
+ * The Settings row validates with this same function, so the editor cannot save
57
+ * a glyph the next read would replace.
58
+ */
59
+ export function isRailGlyph(value: string): boolean {
55
60
  // Control characters first: a bare ESC confuses a width measurement.
56
61
  if (/[\u0000-\u001f\u007f]/.test(value)) return false;
57
62
  return [...value].length === 1 && visibleWidth(value) === 1;
@@ -16,6 +16,7 @@
16
16
  */
17
17
 
18
18
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
19
+ import { WEB_SEARCH_PROVIDER_CHANNEL } from "../../../lib/events.ts";
19
20
  import { missingFindings } from "../../../lib/requirements.ts";
20
21
  import { loadConfig } from "./config.ts";
21
22
  import { warningNotice } from "./format.ts";
@@ -39,6 +40,11 @@ export function registerDoctor(pi: ExtensionAPI, deps: DoctorDeps = {}): DoctorR
39
40
 
40
41
  registerTools(pi, runtime);
41
42
 
43
+ // `web-access` publishes what it resolved at session start; the doctor reports
44
+ // the provider, its key variable, and (on a refresh) its endpoint from that,
45
+ // without importing the extension.
46
+ pi.events.on(WEB_SEARCH_PROVIDER_CHANNEL, (facts) => runtime.noteSearchFacts(facts));
47
+
42
48
  pi.on("session_start", async (_event, ctx) => {
43
49
  const config = loadConfig(ctx.cwd);
44
50
  runtime.configure({ cwd: ctx.cwd, config });
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  import { CONFIG_DIR_NAME, getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
+ import type { WebSearchProviderEvent } from "../../../lib/events.ts";
12
13
  import { readJsonResult } from "../../../lib/config.ts";
13
14
  import { isExtensionEnabled } from "../../../lib/env.ts";
14
15
  import {
@@ -19,11 +20,25 @@ import {
19
20
  type ExtensionReport,
20
21
  type ProbeSeams,
21
22
  } from "../../../lib/requirements.ts";
23
+ import { loadNotifyConfig } from "../notify/config.ts";
22
24
  import { DEFAULT_CONFIG, type DoctorConfig } from "./config.ts";
23
25
 
24
26
  /** Seams a test may replace; everything else is built from the host. */
25
27
  export type DoctorSeamOverrides = Partial<ProbeSeams>;
26
28
 
29
+ /**
30
+ * The notify capability's configured delivery, read fresh from the document.
31
+ *
32
+ * Reading at probe time rather than from the session's snapshot means a
33
+ * corrected method shows up on the next `r` instead of only after a reload.
34
+ */
35
+ export function notifySeam(cwd: string): () => { enabled: boolean; method: string } {
36
+ return () => {
37
+ const notify = loadNotifyConfig(cwd);
38
+ return { enabled: notify.enabled, method: notify.method };
39
+ };
40
+ }
41
+
27
42
  export interface DoctorRuntime {
28
43
  /** Point the runtime at a session: its cwd and effective config. */
29
44
  configure(options: { cwd: string; config: DoctorConfig }): void;
@@ -33,10 +48,28 @@ export interface DoctorRuntime {
33
48
  probeAll(options?: { refresh?: boolean }): Promise<ExtensionReport[]>;
34
49
  /** The cached reports from the last probe, if any. */
35
50
  peek(): ExtensionReport[] | undefined;
36
- /** Drop the cached reports; the configured cwd is kept. */
51
+ /** Remember the search provider `web-access` published for this session. */
52
+ noteSearchFacts(facts: unknown): void;
53
+ /** Drop the cached reports and the published facts; the configured cwd is kept. */
37
54
  reset(): void;
38
55
  }
39
56
 
57
+ /** How long a reachability probe may take; a doctor refresh is not a fetch. */
58
+ const REACH_TIMEOUT_MS = 3_000;
59
+
60
+ /**
61
+ * Probe a URL's reachability. Any HTTP answer proves the host is there; a 5xx is
62
+ * reported because it usually means the server is up but broken.
63
+ */
64
+ async function probeReach(url: string): Promise<{ ok: boolean; error?: string }> {
65
+ try {
66
+ const response = await fetch(url, { method: "GET", signal: AbortSignal.timeout(REACH_TIMEOUT_MS) });
67
+ return response.status < 500 ? { ok: true } : { ok: false, error: `HTTP ${response.status}` };
68
+ } catch (error) {
69
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
70
+ }
71
+ }
72
+
40
73
  /** Build a runtime over the host's `exec`, with `deps.seams` for tests. */
41
74
  export function createDoctorRuntime(
42
75
  pi: Pick<ExtensionAPI, "exec">,
@@ -45,9 +78,11 @@ export function createDoctorRuntime(
45
78
  let cwd = process.cwd();
46
79
  let config: DoctorConfig = DEFAULT_CONFIG;
47
80
  let cached: ExtensionReport[] | undefined;
81
+ /** What `web-access` published at session start; absent when it did not load. */
82
+ let searchProvider: WebSearchProviderEvent | undefined;
48
83
 
49
- /** The full seam set for the current session. */
50
- const seams = (): ProbeSeams => ({
84
+ /** The full seam set for the current session; `refresh` is the caller's. */
85
+ const seams = (refresh: boolean): ProbeSeams => ({
51
86
  exec: async (command, args) => {
52
87
  const result = await pi.exec(command, args, { timeout: config.timeoutMs });
53
88
  return { code: result.code };
@@ -56,6 +91,11 @@ export function createDoctorRuntime(
56
91
  dirs: { agentDir: getAgentDir(), cwd, configDirName: CONFIG_DIR_NAME },
57
92
  platform: process.platform,
58
93
  isEnabled: (extension) => isExtensionEnabled(extension) && !config.skip.includes(extension),
94
+ notify: notifySeam(cwd),
95
+ isTTY: process.stdout.isTTY === true,
96
+ searchProvider: () => searchProvider,
97
+ reach: probeReach,
98
+ refresh,
59
99
  ...deps.seams,
60
100
  });
61
101
 
@@ -68,20 +108,26 @@ export function createDoctorRuntime(
68
108
 
69
109
  async probeRequired() {
70
110
  // Concurrent: a probe is a short-lived process, and session start must
71
- // not wait on the sum of every timeout.
72
- return Promise.all(hardRequirementExtensions().map((name) => probeExtension(name, seams())));
111
+ // not wait on the sum of every timeout. Never a refresh: this runs before
112
+ // anyone asked, so no probe may touch the network.
113
+ return Promise.all(hardRequirementExtensions().map((name) => probeExtension(name, seams(false))));
73
114
  },
74
115
 
75
116
  async probeAll(options = {}) {
76
117
  if (cached && !options.refresh) return cached;
77
- cached = await probeDeclared(seams(), Object.keys(REQUIREMENTS));
118
+ cached = await probeDeclared(seams(options.refresh === true), Object.keys(REQUIREMENTS));
78
119
  return cached;
79
120
  },
80
121
 
81
122
  peek: () => cached,
82
123
 
124
+ noteSearchFacts(facts) {
125
+ searchProvider = facts && typeof facts === "object" ? (facts as WebSearchProviderEvent) : undefined;
126
+ },
127
+
83
128
  reset() {
84
129
  cached = undefined;
130
+ searchProvider = undefined;
85
131
  },
86
132
  };
87
133
  }
@@ -57,9 +57,7 @@ export function registerTools(pi: ExtensionAPI, runtime: DoctorRuntime): void {
57
57
  ],
58
58
  parameters: DoctorParams,
59
59
  outputSchema: DoctorOutput,
60
- exposure: "direct",
61
- defaultActive: true,
62
- annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
60
+ annotations: { readOnlyHint: true, openWorldHint: false },
63
61
 
64
62
  async execute(_toolCallId, params, _signal, onUpdate, _ctx) {
65
63
  const args = params as DoctorArgs;
@@ -7,9 +7,14 @@
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
20
  import type { ContextInfo, FooterData, StatusSnapshot } from "../types.ts";
@@ -23,22 +28,26 @@ 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 function createSnapshotReader(config: ByteflyConfig) {
31
+ export function createSnapshotReader(config: ByteflyConfig, delegation: DelegationLedger) {
27
32
  const price = createPricer(config);
28
33
  let key: string | null = null;
29
34
  let usage = emptyUsage();
35
+ let delegationTotals = { runs: 0, usage: emptyUsage() };
30
36
  let cacheHitRate: number | null = null;
31
37
  let context: ContextInfo = { tokens: null, contextWindow: 0, percent: null };
32
38
 
33
39
  return (ctx: ExtensionContext, footerData: FooterData): StatusSnapshot => {
34
40
  const session = ctx.sessionManager;
35
41
  const current = ctx.model;
36
- const nextKey = `${session.getSessionId()}:${session.getLeafId() ?? ""}:${current?.provider ?? ""}/${current?.id ?? ""}`;
42
+ const nextKey = `${session.getSessionId()}:${session.getLeafId() ?? ""}:${current?.provider ?? ""}/${current?.id ?? ""}:${delegation.version()}`;
37
43
  if (nextKey !== key) {
38
44
  const active = current ? { provider: current.provider, model: current.id } : undefined;
39
45
  const scanned = usageRecords(session.getEntries(), active);
40
46
  usage = price(scanned.records);
41
47
  cacheHitRate = scanned.cacheHitRate;
48
+ // The child runs are priced by the same pricer, so a currency or rate
49
+ // change moves both numbers together — and neither is folded into the other.
50
+ delegationTotals = { runs: delegation.runs(), usage: price(delegation.records()) };
42
51
 
43
52
  const usageInfo = ctx.getContextUsage();
44
53
  context = {
@@ -65,6 +74,7 @@ export function createSnapshotReader(config: ByteflyConfig) {
65
74
  currency: config.currency,
66
75
  cacheHitRate,
67
76
  providerCount: footerData.getAvailableProviderCount(),
77
+ delegation: delegationTotals,
68
78
  };
69
79
  };
70
80
  }
@@ -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: { branch: GLYPHS.branch, detached: GLYPHS.detached, worktree: GLYPHS.worktree, browse: GLYPHS.browse },
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
+ }
@@ -9,13 +9,14 @@
9
9
  *
10
10
  * The built-in table is DeepSeek's published yuan pricing, with the peak
11
11
  * schedule that doubles the price during peak hours. It is the default value of
12
- * `rates`, so nothing here is provider-specific: another provider's currency
13
- * and tariff are stated in `bytefly.json`.
12
+ * `rates`: `currency.code` chooses which table that is, and the peak schedule
13
+ * applies to both. Only an unmatched model differs — `CNY` contributes no cost
14
+ * for it, while `USD` adds Pi's recorded dollars.
14
15
  */
15
16
 
16
17
  import { asRecord, clampInteger, clampNumber, cleanString, loadConfigSection } from "../../../../lib/config.ts";
17
18
  import { parseWindow } from "./schedule.ts";
18
- import type { ByteflyConfig, Currency, ModelRates, PeakSchedule, PeakWindow } from "./types.ts";
19
+ import type { ByteflyConfig, Currency, CurrencyCode, ModelRates, PeakSchedule, PeakWindow } from "./types.ts";
19
20
 
20
21
  /** Monday to Friday, the days DeepSeek charges its peak rate. */
21
22
  const WEEKDAYS: readonly number[] = [1, 2, 3, 4, 5];
@@ -26,8 +27,8 @@ const WEEKDAYS: readonly number[] = [1, 2, 3, 4, 5];
26
27
  * Pi's catalog prices every call in dollars at one flat rate, which is wrong
27
28
  * twice over for DeepSeek: the bill is in yuan, and peak hours cost double.
28
29
  * These are the published peak figures; `DEFAULT_PEAK` supplies the tariff. A
29
- * `bytefly.json` entry with the same key replaces one of these, and every
30
- * other provider still falls back to Pi's recorded cost.
30
+ * `bytefly.json` entry with the same key replaces one of these. A model with no
31
+ * matching entry contributes no cost; USD mode uses Pi's recorded cost instead.
31
32
  *
32
33
  * Source: <https://api-docs.deepseek.com/quick_start/pricing>.
33
34
  */
@@ -36,6 +37,24 @@ export const DEFAULT_RATES: Readonly<Record<string, ModelRates>> = {
36
37
  "deepseek/deepseek-v4-pro": { input: 9, output: 27, cacheRead: 0.3, cacheWrite: 0, offPeakMultiplier: 0.5 },
37
38
  };
38
39
 
40
+ /**
41
+ * The same two models in DeepSeek's published dollars, per million tokens at the
42
+ * peak rate; `offPeakMultiplier` halves them off-peak. The published table has no
43
+ * cache-write line, so `cacheWrite` is 0 here exactly as in the yuan table.
44
+ *
45
+ * Source: <https://api-docs.deepseek.com/quick_start/pricing> (USD table).
46
+ */
47
+ export const DEFAULT_RATES_USD: Readonly<Record<string, ModelRates>> = {
48
+ "deepseek/deepseek-flash": { input: 0.3, output: 1.2, cacheRead: 0.006, cacheWrite: 0, offPeakMultiplier: 0.5 },
49
+ "deepseek/deepseek-v4-pro": { input: 1.32, output: 3.96, cacheRead: 0.044, cacheWrite: 0, offPeakMultiplier: 0.5 },
50
+ };
51
+
52
+ /** The built-in table for each currency: `rates` are stated in the selected one. */
53
+ export const DEFAULT_RATES_BY_CURRENCY: Readonly<Record<CurrencyCode, Readonly<Record<string, ModelRates>>>> = {
54
+ CNY: DEFAULT_RATES,
55
+ USD: DEFAULT_RATES_USD,
56
+ };
57
+
39
58
  /**
40
59
  * DeepSeek's peak hours, in the zone the provider states them in: 01:00-04:00
41
60
  * and 06:00-10:00 UTC on weekdays, which is 09:00-12:00 and 14:00-18:00 in
@@ -51,17 +70,24 @@ export const DEFAULT_PEAK: PeakSchedule = {
51
70
  ],
52
71
  };
53
72
 
54
- /** Rendered-currency default: yuan, the currency `DEFAULT_RATES` is stated in. */
55
- export const DEFAULT_CURRENCY: Currency = { symbol: "¥", decimals: 2 };
73
+ /** The symbol each algorithm prints. It is not configurable, so it can never disagree with the code. */
74
+ export const CURRENCY_SYMBOLS: Readonly<Record<CurrencyCode, string>> = { CNY: "¥", USD: "$" };
75
+
76
+ /** The default precision each algorithm supplies when a config sets only its code. */
77
+ export const CURRENCY_DEFAULTS: Readonly<Record<CurrencyCode, Currency>> = {
78
+ CNY: { code: "CNY", decimals: 2 },
79
+ USD: { code: "USD", decimals: 3 },
80
+ };
81
+
82
+ /** Yuan, the currency `DEFAULT_RATES` is stated in. */
83
+ export const DEFAULT_CURRENCY: Currency = CURRENCY_DEFAULTS.CNY;
56
84
 
57
85
  export const DEFAULT_BYTEFLY_CONFIG: ByteflyConfig = {
58
86
  currency: DEFAULT_CURRENCY,
59
- fallbackToRecordedCost: true,
60
87
  rates: DEFAULT_RATES,
61
88
  peak: DEFAULT_PEAK,
62
89
  };
63
90
 
64
- const MAX_SYMBOL_LENGTH = 4;
65
91
  const MAX_DECIMALS = 4;
66
92
  const MAX_RATE = 1_000_000;
67
93
  const MAX_RATE_KEYS = 200;
@@ -77,12 +103,22 @@ function acceptsTimeZone(timezone: string): boolean {
77
103
  }
78
104
  }
79
105
 
106
+ /** A supported ISO code, or the base code for a missing/mistyped value. */
107
+ function currencyCode(raw: unknown, base: CurrencyCode): CurrencyCode {
108
+ const text = typeof raw === "string" ? raw.trim().toUpperCase() : "";
109
+ return text === "CNY" || text === "USD" ? text : base;
110
+ }
111
+
80
112
  function currencyValue(raw: unknown, base: Currency): Currency {
81
113
  const value = asRecord(raw);
82
114
  if (!value) return base;
115
+ const code = currencyCode(value.code, base.code);
116
+ // A changed code supplies its own default precision unless this layer
117
+ // overrides it; an unchanged code keeps the precision customized above it.
118
+ const display = code !== base.code ? CURRENCY_DEFAULTS[code] : base;
83
119
  return {
84
- symbol: cleanString(value.symbol, base.symbol).slice(0, MAX_SYMBOL_LENGTH),
85
- decimals: clampInteger(value.decimals, base.decimals, 0, MAX_DECIMALS),
120
+ code,
121
+ decimals: clampInteger(value.decimals, display.decimals, 0, MAX_DECIMALS),
86
122
  };
87
123
  }
88
124
 
@@ -137,11 +173,14 @@ function peakValue(raw: unknown, base: PeakSchedule): PeakSchedule {
137
173
  /** Validate a raw config object over `base`. Total: never throws, never guesses. */
138
174
  export function normalizeByteflyConfig(raw: Record<string, unknown> | undefined, base: ByteflyConfig): ByteflyConfig {
139
175
  if (!raw) return base;
176
+ const currency = currencyValue(raw.currency, base.currency);
177
+ // `rates` are stated in the selected currency, so a layer that switches the
178
+ // code starts from that currency's table instead of reinterpreting the old
179
+ // numbers in the new unit. A layer that sets both merges onto the new table.
180
+ const rateBase = currency.code === base.currency.code ? base.rates : DEFAULT_RATES_BY_CURRENCY[currency.code];
140
181
  return {
141
- currency: currencyValue(raw.currency, base.currency),
142
- fallbackToRecordedCost:
143
- typeof raw.fallbackToRecordedCost === "boolean" ? raw.fallbackToRecordedCost : base.fallbackToRecordedCost,
144
- rates: ratesValue(raw.rates, base.rates),
182
+ currency,
183
+ rates: ratesValue(raw.rates, rateBase),
145
184
  peak: peakValue(raw.peak, base.peak),
146
185
  };
147
186
  }
@@ -2,8 +2,10 @@
2
2
  * Price usage records from configured rates.
3
3
  *
4
4
  * `createPricer` owns the two things that must not be rebuilt per record: the
5
- * matching rates and the timezone formatter. A record with no matching rate
6
- * keeps Pi's recorded cost (in USD) unless the config turns that fallback off.
5
+ * matching rates and the timezone formatter. `currency.code` chooses the
6
+ * algorithm: CNY prices matched records from `rates` and `peak`, while USD
7
+ * totals Pi's recorded dollar cost and ignores both. A CNY-mode record with no
8
+ * matching rate contributes nothing: an unpriced model shows no price at all.
7
9
  */
8
10
 
9
11
  import { emptyUsage } from "../collect/entries.ts";
@@ -32,6 +34,9 @@ function tokenCost(record: UsageRecord, rates: ModelRates): number {
32
34
  * its price always describe the same set of records.
33
35
  */
34
36
  export function createPricer(config: ByteflyConfig): (records: readonly UsageRecord[]) => UsageTotals {
37
+ // Both currencies price from `rates` and the tariff; they differ only in what
38
+ // an unmatched model falls back to, and Pi's recorded cost is already dollars.
39
+ const dollars = config.currency.code === "USD";
35
40
  // Building a formatter is the expensive part, and only a schedule needs one.
36
41
  const format = config.peak.windows.length > 0 ? peakFormatter(config.peak.timezone) : null;
37
42
 
@@ -42,16 +47,24 @@ export function createPricer(config: ByteflyConfig): (records: readonly UsageRec
42
47
  totals.output += record.output;
43
48
  totals.cacheRead += record.cacheRead;
44
49
  totals.cacheWrite += record.cacheWrite;
45
- totals.cost += recordCost(config, format, record);
50
+ const rates = findRates(config.rates, record.provider, record.model);
51
+ if (rates !== undefined) {
52
+ totals.cost += ratedCost(config, format, record, rates);
53
+ continue;
54
+ }
55
+ if (dollars) totals.cost += record.recordedCost;
46
56
  }
47
57
  return totals;
48
58
  };
49
59
  }
50
60
 
51
- /** One record's cost: the configured rate, or Pi's recorded value. */
52
- function recordCost(config: ByteflyConfig, format: Intl.DateTimeFormat | null, record: UsageRecord): number {
53
- const rates = findRates(config.rates, record.provider, record.model);
54
- if (rates === undefined) return config.fallbackToRecordedCost ? record.recordedCost : 0;
61
+ /** One record's cost at its matched rates, after the peak schedule. */
62
+ function ratedCost(
63
+ config: ByteflyConfig,
64
+ format: Intl.DateTimeFormat | null,
65
+ record: UsageRecord,
66
+ rates: ModelRates,
67
+ ): number {
55
68
  const cost = tokenCost(record, rates);
56
69
  // An undated record is never discounted: we cannot tell which tariff applied.
57
70
  if (format === null || record.at === null) return cost;
@@ -6,15 +6,23 @@
6
6
  * `../types.ts`.
7
7
  */
8
8
 
9
- /** How a cost is rendered. */
9
+ /** The two pricing algorithms, named by the currency they total. */
10
+ export type CurrencyCode = "CNY" | "USD";
11
+
12
+ /** How a cost is calculated and rendered. */
10
13
  export interface Currency {
11
- /** Printed before the number, e.g. `$` or `¥`. */
12
- symbol: string;
13
- /** Digits after the decimal point. */
14
+ /**
15
+ * Which table `rates` starts from, and what an unmatched model contributes:
16
+ * both currencies price a matched record from `rates` and the peak schedule,
17
+ * while `CNY` gives an unmatched model no cost and `USD` adds Pi's recorded
18
+ * dollars for it.
19
+ */
20
+ code: CurrencyCode;
21
+ /** Digits after the decimal point. The symbol follows from `code`. */
14
22
  decimals: number;
15
23
  }
16
24
 
17
- /** Per-million-token rates for one model, expressed in the configured currency. */
25
+ /** Per-million-token rates for one model, in yuan; used by the CNY algorithm. */
18
26
  export interface ModelRates {
19
27
  input: number;
20
28
  output: number;
@@ -45,8 +53,6 @@ export interface PeakSchedule {
45
53
  /** Everything the price calculation is configured with. */
46
54
  export interface ByteflyConfig {
47
55
  currency: Currency;
48
- /** Fall back to Pi's recorded cost for a record that no rate matches. */
49
- fallbackToRecordedCost: boolean;
50
56
  /** Rates keyed by `provider/model`, matched through wildcards. */
51
57
  rates: Readonly<Record<string, ModelRates>>;
52
58
  peak: PeakSchedule;
@@ -11,16 +11,23 @@
11
11
 
12
12
  import { homedir } from "node:os";
13
13
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
14
+ import { SUBAGENT_FINISHED_CHANNEL } from "../../../lib/events.ts";
14
15
  import { createSnapshotReader } from "./collect/snapshot.ts";
16
+ import { createDelegationLedger } from "./delegation.ts";
15
17
  import { loadByteflyConfig } from "./price/config.ts";
16
18
  import { createFooter } from "./render/footer.ts";
17
19
 
18
20
  /** Register the footer install/restore lifecycle. */
19
21
  export function registerFooter(pi: ExtensionAPI): void {
22
+ // One ledger per load: it is this session's child-run accounting, priced by
23
+ // whichever reader is installed (a config change installs a new one).
24
+ const delegation = createDelegationLedger();
25
+ pi.events.on(SUBAGENT_FINISHED_CHANNEL, (event) => delegation.note(event));
26
+
20
27
  const install = (ctx: ExtensionContext): void => {
21
28
  if (ctx.mode !== "tui") return;
22
29
  const config = loadByteflyConfig(ctx.cwd);
23
- const read = createSnapshotReader(config);
30
+ const read = createSnapshotReader(config, delegation);
24
31
 
25
32
  ctx.ui.setFooter((tui, _theme, footerData) =>
26
33
  createFooter(
@@ -33,7 +40,11 @@ export function registerFooter(pi: ExtensionAPI): void {
33
40
  );
34
41
  };
35
42
 
36
- pi.on("session_start", (_event, ctx) => install(ctx));
43
+ pi.on("session_start", (_event, ctx) => {
44
+ // A new session in this process starts from zero: the ledger is not branch state.
45
+ delegation.reset();
46
+ install(ctx);
47
+ });
37
48
  pi.on("session_tree", (_event, ctx) => install(ctx));
38
49
  pi.on("session_shutdown", (_event, ctx) => {
39
50
  if (ctx.mode !== "tui") return;
@@ -11,7 +11,7 @@ import { shortenPath } from "../../../../lib/format.ts";
11
11
  import { gaugeRole } from "../../../../lib/tui/gauge.ts";
12
12
  import type { TuiRole } from "../../../../lib/tui/tokens.ts";
13
13
  import { CONFIG } from "../config.ts";
14
- import { DEFAULT_CURRENCY } from "../price/config.ts";
14
+ import { CURRENCY_SYMBOLS, DEFAULT_CURRENCY } from "../price/config.ts";
15
15
  import type { Currency } from "../price/types.ts";
16
16
 
17
17
  /** Theme tokens for the reasoning-effort indicator. */
@@ -31,7 +31,8 @@ export type ThinkingColor =
31
31
  * `¥<0.01` — so a small cost is never reported as free.
32
32
  */
33
33
  export function formatCost(cost: number, currency: Currency = DEFAULT_CURRENCY): string {
34
- const { symbol, decimals } = currency;
34
+ const { decimals } = currency;
35
+ const symbol = CURRENCY_SYMBOLS[currency.code];
35
36
  if (!Number.isFinite(cost) || cost <= 0) return `${symbol}${(0).toFixed(decimals)}`;
36
37
  const minimum = 10 ** -decimals;
37
38
  if (cost < minimum) return `${symbol}<${minimum.toFixed(decimals)}`;
@@ -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),