@gavin-hu/my-pi-agent 0.14.0 → 0.15.1

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 (229) hide show
  1. package/README.md +54 -34
  2. package/README.zh-CN.md +44 -28
  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 +126 -37
  57. package/extensions/file-browse/client.ts +98 -33
  58. package/extensions/file-browse/config.ts +24 -9
  59. package/extensions/file-browse/files.ts +183 -48
  60. package/extensions/file-browse/fuzzy.ts +65 -11
  61. package/extensions/file-browse/git.ts +37 -47
  62. package/extensions/file-browse/html.ts +97 -171
  63. package/extensions/file-browse/live-client.ts +158 -0
  64. package/extensions/file-browse/live.ts +222 -0
  65. package/extensions/file-browse/markdown.ts +110 -92
  66. package/extensions/file-browse/raw-html.ts +478 -0
  67. package/extensions/file-browse/router.ts +65 -78
  68. package/extensions/file-browse/server.ts +75 -59
  69. package/extensions/file-browse/styles.ts +124 -0
  70. package/extensions/file-browse/urls.ts +80 -0
  71. package/extensions/goal/README.md +103 -47
  72. package/extensions/goal/clarify-parse.ts +4 -9
  73. package/extensions/goal/clarify.ts +27 -4
  74. package/extensions/goal/commands.ts +24 -11
  75. package/extensions/goal/format.ts +41 -10
  76. package/extensions/goal/index.ts +39 -16
  77. package/extensions/goal/plan-route.ts +51 -0
  78. package/extensions/goal/schema.ts +6 -23
  79. package/extensions/goal/state.ts +39 -57
  80. package/extensions/goal/tools.ts +26 -20
  81. package/extensions/goal/types.ts +11 -1
  82. package/extensions/job/README.md +20 -6
  83. package/extensions/job/format.ts +18 -68
  84. package/extensions/job/index.ts +26 -41
  85. package/extensions/job/log-writer.ts +208 -0
  86. package/extensions/job/logs.ts +3 -2
  87. package/extensions/job/process.ts +6 -99
  88. package/extensions/job/runtime.ts +42 -181
  89. package/extensions/job/schema.ts +1 -4
  90. package/extensions/job/tools.ts +1 -15
  91. package/extensions/job/tui.ts +117 -307
  92. package/extensions/job/ui.ts +7 -33
  93. package/extensions/memory/README.md +1 -1
  94. package/extensions/memory/errors.ts +5 -6
  95. package/extensions/memory/tui.ts +14 -24
  96. package/extensions/plan/README.md +94 -18
  97. package/extensions/plan/commands.ts +9 -5
  98. package/extensions/plan/format.ts +87 -0
  99. package/extensions/plan/index.ts +31 -15
  100. package/extensions/plan/plans.ts +2 -3
  101. package/extensions/plan/policy.ts +0 -6
  102. package/extensions/plan/runtime.ts +4 -2
  103. package/extensions/plan/steps.ts +37 -8
  104. package/extensions/plan/todos.ts +44 -3
  105. package/extensions/plan/tools/enter.ts +94 -0
  106. package/extensions/plan/tools/exit.ts +296 -0
  107. package/extensions/plan/tools/nested.ts +21 -0
  108. package/extensions/plan/tools/steps.ts +136 -0
  109. package/extensions/plan/tools/todo-seed.ts +76 -0
  110. package/extensions/plan/tools/write.ts +127 -0
  111. package/extensions/plan/tools.ts +18 -703
  112. package/extensions/plan/web-client.ts +6 -22
  113. package/extensions/plan/web-page.ts +19 -30
  114. package/extensions/plan/web-router.ts +32 -79
  115. package/extensions/plan/web.ts +21 -42
  116. package/extensions/question/answers.ts +3 -8
  117. package/extensions/question/tui.ts +6 -6
  118. package/extensions/rewind/README.md +95 -22
  119. package/extensions/rewind/checklist.ts +192 -0
  120. package/extensions/rewind/commands.ts +8 -0
  121. package/extensions/rewind/config.ts +3 -11
  122. package/extensions/rewind/format.ts +220 -2
  123. package/extensions/rewind/git.ts +101 -21
  124. package/extensions/rewind/hunk-picker.ts +114 -0
  125. package/extensions/rewind/patch.ts +88 -0
  126. package/extensions/rewind/path-picker.ts +67 -100
  127. package/extensions/rewind/paths.ts +93 -19
  128. package/extensions/rewind/restore.ts +53 -10
  129. package/extensions/rewind/review-tui.ts +320 -0
  130. package/extensions/rewind/review.ts +397 -0
  131. package/extensions/rewind/rewind-tui.ts +4 -3
  132. package/extensions/rewind/runtime.ts +94 -17
  133. package/extensions/rewind/snapshot.ts +13 -4
  134. package/extensions/subagent/README.md +61 -6
  135. package/extensions/subagent/background-tui.ts +208 -0
  136. package/extensions/subagent/background.ts +470 -0
  137. package/extensions/subagent/commands.ts +80 -0
  138. package/extensions/subagent/config.ts +4 -0
  139. package/extensions/subagent/format.ts +24 -1
  140. package/extensions/subagent/index.ts +91 -2
  141. package/extensions/subagent/render.ts +13 -0
  142. package/extensions/subagent/run.ts +17 -16
  143. package/extensions/subagent/schema.ts +12 -0
  144. package/extensions/subagent/types.ts +11 -0
  145. package/extensions/subagent/ui.ts +50 -0
  146. package/extensions/todo/README.md +18 -13
  147. package/extensions/todo/format.ts +3 -2
  148. package/extensions/todo/runtime.ts +3 -2
  149. package/extensions/todo/schema.ts +8 -84
  150. package/extensions/todo/state.ts +5 -46
  151. package/extensions/todo/tools.ts +2 -1
  152. package/extensions/todo/types.ts +6 -15
  153. package/extensions/watch/README.md +14 -6
  154. package/extensions/watch/child.ts +150 -0
  155. package/extensions/watch/format.ts +8 -48
  156. package/extensions/watch/index.ts +42 -58
  157. package/extensions/watch/runtime.ts +49 -128
  158. package/extensions/watch/schema.ts +1 -4
  159. package/extensions/watch/tools.ts +1 -11
  160. package/extensions/watch/tui.ts +103 -273
  161. package/extensions/watch/ui.ts +5 -32
  162. package/extensions/web-access/README.md +1 -1
  163. package/extensions/web-access/fetch/page.ts +1 -1
  164. package/extensions/web-access/fetch/tool.ts +6 -2
  165. package/extensions/web-access/search/search.ts +2 -5
  166. package/extensions/wechat/README.md +1 -1
  167. package/extensions/wechat/client.ts +23 -0
  168. package/extensions/wechat/commands.ts +57 -42
  169. package/extensions/wechat/deliver.ts +5 -9
  170. package/extensions/wechat/errors.ts +3 -6
  171. package/extensions/wechat/inbound.ts +2 -5
  172. package/extensions/wechat/index.ts +2 -8
  173. package/extensions/wechat/outbound.ts +2 -4
  174. package/extensions/wechat/peer-worker.ts +3 -8
  175. package/extensions/wechat/poll.ts +10 -9
  176. package/extensions/wechat/presence.ts +35 -62
  177. package/extensions/wechat/reply-pipeline.ts +3 -1
  178. package/extensions/wechat/status-screen.ts +1 -1
  179. package/extensions/wechat/timer.ts +57 -0
  180. package/extensions/worktree/config.ts +8 -20
  181. package/extensions/worktree/enter.ts +7 -12
  182. package/extensions/worktree/git.ts +38 -69
  183. package/extensions/worktree/index.ts +5 -4
  184. package/extensions/worktree/registry.ts +7 -7
  185. package/extensions/worktree/root-tools.ts +2 -2
  186. package/extensions/worktree/status.ts +15 -9
  187. package/lib/README.md +23 -8
  188. package/lib/config.ts +50 -3
  189. package/lib/errors.ts +12 -0
  190. package/lib/format.ts +50 -0
  191. package/lib/git/read.ts +28 -0
  192. package/lib/goal-state.ts +23 -8
  193. package/lib/http/headers.ts +74 -0
  194. package/lib/http/index.ts +13 -0
  195. package/lib/http/response.ts +46 -0
  196. package/lib/http/router.ts +52 -0
  197. package/lib/http/server.ts +109 -0
  198. package/lib/lines.ts +77 -0
  199. package/lib/pdf.ts +82 -0
  200. package/lib/plan-entry.ts +71 -0
  201. package/lib/plan-state.ts +34 -0
  202. package/lib/pool.ts +62 -0
  203. package/lib/report.ts +99 -0
  204. package/lib/requirements.ts +25 -19
  205. package/lib/spawn.ts +170 -0
  206. package/lib/todo-state.ts +110 -23
  207. package/lib/tool-names.ts +7 -0
  208. package/lib/tool-result.ts +33 -0
  209. package/lib/tui/README.md +68 -3
  210. package/lib/tui/chrome.ts +28 -4
  211. package/lib/tui/columns.ts +6 -1
  212. package/lib/tui/confirm.ts +65 -0
  213. package/lib/tui/dock-screen.ts +418 -0
  214. package/lib/tui/glyphs.ts +2 -1
  215. package/lib/tui/indent.ts +1 -1
  216. package/lib/tui/index.ts +2 -0
  217. package/lib/tui/rows.ts +21 -0
  218. package/lib/tui/status-controller.ts +35 -1
  219. package/lib/tui/tool.ts +59 -1
  220. package/lib/web/components.ts +20 -1
  221. package/lib/web/markup.ts +32 -0
  222. package/lib/web/runtime.ts +50 -0
  223. package/package.json +22 -5
  224. package/skills/init/SKILL.md +165 -0
  225. package/skills/init/references/agents-md-quality.md +103 -0
  226. package/skills/init/references/interview-questions.md +81 -0
  227. package/skills/refactor/SKILL.md +141 -0
  228. package/extensions/watch/process.ts +0 -63
  229. package/extensions/web-access/fetch/pdf.ts +0 -45
package/lib/config.ts CHANGED
@@ -54,6 +54,24 @@ export function cleanString(value: unknown, fallback: string): string {
54
54
  return typeof value === "string" && value.trim() ? value.trim() : fallback;
55
55
  }
56
56
 
57
+ /** A trimmed non-empty string, or `undefined` when the value is unset or blank. */
58
+ export function optionalString(value: unknown): string | undefined {
59
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
60
+ }
61
+
62
+ /** A boolean, or the fallback when the value is anything else. */
63
+ export function asBoolean(value: unknown, fallback: boolean): boolean {
64
+ return typeof value === "boolean" ? value : fallback;
65
+ }
66
+
67
+ /** Kept trimmed, non-empty strings from an untrusted array, or the fallback. */
68
+ export function stringList(value: unknown, fallback: string[]): string[] {
69
+ if (!Array.isArray(value)) return fallback;
70
+ return value
71
+ .filter((item): item is string => typeof item === "string" && item.trim().length > 0)
72
+ .map((item) => item.trim());
73
+ }
74
+
57
75
  /**
58
76
  * `value` when it is a plain object, otherwise `undefined`.
59
77
  *
@@ -225,10 +243,39 @@ export function writeConfigValue(path: string, section: string, key: string, val
225
243
  * malformed document and an empty path.
226
244
  */
227
245
  export function writeConfigValueAt(path: string, section: string, keyPath: readonly string[], value: unknown): void {
228
- if (keyPath.length === 0) throw new Error(`Cannot update ${path}: empty key path`);
246
+ writeConfigValuesAt(path, [{ section, path: keyPath, value }]);
247
+ }
248
+
249
+ /** One leaf edit for {@link writeConfigValuesAt}. */
250
+ export interface ConfigLeafEdit {
251
+ section: string;
252
+ path: readonly string[];
253
+ value: unknown;
254
+ }
255
+
256
+ /**
257
+ * Set several leaves in one document pass, with the guarantees of
258
+ * {@link writeConfigValueAt}.
259
+ *
260
+ * A row that also writes a companion leaf (the currency writes its default
261
+ * precision) must not read, write, and re-read the file between the two: a
262
+ * failure in the second write would leave the first one on disk while the
263
+ * caller reports that nothing was saved. Every edit is applied to one parsed
264
+ * document and written once, so the file is either wholly updated or untouched.
265
+ * Later edits see the sections earlier ones built.
266
+ */
267
+ export function writeConfigValuesAt(path: string, edits: readonly ConfigLeafEdit[]): void {
268
+ if (edits.length === 0) return;
269
+ for (const edit of edits) {
270
+ if (edit.path.length === 0) throw new Error(`Cannot update ${path}: empty key path`);
271
+ }
229
272
  const document = readEditableDocument(path);
230
- const current = configSection(document, section) ?? {};
231
- document[section] = setPath(current, keyPath, value);
273
+ const sections = new Map<string, Record<string, unknown>>();
274
+ for (const edit of edits) {
275
+ const current = sections.get(edit.section) ?? configSection(document, edit.section) ?? {};
276
+ sections.set(edit.section, setPath(current, edit.path, edit.value));
277
+ }
278
+ for (const [section, value] of sections) document[section] = value;
232
279
  writeConfigFile(path, document);
233
280
  }
234
281
 
package/lib/errors.ts ADDED
@@ -0,0 +1,12 @@
1
+ /**
2
+ * One error-to-line helper for an unknown thrown value.
3
+ *
4
+ * Extensions report a rejected promise, a failed child process, and a parser
5
+ * error the same way: an `Error` yields its message, and anything else is
6
+ * stringified. Kept value-only so every extension has one definition.
7
+ */
8
+
9
+ /** A user-facing message for an unknown thrown value. */
10
+ export function messageOf(error: unknown): string {
11
+ return error instanceof Error ? error.message : String(error);
12
+ }
package/lib/format.ts CHANGED
@@ -55,6 +55,17 @@ export function formatToolCount(count: number): string {
55
55
  return `${formatCount(n)} ${n === 1 ? "tool" : "tools"}`;
56
56
  }
57
57
 
58
+ /**
59
+ * A tail bounded to `maxChars`: the end of the joined lines, with the truncation
60
+ * flag. The header and the truncation suffix belong to the caller, because each
61
+ * surface says something different about where the rest of the output lives.
62
+ */
63
+ export function boundedTail(lines: readonly string[], maxChars: number): { text: string; truncated: boolean } {
64
+ const body = lines.join("\n");
65
+ if (body.length <= maxChars) return { text: body, truncated: false };
66
+ return { text: body.slice(body.length - maxChars), truncated: true };
67
+ }
68
+
58
69
  /**
59
70
  * Replace terminal control characters (including ESC and the C1 block) with
60
71
  * spaces, keeping `\n` and `\t` for callers that render multi-line text.
@@ -96,6 +107,45 @@ export function sanitizeLine(text: string): string {
96
107
  return sanitize(stripControlChars(text));
97
108
  }
98
109
 
110
+ /** A string read as one sanitized line; any other value reads empty. */
111
+ export function lineOf(value: unknown): string {
112
+ return typeof value === "string" ? sanitizeLine(value) : "";
113
+ }
114
+
115
+ /** Longest untrusted line kept for display; longer lines are clipped. */
116
+ export const MAX_OUTPUT_LINE = 200;
117
+
118
+ /** ESC-introduced sequences: OSC, CSI, and single-character escapes. */
119
+ const OSC = /\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)/g;
120
+ const CSI = /\u001b\[[0-?]*[ -/]*[@-~]/g;
121
+ const OTHER_ESC = /\u001b[@-Z\\-_]/g;
122
+ const CONTROL = /[\u0000-\u001f\u007f-\u009f]/g;
123
+
124
+ /**
125
+ * The boundary for one line of **program output**: a log file, a child's
126
+ * stdout, a tailed file.
127
+ *
128
+ * Stronger than {@link sanitizeLine}, which strips control characters but leaves
129
+ * the printable tail of an ANSI sequence behind. This removes whole OSC, CSI,
130
+ * and single-character escapes first, then resolves carriage returns the way a
131
+ * terminal would (keep the text after the last `\r`, so progress rewrites
132
+ * collapse to their final state), squeezes whitespace, and clips the result.
133
+ *
134
+ * The escape-stripping has to happen before the control-character pass: once
135
+ * ESC has become a space, the rest of the sequence is ordinary text.
136
+ */
137
+ export function sanitizeUntrustedLine(raw: string, maxLength: number = MAX_OUTPUT_LINE): string {
138
+ const withoutAnsi = raw.replace(OSC, "").replace(CSI, "").replace(OTHER_ESC, "");
139
+ const afterCr = withoutAnsi.slice(withoutAnsi.lastIndexOf("\r") + 1);
140
+ const cleaned = afterCr.replace(CONTROL, " ").replace(/\s+/g, " ").trim();
141
+ return cleaned.length > maxLength ? `${cleaned.slice(0, maxLength - 1)}…` : cleaned;
142
+ }
143
+
144
+ /** {@link sanitizeUntrustedLine} for a block of output, one entry per line. */
145
+ export function sanitizeUntrustedText(raw: string, maxLength: number = MAX_OUTPUT_LINE): string[] {
146
+ return raw.split("\n").map((line) => sanitizeUntrustedLine(line, maxLength));
147
+ }
148
+
99
149
  /**
100
150
  * Replace a home-directory prefix with `~`, matching at a path boundary so
101
151
  * `/home/bob` does not shorten `/home/bobby`. Separators are normalized to `/`
package/lib/git/read.ts CHANGED
@@ -54,6 +54,14 @@ export async function gitDir(runGit: RunGit, cwd: string): Promise<string | unde
54
54
  return dir || undefined;
55
55
  }
56
56
 
57
+ /** Absolute path of the repository's common git directory, shared by its worktrees. */
58
+ export async function gitCommonDir(runGit: RunGit, cwd: string): Promise<string | undefined> {
59
+ const result = await runGit(["rev-parse", "--path-format=absolute", "--git-common-dir"], { cwd });
60
+ if (result.code !== 0) return undefined;
61
+ const dir = result.stdout.trim();
62
+ return dir || undefined;
63
+ }
64
+
57
65
  /** Resolve a revision to a full object id, or undefined when it does not exist. */
58
66
  export async function revParse(runGit: RunGit, cwd: string, rev: string): Promise<string | undefined> {
59
67
  const result = await runGit(["rev-parse", "--verify", "--quiet", rev], { cwd });
@@ -62,6 +70,26 @@ export async function revParse(runGit: RunGit, cwd: string, rev: string): Promis
62
70
  return id || undefined;
63
71
  }
64
72
 
73
+ /**
74
+ * Resolve several revisions in one `git rev-parse`, in the order given.
75
+ *
76
+ * A snapshot needs `HEAD` and its tree, and two calls pay two spawns for one
77
+ * answer; `rev-parse` prints one line per argument, so one call is enough. A
78
+ * revision that does not resolve is not a shorter list — it is no answer, since
79
+ * the caller's indices would shift — so the result is `[]` unless every one
80
+ * resolved.
81
+ */
82
+ export async function revParseMany(runGit: RunGit, cwd: string, revs: readonly string[]): Promise<string[]> {
83
+ if (revs.length === 0) return [];
84
+ const result = await runGit(["rev-parse", ...revs], { cwd });
85
+ if (result.code !== 0) return [];
86
+ const ids = result.stdout
87
+ .split("\n")
88
+ .map((line) => line.trim())
89
+ .filter((line) => line !== "");
90
+ return ids.length === revs.length ? ids : [];
91
+ }
92
+
65
93
  /** Whether the repository has at least one commit. */
66
94
  export async function hasCommits(runGit: RunGit, cwd: string): Promise<boolean> {
67
95
  return (await revParse(runGit, cwd, "HEAD")) !== undefined;
package/lib/goal-state.ts CHANGED
@@ -6,8 +6,13 @@
6
6
  * `error`, which is not a write — and the `/goal` command appends a `goal` custom
7
7
  * entry whose `data.goal` is `null` when the goal is cleared. Replaying the branch
8
8
  * in order is how another extension reads the goal without importing goal's
9
- * modules: `plan` restates an active goal in its planning context and offers to
10
- * record one when a plan is approved with none.
9
+ * modules: `plan` restates a pursued goal in its planning context, promotes a
10
+ * `planning` goal at plan exit, and offers to record one when a plan is approved
11
+ * with none.
12
+ *
13
+ * The status is the goal's phase: `planning` while a plan is being designed,
14
+ * `active` once the objective is final, `achieved` when it is done. Only `goal`
15
+ * writes it; the promotion out of `planning` runs through the `goal` tool.
11
16
  *
12
17
  * Stored values are re-validated and re-sanitized here. Branch data can be
13
18
  * tampered with, migrated, or written by an older version, and a reader must not
@@ -15,9 +20,10 @@
15
20
  * ignored rather than clearing a goal that is still valid.
16
21
  *
17
22
  * The entry type and the objective limit live here and are imported by `goal`, so
18
- * the publisher and the reader cannot drift. `goal` keeps its own reconstruction
19
- * (`extensions/goal/state.ts`) because it also drives the widget; this module is
20
- * the minimal contract other extensions may depend on.
23
+ * the publisher and the reader cannot drift. `goal` reads the branch through
24
+ * {@link lastGoal} as well — [`extensions/goal/state.ts`](../extensions/goal/state.ts)
25
+ * adds only the widget's active check and the open-todo count on top — so there
26
+ * is one replay, not two.
21
27
  */
22
28
 
23
29
  import { sanitizeLine } from "./format.ts";
@@ -29,8 +35,8 @@ export const GOAL_ENTRY_TYPE = "goal";
29
35
  /** Longest objective the goal tool accepts, and the most a reader will replay. */
30
36
  export const MAX_GOAL_OBJECTIVE = 2000;
31
37
 
32
- /** Whether the goal is still being pursued, or already done. */
33
- export type PublishedGoalStatus = "active" | "achieved";
38
+ /** The goal's phase: being designed, final and pursued, or done. */
39
+ export type PublishedGoalStatus = "planning" | "active" | "achieved";
34
40
 
35
41
  /** The goal as another extension may read it. */
36
42
  export interface PublishedGoal {
@@ -60,12 +66,21 @@ function readGoal(value: unknown): PublishedGoal | null | undefined {
60
66
  if (!value || typeof value !== "object") return undefined;
61
67
  const goal = value as { objective?: unknown; status?: unknown };
62
68
  if (typeof goal.objective !== "string") return undefined;
63
- if (goal.status !== "active" && goal.status !== "achieved") return undefined;
69
+ if (goal.status !== "planning" && goal.status !== "active" && goal.status !== "achieved") return undefined;
64
70
  const objective = sanitizeLine(goal.objective);
65
71
  if (!objective || objective.length > MAX_GOAL_OBJECTIVE) return undefined;
66
72
  return { objective, status: goal.status };
67
73
  }
68
74
 
75
+ /**
76
+ * True while the goal steers the session: `planning` or `active`, never
77
+ * `achieved`. Both the reminder and the plan-mode context read this, so a goal
78
+ * being designed is restated like one being executed.
79
+ */
80
+ export function isPursuedGoal(goal: PublishedGoal | null): goal is PublishedGoal {
81
+ return goal?.status === "planning" || goal?.status === "active";
82
+ }
83
+
69
84
  /** The last goal written on the branch, or `null` when the session has none. */
70
85
  export function lastGoal(entries: Iterable<unknown>): PublishedGoal | null {
71
86
  let goal: PublishedGoal | null = null;
@@ -0,0 +1,74 @@
1
+ /**
2
+ * The response header sets both loopback servers answer with.
3
+ *
4
+ * One policy, declared once: the pages `file-browse` and `plan` serve are the
5
+ * same kind of document — a same-origin script, inline styles, and no network
6
+ * — so they must not drift into different envelopes, and a page that refuses to
7
+ * be framed cannot quietly stop doing so on one surface only.
8
+ *
9
+ * Every builder returns a fresh object, so a caller may add to a response
10
+ * without writing into another one. The policy itself is a code-authored
11
+ * constant; nothing here is model- or file-derived.
12
+ */
13
+
14
+ /**
15
+ * The page policy. `default-src 'none'` makes every fetch opt-in; the inline
16
+ * style allowance is the page's own `<style>` block, `script-src 'self'` the
17
+ * same-origin client, and `connect-src 'self'` the page's own API routes.
18
+ *
19
+ * The last three directives are the ones that make a local page safe to leave
20
+ * open: `frame-ancestors 'none'` (with the legacy `X-Frame-Options`) stops a
21
+ * local page from framing the surface and clickjacking a control with a side
22
+ * effect, `base-uri 'self'` stops an injected `<base>` from re-pointing every
23
+ * relative URL, and `form-action 'none'` stops a submission anywhere.
24
+ */
25
+ export const PAGE_CSP =
26
+ "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; script-src 'self'; " +
27
+ "connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'none'";
28
+
29
+ /** The security and caching headers every response carries, whatever its body. */
30
+ function secured(contentType: string): Record<string, string> {
31
+ return {
32
+ "Content-Type": contentType,
33
+ "Content-Security-Policy": PAGE_CSP,
34
+ "X-Frame-Options": "DENY",
35
+ "X-Content-Type-Options": "nosniff",
36
+ "Referrer-Policy": "no-referrer",
37
+ "Cache-Control": "no-store",
38
+ };
39
+ }
40
+
41
+ /** Headers for a served HTML page. */
42
+ export function pageHeaders(): Record<string, string> {
43
+ return secured("text/html; charset=utf-8");
44
+ }
45
+
46
+ /** Headers for a same-origin script: the page policy, with the script's own type. */
47
+ export function scriptHeaders(): Record<string, string> {
48
+ return secured("text/javascript; charset=utf-8");
49
+ }
50
+
51
+ /** Headers for a JSON API reply: the same envelope, a JSON type. */
52
+ export function jsonHeaders(): Record<string, string> {
53
+ return secured("application/json; charset=utf-8");
54
+ }
55
+
56
+ /**
57
+ * Headers for a Server-Sent Events stream: the one response that is not the page
58
+ * policy, because it is a long-lived event stream rather than a document. It
59
+ * carries no policy of its own to relax — the page that opens it is the one
60
+ * governed by `PAGE_CSP` — but it is declared here so both loopback servers
61
+ * answer a stream the same way.
62
+ *
63
+ * `no-store` stops a cached stream from replaying stale status, `keep-alive`
64
+ * names the connection's intent, and `X-Accel-Buffering: no` is for any
65
+ * buffering reverse proxy in front of it, which would otherwise hold the frames.
66
+ */
67
+ export function eventHeaders(): Record<string, string> {
68
+ return {
69
+ "Content-Type": "text/event-stream; charset=utf-8",
70
+ "Cache-Control": "no-store",
71
+ Connection: "keep-alive",
72
+ "X-Accel-Buffering": "no",
73
+ };
74
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The loopback HTTP layer shared by `file-browse` and `plan`: the response
3
+ * shape, the header policy, the request-envelope helpers, and the socket seam.
4
+ *
5
+ * Import from here rather than a leaf module, so the layer reads as one kit.
6
+ * Value-only, like the rest of `lib/`: the only state is what a returned closure
7
+ * carries (`createCloser`).
8
+ */
9
+
10
+ export * from "./headers.ts";
11
+ export * from "./response.ts";
12
+ export * from "./router.ts";
13
+ export * from "./server.ts";
@@ -0,0 +1,46 @@
1
+ /**
2
+ * The response shape both loopback servers answer with, and the builders that
3
+ * pair a body with the header set it needs.
4
+ *
5
+ * A router returns one of these instead of touching a socket, so every route is
6
+ * testable without a server, and `server.ts` in this directory is the only code
7
+ * that writes one out. The shape carries the two ways a response is not a
8
+ * buffered body — a file to stream and a stream to hold open — because those are
9
+ * properties of the answer, not of the server that serves it.
10
+ */
11
+
12
+ import { jsonHeaders, pageHeaders } from "./headers.ts";
13
+
14
+ /** One HTTP response, before it reaches a socket. */
15
+ export interface ServeResponse {
16
+ /** HTTP status code. */
17
+ status: number;
18
+ /** Headers to send verbatim. */
19
+ headers: Record<string, string>;
20
+ /** The buffered body; omitted for an empty response or a streamed file. */
21
+ body?: string | Buffer;
22
+ /** When set, the server streams this file instead of `body`. */
23
+ filePath?: string;
24
+ /** When true, the server keeps the connection open as a Server-Sent Events stream. */
25
+ stream?: boolean;
26
+ }
27
+
28
+ /** An HTML page, with any headers the route needs to add to the page policy. */
29
+ export function htmlResponse(status: number, body: string, extra?: Record<string, string>): ServeResponse {
30
+ return { status, headers: { ...pageHeaders(), ...extra }, body };
31
+ }
32
+
33
+ /** A JSON body, serialized once. */
34
+ export function jsonResponse(status: number, value: unknown): ServeResponse {
35
+ return { status, headers: jsonHeaders(), body: JSON.stringify(value) };
36
+ }
37
+
38
+ /** The JSON error envelope both API surfaces share: `{ error }`. */
39
+ export function jsonError(status: number, message: string): ServeResponse {
40
+ return jsonResponse(status, { error: message });
41
+ }
42
+
43
+ /** A plain-text body, for the internal-error fallback a server sends itself. */
44
+ export function textResponse(status: number, body: string): ServeResponse {
45
+ return { status, headers: { ...pageHeaders(), "Content-Type": "text/plain; charset=utf-8" }, body };
46
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The request-envelope helpers both loopback routers apply before a route runs:
3
+ * the request target, and the `Host` allowlist.
4
+ *
5
+ * Both surfaces bind `127.0.0.1`, so a `Host` naming anything else is a
6
+ * DNS-rebinding attempt against a local page. How strictly a missing `Host` is
7
+ * treated is the caller's decision: the browse server accepts one, because its
8
+ * router is also driven directly from tests and a plain `GET` with no header is
9
+ * not a rebinding, while the review server fails closed.
10
+ */
11
+
12
+ /** The `Host` names a loopback interface, with or without a port. */
13
+ const LOCAL_HOST = /^(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/i;
14
+
15
+ /** A request target split into the parts a router matches on. */
16
+ export interface ParsedRequestUrl {
17
+ /** The path, with the leading slash and no query. */
18
+ pathname: string;
19
+ /** The parsed query string. */
20
+ search: URLSearchParams;
21
+ }
22
+
23
+ /**
24
+ * Parse a raw request target (`path?query`) against a loopback base. Returns
25
+ * `undefined` for a target that cannot be parsed, which a router answers with
26
+ * `400` rather than a route.
27
+ */
28
+ export function parseRequestUrl(rawUrl: string): ParsedRequestUrl | undefined {
29
+ try {
30
+ const url = new URL(rawUrl, "http://127.0.0.1");
31
+ return { pathname: url.pathname, search: url.searchParams };
32
+ } catch {
33
+ return undefined;
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Whether the request's `Host` names this loopback server.
39
+ *
40
+ * With `required: false`, only a present, non-empty foreign name is refused, so
41
+ * a header-less request from another caller still routes. With `required: true`,
42
+ * a missing or non-string `Host` is refused too, so the guard cannot be skipped
43
+ * by omitting the header.
44
+ */
45
+ export function isLocalHostHeader(
46
+ headers: Record<string, string | string[] | undefined>,
47
+ options: { required: boolean },
48
+ ): boolean {
49
+ const host = headers.host;
50
+ if (typeof host !== "string" || host === "") return !options.required;
51
+ return LOCAL_HOST.test(host);
52
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * The socket seam shared by the two loopback servers: bind, write a response
3
+ * out, and close.
4
+ *
5
+ * Everything here is behavior a caller cannot get right by accident: a bind on
6
+ * `127.0.0.1` only, an accept-level `error` listener so a transient failure
7
+ * cannot take the host down, a `HEAD` that sends headers and no body, a `filePath`
8
+ * response streamed instead of buffered, a `stream` response opened as an event
9
+ * stream, and a close that is safe to call twice and drops keep-alive
10
+ * connections. What each server does *around* this — which frames an event
11
+ * stream carries, request bodies, session settling — stays with that server.
12
+ */
13
+
14
+ import { createReadStream } from "node:fs";
15
+ import type { IncomingMessage, Server, ServerResponse } from "node:http";
16
+ import type { ServeResponse } from "./response.ts";
17
+
18
+ /**
19
+ * Bind `server` to `127.0.0.1:port` and resolve the port it actually bound
20
+ * (`0` asks the OS to pick one). Rejects when the bind fails, so a caller that
21
+ * wants a fallback can retry with another port.
22
+ */
23
+ export async function bindLoopback(server: Server, port: number): Promise<number> {
24
+ await new Promise<void>((resolve, reject) => {
25
+ const onError = (error: Error) => reject(error);
26
+ server.once("error", onError);
27
+ server.listen(port, "127.0.0.1", () => {
28
+ server.off("error", onError);
29
+ resolve();
30
+ });
31
+ });
32
+ // An accept-level failure (EMFILE and friends) is emitted on the server, and an
33
+ // `error` event with no listener would take the host down mid-session. The
34
+ // affected request fails on its own and is answered as an error by the caller.
35
+ server.on("error", () => {});
36
+ const address = server.address();
37
+ return typeof address === "object" && address ? address.port : port;
38
+ }
39
+
40
+ /**
41
+ * Write one routed response: headers first, then no body for `HEAD`, the file
42
+ * for a `filePath` response, or the buffered body.
43
+ *
44
+ * A `stream` response is deliberately not handled here: keeping a connection
45
+ * open is the owning server's lifecycle, so it must check `stream` before
46
+ * calling this and open the stream itself through {@link openEventStream}.
47
+ *
48
+ * `afterFlush` runs once a buffered body is on the wire, which is the point a
49
+ * server may resolve a session without the client seeing a dropped connection.
50
+ * A streamed file has no such single point, so `respond` never calls it for one.
51
+ */
52
+ export function respond(
53
+ request: IncomingMessage,
54
+ response: ServerResponse,
55
+ result: ServeResponse,
56
+ afterFlush?: () => void,
57
+ ): void {
58
+ response.writeHead(result.status, result.headers);
59
+ if ((request.method ?? "GET").toUpperCase() === "HEAD") {
60
+ response.end(afterFlush);
61
+ return;
62
+ }
63
+ if (result.filePath) {
64
+ const stream = createReadStream(result.filePath);
65
+ stream.on("error", () => response.destroy());
66
+ stream.pipe(response);
67
+ return;
68
+ }
69
+ response.end(result.body ?? "", afterFlush);
70
+ }
71
+
72
+ /**
73
+ * Write the head of a Server-Sent Events response and start its stream: the
74
+ * status and headers, then a `retry:` hint for the browser's own reconnect.
75
+ *
76
+ * The connection itself stays the calling server's lifecycle — `respond` must
77
+ * not be called after this, and the caller owns ending the response and any
78
+ * periodic frames.
79
+ */
80
+ export function openEventStream(
81
+ response: ServerResponse,
82
+ result: Pick<ServeResponse, "status" | "headers">,
83
+ retryMs = 2000,
84
+ ): void {
85
+ response.writeHead(result.status, result.headers);
86
+ response.write(`retry: ${retryMs}\n\n`);
87
+ }
88
+
89
+ /**
90
+ * An idempotent `close()` for `server`.
91
+ *
92
+ * The second call resolves at once, so a shutdown path and a stop path can both
93
+ * await it. `closeAllConnections` is not on the `Server` type of every supported
94
+ * Node version, so it is probed: without it a keep-alive connection could hold
95
+ * the close open.
96
+ */
97
+ export function createCloser(server: Server): () => Promise<void> {
98
+ let closed = false;
99
+ return () =>
100
+ new Promise<void>((resolve) => {
101
+ if (closed) {
102
+ resolve();
103
+ return;
104
+ }
105
+ closed = true;
106
+ server.close(() => resolve());
107
+ (server as { closeAllConnections?: () => void }).closeAllConnections?.();
108
+ });
109
+ }
package/lib/lines.ts ADDED
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Line splitting for a stream of untrusted output.
3
+ *
4
+ * A child's stdout, or a file read, arrives in arbitrary byte chunks: a chunk
5
+ * boundary can fall in the middle of a line, and — the case a naive
6
+ * `chunk.toString()` gets wrong — in the middle of a multi-byte UTF-8
7
+ * character. This buffers the bytes and decodes only at a newline, so a split
8
+ * character survives intact.
9
+ *
10
+ * `\r` is not treated as a break: a progress rewrite belongs to the sanitizer
11
+ * (`sanitizeUntrustedLine` in `lib/format.ts`, which keeps the text after the
12
+ * last carriage return), and breaking on it here would turn one rewritten line
13
+ * into several.
14
+ *
15
+ * Value-only: the buffer lives on the instance, so each reader owns one.
16
+ */
17
+
18
+ /** Bytes buffered for one incomplete line before it is emitted anyway. */
19
+ export const MAX_LINE_BYTES = 1024 * 1024;
20
+
21
+ export interface LineSplitterOptions {
22
+ /** Emit an unfinished line once it grows past this many bytes. Default 1 MiB. */
23
+ maxLineBytes?: number;
24
+ }
25
+
26
+ export class LineSplitter {
27
+ private pending: Buffer = Buffer.alloc(0);
28
+ private readonly maxLineBytes: number;
29
+
30
+ constructor(options: LineSplitterOptions = {}) {
31
+ this.maxLineBytes = options.maxLineBytes ?? MAX_LINE_BYTES;
32
+ }
33
+
34
+ /** Bytes held for the unfinished line. */
35
+ get pendingBytes(): number {
36
+ return this.pending.length;
37
+ }
38
+
39
+ /**
40
+ * Feed one chunk and take every complete line it finishes.
41
+ *
42
+ * A line far longer than any display can show is emitted once it reaches the
43
+ * cap instead of being dropped, so a program that never prints a newline still
44
+ * produces output.
45
+ */
46
+ push(chunk: Uint8Array | string): string[] {
47
+ const bytes = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : Buffer.from(chunk);
48
+ const combined = this.pending.length > 0 ? Buffer.concat([this.pending, bytes]) : bytes;
49
+ const lines: string[] = [];
50
+ let start = 0;
51
+ for (let newline = combined.indexOf(0x0a); newline >= 0; newline = combined.indexOf(0x0a, start)) {
52
+ lines.push(combined.subarray(start, newline).toString("utf8"));
53
+ start = newline + 1;
54
+ }
55
+ let rest = combined.subarray(start);
56
+ if (rest.length > this.maxLineBytes) {
57
+ lines.push(rest.toString("utf8"));
58
+ rest = combined.subarray(0, 0);
59
+ }
60
+ // Copy, so a caller that reuses its read buffer cannot rewrite the tail.
61
+ this.pending = Buffer.from(rest);
62
+ return lines;
63
+ }
64
+
65
+ /** Take the unfinished line, for a stream that has ended. */
66
+ flush(): string[] {
67
+ if (this.pending.length === 0) return [];
68
+ const line = this.pending.toString("utf8");
69
+ this.pending = Buffer.alloc(0);
70
+ return [line];
71
+ }
72
+
73
+ /** Drop the unfinished line, for a source that was truncated or replaced. */
74
+ reset(): void {
75
+ this.pending = Buffer.alloc(0);
76
+ }
77
+ }