@gavin-hu/my-pi-agent 0.12.0 → 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 (153) hide show
  1. package/README.md +28 -21
  2. package/README.zh-CN.md +30 -22
  3. package/extensions/bytefly/README.md +139 -67
  4. package/extensions/bytefly/chrome.ts +7 -2
  5. package/extensions/bytefly/doctor/register.ts +6 -0
  6. package/extensions/bytefly/doctor/runtime.ts +36 -6
  7. package/extensions/bytefly/footer/collect/snapshot.ts +12 -2
  8. package/extensions/bytefly/footer/config.ts +7 -1
  9. package/extensions/bytefly/footer/delegation.ts +100 -0
  10. package/extensions/bytefly/footer/register.ts +13 -2
  11. package/extensions/bytefly/footer/render/lines.ts +2 -0
  12. package/extensions/bytefly/footer/render/segments.ts +27 -0
  13. package/extensions/bytefly/footer/types.ts +11 -0
  14. package/extensions/bytefly/index.ts +4 -3
  15. package/extensions/bytefly/notify/config.ts +2 -0
  16. package/extensions/bytefly/notify/format.ts +19 -1
  17. package/extensions/bytefly/notify/register.ts +37 -13
  18. package/extensions/bytefly/notify/types.ts +2 -0
  19. package/extensions/bytefly/panel/commands.ts +34 -13
  20. package/extensions/bytefly/panel/settings.ts +98 -16
  21. package/extensions/bytefly/update/cache.ts +73 -0
  22. package/extensions/bytefly/update/format.ts +65 -10
  23. package/extensions/bytefly/update/register.ts +99 -13
  24. package/extensions/bytefly/update/state.ts +13 -1
  25. package/extensions/document/README.md +22 -12
  26. package/extensions/document/config.ts +4 -0
  27. package/extensions/document/deadline.ts +61 -0
  28. package/extensions/document/tool.ts +13 -2
  29. package/extensions/file-browse/README.md +12 -8
  30. package/extensions/file-browse/client.ts +6 -7
  31. package/extensions/file-browse/files.ts +0 -4
  32. package/extensions/file-browse/git.ts +9 -10
  33. package/extensions/file-browse/html.ts +17 -7
  34. package/extensions/file-browse/router.ts +24 -15
  35. package/extensions/goal/README.md +7 -1
  36. package/extensions/goal/schema.ts +6 -2
  37. package/extensions/goal/types.ts +6 -2
  38. package/extensions/job/README.md +16 -12
  39. package/extensions/job/runtime.ts +23 -6
  40. package/extensions/job/status.ts +4 -2
  41. package/extensions/memory/README.md +55 -13
  42. package/extensions/memory/commands.ts +20 -2
  43. package/extensions/memory/doctor.ts +137 -0
  44. package/extensions/memory/format.ts +24 -7
  45. package/extensions/memory/runtime.ts +49 -0
  46. package/extensions/memory/store.ts +31 -0
  47. package/extensions/memory/tui.ts +136 -12
  48. package/extensions/plan/README.md +60 -29
  49. package/extensions/plan/commands.ts +67 -8
  50. package/extensions/plan/index.ts +35 -11
  51. package/extensions/plan/plan-index.ts +234 -0
  52. package/extensions/plan/plan-text.ts +95 -0
  53. package/extensions/plan/plans.ts +90 -1
  54. package/extensions/plan/review.ts +12 -9
  55. package/extensions/plan/runtime.ts +72 -27
  56. package/extensions/plan/steps.ts +35 -25
  57. package/extensions/plan/todos.ts +24 -2
  58. package/extensions/plan/tools.ts +124 -24
  59. package/extensions/plan/tui.ts +7 -28
  60. package/extensions/plan/types.ts +9 -0
  61. package/extensions/plan/web-page.ts +2 -2
  62. package/extensions/plan/web.ts +12 -11
  63. package/extensions/rewind/README.md +28 -8
  64. package/extensions/rewind/commands.ts +1 -1
  65. package/extensions/rewind/format.ts +4 -4
  66. package/extensions/rewind/git.ts +56 -18
  67. package/extensions/rewind/index.ts +3 -5
  68. package/extensions/rewind/path-picker.ts +198 -0
  69. package/extensions/rewind/paths.ts +128 -0
  70. package/extensions/rewind/restore.ts +69 -28
  71. package/extensions/rewind/rewind.ts +80 -3
  72. package/extensions/rewind/runtime.ts +13 -5
  73. package/extensions/rewind/snapshot-run.ts +5 -2
  74. package/extensions/rewind/types.ts +2 -0
  75. package/extensions/subagent/README.md +7 -1
  76. package/extensions/subagent/accounting.ts +34 -0
  77. package/extensions/subagent/index.ts +23 -1
  78. package/extensions/subagent/stream.ts +2 -0
  79. package/extensions/subagent/types.ts +2 -0
  80. package/extensions/watch/README.md +8 -3
  81. package/extensions/watch/index.ts +19 -0
  82. package/extensions/web-access/README.md +43 -13
  83. package/extensions/web-access/charset.ts +58 -0
  84. package/extensions/web-access/fetch/find.ts +11 -7
  85. package/extensions/web-access/fetch/format.ts +64 -18
  86. package/extensions/web-access/fetch/page.ts +5 -1
  87. package/extensions/web-access/fetch/tool.ts +19 -18
  88. package/extensions/web-access/http.ts +49 -8
  89. package/extensions/web-access/search/facts.ts +38 -0
  90. package/extensions/web-access/search/tool.ts +13 -0
  91. package/extensions/web-access/text.ts +50 -11
  92. package/extensions/worktree/README.md +74 -15
  93. package/extensions/worktree/config.ts +11 -0
  94. package/extensions/worktree/exit.ts +35 -3
  95. package/extensions/worktree/gaps.ts +74 -0
  96. package/extensions/worktree/guard.ts +50 -4
  97. package/extensions/worktree/handoff.ts +55 -0
  98. package/extensions/worktree/index.ts +12 -2
  99. package/extensions/worktree/runtime.ts +20 -0
  100. package/extensions/worktree/status.ts +15 -2
  101. package/extensions/worktree/tools.ts +3 -2
  102. package/lib/README.md +10 -8
  103. package/lib/events.ts +86 -1
  104. package/lib/goal-state.ts +95 -0
  105. package/lib/requirements.ts +282 -62
  106. package/lib/tui/README.md +91 -0
  107. package/lib/tui/glyphs.ts +1 -0
  108. package/lib/web/layout.ts +12 -2
  109. package/package.json +5 -3
  110. package/themes/bytefly-dark.json +1 -1
  111. package/assets/chrome.png +0 -0
  112. package/assets/file-browse.png +0 -0
  113. package/assets/job.png +0 -0
  114. package/assets/plan-review.png +0 -0
  115. package/assets/question.png +0 -0
  116. package/assets/rewind.png +0 -0
  117. package/assets/todo.png +0 -0
  118. package/assets/tool-renderings.png +0 -0
  119. package/assets/tools/ask_user_question-cancelled.png +0 -0
  120. package/assets/tools/ask_user_question.png +0 -0
  121. package/assets/tools/bash.png +0 -0
  122. package/assets/tools/edit.png +0 -0
  123. package/assets/tools/enter_plan_mode.png +0 -0
  124. package/assets/tools/enter_worktree.png +0 -0
  125. package/assets/tools/exit_plan_mode.png +0 -0
  126. package/assets/tools/exit_worktree.png +0 -0
  127. package/assets/tools/find.png +0 -0
  128. package/assets/tools/goal.png +0 -0
  129. package/assets/tools/grep.png +0 -0
  130. package/assets/tools/job.png +0 -0
  131. package/assets/tools/list_worktrees.png +0 -0
  132. package/assets/tools/ls.png +0 -0
  133. package/assets/tools/memory-long-subject.png +0 -0
  134. package/assets/tools/memory.png +0 -0
  135. package/assets/tools/notice.png +0 -0
  136. package/assets/tools/open_path.png +0 -0
  137. package/assets/tools/prune_worktrees.png +0 -0
  138. package/assets/tools/read.png +0 -0
  139. package/assets/tools/read_doc-running.png +0 -0
  140. package/assets/tools/read_doc.png +0 -0
  141. package/assets/tools/run_doctor.png +0 -0
  142. package/assets/tools/send_wechat.png +0 -0
  143. package/assets/tools/set_plan_steps.png +0 -0
  144. package/assets/tools/subagent.png +0 -0
  145. package/assets/tools/todo.png +0 -0
  146. package/assets/tools/turn-rule.png +0 -0
  147. package/assets/tools/user-message.png +0 -0
  148. package/assets/tools/watch.png +0 -0
  149. package/assets/tools/web_fetch-rejected.png +0 -0
  150. package/assets/tools/web_fetch.png +0 -0
  151. package/assets/tools/web_search.png +0 -0
  152. package/assets/tools/write.png +0 -0
  153. package/assets/tools/write_plan.png +0 -0
@@ -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
+ }
@@ -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;
@@ -12,6 +12,7 @@ import {
12
12
  cacheSegment,
13
13
  contextSegment,
14
14
  costSegment,
15
+ delegationSegment,
15
16
  modelSegment,
16
17
  modesSegment,
17
18
  pwdSegment,
@@ -39,6 +40,7 @@ export function buildLines(snapshot: StatusSnapshot, theme: Theme, home: string
39
40
  contextSegment(snapshot, theme),
40
41
  windowSegment(snapshot, theme),
41
42
  costSegment(snapshot, theme),
43
+ delegationSegment(snapshot, theme),
42
44
  tokensSegment(snapshot, theme),
43
45
  cacheSegment(snapshot, theme),
44
46
  modesSegment(snapshot, theme),
@@ -182,6 +182,33 @@ export function costSegment(snapshot: StatusSnapshot, theme: Theme): Segment | n
182
182
  };
183
183
  }
184
184
 
185
+ /**
186
+ * Child-run spend, next to the session's own cost and never inside it: a
187
+ * subagent runs in its own process, so its tokens are not the session's. The
188
+ * roll-up covers the ledger's retained window (the last 500 runs).
189
+ */
190
+ export function delegationSegment(snapshot: StatusSnapshot, theme: Theme): Segment | null {
191
+ const { runs, usage } = snapshot.delegation;
192
+ if (runs === 0) return null;
193
+ const icon = CONFIG.icons.delegation;
194
+ const cost = formatCost(usage.cost, snapshot.currency);
195
+ // Detailed first; the run count survives longer than the price, because "some
196
+ // delegation happened" is the part a narrow bar can still say.
197
+ const forms = [
198
+ `${paint(theme, "meta", `${icon} ${runs} · ${cost}`)}`,
199
+ `${paint(theme, "meta", `${icon} ${cost}`)}`,
200
+ paint(theme, "meta", `${icon} ${runs}`),
201
+ paint(theme, "meta", icon),
202
+ ];
203
+ return {
204
+ id: "delegation",
205
+ weight: 4,
206
+ droppable: true,
207
+ separator: faint(theme, CONFIG.separators.item),
208
+ forms,
209
+ };
210
+ }
211
+
185
212
  export function tokensSegment(snapshot: StatusSnapshot, theme: Theme): Segment | null {
186
213
  const { input, output } = snapshot.usage;
187
214
  if (input === 0 && output === 0) return null;
@@ -70,6 +70,17 @@ export interface StatusSnapshot {
70
70
  /** Latest cache hit rate as a percentage, or `null` when unknown. */
71
71
  cacheHitRate: number | null;
72
72
  providerCount: number;
73
+ /**
74
+ * What this session's delegated child runs spent, priced like `usage` but kept
75
+ * apart from it: the session total never includes child spend.
76
+ */
77
+ delegation: DelegationTotals;
78
+ }
79
+
80
+ /** Child-run totals: how many runs finished, and what the retained window spent. */
81
+ export interface DelegationTotals {
82
+ runs: number;
83
+ usage: UsageTotals;
73
84
  }
74
85
 
75
86
  /** One renderable piece of a line. */
@@ -72,8 +72,9 @@ export default function bytefly(pi: ExtensionAPI, deps: ByteflyDeps = {}): void
72
72
  registerTurnSeparator(pi, pad);
73
73
  registerWorkingTimer(pi);
74
74
  const doctor = registerDoctor(pi, deps.doctor);
75
- registerPanel(pi, { doctor, discover: deps.extensions?.discover });
76
75
  registerNotify(pi, deps.notify);
77
- // Last, so a version that arrives after startup can re-install the header.
78
- registerUpdate(pi, updates, { ...deps.update, onUpdate: () => header.refresh() });
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 });
79
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") {
@@ -6,15 +6,16 @@
6
6
  * views, cycling, validation, and grouping — so the screen and the runtime share
7
7
  * one definition and the tests need no host.
8
8
  *
9
- * The catalogue is deliberately curated: one proven row family at a time. It
10
- * covers scalars (a boolean checkbox, an enum cycle, a short string with its own
11
- * rule), both scopes, both write paths, and — with `price.currency.decimals` —
12
- * one nested key. Arrays, nested tariffs, and credentials are still JSON edits.
9
+ * The catalogue covers every scalar preference: booleans, enums, short strings,
10
+ * and — with `doctor.timeoutMs` — a number. Each row states its own rule, so a
11
+ * value the screen accepts is a value the loader keeps. Arrays (`doctor.skip`),
12
+ * the `rates` table, and `price.peak` stay JSON edits: a list, a table, and a
13
+ * nested object are not one row.
13
14
  */
14
15
 
15
16
  import { configSection, configValueAt } from "../../../lib/config.ts";
16
17
  import { GLYPHS } from "../../../lib/tui/glyphs.ts";
17
- import { DEFAULT_CHROME_CONFIG, normalizeChromeConfig } from "../chrome.ts";
18
+ import { DEFAULT_CHROME_CONFIG, isRailGlyph, normalizeChromeConfig } from "../chrome.ts";
18
19
  import { DEFAULT_CONFIG as DEFAULT_DOCTOR_CONFIG, normalizeConfig as normalizeDoctorConfig } from "../doctor/config.ts";
19
20
  import {
20
21
  CURRENCY_DEFAULTS,
@@ -136,14 +137,6 @@ const ON_OFF: readonly SettingOption[] = [
136
137
  { value: "true", label: "On" },
137
138
  ];
138
139
 
139
- /** Rail glyphs offered in the curated picker; every one is one column. */
140
- const RAIL_OPTIONS: readonly SettingOption[] = [
141
- { value: GLYPHS.divider, label: GLYPHS.divider },
142
- { value: "│", label: "│" },
143
- { value: "┃", label: "┃" },
144
- { value: "▏", label: "▏" },
145
- ];
146
-
147
140
  /** The notify section's delivery methods, labeled by the surface each reaches. */
148
141
  const METHOD_OPTIONS: readonly SettingOption[] = [
149
142
  { value: "auto", label: "Auto" },
@@ -160,6 +153,33 @@ const DECIMALS_OPTIONS: readonly SettingOption[] = [0, 1, 2, 3, 4].map((value) =
160
153
  label: String(value),
161
154
  }));
162
155
 
156
+ /**
157
+ * A text rule for a whole number inside an inclusive range.
158
+ *
159
+ * The bounds are the loader's own (`doctor.timeoutMs` is clamped to 100-30000),
160
+ * so an accepted value is never silently clamped on the next read. `maxLength`
161
+ * only stops a runaway buffer; `validate` is the rule.
162
+ */
163
+ function integerRule(min: number, max: number): TextRule {
164
+ return {
165
+ maxLength: String(max).length + 2,
166
+ validate(raw) {
167
+ const text = raw.trim();
168
+ if (!/^\d+$/.test(text)) return `Use a whole number, ${min} to ${max}.`;
169
+ const value = Number(text);
170
+ if (value < min || value > max) return `Out of range: ${min} to ${max}.`;
171
+ return undefined;
172
+ },
173
+ };
174
+ }
175
+
176
+ /** The rail's rule: the loader drops anything that is not one column. */
177
+ const RAIL_RULE: TextRule = {
178
+ maxLength: 4,
179
+ validate: (raw) =>
180
+ isRailGlyph(raw) ? undefined : "Use exactly one column: a single glyph, with no spaces or control characters.",
181
+ };
182
+
163
183
  /** The v1 catalogue, in screen order. */
164
184
  export const SETTING_ROWS: readonly SettingDescriptor[] = [
165
185
  {
@@ -185,6 +205,54 @@ export const SETTING_ROWS: readonly SettingDescriptor[] = [
185
205
  options: ON_OFF,
186
206
  config: { section: "notify", path: ["enabled"] },
187
207
  },
208
+ {
209
+ id: "notify.turnFinished",
210
+ group: "notifications",
211
+ label: "Turn finished",
212
+ description: "Announce it when a full agent run settles.",
213
+ source: "bytefly",
214
+ kind: "boolean",
215
+ fallback: "true",
216
+ json: "boolean",
217
+ options: ON_OFF,
218
+ config: { section: "notify", path: ["turnFinished"] },
219
+ },
220
+ {
221
+ id: "notify.needsInput",
222
+ group: "notifications",
223
+ label: "Needs input",
224
+ description: "Announce it when the agent opens a prompt and waits for an answer.",
225
+ source: "bytefly",
226
+ kind: "boolean",
227
+ fallback: "true",
228
+ json: "boolean",
229
+ options: ON_OFF,
230
+ config: { section: "notify", path: ["needsInput"] },
231
+ },
232
+ {
233
+ id: "notify.jobFinished",
234
+ group: "notifications",
235
+ label: "Job finished",
236
+ description: "Announce it when a background job or watcher settles.",
237
+ source: "bytefly",
238
+ kind: "boolean",
239
+ fallback: "true",
240
+ json: "boolean",
241
+ options: ON_OFF,
242
+ config: { section: "notify", path: ["jobFinished"] },
243
+ },
244
+ {
245
+ id: "notify.matchFound",
246
+ group: "notifications",
247
+ label: "Watcher matched",
248
+ description: "Announce it when a `watch` whose `wake` is on matches while the agent is idle.",
249
+ source: "bytefly",
250
+ kind: "boolean",
251
+ fallback: "true",
252
+ json: "boolean",
253
+ options: ON_OFF,
254
+ config: { section: "notify", path: ["matchFound"] },
255
+ },
188
256
  {
189
257
  id: "notify.method",
190
258
  group: "notifications",
@@ -223,6 +291,19 @@ export const SETTING_ROWS: readonly SettingDescriptor[] = [
223
291
  options: ON_OFF,
224
292
  config: { section: "doctor", path: ["proactive"] },
225
293
  },
294
+ {
295
+ id: "doctor.timeoutMs",
296
+ group: "diagnostics",
297
+ label: "Probe timeout",
298
+ description: "How long one doctor probe may take, in milliseconds; 100 to 30000.",
299
+ source: "bytefly",
300
+ kind: "text",
301
+ fallback: String(DEFAULT_DOCTOR_CONFIG.timeoutMs),
302
+ json: "number",
303
+ options: [],
304
+ text: integerRule(100, 30_000),
305
+ config: { section: "doctor", path: ["timeoutMs"] },
306
+ },
226
307
  {
227
308
  id: "price.currency.code",
228
309
  group: "pricing",
@@ -262,11 +343,12 @@ export const SETTING_ROWS: readonly SettingDescriptor[] = [
262
343
  id: "chrome.rail",
263
344
  group: "transcript",
264
345
  label: "Rail glyph",
265
- description: "The single-column glyph that leads a tool result or notice body.",
346
+ description: "The single-column glyph that leads a tool result or notice body, e.g. ╎ │ ┃ ▏.",
266
347
  source: "bytefly",
267
- kind: "enum",
348
+ kind: "text",
268
349
  fallback: GLYPHS.divider,
269
- options: RAIL_OPTIONS,
350
+ options: [],
351
+ text: RAIL_RULE,
270
352
  config: { section: "chrome", path: ["rail"] },
271
353
  },
272
354
  {