@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.
Files changed (112) hide show
  1. package/README.md +17 -9
  2. package/README.zh-CN.md +16 -9
  3. package/extensions/bytefly/README.md +25 -18
  4. package/extensions/bytefly/notify/types.ts +1 -1
  5. package/extensions/bytefly/panel/settings.ts +25 -18
  6. package/extensions/file-browse/README.md +60 -21
  7. package/extensions/file-browse/chrome.ts +83 -0
  8. package/extensions/file-browse/client.ts +62 -0
  9. package/extensions/file-browse/config.ts +12 -3
  10. package/extensions/file-browse/diff-html.ts +94 -0
  11. package/extensions/file-browse/html.ts +19 -147
  12. package/extensions/file-browse/live-client.ts +47 -1
  13. package/extensions/file-browse/live.ts +35 -14
  14. package/extensions/file-browse/rev.ts +42 -0
  15. package/extensions/file-browse/router.ts +25 -4
  16. package/extensions/file-browse/server.ts +7 -0
  17. package/extensions/file-browse/styles.ts +2 -0
  18. package/extensions/goal/plan-route.ts +8 -2
  19. package/extensions/job/README.md +7 -5
  20. package/extensions/job/index.ts +15 -7
  21. package/extensions/memory/README.md +3 -3
  22. package/extensions/plan/README.md +280 -86
  23. package/extensions/plan/commands.ts +56 -5
  24. package/extensions/plan/feedback.ts +300 -63
  25. package/extensions/plan/index.ts +1 -1
  26. package/extensions/plan/plan-index.ts +1 -1
  27. package/extensions/plan/plan-text.ts +18 -1
  28. package/extensions/plan/plans.ts +165 -57
  29. package/extensions/plan/review-prompt.ts +93 -0
  30. package/extensions/plan/review.ts +78 -98
  31. package/extensions/plan/runtime.ts +1 -1
  32. package/extensions/plan/split.ts +87 -0
  33. package/extensions/plan/steps.ts +80 -93
  34. package/extensions/plan/tools/enter.ts +8 -3
  35. package/extensions/plan/tools/exit.ts +101 -23
  36. package/extensions/plan/tools/write.ts +8 -3
  37. package/extensions/plan/tools.ts +1 -1
  38. package/extensions/plan/tui.ts +51 -8
  39. package/extensions/plan/types.ts +37 -3
  40. package/extensions/plan/web-chrome.ts +2 -1
  41. package/extensions/plan/web-client.ts +121 -177
  42. package/extensions/plan/web-collab.ts +207 -0
  43. package/extensions/plan/web-comments.ts +269 -0
  44. package/extensions/plan/web-page.ts +103 -17
  45. package/extensions/plan/web-router.ts +146 -30
  46. package/extensions/plan/web-share-session.ts +197 -0
  47. package/extensions/plan/web-share.ts +169 -0
  48. package/extensions/plan/web-streams.ts +124 -0
  49. package/extensions/plan/web-tokens.ts +63 -0
  50. package/extensions/plan/web.ts +193 -45
  51. package/extensions/question/README.md +3 -0
  52. package/extensions/question/dialogs.ts +91 -1
  53. package/extensions/question/tools.ts +2 -1
  54. package/extensions/session-bus/README.md +247 -0
  55. package/extensions/session-bus/commands.ts +165 -0
  56. package/extensions/session-bus/format.ts +124 -0
  57. package/extensions/session-bus/index.ts +69 -0
  58. package/extensions/session-bus/notice.ts +58 -0
  59. package/extensions/session-bus/protocol.ts +134 -0
  60. package/extensions/session-bus/result.ts +243 -0
  61. package/extensions/session-bus/rows.ts +169 -0
  62. package/extensions/session-bus/runtime.ts +575 -0
  63. package/extensions/session-bus/store.ts +329 -0
  64. package/extensions/session-bus/team.ts +102 -0
  65. package/extensions/session-bus/tools.ts +227 -0
  66. package/extensions/session-bus/tui.ts +394 -0
  67. package/extensions/session-bus/types.ts +98 -0
  68. package/extensions/subagent/README.md +1 -1
  69. package/extensions/watch/README.md +9 -5
  70. package/extensions/watch/index.ts +15 -9
  71. package/extensions/wechat/README.md +20 -33
  72. package/extensions/wechat/bridge.ts +8 -8
  73. package/extensions/wechat/commands.ts +2 -2
  74. package/extensions/wechat/deliver.ts +57 -37
  75. package/extensions/wechat/interaction.ts +31 -16
  76. package/extensions/wechat/notices.ts +7 -7
  77. package/extensions/wechat/peer-worker.ts +19 -34
  78. package/extensions/wechat/presence.ts +3 -4
  79. package/extensions/wechat/prompt.ts +39 -24
  80. package/extensions/wechat/reply-pipeline.ts +12 -4
  81. package/extensions/worktree/git.ts +5 -0
  82. package/extensions/worktree/registry.ts +11 -5
  83. package/extensions/worktree/status.ts +1 -3
  84. package/lib/README.md +6 -2
  85. package/lib/claims/overlap.ts +43 -0
  86. package/lib/http/router.ts +40 -13
  87. package/lib/http/server.ts +27 -13
  88. package/lib/interaction.ts +10 -0
  89. package/lib/notice.ts +29 -2
  90. package/lib/plan-entry.ts +7 -2
  91. package/lib/plans/sections.ts +172 -0
  92. package/lib/plans/setting.ts +52 -0
  93. package/lib/plans/split.ts +215 -0
  94. package/lib/plans/store.ts +19 -0
  95. package/lib/plans/team.ts +151 -0
  96. package/lib/qr.ts +64 -0
  97. package/lib/repo-key.ts +74 -0
  98. package/lib/report.ts +52 -33
  99. package/lib/requirements.ts +1 -2
  100. package/lib/tui/README.md +16 -2
  101. package/lib/tui/dock-screen.ts +85 -17
  102. package/lib/tui/line-pane.ts +15 -4
  103. package/lib/tui/tool.ts +23 -4
  104. package/lib/web/components.ts +1 -1
  105. package/package.json +3 -2
  106. package/skills/init/SKILL.md +80 -45
  107. package/skills/init/references/agents-md-quality.md +26 -2
  108. package/skills/init/references/interview-questions.md +36 -14
  109. package/skills/next/SKILL.md +78 -53
  110. package/skills/parallel-dev/SKILL.md +150 -0
  111. package/extensions/plan/config.ts +0 -27
  112. 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
+ }
@@ -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
- const token = (deps.token ?? (() => randomBytes(16).toString("hex")))();
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
- let cancelDrop: (() => void) | undefined;
158
- const clearDrop = (): void => {
159
- cancelDrop?.();
160
- cancelDrop = undefined;
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
- clearDrop();
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
- pageText = stripFeedback(current.content);
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
- const page = async (): Promise<ReviewPageData> => {
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
- canApprove,
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
- const save = async (annotations: PlanAnnotation[]): Promise<SaveResult> => {
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
- savedPlan = await store.saveFeedback(cwd, plan.path, annotations);
246
- return { ok: true };
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
- token,
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. The explicit Cancel
269
- // button and the abort signal are the deterministic paths.
270
- const clients = new Set<ServerResponse>();
271
- let hadClient = false;
272
- const dropGrace = deps.dropGraceMs ?? DROP_GRACE_MS;
273
- const schedule = deps.schedule ?? defaultTimer;
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
- hadClient = true;
306
- clients.add(response);
307
- clearDrop();
308
- request.on("close", () => {
309
- clients.delete(response);
310
- scheduleDrop();
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 `bindLoopback`, so a transient
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
- for (const client of clients) client.end();
362
- clients.clear();
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