@gavin-hu/my-pi-agent 0.11.4 → 0.13.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 (197) hide show
  1. package/README.md +38 -26
  2. package/README.zh-CN.md +33 -23
  3. package/extensions/bytefly/README.md +381 -225
  4. package/extensions/bytefly/chrome.ts +7 -2
  5. package/extensions/bytefly/doctor/register.ts +6 -0
  6. package/extensions/bytefly/doctor/runtime.ts +52 -6
  7. package/extensions/bytefly/doctor/tools.ts +1 -3
  8. package/extensions/bytefly/footer/collect/snapshot.ts +12 -2
  9. package/extensions/bytefly/footer/config.ts +7 -1
  10. package/extensions/bytefly/footer/delegation.ts +100 -0
  11. package/extensions/bytefly/footer/price/config.ts +54 -15
  12. package/extensions/bytefly/footer/price/price.ts +20 -7
  13. package/extensions/bytefly/footer/price/types.ts +13 -7
  14. package/extensions/bytefly/footer/register.ts +13 -2
  15. package/extensions/bytefly/footer/render/format.ts +3 -2
  16. package/extensions/bytefly/footer/render/lines.ts +2 -0
  17. package/extensions/bytefly/footer/render/segments.ts +30 -2
  18. package/extensions/bytefly/footer/types.ts +12 -0
  19. package/extensions/bytefly/header/component.ts +21 -6
  20. package/extensions/bytefly/header/config.ts +3 -0
  21. package/extensions/bytefly/header/register.ts +34 -3
  22. package/extensions/bytefly/index.ts +15 -4
  23. package/extensions/bytefly/notify/config.ts +2 -0
  24. package/extensions/bytefly/notify/format.ts +19 -1
  25. package/extensions/bytefly/notify/register.ts +37 -13
  26. package/extensions/bytefly/notify/types.ts +2 -0
  27. package/extensions/bytefly/panel/commands.ts +34 -13
  28. package/extensions/bytefly/panel/settings-runtime.ts +71 -28
  29. package/extensions/bytefly/panel/settings.ts +293 -48
  30. package/extensions/bytefly/panel/tui/entries.ts +58 -0
  31. package/extensions/bytefly/panel/tui/extensions-view.ts +86 -0
  32. package/extensions/bytefly/panel/tui/frame.ts +71 -0
  33. package/extensions/bytefly/panel/tui/nav.ts +75 -0
  34. package/extensions/bytefly/panel/tui/settings-view.ts +115 -0
  35. package/extensions/bytefly/panel/tui.ts +205 -319
  36. package/extensions/bytefly/picker/runtime.ts +61 -2
  37. package/extensions/bytefly/picker/toggle.ts +56 -0
  38. package/extensions/bytefly/update/cache.ts +73 -0
  39. package/extensions/bytefly/update/check.ts +59 -0
  40. package/extensions/bytefly/update/format.ts +87 -0
  41. package/extensions/bytefly/update/register.ts +158 -0
  42. package/extensions/bytefly/update/state.ts +40 -0
  43. package/extensions/bytefly/update/version.ts +83 -0
  44. package/extensions/document/README.md +24 -14
  45. package/extensions/document/config.ts +4 -0
  46. package/extensions/document/deadline.ts +61 -0
  47. package/extensions/document/tool.ts +14 -5
  48. package/extensions/file-browse/README.md +12 -8
  49. package/extensions/file-browse/client.ts +6 -7
  50. package/extensions/file-browse/files.ts +0 -4
  51. package/extensions/file-browse/git.ts +9 -10
  52. package/extensions/file-browse/html.ts +17 -7
  53. package/extensions/file-browse/router.ts +24 -15
  54. package/extensions/file-open/README.md +30 -15
  55. package/extensions/file-open/index.ts +4 -3
  56. package/extensions/file-open/open.ts +52 -21
  57. package/extensions/goal/README.md +45 -13
  58. package/extensions/goal/clarify-prompt.ts +5 -36
  59. package/extensions/goal/clarify.ts +55 -86
  60. package/extensions/goal/commands.ts +41 -35
  61. package/extensions/goal/format.ts +12 -4
  62. package/extensions/goal/schema.ts +6 -2
  63. package/extensions/goal/tools.ts +28 -8
  64. package/extensions/goal/tui.ts +1 -1
  65. package/extensions/goal/types.ts +6 -2
  66. package/extensions/job/README.md +16 -12
  67. package/extensions/job/runtime.ts +23 -6
  68. package/extensions/job/status.ts +4 -2
  69. package/extensions/memory/README.md +56 -14
  70. package/extensions/memory/commands.ts +20 -2
  71. package/extensions/memory/doctor.ts +137 -0
  72. package/extensions/memory/format.ts +24 -7
  73. package/extensions/memory/runtime.ts +49 -0
  74. package/extensions/memory/store.ts +31 -0
  75. package/extensions/memory/tui.ts +136 -12
  76. package/extensions/plan/README.md +83 -36
  77. package/extensions/plan/blocks.ts +1 -35
  78. package/extensions/plan/commands.ts +84 -19
  79. package/extensions/plan/index.ts +35 -11
  80. package/extensions/plan/plan-index.ts +234 -0
  81. package/extensions/plan/plan-text.ts +95 -0
  82. package/extensions/plan/plans.ts +91 -2
  83. package/extensions/plan/policy.ts +4 -3
  84. package/extensions/plan/review.ts +246 -0
  85. package/extensions/plan/runtime.ts +73 -24
  86. package/extensions/plan/steps.ts +60 -39
  87. package/extensions/plan/todos.ts +29 -60
  88. package/extensions/plan/tools.ts +188 -244
  89. package/extensions/plan/tui.ts +18 -31
  90. package/extensions/plan/types.ts +12 -0
  91. package/extensions/plan/web-client.ts +51 -18
  92. package/extensions/plan/web-page.ts +7 -2
  93. package/extensions/plan/web-router.ts +11 -3
  94. package/extensions/plan/web.ts +106 -41
  95. package/extensions/question/README.md +1 -1
  96. package/extensions/question/tools.ts +1 -1
  97. package/extensions/rewind/README.md +28 -8
  98. package/extensions/rewind/commands.ts +1 -1
  99. package/extensions/rewind/format.ts +4 -4
  100. package/extensions/rewind/git.ts +56 -18
  101. package/extensions/rewind/index.ts +3 -5
  102. package/extensions/rewind/path-picker.ts +198 -0
  103. package/extensions/rewind/paths.ts +128 -0
  104. package/extensions/rewind/restore.ts +69 -28
  105. package/extensions/rewind/rewind.ts +80 -3
  106. package/extensions/rewind/runtime.ts +13 -5
  107. package/extensions/rewind/snapshot-run.ts +5 -2
  108. package/extensions/rewind/types.ts +2 -0
  109. package/extensions/subagent/README.md +7 -1
  110. package/extensions/subagent/accounting.ts +34 -0
  111. package/extensions/subagent/index.ts +23 -3
  112. package/extensions/subagent/stream.ts +2 -0
  113. package/extensions/subagent/types.ts +2 -0
  114. package/extensions/todo/README.md +19 -11
  115. package/extensions/todo/index.ts +5 -3
  116. package/extensions/todo/nudge.ts +58 -9
  117. package/extensions/todo/runtime.ts +28 -17
  118. package/extensions/watch/README.md +8 -3
  119. package/extensions/watch/index.ts +19 -0
  120. package/extensions/web-access/README.md +46 -16
  121. package/extensions/web-access/charset.ts +58 -0
  122. package/extensions/web-access/fetch/find.ts +11 -7
  123. package/extensions/web-access/fetch/format.ts +64 -18
  124. package/extensions/web-access/fetch/page.ts +5 -1
  125. package/extensions/web-access/fetch/tool.ts +20 -21
  126. package/extensions/web-access/http.ts +49 -8
  127. package/extensions/web-access/search/facts.ts +38 -0
  128. package/extensions/web-access/search/tool.ts +14 -3
  129. package/extensions/web-access/text.ts +50 -11
  130. package/extensions/wechat/README.md +5 -4
  131. package/extensions/wechat/tools.ts +0 -2
  132. package/extensions/worktree/README.md +74 -15
  133. package/extensions/worktree/config.ts +11 -0
  134. package/extensions/worktree/exit.ts +35 -3
  135. package/extensions/worktree/gaps.ts +74 -0
  136. package/extensions/worktree/guard.ts +50 -4
  137. package/extensions/worktree/handoff.ts +55 -0
  138. package/extensions/worktree/index.ts +12 -2
  139. package/extensions/worktree/runtime.ts +20 -0
  140. package/extensions/worktree/status.ts +15 -2
  141. package/extensions/worktree/tools.ts +3 -2
  142. package/lib/README.md +12 -9
  143. package/lib/config.ts +43 -1
  144. package/lib/env.ts +12 -0
  145. package/lib/events.ts +86 -1
  146. package/lib/goal-state.ts +95 -0
  147. package/lib/requirements.ts +340 -46
  148. package/lib/todo-state.ts +62 -0
  149. package/lib/tui/README.md +91 -0
  150. package/lib/tui/glyphs.ts +2 -0
  151. package/lib/web/layout.ts +12 -2
  152. package/package.json +5 -3
  153. package/themes/bytefly-dark.json +1 -1
  154. package/assets/bytefly.schema.json +0 -785
  155. package/assets/chrome.png +0 -0
  156. package/assets/file-browse.png +0 -0
  157. package/assets/job.png +0 -0
  158. package/assets/plan-review.png +0 -0
  159. package/assets/question.png +0 -0
  160. package/assets/rewind.png +0 -0
  161. package/assets/todo.png +0 -0
  162. package/assets/tool-renderings.png +0 -0
  163. package/assets/tools/ask_user_question-cancelled.png +0 -0
  164. package/assets/tools/ask_user_question.png +0 -0
  165. package/assets/tools/bash.png +0 -0
  166. package/assets/tools/edit.png +0 -0
  167. package/assets/tools/enter_plan_mode.png +0 -0
  168. package/assets/tools/enter_worktree.png +0 -0
  169. package/assets/tools/exit_plan_mode.png +0 -0
  170. package/assets/tools/exit_worktree.png +0 -0
  171. package/assets/tools/find.png +0 -0
  172. package/assets/tools/goal.png +0 -0
  173. package/assets/tools/grep.png +0 -0
  174. package/assets/tools/job.png +0 -0
  175. package/assets/tools/list_worktrees.png +0 -0
  176. package/assets/tools/ls.png +0 -0
  177. package/assets/tools/memory-long-subject.png +0 -0
  178. package/assets/tools/memory.png +0 -0
  179. package/assets/tools/notice.png +0 -0
  180. package/assets/tools/open_path.png +0 -0
  181. package/assets/tools/prune_worktrees.png +0 -0
  182. package/assets/tools/read.png +0 -0
  183. package/assets/tools/read_doc-running.png +0 -0
  184. package/assets/tools/read_doc.png +0 -0
  185. package/assets/tools/run_doctor.png +0 -0
  186. package/assets/tools/send_wechat.png +0 -0
  187. package/assets/tools/set_plan_steps.png +0 -0
  188. package/assets/tools/subagent.png +0 -0
  189. package/assets/tools/todo.png +0 -0
  190. package/assets/tools/turn-rule.png +0 -0
  191. package/assets/tools/user-message.png +0 -0
  192. package/assets/tools/watch.png +0 -0
  193. package/assets/tools/web_fetch-rejected.png +0 -0
  194. package/assets/tools/web_fetch.png +0 -0
  195. package/assets/tools/web_search.png +0 -0
  196. package/assets/tools/write.png +0 -0
  197. package/assets/tools/write_plan.png +0 -0
@@ -1,10 +1,6 @@
1
1
  /**
2
2
  * Terminal rendering for the plan review screen.
3
3
  *
4
- * `sanitizePlanText` is exported so the transcript renderer (`tools.ts`) can
5
- * strip control characters from plan text with the same rules the review screen
6
- * uses.
7
- *
8
4
  * `PlanViewComponent` is the scrollable review screen `exit_plan_mode` opens
9
5
  * for approval. The plan body is rendered by Pi's own `Markdown` component with
10
6
  * the active Markdown theme, so headings, lists, code, links, and quotes match
@@ -30,7 +26,7 @@ import {
30
26
  type TuiMouseEvent,
31
27
  type TuiMouseEventResult,
32
28
  } from "@earendil-works/pi-tui";
33
- import { stripControlChars } from "../../lib/format.ts";
29
+ import { sanitizeLine } from "../../lib/format.ts";
34
30
  import {
35
31
  editorTheme,
36
32
  screenHint,
@@ -45,8 +41,8 @@ import { titleRule } from "../../lib/tui/rules.ts";
45
41
  import { emptyState, checkbox } from "../../lib/tui/rows.ts";
46
42
  import { paint } from "../../lib/tui/tokens.ts";
47
43
  import { planTitle, type StoredPlan } from "./plans.ts";
48
- import { changedSections, extractPlanSections } from "./steps.ts";
49
- import { stripFeedback } from "./feedback.ts";
44
+ import { changedSections, sanitizePlanText, type SectionDigest } from "./plan-text.ts";
45
+ import { extractPlanSections } from "./steps.ts";
50
46
 
51
47
  /** What the user chose from the read/review screen. */
52
48
  export type PlanViewAction = "approve" | "refine" | "keep" | "browser";
@@ -71,10 +67,10 @@ interface BodyCache {
71
67
  export interface PlanViewOptions {
72
68
  plan: StoredPlan;
73
69
  /**
74
- * The content the previous review of this plan showed, when there was one.
75
- * Used only to mark the sections that changed since then.
70
+ * The section digests the previous review of this plan showed, when there was
71
+ * one. Used only to mark the sections that changed since then.
76
72
  */
77
- previous?: string;
73
+ baseline?: SectionDigest[];
78
74
  theme: Theme;
79
75
  /**
80
76
  * Called once when the screen closes. `refinement` carries the typed text
@@ -110,17 +106,6 @@ const PRIMARY_HINTS: ScreenHint[] = [
110
106
  const SCROLL_HINTS: ScreenHint[] = ["↑/↓ or k/j scroll", "space/b page", "g/G ends"];
111
107
  /** Section-jump hint, shown when the plan has more than one heading section. */
112
108
  const SECTION_HINTS: ScreenHint[] = ["n/p section"];
113
- /** Spaces a tab expands to; the terminal's own tab stops are not width-modelled. */
114
- const TAB_WIDTH = 4;
115
-
116
- /**
117
- * Replace control characters (including ESC and the C1 block) and expand tabs
118
- * so model-authored plan text cannot drive the terminal. Newlines are kept, so
119
- * callers can still render or split the text line by line.
120
- */
121
- export function sanitizePlanText(text: string): string {
122
- return stripControlChars(text.replace(/\t/g, " ".repeat(TAB_WIDTH)));
123
- }
124
109
 
125
110
  /** A task checkbox in a list marker: `- [x] `, `1. [ ] `. */
126
111
  const TASK_MARKER = /^([-*+]|\d+[.)])(\s+)(\[([ xX])\])(\s*)$/;
@@ -128,8 +113,11 @@ const TASK_MARKER = /^([-*+]|\d+[.)])(\s+)(\[([ xX])\])(\s*)$/;
128
113
  /**
129
114
  * Wrap the Markdown theme so task checkboxes read at a glance with the shared
130
115
  * `◼`/`◻` vocabulary, the same box the bytefly screen and the question dialog
131
- * draw. The replacement is one column wide instead of the bracket's three, so a
132
- * wrapped list continuation shifts left by two.
116
+ * draw. The box is one column wide instead of the bracket's three, so the two
117
+ * freed columns are kept as padding: pi-tui derives a continuation line's indent
118
+ * from the original marker's width, and shortening the marker without padding
119
+ * hangs every wrapped task line two columns too far right, where the width
120
+ * truncation clips its tail.
133
121
  */
134
122
  function planMarkdownTheme(theme: Theme, base: MarkdownTheme): MarkdownTheme {
135
123
  return {
@@ -138,7 +126,9 @@ function planMarkdownTheme(theme: Theme, base: MarkdownTheme): MarkdownTheme {
138
126
  const match = TASK_MARKER.exec(text);
139
127
  if (!match) return base.listBullet(text);
140
128
  const [, bullet, gap, , mark, trail] = match;
141
- return `${base.listBullet(`${bullet}${gap}`)}${checkbox(theme, mark.toLowerCase() === "x")}${trail}`;
129
+ const themed = `${base.listBullet(`${bullet}${gap}`)}${checkbox(theme, mark.toLowerCase() === "x")}`;
130
+ const pad = Math.max(0, visibleWidth(text) - visibleWidth(themed) - visibleWidth(trail));
131
+ return `${themed}${" ".repeat(pad)}${trail}`;
142
132
  },
143
133
  };
144
134
  }
@@ -205,10 +195,7 @@ export class PlanViewComponent implements Component {
205
195
  const base = this.options.markdownTheme ?? getMarkdownTheme();
206
196
  const theme = planMarkdownTheme(this.theme, base);
207
197
  const current = sanitizePlanText(this.options.plan.content);
208
- const changed =
209
- this.options.previous === undefined
210
- ? new Set<string>()
211
- : changedSections(stripFeedback(this.options.previous), stripFeedback(current));
198
+ const changed = changedSections(this.options.baseline, current);
212
199
  const chunks = extractPlanSections(current);
213
200
  const lines: string[] = [];
214
201
  const sections: SectionOffset[] = [];
@@ -221,14 +208,14 @@ export class PlanViewComponent implements Component {
221
208
  sections.push({
222
209
  title: chunk.title,
223
210
  offset: lines.length,
224
- ...(changed.has(chunk.title) ? { changed: true } : {}),
211
+ ...(changed[index] ? { changed: true } : {}),
225
212
  });
226
213
  lines.push(...rendered);
227
214
  // Sections render independently, so restore the blank line a heading-only
228
215
  // chunk omits before the next section.
229
216
  if (index < chunks.length - 1 && lines.length > 0 && lines[lines.length - 1] !== "") lines.push("");
230
217
  });
231
- this.cache = { width, lines, sections, changedCount: chunks.filter((chunk) => changed.has(chunk.title)).length };
218
+ this.cache = { width, lines, sections, changedCount: changed.filter(Boolean).length };
232
219
  return this.cache;
233
220
  }
234
221
 
@@ -363,7 +350,7 @@ export class PlanViewComponent implements Component {
363
350
  const theme = this.theme;
364
351
  const w = Math.max(1, width);
365
352
  const plan = this.options.plan;
366
- const title = planTitle(basename(plan.path));
353
+ const title = sanitizeLine(planTitle(basename(plan.path)));
367
354
 
368
355
  const previousWidth = this.cache?.width;
369
356
  const built = plan.content.trim() ? this.build(w) : { width: w, lines: [], sections: [], changedCount: 0 };
@@ -2,6 +2,8 @@
2
2
  * Shared types for the plan-mode extension.
3
3
  */
4
4
 
5
+ import type { PlanAnnotation } from "./feedback.ts";
6
+ import type { SectionDigest } from "./plan-text.ts";
5
7
  import type { StepUpdate } from "./steps.ts";
6
8
 
7
9
  /** Persisted (custom entry) plan-mode state. */
@@ -14,6 +16,14 @@ export interface PlanModeEntry {
14
16
  removed?: string[];
15
17
  }
16
18
 
19
+ /** Persisted (custom entry) review baseline for one plan. */
20
+ export interface ReviewBaselineEntry {
21
+ /** Resolved path of the plan file the review showed. */
22
+ path: string;
23
+ /** One digest per heading section, in document order. */
24
+ sections: SectionDigest[];
25
+ }
26
+
17
27
  /** Structured result carried in `enter_plan_mode`'s `details`. */
18
28
  export interface EnterPlanModeDetails {
19
29
  /** Whether the user agreed to enter plan mode. */
@@ -62,6 +72,8 @@ export interface ExitPlanModeDetails {
62
72
  refined?: boolean;
63
73
  /** The refinement the user typed, when `refined` is true. */
64
74
  refinement?: string;
75
+ /** Comments the review carried when it approved or requested a refinement. */
76
+ annotations?: PlanAnnotation[];
65
77
  /** True when no interactive UI was available to ask for approval. */
66
78
  unavailable?: boolean;
67
79
  }
@@ -17,12 +17,15 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
17
17
  "use strict";
18
18
 
19
19
  var dataNode = document.getElementById("review-data");
20
- var fallback = { blocks: [], annotations: [], outline: [], steps: { done: 0, total: 0 }, code: {}, token: "" };
20
+ // The caps come from the island, which takes them from the server's own
21
+ // validation; the fallback only guards a page whose island failed to parse.
22
+ var fallback = { blocks: [], annotations: [], outline: [], steps: { done: 0, total: 0 }, code: {}, limits: { comments: 200, comment: 2000, quote: 500 }, token: "" };
21
23
  var data = fallback;
22
24
  if (dataNode && dataNode.textContent) {
23
25
  try { data = JSON.parse(dataNode.textContent); } catch (error) { data = fallback; }
24
26
  }
25
27
  var token = typeof data.token === "string" ? data.token : "";
28
+ var limits = data.limits || fallback.limits;
26
29
  var annotations = Array.isArray(data.annotations) ? data.annotations.slice() : [];
27
30
  var saved = false;
28
31
  var approved = false;
@@ -34,6 +37,7 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
34
37
  var pendingQuote = null;
35
38
  var activeIndex = -1;
36
39
  var outlineItems = {};
40
+ var stream = null;
37
41
 
38
42
  var planEl = document.getElementById("plan");
39
43
  var statusEl = document.getElementById("status");
@@ -94,12 +98,20 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
94
98
  stateEl.className = "bf-chip bf-chip--accent review-state" + (tone ? " bf-chip--" + tone : "");
95
99
  }
96
100
 
97
- function safeHref(url) {
101
+ // A link is rendered only for an http(s) or mailto target. Resolving against
102
+ // the review origin is what catches a target that only looks relative: the
103
+ // URL parser strips a tab or newline, so "/\t/evil.com" is cross-origin once
104
+ // it is parsed, and a disguised target must stay text.
105
+ function safeHref(url, base) {
98
106
  var trimmed = String(url).trim();
99
- if (/^[\\/]{2}/.test(trimmed)) return null;
100
- if (/^(https?:|mailto:)/i.test(trimmed)) return trimmed;
101
- if (/^[a-z][a-z0-9+.-]*:/i.test(trimmed)) return null;
102
- return trimmed;
107
+ if (!trimmed || /^\/\//.test(trimmed)) return null;
108
+ var resolved;
109
+ try { resolved = new URL(trimmed, base); } catch (error) { return null; }
110
+ if (resolved.protocol === "mailto:") return trimmed;
111
+ if (resolved.protocol !== "http:" && resolved.protocol !== "https:") return null;
112
+ if (resolved.username || resolved.password) return null;
113
+ if (resolved.origin === new URL(base).origin) return resolved.href;
114
+ return /^https?:\/\//i.test(trimmed) ? resolved.href : null;
103
115
  }
104
116
 
105
117
  // Inline token order matters: code, emphasis, and links are matched before a
@@ -120,7 +132,7 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
120
132
  var em = el("em"); em.textContent = match[1]; container.appendChild(em); return;
121
133
  }
122
134
  if ((match = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(part))) {
123
- var href = safeHref(match[2]);
135
+ var href = safeHref(match[2], location.href);
124
136
  if (!href) { container.appendChild(document.createTextNode(part)); return; }
125
137
  var link = el("a"); link.textContent = match[1]; link.href = href; link.target = "_blank"; link.rel = "noreferrer noopener";
126
138
  container.appendChild(link); return;
@@ -133,7 +145,7 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
133
145
  }
134
146
  var dot = url.indexOf(".");
135
147
  var target = /^www\./i.test(url) ? "http://" + url : url;
136
- var safe = dot > 0 && /\.[^./]/.test(url.slice(dot)) ? safeHref(target) : null;
148
+ var safe = dot > 0 && /\.[^./]/.test(url.slice(dot)) ? safeHref(target, location.href) : null;
137
149
  if (safe) {
138
150
  var auto = el("a");
139
151
  auto.textContent = url;
@@ -311,7 +323,7 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
311
323
  if (!selection || selection.isCollapsed) return "";
312
324
  var range = selection.rangeCount ? selection.getRangeAt(0) : null;
313
325
  if (!range || !wrap.contains(range.commonAncestorContainer)) return "";
314
- return selection.toString().replace(/\s+/g, " ").trim().slice(0, 500);
326
+ return selection.toString().replace(/\s+/g, " ").trim().slice(0, limits.quote);
315
327
  }
316
328
 
317
329
  function commentsBoxAfter(wrap) {
@@ -325,7 +337,9 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
325
337
  // Enter submits a comment; Shift+Enter is left for the browser.
326
338
  function submitOnEnter(area, submit) {
327
339
  area.addEventListener("keydown", function (event) {
328
- if (event.key !== "Enter" || event.shiftKey) return;
340
+ // An IME uses Enter to commit a composition; submitting then would send a
341
+ // half-typed comment.
342
+ if (event.key !== "Enter" || event.shiftKey || event.isComposing) return;
329
343
  event.preventDefault();
330
344
  submit();
331
345
  });
@@ -343,6 +357,7 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
343
357
  var area = el("textarea", "bf-textarea");
344
358
  area.placeholder = "Leave feedback on this part of the plan (Enter to add)";
345
359
  area.setAttribute("aria-label", "Comment");
360
+ area.maxLength = limits.comment;
346
361
  composer.appendChild(area);
347
362
  var row = el("div", "bf-composer__row");
348
363
  var cancel = el("button"); cancel.type = "button"; cancel.className = "bf-btn"; cancel.textContent = "Cancel";
@@ -351,7 +366,7 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
351
366
  var submit = function () {
352
367
  var comment = area.value.replace(/\s+/g, " ").trim();
353
368
  if (!comment) { area.focus(); return; }
354
- if (annotations.length >= 200) { setStatus("Comment limit reached (200).", "error"); return; }
369
+ if (annotations.length >= limits.comments) { setStatus("Comment limit reached (" + limits.comments + ").", "error"); return; }
355
370
  annotations.push(quote ? { anchor: block.start, quote: quote, comment: comment } : { anchor: block.start, comment: comment });
356
371
  dirty = true;
357
372
  renderPlan();
@@ -369,6 +384,7 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
369
384
  var area = el("textarea", "bf-textarea");
370
385
  area.value = annotation.comment;
371
386
  area.setAttribute("aria-label", "Edit comment");
387
+ area.maxLength = limits.comment;
372
388
  card.appendChild(area);
373
389
  var row = el("div", "bf-comment__row");
374
390
  var cancel = el("button"); cancel.type = "button"; cancel.className = "bf-btn"; cancel.textContent = "Cancel";
@@ -585,6 +601,10 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
585
601
  planEl.appendChild(box);
586
602
  }
587
603
  planEl.scrollTop = scrollTop;
604
+ // The DOM was rebuilt, so the active block's class is gone; restore it in
605
+ // place rather than scrolling, so j/k resume where the reviewer was.
606
+ var active = activeIndex >= 0 ? blockElements()[activeIndex] : null;
607
+ if (active) active.classList.add("bf-block--active");
588
608
  renderFacts();
589
609
  observeSections();
590
610
  updateState();
@@ -633,6 +653,12 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
633
653
  controls.appendChild(reload);
634
654
  }
635
655
 
656
+ // The stream exists only to notice a closed tab. Once the review is settled it
657
+ // has no job, and leaving it open retries a dead port until the tab closes.
658
+ function settleStream() {
659
+ if (stream && stream.close) { stream.close(); stream = null; }
660
+ }
661
+
636
662
  // Save and approve share the same POST shape; only the route, the settled
637
663
  // flag, and the wording differ. Both persist the current annotations.
638
664
  function submit(url, okText, failText, mark) {
@@ -644,6 +670,7 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
644
670
  if (response.ok) {
645
671
  mark();
646
672
  dirty = false;
673
+ settleStream();
647
674
  setStatus(okText, "saved");
648
675
  lockActions();
649
676
  updateState();
@@ -656,7 +683,12 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
656
683
  offerReload();
657
684
  return;
658
685
  }
659
- throw new Error("server returned " + response.status);
686
+ // Surface the server's own reason (a malformed comment, a stale body)
687
+ // rather than a bare status code.
688
+ return response.json().then(
689
+ function (payload) { throw new Error(payload && payload.error ? payload.error : "server returned " + response.status); },
690
+ function () { throw new Error("server returned " + response.status); }
691
+ );
660
692
  }).catch(function (error) {
661
693
  setStatus(failText + error.message, "error");
662
694
  });
@@ -677,6 +709,7 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
677
709
  function cancel() {
678
710
  if (saved || approved || closed) return;
679
711
  closed = true;
712
+ settleStream();
680
713
  lockActions();
681
714
  setStatus("Closing\u2026");
682
715
  updateState();
@@ -742,18 +775,18 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
742
775
  if (event.key === "s") { event.preventDefault(); save(); return; }
743
776
  });
744
777
 
745
- window.addEventListener("beforeunload", function () {
746
- if (saved || approved || closed) return;
747
- var url = "/api/cancel?token=" + encodeURIComponent(token);
748
- if (navigator.sendBeacon) navigator.sendBeacon(url);
749
- });
778
+ // A closed tab is detected by the stream below: the server sees the last
779
+ // client go away and cancels after a grace period, and a reload reconnects
780
+ // inside it. A beacon here would cancel the review on every reload, including
781
+ // the reload a 409 offers.
750
782
 
751
783
  window.addEventListener("scroll", updateProgress);
752
784
  window.addEventListener("resize", updateProgress);
785
+ window.addEventListener("pagehide", settleStream);
753
786
  if (planEl) planEl.addEventListener("scroll", updateProgress);
754
787
 
755
788
  try {
756
- var stream = new EventSource("/events?token=" + encodeURIComponent(token));
789
+ stream = new EventSource("/events?token=" + encodeURIComponent(token));
757
790
  stream.addEventListener("open", function () { connected = true; everConnected = true; updateState(); });
758
791
  stream.addEventListener("error", function () {
759
792
  // The server ends the stream once the review is settled; keep showing the
@@ -16,6 +16,7 @@
16
16
  import {
17
17
  brandLockup,
18
18
  docHead,
19
+ SIDEBAR_COLLAPSE_MAX,
19
20
  themeToggle,
20
21
  WEB_BASE_CSS,
21
22
  WEB_COMPONENTS_CSS,
@@ -57,6 +58,11 @@ export interface ReviewPageData {
57
58
  steps: { done: number; total: number };
58
59
  /** Highlighted HTML per line, keyed by the code block's 0-based start line. */
59
60
  code: Record<string, string[]>;
61
+ /**
62
+ * The caps the server enforces, so the page does not retype them: comment
63
+ * count, comment length, and quote length, from `feedback.ts`.
64
+ */
65
+ limits: { comments: number; comment: number; quote: number };
60
66
  /** Whether the review may approve the plan (only the `exit_plan_mode` flow). */
61
67
  canApprove: boolean;
62
68
  /** Per-session secret the client echoes on every request. */
@@ -85,7 +91,6 @@ ${WEB_COMPONENTS_CSS}
85
91
  .review-progress__fill{display:block;height:100%;width:0;background:var(--accent);transition:width var(--motion-fast) linear}
86
92
  /* Review panel: the plan's review facts, the section outline, then the keys. */
87
93
  .bf-app__body--single{grid-template-columns:minmax(0,1fr)}
88
- .bf-app__sidebar .bf-panel{flex:1 1 auto;min-height:0;min-width:0}
89
94
  .review-panel{display:flex;flex-direction:column;gap:var(--space-3);padding:var(--space-4) var(--space-4) var(--space-2)}
90
95
  .review-facts{display:flex;flex-direction:column;gap:var(--space-2);padding-bottom:var(--space-3);border-bottom:1px solid var(--border)}
91
96
  .review-fact{display:flex;align-items:center;justify-content:space-between;gap:var(--space-3);font-size:var(--fs-sm);color:var(--muted)}
@@ -167,7 +172,7 @@ ${WEB_COMPONENTS_CSS}
167
172
  .review-nav .lvl-2 a,.review-nav .lvl-3 a,.review-nav .lvl-4 a,.review-nav .lvl-5 a,.review-nav .lvl-6 a{padding-left:8px}
168
173
  .review-hints{display:none}
169
174
  }
170
- @media (max-width:860px){
175
+ @media (max-width:${SIDEBAR_COLLAPSE_MAX}px){
171
176
  .bf-review{padding:var(--space-4) 18px 80px}
172
177
  .review-nav__title{display:none}
173
178
  }
@@ -50,8 +50,13 @@ const HOST_PATTERN = /^(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/i;
50
50
 
51
51
  const HTML_SECURITY: Record<string, string> = {
52
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.
53
56
  "Content-Security-Policy":
54
- "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; script-src 'self'; connect-src 'self'",
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",
55
60
  "X-Content-Type-Options": "nosniff",
56
61
  "Referrer-Policy": "no-referrer",
57
62
  "Cache-Control": "no-store",
@@ -62,7 +67,7 @@ const SCRIPT_HEADERS: Record<string, string> = {
62
67
  "Content-Type": "text/javascript; charset=utf-8",
63
68
  };
64
69
 
65
- const JSON_HEADERS: Record<string, string> = {
70
+ export const JSON_HEADERS: Record<string, string> = {
66
71
  "Content-Type": "application/json; charset=utf-8",
67
72
  "X-Content-Type-Options": "nosniff",
68
73
  "Referrer-Policy": "no-referrer",
@@ -112,7 +117,10 @@ function checkEnvelope(
112
117
  ): ServeResponse | undefined {
113
118
  const upper = method.toUpperCase();
114
119
  if (upper !== "GET" && upper !== "HEAD" && upper !== "POST") {
115
- return { ...errorResponse(405, "Only GET and POST are served."), headers: { ...JSON_HEADERS, Allow: "GET, POST" } };
120
+ return {
121
+ ...errorResponse(405, "Only GET, HEAD, and POST are served."),
122
+ headers: { ...JSON_HEADERS, Allow: "GET, HEAD, POST" },
123
+ };
116
124
  }
117
125
  // Fail closed: a missing or non-string Host is rejected too, not just a
118
126
  // foreign one, so the DNS-rebinding guard cannot be skipped.