@gavin-hu/my-pi-agent 0.16.0 → 0.18.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 +29 -15
- package/README.zh-CN.md +27 -15
- package/extensions/bytefly/README.md +195 -115
- package/extensions/bytefly/footer/collect/entries.ts +1 -6
- package/extensions/bytefly/footer/collect/snapshot.ts +23 -9
- package/extensions/bytefly/footer/price/price.ts +12 -8
- package/extensions/bytefly/footer/register.ts +13 -1
- package/extensions/bytefly/footer/types.ts +11 -0
- package/extensions/bytefly/footer/unpriced.ts +36 -0
- package/extensions/bytefly/header/component.ts +15 -29
- package/extensions/bytefly/header/identity.ts +71 -0
- package/extensions/bytefly/header/register.ts +9 -3
- package/extensions/bytefly/index.ts +13 -5
- package/extensions/bytefly/message/format.ts +20 -13
- package/extensions/bytefly/message/register.ts +8 -7
- package/extensions/bytefly/notify/types.ts +1 -1
- package/extensions/bytefly/panel/commands.ts +46 -25
- package/extensions/bytefly/panel/format.ts +49 -14
- package/extensions/bytefly/panel/join.ts +35 -9
- package/extensions/bytefly/panel/settings.ts +29 -39
- package/extensions/bytefly/panel/skills.ts +51 -0
- package/extensions/bytefly/panel/tui/extensions-view.ts +7 -61
- package/extensions/bytefly/panel/tui/frame.ts +9 -1
- package/extensions/bytefly/panel/tui/list-view.ts +72 -0
- package/extensions/bytefly/panel/tui/nav.ts +71 -3
- package/extensions/bytefly/panel/tui/settings-section.ts +250 -0
- package/extensions/bytefly/panel/tui/skills-view.ts +50 -0
- package/extensions/bytefly/panel/tui.ts +223 -331
- package/extensions/bytefly/panel/types.ts +26 -8
- package/extensions/bytefly/picker/discovery.ts +32 -27
- package/extensions/bytefly/picker/resources.ts +16 -8
- package/extensions/bytefly/picker/runtime.ts +62 -34
- package/extensions/bytefly/picker/toggle.ts +16 -10
- package/extensions/bytefly/picker/types.ts +31 -13
- package/extensions/bytefly/update/format.ts +66 -8
- package/extensions/bytefly/update/patch.ts +66 -0
- package/extensions/bytefly/update/register.ts +4 -11
- package/extensions/bytefly/update/release.ts +65 -0
- package/extensions/bytefly/update/state.ts +14 -0
- package/extensions/bytefly/update/suppress.ts +47 -29
- package/extensions/bytefly/update/types.ts +15 -5
- package/extensions/check/tui.ts +2 -0
- package/extensions/context/README.md +21 -11
- package/extensions/context/format.ts +10 -8
- package/extensions/context/report.ts +21 -8
- package/extensions/context/tui.ts +26 -10
- package/extensions/context/types.ts +5 -3
- package/extensions/file-browse/README.md +89 -22
- package/extensions/file-browse/chrome.ts +83 -0
- package/extensions/file-browse/client.ts +74 -459
- package/extensions/file-browse/config.ts +12 -3
- package/extensions/file-browse/diff-html.ts +94 -0
- package/extensions/file-browse/html.ts +26 -152
- package/extensions/file-browse/live-client.ts +47 -1
- package/extensions/file-browse/live.ts +35 -14
- package/extensions/file-browse/palette.ts +224 -0
- package/extensions/file-browse/rev.ts +42 -0
- package/extensions/file-browse/router.ts +27 -6
- package/extensions/file-browse/server.ts +7 -0
- package/extensions/file-browse/styles.ts +10 -3
- package/extensions/file-browse/tree.ts +381 -0
- package/extensions/file-read/README.md +4 -1
- package/extensions/file-read/extract/odt.ts +7 -5
- package/extensions/goal/plan-route.ts +8 -2
- package/extensions/goal/tools.ts +6 -11
- package/extensions/job/README.md +7 -5
- package/extensions/job/index.ts +35 -52
- package/extensions/job/tools.ts +51 -77
- package/extensions/memory/README.md +3 -3
- package/extensions/memory/tools.ts +4 -6
- package/extensions/memory/tui.ts +5 -1
- package/extensions/plan/README.md +333 -84
- package/extensions/plan/commands.ts +73 -7
- package/extensions/plan/feedback.ts +300 -63
- package/extensions/plan/index.ts +1 -1
- package/extensions/plan/plan-index.ts +4 -2
- package/extensions/plan/plan-text.ts +18 -1
- package/extensions/plan/plans.ts +184 -58
- package/extensions/plan/review-prompt.ts +93 -0
- package/extensions/plan/review.ts +95 -95
- package/extensions/plan/runtime.ts +4 -2
- package/extensions/plan/split.ts +87 -0
- package/extensions/plan/steps.ts +123 -107
- package/extensions/plan/tools/cancel.ts +79 -0
- package/extensions/plan/tools/enter.ts +17 -12
- package/extensions/plan/tools/exit.ts +148 -59
- package/extensions/plan/tools/steps.ts +7 -19
- package/extensions/plan/tools/write.ts +29 -31
- package/extensions/plan/tools.ts +6 -3
- package/extensions/plan/tui.ts +60 -15
- package/extensions/plan/types.ts +45 -3
- package/extensions/plan/web-chrome.ts +102 -0
- package/extensions/plan/web-client.ts +177 -446
- package/extensions/plan/web-collab.ts +207 -0
- package/extensions/plan/web-comments.ts +269 -0
- package/extensions/plan/web-markdown.ts +222 -0
- package/extensions/plan/web-page.ts +131 -19
- package/extensions/plan/web-router.ts +148 -32
- package/extensions/plan/web-share-session.ts +197 -0
- package/extensions/plan/web-share.ts +169 -0
- package/extensions/plan/web-streams.ts +124 -0
- package/extensions/plan/web-tokens.ts +63 -0
- package/extensions/plan/web.ts +193 -45
- package/extensions/question/README.md +3 -0
- package/extensions/question/dialogs.ts +91 -1
- package/extensions/question/tools.ts +2 -1
- package/extensions/rewind/checklist.ts +4 -0
- package/extensions/rewind/review-tui.ts +3 -1
- package/extensions/rewind/rewind-tui.ts +4 -0
- package/extensions/session-bus/README.md +247 -0
- package/extensions/session-bus/commands.ts +165 -0
- package/extensions/session-bus/format.ts +124 -0
- package/extensions/session-bus/index.ts +69 -0
- package/extensions/session-bus/notice.ts +58 -0
- package/extensions/session-bus/protocol.ts +134 -0
- package/extensions/session-bus/result.ts +243 -0
- package/extensions/session-bus/rows.ts +169 -0
- package/extensions/session-bus/runtime.ts +575 -0
- package/extensions/session-bus/store.ts +329 -0
- package/extensions/session-bus/team.ts +102 -0
- package/extensions/session-bus/tools.ts +227 -0
- package/extensions/session-bus/tui.ts +394 -0
- package/extensions/session-bus/types.ts +98 -0
- package/extensions/subagent/README.md +26 -13
- package/extensions/subagent/format.ts +72 -25
- package/extensions/subagent/index.ts +21 -47
- package/extensions/subagent/orchestrate.ts +18 -41
- package/extensions/subagent/render.ts +29 -42
- package/extensions/subagent/stream.ts +24 -18
- package/extensions/subagent/types.ts +7 -0
- package/extensions/todo/tools.ts +5 -6
- package/extensions/todo/tui.ts +5 -1
- package/extensions/watch/README.md +9 -5
- package/extensions/watch/index.ts +25 -44
- package/extensions/watch/tools.ts +31 -59
- package/extensions/wechat/README.md +45 -36
- package/extensions/wechat/bridge.ts +46 -48
- package/extensions/wechat/client.ts +1 -4
- package/extensions/wechat/commands.ts +3 -3
- package/extensions/wechat/deliver.ts +57 -37
- package/extensions/wechat/interaction.ts +54 -18
- package/extensions/wechat/login-screen.ts +2 -3
- package/extensions/wechat/notices.ts +9 -7
- package/extensions/wechat/peer-worker.ts +19 -34
- package/extensions/wechat/presence.ts +3 -4
- package/extensions/wechat/prompt.ts +53 -23
- package/extensions/wechat/reply-pipeline.ts +12 -4
- package/extensions/wechat/slash.ts +14 -0
- package/extensions/wechat/status-screen.ts +2 -2
- package/extensions/wechat/tools.ts +3 -11
- package/extensions/worktree/README.md +48 -12
- package/extensions/worktree/commands.ts +109 -65
- package/extensions/worktree/enter.ts +1 -1
- package/extensions/worktree/exit.ts +11 -1
- package/extensions/worktree/git.ts +5 -0
- package/extensions/worktree/lifecycle.ts +9 -1
- package/extensions/worktree/registry.ts +11 -5
- package/extensions/worktree/screen.ts +263 -0
- package/extensions/worktree/status.ts +84 -40
- package/lib/README.md +12 -7
- package/lib/claims/overlap.ts +43 -0
- package/lib/config.ts +12 -0
- package/lib/format.ts +6 -2
- package/lib/http/router.ts +40 -13
- package/lib/http/server.ts +27 -13
- package/lib/interaction.ts +28 -1
- package/lib/notice.ts +29 -2
- package/lib/plan-entry.ts +7 -2
- package/lib/plans/sections.ts +172 -0
- package/lib/plans/setting.ts +52 -0
- package/lib/plans/split.ts +215 -0
- package/lib/plans/store.ts +19 -0
- package/lib/plans/team.ts +151 -0
- package/lib/qr.ts +64 -0
- package/lib/repo-key.ts +74 -0
- package/lib/report.ts +117 -25
- package/lib/requirements.ts +1 -2
- package/lib/tool-result.ts +54 -4
- package/lib/tui/README.md +30 -8
- package/lib/tui/chrome.ts +27 -6
- package/lib/tui/dock-screen.ts +91 -19
- package/lib/tui/gauge.ts +9 -5
- package/lib/tui/line-pane.ts +15 -4
- package/lib/tui/tool.ts +23 -4
- package/lib/usage.ts +26 -0
- package/lib/web/README.md +131 -0
- package/lib/web/base.ts +19 -2
- package/lib/web/components.ts +10 -6
- package/lib/web/layout.ts +2 -0
- package/lib/web/markup.ts +2 -1
- package/lib/web/runtime.ts +103 -25
- package/lib/web/tokens.ts +23 -7
- package/package.json +5 -2
- package/skills/changelog/SKILL.md +57 -45
- package/skills/init/SKILL.md +80 -45
- package/skills/init/references/agents-md-quality.md +26 -2
- package/skills/init/references/interview-questions.md +36 -14
- package/skills/next/SKILL.md +209 -0
- package/skills/parallel-dev/SKILL.md +150 -0
- package/extensions/wechat/qrcode.ts +0 -46
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The review's event streams: which pages are listening, what they are told, and
|
|
3
|
+
* when a closed tab ends the review.
|
|
4
|
+
*
|
|
5
|
+
* A frame carries only a kind, so the registry is the only thing that knows who
|
|
6
|
+
* is connected. It is keyed by response — a page may hold more than one stream —
|
|
7
|
+
* and records the caller it belongs to, so an owner-only frame, or one revoked
|
|
8
|
+
* link, can be addressed without the caller reaching into the map.
|
|
9
|
+
*
|
|
10
|
+
* The closed-tab rule lives here because it is a fact about streams: a reviewer's
|
|
11
|
+
* tab opening or closing changes nothing, while the owner's own page going away
|
|
12
|
+
* ends the review once the grace a reload needs has passed. The grace is a race
|
|
13
|
+
* against that reload, so it takes the timer as a seam a test can drive by hand.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { ServerResponse } from "node:http";
|
|
17
|
+
import type { ArmTimer } from "../../lib/timer.ts";
|
|
18
|
+
import type { ReviewFrame, ReviewRole } from "./types.ts";
|
|
19
|
+
|
|
20
|
+
/** Who a stream belongs to: the role it may act as, and the token that names it. */
|
|
21
|
+
export interface StreamRecord {
|
|
22
|
+
role: ReviewRole;
|
|
23
|
+
token: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Which streams a frame is for; absent means every open page. */
|
|
27
|
+
export interface StreamFilter {
|
|
28
|
+
role?: ReviewRole;
|
|
29
|
+
token?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** What the registry needs from the session whose lifetime it reports on. */
|
|
33
|
+
export interface StreamRegistryDeps {
|
|
34
|
+
/** The review is over because its owner's page went away. */
|
|
35
|
+
onAbandoned: () => void;
|
|
36
|
+
/** How long a closed owner tab waits for a reload before it counts as gone. */
|
|
37
|
+
graceMs: number;
|
|
38
|
+
/** The timer the grace is armed on. */
|
|
39
|
+
schedule: ArmTimer;
|
|
40
|
+
/** A settled review arms nothing, so a stream closing during teardown is silent. */
|
|
41
|
+
settled: () => boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The stream set one running review holds. */
|
|
45
|
+
export interface StreamRegistry {
|
|
46
|
+
/** Register a stream, and stand down a pending closed-tab grace. */
|
|
47
|
+
add(response: ServerResponse, record: StreamRecord): void;
|
|
48
|
+
/** Forget a stream, arming the grace when it was the owner's last one. */
|
|
49
|
+
remove(response: ServerResponse): void;
|
|
50
|
+
/** Tell the open pages something changed. */
|
|
51
|
+
broadcast(kind: ReviewFrame, only?: StreamFilter): void;
|
|
52
|
+
/** Send one last frame and end the stream with it. */
|
|
53
|
+
end(kind: ReviewFrame, only?: StreamFilter): void;
|
|
54
|
+
/** Drop every stream without ending it, after they have all been ended. */
|
|
55
|
+
clear(): void;
|
|
56
|
+
/** Stand down the closed-tab grace, because the review is ending anyway. */
|
|
57
|
+
cancelDrop(): void;
|
|
58
|
+
/** How many owner streams are open. */
|
|
59
|
+
ownerOpen(): number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function createStreams(deps: StreamRegistryDeps): StreamRegistry {
|
|
63
|
+
const clients = new Map<ServerResponse, StreamRecord>();
|
|
64
|
+
let sawOwner = false;
|
|
65
|
+
let cancelDrop: (() => void) | undefined;
|
|
66
|
+
|
|
67
|
+
const ownerOpen = (): number => {
|
|
68
|
+
let open = 0;
|
|
69
|
+
for (const record of clients.values()) if (record.role === "owner") open += 1;
|
|
70
|
+
return open;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const cancel = (): void => {
|
|
74
|
+
cancelDrop?.();
|
|
75
|
+
cancelDrop = undefined;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const scheduleDrop = (): void => {
|
|
79
|
+
if (ownerOpen() > 0 || !sawOwner || deps.settled() || cancelDrop) return;
|
|
80
|
+
cancelDrop = deps.schedule(deps.graceMs, () => {
|
|
81
|
+
cancelDrop = undefined;
|
|
82
|
+
if (ownerOpen() === 0) deps.onAbandoned();
|
|
83
|
+
});
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const matches = (record: StreamRecord, only: StreamFilter | undefined): boolean => {
|
|
87
|
+
if (only === undefined) return true;
|
|
88
|
+
if (only.role !== undefined && record.role !== only.role) return false;
|
|
89
|
+
if (only.token !== undefined && record.token !== only.token) return false;
|
|
90
|
+
return true;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
add(response, record) {
|
|
95
|
+
clients.set(response, record);
|
|
96
|
+
if (record.role === "owner") {
|
|
97
|
+
sawOwner = true;
|
|
98
|
+
cancel();
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
remove(response) {
|
|
102
|
+
const record = clients.get(response);
|
|
103
|
+
clients.delete(response);
|
|
104
|
+
if (record?.role === "owner") scheduleDrop();
|
|
105
|
+
},
|
|
106
|
+
broadcast(kind, only) {
|
|
107
|
+
for (const [client, record] of clients) {
|
|
108
|
+
if (!matches(record, only)) continue;
|
|
109
|
+
if (client.writableEnded || client.destroyed) continue;
|
|
110
|
+
client.write(`data: ${kind}\n\n`);
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
end(kind, only) {
|
|
114
|
+
for (const [client, record] of clients) {
|
|
115
|
+
if (!matches(record, only)) continue;
|
|
116
|
+
if (client.writableEnded || client.destroyed) continue;
|
|
117
|
+
client.end(`data: ${kind}\n\n`);
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
clear: () => clients.clear(),
|
|
121
|
+
cancelDrop: cancel,
|
|
122
|
+
ownerOpen,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The token table: who a review's bearer token names.
|
|
3
|
+
*
|
|
4
|
+
* A review has two roles and, once shared, one token per reviewer link. The role
|
|
5
|
+
* travels with the token, and a reviewer's name is added to it the first time that
|
|
6
|
+
* link writes. Everything that decides "may this caller approve, save, or close"
|
|
7
|
+
* reads this table, so the rules live here rather than in the session that mints
|
|
8
|
+
* them:
|
|
9
|
+
*
|
|
10
|
+
* - two roles may never share one token, or a reviewer token could displace the
|
|
11
|
+
* owner's and hand a reviewer the approval route;
|
|
12
|
+
* - a removed token is forgotten rather than tombstoned, so a re-shared link may
|
|
13
|
+
* claim the name the revoked one released;
|
|
14
|
+
* - a name already bound elsewhere is visible, because the marker in the plan
|
|
15
|
+
* carries only the name, so a shared name is a shared marker.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { randomBytes } from "node:crypto";
|
|
19
|
+
import type { ReviewCaller, ReviewRole } from "./types.ts";
|
|
20
|
+
|
|
21
|
+
/** The token → caller table one running review holds. */
|
|
22
|
+
export interface TokenTable {
|
|
23
|
+
/** Mint a token for `role`, replacing a colliding one until it is unique. */
|
|
24
|
+
mint(role: ReviewRole): string;
|
|
25
|
+
/** The caller a token names, or undefined when it is not one of this review's. */
|
|
26
|
+
get(token: string): ReviewCaller | undefined;
|
|
27
|
+
/** Forget a token, so the link that used it stops answering. */
|
|
28
|
+
revoke(token: string): void;
|
|
29
|
+
/**
|
|
30
|
+
* The names every other live link has bound, so one review cannot hold two
|
|
31
|
+
* reviewers called `alice`: the marker carries only the name, so a shared name
|
|
32
|
+
* is a shared marker.
|
|
33
|
+
*/
|
|
34
|
+
namesExcept(self: ReviewCaller): string[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** How many times a colliding generator is retried before the table names it itself. */
|
|
38
|
+
const COLLISION_ATTEMPTS = 8;
|
|
39
|
+
|
|
40
|
+
/** A 128-bit token; the one credential a reviewer holds. */
|
|
41
|
+
const defaultToken = (): string => randomBytes(16).toString("hex");
|
|
42
|
+
|
|
43
|
+
/** Build the table for one review. `mint` is the generator a test replaces. */
|
|
44
|
+
export function createTokens(mint: () => string = defaultToken): TokenTable {
|
|
45
|
+
const callers = new Map<string, ReviewCaller>();
|
|
46
|
+
return {
|
|
47
|
+
mint(role) {
|
|
48
|
+
// A role is keyed by its token, so two roles may never share one: a
|
|
49
|
+
// generator that repeats would otherwise let a reviewer token displace the
|
|
50
|
+
// owner's and hand a reviewer the approval route.
|
|
51
|
+
let value = mint();
|
|
52
|
+
for (let attempt = 0; callers.has(value) && attempt < COLLISION_ATTEMPTS; attempt++) value = mint();
|
|
53
|
+
if (callers.has(value)) value = `${value}-${callers.size}`;
|
|
54
|
+
callers.set(value, { role });
|
|
55
|
+
return value;
|
|
56
|
+
},
|
|
57
|
+
get: (token) => callers.get(token),
|
|
58
|
+
revoke: (token) => {
|
|
59
|
+
callers.delete(token);
|
|
60
|
+
},
|
|
61
|
+
namesExcept: (self) => [...callers.values()].flatMap((entry) => (entry !== self && entry.name ? [entry.name] : [])),
|
|
62
|
+
};
|
|
63
|
+
}
|
package/extensions/plan/web.ts
CHANGED
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
* and it takes injected seams so tests can run one on `127.0.0.1:0`.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { randomBytes } from "node:crypto";
|
|
14
13
|
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
|
14
|
+
import { networkInterfaces } from "node:os";
|
|
15
15
|
import { basename } from "node:path";
|
|
16
16
|
import { openInBrowser } from "../../lib/browser.ts";
|
|
17
17
|
import {
|
|
@@ -19,25 +19,33 @@ import {
|
|
|
19
19
|
createCloser,
|
|
20
20
|
jsonHeaders,
|
|
21
21
|
openEventStream,
|
|
22
|
+
parseRequestUrl,
|
|
22
23
|
respond,
|
|
23
24
|
type ServeResponse,
|
|
24
25
|
} from "../../lib/http/index.ts";
|
|
25
26
|
import { fenceLanguage, highlightLines } from "../../lib/highlight.ts";
|
|
26
|
-
import { type ArmTimer, defaultTimer } from "../../lib/timer.ts";
|
|
27
|
+
import { type ArmTimer, defaultInterval, defaultTimer, type RepeatTimer } from "../../lib/timer.ts";
|
|
27
28
|
import { codeBody, fenceInfo, planBlocks } from "./blocks.ts";
|
|
28
29
|
import {
|
|
30
|
+
claimAuthor,
|
|
29
31
|
MAX_ANNOTATIONS,
|
|
30
32
|
MAX_COMMENT_LENGTH,
|
|
31
33
|
MAX_QUOTE_LENGTH,
|
|
34
|
+
normalizeAuthor,
|
|
32
35
|
parseFeedback,
|
|
33
36
|
stripFeedback,
|
|
34
37
|
type PlanAnnotation,
|
|
35
38
|
} from "./feedback.ts";
|
|
36
39
|
import { planTitle, type PlanStore, type StoredPlan } from "./plans.ts";
|
|
37
|
-
import { changedSections, type SectionDigest } from "./plan-text.ts";
|
|
40
|
+
import { changedSections, planDigest, type SectionDigest } from "./plan-text.ts";
|
|
38
41
|
import { extractPlanSections, extractPlanSteps } from "./steps.ts";
|
|
42
|
+
import type { ReviewCaller } from "./types.ts";
|
|
39
43
|
import { handleReviewRequest, type ReviewContext, type SaveResult } from "./web-router.ts";
|
|
40
44
|
import type { ReviewPageData } from "./web-page.ts";
|
|
45
|
+
import { createShareSession, type ShareSession } from "./web-share-session.ts";
|
|
46
|
+
import { createStreams } from "./web-streams.ts";
|
|
47
|
+
import { createTokens } from "./web-tokens.ts";
|
|
48
|
+
import type { NetworkInterfaces, ShareResult, ShareState } from "./web-share.ts";
|
|
41
49
|
|
|
42
50
|
/** How a browser review ended. */
|
|
43
51
|
export type PlanReviewResult =
|
|
@@ -47,13 +55,13 @@ export type PlanReviewResult =
|
|
|
47
55
|
|
|
48
56
|
/** A running review the caller awaits and then closes. */
|
|
49
57
|
export interface PlanReviewSession {
|
|
50
|
-
/** Tokenized URL to open or print. */
|
|
58
|
+
/** Tokenized URL to open or print; the owner's own page. */
|
|
51
59
|
url: string;
|
|
52
60
|
/** Bound loopback port. */
|
|
53
61
|
port: number;
|
|
54
62
|
/** Resolves once, when the review ends. */
|
|
55
63
|
done: Promise<PlanReviewResult>;
|
|
56
|
-
/** Stop the server. Safe to call more than once. */
|
|
64
|
+
/** Stop the server(s). Safe to call more than once. */
|
|
57
65
|
close(): Promise<void>;
|
|
58
66
|
}
|
|
59
67
|
|
|
@@ -63,13 +71,29 @@ export interface PlanReviewOptions {
|
|
|
63
71
|
signal?: AbortSignal;
|
|
64
72
|
/** Whether the browser may approve the plan (only the `exit_plan_mode` flow). */
|
|
65
73
|
canApprove?: boolean;
|
|
74
|
+
/** A line the page shows the owner, such as why approval belongs in the terminal. */
|
|
75
|
+
note?: string;
|
|
66
76
|
/** The section digests the previous review showed, so this page can mark changed sections. */
|
|
67
77
|
baseline?: SectionDigest[];
|
|
78
|
+
/**
|
|
79
|
+
* Called when a shared reviewer's post lands, so the owner's terminal can say so:
|
|
80
|
+
* an open page updates itself, and whoever is waiting in the TUI is told nothing
|
|
81
|
+
* until this fires.
|
|
82
|
+
*/
|
|
83
|
+
onReviewerComment?: (author: string, count: number) => void;
|
|
68
84
|
}
|
|
69
85
|
|
|
70
86
|
/** Starts and tracks review sessions. */
|
|
71
87
|
export interface PlanWebReview {
|
|
72
88
|
start(cwd: string, plan: StoredPlan, options?: PlanReviewOptions): Promise<PlanReviewSession | undefined>;
|
|
89
|
+
/** The session currently serving a review, if any. */
|
|
90
|
+
current(): PlanReviewSession | undefined;
|
|
91
|
+
/** Start (or re-read) the share link for the running review, if one is open. */
|
|
92
|
+
share(): Promise<ShareResult | undefined>;
|
|
93
|
+
/** Revoke every reviewer token and stop accepting reviewers. */
|
|
94
|
+
stopSharing(): Promise<ShareResult | undefined>;
|
|
95
|
+
/** The running review's share state, without changing it. */
|
|
96
|
+
shareState(): ShareState | undefined;
|
|
73
97
|
/** Stop every running session (session shutdown). */
|
|
74
98
|
closeAll(): Promise<void>;
|
|
75
99
|
}
|
|
@@ -93,12 +117,31 @@ export interface PlanWebReviewDeps {
|
|
|
93
117
|
schedule?: ArmTimer;
|
|
94
118
|
/** Override the accepted body size; a test keeps it small. */
|
|
95
119
|
maxBodyBytes?: number;
|
|
120
|
+
/**
|
|
121
|
+
* Override the repeating timer that polls the plan file.
|
|
122
|
+
*
|
|
123
|
+
* A poll is a race against an edit made elsewhere, so a test drives it by hand
|
|
124
|
+
* rather than waiting for a tick that makes a busy machine fail.
|
|
125
|
+
*/
|
|
126
|
+
tick?: RepeatTimer;
|
|
127
|
+
/** How often to check the plan file for an edit this review did not make. */
|
|
128
|
+
pollMs?: number;
|
|
129
|
+
/** Override the machine's network interfaces (tests), never read live in a test. */
|
|
130
|
+
networkInterfaces?: () => NetworkInterfaces;
|
|
96
131
|
}
|
|
97
132
|
|
|
98
133
|
/** Largest feedback body accepted, in bytes. */
|
|
99
134
|
const MAX_BODY_BYTES = 1024 * 1024;
|
|
100
135
|
/** Grace period before an empty client set counts as a closed tab. */
|
|
101
136
|
const DROP_GRACE_MS = 1500;
|
|
137
|
+
/**
|
|
138
|
+
* How often a running review checks the plan file.
|
|
139
|
+
*
|
|
140
|
+
* One stat and one read of a small markdown file, only while a page is open: a
|
|
141
|
+
* second is fast enough that an edit under an open page reaches it while the
|
|
142
|
+
* reader is still looking, and slow enough to be free.
|
|
143
|
+
*/
|
|
144
|
+
const PLAN_POLL_MS = 1000;
|
|
102
145
|
|
|
103
146
|
/** A request body, or the fact that it was too large to read. */
|
|
104
147
|
type BodyResult = { ok: true; body: string } | { ok: false };
|
|
@@ -136,6 +179,9 @@ function readBody(request: IncomingMessage, limit: number): Promise<BodyResult>
|
|
|
136
179
|
|
|
137
180
|
export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps = {}): PlanWebReview {
|
|
138
181
|
const sessions = new Set<PlanReviewSession>();
|
|
182
|
+
// What each running review can do about sharing; the session itself stays the
|
|
183
|
+
// four things a caller awaits and closes.
|
|
184
|
+
const shares = new Map<PlanReviewSession, ShareSession>();
|
|
139
185
|
// Last bound port per plan path, so the same plan keeps one origin across
|
|
140
186
|
// review turns (and its saved browser theme with it).
|
|
141
187
|
const ports = new Map<string, number>();
|
|
@@ -148,21 +194,37 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
|
|
|
148
194
|
const { signal } = options;
|
|
149
195
|
const canApprove = options.canApprove === true;
|
|
150
196
|
const maxBody = deps.maxBodyBytes ?? MAX_BODY_BYTES;
|
|
151
|
-
|
|
197
|
+
// One token per holder: the owner's is minted here and never shared, and a
|
|
198
|
+
// reviewer token is minted by `share()`. The table owns the collision rule and
|
|
199
|
+
// the name each link has bound.
|
|
200
|
+
const tokens = createTokens(deps.token);
|
|
201
|
+
const token = tokens.mint("owner");
|
|
202
|
+
// The owner's bound port, so a shared listener can reuse it for the same URL shape.
|
|
203
|
+
let currentPort = 0;
|
|
152
204
|
let settled = false;
|
|
153
205
|
let resolveDone!: (result: PlanReviewResult) => void;
|
|
154
206
|
const done = new Promise<PlanReviewResult>((resolve) => {
|
|
155
207
|
resolveDone = resolve;
|
|
156
208
|
});
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
209
|
+
// Live event streams, used only to notice a closed owner tab: a reviewer's
|
|
210
|
+
// tab is not the review's lifetime. The explicit Cancel button and the abort
|
|
211
|
+
// signal are the deterministic paths.
|
|
212
|
+
const streams = createStreams({
|
|
213
|
+
onAbandoned: () => finish({ status: "cancelled" }),
|
|
214
|
+
graceMs: deps.dropGraceMs ?? DROP_GRACE_MS,
|
|
215
|
+
schedule: deps.schedule ?? defaultTimer,
|
|
216
|
+
settled: () => settled,
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
let stopTicker: () => void = () => {};
|
|
162
220
|
const finish = (result: PlanReviewResult): void => {
|
|
163
221
|
if (settled) return;
|
|
164
222
|
settled = true;
|
|
165
|
-
|
|
223
|
+
streams.cancelDrop();
|
|
224
|
+
stopTicker();
|
|
225
|
+
// A page still open has no decision to make, so it is told why it stops
|
|
226
|
+
// updating, and its stream is ended with that frame rather than after it.
|
|
227
|
+
streams.end("closed");
|
|
166
228
|
resolveDone(result);
|
|
167
229
|
};
|
|
168
230
|
|
|
@@ -181,11 +243,41 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
|
|
|
181
243
|
*/
|
|
182
244
|
const refreshPageText = async (): Promise<StoredPlan> => {
|
|
183
245
|
const current = (await store.read(cwd, plan.path)) ?? plan;
|
|
184
|
-
|
|
246
|
+
const next = stripFeedback(current.content);
|
|
247
|
+
// Someone saved the plan outside this page: the readers must see the new
|
|
248
|
+
// body, and the next save must anchor against it rather than 409.
|
|
249
|
+
if (next !== pageText) notifyPlanChange();
|
|
250
|
+
pageText = next;
|
|
185
251
|
return current;
|
|
186
252
|
};
|
|
253
|
+
// Set below, once the stream set exists; a page load before that has no
|
|
254
|
+
// reader to notify and nothing to notify it about.
|
|
255
|
+
let notifyPlanChange: () => void = () => {};
|
|
256
|
+
notifyPlanChange = () => streams.broadcast("plan");
|
|
187
257
|
|
|
188
|
-
|
|
258
|
+
/**
|
|
259
|
+
* Notice a plan file this review did not write.
|
|
260
|
+
*
|
|
261
|
+
* A review owns the page, not the file: `/plan review` runs in normal mode, and
|
|
262
|
+
* the plan is a markdown file a user or another tool can rewrite while the page
|
|
263
|
+
* is open. The check compares the stripped text with what the page rendered and
|
|
264
|
+
* only announces — it never writes `pageText`, which is the anchor a save is
|
|
265
|
+
* validated against. So an edit under an open page reaches every reader, and the
|
|
266
|
+
* review's own save, whose text is unchanged, says nothing.
|
|
267
|
+
*
|
|
268
|
+
* A poll rather than an `fs.watch`: the house rule, and one stat plus one read
|
|
269
|
+
* per interval costs less than the watcher's platform-dependent event
|
|
270
|
+
* semantics (see `file-browse`'s note). It stops with the session.
|
|
271
|
+
*/
|
|
272
|
+
const checkPlanFile = async (): Promise<void> => {
|
|
273
|
+
const current = await store.read(cwd, plan.path);
|
|
274
|
+
if (!current) return;
|
|
275
|
+
if (stripFeedback(current.content) !== pageText) notifyPlanChange();
|
|
276
|
+
};
|
|
277
|
+
stopTicker = (deps.tick ?? defaultInterval)(deps.pollMs ?? PLAN_POLL_MS, () => void checkPlanFile());
|
|
278
|
+
|
|
279
|
+
const page = async (caller: ReviewCaller): Promise<ReviewPageData> => {
|
|
280
|
+
const { role } = caller;
|
|
189
281
|
const stored = await refreshPageText();
|
|
190
282
|
const current = pageText;
|
|
191
283
|
const blocks = planBlocks(current);
|
|
@@ -231,29 +323,79 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
|
|
|
231
323
|
code,
|
|
232
324
|
limits: { comments: MAX_ANNOTATIONS, comment: MAX_COMMENT_LENGTH, quote: MAX_QUOTE_LENGTH },
|
|
233
325
|
token,
|
|
234
|
-
|
|
326
|
+
role,
|
|
327
|
+
// What the page keys this tab's unsaved draft on: same plan text, same draft.
|
|
328
|
+
rev: planDigest(current),
|
|
329
|
+
...(caller.name === undefined ? {} : { author: caller.name }),
|
|
330
|
+
canApprove: role === "owner" && canApprove,
|
|
331
|
+
...(role === "owner" && options.note !== undefined ? { note: options.note } : {}),
|
|
332
|
+
share: role === "owner" ? shareSession.state() : undefined,
|
|
235
333
|
};
|
|
236
334
|
};
|
|
237
335
|
|
|
238
|
-
|
|
336
|
+
/**
|
|
337
|
+
* The owner's save decides the review; a reviewer's post only writes.
|
|
338
|
+
* Both replace the comments of the writer they carry — the owner's are
|
|
339
|
+
* untagged, a reviewer's are tagged with the name bound to that link — so
|
|
340
|
+
* neither touches another writer's markers, and both refuse a plan that
|
|
341
|
+
* changed since the page was rendered.
|
|
342
|
+
*/
|
|
343
|
+
const save = async (annotations: PlanAnnotation[], caller: ReviewCaller): Promise<SaveResult> => {
|
|
344
|
+
const { role } = caller;
|
|
239
345
|
const current = await store.read(cwd, plan.path);
|
|
240
346
|
if (!current) return { ok: false, status: 404, message: "The plan file is gone." };
|
|
241
347
|
if (stripFeedback(current.content) !== pageText) {
|
|
242
348
|
return { ok: false, status: 409, message: "The plan changed on disk; reload the page and review again." };
|
|
243
349
|
}
|
|
244
350
|
try {
|
|
245
|
-
|
|
246
|
-
|
|
351
|
+
if (role === "owner") {
|
|
352
|
+
savedPlan = await store.saveFeedback(cwd, plan.path, annotations);
|
|
353
|
+
streams.broadcast("comment");
|
|
354
|
+
return { ok: true };
|
|
355
|
+
}
|
|
356
|
+
// The link, not the payload, decides whose markers this post replaces: the name
|
|
357
|
+
// is bound to the token on its first write, and a page that signs another one
|
|
358
|
+
// is a stale tab rather than an authorized writer.
|
|
359
|
+
const claim = claimAuthor({ bound: caller.name, taken: tokens.namesExcept(caller) }, annotations);
|
|
360
|
+
if (!("name" in claim)) return { ok: false, status: claim.status, message: claim.message };
|
|
361
|
+
const own = annotations.filter((annotation) => normalizeAuthor(annotation.author) === claim.name);
|
|
362
|
+
// The saved plan is deliberately not recorded here: a post is not a result
|
|
363
|
+
// the session can return, so there is nothing for a later flush to resolve.
|
|
364
|
+
await store.saveFeedback(cwd, plan.path, own, { author: claim.name });
|
|
365
|
+
caller.name ??= claim.name;
|
|
366
|
+
streams.broadcast("comment");
|
|
367
|
+
if (own.length > 0) options.onReviewerComment?.(claim.name, own.length);
|
|
368
|
+
return { ok: true, author: claim.name };
|
|
247
369
|
} catch (error) {
|
|
248
370
|
return { ok: false, status: 500, message: error instanceof Error ? error.message : String(error) };
|
|
249
371
|
}
|
|
250
372
|
};
|
|
251
373
|
|
|
374
|
+
// The second listener, its reviewer token, and the state the page and
|
|
375
|
+
// `/plan share` read. It is handed the request handler, the role map, and the
|
|
376
|
+
// frame hooks, so sharing stays one concern instead of five fields here.
|
|
377
|
+
const shareSession = createShareSession({
|
|
378
|
+
handleRequest,
|
|
379
|
+
ownerPort: () => currentPort,
|
|
380
|
+
networkInterfaces: deps.networkInterfaces ?? networkInterfaces,
|
|
381
|
+
mintReviewer: () => tokens.mint("reviewer"),
|
|
382
|
+
revokeReviewer: (value) => tokens.revoke(value),
|
|
383
|
+
nameOf: (value) => tokens.get(value)?.name,
|
|
384
|
+
broadcast: (kind, only) => streams.broadcast(kind, only === undefined ? undefined : { role: only }),
|
|
385
|
+
endStreams: (kind, only) => streams.end(kind, only),
|
|
386
|
+
});
|
|
387
|
+
const hosts = (): readonly string[] => shareSession.hosts();
|
|
388
|
+
|
|
252
389
|
const context: ReviewContext = {
|
|
253
|
-
|
|
390
|
+
caller: (value) => tokens.get(value),
|
|
391
|
+
hosts,
|
|
254
392
|
canApprove,
|
|
255
393
|
page,
|
|
256
394
|
save,
|
|
395
|
+
share: () => shareSession.share(),
|
|
396
|
+
invite: () => shareSession.invite(),
|
|
397
|
+
revoke: (id) => shareSession.revoke(id),
|
|
398
|
+
stopShare: () => shareSession.stop(),
|
|
257
399
|
onSaved: (annotations) => {
|
|
258
400
|
pendingResult = savedPlan ? { status: "saved", plan: savedPlan, annotations } : { status: "cancelled" };
|
|
259
401
|
},
|
|
@@ -265,21 +407,12 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
|
|
|
265
407
|
},
|
|
266
408
|
};
|
|
267
409
|
|
|
268
|
-
// Live event streams, used only to notice a closed tab
|
|
269
|
-
//
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
const scheduleDrop = (): void => {
|
|
275
|
-
if (clients.size > 0 || !hadClient || settled || cancelDrop) return;
|
|
276
|
-
cancelDrop = schedule(dropGrace, () => {
|
|
277
|
-
cancelDrop = undefined;
|
|
278
|
-
if (clients.size === 0) finish({ status: "cancelled" });
|
|
279
|
-
});
|
|
280
|
-
};
|
|
281
|
-
|
|
282
|
-
const server = createServer((request, response) => {
|
|
410
|
+
// Live event streams, used only to notice a closed owner tab: a reviewer's
|
|
411
|
+
// tab is not the review's lifetime. The explicit Cancel button and the abort
|
|
412
|
+
// signal are the deterministic paths.
|
|
413
|
+
const server = createServer(handleRequest);
|
|
414
|
+
/** One request, on either listener: the loopback page or a shared one. */
|
|
415
|
+
function handleRequest(request: IncomingMessage, response: ServerResponse): void {
|
|
283
416
|
void (async () => {
|
|
284
417
|
const method = request.method ?? "GET";
|
|
285
418
|
const read =
|
|
@@ -302,13 +435,12 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
|
|
|
302
435
|
}
|
|
303
436
|
if (result.stream) {
|
|
304
437
|
openEventStream(response, result);
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
});
|
|
438
|
+
// A page that walks away mid-frame must not take the session down.
|
|
439
|
+
response.on("error", () => {});
|
|
440
|
+
const token = parseRequestUrl(request.url ?? "/")?.search.get("token") ?? "";
|
|
441
|
+
const streamCaller = context.caller(token);
|
|
442
|
+
if (streamCaller !== undefined) streams.add(response, { role: streamCaller.role, token });
|
|
443
|
+
request.on("close", () => streams.remove(response));
|
|
312
444
|
return;
|
|
313
445
|
}
|
|
314
446
|
// A save or cancel only resolves the session after its response is on the
|
|
@@ -324,9 +456,9 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
|
|
|
324
456
|
respond(request, response, result, settlePending);
|
|
325
457
|
response.on("close", settlePending);
|
|
326
458
|
})();
|
|
327
|
-
}
|
|
459
|
+
}
|
|
328
460
|
|
|
329
|
-
// The accept-level `error` listener lives in `
|
|
461
|
+
// The accept-level `error` listener lives in `bindHost`, so a transient
|
|
330
462
|
// failure cannot take the host down; the review still ends through its own
|
|
331
463
|
// paths: cancel, abort, drop, or shutdown.
|
|
332
464
|
const preferred = deps.port ?? ports.get(plan.path) ?? 0;
|
|
@@ -348,6 +480,7 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
|
|
|
348
480
|
return undefined;
|
|
349
481
|
}
|
|
350
482
|
ports.set(plan.path, port);
|
|
483
|
+
currentPort = port;
|
|
351
484
|
const closeServer = createCloser(server);
|
|
352
485
|
|
|
353
486
|
const url = `http://127.0.0.1:${port}/?token=${token}`;
|
|
@@ -358,14 +491,19 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
|
|
|
358
491
|
closed = true;
|
|
359
492
|
if (signal && abortHandler) signal.removeEventListener("abort", abortHandler);
|
|
360
493
|
sessions.delete(session);
|
|
361
|
-
|
|
362
|
-
|
|
494
|
+
shares.delete(session);
|
|
495
|
+
// `finish` ends every open stream with its last frame, so it runs before
|
|
496
|
+
// the set is cleared rather than after it.
|
|
363
497
|
finish({ status: "cancelled" });
|
|
498
|
+
stopTicker();
|
|
499
|
+
streams.clear();
|
|
500
|
+
await shareSession.close();
|
|
364
501
|
await closeServer();
|
|
365
502
|
};
|
|
366
503
|
|
|
367
504
|
const session: PlanReviewSession = { url, port, done, close };
|
|
368
505
|
sessions.add(session);
|
|
506
|
+
shares.set(session, shareSession);
|
|
369
507
|
|
|
370
508
|
if (signal) {
|
|
371
509
|
abortHandler = (): void => void close();
|
|
@@ -377,8 +515,18 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
|
|
|
377
515
|
return session;
|
|
378
516
|
};
|
|
379
517
|
|
|
518
|
+
const current = (): PlanReviewSession | undefined => [...sessions].at(-1);
|
|
519
|
+
const controls = () => {
|
|
520
|
+
const session = current();
|
|
521
|
+
return session ? shares.get(session) : undefined;
|
|
522
|
+
};
|
|
523
|
+
|
|
380
524
|
return {
|
|
381
525
|
start,
|
|
526
|
+
current,
|
|
527
|
+
share: async () => controls()?.share(),
|
|
528
|
+
stopSharing: async () => controls()?.stop(),
|
|
529
|
+
shareState: () => controls()?.state(),
|
|
382
530
|
closeAll: async () => {
|
|
383
531
|
await Promise.all([...sessions].map((session) => session.close()));
|
|
384
532
|
},
|
|
@@ -32,6 +32,9 @@ on.
|
|
|
32
32
|
`Other` row `Space` opens the editor, like `Enter`.
|
|
33
33
|
- `Esc` cancels; the model receives “User cancelled the question.” and can
|
|
34
34
|
continue or ask in prose.
|
|
35
|
+
- On a dialog-only host (RPC/WeChat), one question uses the normal select/input.
|
|
36
|
+
Two or more become one batch editor prompt and one reply (`1: 2; 2: A, B`),
|
|
37
|
+
with one retry if the format is unclear; the TUI keeps its tabbed screen.
|
|
35
38
|
|
|
36
39
|
## Tool
|
|
37
40
|
|