@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,202 @@
1
+ /**
2
+ * Opt-in per-run transcripts.
3
+ *
4
+ * The dispatching process writes these; a child never learns that one exists, so
5
+ * the read-only tool set and the project-agent trust gate stay exactly as strict
6
+ * as they are. Lines are buffered in memory up to {@link MAX_TRANSCRIPT_BYTES}
7
+ * and flushed once at the end of the run, which keeps the store ordered without a
8
+ * stream to close and still writes on the abort path.
9
+ *
10
+ * Every failure is reported, never thrown: an unwritable checkout must not fail a
11
+ * delegation that already did its work.
12
+ */
13
+
14
+ import { randomBytes } from "node:crypto";
15
+ import { mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
16
+ import { join } from "node:path";
17
+ import { CONFIG_DIR_NAME, getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
18
+ import { repoRootFor } from "../../lib/git/index.ts";
19
+ import type { SingleResult } from "./types.ts";
20
+
21
+ /** Ceiling for one run's buffered transcript, stdout and stderr together. */
22
+ export const MAX_TRANSCRIPT_BYTES = 32 * 1024 * 1024;
23
+
24
+ /** `YYYYMMDD-HHMMSS-abcdef`; a name sort is chronological, so pruning never parses one. */
25
+ const RUN_ID_PATTERN = /^\d{8}-\d{6}-[0-9a-f]{6}$/;
26
+
27
+ const EVENTS_FILE = "events.jsonl";
28
+ const STDERR_FILE = "stderr.log";
29
+ const META_FILE = "meta.json";
30
+
31
+ /** What a transcript needs from the run that produced it. */
32
+ export interface TranscriptContext {
33
+ /** Working directory the child ran in. */
34
+ cwd: string;
35
+ /** Whether the run forced a reader-only tool set. */
36
+ readOnly: boolean;
37
+ }
38
+
39
+ /** The outcome of writing one run directory. */
40
+ export interface TranscriptWritten {
41
+ /** Directory written, absent when the write failed. */
42
+ path?: string;
43
+ /** Bytes buffered for this run. */
44
+ bytes: number;
45
+ /** Whether the size cap cut the transcript short. */
46
+ truncated: boolean;
47
+ /** Why the write failed, when it did. */
48
+ error?: string;
49
+ }
50
+
51
+ /** A run's buffered transcript, written once the run has finished. */
52
+ export interface Transcript {
53
+ /** Append one raw stdout line. */
54
+ line(raw: string): void;
55
+ /** Append stderr text, which may arrive in partial chunks. */
56
+ stderr(text: string): void;
57
+ /** Write the run directory under `dir`. Never throws. */
58
+ write(dir: string, result: SingleResult, context: TranscriptContext): TranscriptWritten;
59
+ }
60
+
61
+ /** The directory holding every transcript for `cwd`'s repository. */
62
+ export async function transcriptsDirFor(pi: Pick<ExtensionAPI, "exec">, cwd: string): Promise<string> {
63
+ const root = await repoRootFor(pi, cwd, 5_000);
64
+ return root ? join(root, CONFIG_DIR_NAME, "subagents") : join(getAgentDir(), "subagents");
65
+ }
66
+
67
+ /** A run id for `at` (epoch ms, rendered in UTC) with a six-hex suffix. */
68
+ export function runIdFor(at: number, suffix: string): string {
69
+ const date = new Date(at);
70
+ const pad = (value: number, width = 2) => String(value).padStart(width, "0");
71
+ const day = `${date.getUTCFullYear()}${pad(date.getUTCMonth() + 1)}${pad(date.getUTCDate())}`;
72
+ const time = `${pad(date.getUTCHours())}${pad(date.getUTCMinutes())}${pad(date.getUTCSeconds())}`;
73
+ return `${day}-${time}-${suffix}`;
74
+ }
75
+
76
+ /** The metadata written beside a run's stream; enough to audit it without one. */
77
+ function metaFor(runId: string, result: SingleResult, context: TranscriptContext, truncated: boolean) {
78
+ return {
79
+ version: 1,
80
+ runId,
81
+ agent: result.agent,
82
+ agentSource: result.agentSource,
83
+ task: result.task,
84
+ step: result.step,
85
+ cwd: context.cwd,
86
+ readOnly: context.readOnly,
87
+ provider: result.provider,
88
+ model: result.model,
89
+ startedAt: result.startedAt,
90
+ finishedAt: result.finishedAt,
91
+ exitCode: result.exitCode,
92
+ timedOut: result.timedOut === true,
93
+ stopReason: result.stopReason,
94
+ errorMessage: result.errorMessage,
95
+ toolErrors: result.toolErrors ?? 0,
96
+ usage: result.usage,
97
+ transcript: { truncated },
98
+ };
99
+ }
100
+
101
+ /** Buffer one run's stream until it finishes. */
102
+ export function createTranscript(options: { maxBytes?: number } = {}): Transcript {
103
+ const maxBytes = options.maxBytes ?? MAX_TRANSCRIPT_BYTES;
104
+ const lines: string[] = [];
105
+ let stderr = "";
106
+ let bytes = 0;
107
+ let truncated = false;
108
+
109
+ const append = (text: string, kind: "line" | "stderr"): void => {
110
+ if (truncated || !text) return;
111
+ const size = Buffer.byteLength(text);
112
+ if (bytes + size > maxBytes) {
113
+ // Stop growing; the marker is added on write so the file stays parseable.
114
+ truncated = true;
115
+ return;
116
+ }
117
+ bytes += size;
118
+ if (kind === "line") lines.push(text);
119
+ else stderr += text;
120
+ };
121
+
122
+ return {
123
+ line: (raw) => append(raw, "line"),
124
+ stderr: (text) => append(text, "stderr"),
125
+
126
+ write(dir, result, context) {
127
+ const runId = runIdFor(result.startedAt ?? Date.now(), randomBytes(3).toString("hex"));
128
+ const runDir = join(dir, runId);
129
+ try {
130
+ mkdirSync(runDir, { recursive: true });
131
+ const body = truncated ? [...lines, JSON.stringify({ type: "transcript-truncated", bytes })] : lines;
132
+ if (body.length > 0) writeFileSync(join(runDir, EVENTS_FILE), `${body.join("\n")}\n`, { mode: 0o600 });
133
+ if (stderr) writeFileSync(join(runDir, STDERR_FILE), stderr, { mode: 0o600 });
134
+ // Written last: a directory without metadata is an interrupted write.
135
+ const meta = `${JSON.stringify(metaFor(runId, result, context, truncated), null, 2)}\n`;
136
+ writeFileSync(join(runDir, META_FILE), meta, { mode: 0o600 });
137
+ return { path: runDir, bytes, truncated };
138
+ } catch (error) {
139
+ return {
140
+ bytes,
141
+ truncated,
142
+ error: error instanceof Error ? error.message : String(error),
143
+ };
144
+ }
145
+ },
146
+ };
147
+ }
148
+
149
+ /** Which run directories a prune keeps and which it removes. */
150
+ export interface PruneOutcome {
151
+ removed: string[];
152
+ /** Runs still on disk after the prune. */
153
+ kept: number;
154
+ }
155
+
156
+ /**
157
+ * Remove aged and surplus run directories. Only names matching a run id are
158
+ * candidates, so an unrelated directory under the store is never touched.
159
+ */
160
+ export function pruneTranscripts(
161
+ dir: string,
162
+ options: { retentionDays: number; maxRuns: number; now?: number },
163
+ ): PruneOutcome {
164
+ const now = options.now ?? Date.now();
165
+ let names: string[];
166
+ try {
167
+ names = readdirSync(dir, { withFileTypes: true })
168
+ .filter((entry) => entry.isDirectory() && RUN_ID_PATTERN.test(entry.name))
169
+ .map((entry) => entry.name)
170
+ .sort();
171
+ } catch {
172
+ return { removed: [], kept: 0 };
173
+ }
174
+
175
+ const kept: string[] = [];
176
+ const stale: string[] = [];
177
+ for (const name of names) {
178
+ let modified: number;
179
+ try {
180
+ modified = statSync(join(dir, name)).mtimeMs;
181
+ } catch {
182
+ continue;
183
+ }
184
+ if (now - modified > options.retentionDays * 86_400_000) stale.push(name);
185
+ else kept.push(name);
186
+ }
187
+ while (kept.length > options.maxRuns) {
188
+ const oldest = kept.shift();
189
+ if (oldest) stale.push(oldest);
190
+ }
191
+
192
+ const removed: string[] = [];
193
+ for (const name of stale) {
194
+ try {
195
+ rmSync(join(dir, name), { recursive: true, force: true });
196
+ removed.push(name);
197
+ } catch {
198
+ // Best-effort: a locked directory is retried at the next session start.
199
+ }
200
+ }
201
+ return { removed, kept: kept.length };
202
+ }
@@ -50,6 +50,27 @@ export interface SingleResult {
50
50
  startedAt?: number;
51
51
  /** Epoch ms when the subprocess finished. */
52
52
  finishedAt?: number;
53
+ /** Whether the run hit its wall-clock limit, which fails the run without aborting it. */
54
+ timedOut?: boolean;
55
+ /** The limit that applied to this run in ms; absent when it had none. */
56
+ timeoutMs?: number;
57
+ /** Directory of the run's transcript, when one was written. */
58
+ artifactPath?: string;
59
+ /** Bytes buffered for the transcript. */
60
+ artifactBytes?: number;
61
+ /** Whether the transcript stopped at the size cap. */
62
+ artifactTruncated?: boolean;
63
+ /** Why the transcript could not be written, when it could not. */
64
+ artifactError?: string;
65
+ }
66
+
67
+ /** A background run the tool just started. */
68
+ export interface BackgroundStart {
69
+ runId: string;
70
+ agent: string;
71
+ task: string;
72
+ cwd: string;
73
+ startedAt: number;
53
74
  }
54
75
 
55
76
  /** Structured result carried on the `subagent` tool result for rendering. */
@@ -62,6 +83,8 @@ export interface SubagentDetails {
62
83
  projectAgentsDir?: string | null;
63
84
  /** Whether the run forced a read-only tool set (plan mode). */
64
85
  readOnly?: boolean;
86
+ /** Present when the tool started a background run instead of waiting. */
87
+ background?: BackgroundStart;
65
88
  /**
66
89
  * Total steps/tasks requested, which can exceed `results.length` when a chain
67
90
  * stops early. Defaults to `results.length` when absent.
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Footer status and finish notices for background subagent runs.
3
+ *
4
+ * The runtime owns lifecycle and results; this owns the running-count chip and
5
+ * the notice that points back at `/agents`. A cancelled run stays silent because
6
+ * the user initiated it.
7
+ */
8
+
9
+ import { sanitizeLine } from "../../lib/format.ts";
10
+ import { GLYPHS, STATUS_KEYS } from "../../lib/tui/glyphs.ts";
11
+ import { createStatusController, type StatusController } from "../../lib/tui/status-controller.ts";
12
+ import type { BackgroundRun } from "./background.ts";
13
+
14
+ export interface SubagentUiOptions {
15
+ /** The current runs, read fresh on every paint. */
16
+ runs(): readonly BackgroundRun[];
17
+ }
18
+
19
+ export interface SubagentUiController extends StatusController {
20
+ notifyFinished(run: BackgroundRun): void;
21
+ }
22
+
23
+ /** A status word a notice can read without exposing the type's spelling. */
24
+ function statusText(status: BackgroundRun["status"]): string {
25
+ return status === "timedout" ? "timed out" : status;
26
+ }
27
+
28
+ export function createSubagentUi(options: SubagentUiOptions): SubagentUiController {
29
+ const controller = createStatusController({
30
+ keys: [STATUS_KEYS.agents],
31
+ enabled: () => true,
32
+ chips: () => {
33
+ const running = options.runs().filter((run) => run.status === "running").length;
34
+ return [{ key: STATUS_KEYS.agents, glyph: GLYPHS.delegation, count: running, role: "accent" }];
35
+ },
36
+ });
37
+
38
+ const notifyFinished = (run: BackgroundRun): void => {
39
+ // A cancelled run stays silent because the user initiated it.
40
+ if (run.status === "cancelled") return;
41
+ const failed = run.status === "failed" || run.status === "timedout";
42
+ const action = run.status === "finished" ? "collect" : "inspect";
43
+ controller.notify(
44
+ `Background subagent ${run.id} (${sanitizeLine(run.agent)}) ${statusText(run.status)}. Use /agents to ${action} it.`,
45
+ failed ? "error" : "info",
46
+ );
47
+ };
48
+
49
+ return { ...controller, notifyFinished };
50
+ }
@@ -174,13 +174,17 @@ depends on config.
174
174
  - **Whole-list replacement, not mutations.** Every call is a snapshot, so
175
175
  `todos: []` is the natural clear and there is no "forgot to toggle" failure
176
176
  mode. No ids or per-item update actions exist.
177
- - **State in tool-result `details`.** `reconstructTodos` replays the active
178
- branch and takes the last `todo` result, so `/resume` and `/tree` reproduce
179
- the list for that point in history. `pi.appendEntry()` was rejected because a
180
- side-channel list would not branch; error results are skipped as non-writes.
181
- - **Validation is pure and fail-safe.** `normalizeTodos` throws a
182
- model-readable message, and the tool turns that into an `isError` result that
183
- carries the *previous* list. A rejected call never half-applies.
177
+ - **State in tool-result `details`.** `lib/todo-state.ts`'s `lastTodos` replays
178
+ the active branch and takes the last write that fully validates, so `/resume`
179
+ and `/tree` reproduce the list for that point in history. The same replay is
180
+ what `plan` and `goal` read, so there is one rule for which write is a state
181
+ write. `pi.appendEntry()` was rejected because a side-channel list would not
182
+ branch; error results are skipped as non-writes.
183
+ - **Validation is pure and fail-safe.** The parser (`normalizeTodos` in
184
+ `lib/todo-state.ts`) throws a model-readable message, and the tool turns that
185
+ into an `isError` result that carries the *previous* list. A rejected call
186
+ never half-applies, and branch replay follows the same rule: a stored write
187
+ that fails validation is ignored whole rather than partially applied.
184
188
  - **One `in_progress` item, with a label.** The rule is validated, not silently
185
189
  coerced, so the model learns it from the error. The item must carry a
186
190
  non-blank `activeForm`; branch replay passes `{ requireActiveForm: false }`
@@ -210,10 +214,10 @@ depends on config.
210
214
  answer and never reopens that path. `nudged` caps it at one per user turn,
211
215
  `setTodos` clears the counters, and `context` drops the hidden message on the
212
216
  next user turn so it never accumulates.
213
- - **Content is sanitized at the boundary.** `normalizeTodos` replaces control
214
- characters and collapses whitespace before the length and duplicate checks,
215
- keeping `format.ts` free of terminal concerns and every surface to one row
216
- per item.
217
+ - **Content is sanitized at the boundary.** `normalizeTodos` in
218
+ `lib/todo-state.ts` replaces control characters and collapses whitespace
219
+ before the length and duplicate checks, keeping `format.ts` free of terminal
220
+ concerns and every surface to one row per item.
217
221
  - **The widget summarizes; full views keep the model's order.** The persistent
218
222
  widget is a single line (`currentTodo` picks the `in_progress` item, else the
219
223
  first pending); `/todos` keeps the submitted order, and the transcript result
@@ -234,8 +238,9 @@ depends on config.
234
238
  |---|---|
235
239
  | `index.ts` | Factory: wire the runtime, tool, command, reminder events, and session events. |
236
240
  | `types.ts` | `Todo`, `TodoStatus`, `TodoDetails`. |
237
- | `schema.ts` | TypeBox parameters and pure validation/normalization. |
238
- | `state.ts` | Branch reconstruction and status queries (pure). |
241
+ | `types.ts` | `Todo` and `TodoDetails`; re-exports `TodoStatus` / `TODO_STATUSES` from `lib/todo-state.ts`. |
242
+ | `schema.ts` | TypeBox parameters; the parser and limits live in `lib/todo-state.ts`. |
243
+ | `state.ts` | Status queries over the replayed list (pure). |
239
244
  | `nudge.ts` | Lag detection: mutating and file-write classification, due check, reminder text (pure). |
240
245
  | `format.ts` | Model-facing and transcript text (pure). |
241
246
  | `tui.ts` | Widget and scrollable `/todos` components, drawn from the `lib/tui/` kit (`titleRule`, `dockListFrame`, `listRow`, `emptyState`). |
@@ -4,7 +4,8 @@
4
4
 
5
5
  import type { Theme } from "@earendil-works/pi-coding-agent";
6
6
  import { truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
7
- import { expandHint, GLYPH_GAP, GLYPHS } from "../../lib/tui/glyphs.ts";
7
+ import { GLYPH_GAP, GLYPHS } from "../../lib/tui/glyphs.ts";
8
+ import { overflowHint } from "../../lib/tui/tool.ts";
8
9
  import { paint, type TuiRole } from "../../lib/tui/tokens.ts";
9
10
  import { completedCount, currentTodo } from "./state.ts";
10
11
  import type { Todo, TodoStatus } from "./types.ts";
@@ -149,7 +150,7 @@ export function todoRailLines(input: TodoRailInput, theme: Theme, width: number)
149
150
  });
150
151
  }
151
152
  if (input.more && input.more > 0) {
152
- lines.push(`${continuation}${paint(theme, "faint", `… ${input.more} more`)} ${expandHint(theme)}`);
153
+ lines.push(`${continuation}${overflowHint(theme, input.more)}`);
153
154
  }
154
155
  return lines.map((line) => truncateToWidth(line, w, "…"));
155
156
  }
@@ -12,9 +12,10 @@
12
12
 
13
13
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
14
14
  import { onRailsSuppressed, onUpperRailChanged } from "../../lib/rails.ts";
15
+ import { lastTodos } from "../../lib/todo-state.ts";
15
16
  import { DEFAULT_TODO_CONFIG, type TodoConfig } from "./config.ts";
16
17
  import { shouldNudge } from "./nudge.ts";
17
- import { hasOpenTodos, reconstructTodos } from "./state.ts";
18
+ import { hasOpenTodos } from "./state.ts";
18
19
  import { TodoWidget, WIDGET_KEY } from "./tui.ts";
19
20
  import type { Todo } from "./types.ts";
20
21
 
@@ -115,7 +116,7 @@ export function createTodoRuntime(pi?: Pick<ExtensionAPI, "events">): TodoRuntim
115
116
  notify();
116
117
  },
117
118
  reconstruct: (ctx) => {
118
- todos = reconstructTodos(ctx.sessionManager.getBranch());
119
+ todos = lastTodos(ctx.sessionManager.getBranch());
119
120
  mutations = 0;
120
121
  writes = 0;
121
122
  touched = false;
@@ -1,28 +1,22 @@
1
1
  /**
2
- * Parameter schema and validation for the `todo` tool.
2
+ * Parameter schema for the `todo` tool.
3
3
  *
4
- * Everything here is pure: no host APIs, no terminal. Invalid input throws a
5
- * model-readable `Error` before any state changes, so the model can retry with
6
- * a corrected list instead of silently corrupting the list.
4
+ * The schema is the host-facing shape; the semantic validation and the branch
5
+ * replay live together in `lib/todo-state.ts`, so the tool, `plan`, and `goal`
6
+ * read the same rules. Invalid input still throws a model-readable `Error`
7
+ * before any state changes.
7
8
  */
8
9
 
9
10
  import { StringEnum } from "@earendil-works/pi-ai";
10
11
  import { Type, type Static } from "typebox";
11
- import { sanitizeLine } from "../../lib/format.ts";
12
- import { TODO_STATUSES, type Todo, type TodoStatus } from "./types.ts";
13
-
14
- /** Maximum number of items in one list. */
15
- export const MAX_TODOS = 50;
16
- /** Maximum length of a single item's text. */
17
- export const MAX_CONTENT = 500;
12
+ import { MAX_TODOS, TODO_STATUSES } from "../../lib/todo-state.ts";
18
13
 
19
14
  /**
20
15
  * One item in the tool's input and output schemas.
21
16
  *
22
17
  * The input schema (`TodoParams`) enforces `maxItems` and the status enum
23
- * before `execute` runs. `normalizeTodos` re-enforces both for branch replay
24
- * and direct unit use, and owns the semantic rules the schema cannot express
25
- * (one `in_progress`, unique non-empty content, content length).
18
+ * before `execute` runs; `normalizeTodos` in `lib/todo-state.ts` re-enforces
19
+ * both and owns the semantic rules the schema cannot express.
26
20
  */
27
21
  export const TodoItem = Type.Object({
28
22
  content: Type.String({ description: "Imperative description of the task" }),
@@ -53,73 +47,3 @@ export const TodoResult = Type.Object({
53
47
  });
54
48
 
55
49
  export type TodoArgs = Static<typeof TodoParams>;
56
-
57
- const STATUS_SET: ReadonlySet<string> = new Set(TODO_STATUSES);
58
-
59
- function normalizeStatus(raw: unknown, index: number): TodoStatus {
60
- const value = typeof raw === "string" ? raw.trim().toLowerCase() : "";
61
- if (STATUS_SET.has(value)) return value as TodoStatus;
62
- throw new Error(`Todo ${index + 1}: status must be one of ${TODO_STATUSES.join(", ")}.`);
63
- }
64
-
65
- /** Options for {@link normalizeTodos}. */
66
- export interface NormalizeOptions {
67
- /**
68
- * Require a non-blank `activeForm` on `in_progress` items. Live tool input
69
- * enforces this (the default); branch replay passes `false` so lists written
70
- * before the rule was added still load.
71
- */
72
- requireActiveForm?: boolean;
73
- }
74
-
75
- /**
76
- * Validate and normalize the model's list.
77
- *
78
- * Content and `activeForm` are sanitized to a single safe line (see
79
- * `sanitizeLine`). Rejects an empty description, an unknown status, duplicate
80
- * content, an over-long field, more than one `in_progress` item, and — unless
81
- * `options.requireActiveForm` is `false` — an `in_progress` item without a
82
- * non-blank `activeForm`. `activeForm` is dropped when blank.
83
- */
84
- export function normalizeTodos(raw: unknown, options: NormalizeOptions = {}): Todo[] {
85
- const requireActiveForm = options.requireActiveForm ?? true;
86
- if (raw === undefined || raw === null) return [];
87
- if (!Array.isArray(raw)) throw new Error("todos must be an array.");
88
- if (raw.length > MAX_TODOS) throw new Error(`At most ${MAX_TODOS} todos are allowed.`);
89
-
90
- const todos: Todo[] = [];
91
- const seen = new Set<string>();
92
- let inProgress = 0;
93
-
94
- for (let i = 0; i < raw.length; i++) {
95
- const item = (raw[i] ?? {}) as Partial<Todo>;
96
- const content = typeof item.content === "string" ? sanitizeLine(item.content) : "";
97
- if (!content) throw new Error(`Todo ${i + 1}: content is required.`);
98
- if (content.length > MAX_CONTENT) {
99
- throw new Error(`Todo ${i + 1}: content is longer than ${MAX_CONTENT} characters.`);
100
- }
101
-
102
- const key = content.toLowerCase();
103
- if (seen.has(key)) throw new Error(`Todo ${i + 1}: duplicate content "${content}".`);
104
- seen.add(key);
105
-
106
- const status = normalizeStatus(item.status, i);
107
- if (status === "in_progress") inProgress++;
108
-
109
- const activeForm = typeof item.activeForm === "string" ? sanitizeLine(item.activeForm) : "";
110
- if (activeForm.length > MAX_CONTENT) {
111
- throw new Error(`Todo ${i + 1}: activeForm is longer than ${MAX_CONTENT} characters.`);
112
- }
113
- if (status === "in_progress" && requireActiveForm && !activeForm) {
114
- throw new Error(
115
- `Todo ${i + 1}: activeForm is required for an in_progress item (present-continuous, e.g. "Running tests").`,
116
- );
117
- }
118
- todos.push(activeForm ? { content, status, activeForm } : { content, status });
119
- }
120
-
121
- if (inProgress > 1) {
122
- throw new Error("At most one todo may be in_progress; mark the rest pending or completed.");
123
- }
124
- return todos;
125
- }
@@ -1,53 +1,12 @@
1
1
  /**
2
- * Branch-aware state for the todo extension (pure).
2
+ * Status queries over the list the branch published (pure).
3
3
  *
4
- * The list lives in each `todo` tool result's `details`, so navigating or
5
- * branching the session reproduces the list that was correct at that point.
6
- * `reconstructTodos` replays a branch and returns the last valid list; stored
7
- * values are re-validated and re-sanitized so corrupt or tampered branch data
8
- * cannot inject escapes, crash reconstruction, or wipe a valid list.
4
+ * The list itself — the item contract, its validation, and the single strict
5
+ * replay — lives in `lib/todo-state.ts`, shared with `plan` and `goal`. These
6
+ * are the derived facts the widget and the transcript ask of it.
9
7
  */
10
8
 
11
- import { TODO_TOOL } from "../../lib/tool-names.ts";
12
- import { normalizeTodos } from "./schema.ts";
13
- import type { Todo, TodoDetails } from "./types.ts";
14
-
15
- /** Minimal shape of a session entry, used for runtime narrowing. */
16
- interface BranchEntryLike {
17
- type?: string;
18
- message?: {
19
- role?: string;
20
- toolName?: string;
21
- details?: unknown;
22
- };
23
- }
24
-
25
- /** The last valid list written on the branch, or an empty list. */
26
- export function reconstructTodos(entries: Iterable<unknown>): Todo[] {
27
- let todos: Todo[] = [];
28
- for (const raw of entries) {
29
- const entry = raw as BranchEntryLike;
30
- if (entry?.type !== "message") continue;
31
- const message = entry.message;
32
- if (message?.role !== "toolResult" || message.toolName !== TODO_TOOL) continue;
33
- const details = message.details as TodoDetails | undefined;
34
- if (!details || details.todos === undefined) continue;
35
- // A rejected call carries the unchanged list for the model to read; it is
36
- // not a state write and must not be replayed as one.
37
- if (details.error) continue;
38
- // Anything that does not re-validate as a todo list is ignored, so a
39
- // malformed entry cannot wipe a valid one. Replay is lenient about
40
- // `activeForm`: a list written before that field was required still loads
41
- // instead of being dropped.
42
- if (!Array.isArray(details.todos)) continue;
43
- try {
44
- todos = normalizeTodos(details.todos, { requireActiveForm: false });
45
- } catch {
46
- // Keep the previous list.
47
- }
48
- }
49
- return todos;
50
- }
9
+ import type { Todo } from "./types.ts";
51
10
 
52
11
  /** Number of completed items. */
53
12
  export function completedCount(todos: Todo[]): number {
@@ -15,12 +15,13 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
15
15
  import { sanitizeLine } from "../../lib/format.ts";
16
16
  import { TODO_TOOL } from "../../lib/tool-names.ts";
17
17
  import { GLYPHS } from "../../lib/tui/glyphs.ts";
18
+ import { normalizeTodos } from "../../lib/todo-state.ts";
18
19
  import { reuseText, toolDetails } from "../../lib/tui/chrome.ts";
19
20
  import { rejectionLine, reuseCallText, toolCallTitle } from "../../lib/tui/tool.ts";
20
21
  import { paint } from "../../lib/tui/tokens.ts";
21
22
  import { compareByActivity, formatCallText, formatTodoText, type TodoRailInput } from "./format.ts";
22
23
  import type { TodoRuntime } from "./runtime.ts";
23
- import { normalizeTodos, TodoParams, TodoResult, type TodoArgs } from "./schema.ts";
24
+ import { TodoParams, TodoResult, type TodoArgs } from "./schema.ts";
24
25
  import { TodoResult as TodoResultView } from "./tui.ts";
25
26
  import type { Todo, TodoDetails } from "./types.ts";
26
27
 
@@ -1,26 +1,17 @@
1
1
  /**
2
2
  * Shared types for the todo extension.
3
3
  *
4
- * The todo list is a whole-list replacement, the way Claude Code's `TodoWrite`
5
- * works: the model always sends the complete list, so there is no "add" or
6
- * "toggle" operation and no id bookkeeping.
4
+ * The item contract itself lives in `lib/todo-state.ts`, which owns the parser
5
+ * and the branch replay shared with `plan` and `goal`; this module re-exports it
6
+ * under the name the extension uses and adds the tool-result shape.
7
7
  */
8
8
 
9
- export const TODO_STATUSES = ["pending", "in_progress", "completed"] as const;
9
+ import type { ExistingTodo } from "../../lib/todo-state.ts";
10
10
 
11
- export type TodoStatus = (typeof TODO_STATUSES)[number];
11
+ export { TODO_STATUSES, type TodoStatus } from "../../lib/todo-state.ts";
12
12
 
13
13
  /** One item in the list. */
14
- export type Todo = {
15
- /** Imperative description of the task, e.g. "Write the parser tests". */
16
- content: string;
17
- status: TodoStatus;
18
- /**
19
- * Present-continuous label shown while the item is in progress, e.g.
20
- * "Writing the parser tests". Falls back to `content` when absent.
21
- */
22
- activeForm?: string;
23
- };
14
+ export type Todo = ExistingTodo;
24
15
 
25
16
  /** Structured result carried in the tool's `details` and used for reconstruction. */
26
17
  export interface TodoDetails {
@@ -103,6 +103,8 @@ render the tail of the sanitized lines, with the hidden count noted above and
103
103
 
104
104
  ### Command: /watch
105
105
 
106
+ <img src="../../assets/watch.png" alt="The /watch screen: a Watch title rule, a `2 watchers · 1 running · 3 matches` summary, a selected file watcher and a finished command watcher, the focused watcher's path and pattern with its line and match counts, and the key hints" width="100%">
107
+
106
108
  A bare `/watch` opens a dock screen listing watchers. `j`/`k` (and the wheel)
107
109
  move, `Enter`/`l` opens a watcher's matches, `x`/`K` stops a running watcher
108
110
  behind a `y/N` confirm, `d` clears stopped ones behind a `y/N` confirm, `Esc`
@@ -195,6 +197,14 @@ other.
195
197
 
196
198
  ## Design notes
197
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
+
198
208
  The file tailer polls and exposes `tick()` publicly; the interval is a thin
199
209
  wrapper over `tick()`, so tests drive matching deterministically with no real
200
210
  timer. A command tailer is event-driven and feeds both output streams through
@@ -212,15 +222,15 @@ outlive the session.
212
222
 
213
223
  | Module | Responsibility |
214
224
  |---|---|
215
- | `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. |
216
226
  | `tools.ts` | The `watch` tool definition, execution, and transcript rendering. |
217
227
  | `schema.ts` | TypeBox parameters and pure `normalizeCall` validation. |
218
228
  | `types.ts` | `WatchAction`/`WatchStatus`, `WatchRecord`, `WatchDetails`, `PendingMatch`. |
219
- | `runtime.ts` | The session-scoped watcher table, matcher, poll tick, and shutdown. |
220
- | `process.ts` | Injectable spawn and process-tree kill. |
221
- | `format.ts` | Pure sanitization and text formatting. |
222
- | `ui.ts` | Status chips and the match alert, over the shared `lib/tui/status-controller.ts`. |
223
- | `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. |
224
234
  | `commands.ts` | The `/watch` command and its completions. |
225
235
  | `config.ts` | The `watch` config section load and normalization. |
226
236