@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
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
 
@@ -246,27 +293,51 @@ function setPath(source: Record<string, unknown>, path: readonly string[], value
246
293
  }
247
294
 
248
295
  /**
249
- * Delete one key from one top-level section, preserving its siblings and the
250
- * rest of the document. An emptied section is removed rather than left as `{}`.
251
- * A no-op when the file, section, or key is absent; refuses a malformed
252
- * document.
296
+ * Delete the leaf at `keyPath` from one top-level section, preserving every
297
+ * sibling key, every other section, and document metadata. An object left empty
298
+ * by the deletion goes with it, the section included, so a file does not fill
299
+ * with `{}` shells. A no-op when the file, section, or leaf is absent; refuses a
300
+ * malformed document and an empty path.
253
301
  */
254
- export function unsetConfigValue(path: string, section: string, key: string): void {
302
+ export function unsetConfigValueAt(path: string, section: string, keyPath: readonly string[]): void {
303
+ if (keyPath.length === 0) throw new Error(`Cannot update ${path}: empty key path`);
255
304
  const result = readJsonResult(path);
256
305
  if (result.state === "absent") return;
257
306
  if (result.state === "error") throw new Error(`Cannot update ${path}: ${result.error}`);
258
307
 
259
308
  const document = result.value;
260
309
  const current = configSection(document, section);
261
- if (!current || !(key in current)) return;
310
+ if (!current) return;
262
311
 
263
- const next = { ...current };
264
- delete next[key];
265
- if (Object.keys(next).length === 0) delete document[section];
312
+ const next = withoutPath(current, keyPath);
313
+ if (next === current) return;
314
+ if (next === undefined) delete document[section];
266
315
  else document[section] = next;
267
316
  writeConfigFile(path, document);
268
317
  }
269
318
 
319
+ /**
320
+ * Copy `source` without the leaf at `path`, or `undefined` once nothing is left.
321
+ * Returns `source` itself when the leaf is absent or sits behind a non-object,
322
+ * so the caller can tell "nothing to delete" from "a deletion happened".
323
+ */
324
+ function withoutPath(source: Record<string, unknown>, path: readonly string[]): Record<string, unknown> | undefined {
325
+ const [head, ...rest] = path;
326
+ if (head === undefined || !(head in source)) return source;
327
+ const next = { ...source };
328
+ if (rest.length === 0) {
329
+ delete next[head];
330
+ } else {
331
+ const child = asRecord(source[head]);
332
+ if (!child) return source;
333
+ const pruned = withoutPath(child, rest);
334
+ if (pruned === child) return source;
335
+ if (pruned === undefined) delete next[head];
336
+ else next[head] = pruned;
337
+ }
338
+ return Object.keys(next).length === 0 ? undefined : next;
339
+ }
340
+
270
341
  /** Write a whole config document atomically (sibling temp file, then rename). */
271
342
  export function writeConfigFile(path: string, document: Record<string, unknown>): void {
272
343
  mkdirSync(dirname(path), { recursive: true });
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
@@ -49,6 +49,23 @@ export function formatDuration(ms: number): string {
49
49
  return `${hours}h${String(minutes % 60).padStart(2, "0")}m`;
50
50
  }
51
51
 
52
+ /** `1 tool`, `3 tools`: a tool count with the right word. */
53
+ export function formatToolCount(count: number): string {
54
+ const n = Number.isFinite(count) ? Math.max(0, Math.trunc(count)) : 0;
55
+ return `${formatCount(n)} ${n === 1 ? "tool" : "tools"}`;
56
+ }
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
+
52
69
  /**
53
70
  * Replace terminal control characters (including ESC and the C1 block) with
54
71
  * spaces, keeping `\n` and `\t` for callers that render multi-line text.
@@ -90,6 +107,45 @@ export function sanitizeLine(text: string): string {
90
107
  return sanitize(stripControlChars(text));
91
108
  }
92
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
+
93
149
  /**
94
150
  * Replace a home-directory prefix with `~`, matching at a path boundary so
95
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 });
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,54 @@
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
+ }
@@ -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,91 @@
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, and a close that is safe to call twice
9
+ * and drops keep-alive connections. What each server does *around* this — SSE
10
+ * streams, request bodies, session settling — stays with that server.
11
+ */
12
+
13
+ import { createReadStream } from "node:fs";
14
+ import type { IncomingMessage, Server, ServerResponse } from "node:http";
15
+ import type { ServeResponse } from "./response.ts";
16
+
17
+ /**
18
+ * Bind `server` to `127.0.0.1:port` and resolve the port it actually bound
19
+ * (`0` asks the OS to pick one). Rejects when the bind fails, so a caller that
20
+ * wants a fallback can retry with another port.
21
+ */
22
+ export async function bindLoopback(server: Server, port: number): Promise<number> {
23
+ await new Promise<void>((resolve, reject) => {
24
+ const onError = (error: Error) => reject(error);
25
+ server.once("error", onError);
26
+ server.listen(port, "127.0.0.1", () => {
27
+ server.off("error", onError);
28
+ resolve();
29
+ });
30
+ });
31
+ // An accept-level failure (EMFILE and friends) is emitted on the server, and an
32
+ // `error` event with no listener would take the host down mid-session. The
33
+ // affected request fails on its own and is answered as an error by the caller.
34
+ server.on("error", () => {});
35
+ const address = server.address();
36
+ return typeof address === "object" && address ? address.port : port;
37
+ }
38
+
39
+ /**
40
+ * Write one routed response: headers first, then no body for `HEAD`, the file
41
+ * for a `filePath` response, or the buffered body.
42
+ *
43
+ * A `stream` response is deliberately not handled here: keeping a connection
44
+ * open is the owning server's lifecycle, so it must check `stream` before
45
+ * calling this.
46
+ *
47
+ * `afterFlush` runs once a buffered body is on the wire, which is the point a
48
+ * server may resolve a session without the client seeing a dropped connection.
49
+ * A streamed file has no such single point, so `respond` never calls it for one.
50
+ */
51
+ export function respond(
52
+ request: IncomingMessage,
53
+ response: ServerResponse,
54
+ result: ServeResponse,
55
+ afterFlush?: () => void,
56
+ ): void {
57
+ response.writeHead(result.status, result.headers);
58
+ if ((request.method ?? "GET").toUpperCase() === "HEAD") {
59
+ response.end(afterFlush);
60
+ return;
61
+ }
62
+ if (result.filePath) {
63
+ const stream = createReadStream(result.filePath);
64
+ stream.on("error", () => response.destroy());
65
+ stream.pipe(response);
66
+ return;
67
+ }
68
+ response.end(result.body ?? "", afterFlush);
69
+ }
70
+
71
+ /**
72
+ * An idempotent `close()` for `server`.
73
+ *
74
+ * The second call resolves at once, so a shutdown path and a stop path can both
75
+ * await it. `closeAllConnections` is not on the `Server` type of every supported
76
+ * Node version, so it is probed: without it a keep-alive connection could hold
77
+ * the close open.
78
+ */
79
+ export function createCloser(server: Server): () => Promise<void> {
80
+ let closed = false;
81
+ return () =>
82
+ new Promise<void>((resolve) => {
83
+ if (closed) {
84
+ resolve();
85
+ return;
86
+ }
87
+ closed = true;
88
+ server.close(() => resolve());
89
+ (server as { closeAllConnections?: () => void }).closeAllConnections?.();
90
+ });
91
+ }
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
+ }