@gavin-hu/my-pi-agent 0.14.0 → 0.15.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 (222) hide show
  1. package/README.md +55 -33
  2. package/README.zh-CN.md +44 -27
  3. package/extensions/bytefly/README.md +193 -140
  4. package/extensions/bytefly/block/component.ts +7 -15
  5. package/extensions/bytefly/block/register.ts +6 -1
  6. package/extensions/bytefly/doctor/register.ts +24 -5
  7. package/extensions/bytefly/doctor/tools.ts +4 -0
  8. package/extensions/bytefly/footer/collect/snapshot.ts +6 -1
  9. package/extensions/bytefly/footer/config.ts +10 -6
  10. package/extensions/bytefly/footer/price/price.ts +5 -3
  11. package/extensions/bytefly/footer/price/rates.ts +63 -26
  12. package/extensions/bytefly/footer/render/status.ts +3 -3
  13. package/extensions/bytefly/header/component.ts +3 -2
  14. package/extensions/bytefly/index.ts +3 -3
  15. package/extensions/bytefly/notice/component.ts +8 -3
  16. package/extensions/bytefly/notify/config.ts +6 -11
  17. package/extensions/bytefly/notify/dispatch.ts +13 -5
  18. package/extensions/bytefly/panel/commands.ts +21 -1
  19. package/extensions/bytefly/panel/format.ts +5 -2
  20. package/extensions/bytefly/panel/settings-runtime.ts +26 -6
  21. package/extensions/bytefly/panel/settings.ts +41 -4
  22. package/extensions/bytefly/panel/themes.ts +32 -0
  23. package/extensions/bytefly/panel/tui/editing.ts +77 -0
  24. package/extensions/bytefly/panel/tui/entries.ts +24 -0
  25. package/extensions/bytefly/panel/tui/extensions-view.ts +13 -8
  26. package/extensions/bytefly/panel/tui/frame.ts +121 -4
  27. package/extensions/bytefly/panel/tui/settings-view.ts +54 -15
  28. package/extensions/bytefly/panel/tui.ts +94 -152
  29. package/extensions/bytefly/picker/discovery.ts +1 -0
  30. package/extensions/bytefly/picker/types.ts +2 -0
  31. package/extensions/bytefly/separator/component.ts +14 -4
  32. package/extensions/bytefly/update/cache.ts +58 -19
  33. package/extensions/bytefly/update/check.ts +35 -0
  34. package/extensions/bytefly/update/format.ts +76 -35
  35. package/extensions/bytefly/update/register.ts +144 -55
  36. package/extensions/bytefly/update/state.ts +4 -2
  37. package/extensions/bytefly/update/suppress.ts +66 -0
  38. package/extensions/bytefly/update/types.ts +15 -0
  39. package/extensions/check/README.md +253 -0
  40. package/extensions/check/commands.ts +128 -0
  41. package/extensions/check/config.ts +39 -0
  42. package/extensions/check/detect.ts +269 -0
  43. package/extensions/check/failures.ts +230 -0
  44. package/extensions/check/format.ts +135 -0
  45. package/extensions/check/index.ts +26 -0
  46. package/extensions/check/render.ts +185 -0
  47. package/extensions/check/runner.ts +176 -0
  48. package/extensions/check/runtime.ts +244 -0
  49. package/extensions/check/source.ts +80 -0
  50. package/extensions/check/tools.ts +173 -0
  51. package/extensions/check/tui.ts +195 -0
  52. package/extensions/check/types.ts +99 -0
  53. package/extensions/document/README.md +1 -1
  54. package/extensions/document/extract/pdf.ts +8 -16
  55. package/extensions/document/formats.ts +2 -2
  56. package/extensions/file-browse/README.md +66 -32
  57. package/extensions/file-browse/client.ts +95 -32
  58. package/extensions/file-browse/config.ts +9 -9
  59. package/extensions/file-browse/git.ts +37 -47
  60. package/extensions/file-browse/html.ts +39 -58
  61. package/extensions/file-browse/markdown.ts +110 -92
  62. package/extensions/file-browse/raw-html.ts +478 -0
  63. package/extensions/file-browse/router.ts +32 -70
  64. package/extensions/file-browse/server.ts +18 -56
  65. package/extensions/file-browse/urls.ts +80 -0
  66. package/extensions/goal/README.md +103 -47
  67. package/extensions/goal/clarify-parse.ts +4 -9
  68. package/extensions/goal/clarify.ts +27 -4
  69. package/extensions/goal/commands.ts +24 -11
  70. package/extensions/goal/format.ts +41 -10
  71. package/extensions/goal/index.ts +39 -16
  72. package/extensions/goal/plan-route.ts +51 -0
  73. package/extensions/goal/schema.ts +6 -23
  74. package/extensions/goal/state.ts +39 -57
  75. package/extensions/goal/tools.ts +26 -20
  76. package/extensions/goal/types.ts +11 -1
  77. package/extensions/job/README.md +20 -6
  78. package/extensions/job/format.ts +18 -68
  79. package/extensions/job/index.ts +26 -41
  80. package/extensions/job/log-writer.ts +208 -0
  81. package/extensions/job/logs.ts +3 -2
  82. package/extensions/job/process.ts +6 -99
  83. package/extensions/job/runtime.ts +42 -181
  84. package/extensions/job/schema.ts +1 -4
  85. package/extensions/job/tools.ts +1 -15
  86. package/extensions/job/tui.ts +117 -307
  87. package/extensions/job/ui.ts +7 -33
  88. package/extensions/memory/README.md +1 -1
  89. package/extensions/memory/errors.ts +5 -6
  90. package/extensions/memory/tui.ts +14 -24
  91. package/extensions/plan/README.md +94 -18
  92. package/extensions/plan/commands.ts +9 -5
  93. package/extensions/plan/format.ts +87 -0
  94. package/extensions/plan/index.ts +31 -15
  95. package/extensions/plan/plans.ts +2 -3
  96. package/extensions/plan/policy.ts +0 -6
  97. package/extensions/plan/runtime.ts +4 -2
  98. package/extensions/plan/steps.ts +37 -8
  99. package/extensions/plan/todos.ts +44 -3
  100. package/extensions/plan/tools/enter.ts +94 -0
  101. package/extensions/plan/tools/exit.ts +296 -0
  102. package/extensions/plan/tools/nested.ts +21 -0
  103. package/extensions/plan/tools/steps.ts +136 -0
  104. package/extensions/plan/tools/todo-seed.ts +76 -0
  105. package/extensions/plan/tools/write.ts +127 -0
  106. package/extensions/plan/tools.ts +18 -703
  107. package/extensions/plan/web-client.ts +6 -22
  108. package/extensions/plan/web-page.ts +19 -30
  109. package/extensions/plan/web-router.ts +34 -71
  110. package/extensions/plan/web.ts +13 -40
  111. package/extensions/question/answers.ts +3 -8
  112. package/extensions/question/tui.ts +6 -6
  113. package/extensions/rewind/README.md +95 -22
  114. package/extensions/rewind/checklist.ts +185 -0
  115. package/extensions/rewind/commands.ts +8 -0
  116. package/extensions/rewind/config.ts +3 -11
  117. package/extensions/rewind/format.ts +220 -2
  118. package/extensions/rewind/git.ts +74 -7
  119. package/extensions/rewind/hunk-picker.ts +114 -0
  120. package/extensions/rewind/patch.ts +88 -0
  121. package/extensions/rewind/path-picker.ts +67 -100
  122. package/extensions/rewind/paths.ts +93 -19
  123. package/extensions/rewind/restore.ts +53 -10
  124. package/extensions/rewind/review-tui.ts +320 -0
  125. package/extensions/rewind/review.ts +397 -0
  126. package/extensions/rewind/rewind-tui.ts +4 -3
  127. package/extensions/rewind/runtime.ts +94 -17
  128. package/extensions/subagent/README.md +61 -6
  129. package/extensions/subagent/background-tui.ts +208 -0
  130. package/extensions/subagent/background.ts +470 -0
  131. package/extensions/subagent/commands.ts +80 -0
  132. package/extensions/subagent/config.ts +4 -0
  133. package/extensions/subagent/format.ts +24 -1
  134. package/extensions/subagent/index.ts +91 -2
  135. package/extensions/subagent/render.ts +13 -0
  136. package/extensions/subagent/run.ts +17 -16
  137. package/extensions/subagent/schema.ts +12 -0
  138. package/extensions/subagent/types.ts +11 -0
  139. package/extensions/subagent/ui.ts +50 -0
  140. package/extensions/todo/README.md +18 -13
  141. package/extensions/todo/format.ts +3 -2
  142. package/extensions/todo/runtime.ts +3 -2
  143. package/extensions/todo/schema.ts +8 -84
  144. package/extensions/todo/state.ts +5 -46
  145. package/extensions/todo/tools.ts +2 -1
  146. package/extensions/todo/types.ts +6 -15
  147. package/extensions/watch/README.md +14 -6
  148. package/extensions/watch/child.ts +150 -0
  149. package/extensions/watch/format.ts +8 -48
  150. package/extensions/watch/index.ts +42 -58
  151. package/extensions/watch/runtime.ts +49 -128
  152. package/extensions/watch/schema.ts +1 -4
  153. package/extensions/watch/tools.ts +1 -11
  154. package/extensions/watch/tui.ts +103 -273
  155. package/extensions/watch/ui.ts +5 -32
  156. package/extensions/web-access/README.md +1 -1
  157. package/extensions/web-access/fetch/page.ts +1 -1
  158. package/extensions/web-access/fetch/tool.ts +6 -2
  159. package/extensions/web-access/search/search.ts +2 -5
  160. package/extensions/wechat/README.md +1 -1
  161. package/extensions/wechat/client.ts +23 -0
  162. package/extensions/wechat/commands.ts +57 -42
  163. package/extensions/wechat/deliver.ts +5 -9
  164. package/extensions/wechat/errors.ts +3 -6
  165. package/extensions/wechat/inbound.ts +2 -5
  166. package/extensions/wechat/index.ts +2 -8
  167. package/extensions/wechat/outbound.ts +2 -4
  168. package/extensions/wechat/peer-worker.ts +3 -8
  169. package/extensions/wechat/poll.ts +10 -9
  170. package/extensions/wechat/presence.ts +35 -62
  171. package/extensions/wechat/reply-pipeline.ts +3 -1
  172. package/extensions/wechat/status-screen.ts +1 -1
  173. package/extensions/wechat/timer.ts +57 -0
  174. package/extensions/worktree/config.ts +8 -20
  175. package/extensions/worktree/enter.ts +7 -12
  176. package/extensions/worktree/git.ts +38 -69
  177. package/extensions/worktree/index.ts +5 -4
  178. package/extensions/worktree/registry.ts +7 -7
  179. package/extensions/worktree/root-tools.ts +2 -2
  180. package/extensions/worktree/status.ts +3 -2
  181. package/lib/README.md +22 -8
  182. package/lib/config.ts +50 -3
  183. package/lib/errors.ts +12 -0
  184. package/lib/format.ts +50 -0
  185. package/lib/git/read.ts +8 -0
  186. package/lib/goal-state.ts +23 -8
  187. package/lib/http/headers.ts +54 -0
  188. package/lib/http/index.ts +13 -0
  189. package/lib/http/response.ts +46 -0
  190. package/lib/http/router.ts +52 -0
  191. package/lib/http/server.ts +91 -0
  192. package/lib/lines.ts +77 -0
  193. package/lib/pdf.ts +82 -0
  194. package/lib/plan-entry.ts +71 -0
  195. package/lib/plan-state.ts +34 -0
  196. package/lib/report.ts +99 -0
  197. package/lib/requirements.ts +25 -19
  198. package/lib/spawn.ts +170 -0
  199. package/lib/todo-state.ts +110 -23
  200. package/lib/tool-names.ts +7 -0
  201. package/lib/tool-result.ts +33 -0
  202. package/lib/tui/README.md +68 -3
  203. package/lib/tui/chrome.ts +28 -4
  204. package/lib/tui/columns.ts +6 -1
  205. package/lib/tui/confirm.ts +65 -0
  206. package/lib/tui/dock-screen.ts +410 -0
  207. package/lib/tui/glyphs.ts +2 -1
  208. package/lib/tui/indent.ts +1 -1
  209. package/lib/tui/index.ts +2 -0
  210. package/lib/tui/rows.ts +21 -0
  211. package/lib/tui/status-controller.ts +35 -1
  212. package/lib/tui/tool.ts +59 -1
  213. package/lib/web/components.ts +20 -1
  214. package/lib/web/markup.ts +32 -0
  215. package/lib/web/runtime.ts +50 -0
  216. package/package.json +22 -5
  217. package/skills/init/SKILL.md +165 -0
  218. package/skills/init/references/agents-md-quality.md +103 -0
  219. package/skills/init/references/interview-questions.md +81 -0
  220. package/skills/refactor/SKILL.md +141 -0
  221. package/extensions/watch/process.ts +0 -63
  222. package/extensions/web-access/fetch/pdf.ts +0 -45
@@ -28,7 +28,7 @@
28
28
  import type { Theme } from "@earendil-works/pi-coding-agent";
29
29
  import { type Component, stripTerminalSequences, truncateToWidth } from "@earendil-works/pi-tui";
30
30
  import { sanitizeLine } from "../../../lib/format.ts";
31
- import { BODY_INDENT, bodyLines, indentBlockLines } from "../../../lib/tui/indent.ts";
31
+ import { BODY_INDENT, bodyLines, indentBlockLines, isBlank } from "../../../lib/tui/indent.ts";
32
32
  import { RenderCache } from "../../../lib/tui/render-cache.ts";
33
33
  import { paintPrefix } from "../../../lib/tui/tokens.ts";
34
34
 
@@ -174,14 +174,6 @@ export function isRewritable(component: Component): component is Component & Rew
174
174
  return typeof candidate.text === "string" && typeof candidate.setText === "function";
175
175
  }
176
176
 
177
- /**
178
- * Whether a line has no visible text. Tested after stripping terminal sequences,
179
- * so a line carrying only a background or the indent counts as blank.
180
- */
181
- function isBlankLine(line: string): boolean {
182
- return stripTerminalSequences(line).trim().length === 0;
183
- }
184
-
185
177
  /**
186
178
  * Whether a call line degenerated to the tool name alone, so the rest of it —
187
179
  * the path, the command — moved onto the body lines under it.
@@ -195,7 +187,7 @@ function isBlankLine(line: string): boolean {
195
187
  */
196
188
  function isBareHeadline(body: readonly string[], toolName: string | undefined): boolean {
197
189
  if (!toolName) return false;
198
- const headline = body.find((line) => !isBlankLine(line));
190
+ const headline = body.find((line) => !isBlank(line));
199
191
  return headline !== undefined && stripTerminalSequences(headline).trim() === sanitizeLine(toolName);
200
192
  }
201
193
 
@@ -206,9 +198,9 @@ function isBareHeadline(body: readonly string[], toolName: string | undefined):
206
198
  */
207
199
  function callContinuationCount(body: readonly string[], toolName: string | undefined): number {
208
200
  if (!isBareHeadline(body, toolName)) return 0;
209
- let index = body.findIndex((line) => !isBlankLine(line)) + 1;
201
+ let index = body.findIndex((line) => !isBlank(line)) + 1;
210
202
  let count = 0;
211
- while (index < body.length && !isBlankLine(body[index] ?? "")) {
203
+ while (index < body.length && !isBlank(body[index] ?? "")) {
212
204
  count++;
213
205
  index++;
214
206
  }
@@ -228,7 +220,7 @@ function frameLines(
228
220
  options: ToolBlockOptions = {},
229
221
  ): string[] {
230
222
  const w = Math.max(1, width);
231
- const indent = (outputPad ?? 0) + BODY_INDENT;
223
+ const indent = outputPad + BODY_INDENT;
232
224
  const lines = indentBlockLines(body, w, {
233
225
  indent,
234
226
  rail: options.rail,
@@ -277,7 +269,7 @@ export function toolBlockLines(
277
269
  options: ToolBlockOptions = {},
278
270
  ): string[] {
279
271
  const w = Math.max(1, width);
280
- const indent = (outputPad ?? 0) + BODY_INDENT;
272
+ const indent = outputPad + BODY_INDENT;
281
273
  return frameLines(bodyLines(inner, w, indent), w, outputPad, options);
282
274
  }
283
275
 
@@ -307,7 +299,7 @@ export class ToolBlock implements Component {
307
299
 
308
300
  render(width: number): string[] {
309
301
  const w = Math.max(1, width);
310
- const indent = (this.outputPad ?? 0) + BODY_INDENT;
302
+ const indent = this.outputPad + BODY_INDENT;
311
303
  const body = bodyLines(this.inner, w, indent);
312
304
  return this.cache.lines(w, body, () => frameLines(body, w, this.outputPad, this.options));
313
305
  }
@@ -55,7 +55,12 @@ import { isRewritable, recolorCallHeadline, recolorShellCall, ToolBlock } from "
55
55
  import { BLOCK_CONFIG, type BlockStatus } from "./config.ts";
56
56
  import { callFallback, resultFallback } from "./fallback.ts";
57
57
 
58
- /** Per-row memory of the base renderer's own components, kept off Pi's state. */
58
+ /**
59
+ * Per-row memory of the base renderer's own components, stored in the `state`
60
+ * Pi creates for one tool row and hands to both its call and its result
61
+ * renderer. Keeping the base components there is what lets the row keep them
62
+ * while `lastComponent` points at this frame instead.
63
+ */
59
64
  interface BlockMemory {
60
65
  byteflyBaseCall?: Component;
61
66
  byteflyBaseResult?: Component;
@@ -45,7 +45,11 @@ export function registerDoctor(pi: ExtensionAPI, deps: DoctorDeps = {}): DoctorR
45
45
  // without importing the extension.
46
46
  pi.events.on(WEB_SEARCH_PROVIDER_CHANNEL, (facts) => runtime.noteSearchFacts(facts));
47
47
 
48
- pi.on("session_start", async (_event, ctx) => {
48
+ // Bumped when a session starts or ends, so a probe that outlives its session
49
+ // cannot post its notice into the next one (a reload re-registers the surface).
50
+ let probeToken = 0;
51
+
52
+ pi.on("session_start", (_event, ctx) => {
49
53
  const config = loadConfig(ctx.cwd);
50
54
  runtime.configure({ cwd: ctx.cwd, config });
51
55
  if (!config.proactive || !ctx.hasUI) return;
@@ -55,12 +59,27 @@ export function registerDoctor(pi: ExtensionAPI, deps: DoctorDeps = {}): DoctorR
55
59
  if (pi.getSettings?.().quietStartup !== true) return;
56
60
 
57
61
  // Only hard requirements can break an enabled extension, so the startup
58
- // probe is limited to the extensions that declare one, in parallel.
59
- const missing = missingFindings(await runtime.probeRequired());
60
- if (missing.length > 0) pi.sendMessage(warningNotice(missing));
62
+ // probe is limited to the extensions that declare one, in parallel. Fired
63
+ // without awaiting: Pi awaits every `session_start` handler, and these
64
+ // probes spawn processes, so holding the first prompt on them is the cost
65
+ // this avoids. The notice lands a moment later, in the flow, as before.
66
+ const token = ++probeToken;
67
+ void runtime
68
+ .probeRequired()
69
+ .then((reports) => {
70
+ if (token !== probeToken) return;
71
+ const missing = missingFindings(reports);
72
+ if (missing.length > 0) pi.sendMessage(warningNotice(missing));
73
+ })
74
+ .catch(() => {
75
+ // A background check never surfaces an error, and the report is advice.
76
+ });
61
77
  });
62
78
 
63
- pi.on("session_shutdown", () => runtime.reset());
79
+ pi.on("session_shutdown", () => {
80
+ probeToken += 1;
81
+ runtime.reset();
82
+ });
64
83
 
65
84
  return runtime;
66
85
  }
@@ -57,7 +57,11 @@ export function registerTools(pi: ExtensionAPI, runtime: DoctorRuntime): void {
57
57
  ],
58
58
  parameters: DoctorParams,
59
59
  outputSchema: DoctorOutput,
60
+ // `refresh` reaches the configured search endpoint, but that URL comes from
61
+ // config, not from the model, so the tool stays closed-world for the policy.
62
+ // `probeAll` writes the session's report cache, so its calls are ordered.
60
63
  annotations: { readOnlyHint: true, openWorldHint: false },
64
+ executionMode: "sequential",
61
65
 
62
66
  async execute(_toolCallId, params, _signal, onUpdate, _ctx) {
63
67
  const args = params as DoctorArgs;
@@ -48,6 +48,7 @@ export function createSnapshotReader(config: ByteflyConfig, delegation: Delegati
48
48
  let delegationTotals = { runs: 0, usage: emptyUsage() };
49
49
  let cacheHitRate: number | null = null;
50
50
  let context: ContextInfo = { tokens: null, contextWindow: 0, percent: null };
51
+ let sessionName: string | null = null;
51
52
 
52
53
  let last: UsageTotals | undefined;
53
54
 
@@ -70,6 +71,10 @@ export function createSnapshotReader(config: ByteflyConfig, delegation: Delegati
70
71
  contextWindow: usageInfo?.contextWindow ?? current?.contextWindow ?? 0,
71
72
  percent: usageInfo?.percent ?? null,
72
73
  };
74
+ // The host walks the file backwards for the latest `session_info`, and a
75
+ // session renamed early makes that the whole transcript. A rename appends
76
+ // an entry, which moves the leaf, so the memo key already covers it.
77
+ sessionName = session.getSessionName() ?? null;
73
78
  key = nextKey;
74
79
  last = usage;
75
80
  }
@@ -80,7 +85,7 @@ export function createSnapshotReader(config: ByteflyConfig, delegation: Delegati
80
85
 
81
86
  return {
82
87
  cwd: ctx.cwd,
83
- sessionName: ctx.sessionManager.getSessionName() ?? null,
88
+ sessionName,
84
89
  branch: footerData.getGitBranch(),
85
90
  statuses: footerData.getExtensionStatuses(),
86
91
  model,
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Tuning for the two-line status bar.
3
3
  *
4
- * The bar's own content and layout: context thresholds, gauge glyphs and
5
- * widths, separators, icons, status keys, and label limits. Segment weights
4
+ * The bar's own content and layout: context thresholds, gauge widths,
5
+ * separators, icons, status keys, and label limits. Segment weights
6
6
  * live on the segments themselves (see `../render/segments.ts`); the price
7
7
  * configuration is `bytefly.json`, read by `../price/config.ts`.
8
8
  */
@@ -12,10 +12,14 @@ import { GLYPHS, SEPARATORS, STATUS_KEYS } from "../../../lib/tui/glyphs.ts";
12
12
  export const CONFIG = {
13
13
  /** Context percentage thresholds. */
14
14
  thresholds: { warn: 70, danger: 90 },
15
- /** Gauge glyphs, and the block counts the gauge steps down through. */
16
- gauge: { full: GLYPHS.gaugeFull, empty: GLYPHS.gaugeEmpty, widths: [10, 5, 3, 0] as const },
17
- /** Separators printed before grouped and other segments. */
18
- separators: { group: SEPARATORS.group, item: SEPARATORS.item },
15
+ /** The block counts the context gauge steps down through. */
16
+ gauge: { widths: [10, 5, 3, 0] as const },
17
+ /**
18
+ * Separators printed before grouped and other segments. The group divider is
19
+ * the footer's own solid `│`, not the shared `╎`: that glyph is the transcript
20
+ * rail (and `chrome.rail`'s default), and a status line is not a rail.
21
+ */
22
+ separators: { group: " │ ", item: SEPARATORS.item },
19
23
  icons: {
20
24
  branch: GLYPHS.branch,
21
25
  detached: GLYPHS.detached,
@@ -11,7 +11,7 @@
11
11
  import { emptyUsage } from "../collect/entries.ts";
12
12
  import type { ByteflyConfig, ModelRates } from "./types.ts";
13
13
  import type { UsageRecord, UsageTotals } from "../types.ts";
14
- import { findRates } from "./rates.ts";
14
+ import { createRateLookup } from "./rates.ts";
15
15
  import { isPeak, peakFormatter } from "./schedule.ts";
16
16
 
17
17
  const TOKENS_PER_MILLION = 1_000_000;
@@ -37,7 +37,9 @@ export function createPricer(config: ByteflyConfig): (records: readonly UsageRec
37
37
  // Both currencies price from `rates` and the tariff; they differ only in what
38
38
  // an unmatched model falls back to, and Pi's recorded cost is already dollars.
39
39
  const dollars = config.currency.code === "USD";
40
- // Building a formatter is the expensive part, and only a schedule needs one.
40
+ // Both are built once per config, never per record: the table splits every key
41
+ // and remembers the last match, and the formatter is the expensive part.
42
+ const lookup = createRateLookup(config.rates);
41
43
  const format = config.peak.windows.length > 0 ? peakFormatter(config.peak.timezone) : null;
42
44
 
43
45
  return (records) => {
@@ -47,7 +49,7 @@ export function createPricer(config: ByteflyConfig): (records: readonly UsageRec
47
49
  totals.output += record.output;
48
50
  totals.cacheRead += record.cacheRead;
49
51
  totals.cacheWrite += record.cacheWrite;
50
- const rates = findRates(config.rates, record.provider, record.model);
52
+ const rates = lookup(record.provider, record.model);
51
53
  if (rates !== undefined) {
52
54
  totals.cost += ratedCost(config, format, record, rates);
53
55
  continue;
@@ -2,13 +2,31 @@
2
2
  * Rate lookup for a usage record.
3
3
  *
4
4
  * A key is `provider/model`, and either segment may be a wildcard: `*` matches
5
- * anything, and a trailing star matches a prefix (`gpt-*`). The most specific
6
- * key wins and the model segment outranks the provider one, so the documented
7
- * order is `provider/model`, then `any/model`, then `provider/any`, then `*`.
5
+ * anything, and a trailing star matches a prefix (`gpt-*`). The key splits at
6
+ * its first slash, so the model segment may itself contain one — an OpenRouter
7
+ * id is `anthropic/claude-fable-5`, and its key is
8
+ * `openrouter/anthropic/claude-fable-5`. The most specific key wins and the
9
+ * model segment outranks the provider one, so the documented order is
10
+ * `provider/model`, then `any/model`, then `provider/any`, then `*`.
11
+ *
12
+ * `createRateLookup` prepares the table once for a config: the keys are split
13
+ * at build time, and the last match is remembered, because a session prices
14
+ * hundreds of records from one model in a row. Splitting every key per record
15
+ * would rebuild the whole table on the footer's hot path, once per record.
8
16
  */
9
17
 
10
18
  import type { ModelRates } from "./types.ts";
11
19
 
20
+ /** A prepared rate table: call once per record, not once per key. */
21
+ export type RateLookup = (provider: string | null, model: string | null) => ModelRates | undefined;
22
+
23
+ /** One compiled key: both segments already split out of the config string. */
24
+ interface CompiledRate {
25
+ provider: string;
26
+ model: string;
27
+ value: ModelRates;
28
+ }
29
+
12
30
  /** One segment's score against a value: exact 2, prefix 1, wildcard 0, no match -1. */
13
31
  function segmentScore(pattern: string, value: string): number {
14
32
  if (pattern === "*") return 0;
@@ -17,38 +35,57 @@ function segmentScore(pattern: string, value: string): number {
17
35
  }
18
36
 
19
37
  /**
20
- * How specifically `key` matches a record, or `-1` when it does not.
38
+ * How specifically a compiled key matches a record, or `-1` when it does not.
21
39
  *
22
40
  * The model segment dominates: the score is weighted so every model score
23
41
  * outranks any provider score.
24
42
  */
25
- function matchKey(key: string, provider: string, model: string): number {
26
- const parts = key.split("/");
27
- if (parts.length === 1) return key === "*" ? 0 : -1;
28
- if (parts.length !== 2) return -1;
29
- const providerScore = segmentScore(parts[0], provider);
30
- const modelScore = segmentScore(parts[1], model);
43
+ function matchRate(entry: CompiledRate, provider: string, model: string): number {
44
+ const providerScore = segmentScore(entry.provider, provider);
45
+ const modelScore = segmentScore(entry.model, model);
31
46
  if (providerScore < 0 || modelScore < 0) return -1;
32
47
  return modelScore * 3 + providerScore;
33
48
  }
34
49
 
35
- /** The most specific configured rate, or `undefined` when none matches. */
36
- export function findRates(
37
- rates: Readonly<Record<string, ModelRates>>,
38
- provider: string | null,
39
- model: string | null,
40
- ): ModelRates | undefined {
41
- // A record that names no model can only be priced by the catch-all.
42
- if (!provider || !model) return rates["*"];
43
- let best: ModelRates | undefined;
44
- let bestScore = -1;
50
+ /** Prepare `rates` for one config; the returned lookup remembers its last match. */
51
+ export function createRateLookup(rates: Readonly<Record<string, ModelRates>>): RateLookup {
52
+ const compiled: CompiledRate[] = [];
53
+ let catchAll: ModelRates | undefined;
45
54
  for (const [key, value] of Object.entries(rates)) {
46
- const score = matchKey(key, provider, model);
47
- // `>` keeps the first key in config order when two are equally specific.
48
- if (score > bestScore) {
49
- best = value;
50
- bestScore = score;
55
+ const slash = key.indexOf("/");
56
+ if (slash < 0) {
57
+ // A bare key is the catch-all only when it is `*`; anything else is
58
+ // malformed and never matches.
59
+ if (key === "*") {
60
+ catchAll = value;
61
+ compiled.push({ provider: "*", model: "*", value });
62
+ }
63
+ continue;
51
64
  }
65
+ compiled.push({ provider: key.slice(0, slash), model: key.slice(slash + 1), value });
52
66
  }
53
- return best;
67
+
68
+ let lastProvider: string | null = null;
69
+ let lastModel: string | null = null;
70
+ let last: ModelRates | undefined;
71
+
72
+ return (provider, model) => {
73
+ // A record that names no model can only be priced by the catch-all.
74
+ if (!provider || !model) return catchAll;
75
+ if (provider === lastProvider && model === lastModel) return last;
76
+ let best: ModelRates | undefined;
77
+ let bestScore = -1;
78
+ for (const entry of compiled) {
79
+ const score = matchRate(entry, provider, model);
80
+ // `>` keeps the first key in config order when two are equally specific.
81
+ if (score > bestScore) {
82
+ best = entry.value;
83
+ bestScore = score;
84
+ }
85
+ }
86
+ lastProvider = provider;
87
+ lastModel = model;
88
+ last = best;
89
+ return best;
90
+ };
54
91
  }
@@ -12,13 +12,13 @@ import { stripTerminalSequences } from "@earendil-works/pi-tui";
12
12
  /**
13
13
  * Compact a status to its icon form for a narrow bar, keeping its own colour.
14
14
  *
15
- * An icon plus a label (`⋮ plan`) keeps only the icon. An icon plus a bare
15
+ * An icon plus a label (`≡ plan`) keeps only the icon. An icon plus a bare
16
16
  * count (`↺ 2`) is a badge: it keeps the count and drops the space (`↺2`),
17
17
  * because the number is the state. The leading open and trailing reset SGR
18
18
  * sequences are reattached around the compacted text, so a red `✗1` stays red
19
19
  * instead of losing its colour exactly when the bar tightens. Requiring exactly
20
20
  * two tokens (rather than "the last token is numeric") keeps a numeric label
21
- * like `⋮ plan · 2024` from being mistaken for a count.
21
+ * like `≡ plan · 2024` from being mistaken for a count.
22
22
  */
23
23
  export function compactStatus(status: string): string {
24
24
  const prefix = status.match(/^(?:\x1b\[[0-9;]*m)*/)?.[0] ?? "";
@@ -33,7 +33,7 @@ export function compactStatus(status: string): string {
33
33
  /**
34
34
  * Drop a status's trailing ` · detail` while keeping its SGR styling.
35
35
  *
36
- * The `plan-mode` chip carries the plan file name as detail (`⋮ plan · name`);
36
+ * The `plan-mode` chip carries the plan file name as detail (`≡ plan · name`);
37
37
  * the bar shows only the mode. Matching `[^ESC]*` removes the visible suffix
38
38
  * without consuming the closing reset, so the chip's color stays balanced.
39
39
  */
@@ -85,8 +85,9 @@ export class ByteflyHeader implements Component {
85
85
 
86
86
  /**
87
87
  * The header is static, so the lines are memoized on everything they read: the
88
- * width, the theme, the resolved shape, and the key chords the help rows print.
89
- * `keyText` is re-read every frame, so a live rebind still shows.
88
+ * width, the theme the getter returned, the resolved shape, and the key chords
89
+ * the help rows print. `keyText` is re-read every frame, so a live rebind still
90
+ * shows.
90
91
  */
91
92
  render(width: number): string[] {
92
93
  const w = Math.max(1, width);
@@ -8,7 +8,7 @@
8
8
  * `panel/` (the unified `/bytefly` screen and its
9
9
  * `doctor` report), `doctor/` (the `run_doctor` tool and its session-scoped
10
10
  * probe runtime), `notify/` (OS notifications for unattended runs), and
11
- * `update/` (the published-version check behind the header's update marker).
11
+ * `update/` (Pi's package list, the host suppression, and the header marker).
12
12
  *
13
13
  * The header, footer, turn rule, and working line are installed only in
14
14
  * interactive (`tui`) sessions, and are always on; the user-message marker,
@@ -47,7 +47,7 @@ export interface ByteflyDeps {
47
47
  doctor?: DoctorDeps;
48
48
  /** Seams for the notify capability. */
49
49
  notify?: NotifyDeps;
50
- /** Seams for the published-version check. */
50
+ /** Seams for the package-update surface. */
51
51
  update?: UpdateDeps;
52
52
  }
53
53
 
@@ -55,7 +55,7 @@ export interface ByteflyDeps {
55
55
  export default function bytefly(pi: ExtensionAPI, deps: ByteflyDeps = {}): void {
56
56
  if (!isExtensionEnabled("bytefly")) return;
57
57
 
58
- // One store per load: the update check writes the newest published version and
58
+ // One store per load: the update check writes this package's new version and
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);
@@ -24,14 +24,19 @@ import { NOTICE_CONFIG } from "./config.ts";
24
24
  /** Severity order, so the rail can carry the worst one. */
25
25
  const SEVERITY_RANK: Readonly<Record<NoticeSeverity, number>> = { info: 0, ok: 1, warn: 2, bad: 3 };
26
26
 
27
+ /** A severity this module knows, or `fallback` for a value from stored data. */
28
+ function knownSeverity(value: NoticeSeverity | undefined, fallback: NoticeSeverity): NoticeSeverity {
29
+ return value !== undefined && Object.hasOwn(SEVERITY_RANK, value) ? value : fallback;
30
+ }
31
+
27
32
  /** The severity a row draws with, falling back to the notice's own. */
28
33
  function severityOf(item: NoticeItem, fallback: NoticeSeverity): NoticeSeverity {
29
- return item.severity ?? fallback;
34
+ return knownSeverity(item.severity, fallback);
30
35
  }
31
36
 
32
37
  /** The worst severity across the headline and its rows; the rail's role. */
33
38
  function worstSeverity(details: NoticeDetails): NoticeSeverity {
34
- const fallback = details.severity ?? "info";
39
+ const fallback = knownSeverity(details.severity, "info");
35
40
  let worst: NoticeSeverity = fallback;
36
41
  for (const item of details.items ?? []) {
37
42
  const severity = severityOf(item, fallback);
@@ -65,7 +70,7 @@ export function noticeLines(
65
70
  const title = paint(theme, NOTICE_CONFIG.titleRole, sanitizeLine(details.title));
66
71
  const items = details.items ?? [];
67
72
  const shown = items.slice(0, NOTICE_CONFIG.maxItems);
68
- const fallback = details.severity ?? "info";
73
+ const fallback = knownSeverity(details.severity, "info");
69
74
  const gap = " ".repeat(GLYPH_GAP);
70
75
 
71
76
  const rows = shown.map((item) => {
@@ -14,7 +14,7 @@
14
14
  * flags then narrow what is announced.
15
15
  */
16
16
 
17
- import { cleanString, loadConfigSection } from "../../../lib/config.ts";
17
+ import { asBoolean, cleanString, loadConfigSection } from "../../../lib/config.ts";
18
18
  import { NOTIFY_METHODS, type NotifyConfig, type NotifyMethod } from "./types.ts";
19
19
 
20
20
  /** A notification title longer than this is clamped; it is a label, not a message. */
@@ -30,11 +30,6 @@ export const DEFAULT_NOTIFY_CONFIG: NotifyConfig = {
30
30
  title: "Pi",
31
31
  };
32
32
 
33
- /** A boolean, or the fallback. */
34
- function bool(value: unknown, fallback: boolean): boolean {
35
- return typeof value === "boolean" ? value : fallback;
36
- }
37
-
38
33
  /** One of the known methods, or the fallback. */
39
34
  function method(value: unknown, fallback: NotifyMethod): NotifyMethod {
40
35
  return typeof value === "string" && (NOTIFY_METHODS as readonly string[]).includes(value)
@@ -46,11 +41,11 @@ function method(value: unknown, fallback: NotifyMethod): NotifyMethod {
46
41
  export function normalizeNotifyConfig(raw: Record<string, unknown> | undefined, base: NotifyConfig): NotifyConfig {
47
42
  if (!raw) return base;
48
43
  return {
49
- enabled: bool(raw.enabled, base.enabled),
50
- turnFinished: bool(raw.turnFinished, base.turnFinished),
51
- needsInput: bool(raw.needsInput, base.needsInput),
52
- jobFinished: bool(raw.jobFinished, base.jobFinished),
53
- matchFound: bool(raw.matchFound, base.matchFound),
44
+ enabled: asBoolean(raw.enabled, base.enabled),
45
+ turnFinished: asBoolean(raw.turnFinished, base.turnFinished),
46
+ needsInput: asBoolean(raw.needsInput, base.needsInput),
47
+ jobFinished: asBoolean(raw.jobFinished, base.jobFinished),
48
+ matchFound: asBoolean(raw.matchFound, base.matchFound),
54
49
  method: method(raw.method, base.method),
55
50
  title: cleanString(raw.title, base.title).slice(0, MAX_TITLE_LENGTH),
56
51
  };
@@ -14,7 +14,7 @@
14
14
  * to smuggle a control character into an escape sequence or a shell argument.
15
15
  */
16
16
 
17
- import { execFile } from "node:child_process";
17
+ import { spawn } from "node:child_process";
18
18
  import { sanitizeLine } from "../../../lib/format.ts";
19
19
  import type { NotifyConfig, NotifyMethod } from "./types.ts";
20
20
 
@@ -30,7 +30,7 @@ export interface NotifySeams {
30
30
  run(command: string, args: string[]): void;
31
31
  }
32
32
 
33
- /** The production seams: this process's platform, environment, stdout, and `execFile`. */
33
+ /** The production seams: this process's platform, environment, stdout, and a detached notifier. */
34
34
  export function defaultSeams(): NotifySeams {
35
35
  return {
36
36
  platform: process.platform,
@@ -38,9 +38,14 @@ export function defaultSeams(): NotifySeams {
38
38
  stdout: process.stdout,
39
39
  run: (command, args) => {
40
40
  try {
41
- execFile(command, args, { windowsHide: true }, () => {});
41
+ // No pipes and unref'd: the notifier is best-effort and must not keep
42
+ // Pi alive, which `/quit` right after a turn would otherwise wait out
43
+ // (PowerShell's cold start alone is a second).
44
+ const child = spawn(command, args, { windowsHide: true, stdio: "ignore" });
45
+ child.on("error", () => {});
46
+ child.unref();
42
47
  } catch {
43
- // The notifier is best-effort; a missing command must not break a turn.
48
+ // A missing command must not break a turn.
44
49
  }
45
50
  },
46
51
  };
@@ -89,7 +94,10 @@ function windowsToastScript(title: string, body: string): string {
89
94
  return [
90
95
  `${mgr} > $null`,
91
96
  `$xml = [${type}.ToastNotificationManager]::GetTemplateContent(${template})`,
92
- `$xml.GetElementsByTagName('text')[0].AppendChild($xml.CreateTextNode('${powershellLiteral(body)}')) > $null`,
97
+ `$nodes = $xml.GetElementsByTagName('text')`,
98
+ // PowerShell 5.1 cannot index the live node list with `[0]`; `.Item(0)`
99
+ // reads the single node without enumerating a collection AppendChild mutates.
100
+ `$nodes.Item(0).AppendChild($xml.CreateTextNode('${powershellLiteral(body)}')) > $null`,
93
101
  `[${type}.ToastNotificationManager]::CreateToastNotifier('${powershellLiteral(title)}').Show(${toast})`,
94
102
  ].join("; ");
95
103
  }
@@ -29,6 +29,7 @@ import type { Discovery } from "../picker/types.ts";
29
29
  import { formatListing, formatSettingsListing } from "./format.ts";
30
30
  import { buildPanelModel, extensionName } from "./join.ts";
31
31
  import { createSettingsRuntime, type SettingsRuntime, type SettingsRuntimeDeps } from "./settings-runtime.ts";
32
+ import { shippedThemeNames } from "./themes.ts";
32
33
  import { PanelComponent } from "./tui.ts";
33
34
  import type { PanelSection } from "./types.ts";
34
35
 
@@ -130,6 +131,19 @@ function notifyExtension(
130
131
  );
131
132
  }
132
133
 
134
+ /**
135
+ * Theme names the row offers: the host's resolved list, narrowed to this
136
+ * package's own palettes. The list is empty outside the TUI (RPC, print, JSON),
137
+ * where the row still shows the stored value.
138
+ */
139
+ function settingsThemeNames(ctx: ExtensionCommandContext): string[] {
140
+ try {
141
+ return shippedThemeNames(ctx.ui.getAllThemes());
142
+ } catch {
143
+ return [];
144
+ }
145
+ }
146
+
133
147
  /** Register the single `/bytefly` command. */
134
148
  export function registerPanel(pi: ExtensionAPI, deps: PanelCommandDeps): void {
135
149
  const discover = deps.discover ?? discoverExtensions;
@@ -146,6 +160,7 @@ export function registerPanel(pi: ExtensionAPI, deps: PanelCommandDeps): void {
146
160
  const cwd = ctx.cwd ?? process.cwd();
147
161
  const agentDir = getAgentDir();
148
162
  const trusted = ctx.isProjectTrusted();
163
+ const themes = settingsThemeNames(ctx);
149
164
  // The mounted screen owns its feedback: a refusal or a failed write belongs in
150
165
  // the frame, not appended to the transcript behind it. Before the screen
151
166
  // exists (the non-TUI settings path, a pre-mount failure) the transcript is
@@ -171,7 +186,7 @@ export function registerPanel(pi: ExtensionAPI, deps: PanelCommandDeps): void {
171
186
  // packages or probing the doctor.
172
187
  if (section === "settings" && ctx.mode !== "tui") {
173
188
  const stores = createSettingsStores(cwd, agentDir, trusted);
174
- const settings = makeSettings({ stores, cwd, agentDir, trusted, onError: settingsNotice });
189
+ const settings = makeSettings({ stores, cwd, agentDir, trusted, themes, onError: settingsNotice });
175
190
  ctx.ui.notify(formatSettingsListing(settings.views("global")), "info");
176
191
  return;
177
192
  }
@@ -189,6 +204,7 @@ export function registerPanel(pi: ExtensionAPI, deps: PanelCommandDeps): void {
189
204
  cwd,
190
205
  agentDir,
191
206
  trusted: discovery.trusted,
207
+ themes,
192
208
  onError: settingsNotice,
193
209
  });
194
210
 
@@ -236,6 +252,9 @@ export function registerPanel(pi: ExtensionAPI, deps: PanelCommandDeps): void {
236
252
  await withRailsSuppressed(pi, () =>
237
253
  ctx.ui.custom<void>((tui, theme, _keybindings, done) => {
238
254
  const unsubscribe = runtime.onChange(() => tui.requestRender());
255
+ // The settings runtime re-reads its snapshot after a write; without a
256
+ // subscriber that repaint depends on every write chain's own callback.
257
+ const unsubscribeSettings = settings.onChange(() => tui.requestRender());
239
258
  const component = new PanelComponent({
240
259
  runtime,
241
260
  reports: () => reports,
@@ -257,6 +276,7 @@ export function registerPanel(pi: ExtensionAPI, deps: PanelCommandDeps): void {
257
276
  // A late error falls back to the transcript, never a disposed screen.
258
277
  panel = undefined;
259
278
  unsubscribe();
279
+ unsubscribeSettings();
260
280
  done();
261
281
  },
262
282
  });
@@ -158,8 +158,10 @@ interface RowBadge {
158
158
  * The row's badge. Settings is the enablement axis the checkbox already shows,
159
159
  * so an off extension gets one verdict (`off`) instead of a health word that
160
160
  * contradicts it. `PI_DISABLED_EXTENSIONS` is a different axis, so it is named
161
- * rather than sharing the word: `env disabled`. A row without a report leaves
162
- * the glyph and the word blank instead of claiming `ok`.
161
+ * rather than sharing the word: `env disabled`. The glyph marks what needs
162
+ * attention — a warning, a miss, a disable — so a row with nothing to report
163
+ * carries no glyph at all, and the column reads as one shape instead of a
164
+ * checkmark that comes and goes.
163
165
  */
164
166
  export function rowBadge(row: PanelRow): RowBadge {
165
167
  if (row.item && !row.item.enabled) return { glyph: GLYPHS.disabled, role: "faint", text: "off" };
@@ -167,6 +169,7 @@ export function rowBadge(row: PanelRow): RowBadge {
167
169
  if (!report) return { role: "faint" };
168
170
  if (!report.enabled) return { glyph: GLYPHS.disabled, role: "faint", text: "env disabled" };
169
171
  const severity = reportSeverity(report);
172
+ if (severity === "ok") return { role: "faint", text: reportSummary(report) };
170
173
  return { glyph: severityGlyphChar(severity), role: severityRole(severity), text: reportSummary(report) };
171
174
  }
172
175