@gavin-hu/my-pi-agent 0.17.0 → 0.19.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 (274) hide show
  1. package/README.md +61 -45
  2. package/README.zh-CN.md +54 -42
  3. package/docs/extensions/README.md +34 -0
  4. package/docs/extensions/board.md +344 -0
  5. package/{extensions/bytefly/README.md → docs/extensions/bytefly.md} +465 -416
  6. package/{extensions/check/README.md → docs/extensions/check.md} +83 -68
  7. package/{extensions/context/README.md → docs/extensions/context.md} +40 -23
  8. package/docs/extensions/file-browse.md +554 -0
  9. package/{extensions/file-open/README.md → docs/extensions/file-open.md} +31 -23
  10. package/{extensions/file-read/README.md → docs/extensions/file-read.md} +47 -55
  11. package/{extensions/goal/README.md → docs/extensions/goal.md} +115 -131
  12. package/{extensions/job/README.md → docs/extensions/job.md} +66 -53
  13. package/{extensions/memory/README.md → docs/extensions/memory.md} +74 -63
  14. package/docs/extensions/plan.md +976 -0
  15. package/{extensions/question/README.md → docs/extensions/question.md} +54 -28
  16. package/{extensions/rewind/README.md → docs/extensions/rewind.md} +115 -122
  17. package/docs/extensions/session-bus.md +285 -0
  18. package/{extensions/subagent/README.md → docs/extensions/subagent.md} +166 -119
  19. package/docs/extensions/team.md +226 -0
  20. package/{extensions/todo/README.md → docs/extensions/todo.md} +85 -88
  21. package/{extensions/watch/README.md → docs/extensions/watch.md} +60 -38
  22. package/{extensions/web-access/README.md → docs/extensions/web-access.md} +93 -50
  23. package/{extensions/wechat/README.md → docs/extensions/wechat.md} +275 -192
  24. package/{extensions/worktree/README.md → docs/extensions/worktree.md} +115 -72
  25. package/extensions/board/commands.ts +87 -0
  26. package/extensions/board/config.ts +36 -0
  27. package/extensions/board/format.ts +72 -0
  28. package/extensions/board/index.ts +40 -0
  29. package/extensions/board/input.ts +54 -0
  30. package/extensions/board/join.ts +42 -0
  31. package/extensions/board/page.ts +312 -0
  32. package/extensions/board/result.ts +99 -0
  33. package/extensions/board/rows.ts +48 -0
  34. package/extensions/board/schema.ts +75 -0
  35. package/extensions/board/seat.ts +29 -0
  36. package/extensions/board/snapshot.ts +41 -0
  37. package/extensions/board/tools.ts +279 -0
  38. package/extensions/board/transport/router.ts +172 -0
  39. package/extensions/board/transport/server.ts +149 -0
  40. package/extensions/board/types.ts +54 -0
  41. package/extensions/board/view/actions.ts +306 -0
  42. package/extensions/board/view/client.ts +41 -0
  43. package/extensions/board/view/keys.ts +121 -0
  44. package/extensions/board/view/live.ts +397 -0
  45. package/extensions/board/view/page.ts +158 -0
  46. package/extensions/board/view/render.ts +496 -0
  47. package/extensions/board/view/styles.ts +211 -0
  48. package/extensions/board/view/team.ts +305 -0
  49. package/extensions/board/writes.ts +99 -0
  50. package/extensions/bytefly/notify/types.ts +1 -1
  51. package/extensions/bytefly/panel/commands.ts +40 -7
  52. package/extensions/bytefly/panel/settings-runtime.ts +0 -6
  53. package/extensions/bytefly/panel/settings.ts +11 -21
  54. package/extensions/bytefly/panel/tui/settings-view.ts +1 -1
  55. package/extensions/bytefly/picker/discovery.ts +0 -2
  56. package/extensions/bytefly/picker/types.ts +0 -2
  57. package/extensions/bytefly/update/check.ts +39 -0
  58. package/extensions/bytefly/update/format.ts +15 -0
  59. package/extensions/bytefly/update/register.ts +73 -34
  60. package/extensions/bytefly/update/state.ts +6 -3
  61. package/extensions/context/tui.ts +1 -1
  62. package/extensions/file-browse/{config.ts → config/config.ts} +13 -4
  63. package/extensions/file-browse/{files.ts → files/files.ts} +1 -14
  64. package/extensions/file-browse/{paths.ts → files/paths.ts} +1 -1
  65. package/extensions/file-browse/{git.ts → git/git.ts} +10 -3
  66. package/extensions/file-browse/index.ts +4 -4
  67. package/extensions/file-browse/{live.ts → live/live.ts} +54 -82
  68. package/extensions/file-browse/live/rev.ts +42 -0
  69. package/extensions/file-browse/{router.ts → transport/router.ts} +44 -17
  70. package/extensions/file-browse/transport/server.ts +110 -0
  71. package/extensions/file-browse/view/chrome.ts +83 -0
  72. package/extensions/file-browse/{client.ts → view/client.ts} +63 -1
  73. package/extensions/file-browse/view/diff-html.ts +94 -0
  74. package/extensions/file-browse/{html.ts → view/html.ts} +32 -158
  75. package/extensions/file-browse/{icons.ts → view/icons.ts} +1 -1
  76. package/extensions/file-browse/{live-client.ts → view/live-client.ts} +47 -1
  77. package/extensions/file-browse/{markdown.ts → view/markdown.ts} +7 -7
  78. package/extensions/file-browse/{raw-html.ts → view/raw-html.ts} +1 -1
  79. package/extensions/file-browse/{styles.ts → view/styles.ts} +31 -5
  80. package/extensions/file-browse/{tree.ts → view/tree.ts} +1 -1
  81. package/extensions/file-browse/{urls.ts → view/urls.ts} +1 -1
  82. package/extensions/file-read/schema.ts +2 -1
  83. package/extensions/goal/plan-route.ts +8 -2
  84. package/extensions/job/index.ts +15 -7
  85. package/extensions/memory/store.ts +12 -5
  86. package/extensions/plan/blocks.ts +23 -2
  87. package/extensions/plan/commands.ts +77 -21
  88. package/extensions/plan/feedback.ts +409 -63
  89. package/extensions/plan/index.ts +7 -5
  90. package/extensions/plan/plan-index.ts +44 -10
  91. package/extensions/plan/plan-text.ts +51 -24
  92. package/extensions/plan/plans.ts +162 -57
  93. package/extensions/plan/policy.ts +20 -12
  94. package/extensions/plan/review-prompt.ts +93 -0
  95. package/extensions/plan/review.ts +51 -95
  96. package/extensions/plan/runtime.ts +22 -10
  97. package/extensions/plan/sections.ts +112 -0
  98. package/extensions/plan/steps.ts +63 -95
  99. package/extensions/plan/tools/enter.ts +8 -3
  100. package/extensions/plan/tools/exit.ts +13 -19
  101. package/extensions/plan/tools/write.ts +8 -3
  102. package/extensions/plan/tools.ts +1 -1
  103. package/extensions/plan/tui.ts +29 -15
  104. package/extensions/plan/types.ts +29 -3
  105. package/extensions/plan/web-chrome.ts +2 -1
  106. package/extensions/plan/web-client.ts +122 -178
  107. package/extensions/plan/web-collab.ts +207 -0
  108. package/extensions/plan/web-comments.ts +269 -0
  109. package/extensions/plan/web-page.ts +96 -17
  110. package/extensions/plan/web-router.ts +109 -47
  111. package/extensions/plan/web-share-session.ts +191 -0
  112. package/extensions/plan/web-share.ts +169 -0
  113. package/extensions/plan/web-streams.ts +124 -0
  114. package/extensions/plan/web-tokens.ts +63 -0
  115. package/extensions/plan/web.ts +245 -151
  116. package/extensions/question/dialogs.ts +91 -1
  117. package/extensions/question/tools.ts +2 -1
  118. package/extensions/rewind/{commands.ts → commands/commands.ts} +5 -5
  119. package/extensions/rewind/{review.ts → commands/review.ts} +13 -13
  120. package/extensions/rewind/{rewind.ts → commands/rewind.ts} +6 -6
  121. package/extensions/rewind/{config.ts → config/config.ts} +1 -1
  122. package/extensions/rewind/index.ts +3 -3
  123. package/extensions/rewind/{policy.ts → state/policy.ts} +4 -4
  124. package/extensions/rewind/{runtime.ts → state/runtime.ts} +11 -11
  125. package/extensions/rewind/{snapshot-run.ts → state/snapshot-run.ts} +1 -1
  126. package/extensions/rewind/{timeline.ts → state/timeline.ts} +2 -2
  127. package/extensions/rewind/{format.ts → text/format.ts} +3 -3
  128. package/extensions/rewind/{git.ts → transport/git.ts} +2 -2
  129. package/extensions/rewind/{restore.ts → transport/restore.ts} +2 -2
  130. package/extensions/rewind/{snapshot.ts → transport/snapshot.ts} +1 -1
  131. package/extensions/rewind/{store.ts → transport/store.ts} +1 -1
  132. package/extensions/rewind/{checklist.ts → view/checklist.ts} +6 -6
  133. package/extensions/rewind/{hunk-picker.ts → view/hunk-picker.ts} +8 -8
  134. package/extensions/rewind/{path-picker.ts → view/path-picker.ts} +7 -7
  135. package/extensions/rewind/{review-tui.ts → view/review-tui.ts} +11 -11
  136. package/extensions/rewind/{rewind-tui.ts → view/rewind-tui.ts} +17 -11
  137. package/extensions/rewind/{status.ts → view/status.ts} +4 -4
  138. package/extensions/session-bus/commands.ts +113 -0
  139. package/extensions/session-bus/format.ts +101 -0
  140. package/extensions/session-bus/index.ts +75 -0
  141. package/extensions/session-bus/join-request.ts +40 -0
  142. package/extensions/session-bus/mail-watch.ts +134 -0
  143. package/extensions/session-bus/notice.ts +86 -0
  144. package/extensions/session-bus/overlap.ts +36 -0
  145. package/extensions/session-bus/protocol.ts +104 -0
  146. package/extensions/session-bus/resolve.ts +67 -0
  147. package/extensions/session-bus/result.ts +208 -0
  148. package/extensions/session-bus/rows.ts +171 -0
  149. package/extensions/session-bus/runtime.ts +466 -0
  150. package/extensions/session-bus/store.ts +365 -0
  151. package/extensions/session-bus/tools.ts +192 -0
  152. package/extensions/session-bus/tui.ts +394 -0
  153. package/extensions/session-bus/types.ts +98 -0
  154. package/extensions/subagent/agents.ts +9 -8
  155. package/extensions/subagent/invocation.ts +4 -2
  156. package/extensions/team/commands.ts +112 -0
  157. package/extensions/team/dispatch.ts +133 -0
  158. package/extensions/team/format.ts +105 -0
  159. package/extensions/team/index.ts +33 -0
  160. package/extensions/team/schema.ts +29 -0
  161. package/extensions/team/tools.ts +246 -0
  162. package/extensions/team/types.ts +48 -0
  163. package/extensions/watch/index.ts +15 -9
  164. package/extensions/web-access/fetch/page.ts +4 -1
  165. package/extensions/web-access/fetch/structured.ts +447 -0
  166. package/extensions/web-access/fetch/tool.ts +2 -1
  167. package/extensions/web-access/search/tool.ts +2 -1
  168. package/extensions/wechat/bridge.ts +76 -92
  169. package/extensions/wechat/{commands.ts → commands/commands.ts} +26 -18
  170. package/extensions/wechat/{doctor.ts → commands/doctor.ts} +5 -5
  171. package/extensions/wechat/{history.ts → commands/history.ts} +1 -1
  172. package/extensions/wechat/{login.ts → commands/login.ts} +3 -3
  173. package/extensions/wechat/{slash.ts → commands/slash.ts} +38 -2
  174. package/extensions/wechat/dialog/channels.ts +136 -0
  175. package/extensions/wechat/{interaction.ts → dialog/interaction.ts} +66 -51
  176. package/extensions/wechat/{prompt.ts → dialog/prompt.ts} +40 -25
  177. package/extensions/wechat/{remote-ui.ts → dialog/remote-ui.ts} +1 -1
  178. package/extensions/wechat/index.ts +10 -10
  179. package/extensions/wechat/{admission.ts → peer/admission.ts} +2 -2
  180. package/extensions/wechat/{peer-session.ts → peer/peer-session.ts} +2 -2
  181. package/extensions/wechat/{peer-worker.ts → peer/peer-worker.ts} +119 -56
  182. package/extensions/wechat/{presence.ts → peer/presence.ts} +11 -11
  183. package/extensions/wechat/{reply-pipeline.ts → peer/reply-pipeline.ts} +13 -5
  184. package/extensions/wechat/{credentials.ts → state/credentials.ts} +2 -2
  185. package/extensions/wechat/{state.ts → state/state.ts} +14 -2
  186. package/extensions/wechat/{format.ts → text/format.ts} +3 -3
  187. package/extensions/wechat/{notices.ts → text/notices.ts} +15 -7
  188. package/extensions/wechat/tools.ts +29 -12
  189. package/extensions/wechat/{client.ts → transport/client.ts} +26 -2
  190. package/extensions/wechat/{deliver.ts → transport/deliver.ts} +104 -40
  191. package/extensions/wechat/{inbound.ts → transport/inbound.ts} +5 -5
  192. package/extensions/wechat/{media.ts → transport/media.ts} +11 -5
  193. package/extensions/wechat/transport/outbound.ts +142 -0
  194. package/extensions/wechat/{poll.ts → transport/poll.ts} +3 -3
  195. package/extensions/wechat/types.ts +10 -1
  196. package/extensions/wechat/{login-screen.ts → view/login-screen.ts} +4 -4
  197. package/extensions/wechat/{status-screen.ts → view/status-screen.ts} +31 -26
  198. package/extensions/worktree/git.ts +5 -0
  199. package/extensions/worktree/guard.ts +3 -5
  200. package/extensions/worktree/registry.ts +11 -5
  201. package/extensions/worktree/status.ts +1 -3
  202. package/lib/README.md +12 -6
  203. package/lib/board/card.ts +290 -0
  204. package/lib/board/links.ts +117 -0
  205. package/lib/board/page-pointer.ts +256 -0
  206. package/lib/board/round-text.ts +72 -0
  207. package/lib/board/round.ts +89 -0
  208. package/lib/board/rules.ts +304 -0
  209. package/lib/board/session.ts +105 -0
  210. package/lib/board/store.ts +310 -0
  211. package/lib/board/view.ts +150 -0
  212. package/lib/errors.ts +12 -4
  213. package/lib/format.ts +13 -0
  214. package/lib/http/guard.ts +112 -0
  215. package/lib/http/index.ts +16 -5
  216. package/lib/http/router.ts +40 -13
  217. package/lib/http/serve.ts +234 -0
  218. package/lib/http/server.ts +29 -13
  219. package/lib/http/sink.ts +110 -0
  220. package/lib/http/token.ts +38 -0
  221. package/lib/interaction.ts +10 -0
  222. package/lib/notice.ts +29 -2
  223. package/lib/plan-entry.ts +7 -2
  224. package/lib/policy.ts +12 -2
  225. package/lib/qr.ts +64 -0
  226. package/lib/repo-key.ts +74 -0
  227. package/lib/report.ts +52 -33
  228. package/lib/requirements.ts +7 -9
  229. package/lib/session-label.ts +64 -0
  230. package/lib/session-roster.ts +130 -0
  231. package/lib/session-tool.ts +184 -0
  232. package/lib/tool-names.ts +25 -2
  233. package/lib/tui/README.md +16 -2
  234. package/lib/tui/dock-screen.ts +85 -17
  235. package/lib/tui/glyphs.ts +2 -0
  236. package/lib/tui/line-pane.ts +15 -4
  237. package/lib/tui/tool.ts +23 -4
  238. package/lib/web/README.md +5 -2
  239. package/lib/web/components.ts +6 -1
  240. package/lib/web/runtime.ts +7 -1
  241. package/lib/web/tokens.ts +25 -2
  242. package/package.json +27 -14
  243. package/skills/init/SKILL.md +80 -45
  244. package/skills/init/references/agents-md-quality.md +26 -2
  245. package/skills/init/references/interview-questions.md +36 -14
  246. package/skills/next/SKILL.md +77 -51
  247. package/skills/project-docs/SKILL.md +133 -0
  248. package/skills/project-docs/references/audit.md +131 -0
  249. package/skills/project-docs/references/changelog.md +105 -0
  250. package/skills/project-docs/references/discovery.md +127 -0
  251. package/skills/project-docs/references/readme.md +80 -0
  252. package/skills/project-docs/references/release.md +84 -0
  253. package/skills/project-docs/references/roadmap.md +93 -0
  254. package/skills/project-docs/scripts/state.ts +316 -0
  255. package/skills/refactor/SKILL.md +100 -125
  256. package/skills/refactor/references/handoff.md +35 -0
  257. package/skills/refactor/references/signals.md +54 -0
  258. package/skills/refactor/scripts/inventory.ts +587 -0
  259. package/extensions/file-browse/README.md +0 -467
  260. package/extensions/file-browse/server.ts +0 -134
  261. package/extensions/plan/README.md +0 -709
  262. package/extensions/plan/config.ts +0 -27
  263. package/extensions/wechat/outbound.ts +0 -100
  264. package/extensions/wechat/qrcode.ts +0 -46
  265. package/skills/changelog/SKILL.md +0 -148
  266. /package/extensions/file-browse/{diff.ts → text/diff.ts} +0 -0
  267. /package/extensions/file-browse/{fuzzy.ts → text/fuzzy.ts} +0 -0
  268. /package/extensions/file-browse/{palette.ts → view/palette.ts} +0 -0
  269. /package/extensions/rewind/{patch.ts → text/patch.ts} +0 -0
  270. /package/extensions/rewind/{paths.ts → text/paths.ts} +0 -0
  271. /package/extensions/rewind/{queue.ts → transport/queue.ts} +0 -0
  272. /package/extensions/rewind/{temp-index.ts → transport/temp-index.ts} +0 -0
  273. /package/extensions/wechat/{lock.ts → state/lock.ts} +0 -0
  274. /package/extensions/wechat/{channel.ts → text/channel.ts} +0 -0
@@ -6,38 +6,45 @@
6
6
  * for one plan, opens the default browser at a tokenized URL, and resolves
7
7
  * `done` when the user saves feedback, cancels, closes the tab (detected by the
8
8
  * event stream dropping), or aborts the turn. The route logic lives in the pure
9
- * `web-router.ts`; this module is the only part that owns a socket and timers,
10
- * and it takes injected seams so tests can run one on `127.0.0.1:0`.
9
+ * `web-router.ts`; this module owns the routes and the timers, `lib/http` owns
10
+ * the socket, 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
- import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
13
+ import { networkInterfaces } from "node:os";
15
14
  import { basename } from "node:path";
16
15
  import { openInBrowser } from "../../lib/browser.ts";
17
16
  import {
18
- bindLoopback,
19
- createCloser,
20
17
  jsonHeaders,
21
- openEventStream,
22
- respond,
18
+ type LoopbackRequest,
19
+ type LoopbackReply,
20
+ type LoopbackServer,
21
+ parseRequestUrl,
22
+ serveLoopback,
23
23
  type ServeResponse,
24
24
  } from "../../lib/http/index.ts";
25
25
  import { fenceLanguage, highlightLines } from "../../lib/highlight.ts";
26
- import { type ArmTimer, defaultTimer } from "../../lib/timer.ts";
27
- import { codeBody, fenceInfo, planBlocks } from "./blocks.ts";
26
+ import { type ArmTimer, defaultInterval, defaultTimer, type RepeatTimer } from "../../lib/timer.ts";
27
+ import { codeBody, fenceInfo } from "./blocks.ts";
28
28
  import {
29
+ claimAuthor,
29
30
  MAX_ANNOTATIONS,
30
31
  MAX_COMMENT_LENGTH,
31
32
  MAX_QUOTE_LENGTH,
32
- parseFeedback,
33
+ normalizeAuthor,
34
+ readFeedback,
33
35
  stripFeedback,
34
36
  type PlanAnnotation,
35
37
  } from "./feedback.ts";
36
38
  import { planTitle, type PlanStore, type StoredPlan } from "./plans.ts";
37
- import { changedSections, type SectionDigest } from "./plan-text.ts";
38
- import { extractPlanSections, extractPlanSteps } from "./steps.ts";
39
+ import { changedSections, planDigest, type SectionDigest } from "./plan-text.ts";
40
+ import { extractPlanSteps } from "./steps.ts";
41
+ import type { ReviewCaller } from "./types.ts";
39
42
  import { handleReviewRequest, type ReviewContext, type SaveResult } from "./web-router.ts";
40
43
  import type { ReviewPageData } from "./web-page.ts";
44
+ import { createShareSession, type ShareSession } from "./web-share-session.ts";
45
+ import { createStreams } from "./web-streams.ts";
46
+ import { createTokens } from "./web-tokens.ts";
47
+ import type { NetworkInterfaces, ShareResult, ShareState } from "./web-share.ts";
41
48
 
42
49
  /** How a browser review ended. */
43
50
  export type PlanReviewResult =
@@ -47,13 +54,13 @@ export type PlanReviewResult =
47
54
 
48
55
  /** A running review the caller awaits and then closes. */
49
56
  export interface PlanReviewSession {
50
- /** Tokenized URL to open or print. */
57
+ /** Tokenized URL to open or print; the owner's own page. */
51
58
  url: string;
52
59
  /** Bound loopback port. */
53
60
  port: number;
54
61
  /** Resolves once, when the review ends. */
55
62
  done: Promise<PlanReviewResult>;
56
- /** Stop the server. Safe to call more than once. */
63
+ /** Stop the server(s). Safe to call more than once. */
57
64
  close(): Promise<void>;
58
65
  }
59
66
 
@@ -65,11 +72,25 @@ export interface PlanReviewOptions {
65
72
  canApprove?: boolean;
66
73
  /** The section digests the previous review showed, so this page can mark changed sections. */
67
74
  baseline?: SectionDigest[];
75
+ /**
76
+ * Called when a shared reviewer's post lands, so the owner's terminal can say so:
77
+ * an open page updates itself, and whoever is waiting in the TUI is told nothing
78
+ * until this fires.
79
+ */
80
+ onReviewerComment?: (author: string, count: number) => void;
68
81
  }
69
82
 
70
83
  /** Starts and tracks review sessions. */
71
84
  export interface PlanWebReview {
72
85
  start(cwd: string, plan: StoredPlan, options?: PlanReviewOptions): Promise<PlanReviewSession | undefined>;
86
+ /** The session currently serving a review, if any. */
87
+ current(): PlanReviewSession | undefined;
88
+ /** Start (or re-read) the share link for the running review, if one is open. */
89
+ share(): Promise<ShareResult | undefined>;
90
+ /** Revoke every reviewer token and stop accepting reviewers. */
91
+ stopSharing(): Promise<ShareResult | undefined>;
92
+ /** The running review's share state, without changing it. */
93
+ shareState(): ShareState | undefined;
73
94
  /** Stop every running session (session shutdown). */
74
95
  closeAll(): Promise<void>;
75
96
  }
@@ -93,49 +114,37 @@ export interface PlanWebReviewDeps {
93
114
  schedule?: ArmTimer;
94
115
  /** Override the accepted body size; a test keeps it small. */
95
116
  maxBodyBytes?: number;
117
+ /**
118
+ * Override the repeating timer that polls the plan file.
119
+ *
120
+ * A poll is a race against an edit made elsewhere, so a test drives it by hand
121
+ * rather than waiting for a tick that makes a busy machine fail.
122
+ */
123
+ tick?: RepeatTimer;
124
+ /** How often to check the plan file for an edit this review did not make. */
125
+ pollMs?: number;
126
+ /** Override the machine's network interfaces (tests), never read live in a test. */
127
+ networkInterfaces?: () => NetworkInterfaces;
96
128
  }
97
129
 
98
130
  /** Largest feedback body accepted, in bytes. */
99
131
  const MAX_BODY_BYTES = 1024 * 1024;
100
132
  /** Grace period before an empty client set counts as a closed tab. */
101
133
  const DROP_GRACE_MS = 1500;
102
-
103
- /** A request body, or the fact that it was too large to read. */
104
- type BodyResult = { ok: true; body: string } | { ok: false };
105
-
106
134
  /**
107
- * Read a request body, stopping at {@link MAX_BODY_BYTES}. An oversize body is
108
- * reported rather than destroyed here, so the handler can still answer 413 over
109
- * the open socket instead of the client seeing a dropped connection.
135
+ * How often a running review checks the plan file.
136
+ *
137
+ * One stat and one read of a small markdown file, only while a page is open: a
138
+ * second is fast enough that an edit under an open page reaches it while the
139
+ * reader is still looking, and slow enough to be free.
110
140
  */
111
- function readBody(request: IncomingMessage, limit: number): Promise<BodyResult> {
112
- return new Promise((resolve) => {
113
- const chunks: Buffer[] = [];
114
- let size = 0;
115
- let done = false;
116
- const settle = (result: BodyResult): void => {
117
- if (done) return;
118
- done = true;
119
- resolve(result);
120
- };
121
- request.on("data", (chunk: Buffer) => {
122
- if (done) return;
123
- size += chunk.length;
124
- if (size > limit) {
125
- chunks.length = 0;
126
- request.pause();
127
- settle({ ok: false });
128
- return;
129
- }
130
- chunks.push(chunk);
131
- });
132
- request.on("end", () => settle({ ok: true, body: Buffer.concat(chunks).toString("utf-8") }));
133
- request.on("error", () => settle({ ok: false }));
134
- });
135
- }
141
+ const PLAN_POLL_MS = 1000;
136
142
 
137
143
  export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps = {}): PlanWebReview {
138
144
  const sessions = new Set<PlanReviewSession>();
145
+ // What each running review can do about sharing; the session itself stays the
146
+ // four things a caller awaits and closes.
147
+ const shares = new Map<PlanReviewSession, ShareSession>();
139
148
  // Last bound port per plan path, so the same plan keeps one origin across
140
149
  // review turns (and its saved browser theme with it).
141
150
  const ports = new Map<string, number>();
@@ -147,22 +156,40 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
147
156
  ): Promise<PlanReviewSession | undefined> => {
148
157
  const { signal } = options;
149
158
  const canApprove = options.canApprove === true;
150
- const maxBody = deps.maxBodyBytes ?? MAX_BODY_BYTES;
151
- const token = (deps.token ?? (() => randomBytes(16).toString("hex")))();
159
+ // One cap for both listeners: a reviewer's page posts the same bodies as the
160
+ // owner's, so the shared one cannot be the stricter or the looser of the two.
161
+ const bodyLimit = deps.maxBodyBytes ?? MAX_BODY_BYTES;
162
+ // One token per holder: the owner's is minted here and never shared, and a
163
+ // reviewer token is minted by `share()`. The table owns the collision rule and
164
+ // the name each link has bound.
165
+ const tokens = createTokens(deps.token);
166
+ const token = tokens.mint("owner");
167
+ // The owner's bound port, so a shared listener can reuse it for the same URL shape.
168
+ let currentPort = 0;
152
169
  let settled = false;
153
170
  let resolveDone!: (result: PlanReviewResult) => void;
154
171
  const done = new Promise<PlanReviewResult>((resolve) => {
155
172
  resolveDone = resolve;
156
173
  });
157
- let cancelDrop: (() => void) | undefined;
158
- const clearDrop = (): void => {
159
- cancelDrop?.();
160
- cancelDrop = undefined;
161
- };
174
+ // Live event streams, used only to notice a closed owner tab: a reviewer's
175
+ // tab is not the review's lifetime. The explicit Cancel button and the abort
176
+ // signal are the deterministic paths.
177
+ const streams = createStreams({
178
+ onAbandoned: () => finish({ status: "cancelled" }),
179
+ graceMs: deps.dropGraceMs ?? DROP_GRACE_MS,
180
+ schedule: deps.schedule ?? defaultTimer,
181
+ settled: () => settled,
182
+ });
183
+
184
+ let stopTicker: () => void = () => {};
162
185
  const finish = (result: PlanReviewResult): void => {
163
186
  if (settled) return;
164
187
  settled = true;
165
- clearDrop();
188
+ streams.cancelDrop();
189
+ stopTicker();
190
+ // A page still open has no decision to make, so it is told why it stops
191
+ // updating, and its stream is ended with that frame rather than after it.
192
+ streams.end("closed");
166
193
  resolveDone(result);
167
194
  };
168
195
 
@@ -181,19 +208,52 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
181
208
  */
182
209
  const refreshPageText = async (): Promise<StoredPlan> => {
183
210
  const current = (await store.read(cwd, plan.path)) ?? plan;
184
- pageText = stripFeedback(current.content);
211
+ const next = stripFeedback(current.content);
212
+ // Someone saved the plan outside this page: the readers must see the new
213
+ // body, and the next save must anchor against it rather than 409.
214
+ if (next !== pageText) notifyPlanChange();
215
+ pageText = next;
185
216
  return current;
186
217
  };
218
+ // Set below, once the stream set exists; a page load before that has no
219
+ // reader to notify and nothing to notify it about.
220
+ let notifyPlanChange: () => void = () => {};
221
+ notifyPlanChange = () => streams.broadcast("plan");
187
222
 
188
- const page = async (): Promise<ReviewPageData> => {
223
+ /**
224
+ * Notice a plan file this review did not write.
225
+ *
226
+ * A review owns the page, not the file: `/plan review` runs in normal mode, and
227
+ * the plan is a markdown file a user or another tool can rewrite while the page
228
+ * is open. The check compares the stripped text with what the page rendered and
229
+ * only announces — it never writes `pageText`, which is the anchor a save is
230
+ * validated against. So an edit under an open page reaches every reader, and the
231
+ * review's own save, whose text is unchanged, says nothing.
232
+ *
233
+ * A poll rather than an `fs.watch`: the house rule, and one stat plus one read
234
+ * per interval costs less than the watcher's platform-dependent event
235
+ * semantics (see `file-browse`'s note). It stops with the session.
236
+ */
237
+ const checkPlanFile = async (): Promise<void> => {
238
+ const current = await store.read(cwd, plan.path);
239
+ if (!current) return;
240
+ if (stripFeedback(current.content) !== pageText) notifyPlanChange();
241
+ };
242
+ stopTicker = (deps.tick ?? defaultInterval)(deps.pollMs ?? PLAN_POLL_MS, () => void checkPlanFile());
243
+
244
+ const page = async (caller: ReviewCaller): Promise<ReviewPageData> => {
245
+ const { role } = caller;
189
246
  const stored = await refreshPageText();
190
- const current = pageText;
191
- const blocks = planBlocks(current);
192
- const sections = extractPlanSections(current);
193
- // Per-occurrence change flags, aligned with the sections list: the same shape
194
- // the terminal review renders, so duplicate headings cannot both light up.
195
- const changed = changedSections(options.baseline, current);
196
- const outline = sections.flatMap((section, index) =>
247
+ // The body, its blocks, and the comments out of one read of the file: an
248
+ // anchor names a block from this same parse, so the island cannot mark a
249
+ // comment onto a line the page never rendered.
250
+ const read = readFeedback(stored.content);
251
+ const current = read.body;
252
+ const blocks = read.blocks;
253
+ // Per-section change marks, each carrying its own section: the same shape the
254
+ // terminal review renders, so a heading and its mark cannot be paired wrongly.
255
+ const marks = changedSections(options.baseline, current);
256
+ const outline = marks.flatMap(({ section, changed }) =>
197
257
  section.title === ""
198
258
  ? []
199
259
  : [
@@ -202,7 +262,7 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
202
262
  line: section.line,
203
263
  level: section.level,
204
264
  text: section.title,
205
- ...(changed[index] ? { changed: true } : {}),
265
+ ...(changed ? { changed: true } : {}),
206
266
  },
207
267
  ],
208
268
  );
@@ -220,9 +280,9 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
220
280
  return {
221
281
  // The document's own H1 reads better than the file-name slug, and the
222
282
  // slug is still the fallback for a plan that starts with something else.
223
- title: sections.find((section) => section.level === 1)?.title || planTitle(basename(plan.path)),
283
+ title: marks.find((mark) => mark.section.level === 1)?.section.title || planTitle(basename(plan.path)),
224
284
  blocks,
225
- annotations: parseFeedback(stored.content),
285
+ annotations: read.annotations,
226
286
  outline,
227
287
  steps: {
228
288
  done: stepList.filter((step) => step.status === "completed").length,
@@ -231,29 +291,79 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
231
291
  code,
232
292
  limits: { comments: MAX_ANNOTATIONS, comment: MAX_COMMENT_LENGTH, quote: MAX_QUOTE_LENGTH },
233
293
  token,
234
- canApprove,
294
+ role,
295
+ // What the page keys this tab's unsaved draft on: same plan text, same draft.
296
+ rev: planDigest(current),
297
+ ...(caller.name === undefined ? {} : { author: caller.name }),
298
+ canApprove: role === "owner" && canApprove,
299
+ share: role === "owner" ? shareSession.state() : undefined,
235
300
  };
236
301
  };
237
302
 
238
- const save = async (annotations: PlanAnnotation[]): Promise<SaveResult> => {
303
+ /**
304
+ * The owner's save decides the review; a reviewer's post only writes.
305
+ * Both replace the comments of the writer they carry — the owner's are
306
+ * untagged, a reviewer's are tagged with the name bound to that link — so
307
+ * neither touches another writer's markers, and both refuse a plan that
308
+ * changed since the page was rendered.
309
+ */
310
+ const save = async (annotations: PlanAnnotation[], caller: ReviewCaller): Promise<SaveResult> => {
311
+ const { role } = caller;
239
312
  const current = await store.read(cwd, plan.path);
240
313
  if (!current) return { ok: false, status: 404, message: "The plan file is gone." };
241
314
  if (stripFeedback(current.content) !== pageText) {
242
315
  return { ok: false, status: 409, message: "The plan changed on disk; reload the page and review again." };
243
316
  }
244
317
  try {
245
- savedPlan = await store.saveFeedback(cwd, plan.path, annotations);
246
- return { ok: true };
318
+ if (role === "owner") {
319
+ savedPlan = await store.saveFeedback(cwd, plan.path, annotations);
320
+ streams.broadcast("comment");
321
+ return { ok: true };
322
+ }
323
+ // The link, not the payload, decides whose markers this post replaces: the name
324
+ // is bound to the token on its first write, and a page that signs another one
325
+ // is a stale tab rather than an authorized writer.
326
+ const claim = claimAuthor({ bound: caller.name, taken: tokens.namesExcept(caller) }, annotations);
327
+ if (!("name" in claim)) return { ok: false, status: claim.status, message: claim.message };
328
+ const own = annotations.filter((annotation) => normalizeAuthor(annotation.author) === claim.name);
329
+ // The saved plan is deliberately not recorded here: a post is not a result
330
+ // the session can return, so there is nothing for a later flush to resolve.
331
+ await store.saveFeedback(cwd, plan.path, own, { author: claim.name });
332
+ caller.name ??= claim.name;
333
+ streams.broadcast("comment");
334
+ if (own.length > 0) options.onReviewerComment?.(claim.name, own.length);
335
+ return { ok: true, author: claim.name };
247
336
  } catch (error) {
248
337
  return { ok: false, status: 500, message: error instanceof Error ? error.message : String(error) };
249
338
  }
250
339
  };
251
340
 
341
+ // The second listener, its reviewer token, and the state the page and
342
+ // `/plan share` read. It is handed the request handler, the role map, and the
343
+ // frame hooks, so sharing stays one concern instead of five fields here.
344
+ const shareSession = createShareSession({
345
+ handleRequest,
346
+ maxBodyBytes: bodyLimit,
347
+ ownerPort: () => currentPort,
348
+ networkInterfaces: deps.networkInterfaces ?? networkInterfaces,
349
+ mintReviewer: () => tokens.mint("reviewer"),
350
+ revokeReviewer: (value) => tokens.revoke(value),
351
+ nameOf: (value) => tokens.get(value)?.name,
352
+ broadcast: (kind, only) => streams.broadcast(kind, only === undefined ? undefined : { role: only }),
353
+ endStreams: (kind, only) => streams.end(kind, only),
354
+ });
355
+ const hosts = (): readonly string[] => shareSession.hosts();
356
+
252
357
  const context: ReviewContext = {
253
- token,
358
+ caller: (value) => tokens.get(value),
359
+ hosts,
254
360
  canApprove,
255
361
  page,
256
362
  save,
363
+ share: () => shareSession.share(),
364
+ invite: () => shareSession.invite(),
365
+ revoke: (id) => shareSession.revoke(id),
366
+ stopShare: () => shareSession.stop(),
257
367
  onSaved: (annotations) => {
258
368
  pendingResult = savedPlan ? { status: "saved", plan: savedPlan, annotations } : { status: "cancelled" };
259
369
  },
@@ -265,90 +375,59 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
265
375
  },
266
376
  };
267
377
 
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) => {
283
- void (async () => {
284
- const method = request.method ?? "GET";
285
- const read =
286
- method.toUpperCase() === "POST" ? await readBody(request, maxBody) : { ok: true as const, body: "" };
287
- if (!read.ok) {
288
- response.writeHead(413, { ...jsonHeaders(), Connection: "close" });
289
- response.end(JSON.stringify({ error: "The request body is too large." }));
290
- return;
291
- }
292
- const body = read.body;
293
- let result: ServeResponse;
294
- try {
295
- result = await handleReviewRequest(context, method, request.url ?? "/", request.headers, body);
296
- } catch {
297
- result = {
298
- status: 500,
299
- headers: jsonHeaders(),
300
- body: JSON.stringify({ error: "Internal error" }),
301
- };
302
- }
303
- if (result.stream) {
304
- openEventStream(response, result);
305
- hadClient = true;
306
- clients.add(response);
307
- clearDrop();
308
- request.on("close", () => {
309
- clients.delete(response);
310
- scheduleDrop();
311
- });
312
- return;
313
- }
314
- // A save or cancel only resolves the session after its response is on the
315
- // wire; otherwise `close()` would force-close the socket mid-reply. The
316
- // `close` path covers a client that walks away before the flush callback
317
- // runs, so a settle cannot be lost with the socket.
318
- const settlePending = (): void => {
319
- const terminal = pendingResult;
320
- if (!terminal) return;
321
- pendingResult = undefined;
322
- finish(terminal);
378
+ // Live event streams, used only to notice a closed owner tab: a reviewer's
379
+ // tab is not the review's lifetime. The explicit Cancel button and the abort
380
+ // signal are the deterministic paths.
381
+ /** One request, on either listener: the loopback page or a shared one. */
382
+ async function handleRequest(request: LoopbackRequest): Promise<LoopbackReply> {
383
+ let result: ServeResponse;
384
+ try {
385
+ result = await handleReviewRequest(context, request.method, request.url, request.headers, request.body);
386
+ } catch {
387
+ result = {
388
+ status: 500,
389
+ headers: jsonHeaders(),
390
+ body: JSON.stringify({ error: "Internal error" }),
323
391
  };
324
- respond(request, response, result, settlePending);
325
- response.on("close", settlePending);
326
- })();
327
- });
392
+ }
393
+ // A save or cancel only resolves the session after its response is on the
394
+ // wire; otherwise `close()` would force-close the socket mid-reply. The same
395
+ // hook covers a client that walks away before the flush, so a settle cannot be
396
+ // lost with the socket.
397
+ const settlePending = (): void => {
398
+ const terminal = pendingResult;
399
+ if (!terminal) return;
400
+ pendingResult = undefined;
401
+ finish(terminal);
402
+ };
403
+ if (!result.stream) return { result, afterResponse: settlePending };
404
+ const token = parseRequestUrl(request.url)?.search.get("token") ?? "";
405
+ const streamCaller = context.caller(token);
406
+ return {
407
+ result,
408
+ openSink: (sink) => {
409
+ if (streamCaller !== undefined) streams.add(sink, { role: streamCaller.role, token });
410
+ return () => streams.remove(sink);
411
+ },
412
+ };
413
+ }
328
414
 
329
- // The accept-level `error` listener lives in `bindLoopback`, so a transient
330
- // failure cannot take the host down; the review still ends through its own
331
- // paths: cancel, abort, drop, or shutdown.
332
415
  const preferred = deps.port ?? ports.get(plan.path) ?? 0;
333
- let port: number | undefined;
416
+ let server: LoopbackServer;
334
417
  try {
335
- port = await bindLoopback(server, preferred);
418
+ server = await serveLoopback({
419
+ ports: preferred === 0 ? [0] : [preferred, 0],
420
+ maxBodyBytes: bodyLimit,
421
+ handler: handleRequest,
422
+ });
336
423
  } catch {
337
- // The remembered port may have been taken between turns; fall back once.
338
- if (preferred !== 0) {
339
- try {
340
- port = await bindLoopback(server, 0);
341
- } catch {
342
- port = undefined;
343
- }
344
- }
345
- }
346
- if (port === undefined) {
347
- server.close();
424
+ // The remembered port may have been taken between turns; the fallback above
425
+ // covers that, so reaching here means neither port answered.
348
426
  return undefined;
349
427
  }
428
+ const port = server.port;
350
429
  ports.set(plan.path, port);
351
- const closeServer = createCloser(server);
430
+ currentPort = port;
352
431
 
353
432
  const url = `http://127.0.0.1:${port}/?token=${token}`;
354
433
  let closed = false;
@@ -358,14 +437,19 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
358
437
  closed = true;
359
438
  if (signal && abortHandler) signal.removeEventListener("abort", abortHandler);
360
439
  sessions.delete(session);
361
- for (const client of clients) client.end();
362
- clients.clear();
440
+ shares.delete(session);
441
+ // `finish` ends every open stream with its last frame, so it runs before
442
+ // the set is cleared rather than after it.
363
443
  finish({ status: "cancelled" });
364
- await closeServer();
444
+ stopTicker();
445
+ streams.clear();
446
+ await shareSession.close();
447
+ await server.close();
365
448
  };
366
449
 
367
450
  const session: PlanReviewSession = { url, port, done, close };
368
451
  sessions.add(session);
452
+ shares.set(session, shareSession);
369
453
 
370
454
  if (signal) {
371
455
  abortHandler = (): void => void close();
@@ -377,8 +461,18 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
377
461
  return session;
378
462
  };
379
463
 
464
+ const current = (): PlanReviewSession | undefined => [...sessions].at(-1);
465
+ const controls = () => {
466
+ const session = current();
467
+ return session ? shares.get(session) : undefined;
468
+ };
469
+
380
470
  return {
381
471
  start,
472
+ current,
473
+ share: async () => controls()?.share(),
474
+ stopSharing: async () => controls()?.stop(),
475
+ shareState: () => controls()?.state(),
382
476
  closeAll: async () => {
383
477
  await Promise.all([...sessions].map((session) => session.close()));
384
478
  },
@@ -6,13 +6,15 @@
6
6
  * fake and reusable by any host.
7
7
  */
8
8
 
9
- import { OTHER_LABEL, cancelledResult, completedResult } from "./answers.ts";
9
+ import { OTHER_LABEL, cancelledResult, collectAnswers, completedResult } from "./answers.ts";
10
10
  import type { Answer, AskResult, Question, Selection } from "./types.ts";
11
11
 
12
12
  export interface QuestionUI {
13
13
  select(title: string, options: string[], opts?: { signal?: AbortSignal }): Promise<string | undefined>;
14
14
  input(title: string, placeholder?: string, opts?: { signal?: AbortSignal }): Promise<string | undefined>;
15
15
  editor(title: string, prefill?: string): Promise<string | undefined>;
16
+ /** Prompt language, when the host knows it. */
17
+ language?(): "zh" | "en";
16
18
  }
17
19
 
18
20
  const MULTI_INSTRUCTIONS = "Enter one or more option numbers or labels, separated by commas.";
@@ -56,8 +58,96 @@ export function parseMultiSelect(question: Question, text: string): Selection |
56
58
  return { type: "options", values, labels, indices };
57
59
  }
58
60
 
61
+ /** The one-message prompt for a multi-question ask. */
62
+ export function batchPrompt(questions: Question[], language: "zh" | "en" = "en"): string {
63
+ const zh = language === "zh";
64
+ const lines = [zh ? "请一次性回答所有问题:" : "Answer all questions in one message:", ""];
65
+ questions.forEach((question, index) => {
66
+ lines.push(`${index + 1}. ${question.question}`);
67
+ if (question.options.length === 0) {
68
+ lines.push(zh ? " 文字" : " text");
69
+ return;
70
+ }
71
+ const options = question.options.map((option, optionIndex) => `${optionIndex + 1}. ${option.label}`).join(" ");
72
+ lines.push(` ${options}${question.multiSelect ? (zh ? "(可多选)" : " (multi)") : ""}`);
73
+ });
74
+ lines.push("", zh ? "格式:1: 2;2: A, B;3: 文字" : "Format: 1: 2; 2: A, B; 3: text");
75
+ return lines.join("\n");
76
+ }
77
+
78
+ /**
79
+ * Parse one batch reply. Each line/semicolon chunk starts with the question
80
+ * number; a chunk with no prefix makes the whole reply ambiguous.
81
+ */
82
+ export function parseBatchAnswers(questions: Question[], text: string): Map<string, Selection> | undefined {
83
+ const entries = new Map<number, string>();
84
+ for (const chunk of text.split(/[\n;]+/)) {
85
+ const match = /^\s*(\d+)\s*[:.)-]\s*(.*)$/.exec(chunk);
86
+ if (!match) {
87
+ if (chunk.trim() !== "") return undefined;
88
+ continue;
89
+ }
90
+ const index = Number.parseInt(match[1] ?? "", 10);
91
+ if (index < 1 || index > questions.length) return undefined;
92
+ entries.set(index, (match[2] ?? "").trim());
93
+ }
94
+ const selections = new Map<string, Selection>();
95
+ for (const [index, question] of questions.entries()) {
96
+ const raw = entries.get(index + 1);
97
+ if (raw === undefined || raw === "") return undefined;
98
+ if (question.options.length === 0) {
99
+ selections.set(question.id, { type: "custom", text: raw });
100
+ continue;
101
+ }
102
+ if (question.multiSelect) {
103
+ const selection = parseMultiSelect(question, raw);
104
+ if (!selection) return undefined;
105
+ selections.set(question.id, selection);
106
+ continue;
107
+ }
108
+ const asNumber = Number.parseInt(raw, 10);
109
+ const optionIndex =
110
+ String(asNumber) === raw && asNumber >= 1 && asNumber <= question.options.length
111
+ ? asNumber - 1
112
+ : question.options.findIndex((option) => option.label.toLowerCase() === raw.toLowerCase());
113
+ if (optionIndex >= 0) {
114
+ const option = question.options[optionIndex];
115
+ if (!option) return undefined;
116
+ selections.set(question.id, {
117
+ type: "options",
118
+ values: [option.label],
119
+ labels: [option.label],
120
+ indices: [optionIndex + 1],
121
+ });
122
+ continue;
123
+ }
124
+ if (!question.allowOther) return undefined;
125
+ selections.set(question.id, { type: "custom", text: raw });
126
+ }
127
+ return selections;
128
+ }
129
+
130
+ /** One batch editor, with one retry only when the format was not understood. */
131
+ async function askViaBatch(ui: QuestionUI, questions: Question[], signal?: AbortSignal): Promise<AskResult> {
132
+ const language = ui.language?.() ?? "en";
133
+ const prompt = batchPrompt(questions, language);
134
+ const first = await ui.editor(prompt, "");
135
+ if (first === undefined) return cancelledResult(questions);
136
+ const parsed = parseBatchAnswers(questions, first);
137
+ if (parsed) return completedResult(questions, parsed);
138
+ if (signal?.aborted) return cancelledResult(questions);
139
+ const retry = await ui.editor(
140
+ `${prompt}\n\n${language === "zh" ? "格式不对,请按“1: 答案”重发。" : "Please reply again as “1: answer”."}`,
141
+ "",
142
+ );
143
+ if (retry === undefined) return cancelledResult(questions);
144
+ const again = parseBatchAnswers(questions, retry);
145
+ return again ? completedResult(questions, again) : cancelledResult(questions, collectAnswers(questions, new Map()));
146
+ }
147
+
59
148
  /** Ask every question through forwarded dialogs, stopping on cancel or abort. */
60
149
  export async function askViaDialogs(ui: QuestionUI, questions: Question[], signal?: AbortSignal): Promise<AskResult> {
150
+ if (questions.length > 1) return askViaBatch(ui, questions, signal);
61
151
  const selections = new Map<string, Selection>();
62
152
  const opts = { signal };
63
153