@gavin-hu/my-pi-agent 0.16.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (200) hide show
  1. package/README.md +29 -15
  2. package/README.zh-CN.md +27 -15
  3. package/extensions/bytefly/README.md +195 -115
  4. package/extensions/bytefly/footer/collect/entries.ts +1 -6
  5. package/extensions/bytefly/footer/collect/snapshot.ts +23 -9
  6. package/extensions/bytefly/footer/price/price.ts +12 -8
  7. package/extensions/bytefly/footer/register.ts +13 -1
  8. package/extensions/bytefly/footer/types.ts +11 -0
  9. package/extensions/bytefly/footer/unpriced.ts +36 -0
  10. package/extensions/bytefly/header/component.ts +15 -29
  11. package/extensions/bytefly/header/identity.ts +71 -0
  12. package/extensions/bytefly/header/register.ts +9 -3
  13. package/extensions/bytefly/index.ts +13 -5
  14. package/extensions/bytefly/message/format.ts +20 -13
  15. package/extensions/bytefly/message/register.ts +8 -7
  16. package/extensions/bytefly/notify/types.ts +1 -1
  17. package/extensions/bytefly/panel/commands.ts +46 -25
  18. package/extensions/bytefly/panel/format.ts +49 -14
  19. package/extensions/bytefly/panel/join.ts +35 -9
  20. package/extensions/bytefly/panel/settings.ts +29 -39
  21. package/extensions/bytefly/panel/skills.ts +51 -0
  22. package/extensions/bytefly/panel/tui/extensions-view.ts +7 -61
  23. package/extensions/bytefly/panel/tui/frame.ts +9 -1
  24. package/extensions/bytefly/panel/tui/list-view.ts +72 -0
  25. package/extensions/bytefly/panel/tui/nav.ts +71 -3
  26. package/extensions/bytefly/panel/tui/settings-section.ts +250 -0
  27. package/extensions/bytefly/panel/tui/skills-view.ts +50 -0
  28. package/extensions/bytefly/panel/tui.ts +223 -331
  29. package/extensions/bytefly/panel/types.ts +26 -8
  30. package/extensions/bytefly/picker/discovery.ts +32 -27
  31. package/extensions/bytefly/picker/resources.ts +16 -8
  32. package/extensions/bytefly/picker/runtime.ts +62 -34
  33. package/extensions/bytefly/picker/toggle.ts +16 -10
  34. package/extensions/bytefly/picker/types.ts +31 -13
  35. package/extensions/bytefly/update/format.ts +66 -8
  36. package/extensions/bytefly/update/patch.ts +66 -0
  37. package/extensions/bytefly/update/register.ts +4 -11
  38. package/extensions/bytefly/update/release.ts +65 -0
  39. package/extensions/bytefly/update/state.ts +14 -0
  40. package/extensions/bytefly/update/suppress.ts +47 -29
  41. package/extensions/bytefly/update/types.ts +15 -5
  42. package/extensions/check/tui.ts +2 -0
  43. package/extensions/context/README.md +21 -11
  44. package/extensions/context/format.ts +10 -8
  45. package/extensions/context/report.ts +21 -8
  46. package/extensions/context/tui.ts +26 -10
  47. package/extensions/context/types.ts +5 -3
  48. package/extensions/file-browse/README.md +89 -22
  49. package/extensions/file-browse/chrome.ts +83 -0
  50. package/extensions/file-browse/client.ts +74 -459
  51. package/extensions/file-browse/config.ts +12 -3
  52. package/extensions/file-browse/diff-html.ts +94 -0
  53. package/extensions/file-browse/html.ts +26 -152
  54. package/extensions/file-browse/live-client.ts +47 -1
  55. package/extensions/file-browse/live.ts +35 -14
  56. package/extensions/file-browse/palette.ts +224 -0
  57. package/extensions/file-browse/rev.ts +42 -0
  58. package/extensions/file-browse/router.ts +27 -6
  59. package/extensions/file-browse/server.ts +7 -0
  60. package/extensions/file-browse/styles.ts +10 -3
  61. package/extensions/file-browse/tree.ts +381 -0
  62. package/extensions/file-read/README.md +4 -1
  63. package/extensions/file-read/extract/odt.ts +7 -5
  64. package/extensions/goal/plan-route.ts +8 -2
  65. package/extensions/goal/tools.ts +6 -11
  66. package/extensions/job/README.md +7 -5
  67. package/extensions/job/index.ts +35 -52
  68. package/extensions/job/tools.ts +51 -77
  69. package/extensions/memory/README.md +3 -3
  70. package/extensions/memory/tools.ts +4 -6
  71. package/extensions/memory/tui.ts +5 -1
  72. package/extensions/plan/README.md +333 -84
  73. package/extensions/plan/commands.ts +73 -7
  74. package/extensions/plan/feedback.ts +300 -63
  75. package/extensions/plan/index.ts +1 -1
  76. package/extensions/plan/plan-index.ts +4 -2
  77. package/extensions/plan/plan-text.ts +18 -1
  78. package/extensions/plan/plans.ts +184 -58
  79. package/extensions/plan/review-prompt.ts +93 -0
  80. package/extensions/plan/review.ts +95 -95
  81. package/extensions/plan/runtime.ts +4 -2
  82. package/extensions/plan/split.ts +87 -0
  83. package/extensions/plan/steps.ts +123 -107
  84. package/extensions/plan/tools/cancel.ts +79 -0
  85. package/extensions/plan/tools/enter.ts +17 -12
  86. package/extensions/plan/tools/exit.ts +148 -59
  87. package/extensions/plan/tools/steps.ts +7 -19
  88. package/extensions/plan/tools/write.ts +29 -31
  89. package/extensions/plan/tools.ts +6 -3
  90. package/extensions/plan/tui.ts +60 -15
  91. package/extensions/plan/types.ts +45 -3
  92. package/extensions/plan/web-chrome.ts +102 -0
  93. package/extensions/plan/web-client.ts +177 -446
  94. package/extensions/plan/web-collab.ts +207 -0
  95. package/extensions/plan/web-comments.ts +269 -0
  96. package/extensions/plan/web-markdown.ts +222 -0
  97. package/extensions/plan/web-page.ts +131 -19
  98. package/extensions/plan/web-router.ts +148 -32
  99. package/extensions/plan/web-share-session.ts +197 -0
  100. package/extensions/plan/web-share.ts +169 -0
  101. package/extensions/plan/web-streams.ts +124 -0
  102. package/extensions/plan/web-tokens.ts +63 -0
  103. package/extensions/plan/web.ts +193 -45
  104. package/extensions/question/README.md +3 -0
  105. package/extensions/question/dialogs.ts +91 -1
  106. package/extensions/question/tools.ts +2 -1
  107. package/extensions/rewind/checklist.ts +4 -0
  108. package/extensions/rewind/review-tui.ts +3 -1
  109. package/extensions/rewind/rewind-tui.ts +4 -0
  110. package/extensions/session-bus/README.md +247 -0
  111. package/extensions/session-bus/commands.ts +165 -0
  112. package/extensions/session-bus/format.ts +124 -0
  113. package/extensions/session-bus/index.ts +69 -0
  114. package/extensions/session-bus/notice.ts +58 -0
  115. package/extensions/session-bus/protocol.ts +134 -0
  116. package/extensions/session-bus/result.ts +243 -0
  117. package/extensions/session-bus/rows.ts +169 -0
  118. package/extensions/session-bus/runtime.ts +575 -0
  119. package/extensions/session-bus/store.ts +329 -0
  120. package/extensions/session-bus/team.ts +102 -0
  121. package/extensions/session-bus/tools.ts +227 -0
  122. package/extensions/session-bus/tui.ts +394 -0
  123. package/extensions/session-bus/types.ts +98 -0
  124. package/extensions/subagent/README.md +26 -13
  125. package/extensions/subagent/format.ts +72 -25
  126. package/extensions/subagent/index.ts +21 -47
  127. package/extensions/subagent/orchestrate.ts +18 -41
  128. package/extensions/subagent/render.ts +29 -42
  129. package/extensions/subagent/stream.ts +24 -18
  130. package/extensions/subagent/types.ts +7 -0
  131. package/extensions/todo/tools.ts +5 -6
  132. package/extensions/todo/tui.ts +5 -1
  133. package/extensions/watch/README.md +9 -5
  134. package/extensions/watch/index.ts +25 -44
  135. package/extensions/watch/tools.ts +31 -59
  136. package/extensions/wechat/README.md +45 -36
  137. package/extensions/wechat/bridge.ts +46 -48
  138. package/extensions/wechat/client.ts +1 -4
  139. package/extensions/wechat/commands.ts +3 -3
  140. package/extensions/wechat/deliver.ts +57 -37
  141. package/extensions/wechat/interaction.ts +54 -18
  142. package/extensions/wechat/login-screen.ts +2 -3
  143. package/extensions/wechat/notices.ts +9 -7
  144. package/extensions/wechat/peer-worker.ts +19 -34
  145. package/extensions/wechat/presence.ts +3 -4
  146. package/extensions/wechat/prompt.ts +53 -23
  147. package/extensions/wechat/reply-pipeline.ts +12 -4
  148. package/extensions/wechat/slash.ts +14 -0
  149. package/extensions/wechat/status-screen.ts +2 -2
  150. package/extensions/wechat/tools.ts +3 -11
  151. package/extensions/worktree/README.md +48 -12
  152. package/extensions/worktree/commands.ts +109 -65
  153. package/extensions/worktree/enter.ts +1 -1
  154. package/extensions/worktree/exit.ts +11 -1
  155. package/extensions/worktree/git.ts +5 -0
  156. package/extensions/worktree/lifecycle.ts +9 -1
  157. package/extensions/worktree/registry.ts +11 -5
  158. package/extensions/worktree/screen.ts +263 -0
  159. package/extensions/worktree/status.ts +84 -40
  160. package/lib/README.md +12 -7
  161. package/lib/claims/overlap.ts +43 -0
  162. package/lib/config.ts +12 -0
  163. package/lib/format.ts +6 -2
  164. package/lib/http/router.ts +40 -13
  165. package/lib/http/server.ts +27 -13
  166. package/lib/interaction.ts +28 -1
  167. package/lib/notice.ts +29 -2
  168. package/lib/plan-entry.ts +7 -2
  169. package/lib/plans/sections.ts +172 -0
  170. package/lib/plans/setting.ts +52 -0
  171. package/lib/plans/split.ts +215 -0
  172. package/lib/plans/store.ts +19 -0
  173. package/lib/plans/team.ts +151 -0
  174. package/lib/qr.ts +64 -0
  175. package/lib/repo-key.ts +74 -0
  176. package/lib/report.ts +117 -25
  177. package/lib/requirements.ts +1 -2
  178. package/lib/tool-result.ts +54 -4
  179. package/lib/tui/README.md +30 -8
  180. package/lib/tui/chrome.ts +27 -6
  181. package/lib/tui/dock-screen.ts +91 -19
  182. package/lib/tui/gauge.ts +9 -5
  183. package/lib/tui/line-pane.ts +15 -4
  184. package/lib/tui/tool.ts +23 -4
  185. package/lib/usage.ts +26 -0
  186. package/lib/web/README.md +131 -0
  187. package/lib/web/base.ts +19 -2
  188. package/lib/web/components.ts +10 -6
  189. package/lib/web/layout.ts +2 -0
  190. package/lib/web/markup.ts +2 -1
  191. package/lib/web/runtime.ts +103 -25
  192. package/lib/web/tokens.ts +23 -7
  193. package/package.json +5 -2
  194. package/skills/changelog/SKILL.md +57 -45
  195. package/skills/init/SKILL.md +80 -45
  196. package/skills/init/references/agents-md-quality.md +26 -2
  197. package/skills/init/references/interview-questions.md +36 -14
  198. package/skills/next/SKILL.md +209 -0
  199. package/skills/parallel-dev/SKILL.md +150 -0
  200. package/extensions/wechat/qrcode.ts +0 -46
@@ -2,15 +2,17 @@
2
2
  * Slash command for the plan-mode extension.
3
3
  *
4
4
  * `/plan` toggles plan mode, or enters it and sends an optional task;
5
- * `Ctrl+Alt+P` toggles too. Two subcommands are reserved: `/plan review [path]`
5
+ * `Ctrl+Alt+P` toggles too. Four subcommands are reserved: `/plan review [path]`
6
6
  * opens a plan in the browser (and sends saved comments to the model as a
7
- * refinement), and `/plan list` shows the plans directory, newest first, with
8
- * Enter opening a review.
7
+ * refinement), `/plan share [stop]` prints or revokes the link a teammate opens,
8
+ * `/plan list` shows the plans directory, newest first with Enter opening a
9
+ * review, and `/plan cancel` leaves the mode without approving.
9
10
  */
10
11
 
11
12
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
12
13
  import { isRemoteTurn } from "../../lib/interaction.ts";
13
14
  import { READ_ONLY_SUMMARY } from "../../lib/plan-entry.ts";
15
+ import { qrLines } from "../../lib/qr.ts";
14
16
  import { feedbackSummary } from "./feedback.ts";
15
17
  import { PlanIndexComponent, formatPlanIndex } from "./plan-index.ts";
16
18
  import { runBrowserReview } from "./review.ts";
@@ -21,7 +23,9 @@ import { createPlanWebReview, type PlanWebReview } from "./web.ts";
21
23
  const ENABLED_NOTICE = `Plan mode enabled — ${READ_ONLY_SUMMARY}.`;
22
24
  const DISABLED_NOTICE = "Plan mode disabled — full access restored.";
23
25
  const REVIEW_PREFIX = "review";
26
+ const SHARE_PREFIX = "share";
24
27
  const LIST_PREFIX = "list";
28
+ const CANCEL_PREFIX = "cancel";
25
29
 
26
30
  /** Notice text for the current mode; shared by `/plan` and the `Ctrl+Alt+P` shortcut. */
27
31
  export function planModeNotice(enabled: boolean): string {
@@ -49,6 +53,11 @@ function isListSubcommand(prompt: string): boolean {
49
53
  return prompt.toLowerCase() === LIST_PREFIX;
50
54
  }
51
55
 
56
+ /** Exact `cancel` leaves plan mode without approving; a longer sentence stays a task. */
57
+ function isCancelSubcommand(prompt: string): boolean {
58
+ return prompt.toLowerCase() === CANCEL_PREFIX;
59
+ }
60
+
52
61
  /**
53
62
  * Whether a command line is the reserved `review` subcommand. Bare `review`
54
63
  * always is; a path after it is, but `review the auth flow` stays a task, so a
@@ -61,6 +70,44 @@ function isReviewSubcommand(prompt: string): boolean {
61
70
  return target.endsWith(".md") || target.includes("/") || target.includes("\\");
62
71
  }
63
72
 
73
+ /**
74
+ * Whether a command line is the reserved `share` subcommand. Exact `share` and
75
+ * `share stop` are; `share the tokens` stays a task.
76
+ */
77
+ function isShareSubcommand(prompt: string): boolean {
78
+ const text = prompt.toLowerCase();
79
+ return text === SHARE_PREFIX || text === `${SHARE_PREFIX} stop`;
80
+ }
81
+
82
+ /**
83
+ * `/plan share`: print the link (and a QR) a teammate opens, or revoke it. The
84
+ * browser page has the same button; this is the path a phone-owned or remote
85
+ * session needs, where there is no page to click.
86
+ */
87
+ async function shareReview(web: PlanWebReview, prompt: string, ctx: ExtensionContext): Promise<void> {
88
+ const stopping = prompt.toLowerCase().endsWith("stop");
89
+ const result = stopping ? await web.stopSharing() : await web.share();
90
+ if (!result) {
91
+ ctx.ui.notify("Plan share: no browser review is open; run /plan review first.", "warning");
92
+ return;
93
+ }
94
+ if (stopping) {
95
+ ctx.ui.notify("Plan share: reviewers can no longer open this review.", "info");
96
+ return;
97
+ }
98
+ const link = result.state.links.at(-1);
99
+ if (!result.state.sharing || !link) {
100
+ ctx.ui.notify(`Plan share: ${result.state.reason ?? "no link is available"}.`, "warning");
101
+ return;
102
+ }
103
+ const lines = ["Review link: " + link.url, ...(await qrLines(link.url))];
104
+ // One link is one person, so more than one is the team the page can list.
105
+ if (result.state.links.length > 1) {
106
+ lines.push(`${result.state.links.length} links shared — the page lists every link and who each one posts as.`);
107
+ }
108
+ ctx.ui.notify(lines.join("\n"), "info");
109
+ }
110
+
64
111
  /**
65
112
  * Open `planPath` in the browser and, if the user saves comments, hand them to
66
113
  * the model. A cancel is silent beyond a notice; nothing is sent.
@@ -131,6 +178,10 @@ async function openPlanIndex(
131
178
  ctx: ExtensionContext,
132
179
  ): Promise<void> {
133
180
  const dir = await runtime.plans.dirFor(ctx.cwd);
181
+ // A store an older version kept in the checkout was just moved in; the list is
182
+ // where a user notices their plans are somewhere else.
183
+ const moved = runtime.plans.takeNotice();
184
+ if (moved) ctx.ui.notify(moved, "info");
134
185
  const summarise = async (): Promise<Awaited<ReturnType<typeof runtime.plans.index>>> => runtime.plans.index(ctx.cwd);
135
186
  const summaries = await summarise();
136
187
 
@@ -167,22 +218,37 @@ export function registerCommands(
167
218
  ): void {
168
219
  pi.registerCommand("plan", {
169
220
  description:
170
- "Toggle plan mode (read-only exploration), enter it with an optional task, list saved plans, or review one in the browser",
221
+ "Toggle plan mode (read-only exploration), enter it with an optional task, list saved plans, review one in the browser, share that review with a link, or cancel planning without approving",
171
222
  getArgumentCompletions: (prefix) => {
172
223
  const text = prefix.trimStart();
173
224
  if (text.includes(" ")) return null;
174
- const subcommands = [LIST_PREFIX, REVIEW_PREFIX].filter((name) => name.startsWith(text));
225
+ const subcommands = [LIST_PREFIX, REVIEW_PREFIX, SHARE_PREFIX, CANCEL_PREFIX].filter((name) =>
226
+ name.startsWith(text),
227
+ );
175
228
  return subcommands.length > 0 ? subcommands.map((value) => ({ value, label: value })) : null;
176
229
  },
177
230
  handler: async (args, ctx) => {
178
231
  const prompt = args.trim();
179
232
 
180
- // `/plan list` and `/plan review [path]` are reserved subcommands;
181
- // everything else is a task.
233
+ // `/plan list`, `/plan review [path]`, and `/plan cancel` are reserved
234
+ // subcommands; everything else is a task.
235
+ if (isCancelSubcommand(prompt)) {
236
+ if (!runtime.isEnabled()) {
237
+ ctx.ui.notify("Plan mode is already off.", "info");
238
+ return;
239
+ }
240
+ runtime.disable(ctx);
241
+ ctx.ui.notify("Plan mode off — full access restored. The plan was not approved.", "info");
242
+ return;
243
+ }
182
244
  if (isListSubcommand(prompt)) {
183
245
  await openPlanIndex(pi, runtime, web, ctx);
184
246
  return;
185
247
  }
248
+ if (isShareSubcommand(prompt)) {
249
+ await shareReview(web, prompt, ctx);
250
+ return;
251
+ }
186
252
  if (isReviewSubcommand(prompt)) {
187
253
  await reviewInBrowser(pi, runtime, web, reviewTarget(prompt), ctx);
188
254
  return;
@@ -1,26 +1,33 @@
1
1
  /**
2
2
  * Inline review feedback for plan documents (pure).
3
3
  *
4
- * A browser comment is stored *in the plan file itself* as a blockquote on the
5
- * line directly after the block it refers to:
4
+ * A comment is stored *in the plan file itself* as a blockquote on the line
5
+ * directly after the block it refers to:
6
6
  *
7
7
  * - [ ] Add a token bucket
8
- * > [!REVIEW] what about the burst case?
8
+ * > [!REVIEW] @alice: what about the burst case?
9
+ * > [!REVIEW] name the ceiling too
9
10
  *
10
11
  * Placing it after a whole block (never inside a fence) keeps the markdown
11
- * valid and puts the comment next to the text the model must revise. Saving is
12
- * idempotent: {@link formatFeedback} strips every prior marker first, so
13
- * repeated saves replace rather than accumulate. The anchor is a source line in
14
- * the *stripped* plan, which is exactly what the browser rendered.
12
+ * valid and puts the comment next to the text the model must revise. The author
13
+ * tag names whoever wrote it: a tagged marker belongs to that reviewer, and an
14
+ * untagged one is the plan owner's. Every write is authored, so saving one
15
+ * person's comments replaces only their own and leaves the rest in place.
16
+ *
17
+ * The anchor is a source line in the *stripped* plan, which is exactly what the
18
+ * browser rendered. Saving is idempotent: {@link formatFeedback} strips the
19
+ * writer's prior markers first, so repeated saves replace rather than
20
+ * accumulate.
15
21
  */
16
22
 
17
23
  import { sanitizeLine } from "../../lib/format.ts";
18
- import { blockAt, planBlocks } from "./blocks.ts";
24
+ import { blockAt, planBlocks, type PlanBlock } from "./blocks.ts";
25
+ import { extractPlanSections } from "./steps.ts";
19
26
 
20
27
  /** Marker that opens every stored feedback line. */
21
28
  export const REVIEW_MARKER = "[!REVIEW]";
22
29
 
23
- /** A single browser comment, anchored to a source block. */
30
+ /** A single review comment, anchored to a source block. */
24
31
  export interface PlanAnnotation {
25
32
  /** 0-based first source line of the anchored block, in the stripped plan. */
26
33
  anchor: number;
@@ -28,6 +35,8 @@ export interface PlanAnnotation {
28
35
  quote?: string;
29
36
  /** The comment text. */
30
37
  comment: string;
38
+ /** Who wrote it; `""`/absent is the plan owner, the only writer before sharing. */
39
+ author?: string;
31
40
  }
32
41
 
33
42
  /** Most annotations one save may carry. */
@@ -36,14 +45,67 @@ export const MAX_ANNOTATIONS = 200;
36
45
  export const MAX_COMMENT_LENGTH = 2000;
37
46
  /** Longest retained selection quote. */
38
47
  export const MAX_QUOTE_LENGTH = 500;
48
+ /** Longest stored author name. */
49
+ export const MAX_AUTHOR_LENGTH = 40;
50
+
51
+ /** The name a reviewer write is stored under, or why it cannot be decided. */
52
+ export type AuthorClaim = { name: string } | { status: number; message: string };
39
53
 
40
- /** A stored feedback line, capturing its comment. */
54
+ /** A stored feedback line, capturing its author and comment. */
41
55
  const MARKER_LINE = /^\s*>\s*\[!REVIEW\]\s?(.*)$/;
56
+ /** The author prefix inside a marker body (`@alice: note`). */
57
+ const AUTHOR_PREFIX = /^@([^:]{1,40}):\s?(.*)$/;
42
58
 
43
59
  /**
44
- * Source lines inside a fenced code block. A marker-looking line there is
45
- * documentation, not stored feedback, so it is never stripped or parsed.
60
+ * A reviewer name as it is stored and displayed: one sanitized line, no leading
61
+ * `@`, no `:` (which would end the author early in the marker grammar), capped.
62
+ * Anything unusable becomes `""`, the owner.
46
63
  */
64
+ export function normalizeAuthor(raw: unknown): string {
65
+ if (typeof raw !== "string") return "";
66
+ const text = sanitizeLine(raw)
67
+ .replace(/^[\s@]+/, "")
68
+ .replace(/:/g, " ")
69
+ .replace(/\s+/g, " ")
70
+ .trim();
71
+ return text.slice(0, MAX_AUTHOR_LENGTH).trim();
72
+ }
73
+
74
+ /**
75
+ * Decide the name a reviewer's write is stored under.
76
+ *
77
+ * A bound link keeps its name: a payload that signs a different one is a stale or
78
+ * foreign page — a second tab of the same link, or a script — and re-signing it
79
+ * would move markers that belong to whoever picked that name. Before the first
80
+ * write the payload's own signature claims the name, unless another live link
81
+ * already holds it. An unsigned post claims nothing, so it is refused rather than
82
+ * binding a name to an accidental save.
83
+ */
84
+ export function claimAuthor(
85
+ input: { bound?: string; taken?: readonly string[] },
86
+ annotations: readonly PlanAnnotation[],
87
+ ): AuthorClaim {
88
+ const signed = [...new Set(annotations.map((annotation) => normalizeAuthor(annotation.author)))].filter(Boolean);
89
+ if (input.bound !== undefined) {
90
+ if (signed.some((name) => name !== input.bound)) {
91
+ return {
92
+ status: 409,
93
+ message: `This link posts as @${input.bound}; reload the page to comment as @${input.bound}.`,
94
+ };
95
+ }
96
+ return { name: input.bound };
97
+ }
98
+ if (signed.length === 0) return { status: 400, message: "Choose a display name before commenting." };
99
+ if (signed.length > 1) return { status: 409, message: "A post signs one name; reload the page and post again." };
100
+ const name = signed[0] ?? "";
101
+ if ((input.taken ?? []).includes(name)) {
102
+ return { status: 400, message: "That name is already in use in this review." };
103
+ }
104
+ return { name };
105
+ }
106
+
107
+ /** Source lines inside a fenced code block. A marker-looking line there is
108
+ * documentation, not stored feedback, so it is never stripped or parsed. */
47
109
  function fencedLines(plan: string): Set<number> {
48
110
  const fenced = new Set<number>();
49
111
  for (const block of planBlocks(plan)) {
@@ -53,104 +115,277 @@ function fencedLines(plan: string): Set<number> {
53
115
  return fenced;
54
116
  }
55
117
 
56
- /** Remove every stored feedback line, leaving the plan body as authored. */
57
- export function stripFeedback(plan: string): string {
58
- const fenced = fencedLines(plan);
59
- return plan
60
- .split("\n")
61
- .filter((line, index) => fenced.has(index) || !MARKER_LINE.test(line))
62
- .join("\n");
118
+ /** A stored marker, with the clean-plan line it followed and who wrote it. */
119
+ interface StoredMarker {
120
+ /** Index in the clean plan of the line the marker followed; `-1` is the top. */
121
+ at: number;
122
+ comment: string;
123
+ author: string;
63
124
  }
64
125
 
65
- /**
66
- * Read stored feedback back. The anchor is the block ending just before each
67
- * marker in the stripped plan, so a comment keeps its place across reloads.
68
- */
69
- export function parseFeedback(plan: string): PlanAnnotation[] {
126
+ /** The plan body without markers, plus the markers that were removed from it. */
127
+ function scanMarkers(plan: string): { clean: string; markers: StoredMarker[] } {
70
128
  const fenced = fencedLines(plan);
71
129
  const clean: string[] = [];
72
- const markers: Array<{ after: number; comment: string }> = [];
130
+ const markers: StoredMarker[] = [];
73
131
  plan.split("\n").forEach((line, index) => {
74
132
  const match = fenced.has(index) ? null : MARKER_LINE.exec(line);
75
- if (match) {
76
- const comment = match[1].trim();
77
- if (comment) markers.push({ after: clean.length, comment });
133
+ if (!match) {
134
+ clean.push(line);
78
135
  return;
79
136
  }
80
- clean.push(line);
137
+ const body = match[1].trim();
138
+ if (body === "") return;
139
+ const authored = AUTHOR_PREFIX.exec(body);
140
+ const author = authored ? normalizeAuthor(authored[1]) : "";
141
+ const comment = (authored ? authored[2] : body).trim();
142
+ if (comment) markers.push({ at: clean.length - 1, comment, author });
81
143
  });
82
- if (markers.length === 0) return [];
144
+ return { clean: clean.join("\n"), markers };
145
+ }
83
146
 
84
- const blocks = planBlocks(clean.join("\n"));
85
- return markers.map(({ after, comment }) => {
86
- const anchorLine = after - 1;
87
- const block = anchorLine >= 0 ? blockAt(blocks, anchorLine) : undefined;
88
- return { anchor: block ? block.start : 0, comment };
89
- });
147
+ /** One marker line in its stored form. */
148
+ function markerLine(author: string, comment: string): string {
149
+ const body = sanitizeLine(comment);
150
+ return author ? `> ${REVIEW_MARKER} @${author}: ${body}` : `> ${REVIEW_MARKER} ${body}`;
151
+ }
152
+
153
+ /** A marker waiting to be placed after clean-plan line `at`. */
154
+ interface Placed {
155
+ at: number;
156
+ line: string;
90
157
  }
91
158
 
92
159
  /**
93
- * Write `annotations` into `plan`, replacing any feedback already there. A
94
- * comment is placed after the whole anchored block, so a fenced code block is
95
- * never split and a list item gains its comment as a following quote.
160
+ * Rebuild `clean` with `entries` inserted after the lines they name, grouping
161
+ * several entries on one line in the order given. `-1` places at the top.
96
162
  */
97
- export function formatFeedback(plan: string, annotations: PlanAnnotation[]): string {
98
- const clean = stripFeedback(plan);
99
- const items = annotations
100
- .map((annotation) => ({ ...annotation, comment: sanitizeLine(annotation.comment) }))
101
- .filter((annotation) => annotation.comment.length > 0 && annotation.comment.length <= MAX_COMMENT_LENGTH);
102
- if (items.length === 0) return clean;
103
- if (clean === "") return items.map((annotation) => `> ${REVIEW_MARKER} ${annotation.comment}`).join("\n");
104
-
105
- const blocks = planBlocks(clean);
106
- // Insertion line -> marker lines, so several comments on one block stay grouped
107
- // in the order they were written.
163
+ function compose(clean: string, entries: Placed[]): string {
164
+ if (entries.length === 0) return clean;
165
+ if (clean === "") return entries.map((entry) => entry.line).join("\n");
108
166
  const buckets = new Map<number, string[]>();
109
- for (const annotation of items) {
110
- const block = blockAt(blocks, annotation.anchor);
111
- const at = block ? block.end : -1;
112
- const bucket = buckets.get(at) ?? [];
113
- bucket.push(`> ${REVIEW_MARKER} ${annotation.comment}`);
114
- buckets.set(at, bucket);
167
+ for (const entry of entries) {
168
+ const bucket = buckets.get(entry.at) ?? [];
169
+ bucket.push(entry.line);
170
+ buckets.set(entry.at, bucket);
115
171
  }
116
-
172
+ const lines = clean.split("\n");
117
173
  const out: string[] = [];
118
174
  const flush = (at: number): void => {
119
175
  const bucket = buckets.get(at);
120
176
  if (bucket) out.push(...bucket);
121
177
  };
122
178
  flush(-1);
123
- clean.split("\n").forEach((line, index) => {
179
+ lines.forEach((line, index) => {
124
180
  out.push(line);
125
181
  flush(index);
126
182
  });
183
+ // A marker placed past the last line still lands after it rather than nowhere.
184
+ flush(lines.length);
127
185
  return out.join("\n");
128
186
  }
129
187
 
188
+ /** Remove every stored feedback line, leaving the plan body as authored. */
189
+ export function stripFeedback(plan: string): string {
190
+ return scanMarkers(plan).clean;
191
+ }
192
+
193
+ /**
194
+ * Read stored feedback back. The anchor is the block ending just before each
195
+ * marker in the stripped plan, so a comment keeps its place across reloads.
196
+ */
197
+ export function parseFeedback(plan: string): PlanAnnotation[] {
198
+ const { clean, markers } = scanMarkers(plan);
199
+ if (markers.length === 0) return [];
200
+ const blocks = planBlocks(clean);
201
+ return markers.map((marker) => {
202
+ const block = blockAt(blocks, marker.at);
203
+ const annotation: PlanAnnotation = { anchor: block ? block.start : 0, comment: marker.comment };
204
+ if (marker.author) annotation.author = marker.author;
205
+ return annotation;
206
+ });
207
+ }
208
+
209
+ /** Sanitized, non-empty comments with the anchor they land on. */
210
+ function storableComments(annotations: readonly PlanAnnotation[]): Array<{ anchor: number; comment: string }> {
211
+ const items: Array<{ anchor: number; comment: string }> = [];
212
+ for (const annotation of annotations) {
213
+ const comment = sanitizeLine(annotation.comment);
214
+ if (comment.length === 0 || comment.length > MAX_COMMENT_LENGTH) continue;
215
+ items.push({ anchor: annotation.anchor, comment });
216
+ }
217
+ return items;
218
+ }
219
+
220
+ /**
221
+ * Write `annotations` into `plan` as `options.author`'s comments, replacing the
222
+ * markers that author already had and keeping every other writer's in place.
223
+ * With no author, the writer is the plan owner — the untagged markers.
224
+ */
225
+ export function formatFeedback(plan: string, annotations: PlanAnnotation[], options: { author?: string } = {}): string {
226
+ const author = normalizeAuthor(options.author);
227
+ const { clean, markers } = scanMarkers(plan);
228
+ const kept = markers
229
+ .filter((marker) => marker.author !== author)
230
+ .map((marker) => ({ at: marker.at, line: markerLine(marker.author, marker.comment) }));
231
+ const blocks = planBlocks(clean);
232
+ const added = storableComments(annotations).map((item) => ({
233
+ at: blockAt(blocks, item.anchor)?.end ?? -1,
234
+ line: markerLine(author, item.comment),
235
+ }));
236
+ return compose(clean, [...kept, ...added]);
237
+ }
238
+
239
+ /** Text normalized the way an anchor match compares it. */
240
+ function matchingText(text: string): string {
241
+ return text.replace(/\s+/g, " ").trim().toLowerCase();
242
+ }
243
+
244
+ /** Block text normalized for matching a comment's anchor across a rewrite. */
245
+ function blockKey(block: PlanBlock): string {
246
+ return matchingText(block.lines.join(" "));
247
+ }
248
+
249
+ /**
250
+ * A plan's heading sections, keyed by title and occurrence, each with the blocks
251
+ * inside it and its last source line. Two `## Notes` sections are told apart by
252
+ * which one they are, the way `changedSections` does it, so a duplicate heading
253
+ * never collects both sections' blocks.
254
+ */
255
+ function sectionIndex(plan: string, blocks: readonly PlanBlock[]): Map<string, { blocks: PlanBlock[]; end: number }> {
256
+ const sections = extractPlanSections(plan);
257
+ const lineCount = plan === "" ? 0 : plan.split("\n").length;
258
+ const seen = new Map<string, number>();
259
+ const index = new Map<string, { blocks: PlanBlock[]; end: number }>();
260
+ const keys = sections.map((section, position) => {
261
+ const occurrence = seen.get(section.title) ?? 0;
262
+ seen.set(section.title, occurrence + 1);
263
+ const key = `${occurrence}\u0000${section.title}`;
264
+ index.set(key, { blocks: [], end: (sections[position + 1]?.line ?? lineCount) - 1 });
265
+ return key;
266
+ });
267
+
268
+ let position = 0;
269
+ for (const block of blocks) {
270
+ while (position + 1 < sections.length && (sections[position + 1]?.line ?? 0) <= block.start) position += 1;
271
+ const key = keys[position];
272
+ if (key === undefined) continue;
273
+ index.get(key)?.blocks.push(block);
274
+ }
275
+ return index;
276
+ }
277
+
278
+ /**
279
+ * Carry the comments in `previous` onto `next`, the plan as it was rewritten.
280
+ * Comments are not plan content, so `next` is stripped of any marker it carries,
281
+ * and every old one is re-attached by the strongest evidence that survives: the
282
+ * block's text (per occurrence, so two identical steps keep their own comments),
283
+ * then the same section and ordinal, then the end of that section. A comment
284
+ * whose section is gone too is placed at the end rather than dropped, where the
285
+ * review page shows it as unattached.
286
+ */
287
+ export function carryFeedback(previous: string, next: string): string {
288
+ const { clean: oldClean, markers } = scanMarkers(previous);
289
+ const cleanNext = stripFeedback(next);
290
+ if (markers.length === 0) return cleanNext;
291
+ const oldBlocks = planBlocks(oldClean);
292
+ const nextBlocks = planBlocks(cleanNext);
293
+ // Identical blocks queue up in document order, so the first unclaimed one takes
294
+ // the next comment rather than every comment landing on the last of them.
295
+ const queues = new Map<string, PlanBlock[]>();
296
+ for (const block of nextBlocks) {
297
+ if (block.kind === "blank") continue;
298
+ const key = blockKey(block);
299
+ const queue = queues.get(key);
300
+ if (queue) queue.push(block);
301
+ else queues.set(key, [block]);
302
+ }
303
+ const oldSections = sectionIndex(oldClean, oldBlocks);
304
+ const nextSections = sectionIndex(cleanNext, nextBlocks);
305
+ const lastLine = cleanNext === "" ? -1 : cleanNext.split("\n").length - 1;
306
+
307
+ /** The block at the same ordinal in the same section, else that section's end. */
308
+ const bySection = (block: PlanBlock): number | undefined => {
309
+ for (const [key, old] of oldSections) {
310
+ const ordinal = old.blocks.indexOf(block);
311
+ if (ordinal < 0) continue;
312
+ const section = nextSections.get(key);
313
+ if (section === undefined) return undefined;
314
+ const same = section.blocks[ordinal];
315
+ return same ? same.end : section.end;
316
+ }
317
+ return undefined;
318
+ };
319
+
320
+ const placed = markers.map((marker) => {
321
+ const block = blockAt(oldBlocks, marker.at);
322
+ const line = markerLine(marker.author, marker.comment);
323
+ if (block === undefined) return { at: lastLine, line };
324
+ if (block.kind !== "blank") {
325
+ const match = queues.get(blockKey(block))?.shift();
326
+ if (match !== undefined) return { at: match.end, line };
327
+ }
328
+ return { at: bySection(block) ?? lastLine, line };
329
+ });
330
+ return compose(cleanNext, placed);
331
+ }
332
+
333
+ /**
334
+ * Show stored comments in place, as the quotes they are: `@alice: …` for a
335
+ * reviewer, `Review: …` for the owner. A terminal review, a dialog, and a phone
336
+ * prompt read the plan through this, so a comment is never invisible just
337
+ * because it is not the reader's own. Fenced code is opaque, as everywhere else.
338
+ */
339
+ export function displayFeedback(plan: string): string {
340
+ const fenced = fencedLines(plan);
341
+ return plan
342
+ .split("\n")
343
+ .map((line, index) => {
344
+ if (fenced.has(index)) return line;
345
+ const match = MARKER_LINE.exec(line);
346
+ if (!match) return line;
347
+ const body = match[1].trim();
348
+ if (body === "") return line;
349
+ const authored = AUTHOR_PREFIX.exec(body);
350
+ const author = authored ? normalizeAuthor(authored[1]) : "";
351
+ const comment = (authored ? authored[2] : body).trim();
352
+ if (!comment) return line;
353
+ return author ? `> @${author}: ${comment}` : `> Review: ${comment}`;
354
+ })
355
+ .join("\n");
356
+ }
357
+
130
358
  /** One-line quote for the model summary. */
131
359
  function shortQuote(quote: string): string {
132
360
  const clean = sanitizeLine(quote);
133
361
  return clean.length > 80 ? `${clean.slice(0, 79).trimEnd()}…` : clean;
134
362
  }
135
363
 
136
- /** Numbered, model-facing text for the comments a save carried. */
364
+ /** Numbered, model-facing text for the comments a review carried. */
137
365
  export function feedbackSummary(annotations: PlanAnnotation[]): string {
138
366
  const items = annotations
139
367
  .map((annotation) => ({
140
368
  quote: annotation.quote ? shortQuote(annotation.quote) : "",
369
+ // A name attached by a payload is data from another browser, not a label
370
+ // the plan owner chose, so it is normalized like every other author.
371
+ author: normalizeAuthor(annotation.author),
141
372
  comment: sanitizeLine(annotation.comment),
142
373
  }))
143
374
  .filter((item) => item.comment.length > 0);
144
375
  return items
145
- .map((item, index) => `${index + 1}. ${item.quote ? `On "${item.quote}": ` : ""}${item.comment}`)
376
+ .map((item, index) => {
377
+ const who = item.author ? `@${item.author}: ` : "";
378
+ const where = item.quote ? `On "${item.quote}": ` : "";
379
+ return `${index + 1}. ${who}${where}${item.comment}`;
380
+ })
146
381
  .join("\n");
147
382
  }
148
383
 
149
384
  /**
150
- * Validate an untrusted annotations payload from the browser. Returns a
385
+ * Validate an untrusted annotations payload from a browser. Returns a
151
386
  * normalized list, or `undefined` when the shape is wrong. Empty comments are
152
387
  * dropped rather than rejected, so saving after deleting every comment clears
153
- * the file's feedback.
388
+ * that writer's feedback.
154
389
  */
155
390
  export function coerceAnnotations(value: unknown): PlanAnnotation[] | undefined {
156
391
  if (!Array.isArray(value) || value.length > MAX_ANNOTATIONS) return undefined;
@@ -162,11 +397,13 @@ export function coerceAnnotations(value: unknown): PlanAnnotation[] | undefined
162
397
  if (typeof anchor !== "number" || !Number.isInteger(anchor) || anchor < 0) return undefined;
163
398
  if (typeof record.comment !== "string") return undefined;
164
399
  if (record.quote !== undefined && typeof record.quote !== "string") return undefined;
400
+ if (record.author !== undefined && typeof record.author !== "string") return undefined;
165
401
  const comment = sanitizeLine(record.comment);
166
402
  if (comment.length > MAX_COMMENT_LENGTH) return undefined;
167
403
  if (comment.length === 0) continue;
168
404
  const quote = typeof record.quote === "string" ? sanitizeLine(record.quote).slice(0, MAX_QUOTE_LENGTH) : undefined;
169
- annotations.push(quote ? { anchor, quote, comment } : { anchor, comment });
405
+ const author = normalizeAuthor(record.author);
406
+ annotations.push({ anchor, comment, ...(quote ? { quote } : {}), ...(author ? { author } : {}) });
170
407
  }
171
408
  return annotations;
172
409
  }
@@ -6,7 +6,7 @@
6
6
  * read-only git command, powershell is blocked, and everything else that mutates
7
7
  * (worktree, MCP tools) is blocked. `subagent` stays active but is forced
8
8
  * read-only. The one permitted write is `write_plan`,
9
- * which saves the plan under `.pi/plans`. The model investigates, saves the
9
+ * which saves the plan in the agent directory. The model investigates, saves the
10
10
  * plan, and calls `exit_plan_mode` to read the file back and ask for approval.
11
11
  *
12
12
  * Toggle with `/plan`, `Ctrl+Alt+P`, or start with `--plan`. The state is
@@ -25,7 +25,7 @@ import type { PlanSummary } from "./plans.ts";
25
25
 
26
26
  /** Rows the screen shows when the terminal height is unknown. */
27
27
  const DEFAULT_ROWS = 14;
28
- /** Fixed rows around the list: header, summary, blank, range, detail, hint, blank. */
28
+ /** Fixed rows around the list: header, summary, blank, range, detail, hint, closing rule. */
29
29
  const CHROME = 7;
30
30
  /** Longest title a row draws before the trailing meta wins the column. */
31
31
  const TITLE_WIDTH = 56;
@@ -207,7 +207,7 @@ export class PlanIndexComponent implements Component {
207
207
 
208
208
  const body: string[] = [];
209
209
  if (this.summaries.length === 0) {
210
- body.push(...emptyState(theme, w, { label: "No plans yet. write_plan saves one under .pi/plans." }));
210
+ body.push(...emptyState(theme, w, { label: "No plans yet. write_plan saves one in the agent directory." }));
211
211
  } else {
212
212
  const { start, end } = this.cursor.window(this.summaries.length);
213
213
  for (let index = start; index < end; index++) {
@@ -221,6 +221,8 @@ export class PlanIndexComponent implements Component {
221
221
  : screenHint(theme, w, ["Esc close", { text: "Enter review", color: "accent" }, "r reload", "↑/↓", "g/G"]);
222
222
 
223
223
  return dockListFrame({
224
+ theme,
225
+ width: w,
224
226
  header: titleRule(theme, w, "Plan index"),
225
227
  summary: this.summaryLine(w),
226
228
  body,