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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (222) hide show
  1. package/README.md +55 -33
  2. package/README.zh-CN.md +44 -27
  3. package/extensions/bytefly/README.md +193 -140
  4. package/extensions/bytefly/block/component.ts +7 -15
  5. package/extensions/bytefly/block/register.ts +6 -1
  6. package/extensions/bytefly/doctor/register.ts +24 -5
  7. package/extensions/bytefly/doctor/tools.ts +4 -0
  8. package/extensions/bytefly/footer/collect/snapshot.ts +6 -1
  9. package/extensions/bytefly/footer/config.ts +10 -6
  10. package/extensions/bytefly/footer/price/price.ts +5 -3
  11. package/extensions/bytefly/footer/price/rates.ts +63 -26
  12. package/extensions/bytefly/footer/render/status.ts +3 -3
  13. package/extensions/bytefly/header/component.ts +3 -2
  14. package/extensions/bytefly/index.ts +3 -3
  15. package/extensions/bytefly/notice/component.ts +8 -3
  16. package/extensions/bytefly/notify/config.ts +6 -11
  17. package/extensions/bytefly/notify/dispatch.ts +13 -5
  18. package/extensions/bytefly/panel/commands.ts +21 -1
  19. package/extensions/bytefly/panel/format.ts +5 -2
  20. package/extensions/bytefly/panel/settings-runtime.ts +26 -6
  21. package/extensions/bytefly/panel/settings.ts +41 -4
  22. package/extensions/bytefly/panel/themes.ts +32 -0
  23. package/extensions/bytefly/panel/tui/editing.ts +77 -0
  24. package/extensions/bytefly/panel/tui/entries.ts +24 -0
  25. package/extensions/bytefly/panel/tui/extensions-view.ts +13 -8
  26. package/extensions/bytefly/panel/tui/frame.ts +121 -4
  27. package/extensions/bytefly/panel/tui/settings-view.ts +54 -15
  28. package/extensions/bytefly/panel/tui.ts +94 -152
  29. package/extensions/bytefly/picker/discovery.ts +1 -0
  30. package/extensions/bytefly/picker/types.ts +2 -0
  31. package/extensions/bytefly/separator/component.ts +14 -4
  32. package/extensions/bytefly/update/cache.ts +58 -19
  33. package/extensions/bytefly/update/check.ts +35 -0
  34. package/extensions/bytefly/update/format.ts +76 -35
  35. package/extensions/bytefly/update/register.ts +144 -55
  36. package/extensions/bytefly/update/state.ts +4 -2
  37. package/extensions/bytefly/update/suppress.ts +66 -0
  38. package/extensions/bytefly/update/types.ts +15 -0
  39. package/extensions/check/README.md +253 -0
  40. package/extensions/check/commands.ts +128 -0
  41. package/extensions/check/config.ts +39 -0
  42. package/extensions/check/detect.ts +269 -0
  43. package/extensions/check/failures.ts +230 -0
  44. package/extensions/check/format.ts +135 -0
  45. package/extensions/check/index.ts +26 -0
  46. package/extensions/check/render.ts +185 -0
  47. package/extensions/check/runner.ts +176 -0
  48. package/extensions/check/runtime.ts +244 -0
  49. package/extensions/check/source.ts +80 -0
  50. package/extensions/check/tools.ts +173 -0
  51. package/extensions/check/tui.ts +195 -0
  52. package/extensions/check/types.ts +99 -0
  53. package/extensions/document/README.md +1 -1
  54. package/extensions/document/extract/pdf.ts +8 -16
  55. package/extensions/document/formats.ts +2 -2
  56. package/extensions/file-browse/README.md +66 -32
  57. package/extensions/file-browse/client.ts +95 -32
  58. package/extensions/file-browse/config.ts +9 -9
  59. package/extensions/file-browse/git.ts +37 -47
  60. package/extensions/file-browse/html.ts +39 -58
  61. package/extensions/file-browse/markdown.ts +110 -92
  62. package/extensions/file-browse/raw-html.ts +478 -0
  63. package/extensions/file-browse/router.ts +32 -70
  64. package/extensions/file-browse/server.ts +18 -56
  65. package/extensions/file-browse/urls.ts +80 -0
  66. package/extensions/goal/README.md +103 -47
  67. package/extensions/goal/clarify-parse.ts +4 -9
  68. package/extensions/goal/clarify.ts +27 -4
  69. package/extensions/goal/commands.ts +24 -11
  70. package/extensions/goal/format.ts +41 -10
  71. package/extensions/goal/index.ts +39 -16
  72. package/extensions/goal/plan-route.ts +51 -0
  73. package/extensions/goal/schema.ts +6 -23
  74. package/extensions/goal/state.ts +39 -57
  75. package/extensions/goal/tools.ts +26 -20
  76. package/extensions/goal/types.ts +11 -1
  77. package/extensions/job/README.md +20 -6
  78. package/extensions/job/format.ts +18 -68
  79. package/extensions/job/index.ts +26 -41
  80. package/extensions/job/log-writer.ts +208 -0
  81. package/extensions/job/logs.ts +3 -2
  82. package/extensions/job/process.ts +6 -99
  83. package/extensions/job/runtime.ts +42 -181
  84. package/extensions/job/schema.ts +1 -4
  85. package/extensions/job/tools.ts +1 -15
  86. package/extensions/job/tui.ts +117 -307
  87. package/extensions/job/ui.ts +7 -33
  88. package/extensions/memory/README.md +1 -1
  89. package/extensions/memory/errors.ts +5 -6
  90. package/extensions/memory/tui.ts +14 -24
  91. package/extensions/plan/README.md +94 -18
  92. package/extensions/plan/commands.ts +9 -5
  93. package/extensions/plan/format.ts +87 -0
  94. package/extensions/plan/index.ts +31 -15
  95. package/extensions/plan/plans.ts +2 -3
  96. package/extensions/plan/policy.ts +0 -6
  97. package/extensions/plan/runtime.ts +4 -2
  98. package/extensions/plan/steps.ts +37 -8
  99. package/extensions/plan/todos.ts +44 -3
  100. package/extensions/plan/tools/enter.ts +94 -0
  101. package/extensions/plan/tools/exit.ts +296 -0
  102. package/extensions/plan/tools/nested.ts +21 -0
  103. package/extensions/plan/tools/steps.ts +136 -0
  104. package/extensions/plan/tools/todo-seed.ts +76 -0
  105. package/extensions/plan/tools/write.ts +127 -0
  106. package/extensions/plan/tools.ts +18 -703
  107. package/extensions/plan/web-client.ts +6 -22
  108. package/extensions/plan/web-page.ts +19 -30
  109. package/extensions/plan/web-router.ts +34 -71
  110. package/extensions/plan/web.ts +13 -40
  111. package/extensions/question/answers.ts +3 -8
  112. package/extensions/question/tui.ts +6 -6
  113. package/extensions/rewind/README.md +95 -22
  114. package/extensions/rewind/checklist.ts +185 -0
  115. package/extensions/rewind/commands.ts +8 -0
  116. package/extensions/rewind/config.ts +3 -11
  117. package/extensions/rewind/format.ts +220 -2
  118. package/extensions/rewind/git.ts +74 -7
  119. package/extensions/rewind/hunk-picker.ts +114 -0
  120. package/extensions/rewind/patch.ts +88 -0
  121. package/extensions/rewind/path-picker.ts +67 -100
  122. package/extensions/rewind/paths.ts +93 -19
  123. package/extensions/rewind/restore.ts +53 -10
  124. package/extensions/rewind/review-tui.ts +320 -0
  125. package/extensions/rewind/review.ts +397 -0
  126. package/extensions/rewind/rewind-tui.ts +4 -3
  127. package/extensions/rewind/runtime.ts +94 -17
  128. package/extensions/subagent/README.md +61 -6
  129. package/extensions/subagent/background-tui.ts +208 -0
  130. package/extensions/subagent/background.ts +470 -0
  131. package/extensions/subagent/commands.ts +80 -0
  132. package/extensions/subagent/config.ts +4 -0
  133. package/extensions/subagent/format.ts +24 -1
  134. package/extensions/subagent/index.ts +91 -2
  135. package/extensions/subagent/render.ts +13 -0
  136. package/extensions/subagent/run.ts +17 -16
  137. package/extensions/subagent/schema.ts +12 -0
  138. package/extensions/subagent/types.ts +11 -0
  139. package/extensions/subagent/ui.ts +50 -0
  140. package/extensions/todo/README.md +18 -13
  141. package/extensions/todo/format.ts +3 -2
  142. package/extensions/todo/runtime.ts +3 -2
  143. package/extensions/todo/schema.ts +8 -84
  144. package/extensions/todo/state.ts +5 -46
  145. package/extensions/todo/tools.ts +2 -1
  146. package/extensions/todo/types.ts +6 -15
  147. package/extensions/watch/README.md +14 -6
  148. package/extensions/watch/child.ts +150 -0
  149. package/extensions/watch/format.ts +8 -48
  150. package/extensions/watch/index.ts +42 -58
  151. package/extensions/watch/runtime.ts +49 -128
  152. package/extensions/watch/schema.ts +1 -4
  153. package/extensions/watch/tools.ts +1 -11
  154. package/extensions/watch/tui.ts +103 -273
  155. package/extensions/watch/ui.ts +5 -32
  156. package/extensions/web-access/README.md +1 -1
  157. package/extensions/web-access/fetch/page.ts +1 -1
  158. package/extensions/web-access/fetch/tool.ts +6 -2
  159. package/extensions/web-access/search/search.ts +2 -5
  160. package/extensions/wechat/README.md +1 -1
  161. package/extensions/wechat/client.ts +23 -0
  162. package/extensions/wechat/commands.ts +57 -42
  163. package/extensions/wechat/deliver.ts +5 -9
  164. package/extensions/wechat/errors.ts +3 -6
  165. package/extensions/wechat/inbound.ts +2 -5
  166. package/extensions/wechat/index.ts +2 -8
  167. package/extensions/wechat/outbound.ts +2 -4
  168. package/extensions/wechat/peer-worker.ts +3 -8
  169. package/extensions/wechat/poll.ts +10 -9
  170. package/extensions/wechat/presence.ts +35 -62
  171. package/extensions/wechat/reply-pipeline.ts +3 -1
  172. package/extensions/wechat/status-screen.ts +1 -1
  173. package/extensions/wechat/timer.ts +57 -0
  174. package/extensions/worktree/config.ts +8 -20
  175. package/extensions/worktree/enter.ts +7 -12
  176. package/extensions/worktree/git.ts +38 -69
  177. package/extensions/worktree/index.ts +5 -4
  178. package/extensions/worktree/registry.ts +7 -7
  179. package/extensions/worktree/root-tools.ts +2 -2
  180. package/extensions/worktree/status.ts +3 -2
  181. package/lib/README.md +22 -8
  182. package/lib/config.ts +50 -3
  183. package/lib/errors.ts +12 -0
  184. package/lib/format.ts +50 -0
  185. package/lib/git/read.ts +8 -0
  186. package/lib/goal-state.ts +23 -8
  187. package/lib/http/headers.ts +54 -0
  188. package/lib/http/index.ts +13 -0
  189. package/lib/http/response.ts +46 -0
  190. package/lib/http/router.ts +52 -0
  191. package/lib/http/server.ts +91 -0
  192. package/lib/lines.ts +77 -0
  193. package/lib/pdf.ts +82 -0
  194. package/lib/plan-entry.ts +71 -0
  195. package/lib/plan-state.ts +34 -0
  196. package/lib/report.ts +99 -0
  197. package/lib/requirements.ts +25 -19
  198. package/lib/spawn.ts +170 -0
  199. package/lib/todo-state.ts +110 -23
  200. package/lib/tool-names.ts +7 -0
  201. package/lib/tool-result.ts +33 -0
  202. package/lib/tui/README.md +68 -3
  203. package/lib/tui/chrome.ts +28 -4
  204. package/lib/tui/columns.ts +6 -1
  205. package/lib/tui/confirm.ts +65 -0
  206. package/lib/tui/dock-screen.ts +410 -0
  207. package/lib/tui/glyphs.ts +2 -1
  208. package/lib/tui/indent.ts +1 -1
  209. package/lib/tui/index.ts +2 -0
  210. package/lib/tui/rows.ts +21 -0
  211. package/lib/tui/status-controller.ts +35 -1
  212. package/lib/tui/tool.ts +59 -1
  213. package/lib/web/components.ts +20 -1
  214. package/lib/web/markup.ts +32 -0
  215. package/lib/web/runtime.ts +50 -0
  216. package/package.json +22 -5
  217. package/skills/init/SKILL.md +165 -0
  218. package/skills/init/references/agents-md-quality.md +103 -0
  219. package/skills/init/references/interview-questions.md +81 -0
  220. package/skills/refactor/SKILL.md +141 -0
  221. package/extensions/watch/process.ts +0 -63
  222. package/extensions/web-access/fetch/pdf.ts +0 -45
@@ -11,7 +11,7 @@
11
11
  * gone; the explicit buttons are the primary save/cancel path.
12
12
  */
13
13
 
14
- import { THEME_TOGGLE_JS } from "../../lib/web/index.ts";
14
+ import { keysDialogScript, THEME_TOGGLE_JS } from "../../lib/web/index.ts";
15
15
 
16
16
  export const REVIEW_CLIENT_JS = String.raw`(function () {
17
17
  "use strict";
@@ -47,7 +47,6 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
47
47
  var cancelButton = document.getElementById("cancel");
48
48
  var outlineList = document.getElementById("outline-list");
49
49
  var progressEl = document.getElementById("progress-fill");
50
- var keysDialog = document.getElementById("keys-dialog");
51
50
 
52
51
  function el(tag, className) {
53
52
  var node = document.createElement(tag);
@@ -732,23 +731,8 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
732
731
  pendingQuote = wrap && quote ? { wrap: wrap, quote: quote } : null;
733
732
  });
734
733
 
735
- function toggleKeys(force) {
736
- if (!keysDialog) return;
737
- var open = force !== undefined ? force : keysDialog.hidden;
738
- keysDialog.hidden = !open;
739
- if (open) {
740
- var close = document.getElementById("keys-close");
741
- if (close) close.focus();
742
- }
743
- }
744
-
745
- if (keysDialog) {
746
- keysDialog.addEventListener("click", function (event) { if (event.target === keysDialog) toggleKeys(false); });
747
- var keysButton = document.getElementById("shortcuts");
748
- if (keysButton) keysButton.addEventListener("click", function () { toggleKeys(); });
749
- var keysClose = document.getElementById("keys-close");
750
- if (keysClose) keysClose.addEventListener("click", function () { toggleKeys(false); });
751
- }
734
+ // The sheet's own wiring (trigger, close button, backdrop) is shared with browse.
735
+ ${keysDialogScript("shortcuts")}
752
736
 
753
737
  function typing(target) {
754
738
  var tag = target && target.tagName ? target.tagName : "";
@@ -759,15 +743,15 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
759
743
  if (event.key === "Escape") {
760
744
  // Esc peels one layer at a time: the composer, then the sheet, then the review.
761
745
  if (planEl && planEl.querySelector(".bf-composer")) { closeComposer(); return; }
762
- if (keysDialog && !keysDialog.hidden) { toggleKeys(false); return; }
746
+ if (window.bfKeys.isOpen()) { window.bfKeys.close(); return; }
763
747
  cancel();
764
748
  return;
765
749
  }
766
750
  if (typing(event.target)) return;
767
751
  if (event.metaKey || event.ctrlKey || event.altKey) return;
768
- if (event.key === "?") { event.preventDefault(); toggleKeys(); return; }
752
+ if (event.key === "?") { event.preventDefault(); window.bfKeys.toggle(); return; }
769
753
  // While the shortcut sheet is open it owns the keyboard.
770
- if (keysDialog && !keysDialog.hidden) return;
754
+ if (window.bfKeys.isOpen()) return;
771
755
  if (event.key === "j") { event.preventDefault(); moveActive(1); return; }
772
756
  if (event.key === "k") { event.preventDefault(); moveActive(-1); return; }
773
757
  if (event.key === "c") { event.preventDefault(); commentOnActive(); return; }
@@ -16,6 +16,8 @@
16
16
  import {
17
17
  brandLockup,
18
18
  docHead,
19
+ type KeyHint,
20
+ keysSheet,
19
21
  SIDEBAR_COLLAPSE_MAX,
20
22
  themeToggle,
21
23
  WEB_BASE_CSS,
@@ -110,7 +112,7 @@ ${WEB_COMPONENTS_CSS}
110
112
  .review-nav .lvl-3 a{padding-left:30px}
111
113
  .review-nav .lvl-4 a,.review-nav .lvl-5 a,.review-nav .lvl-6 a{padding-left:42px}
112
114
  .review-hints{margin:auto 0 0;padding-top:var(--space-3);color:var(--muted);font-size:var(--fs-xs);line-height:var(--lh-relaxed)}
113
- .review-hints .review-kbd{margin:0 1px}
115
+ .review-hints .bf-kbd{margin:0 1px}
114
116
  /* The document: one measure, centred, with a comment rail in its left margin. */
115
117
  .bf-review>.bf-block{position:relative;width:100%;max-width:var(--review-measure);margin:0 auto;padding:3px 8px 3px var(--review-gutter);border-radius:var(--radius-md);overflow-wrap:break-word}
116
118
  .bf-block[id]{scroll-margin-top:88px}
@@ -155,13 +157,6 @@ ${WEB_COMPONENTS_CSS}
155
157
  .bf-comment__body{white-space:pre-wrap;overflow-wrap:anywhere;line-height:var(--lh-snug)}
156
158
  .bf-composer{margin:0}
157
159
  .bf-block--composing .bf-block__add{opacity:0}
158
- /* The keyboard sheet, reusing the shared dialog shell. */
159
- .bf-dialog__head{display:flex;align-items:center;gap:var(--space-3);padding:var(--space-3) var(--space-4);border-bottom:1px solid var(--border)}
160
- .bf-dialog__head h2{margin:0;font-size:var(--fs-lg)}
161
- .review-keys{display:grid;grid-template-columns:auto 1fr;gap:8px var(--space-4);margin:0;padding:var(--space-4)}
162
- .review-keys dt{white-space:nowrap}
163
- .review-keys dd{margin:0;color:var(--muted);font-size:var(--fs-sm)}
164
- .review-kbd{display:inline-block;min-width:20px;padding:1px 6px;border:1px solid var(--border);border-bottom-width:2px;border-radius:var(--radius-sm);background:var(--surface);color:var(--text);font:var(--fs-xs) var(--font-mono);text-align:center}
165
160
  @media (max-width:1180px){
166
161
  .bf-app__body{grid-template-columns:1fr;align-content:start}
167
162
  .bf-app__sidebar{position:static;max-height:none;border-right:0;border-bottom:1px solid var(--border)}
@@ -192,29 +187,23 @@ ${WEB_COMPONENTS_CSS}
192
187
  * not have to grow every time a binding is added.
193
188
  */
194
189
  function reviewHints(canApprove: boolean): string {
195
- const approve = canApprove ? `, <kbd class="review-kbd">a</kbd> to approve` : "";
196
- return `<p class="review-hints">Press <kbd class="review-kbd">j</kbd>/<kbd class="review-kbd">k</kbd> to move, <kbd class="review-kbd">c</kbd> to comment${approve}, <kbd class="review-kbd">s</kbd> to save, <kbd class="review-kbd">?</kbd> for all shortcuts.</p>`;
190
+ const approve = canApprove ? `, <kbd class="bf-kbd">a</kbd> to approve` : "";
191
+ return `<p class="review-hints">Press <kbd class="bf-kbd">j</kbd>/<kbd class="bf-kbd">k</kbd> to move, <kbd class="bf-kbd">c</kbd> to comment${approve}, <kbd class="bf-kbd">s</kbd> to save, <kbd class="bf-kbd">?</kbd> for all shortcuts.</p>`;
197
192
  }
198
193
 
199
- /** The keyboard sheet, hidden until the client opens it. */
200
- function keysDialog(canApprove: boolean): string {
201
- const approveRow = canApprove
202
- ? `<div><dt><kbd class="review-kbd">a</kbd></dt><dd>Approve and run the plan</dd></div>
203
- `
204
- : "";
205
- return `<div class="bf-dialog" id="keys-dialog" hidden>
206
- <div class="bf-dialog__panel" role="dialog" aria-modal="true" aria-labelledby="keys-title">
207
- <div class="bf-dialog__head"><h2 id="keys-title">Keyboard shortcuts</h2><span class="bf-spacer"></span><button type="button" class="bf-btn" id="keys-close">Close</button></div>
208
- <dl class="review-keys">
209
- ${approveRow}<div><dt><kbd class="review-kbd">j</kbd> <kbd class="review-kbd">k</kbd></dt><dd>Next and previous block</dd></div>
210
- <div><dt><kbd class="review-kbd">c</kbd></dt><dd>Comment on the current block</dd></div>
211
- <div><dt><kbd class="review-kbd">Enter</kbd></dt><dd>Add the comment you are writing</dd></div>
212
- <div><dt><kbd class="review-kbd">s</kbd></dt><dd>Save feedback and return to Pi</dd></div>
213
- <div><dt><kbd class="review-kbd">Esc</kbd></dt><dd>Close the composer, the sheet, or the review</dd></div>
214
- <div><dt><kbd class="review-kbd">?</kbd></dt><dd>Toggle this sheet</dd></div>
215
- </dl>
216
- </div>
217
- </div>`;
194
+ /** The shortcut sheet's rows: the review's own keys, led by approve when offered. */
195
+ function reviewKeys(canApprove: boolean): KeyHint[] {
196
+ const rows: KeyHint[] = [];
197
+ if (canApprove) rows.push({ keys: "a", label: "Approve and run the plan" });
198
+ rows.push(
199
+ { keys: ["j", "k"], label: "Next and previous block" },
200
+ { keys: "c", label: "Comment on the current block" },
201
+ { keys: "Enter", label: "Add the comment you are writing" },
202
+ { keys: "s", label: "Save feedback and return to Pi" },
203
+ { keys: "Esc", label: "Close the composer, the sheet, or the review" },
204
+ { keys: "?", label: "Toggle this sheet" },
205
+ );
206
+ return rows;
218
207
  }
219
208
 
220
209
  /**
@@ -268,7 +257,7 @@ ${saveButton}
268
257
  <div class="${bodyClass}">
269
258
  ${outlinePanel}<main class="bf-app__main bf-review" id="plan" aria-label="Plan"></main>
270
259
  </div>
271
- ${keysDialog(data.canApprove)}
260
+ ${keysSheet(reviewKeys(data.canApprove))}
272
261
  <script type="application/json" id="review-data">${island}</script>
273
262
  <script src="/client.js" defer></script>
274
263
  </body>
@@ -12,6 +12,16 @@
12
12
  * local page from driving the session.
13
13
  */
14
14
 
15
+ import {
16
+ isLocalHostHeader,
17
+ jsonError,
18
+ jsonHeaders,
19
+ jsonResponse,
20
+ pageHeaders,
21
+ parseRequestUrl,
22
+ scriptHeaders,
23
+ type ServeResponse,
24
+ } from "../../lib/http/index.ts";
15
25
  import { THEME_JS } from "../../lib/web/index.ts";
16
26
  import { coerceAnnotations, type PlanAnnotation } from "./feedback.ts";
17
27
  import { REVIEW_CLIENT_JS } from "./web-client.ts";
@@ -38,42 +48,10 @@ export interface ReviewContext {
38
48
  onCancel: () => void;
39
49
  }
40
50
 
41
- export interface ServeResponse {
42
- status: number;
43
- headers: Record<string, string>;
44
- body?: string;
45
- /** When true, the node server keeps the connection open as an SSE stream. */
46
- stream?: boolean;
47
- }
48
-
49
- const HOST_PATTERN = /^(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/i;
50
-
51
- const HTML_SECURITY: Record<string, string> = {
52
- "Content-Type": "text/html; charset=utf-8",
53
- // `frame-ancestors` keeps a local page from framing the review and clickjacking
54
- // the one control with a side effect (Approve & run); the rest is the same
55
- // no-script, no-network envelope the page already relied on.
56
- "Content-Security-Policy":
57
- "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; script-src 'self'; " +
58
- "connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'none'",
59
- "X-Frame-Options": "DENY",
60
- "X-Content-Type-Options": "nosniff",
61
- "Referrer-Policy": "no-referrer",
62
- "Cache-Control": "no-store",
63
- };
64
-
65
- const SCRIPT_HEADERS: Record<string, string> = {
66
- ...HTML_SECURITY,
67
- "Content-Type": "text/javascript; charset=utf-8",
68
- };
69
-
70
- export const JSON_HEADERS: Record<string, string> = {
71
- "Content-Type": "application/json; charset=utf-8",
72
- "X-Content-Type-Options": "nosniff",
73
- "Referrer-Policy": "no-referrer",
74
- "Cache-Control": "no-store",
75
- };
76
-
51
+ /**
52
+ * The event stream's headers: the one response that is not the page policy, because
53
+ * it is a long-lived `text/event-stream` rather than a document.
54
+ */
77
55
  const EVENT_HEADERS: Record<string, string> = {
78
56
  "Content-Type": "text/event-stream; charset=utf-8",
79
57
  "Cache-Control": "no-store",
@@ -81,14 +59,6 @@ const EVENT_HEADERS: Record<string, string> = {
81
59
  "X-Accel-Buffering": "no",
82
60
  };
83
61
 
84
- function jsonResponse(status: number, value: unknown): ServeResponse {
85
- return { status, headers: { ...JSON_HEADERS }, body: JSON.stringify(value) };
86
- }
87
-
88
- function errorResponse(status: number, message: string): ServeResponse {
89
- return jsonResponse(status, { error: message });
90
- }
91
-
92
62
  /**
93
63
  * Parse and validate an `{ annotations }` request body. Returns the annotations,
94
64
  * or an error response to send instead.
@@ -98,10 +68,10 @@ function parseAnnotations(body: string): PlanAnnotation[] | ServeResponse {
98
68
  try {
99
69
  parsed = JSON.parse(body);
100
70
  } catch {
101
- return errorResponse(400, "The request body must be JSON.");
71
+ return jsonError(400, "The request body must be JSON.");
102
72
  }
103
73
  const annotations = coerceAnnotations((parsed as { annotations?: unknown } | null)?.annotations);
104
- if (!annotations) return errorResponse(400, "The annotations are malformed.");
74
+ if (!annotations) return jsonError(400, "The annotations are malformed.");
105
75
  return annotations;
106
76
  }
107
77
 
@@ -118,15 +88,14 @@ function checkEnvelope(
118
88
  const upper = method.toUpperCase();
119
89
  if (upper !== "GET" && upper !== "HEAD" && upper !== "POST") {
120
90
  return {
121
- ...errorResponse(405, "Only GET, HEAD, and POST are served."),
122
- headers: { ...JSON_HEADERS, Allow: "GET, HEAD, POST" },
91
+ ...jsonError(405, "Only GET, HEAD, and POST are served."),
92
+ headers: { ...jsonHeaders(), Allow: "GET, HEAD, POST" },
123
93
  };
124
94
  }
125
95
  // Fail closed: a missing or non-string Host is rejected too, not just a
126
96
  // foreign one, so the DNS-rebinding guard cannot be skipped.
127
- const host = headers.host;
128
- if (typeof host !== "string" || !HOST_PATTERN.test(host)) {
129
- return errorResponse(403, "Unexpected Host header.");
97
+ if (!isLocalHostHeader(headers, { required: true })) {
98
+ return jsonError(403, "Unexpected Host header.");
130
99
  }
131
100
  return undefined;
132
101
  }
@@ -145,52 +114,46 @@ export async function handleReviewRequest(
145
114
  const envelope = checkEnvelope(method, headers);
146
115
  if (envelope) return envelope;
147
116
 
148
- let pathname: string;
149
- let search: URLSearchParams;
150
- try {
151
- const url = new URL(rawUrl, "http://127.0.0.1");
152
- pathname = url.pathname;
153
- search = url.searchParams;
154
- } catch {
155
- return errorResponse(400, "Malformed request URL.");
156
- }
117
+ const parsed = parseRequestUrl(rawUrl);
118
+ if (!parsed) return jsonError(400, "Malformed request URL.");
119
+ const { pathname, search } = parsed;
157
120
 
158
121
  if (pathname === "/favicon.ico") return { status: 204, headers: { "Cache-Control": "no-store" } };
159
- if (pathname === "/theme.js") return { status: 200, headers: { ...SCRIPT_HEADERS }, body: THEME_JS };
160
- if (pathname === "/client.js") return { status: 200, headers: { ...SCRIPT_HEADERS }, body: REVIEW_CLIENT_JS };
122
+ if (pathname === "/theme.js") return { status: 200, headers: scriptHeaders(), body: THEME_JS };
123
+ if (pathname === "/client.js") return { status: 200, headers: scriptHeaders(), body: REVIEW_CLIENT_JS };
161
124
 
162
- if (search.get("token") !== ctx.token) return errorResponse(403, "Invalid or missing token.");
125
+ if (search.get("token") !== ctx.token) return jsonError(403, "Invalid or missing token.");
163
126
 
164
127
  if (pathname === "/" || pathname === "/index.html") {
165
- return { status: 200, headers: { ...HTML_SECURITY }, body: renderReviewPage(await ctx.page()) };
128
+ return { status: 200, headers: pageHeaders(), body: renderReviewPage(await ctx.page()) };
166
129
  }
167
130
  if (pathname === "/events") return { status: 200, headers: { ...EVENT_HEADERS }, stream: true };
168
131
  if (pathname === "/api/cancel") {
169
- if (method.toUpperCase() !== "POST") return errorResponse(405, "Use POST to cancel.");
132
+ if (method.toUpperCase() !== "POST") return jsonError(405, "Use POST to cancel.");
170
133
  ctx.onCancel();
171
134
  return jsonResponse(200, { ok: true });
172
135
  }
173
136
  if (pathname === "/api/feedback") {
174
- if (method.toUpperCase() !== "POST") return errorResponse(405, "Use POST to save feedback.");
137
+ if (method.toUpperCase() !== "POST") return jsonError(405, "Use POST to save feedback.");
175
138
  const annotations = parseAnnotations(body);
176
139
  if (isResponse(annotations)) return annotations;
177
140
  const result = await ctx.save(annotations);
178
- if (!result.ok) return errorResponse(result.status, result.message);
141
+ if (!result.ok) return jsonError(result.status, result.message);
179
142
  ctx.onSaved(annotations);
180
143
  return jsonResponse(200, { ok: true });
181
144
  }
182
145
  if (pathname === "/api/approve") {
183
- if (method.toUpperCase() !== "POST") return errorResponse(405, "Use POST to approve.");
146
+ if (method.toUpperCase() !== "POST") return jsonError(405, "Use POST to approve.");
184
147
  // Only the exit_plan_mode flow owns the approval side effects, so a comment-only
185
148
  // review refuses this even if a client posts it directly.
186
- if (!ctx.canApprove) return errorResponse(403, "This review cannot be approved from the browser.");
149
+ if (!ctx.canApprove) return jsonError(403, "This review cannot be approved from the browser.");
187
150
  const annotations = parseAnnotations(body);
188
151
  if (isResponse(annotations)) return annotations;
189
152
  const result = await ctx.save(annotations);
190
- if (!result.ok) return errorResponse(result.status, result.message);
153
+ if (!result.ok) return jsonError(result.status, result.message);
191
154
  ctx.onApprove(annotations);
192
155
  return jsonResponse(200, { ok: true });
193
156
  }
194
157
 
195
- return errorResponse(404, "That page does not exist.");
158
+ return jsonError(404, "That page does not exist.");
196
159
  }
@@ -11,9 +11,10 @@
11
11
  */
12
12
 
13
13
  import { randomBytes } from "node:crypto";
14
- import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
14
+ import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
15
15
  import { basename } from "node:path";
16
16
  import { openInBrowser } from "../../lib/browser.ts";
17
+ import { bindLoopback, createCloser, jsonHeaders, respond, type ServeResponse } from "../../lib/http/index.ts";
17
18
  import { fenceLanguage, highlightLines } from "../../lib/highlight.ts";
18
19
  import { codeBody, fenceInfo, planBlocks } from "./blocks.ts";
19
20
  import {
@@ -27,13 +28,7 @@ import {
27
28
  import { planTitle, type PlanStore, type StoredPlan } from "./plans.ts";
28
29
  import { changedSections, type SectionDigest } from "./plan-text.ts";
29
30
  import { extractPlanSections, extractPlanSteps } from "./steps.ts";
30
- import {
31
- handleReviewRequest,
32
- JSON_HEADERS,
33
- type ReviewContext,
34
- type ServeResponse,
35
- type SaveResult,
36
- } from "./web-router.ts";
31
+ import { handleReviewRequest, type ReviewContext, type SaveResult } from "./web-router.ts";
37
32
  import type { ReviewPageData } from "./web-page.ts";
38
33
 
39
34
  /** How a browser review ended. */
@@ -123,19 +118,6 @@ function readBody(request: IncomingMessage, limit: number): Promise<BodyResult>
123
118
  });
124
119
  }
125
120
 
126
- async function listen(server: Server, port: number): Promise<number> {
127
- await new Promise<void>((resolve, reject) => {
128
- const onError = (error: Error) => reject(error);
129
- server.once("error", onError);
130
- server.listen(port, "127.0.0.1", () => {
131
- server.off("error", onError);
132
- resolve();
133
- });
134
- });
135
- const address = server.address();
136
- return typeof address === "object" && address ? address.port : port;
137
- }
138
-
139
121
  export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps = {}): PlanWebReview {
140
122
  const sessions = new Set<PlanReviewSession>();
141
123
  // Last bound port per plan path, so the same plan keeps one origin across
@@ -284,7 +266,7 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
284
266
  const read =
285
267
  method.toUpperCase() === "POST" ? await readBody(request, maxBody) : { ok: true as const, body: "" };
286
268
  if (!read.ok) {
287
- response.writeHead(413, { ...JSON_HEADERS, Connection: "close" });
269
+ response.writeHead(413, { ...jsonHeaders(), Connection: "close" });
288
270
  response.end(JSON.stringify({ error: "The request body is too large." }));
289
271
  return;
290
272
  }
@@ -295,7 +277,7 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
295
277
  } catch {
296
278
  result = {
297
279
  status: 500,
298
- headers: { ...JSON_HEADERS },
280
+ headers: jsonHeaders(),
299
281
  body: JSON.stringify({ error: "Internal error" }),
300
282
  };
301
283
  }
@@ -314,11 +296,6 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
314
296
  });
315
297
  return;
316
298
  }
317
- response.writeHead(result.status, result.headers);
318
- if (method.toUpperCase() === "HEAD") {
319
- response.end();
320
- return;
321
- }
322
299
  // A save or cancel only resolves the session after its response is on the
323
300
  // wire; otherwise `close()` would force-close the socket mid-reply. The
324
301
  // `close` path covers a client that walks away before the flush callback
@@ -329,25 +306,23 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
329
306
  pendingResult = undefined;
330
307
  finish(terminal);
331
308
  };
332
- response.end(result.body ?? "", settlePending);
309
+ respond(request, response, result, settlePending);
333
310
  response.on("close", settlePending);
334
311
  })();
335
312
  });
336
313
 
337
- // An accept-level error (EMFILE and friends) is emitted on the server, and an
338
- // `error` event with no listener would take the host down mid-review. The
339
- // review still ends through its own paths: cancel, abort, drop, or shutdown.
340
- server.on("error", () => {});
341
-
314
+ // The accept-level `error` listener lives in `bindLoopback`, so a transient
315
+ // failure cannot take the host down; the review still ends through its own
316
+ // paths: cancel, abort, drop, or shutdown.
342
317
  const preferred = deps.port ?? ports.get(plan.path) ?? 0;
343
318
  let port: number | undefined;
344
319
  try {
345
- port = await listen(server, preferred);
320
+ port = await bindLoopback(server, preferred);
346
321
  } catch {
347
322
  // The remembered port may have been taken between turns; fall back once.
348
323
  if (preferred !== 0) {
349
324
  try {
350
- port = await listen(server, 0);
325
+ port = await bindLoopback(server, 0);
351
326
  } catch {
352
327
  port = undefined;
353
328
  }
@@ -358,6 +333,7 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
358
333
  return undefined;
359
334
  }
360
335
  ports.set(plan.path, port);
336
+ const closeServer = createCloser(server);
361
337
 
362
338
  const url = `http://127.0.0.1:${port}/?token=${token}`;
363
339
  let closed = false;
@@ -370,10 +346,7 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
370
346
  for (const client of clients) client.end();
371
347
  clients.clear();
372
348
  finish({ status: "cancelled" });
373
- await new Promise<void>((resolve) => {
374
- server.close(() => resolve());
375
- (server as { closeAllConnections?: () => void }).closeAllConnections?.();
376
- });
349
+ await closeServer();
377
350
  };
378
351
 
379
352
  const session: PlanReviewSession = { url, port, done, close };
@@ -2,7 +2,7 @@
2
2
  * Answer normalization and model-facing formatting (pure).
3
3
  */
4
4
 
5
- import { sanitizeLine } from "../../lib/format.ts";
5
+ import { lineOf, sanitizeLine } from "../../lib/format.ts";
6
6
  import type { Answer, AskResult, Question, Selection } from "./types.ts";
7
7
 
8
8
  export const CANCELLED_TEXT = "User cancelled the question.";
@@ -93,20 +93,15 @@ export interface CallQuestion {
93
93
  options?: unknown;
94
94
  }
95
95
 
96
- /** A partial value read as a single line; anything but a string reads empty. */
97
- function asLine(value: unknown): string {
98
- return typeof value === "string" ? sanitizeLine(value) : "";
99
- }
100
-
101
96
  /** Display headers with positional defaults (`Q1`, `Q2`, …). */
102
97
  export function questionHeaders(questions: CallQuestion[]): string[] {
103
- return questions.map((question, i) => asLine(question?.header) || `Q${i + 1}`);
98
+ return questions.map((question, i) => lineOf(question?.header) || `Q${i + 1}`);
104
99
  }
105
100
 
106
101
  /** One `Header: a, b, Other` summary per question that offers options. */
107
102
  export function optionSummary(questions: CallQuestion[]): string[] {
108
103
  return questions
109
- .map((question, i) => ({ header: asLine(question?.header) || `Q${i + 1}`, options: question?.options }))
104
+ .map((question, i) => ({ header: lineOf(question?.header) || `Q${i + 1}`, options: question?.options }))
110
105
  .filter(({ options }) => Array.isArray(options) && options.length > 0)
111
106
  .map(({ header, options }) => {
112
107
  // A partial option has no label yet; list the ones that have arrived.
@@ -18,7 +18,7 @@ import {
18
18
  import { sanitizeLine } from "../../lib/format.ts";
19
19
  import { flowCells } from "../../lib/tui/columns.ts";
20
20
  import { clampScroll, formatRange, keepVisible, selectionMarker, wheelDelta } from "../../lib/tui/cursor.ts";
21
- import { editorTheme, screenHint } from "../../lib/tui/chrome.ts";
21
+ import { editorTheme, screenHint, type ScreenHint } from "../../lib/tui/chrome.ts";
22
22
  import { GLYPHS } from "../../lib/tui/glyphs.ts";
23
23
  import { checkbox } from "../../lib/tui/rows.ts";
24
24
  import { plainRule, titleRule } from "../../lib/tui/rules.ts";
@@ -255,7 +255,7 @@ export async function askViaTui(
255
255
  }
256
256
  lines.push("");
257
257
  if (state.message) addWithPrefix(" ", paint(theme, "warn", state.message));
258
- lines.push(screenHint(theme, renderWidth, ["Enter to submit", "Esc to go back"]));
258
+ lines.push(screenHint(theme, renderWidth, [{ text: "Enter to submit", color: "accent" }, "Esc to go back"]));
259
259
  } else if (submitTab) {
260
260
  addWithPrefix(" ", paint(theme, "accent", theme.bold("Ready to submit")));
261
261
  lines.push("");
@@ -285,12 +285,12 @@ export async function askViaTui(
285
285
  // option list can be given exactly the rows that are left.
286
286
  const tail: string[] = [""];
287
287
  if (state.message) tail.push(...prefixWrapped(" ", paint(theme, "warn", state.message)));
288
- const hints =
288
+ const hints: ScreenHint[] =
289
289
  optionCount(question) === 0
290
- ? ["Enter to type an answer", "Esc cancel"]
290
+ ? [{ text: "Enter to type an answer", color: "accent" }, "Esc cancel"]
291
291
  : question.multiSelect
292
- ? ["↑/↓ move", "Space toggle", "Enter confirm", "Esc cancel"]
293
- : ["↑/↓ choose", "Enter select", "Esc cancel"];
292
+ ? ["↑/↓ move", "Space toggle", { text: "Enter confirm", color: "accent" }, "Esc cancel"]
293
+ : ["↑/↓ choose", { text: "Enter select", color: "accent" }, "Esc cancel"];
294
294
  if (isMulti) hints.unshift(`Tab/${GLYPHS.prev}${GLYPHS.next} navigate`);
295
295
  tail.push(screenHint(theme, renderWidth, hints));
296
296
  if (question.multiSelect) {