@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
@@ -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,6 +23,7 @@ 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";
25
28
  const CANCEL_PREFIX = "cancel";
26
29
 
@@ -67,6 +70,44 @@ function isReviewSubcommand(prompt: string): boolean {
67
70
  return target.endsWith(".md") || target.includes("/") || target.includes("\\");
68
71
  }
69
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
+
70
111
  /**
71
112
  * Open `planPath` in the browser and, if the user saves comments, hand them to
72
113
  * the model. A cancel is silent beyond a notice; nothing is sent.
@@ -137,6 +178,10 @@ async function openPlanIndex(
137
178
  ctx: ExtensionContext,
138
179
  ): Promise<void> {
139
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");
140
185
  const summarise = async (): Promise<Awaited<ReturnType<typeof runtime.plans.index>>> => runtime.plans.index(ctx.cwd);
141
186
  const summaries = await summarise();
142
187
 
@@ -173,11 +218,13 @@ export function registerCommands(
173
218
  ): void {
174
219
  pi.registerCommand("plan", {
175
220
  description:
176
- "Toggle plan mode (read-only exploration), enter it with an optional task, list saved plans, review one in the browser, or cancel planning without approving",
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",
177
222
  getArgumentCompletions: (prefix) => {
178
223
  const text = prefix.trimStart();
179
224
  if (text.includes(" ")) return null;
180
- const subcommands = [LIST_PREFIX, REVIEW_PREFIX, CANCEL_PREFIX].filter((name) => name.startsWith(text));
225
+ const subcommands = [LIST_PREFIX, REVIEW_PREFIX, SHARE_PREFIX, CANCEL_PREFIX].filter((name) =>
226
+ name.startsWith(text),
227
+ );
181
228
  return subcommands.length > 0 ? subcommands.map((value) => ({ value, label: value })) : null;
182
229
  },
183
230
  handler: async (args, ctx) => {
@@ -198,6 +245,10 @@ export function registerCommands(
198
245
  await openPlanIndex(pi, runtime, web, ctx);
199
246
  return;
200
247
  }
248
+ if (isShareSubcommand(prompt)) {
249
+ await shareReview(web, prompt, ctx);
250
+ return;
251
+ }
201
252
  if (isReviewSubcommand(prompt)) {
202
253
  await reviewInBrowser(pi, runtime, web, reviewTarget(prompt), ctx);
203
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
@@ -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++) {
@@ -15,7 +15,7 @@
15
15
 
16
16
  import { createHash } from "node:crypto";
17
17
  import { stripControlChars } from "../../lib/format.ts";
18
- import { stripFeedback } from "./feedback.ts";
18
+ import { displayFeedback, stripFeedback } from "./feedback.ts";
19
19
  import { extractPlanSections } from "./steps.ts";
20
20
 
21
21
  /** Spaces a tab expands to; the terminal's own tab stops are not width-modelled. */
@@ -38,6 +38,14 @@ export function sanitizePlanText(text: string): string {
38
38
  return stripControlChars(text.replace(/\t/g, " ".repeat(TAB_WIDTH)));
39
39
  }
40
40
 
41
+ /**
42
+ * The text a review *shows*: terminal-safe, with stored comments kept in place
43
+ * as readable quotes, so a reviewer sees what a teammate wrote.
44
+ */
45
+ export function planDisplayText(content: string): string {
46
+ return displayFeedback(sanitizePlanText(content));
47
+ }
48
+
41
49
  /**
42
50
  * The text a review compares: terminal-safe, with tabs expanded and stored
43
51
  * feedback removed. A review comment a browser wrote into the file is a note to
@@ -52,6 +60,15 @@ function digestOf(text: string): string {
52
60
  return createHash("sha256").update(text.trim()).digest("hex");
53
61
  }
54
62
 
63
+ /**
64
+ * A short, stable id for one plan text. The review page keys this tab's unsaved
65
+ * draft on it, so a draft is restored for the plan it was written against and
66
+ * dropped for a plan that changed underneath it.
67
+ */
68
+ export function planDigest(text: string): string {
69
+ return digestOf(text).slice(0, 12);
70
+ }
71
+
55
72
  /** One digest per heading section, in document order, for a persisted baseline. */
56
73
  export function sectionDigests(content: string): SectionDigest[] {
57
74
  return extractPlanSections(reviewText(content)).map((section) => ({