@gavin-hu/my-pi-agent 0.11.3 → 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.
- package/README.md +13 -8
- package/README.zh-CN.md +5 -3
- package/assets/chrome.png +0 -0
- package/assets/plan-review.png +0 -0
- package/extensions/bytefly/README.md +291 -207
- package/extensions/bytefly/doctor/runtime.ts +16 -0
- package/extensions/bytefly/doctor/tools.ts +1 -3
- package/extensions/bytefly/footer/price/config.ts +54 -15
- package/extensions/bytefly/footer/price/price.ts +20 -7
- package/extensions/bytefly/footer/price/types.ts +13 -7
- package/extensions/bytefly/footer/render/format.ts +3 -2
- package/extensions/bytefly/footer/render/segments.ts +3 -2
- package/extensions/bytefly/footer/types.ts +1 -0
- package/extensions/bytefly/header/component.ts +21 -6
- package/extensions/bytefly/header/config.ts +3 -0
- package/extensions/bytefly/header/register.ts +34 -3
- package/extensions/bytefly/index.ts +13 -3
- package/extensions/bytefly/panel/settings-runtime.ts +71 -28
- package/extensions/bytefly/panel/settings.ts +201 -38
- package/extensions/bytefly/panel/tui/entries.ts +58 -0
- package/extensions/bytefly/panel/tui/extensions-view.ts +86 -0
- package/extensions/bytefly/panel/tui/frame.ts +71 -0
- package/extensions/bytefly/panel/tui/nav.ts +75 -0
- package/extensions/bytefly/panel/tui/settings-view.ts +115 -0
- package/extensions/bytefly/panel/tui.ts +205 -319
- package/extensions/bytefly/picker/runtime.ts +61 -2
- package/extensions/bytefly/picker/toggle.ts +56 -0
- package/extensions/bytefly/update/check.ts +59 -0
- package/extensions/bytefly/update/format.ts +32 -0
- package/extensions/bytefly/update/register.ts +72 -0
- package/extensions/bytefly/update/state.ts +28 -0
- package/extensions/bytefly/update/version.ts +83 -0
- package/extensions/document/README.md +2 -2
- package/extensions/document/tool.ts +1 -3
- package/extensions/file-open/README.md +30 -15
- package/extensions/file-open/index.ts +4 -3
- package/extensions/file-open/open.ts +52 -21
- package/extensions/goal/README.md +39 -13
- package/extensions/goal/clarify-prompt.ts +5 -36
- package/extensions/goal/clarify.ts +55 -86
- package/extensions/goal/commands.ts +41 -35
- package/extensions/goal/format.ts +12 -4
- package/extensions/goal/tools.ts +28 -8
- package/extensions/goal/tui.ts +1 -1
- package/extensions/memory/README.md +1 -1
- package/extensions/plan/README.md +24 -8
- package/extensions/plan/blocks.ts +1 -35
- package/extensions/plan/commands.ts +18 -12
- package/extensions/plan/plans.ts +1 -1
- package/extensions/plan/policy.ts +4 -3
- package/extensions/plan/review.ts +243 -0
- package/extensions/plan/runtime.ts +4 -0
- package/extensions/plan/steps.ts +34 -23
- package/extensions/plan/todos.ts +5 -58
- package/extensions/plan/tools.ts +66 -222
- package/extensions/plan/tui.ts +17 -9
- package/extensions/plan/types.ts +3 -0
- package/extensions/plan/web-client.ts +51 -18
- package/extensions/plan/web-page.ts +5 -0
- package/extensions/plan/web-router.ts +11 -3
- package/extensions/plan/web.ts +100 -36
- package/extensions/question/README.md +1 -1
- package/extensions/question/tools.ts +1 -1
- package/extensions/subagent/index.ts +0 -2
- package/extensions/todo/README.md +19 -11
- package/extensions/todo/index.ts +5 -3
- package/extensions/todo/nudge.ts +58 -9
- package/extensions/todo/runtime.ts +28 -17
- package/extensions/web-access/README.md +3 -3
- package/extensions/web-access/fetch/tool.ts +1 -3
- package/extensions/web-access/search/tool.ts +1 -3
- package/extensions/wechat/README.md +22 -11
- package/extensions/wechat/bridge.ts +90 -22
- package/extensions/wechat/commands.ts +45 -1
- package/extensions/wechat/doctor.ts +1 -0
- package/extensions/wechat/index.ts +3 -1
- package/extensions/wechat/status-screen.ts +32 -2
- package/extensions/wechat/tools.ts +0 -2
- package/lib/README.md +2 -1
- package/lib/config.ts +43 -1
- package/lib/env.ts +12 -0
- package/lib/requirements.ts +76 -2
- package/lib/todo-state.ts +62 -0
- package/lib/tui/glyphs.ts +1 -0
- package/package.json +1 -1
- 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
|
-
|
|
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
|
-
|
|
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 (
|
|
100
|
-
|
|
101
|
-
|
|
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,
|
|
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
|
-
|
|
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 >=
|
|
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
|
-
|
|
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
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
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
|
-
|
|
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';
|
|
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 {
|
|
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.
|
package/extensions/plan/web.ts
CHANGED
|
@@ -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
|
|
19
|
-
import {
|
|
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 {
|
|
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
|
-
|
|
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 >
|
|
81
|
-
|
|
82
|
-
request.
|
|
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", () =>
|
|
88
|
-
request.on("error", () =>
|
|
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
|
-
|
|
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
|
-
|
|
149
|
-
|
|
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(
|
|
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:
|
|
221
|
+
title: sections.find((section) => section.level === 1)?.title || planTitle(basename(plan.path)),
|
|
165
222
|
blocks,
|
|
166
|
-
annotations: parseFeedback(
|
|
167
|
-
outline
|
|
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
|
-
},
|
|
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
|
|
231
|
-
|
|
232
|
-
|
|
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: {
|
|
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
|
-
|
|
269
|
-
|
|
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
|
|
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
|
|
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
|
|
45
|
-
|
|
46
|
-
|
|
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`;
|
|
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`
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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-
|
|
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`). |
|
package/extensions/todo/index.ts
CHANGED
|
@@ -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))
|
|
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
|