@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
@@ -197,6 +197,14 @@ other.
197
197
 
198
198
  ## Design notes
199
199
 
200
+ The shared seams are `lib/`'s: `lib/spawn.ts` (spawn, tree-kill), `lib/lines.ts`
201
+ (the byte-safe splitter), `lib/format.ts` (`sanitizeUntrustedLine`),
202
+ `lib/report.ts` (pending matches → notice → one wake turn),
203
+ `lib/tui/status-controller.ts` (chips and the alert), and
204
+ `lib/tui/dock-screen.ts` (the `/watch` frame, navigation, and confirm layer).
205
+ What stays here is the watcher policy: the tailer, the matcher, the source
206
+ model, the command-child lifecycle in `child.ts`, and the watcher vocabulary.
207
+
200
208
  The file tailer polls and exposes `tick()` publicly; the interval is a thin
201
209
  wrapper over `tick()`, so tests drive matching deterministically with no real
202
210
  timer. A command tailer is event-driven and feeds both output streams through
@@ -214,15 +222,15 @@ outlive the session.
214
222
 
215
223
  | Module | Responsibility |
216
224
  |---|---|
217
- | `index.ts` | Extension factory: registers the tool and command, wires the lifecycle and the shared notices. |
225
+ | `index.ts` | Extension factory: registers the tool and command, wires the lifecycle, the shared `lib/report.ts` match reporter, and the shared notices. |
218
226
  | `tools.ts` | The `watch` tool definition, execution, and transcript rendering. |
219
227
  | `schema.ts` | TypeBox parameters and pure `normalizeCall` validation. |
220
228
  | `types.ts` | `WatchAction`/`WatchStatus`, `WatchRecord`, `WatchDetails`, `PendingMatch`. |
221
- | `runtime.ts` | The session-scoped watcher table, matcher, poll tick, and shutdown. |
222
- | `process.ts` | Injectable spawn and process-tree kill. |
223
- | `format.ts` | Pure sanitization and text formatting. |
224
- | `ui.ts` | Status chips and the match alert, over the shared `lib/tui/status-controller.ts`. |
225
- | `tui.ts` | `WatchResult` transcript block and the `/watch` dock screen, drawn from the `lib/tui/` kit (`titleRule`, `listRow`, `emptyState`, `IdCursor`, `LinePane`). |
229
+ | `runtime.ts` | The session-scoped watcher table, matcher, poll tick, and shutdown; spawning and tree-kill come from `child.ts`, line splitting from `lib/lines.ts`. |
230
+ | `child.ts` | A command watcher's process lifecycle: spawn, the SIGTERM → SIGKILL grace, and the shutdown drain, over the injected `SpawnFn`/`KillTreeFn` seams. |
231
+ | `format.ts` | Pure text formatting; sanitization is `lib/format.ts`'s `sanitizeUntrustedLine`. |
232
+ | `ui.ts` | Status chips and the match alert, over the shared `lib/tui/status-controller.ts` (chips, attached context, `notify`). |
233
+ | `tui.ts` | The `WatchResult` transcript block over the shared `TailResult`, and the `/watch` screen as a `DockListScreen` subclass: watcher rows, focus block, counts, verbs. |
226
234
  | `commands.ts` | The `/watch` command and its completions. |
227
235
  | `config.ts` | The `watch` config section load and normalization. |
228
236
 
@@ -0,0 +1,150 @@
1
+ /**
2
+ * A command watcher's process lifecycle: spawn, tree-kill, escalate, and drain.
3
+ *
4
+ * A command source is a spawned process whose merged stdout and stderr feed the
5
+ * matcher. This module owns the process side only — the spawn, the SIGTERM →
6
+ * SIGKILL grace, and the shutdown drain — over the injected `SpawnFn` and
7
+ * `KillTreeFn` seams, so the runtime keeps the watcher's status, its repaint,
8
+ * and its clock.
9
+ *
10
+ * Value-only: the child handle and its escalation timer live on the caller's
11
+ * slot, never here, because Pi loads each extension with its own module cache.
12
+ */
13
+
14
+ import { defaultKillTree, defaultSpawn, type KillTreeFn, type SpawnFn, type SpawnedProcess } from "../../lib/spawn.ts";
15
+
16
+ /** Grace period before a stopped command is escalated to SIGKILL. */
17
+ export const KILL_GRACE_MS = 2000;
18
+
19
+ /**
20
+ * The process fields a watcher record carries. The supervisor mutates them in
21
+ * place, so the runtime's record and the module's handle cannot disagree about
22
+ * whether a child is still live.
23
+ */
24
+ export interface ChildSlot {
25
+ pid?: number;
26
+ child?: SpawnedProcess;
27
+ killTimer?: ReturnType<typeof setTimeout>;
28
+ }
29
+
30
+ /** How a child settled, for the sink that owns the watcher's status transition. */
31
+ export type ChildOutcome =
32
+ | { kind: "closed"; code: number | null; signal: string | null }
33
+ | { kind: "failed"; message: string };
34
+
35
+ export interface ChildSink {
36
+ /** One chunk of merged stdout/stderr. */
37
+ onData(chunk: Buffer): void;
38
+ /**
39
+ * The child closed or failed to spawn. The sink owns the status, because only
40
+ * it knows whether the watcher was still running when that happened.
41
+ */
42
+ onSettled(outcome: ChildOutcome): void;
43
+ }
44
+
45
+ export interface ChildSupervisorOptions {
46
+ spawn?: SpawnFn;
47
+ killTree?: KillTreeFn;
48
+ /** Grace before SIGTERM escalates to SIGKILL; default {@link KILL_GRACE_MS}. */
49
+ graceMs?: number;
50
+ }
51
+
52
+ export interface ChildSupervisor {
53
+ /** Spawn `command`, or return the spawn error and leave the slot's child unset. */
54
+ start(slot: ChildSlot, command: string, cwd: string, sink: ChildSink): string | undefined;
55
+ /** SIGTERM a command, escalating to SIGKILL when it has not closed in time. */
56
+ terminate(slot: ChildSlot): void;
57
+ /** Kill immediately, without arming an escalation. */
58
+ kill(slot: ChildSlot, signal: "SIGTERM" | "SIGKILL"): void;
59
+ /** Cancel a pending escalation. */
60
+ clearTimer(slot: ChildSlot): void;
61
+ /** Resolve when every slot's child has closed, or after `ms`, whichever comes first. */
62
+ wait(slots: readonly ChildSlot[], ms: number): Promise<void>;
63
+ }
64
+
65
+ export function createChildSupervisor(options: ChildSupervisorOptions = {}): ChildSupervisor {
66
+ const spawnFn = options.spawn ?? defaultSpawn;
67
+ const killTree = options.killTree ?? defaultKillTree;
68
+ const graceMs = options.graceMs ?? KILL_GRACE_MS;
69
+
70
+ const clearTimer = (slot: ChildSlot): void => {
71
+ if (slot.killTimer) {
72
+ clearTimeout(slot.killTimer);
73
+ slot.killTimer = undefined;
74
+ }
75
+ };
76
+
77
+ const kill = (slot: ChildSlot, signal: "SIGTERM" | "SIGKILL"): void => {
78
+ const pid = slot.pid;
79
+ try {
80
+ if (pid !== undefined) killTree(pid, signal, signal === "SIGKILL");
81
+ else slot.child?.kill(signal);
82
+ } catch {
83
+ // Best-effort: the child may already be gone.
84
+ }
85
+ };
86
+
87
+ const terminate = (slot: ChildSlot): void => {
88
+ clearTimer(slot);
89
+ kill(slot, "SIGTERM");
90
+ if (slot.pid === undefined && !slot.child) return;
91
+ slot.killTimer = setTimeout(() => {
92
+ slot.killTimer = undefined;
93
+ if (slot.child) kill(slot, "SIGKILL");
94
+ }, graceMs);
95
+ slot.killTimer.unref?.();
96
+ };
97
+
98
+ const start = (slot: ChildSlot, command: string, cwd: string, sink: ChildSink): string | undefined => {
99
+ let child: SpawnedProcess;
100
+ try {
101
+ child = spawnFn(command, { cwd });
102
+ } catch (error) {
103
+ return error instanceof Error ? error.message : String(error);
104
+ }
105
+ slot.child = child;
106
+ slot.pid = child.pid;
107
+ const onData = (chunk: Buffer): void => sink.onData(chunk);
108
+ child.stdout?.on("data", onData);
109
+ child.stderr?.on("data", onData);
110
+ child.on("close", (code, signal) => {
111
+ clearTimer(slot);
112
+ slot.child = undefined;
113
+ sink.onSettled({ kind: "closed", code, signal });
114
+ });
115
+ child.on("error", (error) => {
116
+ clearTimer(slot);
117
+ slot.child = undefined;
118
+ sink.onSettled({ kind: "failed", message: error.message });
119
+ });
120
+ return undefined;
121
+ };
122
+
123
+ const wait = (slots: readonly ChildSlot[], ms: number): Promise<void> => {
124
+ const alive = slots.filter((slot) => slot.child !== undefined);
125
+ if (alive.length === 0) return Promise.resolve();
126
+ return new Promise((resolve) => {
127
+ let remaining = alive.length;
128
+ let timer: ReturnType<typeof setTimeout> | undefined;
129
+ const settle = (): void => {
130
+ if (--remaining > 0) return;
131
+ if (timer !== undefined) clearTimeout(timer);
132
+ resolve();
133
+ };
134
+ for (const slot of alive) {
135
+ let done = false;
136
+ const once = (): void => {
137
+ if (done) return;
138
+ done = true;
139
+ settle();
140
+ };
141
+ slot.child?.on("close", once);
142
+ slot.child?.on("error", once);
143
+ }
144
+ timer = setTimeout(resolve, ms);
145
+ timer.unref?.();
146
+ });
147
+ };
148
+
149
+ return { start, terminate, kill, clearTimer, wait };
150
+ }
@@ -6,50 +6,23 @@
6
6
  * status chip, the `/watch` screen, the transcript, and the model.
7
7
  *
8
8
  * File and command output is untrusted, so every line that reaches a widget or
9
- * the model goes through `sanitizeLine` at the boundary.
9
+ * the model goes through `sanitizeUntrustedLine` from `lib/format.ts` at the
10
+ * boundary; `sanitizeLine` covers the extension's own trusted text.
10
11
  */
11
12
 
12
13
  import type { Theme } from "@earendil-works/pi-coding-agent";
13
- import { sanitizeLine } from "../../lib/format.ts";
14
- import { clip, labelLine } from "../../lib/tui/columns.ts";
14
+ import { boundedTail, sanitizeLine } from "../../lib/format.ts";
15
+ import { clip, clipLabel, labelLine } from "../../lib/tui/columns.ts";
15
16
  import { GLYPHS, SEPARATORS } from "../../lib/tui/glyphs.ts";
17
+ import { byRunningFirst } from "../../lib/tui/rows.ts";
16
18
  import { paint, type TuiRole } from "../../lib/tui/tokens.ts";
17
19
  import type { PendingMatch, WatchRecord, WatchStatus } from "./types.ts";
18
20
 
19
- /** Longest sanitized watched line kept for display; longer lines are clipped. */
20
- const MAX_WATCH_LINE = 200;
21
-
22
- /** ESC-introduced sequences: OSC, CSI, and single-character escapes. */
23
- const OSC = /\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)/g;
24
- const CSI = /\u001b\[[0-?]*[ -/]*[@-~]/g;
25
- const OTHER_ESC = /\u001b[@-Z\\-_]/g;
26
- const CONTROL = /[\u0000-\u001f\u007f-\u009f]/g;
27
-
28
- /**
29
- * Make one line of untrusted file/command output safe to render.
30
- *
31
- * Resolves carriage returns the way a terminal would (keep the text after the
32
- * last `\r`, so progress rewrites collapse to their final state), strips ANSI
33
- * escapes and control characters, collapses whitespace, and clips the result.
34
- * `sanitizeLine` alone would leave the printable tail of an ANSI sequence.
35
- */
36
- export function sanitizeWatchLine(raw: string): string {
37
- const withoutAnsi = raw.replace(OSC, "").replace(CSI, "").replace(OTHER_ESC, "");
38
- const afterCr = withoutAnsi.slice(withoutAnsi.lastIndexOf("\r") + 1);
39
- const cleaned = afterCr.replace(CONTROL, " ").replace(/\s+/g, " ").trim();
40
- return cleaned.length > MAX_WATCH_LINE ? `${cleaned.slice(0, MAX_WATCH_LINE - 1)}…` : cleaned;
41
- }
42
-
43
21
  /** Compile a pattern; case-insensitive unless `caseSensitive`. */
44
22
  export function compileRegex(pattern: string, caseSensitive: boolean): RegExp {
45
23
  return new RegExp(pattern, caseSensitive ? "" : "i");
46
24
  }
47
25
 
48
- /** A label clipped to one safe display line. */
49
- export function clipLabel(text: string): string {
50
- return labelLine(text, sanitizeWatchLine);
51
- }
52
-
53
26
  /** What a watcher reads: a path or a command, clipped for display. */
54
27
  export function sourceLabel(watch: WatchRecord): string {
55
28
  if (watch.source.kind === "file") return clipLabel(watch.source.path ?? "");
@@ -89,11 +62,6 @@ export function statusGlyphChar(status: WatchStatus): string {
89
62
  }
90
63
  }
91
64
 
92
- /** Width-1 themed glyph for a watcher status. */
93
- export function statusGlyph(status: WatchStatus, theme: Theme): string {
94
- return paint(theme, statusRole(status), statusGlyphChar(status));
95
- }
96
-
97
65
  /** Short status word, including an exit code or error when present. */
98
66
  export function statusWord(watch: WatchRecord): string {
99
67
  if (watch.status === "error" && watch.error) return `error (${watch.error})`;
@@ -104,10 +72,7 @@ export function statusWord(watch: WatchRecord): string {
104
72
  }
105
73
 
106
74
  /** Order watchers for display: running first, then by most recent start. */
107
- export function compareWatches(a: WatchRecord, b: WatchRecord): number {
108
- const rank = (watch: WatchRecord): number => (watch.status === "running" ? 0 : 1);
109
- return rank(a) - rank(b) || b.startedAt - a.startedAt;
110
- }
75
+ export const compareWatches = byRunningFirst<WatchRecord>((watch) => watch.status === "running");
111
76
 
112
77
  /** One-line watcher summary shared by model output and the `/watch` screen. */
113
78
  function watchSummary(watch: WatchRecord, now: number): string {
@@ -155,12 +120,7 @@ export function formatTail(
155
120
  truncated: false,
156
121
  };
157
122
  }
158
- let body = lines.join("\n");
159
- let truncated = false;
160
- if (body.length > maxChars) {
161
- body = body.slice(body.length - maxChars);
162
- truncated = true;
163
- }
123
+ const { text: body, truncated } = boundedTail(lines, maxChars);
164
124
  const suffix = truncated ? `\n… truncated (${lines.length} lines total)` : "";
165
125
  return { text: `${watch.id} (${statusWord(watch)}) ${label}:\n${body}${suffix}`, truncated };
166
126
  }
@@ -199,7 +159,7 @@ export function formatWatchOutcomeLine(watch: WatchRecord, theme: Theme, now = D
199
159
  ? paint(theme, "bad", statusWord(watch))
200
160
  : paint(theme, "faint", `${watch.matchCount} match${watch.matchCount === 1 ? "" : "es"}`);
201
161
  return (
202
- statusGlyph(watch.status, theme) +
162
+ paint(theme, statusRole(watch.status), statusGlyphChar(watch.status)) +
203
163
  " " +
204
164
  paint(theme, "accent", watch.id) +
205
165
  " " +
@@ -14,10 +14,11 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
14
14
  import { isExtensionEnabled } from "../../lib/env.ts";
15
15
  import { WATCH_MATCH_CHANNEL, type WatchMatchEvent } from "../../lib/events.ts";
16
16
  import { sanitizeLine } from "../../lib/format.ts";
17
- import { noticeMessage, type NoticeMessage, pruneStaleNotices } from "../../lib/notice.ts";
18
- import { STATUS_KEYS } from "../../lib/tui/glyphs.ts";
17
+ import { noticeMessage, type NoticeMessage } from "../../lib/notice.ts";
18
+ import { createPendingReporter } from "../../lib/report.ts";
19
19
  import { registerCommands } from "./commands.ts";
20
- import { clipLabel, formatPendingMatch } from "./format.ts";
20
+ import { clipLabel } from "../../lib/tui/columns.ts";
21
+ import { formatPendingMatch } from "./format.ts";
21
22
  import { createWatchRuntime, type WatchRuntime } from "./runtime.ts";
22
23
  import { registerTools } from "./tools.ts";
23
24
  import type { PendingMatch } from "./types.ts";
@@ -62,53 +63,46 @@ export interface WatchDeps {
62
63
  export default function watch(pi: ExtensionAPI, deps: WatchDeps = {}): void {
63
64
  if (!isExtensionEnabled("watch")) return;
64
65
  const runtime = deps.runtime ?? createWatchRuntime();
65
- let busy = false;
66
- let waking = false;
67
66
 
68
- registerTools(pi, runtime);
69
- registerCommands(pi, runtime);
70
-
71
- // Opt-in wake: a `wake` watcher matched while the agent is idle. Report *all*
72
- // pending matches, not just the waking one, so a non-wake match is not
73
- // silently dropped. `waking` guards the window before `agent_start` fires.
74
- const handleMatch = (): void => {
75
- if (busy || waking) return;
76
- const pending = runtime.pendingMatches();
77
- if (pending.length === 0) return;
78
- try {
79
- pi.sendMessage(watchNotice(pending), { triggerTurn: true });
80
- runtime.markReported(pending);
81
- waking = true;
82
- } catch {
83
- // Waking is best-effort; the next turn still reports the matches.
84
- return;
85
- }
67
+ // Opt-in wake: a `wake` watcher matched while the agent is idle. The reporter
68
+ // reports *all* pending matches, not just the waking one, so a non-wake match
69
+ // is not silently dropped.
70
+ const reporter = createPendingReporter<PendingMatch>({
71
+ wake: (notice) => pi.sendMessage(notice, { triggerTurn: true }),
72
+ pending: () => runtime.pendingMatches(),
73
+ markReported: (matches) => runtime.markReported(matches),
74
+ buildNotice: watchNotice,
75
+ source: "watch",
86
76
  // A wake means the user is away from the terminal, so the alert also leaves
87
77
  // the process. bytefly's notify capability owns delivery; this publishes the
88
78
  // fact and never learns how it is sent. The newest match is the one an alert
89
79
  // is about, while the count covers the whole cycle.
90
- const newest = pending[pending.length - 1];
91
- if (!newest) return;
92
- try {
93
- const event: WatchMatchEvent = {
94
- count: pending.length,
95
- watcherId: newest.watcherId,
96
- label: newest.label,
97
- line: newest.line,
98
- };
99
- pi.events.emit(WATCH_MATCH_CHANNEL, event);
100
- } catch {
101
- // A throwing listener must not cost the wake that already happened.
102
- }
103
- };
80
+ onWoke: (matches) => {
81
+ const newest = matches[matches.length - 1];
82
+ if (!newest) return;
83
+ try {
84
+ const event: WatchMatchEvent = {
85
+ count: matches.length,
86
+ watcherId: newest.watcherId,
87
+ label: newest.label,
88
+ line: newest.line,
89
+ };
90
+ pi.events.emit(WATCH_MATCH_CHANNEL, event);
91
+ } catch {
92
+ // A throwing listener must not cost the wake that already happened.
93
+ }
94
+ },
95
+ });
96
+
97
+ registerTools(pi, runtime);
98
+ registerCommands(pi, runtime);
104
99
 
105
100
  pi.on("session_start", (_event, ctx) => {
106
- busy = false;
107
- waking = false;
101
+ reporter.sessionStart();
108
102
  runtime.load(ctx);
109
103
  // `shutdown` clears this; a reload or session replacement reuses the
110
104
  // runtime, so re-arm it on every session start.
111
- runtime.onMatch = handleMatch;
105
+ runtime.onMatch = () => reporter.report();
112
106
  });
113
107
 
114
108
  // Tree navigation does not change the watcher table; only repaint.
@@ -117,37 +111,27 @@ export default function watch(pi: ExtensionAPI, deps: WatchDeps = {}): void {
117
111
  });
118
112
 
119
113
  pi.on("agent_start", () => {
120
- busy = true;
114
+ reporter.agentStart();
121
115
  });
122
116
  pi.on("agent_settled", () => {
123
- busy = false;
124
- waking = false;
117
+ reporter.agentSettled();
125
118
  });
126
119
 
127
120
  // Report matches to the model at the start of the next turn.
128
121
  pi.on("before_agent_start", (_event, ctx) => {
129
- const pending = runtime.pendingMatches();
130
- if (pending.length === 0) return undefined;
131
- runtime.markReported(pending);
122
+ const reported = reporter.beforeAgentStart();
123
+ if (!reported) return undefined;
132
124
  runtime.setStatus(ctx);
133
- return { message: watchNotice(pending) };
125
+ return reported;
134
126
  });
135
127
 
136
128
  // Keep stale match notices out of later turns. Only this extension's notices
137
129
  // are pruned; another source's are left for its own handler.
138
- pi.on("context", (event) => {
139
- const pruned = pruneStaleNotices(event.messages, "watch");
140
- return pruned ? { messages: pruned } : undefined;
141
- });
130
+ pi.on("context", (event) => reporter.filterContext(event.messages));
142
131
 
143
- pi.on("session_shutdown", async (_event, ctx) => {
132
+ // `shutdown` detaches the UI controller, which clears both chips.
133
+ pi.on("session_shutdown", async () => {
144
134
  runtime.onMatch = undefined;
145
135
  await runtime.shutdown();
146
- try {
147
- ctx.ui.setStatus(STATUS_KEYS.watch, undefined);
148
- ctx.ui.setStatus(STATUS_KEYS.watchMatches, undefined);
149
- } catch {
150
- // UI may already be gone.
151
- }
152
136
  });
153
137
  }