@gavin-hu/my-pi-agent 0.20.0 → 0.22.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 (298) hide show
  1. package/README.md +46 -30
  2. package/README.zh-CN.md +40 -25
  3. package/assets/screens/board.png +0 -0
  4. package/assets/screens/chrome.png +0 -0
  5. package/assets/screens/file-browse.png +0 -0
  6. package/assets/screens/plan-review.png +0 -0
  7. package/assets/screens/plan.png +0 -0
  8. package/docs/extensions/README.md +22 -23
  9. package/docs/extensions/board.md +397 -168
  10. package/docs/extensions/bytefly.md +234 -197
  11. package/docs/extensions/check.md +15 -17
  12. package/docs/extensions/context.md +35 -15
  13. package/docs/extensions/file-browse.md +57 -63
  14. package/docs/extensions/file-open.md +9 -11
  15. package/docs/extensions/file-read.md +12 -14
  16. package/docs/extensions/goal.md +15 -17
  17. package/docs/extensions/job.md +54 -42
  18. package/docs/extensions/memory.md +17 -19
  19. package/docs/extensions/plan.md +197 -140
  20. package/docs/extensions/question.md +16 -18
  21. package/docs/extensions/rewind.md +17 -19
  22. package/docs/extensions/serve.md +145 -0
  23. package/docs/extensions/session-bus.md +38 -16
  24. package/docs/extensions/subagent.md +23 -25
  25. package/docs/extensions/team.md +83 -44
  26. package/docs/extensions/todo.md +18 -18
  27. package/docs/extensions/watch.md +31 -31
  28. package/docs/extensions/web-access.md +2 -4
  29. package/docs/extensions/wechat.md +34 -35
  30. package/docs/extensions/worktree.md +56 -50
  31. package/extensions/board/commands.ts +79 -25
  32. package/extensions/board/format.ts +43 -24
  33. package/extensions/board/index.ts +17 -4
  34. package/extensions/board/input.ts +4 -9
  35. package/extensions/board/join.ts +5 -20
  36. package/extensions/board/lazy-page.ts +40 -0
  37. package/extensions/board/page.ts +65 -93
  38. package/extensions/board/result.ts +30 -39
  39. package/extensions/board/rows.ts +62 -25
  40. package/extensions/board/schema.ts +49 -11
  41. package/extensions/board/snapshot.ts +8 -6
  42. package/extensions/board/tone.ts +22 -0
  43. package/extensions/board/tools.ts +154 -55
  44. package/extensions/board/transport/router.ts +3 -5
  45. package/extensions/board/transport/server.ts +59 -28
  46. package/extensions/board/types.ts +36 -42
  47. package/extensions/board/view/card.ts +229 -0
  48. package/extensions/board/view/client.ts +35 -16
  49. package/extensions/board/view/dialog.ts +169 -6
  50. package/extensions/board/view/empty.ts +47 -0
  51. package/extensions/board/view/keys.ts +13 -2
  52. package/extensions/board/view/live.ts +90 -28
  53. package/extensions/board/view/names.ts +13 -4
  54. package/extensions/board/view/page.ts +38 -13
  55. package/extensions/board/view/render.ts +219 -277
  56. package/extensions/board/view/styles.ts +130 -32
  57. package/extensions/board/view/team.ts +297 -76
  58. package/extensions/bytefly/block/fallback.ts +1 -1
  59. package/extensions/bytefly/doctor/runtime.ts +5 -2
  60. package/extensions/bytefly/footer/collect/label.ts +60 -0
  61. package/extensions/bytefly/footer/collect/snapshot.ts +8 -1
  62. package/extensions/bytefly/footer/config.ts +7 -8
  63. package/extensions/bytefly/footer/register.ts +33 -4
  64. package/extensions/bytefly/footer/render/footer.ts +18 -4
  65. package/extensions/bytefly/footer/render/key.ts +46 -0
  66. package/extensions/bytefly/footer/render/layout.ts +13 -3
  67. package/extensions/bytefly/footer/render/lines.ts +10 -7
  68. package/extensions/bytefly/footer/render/segments.ts +41 -38
  69. package/extensions/bytefly/footer/render/status.ts +0 -11
  70. package/extensions/bytefly/footer/types.ts +7 -0
  71. package/extensions/bytefly/header/component.ts +4 -20
  72. package/extensions/bytefly/header/config.ts +1 -1
  73. package/extensions/bytefly/index.ts +32 -6
  74. package/extensions/bytefly/mode/editor.ts +115 -0
  75. package/extensions/bytefly/panel/commands.ts +210 -120
  76. package/extensions/bytefly/panel/format.ts +4 -4
  77. package/extensions/bytefly/panel/settings.ts +73 -6
  78. package/extensions/bytefly/panel/tui/keys.ts +2 -6
  79. package/extensions/bytefly/update/cache.ts +1 -1
  80. package/extensions/bytefly/update/register.ts +4 -4
  81. package/extensions/bytefly/working/config.ts +5 -7
  82. package/extensions/bytefly/working/format.ts +10 -13
  83. package/extensions/bytefly/working/register.ts +22 -27
  84. package/extensions/check/commands.ts +3 -2
  85. package/extensions/check/render.ts +10 -1
  86. package/extensions/check/{tui.ts → view/check-screens.ts} +14 -19
  87. package/extensions/context/commands.ts +2 -2
  88. package/extensions/context/{tui.ts → view/context-report.ts} +51 -19
  89. package/extensions/file-browse/config/config.ts +4 -19
  90. package/extensions/file-browse/index.ts +89 -79
  91. package/extensions/file-browse/live/live.ts +1 -1
  92. package/extensions/file-browse/transport/router.ts +12 -4
  93. package/extensions/file-browse/transport/server.ts +61 -28
  94. package/extensions/file-browse/view/chrome.ts +4 -4
  95. package/extensions/file-browse/view/client.ts +3 -1
  96. package/extensions/file-browse/view/html.ts +73 -40
  97. package/extensions/file-browse/view/live-client.ts +2 -2
  98. package/extensions/file-browse/view/markdown.ts +13 -14
  99. package/extensions/file-browse/view/palette.ts +2 -2
  100. package/extensions/file-browse/view/raw-html.ts +9 -9
  101. package/extensions/file-browse/view/tree.ts +1 -1
  102. package/extensions/file-browse/view/urls.ts +20 -6
  103. package/extensions/file-read/render.ts +4 -4
  104. package/extensions/goal/plan-route.ts +2 -2
  105. package/extensions/goal/tui.ts +12 -2
  106. package/extensions/job/child.ts +527 -0
  107. package/extensions/job/clock.ts +142 -0
  108. package/extensions/job/commands.ts +4 -2
  109. package/extensions/job/config.ts +8 -1
  110. package/extensions/job/format.ts +22 -1
  111. package/extensions/job/log-writer.ts +13 -2
  112. package/extensions/job/registry.ts +63 -7
  113. package/extensions/job/render.ts +34 -0
  114. package/extensions/job/runtime.ts +296 -655
  115. package/extensions/job/session.ts +132 -19
  116. package/extensions/job/store.ts +7 -7
  117. package/extensions/job/tools.ts +21 -9
  118. package/extensions/job/types.ts +47 -0
  119. package/extensions/job/{tui.ts → view/jobs-screen.ts} +19 -99
  120. package/extensions/job/waiters.ts +44 -1
  121. package/extensions/memory/commands.ts +4 -2
  122. package/extensions/memory/{tui.ts → view/memory-picker.ts} +12 -12
  123. package/extensions/plan/commands.ts +12 -9
  124. package/extensions/plan/feedback.ts +62 -16
  125. package/extensions/plan/format.ts +8 -2
  126. package/extensions/plan/git-bash.ts +115 -16
  127. package/extensions/plan/index.ts +58 -51
  128. package/extensions/plan/plan-index.ts +4 -2
  129. package/extensions/plan/plan-text.ts +114 -0
  130. package/extensions/plan/plans.ts +5 -54
  131. package/extensions/plan/policy.ts +8 -10
  132. package/extensions/plan/review-lazy.ts +85 -0
  133. package/extensions/plan/review.ts +2 -2
  134. package/extensions/plan/runtime.ts +61 -41
  135. package/extensions/plan/sections.ts +1 -1
  136. package/extensions/plan/steps.ts +3 -3
  137. package/extensions/plan/todos.ts +1 -1
  138. package/extensions/plan/tools/cancel.ts +27 -62
  139. package/extensions/plan/tools/enter.ts +55 -81
  140. package/extensions/plan/tools/exit.ts +221 -238
  141. package/extensions/plan/tools/refusal.ts +18 -0
  142. package/extensions/plan/tools/steps.ts +75 -92
  143. package/extensions/plan/tools/write.ts +77 -120
  144. package/extensions/plan/tools.ts +188 -30
  145. package/extensions/plan/tui.ts +5 -5
  146. package/extensions/plan/types.ts +48 -5
  147. package/extensions/plan/web-blocks.ts +76 -5
  148. package/extensions/plan/web-chrome.ts +4 -0
  149. package/extensions/plan/web-client.ts +102 -2
  150. package/extensions/plan/web-collab.ts +4 -4
  151. package/extensions/plan/web-comments.ts +39 -8
  152. package/extensions/plan/web-composer.ts +14 -3
  153. package/extensions/plan/web-keys.ts +149 -25
  154. package/extensions/plan/web-lazy.ts +53 -0
  155. package/extensions/plan/web-markdown.ts +10 -68
  156. package/extensions/plan/web-outline.ts +22 -2
  157. package/extensions/plan/web-page.ts +56 -86
  158. package/extensions/plan/web-payload.ts +188 -0
  159. package/extensions/plan/web-plan.ts +23 -13
  160. package/extensions/plan/web-router.ts +18 -9
  161. package/extensions/plan/web-settle.ts +14 -7
  162. package/extensions/plan/web-share-session.ts +24 -55
  163. package/extensions/plan/web-share.ts +19 -37
  164. package/extensions/plan/web-write.ts +112 -0
  165. package/extensions/plan/web.ts +67 -131
  166. package/extensions/question/tools.ts +6 -2
  167. package/extensions/question/{tui.ts → view/question-screen.ts} +11 -11
  168. package/extensions/rewind/commands/commands.ts +6 -3
  169. package/extensions/rewind/state/runtime.ts +1 -1
  170. package/extensions/rewind/{view/status.ts → status.ts} +4 -4
  171. package/extensions/rewind/view/review-tui.ts +4 -4
  172. package/extensions/serve/config.ts +39 -0
  173. package/extensions/serve/index.ts +85 -0
  174. package/extensions/serve/registry.ts +260 -0
  175. package/extensions/session-bus/commands.ts +11 -6
  176. package/extensions/session-bus/join-request.ts +17 -9
  177. package/extensions/session-bus/mail-watch.ts +26 -17
  178. package/extensions/session-bus/protocol.ts +15 -3
  179. package/extensions/session-bus/resolve.ts +5 -7
  180. package/extensions/session-bus/result.ts +1 -27
  181. package/extensions/session-bus/runtime.ts +40 -12
  182. package/extensions/session-bus/store.ts +101 -19
  183. package/extensions/session-bus/tools.ts +5 -4
  184. package/extensions/session-bus/types.ts +19 -0
  185. package/extensions/session-bus/{tui.ts → view/session-list.ts} +16 -16
  186. package/extensions/subagent/agents.ts +1 -1
  187. package/extensions/subagent/commands.ts +4 -2
  188. package/extensions/subagent/format.ts +15 -15
  189. package/extensions/subagent/index.ts +1 -1
  190. package/extensions/subagent/render.ts +3 -3
  191. package/extensions/subagent/{background-tui.ts → view/agents-board.ts} +11 -11
  192. package/extensions/team/commands.ts +24 -5
  193. package/extensions/team/dispatch.ts +18 -4
  194. package/extensions/team/format.ts +17 -2
  195. package/extensions/team/index.ts +9 -8
  196. package/extensions/team/schema.ts +6 -5
  197. package/extensions/team/tools.ts +107 -45
  198. package/extensions/team/types.ts +3 -2
  199. package/extensions/todo/commands.ts +4 -2
  200. package/extensions/todo/format.ts +11 -0
  201. package/extensions/todo/render.ts +57 -0
  202. package/extensions/todo/runtime.ts +1 -1
  203. package/extensions/todo/tools.ts +1 -1
  204. package/extensions/todo/{tui.ts → view/todo-screen.ts} +18 -90
  205. package/extensions/todo/widget.ts +47 -0
  206. package/extensions/watch/commands.ts +4 -2
  207. package/extensions/watch/format.ts +36 -7
  208. package/extensions/watch/render.ts +57 -0
  209. package/extensions/watch/runtime.ts +27 -22
  210. package/extensions/watch/tools.ts +4 -4
  211. package/extensions/watch/{tui.ts → view/watch-screen.ts} +18 -80
  212. package/extensions/web-access/fetch/tool.ts +12 -4
  213. package/extensions/wechat/commands/commands.ts +18 -14
  214. package/extensions/wechat/index.ts +72 -37
  215. package/extensions/wechat/state/credentials.ts +1 -1
  216. package/extensions/wechat/state/state.ts +1 -1
  217. package/extensions/wechat/text/status.ts +112 -0
  218. package/extensions/wechat/types.ts +2 -2
  219. package/extensions/wechat/view/status-screen.ts +4 -103
  220. package/extensions/worktree/commands.ts +11 -9
  221. package/extensions/worktree/enter.ts +2 -2
  222. package/extensions/worktree/exit.ts +2 -2
  223. package/extensions/worktree/index.ts +5 -5
  224. package/extensions/worktree/screen.ts +3 -3
  225. package/extensions/worktree/status.ts +2 -2
  226. package/extensions/worktree/tools.ts +113 -116
  227. package/lib/README.md +28 -13
  228. package/lib/board/actions.ts +111 -0
  229. package/lib/board/artifact.ts +149 -0
  230. package/lib/board/card.ts +163 -52
  231. package/lib/board/config.ts +58 -0
  232. package/lib/board/contract.ts +113 -0
  233. package/lib/board/join.ts +38 -0
  234. package/lib/board/lanes.ts +182 -0
  235. package/lib/board/lead.ts +130 -0
  236. package/lib/board/limits.ts +78 -0
  237. package/lib/board/links.ts +31 -10
  238. package/lib/board/page-pointer.ts +30 -5
  239. package/lib/board/read.ts +83 -0
  240. package/lib/board/reason.ts +102 -0
  241. package/lib/board/round-text.ts +68 -6
  242. package/lib/board/round.ts +31 -15
  243. package/lib/board/rules.ts +151 -307
  244. package/lib/board/seat.ts +35 -0
  245. package/lib/board/session.ts +199 -51
  246. package/lib/board/skipped.ts +22 -0
  247. package/lib/board/steps.ts +487 -0
  248. package/lib/board/store.ts +119 -36
  249. package/lib/board/tone.ts +92 -0
  250. package/lib/board/view.ts +81 -63
  251. package/lib/bus-request.ts +83 -0
  252. package/{extensions/watch → lib}/child.ts +40 -21
  253. package/lib/env.ts +65 -7
  254. package/lib/format.ts +13 -0
  255. package/lib/http/index.ts +17 -11
  256. package/lib/http/mount.ts +142 -0
  257. package/lib/http/port.ts +30 -0
  258. package/lib/http/serve.ts +170 -46
  259. package/lib/http/server.ts +3 -3
  260. package/lib/lazy.ts +45 -0
  261. package/lib/plan-entry.ts +46 -21
  262. package/lib/plans/paths.ts +82 -0
  263. package/lib/policy.ts +9 -6
  264. package/lib/report.ts +3 -2
  265. package/lib/requirements.ts +10 -10
  266. package/lib/session-label.ts +129 -7
  267. package/lib/session-roster.ts +43 -1
  268. package/lib/session-tool.ts +39 -22
  269. package/lib/stray-fields.ts +7 -2
  270. package/lib/tool-names.ts +5 -10
  271. package/lib/tui/README.md +29 -11
  272. package/lib/tui/chrome.ts +16 -15
  273. package/lib/tui/cursor.ts +18 -3
  274. package/lib/tui/glyphs.ts +11 -11
  275. package/lib/tui/render-cache.ts +29 -0
  276. package/lib/tui/tokens.ts +33 -2
  277. package/lib/tui/tool.ts +33 -4
  278. package/lib/web/README.md +21 -0
  279. package/lib/web/client-runtime.ts +27 -1
  280. package/lib/web/components.ts +5 -1
  281. package/lib/web/index.ts +1 -0
  282. package/lib/web/markdown.ts +83 -0
  283. package/lib/web/markup.ts +20 -2
  284. package/lib/web/runtime.ts +9 -10
  285. package/package.json +8 -2
  286. package/skills/init/SKILL.md +5 -5
  287. package/skills/next/SKILL.md +3 -5
  288. package/extensions/board/config.ts +0 -36
  289. package/extensions/board/seat.ts +0 -28
  290. package/lib/tui/index.ts +0 -34
  291. package/skills/project-docs/SKILL.md +0 -133
  292. package/skills/project-docs/references/audit.md +0 -131
  293. package/skills/project-docs/references/changelog.md +0 -105
  294. package/skills/project-docs/references/discovery.md +0 -127
  295. package/skills/project-docs/references/readme.md +0 -80
  296. package/skills/project-docs/references/release.md +0 -84
  297. package/skills/project-docs/references/roadmap.md +0 -93
  298. package/skills/project-docs/scripts/state.ts +0 -316
@@ -2,7 +2,7 @@
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. Four subcommands are reserved: `/plan review [path]`
5
+ * Shift+Tab toggles too, through bytefly's editor. 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
7
  * refinement), `/plan share [stop]` prints or revokes the link a teammate opens,
8
8
  * `/plan list` shows the plans directory, newest first with Enter opening a
@@ -15,11 +15,11 @@ import { READ_ONLY_SUMMARY } from "../../lib/plan-entry.ts";
15
15
  import { qrLines } from "../../lib/qr.ts";
16
16
  import { withRailsSuppressed } from "../../lib/rails.ts";
17
17
  import { feedbackSummary } from "./feedback.ts";
18
- import { PlanIndexComponent, formatPlanIndex } from "./plan-index.ts";
19
- import { runBrowserReview } from "./review.ts";
18
+ import { runBrowserReview } from "./review-lazy.ts";
20
19
  import type { PlanRuntime } from "./runtime.ts";
21
20
  import type { PlanSummary } from "./plans.ts";
22
- import { createPlanWebReview, type PlanWebReview } from "./web.ts";
21
+ import { createLazyPlanWebReview, NO_SERVER_DEPS } from "./web-lazy.ts";
22
+ import type { PlanWebReview } from "./web.ts";
23
23
 
24
24
  const ENABLED_NOTICE = `Plan mode enabled — ${READ_ONLY_SUMMARY}.`;
25
25
  const DISABLED_NOTICE = "Plan mode disabled — full access restored.";
@@ -28,7 +28,7 @@ const SHARE_PREFIX = "share";
28
28
  const LIST_PREFIX = "list";
29
29
  const CANCEL_PREFIX = "cancel";
30
30
 
31
- /** Notice text for the current mode; shared by `/plan` and the `Ctrl+Alt+P` shortcut. */
31
+ /** Notice text for the current mode; shared by `/plan` and the toggle request. */
32
32
  export function planModeNotice(enabled: boolean): string {
33
33
  return enabled ? ENABLED_NOTICE : DISABLED_NOTICE;
34
34
  }
@@ -126,7 +126,8 @@ async function reviewInBrowser(
126
126
  const file = path ? await runtime.plans.read(ctx.cwd, path) : await runtime.plans.latest(ctx.cwd);
127
127
  if (!file) {
128
128
  if (path) ctx.ui.notify(`Plan review: no plan file at ${path}`, "error");
129
- else ctx.ui.notify("Plan review: no plan to review; write one with write_plan or pass a path.", "warning");
129
+ else
130
+ ctx.ui.notify("Plan review: no plan to review; write one with `plan` action `write` or pass a path.", "warning");
130
131
  return;
131
132
  }
132
133
  // The server is loopback-only, so a remote turn cannot drive it.
@@ -161,7 +162,7 @@ async function reviewInBrowser(
161
162
  return;
162
163
  }
163
164
  const message = runtime.isEnabled()
164
- ? `The user reviewed the plan ${result.plan.relativePath} in the browser and left feedback:\n${summary}\nStay in plan mode, revise the plan (write_plan, reusing plan_path), then call exit_plan_mode again.`
165
+ ? `The user reviewed the plan ${result.plan.relativePath} in the browser and left feedback:\n${summary}\nStay in plan mode, revise the plan (\`plan\` action \`write\`, reusing plan_path), then call \`plan\` action \`exit\` again.`
165
166
  : `The user reviewed the plan ${result.plan.relativePath} in the browser and left feedback:\n${summary}\nUpdate the plan file to address the feedback.`;
166
167
  if (ctx.isIdle()) await pi.sendUserMessage(message);
167
168
  else await pi.sendUserMessage(message, { deliverAs: "followUp" });
@@ -187,6 +188,7 @@ async function openPlanIndex(
187
188
  const summaries = await summarise();
188
189
 
189
190
  if (ctx.mode !== "tui") {
191
+ const { formatPlanIndex } = await import("./plan-index.ts");
190
192
  ctx.ui.notify(formatPlanIndex(summaries, dir), "info");
191
193
  return;
192
194
  }
@@ -195,6 +197,7 @@ async function openPlanIndex(
195
197
  let selected: (PlanSummary | undefined) | undefined;
196
198
  // A dock list shares the editor slot with the goal and todo rails, so it takes
197
199
  // the dock to itself for as long as it is open, like the other list screens.
200
+ const { PlanIndexComponent } = await import("./plan-index.ts");
198
201
  await withRailsSuppressed(pi, () =>
199
202
  ctx.ui.custom<void>((tui, theme, _keybindings, done) => {
200
203
  const component = new PlanIndexComponent({
@@ -219,7 +222,7 @@ async function openPlanIndex(
219
222
  export function registerCommands(
220
223
  pi: ExtensionAPI,
221
224
  runtime: PlanRuntime,
222
- web: PlanWebReview = createPlanWebReview(runtime.plans),
225
+ web: PlanWebReview = createLazyPlanWebReview(runtime.plans, NO_SERVER_DEPS),
223
226
  ): void {
224
227
  pi.registerCommand("plan", {
225
228
  description:
@@ -259,7 +262,7 @@ export function registerCommands(
259
262
  return;
260
263
  }
261
264
 
262
- // No task: toggle, exactly like Ctrl+Alt+P.
265
+ // No task: toggle, exactly like Shift+Tab.
263
266
  if (!prompt) {
264
267
  runtime.toggle(ctx);
265
268
  ctx.ui.notify(planModeNotice(runtime.isEnabled()), "info");
@@ -43,6 +43,13 @@ export interface PlanAnnotation {
43
43
  * guess, and the page says so instead of presenting a guess as her own anchor.
44
44
  */
45
45
  moved?: true;
46
+ /**
47
+ * Set when the note is settled: its writer, or the owner, decided it needs no action, or
48
+ * the revision answered it. The comment stays in the file — it is the record of the
49
+ * question — and a review sends it flagged, so the model reads what is closed without
50
+ * treating it as new.
51
+ */
52
+ resolved?: true;
46
53
  }
47
54
 
48
55
  /** Most annotations one save may carry. */
@@ -59,10 +66,10 @@ export type AuthorClaim = { name: string } | { status: number; message: string }
59
66
 
60
67
  /**
61
68
  * A stored feedback line, capturing whether a refinement moved it, its author, and its
62
- * comment. The flag is inside the marker tag rather than the comment, so the text a
69
+ * comment. The flags are inside the marker tag rather than the comment, so the text a
63
70
  * reviewer wrote stays exactly what she typed.
64
71
  */
65
- const MARKER_LINE = /^\s*>\s*\[!REVIEW(\s+moved)?\]\s?(.*)$/;
72
+ const MARKER_LINE = /^\s*>\s*\[!REVIEW((?:\s+(?:moved|resolved))*)\]\s?(.*)$/;
66
73
  /** The author prefix inside a marker body (`@alice: note`). */
67
74
  const AUTHOR_PREFIX = /^@([^:]{1,40}):\s?(.*)$/;
68
75
 
@@ -133,6 +140,8 @@ interface StoredMarker {
133
140
  author: string;
134
141
  /** Whether a refinement carried this comment off the text it was written on. */
135
142
  moved: boolean;
143
+ /** Whether the note is settled: it stays in the file, and is no longer an open question. */
144
+ resolved: boolean;
136
145
  }
137
146
 
138
147
  /** The plan body without markers, plus the markers that were removed from it. */
@@ -151,17 +160,28 @@ function scanMarkers(plan: string): { clean: string; markers: StoredMarker[] } {
151
160
  const authored = AUTHOR_PREFIX.exec(body);
152
161
  const author = authored ? normalizeAuthor(authored[1]) : "";
153
162
  const comment = (authored ? authored[2] : body).trim();
154
- if (comment) markers.push({ at: clean.length - 1, comment, author, moved: match[1] !== undefined });
163
+ // The tag carries a set of flags, not one keyword: a note can be carried off its text
164
+ // and settled in the same write, and an older build reading it still finds the comment.
165
+ const flags = new Set((match[1] ?? "").trim().split(/\s+/).filter(Boolean));
166
+ if (comment) {
167
+ markers.push({
168
+ at: clean.length - 1,
169
+ comment,
170
+ author,
171
+ moved: flags.has("moved"),
172
+ resolved: flags.has("resolved"),
173
+ });
174
+ }
155
175
  });
156
176
  return { clean: clean.join("\n"), markers };
157
177
  }
158
178
 
159
179
  /** One marker line in its stored form. */
160
- function markerLine(author: string, comment: string, moved = false): string {
180
+ function markerLine(author: string, comment: string, moved = false, resolved = false): string {
161
181
  const body = sanitizeLine(comment);
162
- // The flag rides inside the tag, where it cannot be confused with the comment: after
163
- // the closing bracket, a comment that begins "moved …" would read as the flag.
164
- const tag = moved ? "[!REVIEW moved]" : REVIEW_MARKER;
182
+ // The flags ride inside the tag, in a fixed order, where they cannot be confused with the
183
+ // comment: after the closing bracket, a comment that begins "moved …" would read as one.
184
+ const tag = moved || resolved ? `[!REVIEW${moved ? " moved" : ""}${resolved ? " resolved" : ""}]` : REVIEW_MARKER;
165
185
  return author ? `> ${tag} @${author}: ${body}` : `> ${tag} ${body}`;
166
186
  }
167
187
 
@@ -236,6 +256,7 @@ export function readFeedback(plan: string): FeedbackRead {
236
256
  };
237
257
  if (marker.author) annotation.author = marker.author;
238
258
  if (marker.moved) annotation.moved = true;
259
+ if (marker.resolved) annotation.resolved = true;
239
260
  return annotation;
240
261
  });
241
262
  return { body: clean, blocks, annotations };
@@ -249,15 +270,21 @@ export function parseFeedback(plan: string): PlanAnnotation[] {
249
270
  /** Sanitized, non-empty comments with the anchor they land on. */
250
271
  function storableComments(
251
272
  annotations: readonly PlanAnnotation[],
252
- ): Array<{ anchor: number; comment: string; moved: boolean }> {
253
- const items: Array<{ anchor: number; comment: string; moved: boolean }> = [];
273
+ ): Array<{ anchor: number; comment: string; moved: boolean; resolved: boolean }> {
274
+ const items: Array<{ anchor: number; comment: string; moved: boolean; resolved: boolean }> = [];
254
275
  for (const annotation of annotations) {
255
276
  const comment = sanitizeLine(annotation.comment);
256
277
  if (comment.length === 0 || comment.length > MAX_COMMENT_LENGTH) continue;
257
278
  // Editing a moved note's text does not put it back on its old block, so the fact
258
279
  // that a refinement placed it where it sits survives the save: the page posts back
259
- // the annotation it was handed, flag included, and this keeps it.
260
- items.push({ anchor: annotation.anchor, comment, moved: annotation.moved === true });
280
+ // the annotation it was handed, flags included, and this keeps them. Settling is the
281
+ // writer's own decision of the same kind.
282
+ items.push({
283
+ anchor: annotation.anchor,
284
+ comment,
285
+ moved: annotation.moved === true,
286
+ resolved: annotation.resolved === true,
287
+ });
261
288
  }
262
289
  return items;
263
290
  }
@@ -272,13 +299,16 @@ export function formatFeedback(plan: string, annotations: PlanAnnotation[], opti
272
299
  const { clean, markers } = scanMarkers(plan);
273
300
  const kept = markers
274
301
  .filter((marker) => marker.author !== author)
275
- .map((marker) => ({ at: marker.at, line: markerLine(marker.author, marker.comment, marker.moved) }));
302
+ .map((marker) => ({
303
+ at: marker.at,
304
+ line: markerLine(marker.author, marker.comment, marker.moved, marker.resolved),
305
+ }));
276
306
  const blocks = planBlocks(clean);
277
307
  const added = storableComments(annotations).map((item) => ({
278
308
  // A marker beside a block, never after a blank line: an anchor that names
279
309
  // blank space is written where the page can read it back as a thread.
280
310
  at: contentBlockAt(blocks, item.anchor)?.end ?? -1,
281
- line: markerLine(author, item.comment, item.moved),
311
+ line: markerLine(author, item.comment, item.moved, item.resolved),
282
312
  }));
283
313
  return compose(clean, [...kept, ...added]);
284
314
  }
@@ -452,7 +482,9 @@ export function carryFeedback(previous: string, next: string): string {
452
482
  // The flag means "this note is no longer on the text it was written about", so it
453
483
  // is decided by the carry in hand: a block whose text survived proves the anchor,
454
484
  // and anything after that is a guess the page has to admit to.
455
- const keep = (moved: boolean): string => markerLine(marker.author, marker.comment, moved);
485
+ // Settling is the note's own history, not the carry's decision: a moved note that was
486
+ // resolved stays settled, and one whose text survived stays as it was.
487
+ const keep = (moved: boolean): string => markerLine(marker.author, marker.comment, moved, marker.resolved);
456
488
  if (block === undefined) return { at: tail, line: keep(true) };
457
489
  if (block.kind !== "blank") {
458
490
  const match = queues.get(blockKey(block))?.shift();
@@ -503,13 +535,17 @@ export function feedbackSummary(annotations: PlanAnnotation[]): string {
503
535
  // the plan owner chose, so it is normalized like every other author.
504
536
  author: normalizeAuthor(annotation.author),
505
537
  comment: sanitizeLine(annotation.comment),
538
+ resolved: annotation.resolved === true,
506
539
  }))
507
540
  .filter((item) => item.comment.length > 0);
508
541
  return items
509
542
  .map((item, index) => {
510
543
  const who = item.author ? `@${item.author}: ` : "";
511
544
  const where = item.quote ? `On "${item.quote}": ` : "";
512
- return `${index + 1}. ${who}${where}${item.comment}`;
545
+ // A settled note is still sent — it is the record of the question — but saying so
546
+ // is what stops the model from answering it a second time.
547
+ const settled = item.resolved ? " (resolved)" : "";
548
+ return `${index + 1}. ${who}${where}${item.comment}${settled}`;
513
549
  })
514
550
  .join("\n");
515
551
  }
@@ -531,12 +567,22 @@ export function coerceAnnotations(value: unknown): PlanAnnotation[] | undefined
531
567
  if (typeof record.comment !== "string") return undefined;
532
568
  if (record.quote !== undefined && typeof record.quote !== "string") return undefined;
533
569
  if (record.author !== undefined && typeof record.author !== "string") return undefined;
570
+ if (record.resolved !== undefined && typeof record.resolved !== "boolean") return undefined;
534
571
  const comment = sanitizeLine(record.comment);
535
572
  if (comment.length > MAX_COMMENT_LENGTH) return undefined;
536
573
  if (comment.length === 0) continue;
537
574
  const quote = typeof record.quote === "string" ? sanitizeLine(record.quote).slice(0, MAX_QUOTE_LENGTH) : undefined;
538
575
  const author = normalizeAuthor(record.author);
539
- annotations.push({ anchor, comment, ...(quote ? { quote } : {}), ...(author ? { author } : {}) });
576
+ // A note a refinement carried onto another block keeps saying so when the page posts
577
+ // it back, and a settled note keeps saying that too: only this body knows either.
578
+ annotations.push({
579
+ anchor,
580
+ comment,
581
+ ...(quote ? { quote } : {}),
582
+ ...(author ? { author } : {}),
583
+ ...(record.moved === true ? { moved: true } : {}),
584
+ ...(record.resolved === true ? { resolved: true } : {}),
585
+ });
540
586
  }
541
587
  return annotations;
542
588
  }
@@ -5,10 +5,16 @@
5
5
  * the cap diagnosis, the preview, and the merge summary in one place.
6
6
  */
7
7
 
8
- import { SET_PLAN_STEPS_TOOL } from "./runtime.ts";
9
8
  import { MAX_STEP_LENGTH, MAX_STEPS, type PlanStep, type StepUpdate } from "./steps.ts";
10
9
  import type { TodoMerge } from "./todos.ts";
11
10
 
11
+ /**
12
+ * How every message names the tick-off call. One spelling, because the tool is now
13
+ * one verb among five and a message that still spelled the old name would teach the
14
+ * model a tool that no longer exists.
15
+ */
16
+ export const TICK_CALL = "`plan` action `steps`";
17
+
12
18
  /** Lines of plan shown in an unexpanded result. */
13
19
  const PREVIEW_LINES = 8;
14
20
 
@@ -80,7 +86,7 @@ export function mergeSummary(merged: TodoMerge, steps: PlanStep[]): string {
80
86
  merged.deferred > 0 ? `${merged.deferred} deferred, list full` : "",
81
87
  ].filter(Boolean);
82
88
  const suffix = notes.length > 0 ? ` (${notes.join(", ")})` : "";
83
- const tick = `Tick each one off in the file with ${SET_PLAN_STEPS_TOOL} as you finish it, passing the step's text as \`expected\`.`;
89
+ const tick = `Tick each one off in the file with ${TICK_CALL} as you finish it, passing the step's text as \`expected\`.`;
84
90
  const touched =
85
91
  merged.stale > 0
86
92
  ? ` ${merged.stale} older item${merged.stale === 1 ? "" : "s"} no longer match${merged.stale === 1 ? "es" : ""} this plan but ${merged.stale === 1 ? "was" : "were"} left alone: ${merged.stale === 1 ? "it was" : "they were"} started or finished, so check ${merged.stale === 1 ? "it" : "them"} yourself.`
@@ -202,6 +202,119 @@ function checkFlagAndPositional(rest: string[], spec: RestCheck, label: string):
202
202
  return { ok: true };
203
203
  }
204
204
 
205
+ /** `git config` flags that write the file, each of which the guard refuses by name. */
206
+ const CONFIG_WRITE_FLAGS = new Set([
207
+ "--add",
208
+ "--edit",
209
+ "-e",
210
+ "--remove-section",
211
+ "--rename-section",
212
+ "--replace-all",
213
+ "--unset",
214
+ "--unset-all",
215
+ ]);
216
+
217
+ /** `git config` flags whose value is the next token, or the part after `=`. */
218
+ const CONFIG_VALUE_FLAGS = new Set([
219
+ "--get",
220
+ "--get-all",
221
+ "--get-regexp",
222
+ "--get-urlmatch",
223
+ "--get-color",
224
+ "--get-colorbool",
225
+ "--default",
226
+ "--file",
227
+ "-f",
228
+ "--type",
229
+ "--blob",
230
+ ]);
231
+
232
+ /** `git config` flags that select or shape a read without taking a value. */
233
+ const CONFIG_READ_FLAGS = new Set([
234
+ "--list",
235
+ "-l",
236
+ "--show-origin",
237
+ "--show-scope",
238
+ "--name-only",
239
+ "--null",
240
+ "-z",
241
+ "--local",
242
+ "--global",
243
+ "--system",
244
+ "--worktree",
245
+ "--includes",
246
+ "--no-includes",
247
+ "--bool",
248
+ "--int",
249
+ "--bool-or-int",
250
+ "--path",
251
+ ]);
252
+
253
+ /** `git config` flags that ask for a value rather than only shaping the output. */
254
+ const CONFIG_GET_FLAGS = new Set([
255
+ "--get",
256
+ "--get-all",
257
+ "--get-regexp",
258
+ "--get-urlmatch",
259
+ "--get-color",
260
+ "--get-colorbool",
261
+ ]);
262
+
263
+ /**
264
+ * `git config` reads only, by allowlist.
265
+ *
266
+ * Asking whether a read flag appeared *anywhere* let every write through beside one:
267
+ * `git config --show-scope --replace-all core.fsmonitor /tmp/hook.sh` wrote `.git/config`
268
+ * from inside plan mode, and the `git status` that followed ran the hook. Each flag is now
269
+ * one this guard knows, a write action is refused by name, positionals are at most one, and
270
+ * the only forms left are the ones git reads with: `--get*`, `--list`, or one bare name.
271
+ */
272
+ function checkConfig(rest: string[]): GitCommandVerdict {
273
+ const tokens = rest.map(unquote);
274
+ const positionals: string[] = [];
275
+ let reads = false;
276
+ for (let index = 0; index < tokens.length; index += 1) {
277
+ const token = tokens[index] ?? "";
278
+ if (!token.startsWith("-")) {
279
+ positionals.push(token);
280
+ continue;
281
+ }
282
+ const [flag = "", inline] = token.split("=", 2);
283
+ if (CONFIG_WRITE_FLAGS.has(flag)) {
284
+ return {
285
+ ok: false,
286
+ reason: `"git config ${flag}" writes .git/config; "git config" may only read while planning.`,
287
+ };
288
+ }
289
+ if (!CONFIG_READ_FLAGS.has(flag) && !CONFIG_VALUE_FLAGS.has(flag)) {
290
+ return { ok: false, reason: `"git config" flag "${token}" is not a read-only option.` };
291
+ }
292
+ if (!CONFIG_VALUE_FLAGS.has(flag)) {
293
+ if (flag === "--list" || flag === "-l") reads = true;
294
+ continue;
295
+ }
296
+ if (CONFIG_GET_FLAGS.has(flag)) reads = true;
297
+ if (inline !== undefined) continue;
298
+ const value = tokens[index + 1];
299
+ // A flag is not a value: reading `--replace-all` as `--get`'s key would refuse nothing
300
+ // and leave git to act on it, so the value is refused rather than guessed.
301
+ if (value === undefined || value.startsWith("-")) {
302
+ return { ok: false, reason: `"git config ${flag}" needs a value, and plan mode does not guess one.` };
303
+ }
304
+ index += 1;
305
+ }
306
+ if (positionals.length > 1) {
307
+ return { ok: false, reason: '"git config <name> <value>" writes .git/config; plan mode reads config only.' };
308
+ }
309
+ if (!reads && positionals.length !== 1) {
310
+ return {
311
+ ok: false,
312
+ reason: '"git config" may only read; use "git config <name>", --get, --get-all, --get-regexp, or --list.',
313
+ };
314
+ }
315
+ return { ok: true };
316
+ }
317
+
205
318
  /** Subcommand-specific read-only rules; the rest only pass the option denylist. */
206
319
  function checkRest(subcommand: string, rest: string[]): GitCommandVerdict {
207
320
  switch (subcommand) {
@@ -219,22 +332,8 @@ function checkRest(subcommand: string, rest: string[]): GitCommandVerdict {
219
332
  reason: `"git remote ${first}" can change a remote; use "git remote -v" or "git remote show".`,
220
333
  };
221
334
  }
222
- case "config": {
223
- const hasReadFlag = rest.some((raw) => {
224
- const token = unquote(raw);
225
- return (
226
- token.startsWith("--get") ||
227
- token === "--list" ||
228
- token === "-l" ||
229
- token === "--show-origin" ||
230
- token === "--show-scope"
231
- );
232
- });
233
- if (!hasReadFlag) {
234
- return { ok: false, reason: '"git config" may only read; add --get, --get-all, --get-regexp, or --list.' };
235
- }
236
- return { ok: true };
237
- }
335
+ case "config":
336
+ return checkConfig(rest);
238
337
  case "stash": {
239
338
  const [first] = rest.map(unquote);
240
339
  if (first === "list" || first === "show") return { ok: true };
@@ -5,11 +5,12 @@
5
5
  * active set or blocked: write and edit are hidden, bash is limited to a single
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
- * read-only. The one permitted write is `write_plan`,
9
- * which saves the plan in the agent directory. The model investigates, saves the
10
- * plan, and calls `exit_plan_mode` to read the file back and ask for approval.
8
+ * read-only. The one permitted write is the `plan` tool's `write` action, which
9
+ * saves the plan in the agent directory. The model investigates, saves the plan,
10
+ * and calls `plan` action `exit` to read the file back and ask for approval.
11
11
  *
12
- * Toggle with `/plan`, `Ctrl+Alt+P`, or start with `--plan`. The state is
12
+ * Toggle with `/plan` (or Shift+Tab, which bytefly's editor binds), or start with
13
+ * `--plan`. The state is
13
14
  * persisted as a custom session entry, so it follows the active branch.
14
15
  *
15
16
  * Load with: pi --extension ./extensions/plan
@@ -17,29 +18,27 @@
17
18
 
18
19
  import type { AgentMessage } from "@earendil-works/pi-agent-core";
19
20
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
20
- import { Key } from "@earendil-works/pi-tui";
21
21
  import { fileURLToPath } from "node:url";
22
- import { hasPathInput } from "./path-guard.ts";
23
22
  import { isExtensionEnabled } from "../../lib/env.ts";
23
+ import { requestMount } from "../../lib/http/mount.ts";
24
24
  import { sanitizeLine } from "../../lib/format.ts";
25
25
  import { lastGoal, isPursuedGoal, type PublishedGoal } from "../../lib/goal-state.ts";
26
- import { READ_ONLY_SUMMARY, PLAN_ENTER_REQUEST, type PlanEnterRequest } from "../../lib/plan-entry.ts";
27
- import { isBoardRead } from "../../lib/policy.ts";
28
26
  import {
29
- ASK_USER_QUESTION_TOOL,
30
- BOARD_READ_ACTION,
31
- BOARD_TOOL,
32
- SUBAGENT_READ_ONLY_PARAM,
33
- SUBAGENT_TOOL,
34
- } from "../../lib/tool-names.ts";
27
+ READ_ONLY_SUMMARY,
28
+ PLAN_ENTER_REQUEST,
29
+ PLAN_TOGGLE_REQUEST,
30
+ type PlanModeRequest,
31
+ } from "../../lib/plan-entry.ts";
32
+ import { isBoardRead } from "../../lib/policy.ts";
33
+ import { boardWriteRefusal } from "../../lib/board/actions.ts";
34
+ import { ASK_USER_QUESTION_TOOL, BOARD_TOOL, SUBAGENT_READ_ONLY_PARAM, SUBAGENT_TOOL } from "../../lib/tool-names.ts";
35
35
  import { clip } from "../../lib/tui/columns.ts";
36
36
  import { STATUS_KEYS } from "../../lib/tui/glyphs.ts";
37
37
  import { planModeNotice, registerCommands } from "./commands.ts";
38
- import { checkReadOnlyGit } from "./git-bash.ts";
39
38
  import { createPlanPolicy, BASH_BLOCKED_GUIDANCE, BLOCKED_GUIDANCE, PLAN_SAFE_TOOLS } from "./policy.ts";
40
39
  import { createPlanRuntime } from "./runtime.ts";
41
40
  import { registerTools } from "./tools.ts";
42
- import { createPlanWebReview } from "./web.ts";
41
+ import { createLazyPlanWebReview } from "./web-lazy.ts";
43
42
 
44
43
  /** Marker embedded in the injected prompt and used to filter stale context. */
45
44
  export const PLAN_MODE_MARKER = "[PLAN MODE ACTIVE]";
@@ -62,7 +61,7 @@ export function planModeContext(goal?: PublishedGoal | null): string {
62
61
  "",
63
62
  `- Do not modify anything except the plan file: while planning, ${READ_ONLY_SUMMARY}.`,
64
63
  "- bash is git-only: read-only git commands chain with && or ; — no pipes, redirection, or substitution, and no powershell. The cwd is already the project root, so skip cd, and read files with read, grep, find, and ls.",
65
- "- Investigate and design a concrete plan, then save it with write_plan and call exit_plan_mode with the returned plan_path for approval.",
64
+ "- Investigate and design a concrete plan, then save it with `plan` action `write` and call `plan` action `exit` with the returned plan_path for approval.",
66
65
  "- Delegate broad exploration with subagent.",
67
66
  `- To choose between approaches, ask with ${ASK_USER_QUESTION_TOOL} before finalizing.`,
68
67
  ];
@@ -91,7 +90,12 @@ export default function planMode(pi: ExtensionAPI): void {
91
90
  if (!isExtensionEnabled("plan")) return;
92
91
  const policy = createPlanPolicy(pi);
93
92
  const runtime = createPlanRuntime(pi, policy, { entryPath: extensionEntryPath() });
94
- const web = createPlanWebReview(runtime.plans);
93
+ /** The session this review serves as; its prefix lands on that session's one server. */
94
+ let sessionId = "";
95
+ const web = createLazyPlanWebReview(runtime.plans, {
96
+ mount: (spec) => requestMount(pi, spec),
97
+ session: () => sessionId,
98
+ });
95
99
 
96
100
  registerTools(pi, runtime, web);
97
101
  registerCommands(pi, runtime, web);
@@ -102,33 +106,36 @@ export default function planMode(pi: ExtensionAPI): void {
102
106
  default: false,
103
107
  });
104
108
 
105
- pi.registerShortcut(Key.ctrlAlt("p"), {
106
- description: "Toggle plan mode",
107
- handler: (ctx) => {
108
- runtime.toggle(ctx);
109
- ctx.ui.notify(planModeNotice(runtime.isEnabled()), "info");
110
- },
111
- });
112
-
113
- // Another extension that offers plan mode (the goal workflow) asks on this
114
- // channel instead of calling a tool it cannot reach from a command context. It
115
- // owns the user's answer; enabling here must therefore not ask again.
109
+ // Another extension asks on these channels instead of calling a tool it cannot
110
+ // reach from a command or editor context: `goal` offers plan mode as a phase, and
111
+ // bytefly's editor binds Shift+Tab to the toggle. The asker owns the user's answer,
112
+ // so enabling here must not ask again, and a toggle answers with the state it left.
116
113
  const unsubscribes: Array<() => void> = [];
117
- const listenForEntryRequests = (): void => {
114
+ const listenForRequests = (): void => {
118
115
  for (const unsubscribe of unsubscribes) unsubscribe();
119
116
  unsubscribes.length = 0;
120
- unsubscribes.push(
121
- pi.events.on(PLAN_ENTER_REQUEST, (data) => {
122
- const request = data as Partial<PlanEnterRequest> | undefined;
123
- if (!request?.ctx || typeof request.respond !== "function") return;
124
- if (!runtime.isEnabled()) runtime.enable(request.ctx);
125
- request.respond(true);
126
- }),
127
- );
117
+ const listen = (channel: string, handle: (request: PlanModeRequest) => void): void => {
118
+ unsubscribes.push(
119
+ pi.events.on(channel, (data) => {
120
+ const request = data as Partial<PlanModeRequest> | undefined;
121
+ if (!request?.ctx || typeof request.respond !== "function") return;
122
+ handle(request as PlanModeRequest);
123
+ }),
124
+ );
125
+ };
126
+ listen(PLAN_ENTER_REQUEST, (request) => {
127
+ if (!runtime.isEnabled()) runtime.enable(request.ctx);
128
+ request.respond(true);
129
+ });
130
+ listen(PLAN_TOGGLE_REQUEST, (request) => {
131
+ runtime.toggle(request.ctx);
132
+ request.ctx.ui.notify(planModeNotice(runtime.isEnabled()), "info");
133
+ request.respond(runtime.isEnabled());
134
+ });
128
135
  };
129
136
 
130
137
  pi.on("session_start", (_event, ctx) => {
131
- listenForEntryRequests();
138
+ listenForRequests();
132
139
  runtime.restore(ctx);
133
140
  });
134
141
  pi.on("session_tree", (_event, ctx) => runtime.restore(ctx));
@@ -155,12 +162,12 @@ export default function planMode(pi: ExtensionAPI): void {
155
162
  * proceed. Two layers: the policy (readers, `readOnlyHint`, deny list), then
156
163
  * the path backstop (a hint on a path-carrying tool is only a claim).
157
164
  */
158
- const blockedReason = (toolName: string, input: unknown): string | undefined => {
159
- // Plan-owned tools are the sanctioned exception: `write_plan` saves the
160
- // artifact, `exit_plan_mode` presents it, `cancel_plan_mode` restores access,
161
- // and `enter_plan_mode` answers "already" to a call that was in flight when
162
- // plan mode came on. The source-path check keeps a same-named tool from
163
- // another extension from borrowing the exemption.
165
+ const blockedReason = async (toolName: string, input: unknown): Promise<string | undefined> => {
166
+ // Plan's own tool is the sanctioned exception: it is the mode's vocabulary —
167
+ // `write` saves the artifact, `exit` presents it, `cancel` restores access, and
168
+ // `enter` answers "already" — so its actions decide for themselves whether they
169
+ // apply, and each refuses outside its mode. The source-path check keeps a
170
+ // same-named tool from another extension from borrowing the exemption.
164
171
  if (runtime.isOwnTool(toolName)) return undefined;
165
172
  // `subagent` stays active, but only as read-only delegation. The flag is
166
173
  // forced in place (the child enforces it through `--tools`), so the model
@@ -177,12 +184,10 @@ export default function planMode(pi: ExtensionAPI): void {
177
184
  }
178
185
  // `board` stays active so planning can read the board it is about to add cards
179
186
  // to. The action is the truth here, not the annotation: the same tool writes, so
180
- // `readOnlyHint` cannot say it, and every verb but the read is refused by name.
187
+ // `readOnlyHint` cannot say it, and every verb but the reads are refused by name.
181
188
  if (toolName === BOARD_TOOL) {
182
189
  if (isBoardRead(input)) return undefined;
183
- const action = (input as { action?: unknown } | undefined)?.action;
184
- const verb = typeof action === "string" && action.trim() !== "" ? action.trim() : "board";
185
- return `Plan mode: \`${verb}\` changes the board — only \`${BOARD_READ_ACTION}\` reads it. ${BLOCKED_GUIDANCE}`;
190
+ return boardWriteRefusal((input as { action?: unknown } | undefined)?.action, BLOCKED_GUIDANCE);
186
191
  }
187
192
  const blocked = policy.check(toolName);
188
193
  if (blocked) return `Plan mode: ${blocked.reason}`;
@@ -195,9 +200,11 @@ export default function planMode(pi: ExtensionAPI): void {
195
200
  if (typeof command !== "string" || command.trim() === "") {
196
201
  return `Plan mode: an empty bash command is not allowed. ${BASH_BLOCKED_GUIDANCE}`;
197
202
  }
203
+ const { checkReadOnlyGit } = await import("./git-bash.ts");
198
204
  const verdict = checkReadOnlyGit(command);
199
205
  if (!verdict.ok) return `Plan mode: ${verdict.reason} ${BASH_BLOCKED_GUIDANCE}`;
200
206
  }
207
+ const { hasPathInput } = await import("./path-guard.ts");
201
208
  if (hasPathInput(input) && !PLAN_SAFE_TOOLS.includes(toolName)) {
202
209
  return `Plan mode: "${toolName}" takes a file path and is not a known reader. ${BLOCKED_GUIDANCE}`;
203
210
  }
@@ -208,9 +215,9 @@ export default function planMode(pi: ExtensionAPI): void {
208
215
  // above hides most of these; this is the second layer for tools already
209
216
  // declared in an in-flight request, and the guard for tools we did not know
210
217
  // about when the extension loaded (MCP servers, future extensions).
211
- pi.on("tool_call", (event) => {
218
+ pi.on("tool_call", async (event) => {
212
219
  if (!runtime.isEnabled()) return undefined;
213
- const reason = blockedReason(event.toolName, event.input);
220
+ const reason = await blockedReason(event.toolName, event.input);
214
221
  return reason ? { block: true, reason } : undefined;
215
222
  });
216
223
 
@@ -90,7 +90,7 @@ function footprintText(summaries: readonly PlanSummary[], nowMs: number): string
90
90
  export function formatPlanIndex(summaries: readonly PlanSummary[], dir: string, now: number = Date.now()): string {
91
91
  const where = sanitizeLine(shortenPath(dir));
92
92
  if (summaries.length === 0) {
93
- return `No plans in ${where} yet; write_plan saves one there.`;
93
+ return `No plans in ${where} yet; \`plan\` action \`write\` saves one there.`;
94
94
  }
95
95
  const lines = summaries.map(
96
96
  (summary) =>
@@ -241,7 +241,9 @@ export class PlanIndexComponent implements Component {
241
241
 
242
242
  const body: string[] = [];
243
243
  if (this.summaries.length === 0) {
244
- body.push(...emptyState(theme, w, { label: "No plans yet. write_plan saves one in the agent directory." }));
244
+ body.push(
245
+ ...emptyState(theme, w, { label: "No plans yet. `plan` action `write` saves one in the agent directory." }),
246
+ );
245
247
  } else {
246
248
  const { start, end } = this.cursor.window(this.summaries.length);
247
249
  for (let index = start; index < end; index++) {