@gavin-hu/my-pi-agent 0.17.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 +17 -9
- package/README.zh-CN.md +16 -9
- package/extensions/bytefly/README.md +25 -18
- package/extensions/bytefly/notify/types.ts +1 -1
- package/extensions/bytefly/panel/settings.ts +25 -18
- package/extensions/file-browse/README.md +60 -21
- package/extensions/file-browse/chrome.ts +83 -0
- package/extensions/file-browse/client.ts +62 -0
- package/extensions/file-browse/config.ts +12 -3
- package/extensions/file-browse/diff-html.ts +94 -0
- package/extensions/file-browse/html.ts +19 -147
- package/extensions/file-browse/live-client.ts +47 -1
- package/extensions/file-browse/live.ts +35 -14
- package/extensions/file-browse/rev.ts +42 -0
- package/extensions/file-browse/router.ts +25 -4
- package/extensions/file-browse/server.ts +7 -0
- package/extensions/file-browse/styles.ts +2 -0
- package/extensions/goal/plan-route.ts +8 -2
- package/extensions/job/README.md +7 -5
- package/extensions/job/index.ts +15 -7
- package/extensions/memory/README.md +3 -3
- package/extensions/plan/README.md +280 -86
- package/extensions/plan/commands.ts +56 -5
- package/extensions/plan/feedback.ts +300 -63
- package/extensions/plan/index.ts +1 -1
- package/extensions/plan/plan-index.ts +1 -1
- package/extensions/plan/plan-text.ts +18 -1
- package/extensions/plan/plans.ts +165 -57
- package/extensions/plan/review-prompt.ts +93 -0
- package/extensions/plan/review.ts +78 -98
- package/extensions/plan/runtime.ts +1 -1
- package/extensions/plan/split.ts +87 -0
- package/extensions/plan/steps.ts +80 -93
- package/extensions/plan/tools/enter.ts +8 -3
- package/extensions/plan/tools/exit.ts +101 -23
- package/extensions/plan/tools/write.ts +8 -3
- package/extensions/plan/tools.ts +1 -1
- package/extensions/plan/tui.ts +51 -8
- package/extensions/plan/types.ts +37 -3
- package/extensions/plan/web-chrome.ts +2 -1
- package/extensions/plan/web-client.ts +121 -177
- package/extensions/plan/web-collab.ts +207 -0
- package/extensions/plan/web-comments.ts +269 -0
- package/extensions/plan/web-page.ts +103 -17
- package/extensions/plan/web-router.ts +146 -30
- 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/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 +1 -1
- package/extensions/watch/README.md +9 -5
- package/extensions/watch/index.ts +15 -9
- package/extensions/wechat/README.md +20 -33
- package/extensions/wechat/bridge.ts +8 -8
- package/extensions/wechat/commands.ts +2 -2
- package/extensions/wechat/deliver.ts +57 -37
- package/extensions/wechat/interaction.ts +31 -16
- package/extensions/wechat/notices.ts +7 -7
- package/extensions/wechat/peer-worker.ts +19 -34
- package/extensions/wechat/presence.ts +3 -4
- package/extensions/wechat/prompt.ts +39 -24
- package/extensions/wechat/reply-pipeline.ts +12 -4
- package/extensions/worktree/git.ts +5 -0
- package/extensions/worktree/registry.ts +11 -5
- package/extensions/worktree/status.ts +1 -3
- package/lib/README.md +6 -2
- package/lib/claims/overlap.ts +43 -0
- package/lib/http/router.ts +40 -13
- package/lib/http/server.ts +27 -13
- package/lib/interaction.ts +10 -0
- 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 +52 -33
- package/lib/requirements.ts +1 -2
- package/lib/tui/README.md +16 -2
- package/lib/tui/dock-screen.ts +85 -17
- package/lib/tui/line-pane.ts +15 -4
- package/lib/tui/tool.ts +23 -4
- package/lib/web/components.ts +1 -1
- package/package.json +3 -2
- 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 +78 -53
- package/skills/parallel-dev/SKILL.md +150 -0
- package/extensions/plan/config.ts +0 -27
- package/extensions/wechat/qrcode.ts +0 -46
|
@@ -1,20 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The request router for browser plan review.
|
|
3
3
|
*
|
|
4
|
-
* A request is validated (method, `Host`,
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* notice when the browser tab is gone.
|
|
4
|
+
* A request is validated (method, `Host`, origin, token), matched to a route,
|
|
5
|
+
* and answered with a plain {@link ServeResponse}, so every route can be tested
|
|
6
|
+
* without a socket. The `/events` route is a marker: the node server in `web.ts`
|
|
7
|
+
* keeps that response open as a Server-Sent Events stream.
|
|
9
8
|
*
|
|
10
|
-
* The token is a per-session secret carried in the page URL
|
|
11
|
-
*
|
|
12
|
-
*
|
|
9
|
+
* The token is a per-session secret carried in the page URL, and on a shared
|
|
10
|
+
* review it is the only credential a reviewer has: it names the caller — the
|
|
11
|
+
* role it holds, and the name it posts under — and the router refuses an
|
|
12
|
+
* owner-only route for a reviewer however the request was built.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import {
|
|
16
16
|
eventHeaders,
|
|
17
|
-
|
|
17
|
+
hostHeaderName,
|
|
18
|
+
isAllowedHostHeader,
|
|
18
19
|
jsonError,
|
|
19
20
|
jsonHeaders,
|
|
20
21
|
jsonResponse,
|
|
@@ -27,25 +28,38 @@ import { THEME_JS, THEME_JS_PATH } from "../../lib/web/index.ts";
|
|
|
27
28
|
import { coerceAnnotations, type PlanAnnotation } from "./feedback.ts";
|
|
28
29
|
import { REVIEW_CLIENT_JS } from "./web-client.ts";
|
|
29
30
|
import { renderReviewPage, type ReviewPageData } from "./web-page.ts";
|
|
31
|
+
import type { ReviewCaller, ReviewRole } from "./types.ts";
|
|
32
|
+
import type { ShareResult } from "./web-share.ts";
|
|
30
33
|
|
|
31
|
-
/** What a save callback reports back.
|
|
32
|
-
|
|
34
|
+
/** What a save callback reports back. A reviewer's post reports the name it was
|
|
35
|
+
* stored under, so a page created before the first write can adopt it. */
|
|
36
|
+
export type SaveResult = { ok: true; author?: string } | { ok: false; status: number; message: string };
|
|
33
37
|
|
|
34
38
|
/** Everything the router needs from the running session. */
|
|
35
39
|
export interface ReviewContext {
|
|
36
|
-
/**
|
|
37
|
-
token: string;
|
|
40
|
+
/** The caller a token names, or undefined when it is not one of this session's. */
|
|
41
|
+
caller: (token: string) => ReviewCaller | undefined;
|
|
42
|
+
/** The host names this server answers to now, so a rebound page is refused. */
|
|
43
|
+
hosts: () => readonly string[];
|
|
38
44
|
/** Whether the browser may approve the plan (only the `exit_plan_mode` flow). */
|
|
39
45
|
canApprove: boolean;
|
|
40
|
-
/** Build the page data (recomputed per request
|
|
41
|
-
page: () => Promise<ReviewPageData>;
|
|
42
|
-
/** Persist the annotations and report the outcome. */
|
|
43
|
-
save: (annotations: PlanAnnotation[]) => Promise<SaveResult>;
|
|
44
|
-
/**
|
|
46
|
+
/** Build the page data for the caller (recomputed per request). */
|
|
47
|
+
page: (caller: ReviewCaller) => Promise<ReviewPageData>;
|
|
48
|
+
/** Persist the annotations as the caller and report the outcome. */
|
|
49
|
+
save: (annotations: PlanAnnotation[], caller: ReviewCaller) => Promise<SaveResult>;
|
|
50
|
+
/** Start sharing the review, or report why it cannot be shared. */
|
|
51
|
+
share: () => Promise<ShareResult>;
|
|
52
|
+
/** Mint one more reviewer link, so each teammate holds their own. */
|
|
53
|
+
invite: () => Promise<ShareResult>;
|
|
54
|
+
/** Revoke one link by id; undefined when this review does not hold it. */
|
|
55
|
+
revoke: (id: string) => Promise<ShareResult | undefined>;
|
|
56
|
+
/** Revoke reviewer tokens and stop accepting reviewers. */
|
|
57
|
+
stopShare: () => Promise<ShareResult>;
|
|
58
|
+
/** Called after the owner's save; the server resolves the session once the response has flushed. */
|
|
45
59
|
onSaved: (annotations: PlanAnnotation[]) => void;
|
|
46
60
|
/** Called after a successful approve; the server resolves the session once the response has flushed. */
|
|
47
61
|
onApprove: (annotations: PlanAnnotation[]) => void;
|
|
48
|
-
/** Called when the
|
|
62
|
+
/** Called when the owner cancels; the server resolves the session once the response has flushed. */
|
|
49
63
|
onCancel: () => void;
|
|
50
64
|
}
|
|
51
65
|
|
|
@@ -70,8 +84,36 @@ function isResponse(value: PlanAnnotation[] | ServeResponse): value is ServeResp
|
|
|
70
84
|
return !Array.isArray(value);
|
|
71
85
|
}
|
|
72
86
|
|
|
87
|
+
/** The 405 for a route that only accepts POST, or nothing when the method is right. */
|
|
88
|
+
function postOnly(method: string, what: string): ServeResponse | undefined {
|
|
89
|
+
if (method.toUpperCase() === "POST") return undefined;
|
|
90
|
+
return jsonError(405, `Use POST to ${what}.`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The 403 for a route the caller's role may not use, or nothing when it may. Each
|
|
95
|
+
* route keeps its own sentence, because the reason differs: a reviewer posts
|
|
96
|
+
* comments, and only the owner decides.
|
|
97
|
+
*/
|
|
98
|
+
function roleOnly(caller: ReviewCaller, roles: readonly ReviewRole[], message: string): ServeResponse | undefined {
|
|
99
|
+
return roles.includes(caller.role) ? undefined : jsonError(403, message);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The link id a revoke request names, or undefined when the body has none. */
|
|
103
|
+
function linkIdOf(body: string): string | undefined {
|
|
104
|
+
let parsed: unknown;
|
|
105
|
+
try {
|
|
106
|
+
parsed = JSON.parse(body);
|
|
107
|
+
} catch {
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
const id = (parsed as { id?: unknown } | null)?.id;
|
|
111
|
+
return typeof id === "string" && id !== "" ? id : undefined;
|
|
112
|
+
}
|
|
113
|
+
|
|
73
114
|
/** Validate method and `Host`; returns an error response to send instead. */
|
|
74
115
|
function checkEnvelope(
|
|
116
|
+
ctx: ReviewContext,
|
|
75
117
|
method: string,
|
|
76
118
|
headers: Record<string, string | string[] | undefined>,
|
|
77
119
|
): ServeResponse | undefined {
|
|
@@ -84,9 +126,35 @@ function checkEnvelope(
|
|
|
84
126
|
}
|
|
85
127
|
// Fail closed: a missing or non-string Host is rejected too, not just a
|
|
86
128
|
// foreign one, so the DNS-rebinding guard cannot be skipped.
|
|
87
|
-
if (!
|
|
129
|
+
if (!isAllowedHostHeader(headers, { hosts: ctx.hosts(), required: true })) {
|
|
88
130
|
return jsonError(403, "Unexpected Host header.");
|
|
89
131
|
}
|
|
132
|
+
return upper === "POST" ? checkOrigin(headers) : undefined;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Refuse a state-changing request another site made. A browser sends `Origin`
|
|
137
|
+
* on every POST and `Sec-Fetch-Site` with its own classification; a request
|
|
138
|
+
* with neither is a script or a test, which has no ambient credential to abuse.
|
|
139
|
+
*/
|
|
140
|
+
function checkOrigin(headers: Record<string, string | string[] | undefined>): ServeResponse | undefined {
|
|
141
|
+
const site = headers["sec-fetch-site"];
|
|
142
|
+
if (typeof site === "string" && site !== "" && site !== "same-origin" && site !== "none") {
|
|
143
|
+
return jsonError(403, "Cross-origin request refused.");
|
|
144
|
+
}
|
|
145
|
+
const origin = headers.origin;
|
|
146
|
+
if (origin === undefined) return undefined;
|
|
147
|
+
if (typeof origin !== "string" || origin === "") return jsonError(403, "Unexpected Origin header.");
|
|
148
|
+
let parsed: URL;
|
|
149
|
+
try {
|
|
150
|
+
parsed = new URL(origin);
|
|
151
|
+
} catch {
|
|
152
|
+
return jsonError(403, "Unexpected Origin header.");
|
|
153
|
+
}
|
|
154
|
+
const host = headers.host;
|
|
155
|
+
if (typeof host !== "string" || hostHeaderName(parsed.host) !== hostHeaderName(host)) {
|
|
156
|
+
return jsonError(403, "Cross-origin request refused.");
|
|
157
|
+
}
|
|
90
158
|
return undefined;
|
|
91
159
|
}
|
|
92
160
|
|
|
@@ -101,7 +169,7 @@ export async function handleReviewRequest(
|
|
|
101
169
|
headers: Record<string, string | string[] | undefined> = {},
|
|
102
170
|
body = "",
|
|
103
171
|
): Promise<ServeResponse> {
|
|
104
|
-
const envelope = checkEnvelope(method, headers);
|
|
172
|
+
const envelope = checkEnvelope(ctx, method, headers);
|
|
105
173
|
if (envelope) return envelope;
|
|
106
174
|
|
|
107
175
|
const parsed = parseRequestUrl(rawUrl);
|
|
@@ -112,34 +180,82 @@ export async function handleReviewRequest(
|
|
|
112
180
|
if (pathname === THEME_JS_PATH) return { status: 200, headers: scriptHeaders(), body: THEME_JS };
|
|
113
181
|
if (pathname === "/client.js") return { status: 200, headers: scriptHeaders(), body: REVIEW_CLIENT_JS };
|
|
114
182
|
|
|
115
|
-
|
|
183
|
+
const token = search.get("token") ?? "";
|
|
184
|
+
const caller = ctx.caller(token);
|
|
185
|
+
if (caller === undefined) return jsonError(403, "Invalid or missing token.");
|
|
116
186
|
|
|
117
187
|
if (pathname === "/" || pathname === "/index.html") {
|
|
118
|
-
return { status: 200, headers: pageHeaders(), body: renderReviewPage(await ctx.page()) };
|
|
188
|
+
return { status: 200, headers: pageHeaders(), body: renderReviewPage(await ctx.page(caller)) };
|
|
119
189
|
}
|
|
120
190
|
if (pathname === "/events") return { status: 200, headers: eventHeaders(), stream: true };
|
|
121
191
|
if (pathname === "/api/cancel") {
|
|
122
|
-
|
|
192
|
+
// Ending the review is the owner's decision; a reviewer closes their tab.
|
|
193
|
+
const refusal =
|
|
194
|
+
postOnly(method, "cancel") ?? roleOnly(caller, ["owner"], "Only the plan owner can close this review.");
|
|
195
|
+
if (refusal) return refusal;
|
|
123
196
|
ctx.onCancel();
|
|
124
197
|
return jsonResponse(200, { ok: true });
|
|
125
198
|
}
|
|
199
|
+
if (pathname === "/api/share" || pathname === "/api/share/link" || pathname === "/api/share/stop") {
|
|
200
|
+
// A shared link must never be able to mint another one.
|
|
201
|
+
const refusal =
|
|
202
|
+
postOnly(method, "share or stop sharing") ??
|
|
203
|
+
roleOnly(caller, ["owner"], "Only the plan owner can share this review.");
|
|
204
|
+
if (refusal) return refusal;
|
|
205
|
+
const result =
|
|
206
|
+
pathname === "/api/share/stop"
|
|
207
|
+
? await ctx.stopShare()
|
|
208
|
+
: pathname === "/api/share/link"
|
|
209
|
+
? await ctx.invite()
|
|
210
|
+
: await ctx.share();
|
|
211
|
+
return jsonResponse(200, { ok: true, share: result.state, svg: result.svg });
|
|
212
|
+
}
|
|
213
|
+
if (pathname === "/api/share/revoke") {
|
|
214
|
+
const refusal =
|
|
215
|
+
postOnly(method, "revoke a link") ?? roleOnly(caller, ["owner"], "Only the plan owner can revoke a link.");
|
|
216
|
+
if (refusal) return refusal;
|
|
217
|
+
const id = linkIdOf(body);
|
|
218
|
+
if (id === undefined) return jsonError(400, "The request body must name the link's id.");
|
|
219
|
+
const result = await ctx.revoke(id);
|
|
220
|
+
if (!result) return jsonError(404, "That link is not shared.");
|
|
221
|
+
return jsonResponse(200, { ok: true, share: result.state, svg: result.svg });
|
|
222
|
+
}
|
|
223
|
+
if (pathname === "/api/comment") {
|
|
224
|
+
// A reviewer's whole surface: persist the comments they wrote and end
|
|
225
|
+
// nothing. Only the owner's own save decides the review.
|
|
226
|
+
const refusal =
|
|
227
|
+
postOnly(method, "post comments") ?? roleOnly(caller, ["reviewer"], "Only a shared reviewer posts comments.");
|
|
228
|
+
if (refusal) return refusal;
|
|
229
|
+
const annotations = parseAnnotations(body);
|
|
230
|
+
if (isResponse(annotations)) return annotations;
|
|
231
|
+
const result = await ctx.save(annotations, caller);
|
|
232
|
+
if (!result.ok) return jsonError(result.status, result.message);
|
|
233
|
+
return jsonResponse(200, { ok: true, ...(result.author === undefined ? {} : { author: result.author }) });
|
|
234
|
+
}
|
|
126
235
|
if (pathname === "/api/feedback") {
|
|
127
|
-
|
|
236
|
+
// Returning to the model is the owner's decision; a reviewer posts to
|
|
237
|
+
// `/api/comment`, so this route can never settle a shared review.
|
|
238
|
+
const refusal =
|
|
239
|
+
postOnly(method, "save feedback") ??
|
|
240
|
+
roleOnly(caller, ["owner"], "Only the plan owner saves this review; a reviewer posts comments.");
|
|
241
|
+
if (refusal) return refusal;
|
|
128
242
|
const annotations = parseAnnotations(body);
|
|
129
243
|
if (isResponse(annotations)) return annotations;
|
|
130
|
-
const result = await ctx.save(annotations);
|
|
244
|
+
const result = await ctx.save(annotations, caller);
|
|
131
245
|
if (!result.ok) return jsonError(result.status, result.message);
|
|
132
246
|
ctx.onSaved(annotations);
|
|
133
247
|
return jsonResponse(200, { ok: true });
|
|
134
248
|
}
|
|
135
249
|
if (pathname === "/api/approve") {
|
|
136
|
-
|
|
137
|
-
//
|
|
138
|
-
|
|
250
|
+
// Only the owner's own page approves, and only the exit_plan_mode flow owns
|
|
251
|
+
// the side effects: a reviewer token is refused whatever it posts.
|
|
252
|
+
const refusal =
|
|
253
|
+
postOnly(method, "approve") ?? roleOnly(caller, ["owner"], "A shared review cannot approve the plan.");
|
|
254
|
+
if (refusal) return refusal;
|
|
139
255
|
if (!ctx.canApprove) return jsonError(403, "This review cannot be approved from the browser.");
|
|
140
256
|
const annotations = parseAnnotations(body);
|
|
141
257
|
if (isResponse(annotations)) return annotations;
|
|
142
|
-
const result = await ctx.save(annotations);
|
|
258
|
+
const result = await ctx.save(annotations, caller);
|
|
143
259
|
if (!result.ok) return jsonError(result.status, result.message);
|
|
144
260
|
ctx.onApprove(annotations);
|
|
145
261
|
return jsonResponse(200, { ok: true });
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared review's second listener: one reviewer token, one address on this
|
|
3
|
+
* network, and the state the owner's page and `/plan share` read.
|
|
4
|
+
*
|
|
5
|
+
* Separate from `web.ts` because the review session's job is its own lifecycle —
|
|
6
|
+
* open, settle, close — while this module owns the socket that exists only while
|
|
7
|
+
* the owner has sharing on. It is handed the one request handler, the token
|
|
8
|
+
* hooks, and the frame hooks, so it never reaches into the session's maps.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
|
12
|
+
import { bindHost, createCloser, LOOPBACK_HOSTS } from "../../lib/http/index.ts";
|
|
13
|
+
import { qrSvg } from "../../lib/qr.ts";
|
|
14
|
+
import type { ReviewFrame, ReviewRole } from "./types.ts";
|
|
15
|
+
import {
|
|
16
|
+
AT_CAP_REASON,
|
|
17
|
+
createLinkTable,
|
|
18
|
+
lanAddresses,
|
|
19
|
+
NO_SHARE_REASON,
|
|
20
|
+
shareUrl,
|
|
21
|
+
type LinkTable,
|
|
22
|
+
type NetworkInterfaces,
|
|
23
|
+
type ShareResult,
|
|
24
|
+
type ShareState,
|
|
25
|
+
} from "./web-share.ts";
|
|
26
|
+
|
|
27
|
+
/** What the share listener needs from the review session it belongs to. */
|
|
28
|
+
export interface ShareSessionDeps {
|
|
29
|
+
/** The one request handler: a loopback page and a shared page are the same page. */
|
|
30
|
+
handleRequest: (request: IncomingMessage, response: ServerResponse) => void;
|
|
31
|
+
/** The owner's bound port, reused for the link when it is free. */
|
|
32
|
+
ownerPort: () => number;
|
|
33
|
+
/** The machine's interfaces, or a snapshot a test injected. */
|
|
34
|
+
networkInterfaces: () => NetworkInterfaces;
|
|
35
|
+
/** Add a reviewer token to the session's role map. */
|
|
36
|
+
mintReviewer: () => string;
|
|
37
|
+
/** Remove a reviewer token from the session's role map. */
|
|
38
|
+
revokeReviewer: (token: string) => void;
|
|
39
|
+
/** The name a reviewer token has bound, or undefined before its first post. */
|
|
40
|
+
nameOf: (token: string) => string | undefined;
|
|
41
|
+
/** Tell the open pages something changed, optionally only one role or link. */
|
|
42
|
+
broadcast: (kind: ReviewFrame, only?: ReviewRole) => void;
|
|
43
|
+
/** End streams with a last frame: every reviewer, or one link's. */
|
|
44
|
+
endStreams: (kind: ReviewFrame, only?: { role?: ReviewRole; token?: string }) => void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The share surface one running review exposes. */
|
|
48
|
+
export interface ShareSession {
|
|
49
|
+
/** Start sharing with the first link, or report why it cannot; idempotent while on. */
|
|
50
|
+
share(): Promise<ShareResult>;
|
|
51
|
+
/** Mint one more link, so each teammate holds their own. */
|
|
52
|
+
invite(): Promise<ShareResult>;
|
|
53
|
+
/** Revoke one link, or undefined when this review does not hold that id. */
|
|
54
|
+
revoke(id: string): Promise<ShareResult | undefined>;
|
|
55
|
+
/** Revoke every link, tell the reviewers, and close the listener. */
|
|
56
|
+
stop(): Promise<ShareResult>;
|
|
57
|
+
/** The current state, without changing it. */
|
|
58
|
+
state(): ShareState;
|
|
59
|
+
/** Host names the server answers to: loopback plus the shared address. */
|
|
60
|
+
hosts(): readonly string[];
|
|
61
|
+
/** Close the listener as the review ends, without reading it as a decision. */
|
|
62
|
+
close(): Promise<void>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function createShareSession(deps: ShareSessionDeps): ShareSession {
|
|
66
|
+
let address = "";
|
|
67
|
+
let port = 0;
|
|
68
|
+
let server: Server | undefined;
|
|
69
|
+
let closeServer: (() => Promise<void>) | undefined;
|
|
70
|
+
const links: LinkTable = createLinkTable({
|
|
71
|
+
mint: deps.mintReviewer,
|
|
72
|
+
revoke: deps.revokeReviewer,
|
|
73
|
+
nameOf: deps.nameOf,
|
|
74
|
+
});
|
|
75
|
+
let reason: string | undefined;
|
|
76
|
+
const hosts = (): readonly string[] => (address ? [...LOOPBACK_HOSTS, address] : [...LOOPBACK_HOSTS]);
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The share surface as it is now, read rather than cached: a link's bound name
|
|
80
|
+
* changes when its reviewer posts, and the owner's page has to see that without
|
|
81
|
+
* another share action.
|
|
82
|
+
*/
|
|
83
|
+
const snapshot = (): ShareState => {
|
|
84
|
+
const base: ShareState = server === undefined ? { sharing: false, port: 0, links: [] } : links.state(port, address);
|
|
85
|
+
return reason === undefined ? base : { ...base, reason };
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/** Bind the shared listener on `chosen`, reusing the owner's port when it is free. */
|
|
89
|
+
const bind = async (chosen: string): Promise<void> => {
|
|
90
|
+
const candidate = createServer(deps.handleRequest);
|
|
91
|
+
let bound: number | undefined;
|
|
92
|
+
for (const attempt of [deps.ownerPort(), 0]) {
|
|
93
|
+
try {
|
|
94
|
+
bound = await bindHost(candidate, chosen, attempt);
|
|
95
|
+
break;
|
|
96
|
+
} catch {
|
|
97
|
+
bound = undefined;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (bound === undefined) {
|
|
101
|
+
candidate.close();
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
server = candidate;
|
|
105
|
+
closeServer = createCloser(candidate);
|
|
106
|
+
address = chosen;
|
|
107
|
+
port = bound;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Bind the one shared listener the first time a link needs it, or report why
|
|
112
|
+
* there is nothing to bind to. Every link answers on this same listener, so this
|
|
113
|
+
* runs once per share and the rest is bookkeeping.
|
|
114
|
+
*/
|
|
115
|
+
const ensureListener = async (): Promise<boolean> => {
|
|
116
|
+
if (server !== undefined) return true;
|
|
117
|
+
const chosen = lanAddresses(deps.networkInterfaces())[0];
|
|
118
|
+
if (chosen === undefined) {
|
|
119
|
+
reason = NO_SHARE_REASON;
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
await bind(chosen);
|
|
123
|
+
if (server === undefined) {
|
|
124
|
+
reason = NO_SHARE_REASON;
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
reason = undefined;
|
|
128
|
+
return true;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/** Announce the change and hand back the state, plus the QR of a link just minted. */
|
|
132
|
+
const settle = async (minted?: string): Promise<ShareResult> => {
|
|
133
|
+
deps.broadcast("share", "owner");
|
|
134
|
+
const state = snapshot();
|
|
135
|
+
if (minted === undefined) return { state };
|
|
136
|
+
return { state, svg: await qrSvg(shareUrl(address, port, minted)) };
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const share = async (): Promise<ShareResult> => {
|
|
140
|
+
reason = undefined;
|
|
141
|
+
if (!(await ensureListener())) return { state: snapshot() };
|
|
142
|
+
// One link per share, so a second click re-reads the link it already minted
|
|
143
|
+
// rather than making another one the owner has to revoke.
|
|
144
|
+
const added = links.list().length === 0 ? links.add() : undefined;
|
|
145
|
+
return settle(added?.token ?? links.list().at(-1)?.token);
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const invite = async (): Promise<ShareResult> => {
|
|
149
|
+
reason = undefined;
|
|
150
|
+
if (!(await ensureListener())) return { state: snapshot() };
|
|
151
|
+
const added = links.add();
|
|
152
|
+
if (added === undefined) {
|
|
153
|
+
// At the cap: the page keeps rendering the links it holds and says why.
|
|
154
|
+
reason = AT_CAP_REASON;
|
|
155
|
+
return { state: snapshot() };
|
|
156
|
+
}
|
|
157
|
+
return settle(added.token);
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const revoke = async (id: string): Promise<ShareResult | undefined> => {
|
|
161
|
+
reason = undefined;
|
|
162
|
+
const link = links.find(id);
|
|
163
|
+
if (link === undefined) return undefined;
|
|
164
|
+
// The link is told before its token goes away, so its page reads the reason
|
|
165
|
+
// rather than falling back to "Offline".
|
|
166
|
+
deps.endStreams("revoked", { token: link.token });
|
|
167
|
+
links.remove(id);
|
|
168
|
+
// The last link leaving is the same thing as stopping: there is nothing left to
|
|
169
|
+
// answer on the shared listener.
|
|
170
|
+
if (links.list().length === 0) return stop();
|
|
171
|
+
return settle();
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const stop = async (): Promise<ShareResult> => {
|
|
175
|
+
for (const link of links.list()) links.remove(link.id);
|
|
176
|
+
// The reviewers are told before their listener goes away; the review itself
|
|
177
|
+
// stays open for its owner.
|
|
178
|
+
deps.endStreams("revoked", { role: "reviewer" });
|
|
179
|
+
const closer = closeServer;
|
|
180
|
+
server = undefined;
|
|
181
|
+
closeServer = undefined;
|
|
182
|
+
address = "";
|
|
183
|
+
port = 0;
|
|
184
|
+
reason = undefined;
|
|
185
|
+
if (closer) await closer();
|
|
186
|
+
return settle();
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const close = async (): Promise<void> => {
|
|
190
|
+
const closer = closeServer;
|
|
191
|
+
server = undefined;
|
|
192
|
+
closeServer = undefined;
|
|
193
|
+
if (closer) await closer();
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
return { share, invite, revoke, stop, state: snapshot, hosts, close };
|
|
197
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sharing a review with the people on this network (pure).
|
|
3
|
+
*
|
|
4
|
+
* A shared review needs one thing the running server cannot discover for
|
|
5
|
+
* itself: which address a teammate's browser should name. This module answers
|
|
6
|
+
* that from an injected `networkInterfaces()` snapshot — never by reading the
|
|
7
|
+
* machine's own NICs — and turns it into the links the owner is shown and the
|
|
8
|
+
* page renders.
|
|
9
|
+
*
|
|
10
|
+
* Only addresses another machine can plausibly reach are offered: IPv4, not
|
|
11
|
+
* internal, and not the link-local range a machine assigns itself when DHCP
|
|
12
|
+
* failed. Everything else would print a link that cannot work.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { NetworkInterfaceInfo } from "node:os";
|
|
16
|
+
|
|
17
|
+
/** The interfaces a review may be shared to, by name. */
|
|
18
|
+
export type NetworkInterfaces = Record<string, NetworkInterfaceInfo[] | undefined>;
|
|
19
|
+
|
|
20
|
+
/** One address a reviewer can open the review at. */
|
|
21
|
+
export interface ShareLink {
|
|
22
|
+
/** Stable for the life of this review and never reused, so a page can name one link. */
|
|
23
|
+
id: string;
|
|
24
|
+
/** The bare address, as it appears in the URL. */
|
|
25
|
+
address: string;
|
|
26
|
+
/** The full tokenized URL a reviewer opens. */
|
|
27
|
+
url: string;
|
|
28
|
+
/** The name this link posts under, once its first post has bound it. */
|
|
29
|
+
name?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** What the page and the terminal show about a running share. */
|
|
33
|
+
export interface ShareState {
|
|
34
|
+
/** True while reviewer tokens exist and an address is bound. */
|
|
35
|
+
sharing: boolean;
|
|
36
|
+
/** The port reviewers connect to. */
|
|
37
|
+
port: number;
|
|
38
|
+
/** Every shared link, oldest first. */
|
|
39
|
+
links: ShareLink[];
|
|
40
|
+
/** Why sharing is unavailable, when it is. */
|
|
41
|
+
reason?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** What the page gets back from a share or a stop: the state, plus a QR to render. */
|
|
45
|
+
export interface ShareResult {
|
|
46
|
+
state: ShareState;
|
|
47
|
+
/** Inline SVG for the first link; absent when nothing is shared. */
|
|
48
|
+
svg?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Why a machine cannot share a review. */
|
|
52
|
+
export const NO_SHARE_REASON =
|
|
53
|
+
"This machine has no network address to share on. Reviewers can still use a loopback link on this computer.";
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* IPv4 addresses another machine on this network can reach, deduplicated and
|
|
57
|
+
* sorted, so the same machine prints the same link every time.
|
|
58
|
+
*/
|
|
59
|
+
export function lanAddresses(interfaces: NetworkInterfaces): string[] {
|
|
60
|
+
const addresses = new Set<string>();
|
|
61
|
+
for (const entries of Object.values(interfaces)) {
|
|
62
|
+
for (const entry of entries ?? []) {
|
|
63
|
+
if (entry.family !== "IPv4" || entry.internal) continue;
|
|
64
|
+
if (entry.address === "0.0.0.0" || entry.address.startsWith("169.254.")) continue;
|
|
65
|
+
addresses.add(entry.address);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return [...addresses].sort(byAddress);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Numeric IPv4 comparison, so `…1.9` sorts before `…1.24`. */
|
|
72
|
+
function byAddress(a: string, b: string): number {
|
|
73
|
+
const left = a.split(".").map(Number);
|
|
74
|
+
const right = b.split(".").map(Number);
|
|
75
|
+
for (let index = 0; index < 4; index++) {
|
|
76
|
+
const difference = (left[index] ?? 0) - (right[index] ?? 0);
|
|
77
|
+
if (difference !== 0) return difference;
|
|
78
|
+
}
|
|
79
|
+
return 0;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The tokenized link for one address. */
|
|
83
|
+
export function shareUrl(address: string, port: number, token: string): string {
|
|
84
|
+
return `http://${address}:${port}/?token=${token}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* One link is one person.
|
|
89
|
+
*
|
|
90
|
+
* A shared review holds one reviewer token per link, and the token is the only
|
|
91
|
+
* credential a reviewer has: it names the role, and it binds the name the link
|
|
92
|
+
* posts under on its first write. Two people on one link are one writer, so the
|
|
93
|
+
* table exists to let the owner hand out as many as the team has members — and to
|
|
94
|
+
* take one back without touching the others.
|
|
95
|
+
*/
|
|
96
|
+
|
|
97
|
+
/** The most links one review may hold: each is a live bearer credential. */
|
|
98
|
+
export const MAX_SHARE_LINKS = 8;
|
|
99
|
+
|
|
100
|
+
/** Why another link cannot be minted: every link is a live credential. */
|
|
101
|
+
export const AT_CAP_REASON = `This review already holds ${MAX_SHARE_LINKS} links; revoke one before minting another.`;
|
|
102
|
+
|
|
103
|
+
/** One share the table holds: the id a page names it by, and its token. */
|
|
104
|
+
export interface SharedLink {
|
|
105
|
+
/** Stable for the life of the review, and never reused after a revoke. */
|
|
106
|
+
id: string;
|
|
107
|
+
/** The reviewer token this link carries. */
|
|
108
|
+
token: string;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** What the table needs from the review that owns the tokens. */
|
|
112
|
+
export interface LinkTableDeps {
|
|
113
|
+
/** Mint a reviewer token. */
|
|
114
|
+
mint: () => string;
|
|
115
|
+
/** Release a token, so the link that carried it stops answering. */
|
|
116
|
+
revoke: (token: string) => void;
|
|
117
|
+
/** The name a link's token has bound, so the owner can see who holds it. */
|
|
118
|
+
nameOf: (token: string) => string | undefined;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The reviewer links one running review has shared. */
|
|
122
|
+
export interface LinkTable {
|
|
123
|
+
/** Mint a link on a fresh token, or nothing when the review is at the cap. */
|
|
124
|
+
add(): SharedLink | undefined;
|
|
125
|
+
/** The link with this id, or nothing when the review does not hold it. */
|
|
126
|
+
find(id: string): SharedLink | undefined;
|
|
127
|
+
/** Release one link by id; false when the review does not hold it. */
|
|
128
|
+
remove(id: string): boolean;
|
|
129
|
+
/** The links currently shared, oldest first. */
|
|
130
|
+
list(): readonly SharedLink[];
|
|
131
|
+
/** The state the owner's page and the terminal read. */
|
|
132
|
+
state(port: number, address: string): ShareState;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function createLinkTable(deps: LinkTableDeps): LinkTable {
|
|
136
|
+
const links: SharedLink[] = [];
|
|
137
|
+
let minted = 0;
|
|
138
|
+
return {
|
|
139
|
+
add() {
|
|
140
|
+
if (links.length >= MAX_SHARE_LINKS) return undefined;
|
|
141
|
+
minted += 1;
|
|
142
|
+
const link: SharedLink = { id: `link-${minted}`, token: deps.mint() };
|
|
143
|
+
links.push(link);
|
|
144
|
+
return link;
|
|
145
|
+
},
|
|
146
|
+
find: (id) => links.find((link) => link.id === id),
|
|
147
|
+
remove(id) {
|
|
148
|
+
const index = links.findIndex((link) => link.id === id);
|
|
149
|
+
if (index < 0) return false;
|
|
150
|
+
const [removed] = links.splice(index, 1);
|
|
151
|
+
if (removed) deps.revoke(removed.token);
|
|
152
|
+
return true;
|
|
153
|
+
},
|
|
154
|
+
list: () => [...links],
|
|
155
|
+
state: (port, address) => ({
|
|
156
|
+
sharing: links.length > 0,
|
|
157
|
+
port,
|
|
158
|
+
links: links.map((link) => {
|
|
159
|
+
const name = deps.nameOf(link.token);
|
|
160
|
+
return {
|
|
161
|
+
id: link.id,
|
|
162
|
+
address,
|
|
163
|
+
url: shareUrl(address, port, link.token),
|
|
164
|
+
...(name === undefined ? {} : { name }),
|
|
165
|
+
};
|
|
166
|
+
}),
|
|
167
|
+
}),
|
|
168
|
+
};
|
|
169
|
+
}
|