@gavin-hu/my-pi-agent 0.11.4 → 0.12.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 (81) hide show
  1. package/README.md +13 -8
  2. package/README.zh-CN.md +5 -3
  3. package/assets/chrome.png +0 -0
  4. package/assets/plan-review.png +0 -0
  5. package/extensions/bytefly/README.md +291 -207
  6. package/extensions/bytefly/doctor/runtime.ts +16 -0
  7. package/extensions/bytefly/doctor/tools.ts +1 -3
  8. package/extensions/bytefly/footer/price/config.ts +54 -15
  9. package/extensions/bytefly/footer/price/price.ts +20 -7
  10. package/extensions/bytefly/footer/price/types.ts +13 -7
  11. package/extensions/bytefly/footer/render/format.ts +3 -2
  12. package/extensions/bytefly/footer/render/segments.ts +3 -2
  13. package/extensions/bytefly/footer/types.ts +1 -0
  14. package/extensions/bytefly/header/component.ts +21 -6
  15. package/extensions/bytefly/header/config.ts +3 -0
  16. package/extensions/bytefly/header/register.ts +34 -3
  17. package/extensions/bytefly/index.ts +13 -3
  18. package/extensions/bytefly/panel/settings-runtime.ts +71 -28
  19. package/extensions/bytefly/panel/settings.ts +201 -38
  20. package/extensions/bytefly/panel/tui/entries.ts +58 -0
  21. package/extensions/bytefly/panel/tui/extensions-view.ts +86 -0
  22. package/extensions/bytefly/panel/tui/frame.ts +71 -0
  23. package/extensions/bytefly/panel/tui/nav.ts +75 -0
  24. package/extensions/bytefly/panel/tui/settings-view.ts +115 -0
  25. package/extensions/bytefly/panel/tui.ts +205 -319
  26. package/extensions/bytefly/picker/runtime.ts +61 -2
  27. package/extensions/bytefly/picker/toggle.ts +56 -0
  28. package/extensions/bytefly/update/check.ts +59 -0
  29. package/extensions/bytefly/update/format.ts +32 -0
  30. package/extensions/bytefly/update/register.ts +72 -0
  31. package/extensions/bytefly/update/state.ts +28 -0
  32. package/extensions/bytefly/update/version.ts +83 -0
  33. package/extensions/document/README.md +2 -2
  34. package/extensions/document/tool.ts +1 -3
  35. package/extensions/file-open/README.md +30 -15
  36. package/extensions/file-open/index.ts +4 -3
  37. package/extensions/file-open/open.ts +52 -21
  38. package/extensions/goal/README.md +39 -13
  39. package/extensions/goal/clarify-prompt.ts +5 -36
  40. package/extensions/goal/clarify.ts +55 -86
  41. package/extensions/goal/commands.ts +41 -35
  42. package/extensions/goal/format.ts +12 -4
  43. package/extensions/goal/tools.ts +28 -8
  44. package/extensions/goal/tui.ts +1 -1
  45. package/extensions/memory/README.md +1 -1
  46. package/extensions/plan/README.md +24 -8
  47. package/extensions/plan/blocks.ts +1 -35
  48. package/extensions/plan/commands.ts +18 -12
  49. package/extensions/plan/plans.ts +1 -1
  50. package/extensions/plan/policy.ts +4 -3
  51. package/extensions/plan/review.ts +243 -0
  52. package/extensions/plan/runtime.ts +4 -0
  53. package/extensions/plan/steps.ts +34 -23
  54. package/extensions/plan/todos.ts +5 -58
  55. package/extensions/plan/tools.ts +66 -222
  56. package/extensions/plan/tui.ts +17 -9
  57. package/extensions/plan/types.ts +3 -0
  58. package/extensions/plan/web-client.ts +51 -18
  59. package/extensions/plan/web-page.ts +5 -0
  60. package/extensions/plan/web-router.ts +11 -3
  61. package/extensions/plan/web.ts +100 -36
  62. package/extensions/question/README.md +1 -1
  63. package/extensions/question/tools.ts +1 -1
  64. package/extensions/subagent/index.ts +0 -2
  65. package/extensions/todo/README.md +19 -11
  66. package/extensions/todo/index.ts +5 -3
  67. package/extensions/todo/nudge.ts +58 -9
  68. package/extensions/todo/runtime.ts +28 -17
  69. package/extensions/web-access/README.md +3 -3
  70. package/extensions/web-access/fetch/tool.ts +1 -3
  71. package/extensions/web-access/search/tool.ts +1 -3
  72. package/extensions/wechat/README.md +5 -4
  73. package/extensions/wechat/tools.ts +0 -2
  74. package/lib/README.md +2 -1
  75. package/lib/config.ts +43 -1
  76. package/lib/env.ts +12 -0
  77. package/lib/requirements.ts +76 -2
  78. package/lib/todo-state.ts +62 -0
  79. package/lib/tui/glyphs.ts +1 -0
  80. package/package.json +1 -1
  81. package/assets/bytefly.schema.json +0 -785
@@ -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
@@ -57,6 +57,11 @@ export interface ReviewPageData {
57
57
  steps: { done: number; total: number };
58
58
  /** Highlighted HTML per line, keyed by the code block's 0-based start line. */
59
59
  code: Record<string, string[]>;
60
+ /**
61
+ * The caps the server enforces, so the page does not retype them: comment
62
+ * count, comment length, and quote length, from `feedback.ts`.
63
+ */
64
+ limits: { comments: number; comment: number; quote: number };
60
65
  /** Whether the review may approve the plan (only the `exit_plan_mode` flow). */
61
66
  canApprove: boolean;
62
67
  /** Per-session secret the client echoes on every request. */
@@ -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.
@@ -15,11 +15,24 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse }
15
15
  import { basename } from "node:path";
16
16
  import { openInBrowser } from "../../lib/browser.ts";
17
17
  import { fenceLanguage, highlightLines } from "../../lib/highlight.ts";
18
- import { codeBody, fenceInfo, planBlocks, planHeadings } from "./blocks.ts";
19
- import { parseFeedback, stripFeedback, type PlanAnnotation } from "./feedback.ts";
18
+ import { codeBody, fenceInfo, planBlocks } from "./blocks.ts";
19
+ import {
20
+ MAX_ANNOTATIONS,
21
+ MAX_COMMENT_LENGTH,
22
+ MAX_QUOTE_LENGTH,
23
+ parseFeedback,
24
+ stripFeedback,
25
+ type PlanAnnotation,
26
+ } from "./feedback.ts";
20
27
  import { planTitle, type PlanStore, type StoredPlan } from "./plans.ts";
21
- import { changedSections, extractPlanSteps } from "./steps.ts";
22
- import { handleReviewRequest, type ReviewContext, type ServeResponse, type SaveResult } from "./web-router.ts";
28
+ import { changedSections, extractPlanSections, extractPlanSteps } from "./steps.ts";
29
+ import {
30
+ handleReviewRequest,
31
+ JSON_HEADERS,
32
+ type ReviewContext,
33
+ type ServeResponse,
34
+ type SaveResult,
35
+ } from "./web-router.ts";
23
36
  import type { ReviewPageData } from "./web-page.ts";
24
37
 
25
38
  /** How a browser review ended. */
@@ -64,6 +77,10 @@ export interface PlanWebReviewDeps {
64
77
  token?: () => string;
65
78
  /** Override the bind port; the default `0` lets the OS pick. */
66
79
  port?: number;
80
+ /** Override the closed-tab grace; the default leaves a reload time to reconnect. */
81
+ dropGraceMs?: number;
82
+ /** Override the accepted body size; a test keeps it small. */
83
+ maxBodyBytes?: number;
67
84
  }
68
85
 
69
86
  /** Largest feedback body accepted, in bytes. */
@@ -71,21 +88,37 @@ const MAX_BODY_BYTES = 1024 * 1024;
71
88
  /** Grace period before an empty client set counts as a closed tab. */
72
89
  const DROP_GRACE_MS = 1500;
73
90
 
74
- function readBody(request: IncomingMessage): Promise<string | undefined> {
91
+ /** A request body, or the fact that it was too large to read. */
92
+ type BodyResult = { ok: true; body: string } | { ok: false };
93
+
94
+ /**
95
+ * Read a request body, stopping at {@link MAX_BODY_BYTES}. An oversize body is
96
+ * reported rather than destroyed here, so the handler can still answer 413 over
97
+ * the open socket instead of the client seeing a dropped connection.
98
+ */
99
+ function readBody(request: IncomingMessage, limit: number): Promise<BodyResult> {
75
100
  return new Promise((resolve) => {
76
101
  const chunks: Buffer[] = [];
77
102
  let size = 0;
103
+ let done = false;
104
+ const settle = (result: BodyResult): void => {
105
+ if (done) return;
106
+ done = true;
107
+ resolve(result);
108
+ };
78
109
  request.on("data", (chunk: Buffer) => {
110
+ if (done) return;
79
111
  size += chunk.length;
80
- if (size > MAX_BODY_BYTES) {
81
- resolve(undefined);
82
- request.destroy();
112
+ if (size > limit) {
113
+ chunks.length = 0;
114
+ request.pause();
115
+ settle({ ok: false });
83
116
  return;
84
117
  }
85
118
  chunks.push(chunk);
86
119
  });
87
- request.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
88
- request.on("error", () => resolve(undefined));
120
+ request.on("end", () => settle({ ok: true, body: Buffer.concat(chunks).toString("utf-8") }));
121
+ request.on("error", () => settle({ ok: false }));
89
122
  });
90
123
  }
91
124
 
@@ -115,6 +148,7 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
115
148
  ): Promise<PlanReviewSession | undefined> => {
116
149
  const { signal } = options;
117
150
  const canApprove = options.canApprove === true;
151
+ const maxBody = deps.maxBodyBytes ?? MAX_BODY_BYTES;
118
152
  const token = (deps.token ?? (() => randomBytes(16).toString("hex")))();
119
153
  let settled = false;
120
154
  let resolveDone!: (result: PlanReviewResult) => void;
@@ -133,23 +167,46 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
133
167
  // The stripped plan the page last rendered; a save must anchor against a file
134
168
  // that still matches it, or the comments land beside the wrong text.
135
169
  let baseline = stripFeedback(plan.content);
136
- // Section titles that differ from what the previous review showed. Feedback
137
- // is stripped on both sides, so a comment never reads as an edit.
138
- const changed =
139
- options.previous === undefined ? new Set<string>() : changedSections(stripFeedback(options.previous), baseline);
140
170
  let savedPlan: StoredPlan | undefined;
141
171
  // Set by `onSaved`/`onCancel`; resolved once the HTTP response has flushed,
142
172
  // so the caller cannot close the socket before the browser sees the reply.
143
173
  let pendingResult: PlanReviewResult | undefined;
144
174
 
145
- const page = async (): Promise<ReviewPageData> => {
175
+ /**
176
+ * Re-read the plan into `baseline` and return it. Named apart from the page
177
+ * build because it writes the state a later save validates against, so a `GET`
178
+ * does not hide that write inside what looks like a read.
179
+ */
180
+ const refreshBaseline = async (): Promise<StoredPlan> => {
146
181
  const current = (await store.read(cwd, plan.path)) ?? plan;
147
182
  baseline = stripFeedback(current.content);
148
- const blocks = planBlocks(baseline);
149
- const headings = planHeadings(blocks);
183
+ return current;
184
+ };
185
+
186
+ const page = async (): Promise<ReviewPageData> => {
187
+ const stored = await refreshBaseline();
188
+ const current = baseline;
189
+ const blocks = planBlocks(current);
190
+ const sections = extractPlanSections(current);
191
+ // Per-occurrence change flags, aligned with the sections list: the same shape
192
+ // the terminal review renders, so duplicate headings cannot both light up.
193
+ const changed = options.previous === undefined ? [] : changedSections(stripFeedback(options.previous), current);
194
+ const outline = sections.flatMap((section, index) =>
195
+ section.title === ""
196
+ ? []
197
+ : [
198
+ {
199
+ id: `s-${section.line}`,
200
+ line: section.line,
201
+ level: section.level,
202
+ text: section.title,
203
+ ...(changed[index] ? { changed: true } : {}),
204
+ },
205
+ ],
206
+ );
150
207
  // The same step list approval seeds and set_plan_steps indexes, so the page's
151
208
  // progress cannot disagree with the todo list.
152
- const stepList = extractPlanSteps(baseline);
209
+ const stepList = extractPlanSteps(current);
153
210
  // Highlight code blocks once, at the boundary: the client copies only the
154
211
  // tok-* spans out of the server-escaped HTML.
155
212
  const code: Record<string, string[]> = {};
@@ -161,21 +218,16 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
161
218
  return {
162
219
  // The document's own H1 reads better than the file-name slug, and the
163
220
  // slug is still the fallback for a plan that starts with something else.
164
- title: headings.find((heading) => heading.level === 1)?.text || planTitle(basename(current.path)),
221
+ title: sections.find((section) => section.level === 1)?.title || planTitle(basename(plan.path)),
165
222
  blocks,
166
- annotations: parseFeedback(current.content),
167
- outline: headings.map((heading) => ({
168
- id: `s-${heading.line}`,
169
- line: heading.line,
170
- level: heading.level,
171
- text: heading.text,
172
- ...(changed.has(heading.text) ? { changed: true } : {}),
173
- })),
223
+ annotations: parseFeedback(stored.content),
224
+ outline,
174
225
  steps: {
175
226
  done: stepList.filter((step) => step.status === "completed").length,
176
227
  total: stepList.length,
177
228
  },
178
229
  code,
230
+ limits: { comments: MAX_ANNOTATIONS, comment: MAX_COMMENT_LENGTH, quote: MAX_QUOTE_LENGTH },
179
231
  token,
180
232
  canApprove,
181
233
  };
@@ -215,31 +267,34 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
215
267
  // button and the abort signal are the deterministic paths.
216
268
  const clients = new Set<ServerResponse>();
217
269
  let hadClient = false;
270
+ const dropGrace = deps.dropGraceMs ?? DROP_GRACE_MS;
218
271
  const scheduleDrop = (): void => {
219
272
  if (clients.size > 0 || !hadClient || settled || dropTimer) return;
220
273
  dropTimer = setTimeout(() => {
221
274
  dropTimer = undefined;
222
275
  if (clients.size === 0) finish({ status: "cancelled" });
223
- }, DROP_GRACE_MS);
276
+ }, dropGrace);
224
277
  (dropTimer as { unref?: () => void }).unref?.();
225
278
  };
226
279
 
227
280
  const server = createServer((request, response) => {
228
281
  void (async () => {
229
282
  const method = request.method ?? "GET";
230
- const body = method.toUpperCase() === "POST" ? await readBody(request) : "";
231
- if (body === undefined) {
232
- response.writeHead(413, { "Content-Type": "application/json" });
283
+ const read =
284
+ method.toUpperCase() === "POST" ? await readBody(request, maxBody) : { ok: true as const, body: "" };
285
+ if (!read.ok) {
286
+ response.writeHead(413, { ...JSON_HEADERS, Connection: "close" });
233
287
  response.end(JSON.stringify({ error: "The request body is too large." }));
234
288
  return;
235
289
  }
290
+ const body = read.body;
236
291
  let result: ServeResponse;
237
292
  try {
238
293
  result = await handleReviewRequest(context, method, request.url ?? "/", request.headers, body);
239
294
  } catch {
240
295
  result = {
241
296
  status: 500,
242
- headers: { "Content-Type": "application/json" },
297
+ headers: { ...JSON_HEADERS },
243
298
  body: JSON.stringify({ error: "Internal error" }),
244
299
  };
245
300
  }
@@ -264,16 +319,25 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
264
319
  return;
265
320
  }
266
321
  // A save or cancel only resolves the session after its response is on the
267
- // wire; otherwise `close()` would force-close the socket mid-reply.
268
- response.end(result.body ?? "", () => {
269
- if (!pendingResult) return;
322
+ // wire; otherwise `close()` would force-close the socket mid-reply. The
323
+ // `close` path covers a client that walks away before the flush callback
324
+ // runs, so a settle cannot be lost with the socket.
325
+ const settlePending = (): void => {
270
326
  const terminal = pendingResult;
327
+ if (!terminal) return;
271
328
  pendingResult = undefined;
272
329
  finish(terminal);
273
- });
330
+ };
331
+ response.end(result.body ?? "", settlePending);
332
+ response.on("close", settlePending);
274
333
  })();
275
334
  });
276
335
 
336
+ // An accept-level error (EMFILE and friends) is emitted on the server, and an
337
+ // `error` event with no listener would take the host down mid-review. The
338
+ // review still ends through its own paths: cancel, abort, drop, or shutdown.
339
+ server.on("error", () => {});
340
+
277
341
  const preferred = deps.port ?? ports.get(plan.path) ?? 0;
278
342
  let port: number | undefined;
279
343
  try {
@@ -131,7 +131,7 @@ None in v1 — the tool is either available (a UI exists) or not.
131
131
  |---|---|
132
132
  | Tool | `ask_user_question`; `exposure: "model-only"`, `defaultActive: false`, activated in `session_start` when `ctx.hasUI` is true. |
133
133
  | Execution | `executionMode: "sequential"`. |
134
- | Annotations | `readOnlyHint: true`, `openWorldHint: false`, `destructiveHint: false`. |
134
+ | Annotations | `readOnlyHint: true`, `openWorldHint: false` (a read-only tool declares no `destructiveHint`). |
135
135
  | Output schema | None. Structured data is returned as tool-result `details` (not `structuredContent`). |
136
136
  | State storage | Tool-result `details` (`questions`, `answers`, `cancelled`, `unavailable`). No `pi.appendEntry`. |
137
137
  | Lifecycle | `session_start` only: add the tool to the active set when a UI exists. No `session_shutdown`. |
@@ -45,7 +45,7 @@ export function registerTools(pi: ExtensionAPI): void {
45
45
  parameters: AskUserQuestionParams,
46
46
  exposure: "model-only",
47
47
  defaultActive: false,
48
- annotations: { readOnlyHint: true, openWorldHint: false, destructiveHint: false },
48
+ annotations: { readOnlyHint: true, openWorldHint: false },
49
49
  executionMode: "sequential",
50
50
 
51
51
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
@@ -74,8 +74,6 @@ export default function subagent(pi: ExtensionAPI, deps: SubagentDeps = {}): voi
74
74
  promptSnippet: "Delegate a task to a specialized subagent with an isolated context window.",
75
75
  promptGuidelines: GUIDELINES,
76
76
  parameters: SubagentParams,
77
- exposure: "direct",
78
- defaultActive: true,
79
77
  // Deliberately no `executionMode`: sibling subagent calls are independent
80
78
  // (each spawns its own child process), so they stay parallel by default.
81
79
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
@@ -41,9 +41,13 @@ How the `todo` call and its result look in the transcript — the call line as a
41
41
  mutating tool call (`write`/`edit`/`bash`/`powershell`, or a tool declaring
42
42
  `readOnlyHint: false`) without a todo update, it injects one hidden reminder
43
43
  and forces a single continuation. A failed or blocked call changed nothing, so
44
- it never triggers one. It only fires while the list has unfinished items, at
45
- most once per user turn, and is dropped on the next user turn, so a reader can
46
- never trigger it.
44
+ it never triggers one. It fires while the list has unfinished items, and —
45
+ when no list exists at all — only after three such calls in one turn, at least
46
+ one of them a file write (`write`/`edit`, or a tool declaring
47
+ `readOnlyHint: false`), so repeated `bash` inspection is not mistaken for
48
+ unplanned work. A list the model wrote or cleared during the turn is left
49
+ alone. At most one reminder per user turn, and it is dropped on the next user
50
+ turn, so a reader can never trigger it.
47
51
  - Declares an `outputSchema` and returns matching `structuredContent`
48
52
  (`{ todos, action, error? }`), so codemode/scripts can read the list as data.
49
53
 
@@ -157,7 +161,7 @@ depends on config.
157
161
 
158
162
  | Integration point | Value |
159
163
  |---|---|
160
- | Tool | `todo`; `exposure: "direct"` (default), `executionMode: "sequential"` |
164
+ | Tool | `todo`; direct exposure (the default), `executionMode: "sequential"` |
161
165
  | Annotations | `readOnlyHint: false`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: false` |
162
166
  | Output | `outputSchema` `TodoResult` — `{ todos, action, error? }` — also returned as `structuredContent` |
163
167
  | Command | `/todos` |
@@ -196,12 +200,16 @@ depends on config.
196
200
  [`lib/rails.ts`](../../lib/rails.ts) and [`goal`](../goal/).
197
201
  - **The reminder is a bounded nudge, not a nag.** A prompt guideline cannot
198
202
  guarantee a timely update, so the extension backs it with one forced
199
- continuation. `tool_execution_end` marks a lag only for a successful call by
200
- an explicit mutator list plus `readOnlyHint: false` — a failed or blocked call
201
- is ignored, and a false positive costs a whole extra model request, so an
202
- unrecognized reader is treated as harmless. `nudged` caps it at one per user
203
- turn, `setTodos` clears the work flag, and `context` drops the hidden message
204
- on the next user turn so it never accumulates.
203
+ continuation. `tool_execution_end` counts a successful call by an explicit
204
+ mutator list plus `readOnlyHint: false` — a failed or blocked call is ignored,
205
+ and a false positive costs a whole extra model request, so an unrecognized
206
+ reader is treated as harmless. An open item makes one call enough; a run with
207
+ no list at all must reach `EMPTY_LIST_MUTATIONS` (three) first and include a
208
+ file write, because a shell call cannot be told apart from `rg` and counts as
209
+ work but not as a write; a list the model wrote or cleared this turn is its own
210
+ answer and never reopens that path. `nudged` caps it at one per user turn,
211
+ `setTodos` clears the counters, and `context` drops the hidden message on the
212
+ next user turn so it never accumulates.
205
213
  - **Content is sanitized at the boundary.** `normalizeTodos` replaces control
206
214
  characters and collapses whitespace before the length and duplicate checks,
207
215
  keeping `format.ts` free of terminal concerns and every surface to one row
@@ -228,7 +236,7 @@ depends on config.
228
236
  | `types.ts` | `Todo`, `TodoStatus`, `TodoDetails`. |
229
237
  | `schema.ts` | TypeBox parameters and pure validation/normalization. |
230
238
  | `state.ts` | Branch reconstruction and status queries (pure). |
231
- | `nudge.ts` | Lag detection: mutating-tool classification, due check, reminder text (pure). |
239
+ | `nudge.ts` | Lag detection: mutating and file-write classification, due check, reminder text (pure). |
232
240
  | `format.ts` | Model-facing and transcript text (pure). |
233
241
  | `tui.ts` | Widget and scrollable `/todos` components, drawn from the `lib/tui/` kit (`titleRule`, `dockListFrame`, `listRow`, `emptyState`). |
234
242
  | `config.ts` | Widget config load/validation (the `todo` section of `bytefly.json`). |
@@ -16,7 +16,7 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
16
16
  import { isExtensionEnabled } from "../../lib/env.ts";
17
17
  import { registerCommands } from "./commands.ts";
18
18
  import { loadTodoConfig } from "./config.ts";
19
- import { formatNudge, isMutatingTool } from "./nudge.ts";
19
+ import { formatNudge, isFileWrite, isMutatingTool } from "./nudge.ts";
20
20
  import { createTodoRuntime } from "./runtime.ts";
21
21
  import { registerTools } from "./tools.ts";
22
22
 
@@ -55,11 +55,13 @@ export default function todo(pi: ExtensionAPI) {
55
55
 
56
56
  // Only work that can change the working tree sets the lag flag; read-only
57
57
  // tools and the todo call itself are ignored. A failed or blocked call changed
58
- // nothing, so it must not force a reminder.
58
+ // nothing, so it must not force a reminder. A shell call counts as work but
59
+ // not as a write: its name cannot tell `rg` from `git commit`.
59
60
  pi.on("tool_execution_end", (event) => {
60
61
  if (event.isError) return;
61
62
  const annotations = pi.getAllTools().find((tool) => tool.name === event.toolName)?.annotations;
62
- if (isMutatingTool(event.toolName, annotations)) runtime.noteWork();
63
+ if (!isMutatingTool(event.toolName, annotations)) return;
64
+ runtime.noteWork(isFileWrite(event.toolName, annotations));
63
65
  });
64
66
 
65
67
  // When the run is about to settle with unfinished work done since the last