@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
@@ -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) => ({
@@ -4,13 +4,19 @@
4
4
  * A plan is written to disk as markdown: one file per plan, named from the
5
5
  * title and stamp. A refinement rewrites that file atomically, so the user can
6
6
  * read the same artifact the model is working from and the plan survives
7
- * outside the conversation without a partial write ever being visible. Files
8
- * live under `<repo-root>/.pi/plans` (the worktree root inside a worktree), or
9
- * under `<agent-dir>/plans` outside a repository.
7
+ * outside the conversation without a partial write ever being visible.
10
8
  *
11
- * The directory is self-ignoring: the first write drops a `.gitignore` with `*`
12
- * so plans never show up as untracked files or inside rewind snapshots,
13
- * without editing the project's own `.gitignore`.
9
+ * Plans live under `<agent-dir>/plans/<repo-key>`, keyed by the repository's
10
+ * common git directory, so every worktree and every session of one repository
11
+ * reads one store — a plan approved in a worktree is the same plan from the main
12
+ * checkout. Outside a repository they land under `<agent-dir>/plans`. An
13
+ * extension tool sees the session cwd rather than the worktree the user entered,
14
+ * so the active worktree is applied before any path is resolved.
15
+ *
16
+ * Plans are outside the project, so they are never untracked files and never
17
+ * appear in a rewind snapshot. A store written by an older version, under
18
+ * `<repo>/.pi/plans`, is moved in once, and nothing is deleted before its plans
19
+ * have arrived.
14
20
  *
15
21
  * This module owns paths and bytes only; tool wiring lives in `tools.ts`.
16
22
  */
@@ -23,16 +29,20 @@ import {
23
29
  readFileSync,
24
30
  readdirSync,
25
31
  renameSync,
32
+ rmdirSync,
26
33
  statSync,
27
34
  unlinkSync,
28
35
  writeFileSync,
29
36
  } from "node:fs";
30
- import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
37
+ import { homedir } from "node:os";
38
+ import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
31
39
  import { CONFIG_DIR_NAME, getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
32
- import { repoRootFor } from "../../lib/git/index.ts";
33
- import { sanitizeLine } from "../../lib/format.ts";
40
+ import { resolveEffectiveCwd } from "../../lib/env.ts";
41
+ import { sanitizeLine, shortenPath } from "../../lib/format.ts";
34
42
  import { isInside, isInsideReal } from "../../lib/path.ts";
35
- import { formatFeedback, type PlanAnnotation } from "./feedback.ts";
43
+ import { plansDir } from "../../lib/plans/store.ts";
44
+ import { repoKeyFor } from "../../lib/repo-key.ts";
45
+ import { carryFeedback, formatFeedback, normalizeAuthor, type PlanAnnotation } from "./feedback.ts";
36
46
  import { extractPlanSteps, matchesStepText, setStepStatuses, type StepUpdate } from "./steps.ts";
37
47
 
38
48
  /** Largest plan accepted, in UTF-8 bytes. */
@@ -44,7 +54,7 @@ const MAX_SLUG_LENGTH = 60;
44
54
  export interface StoredPlan {
45
55
  /** Absolute path. */
46
56
  path: string;
47
- /** Path relative to the working directory, when it is inside it. */
57
+ /** How the path is shown to the user: `~`-shortened, since plans live outside the project. */
48
58
  relativePath: string;
49
59
  content: string;
50
60
  bytes: number;
@@ -59,7 +69,7 @@ export interface WrittenPlan extends StoredPlan {
59
69
  export interface PlanSummary {
60
70
  /** Absolute path. */
61
71
  path: string;
62
- /** Path relative to the working directory, when it is inside it. */
72
+ /** How the path is shown to the user: `~`-shortened, since plans live outside the project. */
63
73
  relativePath: string;
64
74
  /** The plan's `# title`, or a title derived from the file name. */
65
75
  title: string;
@@ -84,6 +94,13 @@ export interface WritePlanInput {
84
94
  export interface PlanStore {
85
95
  /** Directory plans are written to for `cwd`. */
86
96
  dirFor(cwd: string): Promise<string>;
97
+ /**
98
+ * The repository root `cwd` belongs to, or undefined outside a repository.
99
+ *
100
+ * A repository-scoped setting — the split switch — is read at the root, so a
101
+ * session started in a subdirectory still sees what the project turned on.
102
+ */
103
+ rootFor(cwd: string): Promise<string | undefined>;
87
104
  write(cwd: string, input: WritePlanInput): Promise<WrittenPlan>;
88
105
  /** Read a plan inside the plans directory, or undefined when missing/outside. */
89
106
  read(cwd: string, planPath: string): Promise<StoredPlan | undefined>;
@@ -96,8 +113,20 @@ export interface PlanStore {
96
113
  index(cwd: string): Promise<PlanSummary[]>;
97
114
  /** Set the given top-level steps' checkboxes in an existing plan. Throws when the file or any step is invalid. */
98
115
  setSteps(cwd: string, planPath: string, updates: StepUpdate[]): Promise<StoredPlan>;
99
- /** Replace the plan's stored browser feedback. Throws when the file is missing or outside the plans directory. */
100
- saveFeedback(cwd: string, planPath: string, annotations: PlanAnnotation[]): Promise<StoredPlan>;
116
+ /** Replace the comments of `options.author` (the owner when absent) and keep every other writer's. */
117
+ saveFeedback(
118
+ cwd: string,
119
+ planPath: string,
120
+ annotations: PlanAnnotation[],
121
+ options?: { author?: string },
122
+ ): Promise<StoredPlan>;
123
+ /**
124
+ * The line a one-time migration wants reported, or undefined.
125
+ *
126
+ * A moved store is the user's business — plans are not where they used to be —
127
+ * so the first caller that can show a notice takes it and clears it.
128
+ */
129
+ takeNotice(): string | undefined;
101
130
  }
102
131
 
103
132
  /** Timestamp prefix the plan store adds to file names. */
@@ -152,16 +181,19 @@ function isContainedPlan(dir: string, path: string): boolean {
152
181
  return isInsideReal(dir, dirname(path));
153
182
  }
154
183
 
155
- /** Keep plan files out of git (and so out of rewind snapshots) without touching the project. */
156
- function ensureIgnored(dir: string): void {
157
- const ignore = join(dir, ".gitignore");
158
- try {
159
- // `wx` is race-free: an existing ignore file (ours or a user's) is left alone.
160
- writeFileSync(ignore, "*\n", { encoding: "utf-8", flag: "wx" });
161
- } catch {
162
- // A read-only plans directory will fail on the plan write anyway; the
163
- // ignore file is best-effort.
164
- }
184
+ /**
185
+ * Resolve a `plan_path` argument against the store.
186
+ *
187
+ * A plan lives outside the project, so a path relative to the working directory
188
+ * can no longer name one. Absolute paths and the `~/…` form the tools display are
189
+ * accepted, and a bare name resolves inside the store — which is what a reader of
190
+ * a tool result has in hand.
191
+ */
192
+ function resolvePlanArg(dir: string, planPath: string): string {
193
+ const text = planPath.trim();
194
+ if (text === "~") return homedir();
195
+ if (text.startsWith("~/") || text.startsWith(`~${sep}`)) return resolve(homedir(), text.slice(2));
196
+ return isAbsolute(text) ? resolve(text) : resolve(dir, text);
165
197
  }
166
198
 
167
199
  /**
@@ -185,12 +217,6 @@ function writeAtomic(path: string, content: string): void {
185
217
  }
186
218
  }
187
219
 
188
- /** `path` relative to `cwd` when inside it, else `path`. */
189
- function relativeTo(cwd: string, path: string): string {
190
- const rel = relative(cwd, path);
191
- return rel && rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel) ? rel : path;
192
- }
193
-
194
220
  function uniquePath(dir: string, base: string): string {
195
221
  const taken = (candidate: string): boolean => {
196
222
  try {
@@ -211,27 +237,103 @@ function uniquePath(dir: string, base: string): string {
211
237
  return candidate;
212
238
  }
213
239
 
214
- /** Repository (or worktree) root of `cwd`, or undefined outside a repository. */
215
- async function repoRoot(pi: ExtensionAPI, cwd: string): Promise<string | undefined> {
216
- // Local `rev-parse` is fast; cap it so a stuck call cannot hang the tool.
217
- return repoRootFor(pi, cwd, 5_000);
240
+ /** A repository (or worktree) root is applied before any path here is resolved. */
241
+
242
+ /** How many legacy plans one migration moves, so a pathological directory cannot stall a tool call. */
243
+ const MAX_MIGRATED_PLANS = 200;
244
+
245
+ /**
246
+ * Move a store written by an older version, under `<repo>/.pi/plans`, into `dir`.
247
+ *
248
+ * Copy-then-unlink when a rename crosses a filesystem boundary, so a store on a
249
+ * different volume still arrives. Nothing here deletes before its plan has
250
+ * landed, a file that cannot move is left where it is, and the old directory
251
+ * goes only when what remains is the ignore file this package used to write.
252
+ */
253
+ function migrateLegacyStore(dir: string, legacy: string): number {
254
+ if (!existsSync(legacy)) return 0;
255
+ let names: string[];
256
+ try {
257
+ names = readdirSync(legacy);
258
+ } catch {
259
+ return 0;
260
+ }
261
+ const plans = names.filter((name) => isPlanFileName(name)).slice(0, MAX_MIGRATED_PLANS);
262
+ let moved = 0;
263
+ for (const name of plans) {
264
+ const from = join(legacy, name);
265
+ try {
266
+ if (!statSync(from).isFile()) continue;
267
+ mkdirSync(dir, { recursive: true });
268
+ const to = uniquePath(dir, name.replace(/\.md$/i, ""));
269
+ try {
270
+ renameSync(from, to);
271
+ } catch {
272
+ writeFileSync(to, readFileSync(from), { flag: "wx" });
273
+ unlinkSync(from);
274
+ }
275
+ moved += 1;
276
+ } catch {
277
+ // A plan that cannot move stays where it is, and the directory stays with it.
278
+ }
279
+ }
280
+ try {
281
+ if (readdirSync(legacy).every((name) => name === ".gitignore")) {
282
+ const ignore = join(legacy, ".gitignore");
283
+ if (existsSync(ignore) && readFileSync(ignore, "utf-8") === "*\n") unlinkSync(ignore);
284
+ rmdirSync(legacy);
285
+ }
286
+ } catch {
287
+ // Leave a directory that still holds something.
288
+ }
289
+ return moved;
218
290
  }
219
291
 
220
292
  export function createPlanStore(pi: ExtensionAPI, options: { now?: () => Date } = {}): PlanStore {
221
293
  const now = options.now ?? (() => new Date());
222
- // One `git rev-parse` per working directory; a worktree switch is a new cwd.
223
- const dirCache = new Map<string, string>();
294
+ // One `git rev-parse` per effective root; a worktree switch changes that root.
295
+ const dirCache = new Map<string, { dir: string; root: string | undefined }>();
296
+ let notice: string | undefined;
297
+
298
+ const takeNotice = (): string | undefined => {
299
+ const line = notice;
300
+ notice = undefined;
301
+ return line;
302
+ };
303
+
304
+ /**
305
+ * The root every path here is resolved against.
306
+ *
307
+ * A tool registered by an extension receives the session's cwd, not the
308
+ * worktree the user entered: only the built-ins are re-rooted. The worktree
309
+ * extension publishes its root in the environment, so this is where the two
310
+ * meet, and it is applied before the repository lookup, the migration of an
311
+ * older store, and the plan path resolution.
312
+ */
313
+ const baseOf = (cwd: string): string => resolveEffectiveCwd(cwd);
224
314
 
225
- const dirFor = async (cwd: string): Promise<string> => {
226
- const cached = dirCache.get(cwd);
315
+ const resolve = async (cwd: string): Promise<{ dir: string; root: string | undefined }> => {
316
+ const base = baseOf(cwd);
317
+ const cached = dirCache.get(base);
227
318
  if (cached) return cached;
228
- const root = await repoRoot(pi, cwd);
229
- const dir = root ? join(root, CONFIG_DIR_NAME, "plans") : join(getAgentDir(), "plans");
230
- dirCache.set(cwd, dir);
231
- return dir;
319
+ const repo = await repoKeyFor(pi, base);
320
+ const dir = plansDir(getAgentDir(), repo.fromGit ? repo.key : undefined);
321
+ dirCache.set(base, { dir, root: repo.fromGit ? repo.root : undefined });
322
+ if (repo.fromGit) {
323
+ // The keyed directory is the store; a store an older version left in the
324
+ // checkout moves in once, so an upgrade does not strand its plans.
325
+ const moved = migrateLegacyStore(dir, join(base, CONFIG_DIR_NAME, "plans"));
326
+ if (moved > 0) notice = `Moved ${moved} plan${moved === 1 ? "" : "s"} to ${shortenPath(dir)}.`;
327
+ }
328
+ return { dir, root: repo.fromGit ? repo.root : undefined };
232
329
  };
233
330
 
331
+ const dirFor = async (cwd: string): Promise<string> => (await resolve(cwd)).dir;
332
+
333
+ const rootFor = async (cwd: string): Promise<string | undefined> => (await resolve(cwd)).root;
334
+
234
335
  const write = async (cwd: string, input: WritePlanInput): Promise<WrittenPlan> => {
336
+ const base = baseOf(cwd);
235
337
  const title = input.title.trim();
236
338
  if (!title) throw new Error("A plan title is required.");
237
339
  const bytes = Buffer.byteLength(input.content, "utf-8");
@@ -239,13 +341,12 @@ export function createPlanStore(pi: ExtensionAPI, options: { now?: () => Date }
239
341
  throw new Error(`The plan is too large (${bytes} bytes; the limit is ${MAX_PLAN_BYTES}).`);
240
342
  }
241
343
 
242
- const dir = await dirFor(cwd);
344
+ const dir = await dirFor(base);
243
345
  mkdirSync(dir, { recursive: true });
244
- ensureIgnored(dir);
245
346
 
246
347
  let path: string;
247
348
  if (input.planPath) {
248
- const resolved = resolve(cwd, input.planPath);
349
+ const resolved = resolvePlanArg(dir, input.planPath);
249
350
  if (!isContainedPlan(dir, resolved)) {
250
351
  throw new Error("plan_path must be a markdown plan file inside the plans directory.");
251
352
  }
@@ -259,19 +360,30 @@ export function createPlanStore(pi: ExtensionAPI, options: { now?: () => Date }
259
360
  throw new Error("The plan path is not writable inside the plans directory.");
260
361
  }
261
362
 
262
- writeAtomic(path, input.content);
263
- return { path, relativePath: relativeTo(cwd, path), title, content: input.content, bytes };
363
+ // A refinement rewrites the body but not the review: every stored comment is
364
+ // carried onto the block whose text survived, so revising a plan under review
365
+ // does not silently discard what a teammate wrote.
366
+ const previous = existsSync(path) ? readFileSync(path, "utf-8") : undefined;
367
+ const content = previous === undefined ? carryFeedback("", input.content) : carryFeedback(previous, input.content);
368
+ const nextBytes = Buffer.byteLength(content, "utf-8");
369
+ if (nextBytes > MAX_PLAN_BYTES) {
370
+ throw new Error(`The plan is too large with its review comments (${nextBytes} bytes).`);
371
+ }
372
+
373
+ writeAtomic(path, content);
374
+ return { path, relativePath: shortenPath(path), title, content, bytes: nextBytes };
264
375
  };
265
376
 
266
377
  const read = async (cwd: string, planPath: string): Promise<StoredPlan | undefined> => {
267
- const dir = await dirFor(cwd);
268
- const resolved = resolve(cwd, planPath);
378
+ const base = baseOf(cwd);
379
+ const dir = await dirFor(base);
380
+ const resolved = resolvePlanArg(dir, planPath);
269
381
  if (!isContainedPlan(dir, resolved) || !existsSync(resolved)) return undefined;
270
382
  try {
271
383
  const content = readFileSync(resolved, "utf-8");
272
384
  return {
273
385
  path: resolved,
274
- relativePath: relativeTo(cwd, resolved),
386
+ relativePath: shortenPath(resolved),
275
387
  content,
276
388
  bytes: Buffer.byteLength(content, "utf-8"),
277
389
  };
@@ -316,14 +428,27 @@ export function createPlanStore(pi: ExtensionAPI, options: { now?: () => Date }
316
428
  };
317
429
 
318
430
  /**
319
- * Write the browser review comments into an existing plan, replacing any
320
- * feedback already stored there. The file must already be a contained plan,
321
- * so this is the same path-safe write as `setSteps`.
431
+ * Write the browser review comments into an existing plan, replacing the
432
+ * comments of `options.author` (the owner when absent) and leaving every other
433
+ * writer's in place. The file must already be a contained plan, so this is the
434
+ * same path-safe write as `setSteps`.
322
435
  */
323
- const saveFeedback = async (cwd: string, planPath: string, annotations: PlanAnnotation[]): Promise<StoredPlan> => {
436
+ const saveFeedback = async (
437
+ cwd: string,
438
+ planPath: string,
439
+ annotations: PlanAnnotation[],
440
+ options: { author?: string } = {},
441
+ ): Promise<StoredPlan> => {
324
442
  const plan = await read(cwd, planPath);
325
443
  if (!plan) throw new Error("No plan file there; pass a plan_path written by write_plan.");
326
- const content = formatFeedback(plan.content, annotations);
444
+ const author = normalizeAuthor(options.author);
445
+ // An untagged entry is the writer's own; one tagged with a different name is
446
+ // another writer's comment, so it is dropped rather than re-signed.
447
+ const own = annotations.filter((annotation) => {
448
+ const who = normalizeAuthor(annotation.author);
449
+ return who === "" || who === author;
450
+ });
451
+ const content = formatFeedback(plan.content, own, { author });
327
452
  writeAtomic(plan.path, content);
328
453
  return {
329
454
  path: plan.path,
@@ -375,7 +500,8 @@ export function createPlanStore(pi: ExtensionAPI, options: { now?: () => Date }
375
500
  * dotfile, or a file that vanishes mid-walk is simply not a plan.
376
501
  */
377
502
  const index = async (cwd: string): Promise<PlanSummary[]> => {
378
- const dir = await dirFor(cwd);
503
+ const base = baseOf(cwd);
504
+ const dir = await dirFor(base);
379
505
  let names: string[];
380
506
  try {
381
507
  names = readdirSync(dir);
@@ -399,7 +525,7 @@ export function createPlanStore(pi: ExtensionAPI, options: { now?: () => Date }
399
525
 
400
526
  const summary: PlanSummary = {
401
527
  path,
402
- relativePath: relativeTo(cwd, path),
528
+ relativePath: shortenPath(path),
403
529
  title: planTitle(name),
404
530
  modified: info.mtimeMs,
405
531
  bytes: info.size,
@@ -430,5 +556,5 @@ export function createPlanStore(pi: ExtensionAPI, options: { now?: () => Date }
430
556
  return summaries;
431
557
  };
432
558
 
433
- return { dirFor, write, read, latest, index, setSteps, saveFeedback };
559
+ return { dirFor, rootFor, write, read, latest, index, setSteps, saveFeedback, takeNotice };
434
560
  }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Pure rendering and parsing for the compact remote plan review.
3
+ *
4
+ * The prompt and its reply share one action table, so changing a number or a
5
+ * localized label cannot leave the parser behind. File delivery and the review
6
+ * lifecycle stay in `review.ts`.
7
+ */
8
+
9
+ import type { InteractionLanguage } from "../../lib/interaction.ts";
10
+ import { extractPlanSteps } from "./steps.ts";
11
+
12
+ /** The actions the compact prompt can resolve without a refinement. */
13
+ export type PlanReviewAction = "approve" | "keep" | "cancel";
14
+
15
+ /** One editor reply: an action, or free text the model should apply. */
16
+ export type PlanReviewReply =
17
+ | { action: "approve" }
18
+ | { action: "keep" }
19
+ | { action: "cancel" }
20
+ | { action: "refine"; refinement: string };
21
+
22
+ interface ReviewAction {
23
+ key: string;
24
+ action: PlanReviewAction;
25
+ zh: string;
26
+ en: string;
27
+ words: readonly string[];
28
+ }
29
+
30
+ /** The action line and the parser read this one table, in display order. */
31
+ const REVIEW_ACTIONS: readonly ReviewAction[] = [
32
+ { key: "1", action: "approve", zh: "批准", en: "approve", words: ["approve", "批准", "同意"] },
33
+ {
34
+ key: "2",
35
+ action: "keep",
36
+ zh: "继续规划",
37
+ en: "keep planning",
38
+ words: ["keep", "keep planning", "继续", "继续规划"],
39
+ },
40
+ { key: "3", action: "cancel", zh: "退出", en: "exit", words: ["exit", "cancel", "quit", "退出", "取消"] },
41
+ ];
42
+
43
+ /** How much of one step the remote digest shows. */
44
+ const DIGEST_STEP_CHARS = 90;
45
+
46
+ /**
47
+ * One compact remote prompt: title, first steps, mode status, and the action
48
+ * line. The plan file is sent before it; a non-action reply refines the plan.
49
+ */
50
+ export function planReviewPrompt(content: string, language: InteractionLanguage, attached: boolean): string {
51
+ const title = /^#\s+(.+)$/m.exec(content)?.[1]?.trim();
52
+ const steps = extractPlanSteps(content);
53
+ const count = steps.length;
54
+ const head =
55
+ language === "zh"
56
+ ? `计划${title ? `:${title}` : ""}${count > 0 ? `(${count} 步)` : ""}`
57
+ : `Plan${title ? `: ${title}` : ""}${count > 0 ? ` (${count} step${count === 1 ? "" : "s"})` : ""}`;
58
+ const preview = steps.slice(0, 3).map((step, index) => {
59
+ const text = step.content.replace(/\s+/g, " ").trim();
60
+ const shown = text.length > DIGEST_STEP_CHARS ? `${text.slice(0, DIGEST_STEP_CHARS - 1)}…` : text;
61
+ return `${index + 1}. ${shown}`;
62
+ });
63
+ const more =
64
+ count > preview.length
65
+ ? language === "zh"
66
+ ? `… 还有 ${count - preview.length} 步`
67
+ : `… (${count - preview.length} more)`
68
+ : "";
69
+ const status = language === "zh" ? "Plan 模式开启,写入已关闭。" : "Plan mode is on — write access is off.";
70
+ const attachment = attached ? (language === "zh" ? "计划已在上方。" : "The plan is above.") : "";
71
+ const choices = REVIEW_ACTIONS.map((action) => `${action.key} ${action[language]}`).join(
72
+ language === "zh" ? "," : ", ",
73
+ );
74
+ const actions =
75
+ language === "zh"
76
+ ? `回复 ${choices};或直接输入修改意见(想做别的先退出或批准)。`
77
+ : `Reply ${choices} — or type feedback to revise. To ask something else, exit or approve first.`;
78
+ return [head, ...preview, more, status, attachment, actions].filter((line) => line !== "").join("\n");
79
+ }
80
+
81
+ /**
82
+ * Parse the one editor reply. Only an exact number or action word is an action;
83
+ * any other non-empty text is a refinement request.
84
+ */
85
+ export function parsePlanReviewReply(raw: string): PlanReviewReply {
86
+ const text = raw.trim();
87
+ if (text === "") return { action: "cancel" };
88
+ const key = text.toLowerCase();
89
+ for (const action of REVIEW_ACTIONS) {
90
+ if (key === action.key || action.words.includes(key)) return { action: action.action };
91
+ }
92
+ return { action: "refine", refinement: text };
93
+ }