@gavin-hu/my-pi-agent 0.13.1 → 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 (237) hide show
  1. package/README.md +66 -41
  2. package/README.zh-CN.md +53 -36
  3. package/extensions/bytefly/README.md +321 -186
  4. package/extensions/bytefly/block/component.ts +7 -15
  5. package/extensions/bytefly/block/register.ts +6 -1
  6. package/extensions/bytefly/doctor/config.ts +4 -1
  7. package/extensions/bytefly/doctor/register.ts +24 -5
  8. package/extensions/bytefly/doctor/tools.ts +4 -0
  9. package/extensions/bytefly/footer/collect/snapshot.ts +6 -1
  10. package/extensions/bytefly/footer/config.ts +10 -6
  11. package/extensions/bytefly/footer/price/config.ts +3 -2
  12. package/extensions/bytefly/footer/price/price.ts +5 -3
  13. package/extensions/bytefly/footer/price/rates.ts +63 -26
  14. package/extensions/bytefly/footer/render/status.ts +3 -3
  15. package/extensions/bytefly/header/component.ts +33 -10
  16. package/extensions/bytefly/header/config.ts +22 -0
  17. package/extensions/bytefly/header/register.ts +2 -1
  18. package/extensions/bytefly/index.ts +6 -6
  19. package/extensions/bytefly/notice/component.ts +8 -3
  20. package/extensions/bytefly/notify/config.ts +7 -12
  21. package/extensions/bytefly/notify/dispatch.ts +13 -5
  22. package/extensions/bytefly/panel/commands.ts +22 -2
  23. package/extensions/bytefly/panel/format.ts +17 -21
  24. package/extensions/bytefly/panel/settings-runtime.ts +86 -22
  25. package/extensions/bytefly/panel/settings.ts +279 -46
  26. package/extensions/bytefly/panel/themes.ts +32 -0
  27. package/extensions/bytefly/panel/tui/editing.ts +77 -0
  28. package/extensions/bytefly/panel/tui/entries.ts +24 -0
  29. package/extensions/bytefly/panel/tui/extensions-view.ts +13 -8
  30. package/extensions/bytefly/panel/tui/frame.ts +121 -4
  31. package/extensions/bytefly/panel/tui/settings-view.ts +98 -13
  32. package/extensions/bytefly/panel/tui.ts +176 -166
  33. package/extensions/bytefly/picker/discovery.ts +1 -0
  34. package/extensions/bytefly/picker/types.ts +2 -0
  35. package/extensions/bytefly/separator/component.ts +17 -6
  36. package/extensions/bytefly/separator/config.ts +15 -0
  37. package/extensions/bytefly/separator/format.ts +60 -6
  38. package/extensions/bytefly/separator/register.ts +68 -9
  39. package/extensions/bytefly/separator/types.ts +8 -0
  40. package/extensions/bytefly/update/cache.ts +58 -19
  41. package/extensions/bytefly/update/check.ts +35 -0
  42. package/extensions/bytefly/update/format.ts +76 -35
  43. package/extensions/bytefly/update/register.ts +144 -55
  44. package/extensions/bytefly/update/state.ts +4 -2
  45. package/extensions/bytefly/update/suppress.ts +66 -0
  46. package/extensions/bytefly/update/types.ts +15 -0
  47. package/extensions/bytefly/working/config.ts +4 -5
  48. package/extensions/bytefly/working/format.ts +24 -7
  49. package/extensions/bytefly/working/register.ts +34 -3
  50. package/extensions/check/README.md +253 -0
  51. package/extensions/check/commands.ts +128 -0
  52. package/extensions/check/config.ts +39 -0
  53. package/extensions/check/detect.ts +269 -0
  54. package/extensions/check/failures.ts +230 -0
  55. package/extensions/check/format.ts +135 -0
  56. package/extensions/check/index.ts +26 -0
  57. package/extensions/check/render.ts +185 -0
  58. package/extensions/check/runner.ts +176 -0
  59. package/extensions/check/runtime.ts +244 -0
  60. package/extensions/check/source.ts +80 -0
  61. package/extensions/check/tools.ts +173 -0
  62. package/extensions/check/tui.ts +195 -0
  63. package/extensions/check/types.ts +99 -0
  64. package/extensions/document/README.md +1 -1
  65. package/extensions/document/extract/pdf.ts +8 -16
  66. package/extensions/document/formats.ts +2 -2
  67. package/extensions/file-browse/README.md +66 -32
  68. package/extensions/file-browse/client.ts +95 -32
  69. package/extensions/file-browse/config.ts +9 -9
  70. package/extensions/file-browse/git.ts +37 -47
  71. package/extensions/file-browse/html.ts +39 -58
  72. package/extensions/file-browse/markdown.ts +110 -92
  73. package/extensions/file-browse/raw-html.ts +478 -0
  74. package/extensions/file-browse/router.ts +32 -70
  75. package/extensions/file-browse/server.ts +18 -56
  76. package/extensions/file-browse/urls.ts +80 -0
  77. package/extensions/goal/README.md +103 -47
  78. package/extensions/goal/clarify-parse.ts +4 -9
  79. package/extensions/goal/clarify.ts +27 -4
  80. package/extensions/goal/commands.ts +24 -11
  81. package/extensions/goal/format.ts +41 -10
  82. package/extensions/goal/index.ts +39 -16
  83. package/extensions/goal/plan-route.ts +51 -0
  84. package/extensions/goal/schema.ts +6 -23
  85. package/extensions/goal/state.ts +39 -57
  86. package/extensions/goal/tools.ts +26 -20
  87. package/extensions/goal/types.ts +11 -1
  88. package/extensions/job/README.md +20 -6
  89. package/extensions/job/format.ts +18 -68
  90. package/extensions/job/index.ts +26 -41
  91. package/extensions/job/log-writer.ts +208 -0
  92. package/extensions/job/logs.ts +3 -2
  93. package/extensions/job/process.ts +6 -99
  94. package/extensions/job/runtime.ts +42 -181
  95. package/extensions/job/schema.ts +1 -4
  96. package/extensions/job/tools.ts +1 -15
  97. package/extensions/job/tui.ts +117 -307
  98. package/extensions/job/ui.ts +7 -33
  99. package/extensions/memory/README.md +3 -1
  100. package/extensions/memory/errors.ts +5 -6
  101. package/extensions/memory/tui.ts +14 -24
  102. package/extensions/plan/README.md +106 -18
  103. package/extensions/plan/commands.ts +9 -5
  104. package/extensions/plan/format.ts +87 -0
  105. package/extensions/plan/index.ts +31 -15
  106. package/extensions/plan/plans.ts +2 -3
  107. package/extensions/plan/policy.ts +0 -6
  108. package/extensions/plan/runtime.ts +4 -2
  109. package/extensions/plan/steps.ts +37 -8
  110. package/extensions/plan/todos.ts +44 -3
  111. package/extensions/plan/tools/enter.ts +94 -0
  112. package/extensions/plan/tools/exit.ts +296 -0
  113. package/extensions/plan/tools/nested.ts +21 -0
  114. package/extensions/plan/tools/steps.ts +136 -0
  115. package/extensions/plan/tools/todo-seed.ts +76 -0
  116. package/extensions/plan/tools/write.ts +127 -0
  117. package/extensions/plan/tools.ts +18 -703
  118. package/extensions/plan/web-client.ts +6 -22
  119. package/extensions/plan/web-page.ts +19 -30
  120. package/extensions/plan/web-router.ts +34 -71
  121. package/extensions/plan/web.ts +13 -40
  122. package/extensions/question/answers.ts +3 -8
  123. package/extensions/question/tui.ts +6 -6
  124. package/extensions/rewind/README.md +95 -22
  125. package/extensions/rewind/checklist.ts +185 -0
  126. package/extensions/rewind/commands.ts +8 -0
  127. package/extensions/rewind/config.ts +3 -11
  128. package/extensions/rewind/format.ts +220 -2
  129. package/extensions/rewind/git.ts +74 -7
  130. package/extensions/rewind/hunk-picker.ts +114 -0
  131. package/extensions/rewind/patch.ts +88 -0
  132. package/extensions/rewind/path-picker.ts +67 -100
  133. package/extensions/rewind/paths.ts +93 -19
  134. package/extensions/rewind/restore.ts +53 -10
  135. package/extensions/rewind/review-tui.ts +320 -0
  136. package/extensions/rewind/review.ts +397 -0
  137. package/extensions/rewind/rewind-tui.ts +4 -3
  138. package/extensions/rewind/runtime.ts +94 -17
  139. package/extensions/subagent/README.md +118 -8
  140. package/extensions/subagent/background-tui.ts +208 -0
  141. package/extensions/subagent/background.ts +470 -0
  142. package/extensions/subagent/commands.ts +80 -0
  143. package/extensions/subagent/config.ts +62 -0
  144. package/extensions/subagent/format.ts +24 -1
  145. package/extensions/subagent/index.ts +116 -1
  146. package/extensions/subagent/orchestrate.ts +16 -3
  147. package/extensions/subagent/render.ts +25 -0
  148. package/extensions/subagent/run.ts +113 -49
  149. package/extensions/subagent/schema.ts +12 -0
  150. package/extensions/subagent/transcripts.ts +202 -0
  151. package/extensions/subagent/types.ts +23 -0
  152. package/extensions/subagent/ui.ts +50 -0
  153. package/extensions/todo/README.md +18 -13
  154. package/extensions/todo/format.ts +3 -2
  155. package/extensions/todo/runtime.ts +3 -2
  156. package/extensions/todo/schema.ts +8 -84
  157. package/extensions/todo/state.ts +5 -46
  158. package/extensions/todo/tools.ts +2 -1
  159. package/extensions/todo/types.ts +6 -15
  160. package/extensions/watch/README.md +16 -6
  161. package/extensions/watch/child.ts +150 -0
  162. package/extensions/watch/format.ts +8 -48
  163. package/extensions/watch/index.ts +42 -58
  164. package/extensions/watch/runtime.ts +49 -128
  165. package/extensions/watch/schema.ts +1 -4
  166. package/extensions/watch/tools.ts +1 -11
  167. package/extensions/watch/tui.ts +103 -273
  168. package/extensions/watch/ui.ts +5 -32
  169. package/extensions/web-access/README.md +1 -1
  170. package/extensions/web-access/fetch/page.ts +1 -1
  171. package/extensions/web-access/fetch/tool.ts +6 -2
  172. package/extensions/web-access/search/search.ts +2 -5
  173. package/extensions/wechat/README.md +3 -1
  174. package/extensions/wechat/client.ts +23 -0
  175. package/extensions/wechat/commands.ts +57 -42
  176. package/extensions/wechat/deliver.ts +5 -9
  177. package/extensions/wechat/errors.ts +3 -6
  178. package/extensions/wechat/inbound.ts +2 -5
  179. package/extensions/wechat/index.ts +2 -8
  180. package/extensions/wechat/outbound.ts +2 -4
  181. package/extensions/wechat/peer-worker.ts +3 -8
  182. package/extensions/wechat/poll.ts +10 -9
  183. package/extensions/wechat/presence.ts +35 -62
  184. package/extensions/wechat/reply-pipeline.ts +3 -1
  185. package/extensions/wechat/status-screen.ts +1 -1
  186. package/extensions/wechat/timer.ts +57 -0
  187. package/extensions/worktree/config.ts +8 -20
  188. package/extensions/worktree/enter.ts +7 -12
  189. package/extensions/worktree/git.ts +38 -69
  190. package/extensions/worktree/index.ts +5 -4
  191. package/extensions/worktree/registry.ts +7 -7
  192. package/extensions/worktree/root-tools.ts +2 -2
  193. package/extensions/worktree/status.ts +3 -2
  194. package/lib/README.md +22 -8
  195. package/lib/config.ts +83 -12
  196. package/lib/errors.ts +12 -0
  197. package/lib/format.ts +56 -0
  198. package/lib/git/read.ts +8 -0
  199. package/lib/goal-state.ts +23 -8
  200. package/lib/http/headers.ts +54 -0
  201. package/lib/http/index.ts +13 -0
  202. package/lib/http/response.ts +46 -0
  203. package/lib/http/router.ts +52 -0
  204. package/lib/http/server.ts +91 -0
  205. package/lib/lines.ts +77 -0
  206. package/lib/pdf.ts +82 -0
  207. package/lib/plan-entry.ts +71 -0
  208. package/lib/plan-state.ts +34 -0
  209. package/lib/report.ts +99 -0
  210. package/lib/requirements.ts +25 -19
  211. package/lib/spawn.ts +170 -0
  212. package/lib/todo-state.ts +110 -23
  213. package/lib/tool-names.ts +7 -0
  214. package/lib/tool-result.ts +33 -0
  215. package/lib/tui/README.md +68 -3
  216. package/lib/tui/chrome.ts +28 -4
  217. package/lib/tui/columns.ts +6 -1
  218. package/lib/tui/confirm.ts +65 -0
  219. package/lib/tui/dock-screen.ts +410 -0
  220. package/lib/tui/glyphs.ts +2 -1
  221. package/lib/tui/indent.ts +1 -1
  222. package/lib/tui/index.ts +2 -0
  223. package/lib/tui/rows.ts +21 -0
  224. package/lib/tui/status-controller.ts +35 -1
  225. package/lib/tui/tool.ts +59 -1
  226. package/lib/web/components.ts +20 -1
  227. package/lib/web/markup.ts +32 -0
  228. package/lib/web/runtime.ts +50 -0
  229. package/package.json +23 -5
  230. package/skills/init/SKILL.md +165 -0
  231. package/skills/init/references/agents-md-quality.md +103 -0
  232. package/skills/init/references/interview-questions.md +81 -0
  233. package/skills/refactor/SKILL.md +141 -0
  234. package/themes/bytefly-dark.json +2 -3
  235. package/themes/bytefly-light.json +2 -3
  236. package/extensions/watch/process.ts +0 -63
  237. package/extensions/web-access/fetch/pdf.ts +0 -45
@@ -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
  }
@@ -12,17 +12,18 @@ import { closeSync, openSync, readSync, statSync } from "node:fs";
12
12
  import { isAbsolute, resolve } from "node:path";
13
13
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
14
14
  import { resolveEffectiveCwd } from "../../lib/env.ts";
15
+ import { LineSplitter } from "../../lib/lines.ts";
16
+ import type { KillTreeFn, SpawnFn, SpawnedProcess } from "../../lib/spawn.ts";
17
+ import { createChildSupervisor } from "./child.ts";
15
18
  import { DEFAULT_CONFIG, loadConfig, type WatchConfig } from "./config.ts";
16
- import { compileRegex, sanitizeWatchLine, sourceLabel } from "./format.ts";
17
- import { defaultKillTree, defaultSpawn, type KillTreeFn, type SpawnFn, type SpawnedProcess } from "./process.ts";
19
+ import { sanitizeUntrustedLine } from "../../lib/format.ts";
20
+ import { compileRegex, sourceLabel } from "./format.ts";
18
21
  import { createUiController, type UiController } from "./ui.ts";
19
22
  import type { PendingMatch, WatchMatch, WatchRecord, WatchSource } from "./types.ts";
20
23
 
21
24
  /** Largest slice read from a file per tick. */
22
25
  const MAX_CHUNK = 1024 * 1024;
23
- /** A line without a newline is flushed once it grows past this. */
24
- const MAX_LINE_BYTES = 1024 * 1024;
25
- /** Grace period before a stopped command is escalated to SIGKILL. */
26
+ /** Grace before a stopped command escalates to SIGKILL; `child.ts` defaults the same. */
26
27
  const KILL_GRACE_MS = 2000;
27
28
 
28
29
  /** Internal watcher state: the public record plus non-serializable fields. */
@@ -48,7 +49,7 @@ interface Internal {
48
49
  regex: RegExp;
49
50
  excludes: RegExp[];
50
51
  offset: number;
51
- pending: Buffer;
52
+ lines: LineSplitter;
52
53
  rawLines: string[];
53
54
  matches: WatchMatch[];
54
55
  child?: SpawnedProcess;
@@ -103,8 +104,11 @@ export interface WatchRuntimeOptions {
103
104
  }
104
105
 
105
106
  export function createWatchRuntime(options: WatchRuntimeOptions = {}): WatchRuntime {
106
- const spawnFn = options.spawn ?? defaultSpawn;
107
- const killTree = options.killTree ?? defaultKillTree;
107
+ const children = createChildSupervisor({
108
+ spawn: options.spawn,
109
+ killTree: options.killTree,
110
+ graceMs: options.killGraceMs,
111
+ });
108
112
  const now = options.now ?? (() => Date.now());
109
113
  const fixedConfig = options.config;
110
114
  const autoClock = options.autoClock ?? true;
@@ -208,7 +212,7 @@ export function createWatchRuntime(options: WatchRuntimeOptions = {}): WatchRunt
208
212
  for (const exclude of w.excludes) if (exclude.test(line)) return false;
209
213
  if (!w.regex.test(line)) return false;
210
214
 
211
- const clean = sanitizeWatchLine(line);
215
+ const clean = sanitizeUntrustedLine(line);
212
216
  w.matches.push({ seq: ++w.seq, line: clean, at: time, seen: false });
213
217
  if (w.matches.length > config.maxMatches) {
214
218
  w.matches.splice(0, w.matches.length - config.maxMatches);
@@ -224,85 +228,16 @@ export function createWatchRuntime(options: WatchRuntimeOptions = {}): WatchRunt
224
228
  };
225
229
 
226
230
  const consume = (w: Internal, chunk: Buffer, time: number): void => {
227
- const combined = w.pending.length > 0 ? Buffer.concat([w.pending, chunk]) : chunk;
228
- let start = 0;
229
231
  let matched = false;
230
- while (true) {
231
- const newline = combined.indexOf(0x0a, start);
232
- if (newline < 0) break;
233
- if (emit(w, combined.subarray(start, newline).toString("utf-8"), time)) matched = true;
234
- start = newline + 1;
235
- }
236
- let rest = combined.subarray(start);
237
- if (rest.length > MAX_LINE_BYTES) {
238
- if (emit(w, rest.toString("utf-8"), time)) matched = true;
239
- rest = Buffer.alloc(0);
232
+ for (const line of w.lines.push(chunk)) {
233
+ if (emit(w, line, time)) matched = true;
240
234
  }
241
- w.pending = Buffer.from(rest);
242
235
  if (matched && w.wake) fireOnMatch();
243
236
  };
244
237
 
238
+ /** A source that ended mid-line still reports that line. */
245
239
  const flushPending = (w: Internal, time: number): void => {
246
- if (w.pending.length === 0) return;
247
- const rest = w.pending.toString("utf-8");
248
- w.pending = Buffer.alloc(0);
249
- emit(w, rest, time);
250
- };
251
-
252
- const clearKillTimer = (w: Internal): void => {
253
- if (w.killTimer) {
254
- clearTimeout(w.killTimer);
255
- w.killTimer = undefined;
256
- }
257
- };
258
-
259
- const killChild = (w: Internal, signal: "SIGTERM" | "SIGKILL"): void => {
260
- const pid = w.pid;
261
- try {
262
- if (pid !== undefined) killTree(pid, signal, signal === "SIGKILL");
263
- else w.child?.kill(signal);
264
- } catch {
265
- // Best-effort: the child may already be gone.
266
- }
267
- };
268
-
269
- /** SIGTERM a command, escalating to SIGKILL when it has not closed in time. */
270
- const terminateChild = (w: Internal): void => {
271
- clearKillTimer(w);
272
- killChild(w, "SIGTERM");
273
- if (w.pid === undefined && !w.child) return;
274
- w.killTimer = setTimeout(() => {
275
- w.killTimer = undefined;
276
- if (w.child) killChild(w, "SIGKILL");
277
- }, killGraceMs);
278
- w.killTimer.unref?.();
279
- };
280
-
281
- /** Resolve when every command has closed, or after `ms`, whichever comes first. */
282
- const waitForChildren = (watches: Internal[], ms: number): Promise<void> => {
283
- const alive = watches.filter((w) => w.child !== undefined);
284
- if (alive.length === 0) return Promise.resolve();
285
- return new Promise((resolve) => {
286
- let remaining = alive.length;
287
- let timer: ReturnType<typeof setTimeout> | undefined;
288
- const settle = (): void => {
289
- if (--remaining > 0) return;
290
- if (timer !== undefined) clearTimeout(timer);
291
- resolve();
292
- };
293
- for (const w of alive) {
294
- let done = false;
295
- const once = (): void => {
296
- if (done) return;
297
- done = true;
298
- settle();
299
- };
300
- w.child?.on("close", once);
301
- w.child?.on("error", once);
302
- }
303
- timer = setTimeout(resolve, ms);
304
- timer.unref?.();
305
- });
240
+ for (const line of w.lines.flush()) emit(w, line, time);
306
241
  };
307
242
 
308
243
  const pollFile = (w: Internal, time: number): void => {
@@ -317,7 +252,7 @@ export function createWatchRuntime(options: WatchRuntimeOptions = {}): WatchRunt
317
252
  }
318
253
  if (size < w.offset) {
319
254
  w.offset = 0;
320
- w.pending = Buffer.alloc(0);
255
+ w.lines.reset();
321
256
  }
322
257
  if (w.offset >= size) return;
323
258
 
@@ -344,42 +279,28 @@ export function createWatchRuntime(options: WatchRuntimeOptions = {}): WatchRunt
344
279
  };
345
280
 
346
281
  const startCommand = (w: Internal): void => {
347
- let child: SpawnedProcess;
348
- try {
349
- child = spawnFn(w.source.command ?? "", { cwd: w.source.cwd ?? process.cwd() });
350
- } catch (error) {
351
- w.status = "error";
352
- w.error = (error as Error).message;
353
- w.endedAt = now();
354
- return;
355
- }
356
- w.child = child;
357
- w.pid = child.pid;
358
- const onData = (chunk: Buffer): void => consume(w, chunk, now());
359
- child.stdout?.on("data", onData);
360
- child.stderr?.on("data", onData);
361
- child.on("close", (code, signal) => {
362
- clearKillTimer(w);
363
- w.child = undefined;
364
- if (w.status !== "running") return;
365
- flushPending(w, now());
366
- w.status = "exited";
367
- w.exitCode = code;
368
- w.signal = signal;
369
- w.endedAt = now();
370
- ui.paint();
371
- if (runningCount() === 0) stopClock();
372
- });
373
- child.on("error", (error) => {
374
- clearKillTimer(w);
375
- w.child = undefined;
376
- if (w.status !== "running") return;
377
- w.status = "error";
378
- w.error = error.message;
379
- w.endedAt = now();
380
- ui.paint();
381
- if (runningCount() === 0) stopClock();
282
+ const error = children.start(w, w.source.command ?? "", w.source.cwd ?? process.cwd(), {
283
+ onData: (chunk) => consume(w, chunk, now()),
284
+ onSettled: (outcome) => {
285
+ if (w.status !== "running") return;
286
+ if (outcome.kind === "closed") {
287
+ flushPending(w, now());
288
+ w.status = "exited";
289
+ w.exitCode = outcome.code;
290
+ w.signal = outcome.signal;
291
+ } else {
292
+ w.status = "error";
293
+ w.error = outcome.message;
294
+ }
295
+ w.endedAt = now();
296
+ ui.paint();
297
+ if (runningCount() === 0) stopClock();
298
+ },
382
299
  });
300
+ if (error === undefined) return;
301
+ w.status = "error";
302
+ w.error = error;
303
+ w.endedAt = now();
383
304
  };
384
305
 
385
306
  const tick = (): void => {
@@ -443,7 +364,7 @@ export function createWatchRuntime(options: WatchRuntimeOptions = {}): WatchRunt
443
364
  regex,
444
365
  excludes,
445
366
  offset,
446
- pending: Buffer.alloc(0),
367
+ lines: new LineSplitter(),
447
368
  rawLines: [],
448
369
  matches: [],
449
370
  notified: false,
@@ -476,13 +397,13 @@ export function createWatchRuntime(options: WatchRuntimeOptions = {}): WatchRunt
476
397
 
477
398
  const matches = (id: string, lines?: number) => tail(id, lines, (w) => w.matches.map((m) => m.line));
478
399
  const logs = (id: string, lines?: number) =>
479
- tail(id, lines, (w) => w.rawLines.map((line) => sanitizeWatchLine(line)));
400
+ tail(id, lines, (w) => w.rawLines.map((line) => sanitizeUntrustedLine(line)));
480
401
 
481
402
  const stop = (id: string): boolean => {
482
403
  const w = watches.get(id);
483
404
  if (!w || w.status !== "running") return false;
484
405
  flushPending(w, now());
485
- terminateChild(w);
406
+ children.terminate(w);
486
407
  w.status = "stopped";
487
408
  w.endedAt = now();
488
409
  ui.paint();
@@ -535,8 +456,8 @@ export function createWatchRuntime(options: WatchRuntimeOptions = {}): WatchRunt
535
456
  for (const w of watches.values()) {
536
457
  if (w.status !== "running") continue;
537
458
  w.status = "stopped";
538
- clearKillTimer(w);
539
- killChild(w, "SIGKILL");
459
+ children.clearTimer(w);
460
+ children.kill(w, "SIGKILL");
540
461
  w.child = undefined;
541
462
  }
542
463
  watches.clear();
@@ -554,13 +475,13 @@ export function createWatchRuntime(options: WatchRuntimeOptions = {}): WatchRunt
554
475
  flushPending(w, now());
555
476
  w.status = "stopped";
556
477
  w.endedAt = now();
557
- clearKillTimer(w);
558
- killChild(w, "SIGTERM");
478
+ children.clearTimer(w);
479
+ children.kill(w, "SIGTERM");
559
480
  }
560
- await waitForChildren(running, killGraceMs);
481
+ await children.wait(running, killGraceMs);
561
482
  for (const w of running) {
562
- clearKillTimer(w);
563
- if (w.child) killChild(w, "SIGKILL");
483
+ children.clearTimer(w);
484
+ if (w.child) children.kill(w, "SIGKILL");
564
485
  }
565
486
  watches.clear();
566
487
  ui.detach();