@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
@@ -171,13 +171,41 @@ export function windowSegment(snapshot: StatusSnapshot, theme: Theme): Segment |
171
171
  }
172
172
 
173
173
  export function costSegment(snapshot: StatusSnapshot, theme: Theme): Segment | null {
174
- if (snapshot.usage.cost === 0) return null;
174
+ const { cost } = snapshot.usage;
175
+ if (cost === 0) return null;
175
176
  return {
176
177
  id: "cost",
177
178
  weight: 2,
178
179
  droppable: true,
179
180
  separator: faint(theme, CONFIG.separators.item),
180
- forms: [paint(theme, "meta", formatCost(snapshot.usage.cost, snapshot.currency))],
181
+ forms: [paint(theme, "meta", formatCost(cost, snapshot.currency))],
182
+ };
183
+ }
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,
181
209
  };
182
210
  }
183
211
 
@@ -32,6 +32,7 @@ export interface UsageTotals {
32
32
  output: number;
33
33
  cacheRead: number;
34
34
  cacheWrite: number;
35
+ /** Cost in the configured currency: rated in CNY mode, Pi's recorded amount in USD mode. */
35
36
  cost: number;
36
37
  }
37
38
 
@@ -69,6 +70,17 @@ export interface StatusSnapshot {
69
70
  /** Latest cache hit rate as a percentage, or `null` when unknown. */
70
71
  cacheHitRate: number | null;
71
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;
72
84
  }
73
85
 
74
86
  /** One renderable piece of a line. */
@@ -17,6 +17,7 @@ import { keyHintText } from "../../../lib/tui/glyphs.ts";
17
17
  import { BODY_INDENT } from "../../../lib/tui/indent.ts";
18
18
  import { RULE_GLYPH } from "../../../lib/tui/rules.ts";
19
19
  import { paint } from "../../../lib/tui/tokens.ts";
20
+ import { updateMarker } from "../update/format.ts";
20
21
  import { HEADER_CONFIG, type HelpEntry } from "./config.ts";
21
22
 
22
23
  /** The header's shape, resolved once per apply so `render` stays cheap. */
@@ -27,6 +28,8 @@ export interface HeaderView {
27
28
  halfBlocks: boolean;
28
29
  /** Columns of left margin: Pi's output pad, so the nameplate sits on the transcript margin. */
29
30
  pad: number;
31
+ /** A newer published version of this package, when one is known. */
32
+ latest?: string;
30
33
  }
31
34
 
32
35
  /** The mark block is four columns on both rows, so the identity column aligns. */
@@ -78,7 +81,7 @@ export class ByteflyHeader implements Component {
78
81
  const w = Math.max(1, width);
79
82
  const theme = this.getTheme();
80
83
  const view = this.view();
81
- const viewKey = `${view.showVersion}:${view.halfBlocks}:${view.pad}`;
84
+ const viewKey = `${view.showVersion}:${view.halfBlocks}:${view.pad}:${view.latest ?? ""}`;
82
85
  const keys = this.keySignature();
83
86
  if (
84
87
  this.cachedWidth === w &&
@@ -106,7 +109,8 @@ export class ByteflyHeader implements Component {
106
109
  }
107
110
 
108
111
  private build(w: number, theme: Theme, view: HeaderView): string[] {
109
- const { nameRole, chromeRole, detailRole, chromeTagline, chromeName, chromeVersion, help } = HEADER_CONFIG;
112
+ const { nameRole, chromeRole, detailRole, updateRole, chromeTagline, chromeName, chromeVersion, help } =
113
+ HEADER_CONFIG;
110
114
  const padWidth = Math.max(0, Math.min(Math.floor(view.pad), Math.max(0, w - 1)));
111
115
  const pad = " ".repeat(padWidth);
112
116
  const [markTop, markBottom] = markLines(theme, view.halfBlocks);
@@ -120,11 +124,18 @@ export class ByteflyHeader implements Component {
120
124
  : theme.bold(paint(theme, nameRole, HEADER_CONFIG.appName))
121
125
  : version;
122
126
  const chrome = `${paint(theme, detailRole, chromeTagline)} ${paint(theme, chromeRole, chromeName)}`;
123
- const chromeVersioned = chromeVersion ? `${chrome} ${paint(theme, detailRole, `v${chromeVersion}`)}` : chrome;
124
-
125
- // The identity rows shed as one block: chrome version, chrome line, agent.
127
+ const chromeWithVersion = chromeVersion ? `${chrome} ${paint(theme, detailRole, `v${chromeVersion}`)}` : chrome;
128
+ // The published version follows the loaded one: `v0.11.4 ↑0.12.0`.
129
+ const chromeWithMarker =
130
+ chromeVersion && view.latest
131
+ ? `${chromeWithVersion} ${paint(theme, updateRole, updateMarker(view.latest))}`
132
+ : chromeWithVersion;
133
+
134
+ // The identity rows shed as one block: the update marker first, then the
135
+ // chrome version, the chrome line, and finally the agent.
126
136
  const states: Array<readonly [string, string]> = [
127
- [agent, chromeVersioned],
137
+ [agent, chromeWithMarker],
138
+ [agent, chromeWithVersion],
128
139
  [agent, chrome],
129
140
  [agent, ""],
130
141
  ["", ""],
@@ -132,7 +143,11 @@ export class ByteflyHeader implements Component {
132
143
  const lineWidth = (cell: string): number => pad.length + MARK_WIDTH + (cell ? 1 + visibleWidth(cell) : 0);
133
144
  const [agentCell, chromeCell] = states.find(([a, c]) => Math.max(lineWidth(a), lineWidth(c)) <= w) ?? ["", ""];
134
145
 
146
+ // The header owns its leading blank: Pi mounts the built-in header inside
147
+ // `Spacer(1)`s, but the `quietStartup: true` branch that hands this header the
148
+ // screen adds none, and `setHeader` swaps only the header child.
135
149
  return [
150
+ "",
136
151
  this.identityLine(pad, markTop, agentCell, w),
137
152
  this.identityLine(pad, markBottom, chromeCell, w),
138
153
  this.rule(theme, w, pad),
@@ -35,6 +35,8 @@ export interface HeaderConfig {
35
35
  readonly chromeRole: TuiRole;
36
36
  /** Role for the versions, the tagline, and the help descriptions. */
37
37
  readonly detailRole: TuiRole;
38
+ /** Role for the update marker: an action, so the accent rather than a warning colour. */
39
+ readonly updateRole: TuiRole;
38
40
  /** Role for the rule's accent lead under the mark. */
39
41
  readonly ruleAccentRole: TuiRole;
40
42
  /** Role for the rest of the rule. */
@@ -57,6 +59,7 @@ export const HEADER_CONFIG: HeaderConfig = {
57
59
  nameRole: "label",
58
60
  chromeRole: "meta",
59
61
  detailRole: "faint",
62
+ updateRole: "accent",
60
63
  ruleAccentRole: "accent",
61
64
  ruleRole: "rule",
62
65
  chromeTagline: "enhanced by",
@@ -9,21 +9,40 @@
9
9
  * `pi config` remains the way to turn all of bytefly's chrome off. The header
10
10
  * is installed only in interactive (`tui`) sessions; every other mode leaves
11
11
  * Pi's built-in header in place, which does not exist there.
12
+ *
13
+ * The update check resolves after this install, so the header reads the newest
14
+ * known version through `UpdateSource` and `refresh()` re-applies the last
15
+ * context's header once one arrives. That is the only reason the header is ever
16
+ * installed twice.
12
17
  */
13
18
 
14
19
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
15
20
  import { isAppleTerminalSession } from "@earendil-works/pi-tui";
16
21
  import { DEFAULT_HOST_PAD } from "../pad.ts";
22
+ import { createUpdateState, type UpdateSource } from "../update/state.ts";
17
23
  import { createHeader, type HeaderView } from "./component.ts";
18
24
  import { HEADER_CONFIG } from "./config.ts";
19
25
 
20
- /** Register the install/restore lifecycle for the custom header. */
21
- export function registerHeader(pi: ExtensionAPI): void {
26
+ /** The installed header, and the way to re-install it after a change. */
27
+ export interface HeaderHandle {
28
+ /** Re-apply the last session's header, picking up the current update marker. */
29
+ refresh(): void;
30
+ }
31
+
32
+ /**
33
+ * Register the install/restore lifecycle for the custom header.
34
+ *
35
+ * `updates` is the store the update check writes, so the marker appears when a
36
+ * newer version is known. A test that does not exercise the marker may omit it.
37
+ */
38
+ export function registerHeader(pi: ExtensionAPI, updates: UpdateSource = createUpdateState()): HeaderHandle {
22
39
  // Half blocks misalign in Apple Terminal, so the mark falls back to a wordmark
23
40
  // there, mirroring Pi's own built-in header.
24
41
  const halfBlocks = !isAppleTerminalSession();
42
+ let last: ExtensionContext | undefined;
25
43
 
26
44
  const apply = (ctx: ExtensionContext): void => {
45
+ last = ctx;
27
46
  if (ctx.mode !== "tui") return;
28
47
  // Bytefly's startup TUI is on only when Pi's `quietStartup` is true, which
29
48
  // hides Pi's built-in startup header. Every other value (`false`,
@@ -36,14 +55,26 @@ export function registerHeader(pi: ExtensionAPI): void {
36
55
  // value the tool frame and the notice inset by. An older host without
37
56
  // `getSettings` keeps the default margin.
38
57
  const pad = pi.getSettings?.().outputPad ?? DEFAULT_HOST_PAD;
39
- const view = (): HeaderView => ({ showVersion: HEADER_CONFIG.showVersion, halfBlocks, pad });
58
+ const view = (): HeaderView => ({
59
+ showVersion: HEADER_CONFIG.showVersion,
60
+ halfBlocks,
61
+ pad,
62
+ latest: updates.latest(),
63
+ });
40
64
  ctx.ui.setHeader(createHeader(() => ctx.ui.theme, view));
41
65
  };
42
66
 
43
67
  pi.on("session_start", (_event, ctx) => apply(ctx));
44
68
  pi.on("session_tree", (_event, ctx) => apply(ctx));
45
69
  pi.on("session_shutdown", (_event, ctx) => {
70
+ last = undefined;
46
71
  if (ctx.mode !== "tui") return;
47
72
  ctx.ui.setHeader(undefined);
48
73
  });
74
+
75
+ return {
76
+ refresh() {
77
+ if (last) apply(last);
78
+ },
79
+ };
49
80
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * bytefly — the package's terminal chrome and diagnostics, composed from ten
2
+ * bytefly — the package's terminal chrome and diagnostics, composed from eleven
3
3
  * feature folders: `header/` (the themed startup mark and version), `footer/`
4
4
  * (the two-line, width-adaptive status bar with its price calculation),
5
5
  * `message/` (the user-message nameplate), `notice/` (the shared transcript
@@ -7,7 +7,8 @@
7
7
  * transcript turn rule), `working/` (the live elapsed counter on Pi's working
8
8
  * row), `panel/` (the unified `/bytefly` screen and its
9
9
  * `doctor` report), `doctor/` (the `run_doctor` tool and its session-scoped
10
- * probe runtime), and `notify/` (OS notifications for unattended runs).
10
+ * probe runtime), `notify/` (OS notifications for unattended runs), and
11
+ * `update/` (the published-version check behind the header's update marker).
11
12
  *
12
13
  * The header, footer, turn rule, and working timer are installed only in
13
14
  * interactive (`tui`) sessions, and are always on; the user-message marker,
@@ -34,6 +35,8 @@ import { type NotifyDeps, registerNotify } from "./notify/register.ts";
34
35
  import { createPadStore } from "./pad.ts";
35
36
  import { type PanelCommandDeps, registerPanel } from "./panel/commands.ts";
36
37
  import { registerTurnSeparator } from "./separator/register.ts";
38
+ import { registerUpdate, type UpdateDeps } from "./update/register.ts";
39
+ import { createUpdateState } from "./update/state.ts";
37
40
  import { registerWorkingTimer } from "./working/register.ts";
38
41
 
39
42
  /** Host seams, injected by tests. */
@@ -44,13 +47,18 @@ export interface ByteflyDeps {
44
47
  doctor?: DoctorDeps;
45
48
  /** Seams for the notify capability. */
46
49
  notify?: NotifyDeps;
50
+ /** Seams for the published-version check. */
51
+ update?: UpdateDeps;
47
52
  }
48
53
 
49
54
  /** Register every bytefly surface; the factory only registers. */
50
55
  export default function bytefly(pi: ExtensionAPI, deps: ByteflyDeps = {}): void {
51
56
  if (!isExtensionEnabled("bytefly")) return;
52
57
 
53
- registerHeader(pi);
58
+ // One store per load: the update check writes the newest published version and
59
+ // the header reads it, so the marker and every re-install agree.
60
+ const updates = createUpdateState();
61
+ const header = registerHeader(pi, updates);
54
62
  registerFooter(pi);
55
63
  registerUserMessage(pi);
56
64
  // One store per load: the tool frame and the notice record Pi's output pad and
@@ -64,6 +72,9 @@ export default function bytefly(pi: ExtensionAPI, deps: ByteflyDeps = {}): void
64
72
  registerTurnSeparator(pi, pad);
65
73
  registerWorkingTimer(pi);
66
74
  const doctor = registerDoctor(pi, deps.doctor);
67
- registerPanel(pi, { doctor, discover: deps.extensions?.discover });
68
75
  registerNotify(pi, deps.notify);
76
+ // The returned handle is what `/bytefly update` re-runs, so the update surface is
77
+ // registered before the panel that offers the command.
78
+ const update = registerUpdate(pi, updates, { ...deps.update, onUpdate: () => header.refresh() });
79
+ registerPanel(pi, { doctor, update, discover: deps.extensions?.discover });
69
80
  }
@@ -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}. The two extensions never import each other;
13
- * the bus and its payload are the whole contract.
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 { JOB_FINISHED_CHANNEL, type JobFinishedEvent } from "../../../lib/events.ts";
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,25 @@ 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
- /** Register the three notification triggers. */
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
+ /** Register the notification triggers. */
41
59
  export function registerNotify(pi: ExtensionAPI, deps: NotifyDeps = {}): void {
42
60
  const seams = deps.seams ?? defaultSeams();
43
61
  let config: NotifyConfig = DEFAULT_NOTIFY_CONFIG;
44
62
  let busy = false;
45
- let unsubscribe: (() => void) | undefined;
63
+ /** Bus subscriptions: one per emitting extension, all created at session start. */
64
+ const unsubscribes: Array<() => void> = [];
46
65
 
47
66
  const fire = (body: string): void => {
48
67
  sendNotification(config, config.title, body, seams);
@@ -59,11 +78,17 @@ export function registerNotify(pi: ExtensionAPI, deps: NotifyDeps = {}): void {
59
78
  pi.on("session_start", (_event, ctx) => {
60
79
  config = loadNotifyConfig(ctx.cwd);
61
80
  busy = false;
62
- unsubscribe?.();
63
- unsubscribe = pi.events.on(JOB_FINISHED_CHANNEL, (data) => {
64
- if (!config.jobFinished || !isJobFinished(data)) return;
65
- fire(formatJobBody(data));
66
- });
81
+ while (unsubscribes.length > 0) unsubscribes.pop()?.();
82
+ unsubscribes.push(
83
+ pi.events.on(JOB_FINISHED_CHANNEL, (data) => {
84
+ if (!config.jobFinished || !isJobFinished(data)) return;
85
+ fire(formatJobBody(data));
86
+ }),
87
+ pi.events.on(WATCH_MATCH_CHANNEL, (data) => {
88
+ if (!config.matchFound || !isWatchMatch(data)) return;
89
+ fire(formatWatchBody(data));
90
+ }),
91
+ );
67
92
  });
68
93
 
69
94
  pi.on("agent_settled", (event, ctx) => {
@@ -78,8 +103,7 @@ export function registerNotify(pi: ExtensionAPI, deps: NotifyDeps = {}): void {
78
103
  });
79
104
 
80
105
  pi.on("session_shutdown", () => {
81
- unsubscribe?.();
82
- unsubscribe = undefined;
106
+ while (unsubscribes.length > 0) unsubscribes.pop()?.();
83
107
  busy = false;
84
108
  });
85
109
  }
@@ -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`. */
@@ -1,23 +1,28 @@
1
1
  /**
2
- * The `/bytefly` surface: one command, `/bytefly [doctor] [settings] [extension] [refresh]`.
2
+ * The `/bytefly` surface: one command, `/bytefly [doctor] [settings] [update] [extension] [refresh]`.
3
3
  *
4
4
  * Without `doctor` it opens the extension screen: the dock in `tui`, the
5
5
  * grouped listing elsewhere. `settings` opens the Settings section, or prints
6
- * its grouped listing outside `tui` without resolving packages. With `doctor`
7
- * it produces the doctor report — the same text `run_doctor` returns — for the
8
- * whole set or one named extension; in `tui` the report is the dock screen.
9
- * Discovery, the doctor probe, and the dirty-to-reload confirm all live here.
10
- * The doctor runtime is injected by the composition root, so `run_doctor` and
11
- * the screen keep reading one cache.
6
+ * its grouped listing outside `tui` without resolving packages. `update`
7
+ * re-runs the published-version check and reports what it found, which is the
8
+ * one place a request is made on demand. With `doctor` it produces the doctor
9
+ * report — the same text `run_doctor` returns — for the whole set or one named
10
+ * extension; in `tui` the report is the dock screen. Discovery, the doctor
11
+ * probe, and the dirty-to-reload confirm all live here. The doctor runtime and
12
+ * the update handle are injected by the composition root, so `run_doctor` and
13
+ * the screen keep reading one cache, and the command paints the marker it finds.
12
14
  */
13
15
 
14
16
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
15
17
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
18
+ import pkg from "../../../package.json";
16
19
  import { sanitizeLine } from "../../../lib/format.ts";
17
20
  import { withRailsSuppressed } from "../../../lib/rails.ts";
18
21
  import { REQUIREMENTS, type ExtensionReport } from "../../../lib/requirements.ts";
19
22
  import { formatExtension, formatReport, hasProblems } from "../doctor/format.ts";
20
23
  import type { DoctorRuntime } from "../doctor/runtime.ts";
24
+ import { updateOutcomeText } from "../update/format.ts";
25
+ import type { UpdateHandle } from "../update/register.ts";
21
26
  import { createSettingsStores, discoverExtensions, type DiscoveryDeps } from "../picker/discovery.ts";
22
27
  import { createExtensionsPickerRuntime, type ExtensionsPickerRuntime } from "../picker/runtime.ts";
23
28
  import type { Discovery } from "../picker/types.ts";
@@ -31,6 +36,8 @@ import type { PanelSection } from "./types.ts";
31
36
  export interface PanelCommandDeps {
32
37
  /** The doctor runtime created by `registerDoctor`; shared with `run_doctor`. */
33
38
  doctor: DoctorRuntime;
39
+ /** The update handle `registerUpdate` returns, for `/bytefly update`. */
40
+ update: UpdateHandle;
34
41
  /** The picker's discovery seam. */
35
42
  discover?: (deps: DiscoveryDeps) => Promise<Discovery>;
36
43
  /** The Settings runtime seam. */
@@ -49,13 +56,14 @@ function selectableNames(discovery: Discovery): Set<string> {
49
56
  }
50
57
 
51
58
  /** The values a bare `/bytefly <tab>` offers, in screen order. */
52
- const FIRST_TOKENS = ["doctor", "settings", ...Object.keys(REQUIREMENTS), "refresh"] as const;
59
+ const FIRST_TOKENS = ["doctor", "settings", "update", ...Object.keys(REQUIREMENTS), "refresh"] as const;
53
60
 
54
61
  /**
55
62
  * Argument completions. The host replaces the whole argument text with the
56
63
  * returned `value`, so a second token returns the full `doctor <name>` or
57
- * `<name> refresh` argument rather than just the word. `/bytefly settings` takes
58
- * no name, and `refresh` is never followed by another token.
64
+ * `<name> refresh` argument rather than just the word. `/bytefly settings` and
65
+ * `/bytefly update` take no name, and `refresh` is never followed by another
66
+ * token.
59
67
  */
60
68
  function completions(prefix: string): Array<{ value: string; label: string }> | null {
61
69
  const [first = "", second, ...rest] = prefix.trimStart().split(/\s+/);
@@ -64,7 +72,7 @@ function completions(prefix: string): Array<{ value: string; label: string }> |
64
72
  const options = FIRST_TOKENS.filter((option) => option.startsWith(first));
65
73
  return options.length > 0 ? options.map((option) => ({ value: `${option} `, label: option })) : null;
66
74
  }
67
- if (first === "settings" || first === "refresh") return null;
75
+ if (first === "settings" || first === "refresh" || first === "update") return null;
68
76
  const options = (first === "doctor" ? [...Object.keys(REQUIREMENTS), "refresh"] : ["refresh"]).filter((option) =>
69
77
  option.startsWith(second),
70
78
  );
@@ -72,7 +80,7 @@ function completions(prefix: string): Array<{ value: string; label: string }> |
72
80
  }
73
81
 
74
82
  /** Which bytefly surface the arguments select. */
75
- type PanelMode = "screen" | "doctor";
83
+ type PanelMode = "screen" | "doctor" | "update";
76
84
 
77
85
  /** `/bytefly [doctor] [settings] [extension] [refresh]`, parsed. */
78
86
  interface PanelArgs {
@@ -85,12 +93,15 @@ interface PanelArgs {
85
93
  refresh: boolean;
86
94
  }
87
95
 
88
- /** Parse `/bytefly [doctor] [settings] [extension] [refresh]`; extra tokens are ignored. */
96
+ /** Parse `/bytefly [doctor] [settings] [update] [extension] [refresh]`; extra tokens are ignored. */
89
97
  function parseArgs(args: string): PanelArgs {
90
98
  const parts = args.trim().split(/\s+/).filter(Boolean);
91
99
  const refresh = parts.includes("refresh");
92
100
  const rest = parts.filter((part) => part !== "refresh");
93
101
  if (rest[0] === "doctor") return { mode: "doctor", section: "extensions", name: rest[1], refresh };
102
+ // `update` is already a refresh of the published version, so a stray `refresh`
103
+ // beside it is accepted and ignored rather than refused.
104
+ if (rest[0] === "update") return { mode: "update", section: "extensions", refresh };
94
105
  if (rest[0] === "settings") return { mode: "screen", section: "settings", name: rest[1], refresh };
95
106
  return { mode: "screen", section: "extensions", name: rest[0], refresh };
96
107
  }
@@ -146,6 +157,16 @@ export function registerPanel(pi: ExtensionAPI, deps: PanelCommandDeps): void {
146
157
  };
147
158
  const settingsNotice = (message: string): void => surfaceNotice(message, "warning");
148
159
 
160
+ // `/bytefly update` answers one question and resolves nothing: no package
161
+ // discovery, no doctor probe, no screen. It is the one path a version check
162
+ // takes on demand, so the request happens here and nowhere else.
163
+ if (mode === "update") {
164
+ const outcome = await deps.update.recheck();
165
+ const { text, severity } = updateOutcomeText(outcome, pkg.version);
166
+ ctx.ui.notify(text, severity);
167
+ return;
168
+ }
169
+
149
170
  // Settings outside `tui` prints its grouped listing without resolving
150
171
  // packages or probing the doctor.
151
172
  if (section === "settings" && ctx.mode !== "tui") {