@gavin-hu/my-pi-agent 0.21.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 (254) hide show
  1. package/README.md +46 -31
  2. package/README.zh-CN.md +40 -26
  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 -24
  9. package/docs/extensions/board.md +361 -147
  10. package/docs/extensions/bytefly.md +152 -111
  11. package/docs/extensions/check.md +15 -17
  12. package/docs/extensions/context.md +35 -15
  13. package/docs/extensions/file-browse.md +30 -31
  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 +166 -126
  20. package/docs/extensions/question.md +16 -18
  21. package/docs/extensions/rewind.md +17 -19
  22. package/docs/extensions/serve.md +34 -18
  23. package/docs/extensions/session-bus.md +38 -16
  24. package/docs/extensions/subagent.md +23 -25
  25. package/docs/extensions/team.md +72 -33
  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 +56 -15
  32. package/extensions/board/format.ts +43 -24
  33. package/extensions/board/index.ts +5 -4
  34. package/extensions/board/input.ts +4 -9
  35. package/extensions/board/join.ts +4 -40
  36. package/extensions/board/lazy-page.ts +40 -0
  37. package/extensions/board/page.ts +13 -11
  38. package/extensions/board/result.ts +30 -16
  39. package/extensions/board/rows.ts +62 -25
  40. package/extensions/board/schema.ts +42 -10
  41. package/extensions/board/snapshot.ts +2 -1
  42. package/extensions/board/tone.ts +22 -0
  43. package/extensions/board/tools.ts +131 -44
  44. package/extensions/board/types.ts +36 -32
  45. package/extensions/board/view/card.ts +229 -0
  46. package/extensions/board/view/client.ts +33 -15
  47. package/extensions/board/view/dialog.ts +169 -6
  48. package/extensions/board/view/empty.ts +47 -0
  49. package/extensions/board/view/keys.ts +13 -2
  50. package/extensions/board/view/live.ts +89 -21
  51. package/extensions/board/view/names.ts +13 -4
  52. package/extensions/board/view/page.ts +23 -9
  53. package/extensions/board/view/render.ts +208 -271
  54. package/extensions/board/view/styles.ts +123 -31
  55. package/extensions/board/view/team.ts +297 -76
  56. package/extensions/bytefly/block/fallback.ts +1 -1
  57. package/extensions/bytefly/doctor/runtime.ts +5 -2
  58. package/extensions/bytefly/footer/collect/label.ts +60 -0
  59. package/extensions/bytefly/footer/collect/snapshot.ts +8 -1
  60. package/extensions/bytefly/footer/config.ts +4 -2
  61. package/extensions/bytefly/footer/register.ts +33 -4
  62. package/extensions/bytefly/footer/render/lines.ts +7 -4
  63. package/extensions/bytefly/footer/render/segments.ts +37 -10
  64. package/extensions/bytefly/footer/render/status.ts +0 -11
  65. package/extensions/bytefly/footer/types.ts +7 -0
  66. package/extensions/bytefly/header/config.ts +1 -1
  67. package/extensions/bytefly/index.ts +32 -6
  68. package/extensions/bytefly/mode/editor.ts +115 -0
  69. package/extensions/bytefly/panel/commands.ts +210 -120
  70. package/extensions/bytefly/panel/format.ts +4 -4
  71. package/extensions/bytefly/panel/settings.ts +73 -6
  72. package/extensions/bytefly/panel/tui/keys.ts +2 -6
  73. package/extensions/bytefly/update/cache.ts +1 -1
  74. package/extensions/bytefly/update/register.ts +4 -4
  75. package/extensions/bytefly/working/config.ts +5 -7
  76. package/extensions/bytefly/working/format.ts +10 -13
  77. package/extensions/bytefly/working/register.ts +22 -27
  78. package/extensions/check/commands.ts +3 -2
  79. package/extensions/check/{tui.ts → view/check-screens.ts} +14 -19
  80. package/extensions/context/commands.ts +2 -2
  81. package/extensions/context/{tui.ts → view/context-report.ts} +51 -19
  82. package/extensions/file-browse/index.ts +67 -36
  83. package/extensions/file-read/render.ts +4 -4
  84. package/extensions/goal/plan-route.ts +2 -2
  85. package/extensions/job/child.ts +527 -0
  86. package/extensions/job/clock.ts +142 -0
  87. package/extensions/job/commands.ts +4 -2
  88. package/extensions/job/config.ts +8 -1
  89. package/extensions/job/format.ts +22 -1
  90. package/extensions/job/log-writer.ts +13 -2
  91. package/extensions/job/registry.ts +63 -7
  92. package/extensions/job/render.ts +34 -0
  93. package/extensions/job/runtime.ts +296 -655
  94. package/extensions/job/session.ts +132 -19
  95. package/extensions/job/store.ts +7 -7
  96. package/extensions/job/tools.ts +3 -3
  97. package/extensions/job/types.ts +47 -0
  98. package/extensions/job/{tui.ts → view/jobs-screen.ts} +19 -73
  99. package/extensions/job/waiters.ts +44 -1
  100. package/extensions/memory/commands.ts +4 -2
  101. package/extensions/memory/{tui.ts → view/memory-picker.ts} +12 -12
  102. package/extensions/plan/commands.ts +12 -9
  103. package/extensions/plan/format.ts +8 -2
  104. package/extensions/plan/git-bash.ts +115 -16
  105. package/extensions/plan/index.ts +53 -52
  106. package/extensions/plan/plan-index.ts +4 -2
  107. package/extensions/plan/plans.ts +5 -54
  108. package/extensions/plan/policy.ts +8 -10
  109. package/extensions/plan/review-lazy.ts +85 -0
  110. package/extensions/plan/review.ts +2 -2
  111. package/extensions/plan/runtime.ts +49 -39
  112. package/extensions/plan/sections.ts +1 -1
  113. package/extensions/plan/steps.ts +3 -3
  114. package/extensions/plan/todos.ts +1 -1
  115. package/extensions/plan/tools/cancel.ts +27 -62
  116. package/extensions/plan/tools/enter.ts +55 -81
  117. package/extensions/plan/tools/exit.ts +221 -238
  118. package/extensions/plan/tools/refusal.ts +18 -0
  119. package/extensions/plan/tools/steps.ts +75 -92
  120. package/extensions/plan/tools/write.ts +77 -120
  121. package/extensions/plan/tools.ts +188 -30
  122. package/extensions/plan/tui.ts +5 -5
  123. package/extensions/plan/types.ts +48 -5
  124. package/extensions/plan/web-client.ts +3 -0
  125. package/extensions/plan/web-lazy.ts +53 -0
  126. package/extensions/plan/web-markdown.ts +9 -67
  127. package/extensions/plan/web-payload.ts +3 -3
  128. package/extensions/plan/web-router.ts +18 -9
  129. package/extensions/plan/web.ts +1 -12
  130. package/extensions/question/tools.ts +6 -2
  131. package/extensions/question/{tui.ts → view/question-screen.ts} +11 -11
  132. package/extensions/rewind/commands/commands.ts +6 -3
  133. package/extensions/rewind/state/runtime.ts +1 -1
  134. package/extensions/rewind/{view/status.ts → status.ts} +4 -4
  135. package/extensions/rewind/view/review-tui.ts +4 -4
  136. package/extensions/serve/config.ts +10 -8
  137. package/extensions/serve/index.ts +17 -3
  138. package/extensions/serve/registry.ts +26 -17
  139. package/extensions/session-bus/commands.ts +11 -6
  140. package/extensions/session-bus/join-request.ts +2 -1
  141. package/extensions/session-bus/mail-watch.ts +26 -17
  142. package/extensions/session-bus/protocol.ts +15 -3
  143. package/extensions/session-bus/resolve.ts +5 -7
  144. package/extensions/session-bus/runtime.ts +40 -12
  145. package/extensions/session-bus/store.ts +101 -19
  146. package/extensions/session-bus/types.ts +19 -0
  147. package/extensions/session-bus/{tui.ts → view/session-list.ts} +16 -16
  148. package/extensions/subagent/agents.ts +1 -1
  149. package/extensions/subagent/commands.ts +4 -2
  150. package/extensions/subagent/format.ts +15 -15
  151. package/extensions/subagent/index.ts +1 -1
  152. package/extensions/subagent/render.ts +3 -3
  153. package/extensions/subagent/{background-tui.ts → view/agents-board.ts} +11 -11
  154. package/extensions/team/commands.ts +24 -7
  155. package/extensions/team/dispatch.ts +18 -4
  156. package/extensions/team/format.ts +17 -2
  157. package/extensions/team/index.ts +9 -8
  158. package/extensions/team/schema.ts +6 -5
  159. package/extensions/team/tools.ts +107 -45
  160. package/extensions/team/types.ts +3 -2
  161. package/extensions/todo/commands.ts +4 -2
  162. package/extensions/todo/format.ts +11 -0
  163. package/extensions/todo/render.ts +57 -0
  164. package/extensions/todo/runtime.ts +1 -1
  165. package/extensions/todo/tools.ts +1 -1
  166. package/extensions/todo/view/todo-screen.ts +122 -0
  167. package/extensions/todo/widget.ts +47 -0
  168. package/extensions/watch/commands.ts +4 -2
  169. package/extensions/watch/format.ts +36 -7
  170. package/extensions/watch/render.ts +57 -0
  171. package/extensions/watch/runtime.ts +27 -22
  172. package/extensions/watch/tools.ts +4 -4
  173. package/extensions/watch/{tui.ts → view/watch-screen.ts} +18 -80
  174. package/extensions/web-access/fetch/tool.ts +12 -4
  175. package/extensions/wechat/commands/commands.ts +18 -14
  176. package/extensions/wechat/index.ts +72 -37
  177. package/extensions/wechat/state/credentials.ts +1 -1
  178. package/extensions/wechat/state/state.ts +1 -1
  179. package/extensions/wechat/text/status.ts +112 -0
  180. package/extensions/wechat/types.ts +2 -2
  181. package/extensions/wechat/view/status-screen.ts +4 -103
  182. package/extensions/worktree/commands.ts +11 -9
  183. package/extensions/worktree/enter.ts +2 -2
  184. package/extensions/worktree/exit.ts +2 -2
  185. package/extensions/worktree/index.ts +5 -5
  186. package/extensions/worktree/screen.ts +3 -3
  187. package/extensions/worktree/status.ts +2 -2
  188. package/extensions/worktree/tools.ts +113 -116
  189. package/lib/README.md +27 -13
  190. package/lib/board/actions.ts +111 -0
  191. package/lib/board/artifact.ts +149 -0
  192. package/lib/board/card.ts +163 -52
  193. package/lib/board/config.ts +58 -0
  194. package/lib/board/contract.ts +113 -0
  195. package/lib/board/join.ts +38 -0
  196. package/lib/board/lanes.ts +182 -0
  197. package/lib/board/lead.ts +130 -0
  198. package/lib/board/limits.ts +78 -0
  199. package/lib/board/links.ts +31 -10
  200. package/lib/board/page-pointer.ts +1 -1
  201. package/lib/board/read.ts +83 -0
  202. package/lib/board/reason.ts +102 -0
  203. package/lib/board/round-text.ts +68 -6
  204. package/lib/board/round.ts +31 -15
  205. package/lib/board/rules.ts +151 -307
  206. package/lib/board/seat.ts +35 -0
  207. package/lib/board/session.ts +136 -54
  208. package/lib/board/skipped.ts +22 -0
  209. package/lib/board/steps.ts +487 -0
  210. package/lib/board/store.ts +87 -30
  211. package/lib/board/tone.ts +92 -0
  212. package/lib/board/view.ts +70 -58
  213. package/{extensions/watch → lib}/child.ts +40 -21
  214. package/lib/env.ts +65 -7
  215. package/lib/format.ts +13 -0
  216. package/lib/http/index.ts +14 -12
  217. package/lib/http/port.ts +15 -10
  218. package/lib/lazy.ts +45 -0
  219. package/lib/plan-entry.ts +39 -10
  220. package/lib/plans/paths.ts +82 -0
  221. package/lib/policy.ts +9 -6
  222. package/lib/report.ts +3 -2
  223. package/lib/requirements.ts +8 -8
  224. package/lib/session-label.ts +111 -5
  225. package/lib/session-roster.ts +43 -1
  226. package/lib/session-tool.ts +5 -4
  227. package/lib/stray-fields.ts +7 -2
  228. package/lib/tool-names.ts +5 -10
  229. package/lib/tui/README.md +23 -8
  230. package/lib/tui/chrome.ts +16 -15
  231. package/lib/tui/cursor.ts +18 -3
  232. package/lib/tui/glyphs.ts +9 -7
  233. package/lib/tui/tokens.ts +33 -2
  234. package/lib/tui/tool.ts +3 -3
  235. package/lib/web/README.md +21 -0
  236. package/lib/web/components.ts +5 -1
  237. package/lib/web/index.ts +1 -0
  238. package/lib/web/markdown.ts +83 -0
  239. package/lib/web/markup.ts +2 -1
  240. package/lib/web/runtime.ts +9 -10
  241. package/package.json +6 -1
  242. package/skills/init/SKILL.md +5 -5
  243. package/skills/next/SKILL.md +3 -5
  244. package/extensions/board/seat.ts +0 -28
  245. package/extensions/todo/tui.ts +0 -212
  246. package/lib/tui/index.ts +0 -34
  247. package/skills/project-docs/SKILL.md +0 -133
  248. package/skills/project-docs/references/audit.md +0 -131
  249. package/skills/project-docs/references/changelog.md +0 -105
  250. package/skills/project-docs/references/discovery.md +0 -127
  251. package/skills/project-docs/references/readme.md +0 -80
  252. package/skills/project-docs/references/release.md +0 -84
  253. package/skills/project-docs/references/roadmap.md +0 -93
  254. package/skills/project-docs/scripts/state.ts +0 -316
@@ -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,30 +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";
24
- import { requestMount } from "../../lib/http/index.ts";
23
+ import { requestMount } from "../../lib/http/mount.ts";
25
24
  import { sanitizeLine } from "../../lib/format.ts";
26
25
  import { lastGoal, isPursuedGoal, type PublishedGoal } from "../../lib/goal-state.ts";
27
- import { READ_ONLY_SUMMARY, PLAN_ENTER_REQUEST, type PlanEnterRequest } from "../../lib/plan-entry.ts";
28
- import { isBoardRead } from "../../lib/policy.ts";
29
26
  import {
30
- ASK_USER_QUESTION_TOOL,
31
- BOARD_READ_ACTION,
32
- BOARD_TOOL,
33
- SUBAGENT_READ_ONLY_PARAM,
34
- SUBAGENT_TOOL,
35
- } 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";
36
35
  import { clip } from "../../lib/tui/columns.ts";
37
36
  import { STATUS_KEYS } from "../../lib/tui/glyphs.ts";
38
37
  import { planModeNotice, registerCommands } from "./commands.ts";
39
- import { checkReadOnlyGit } from "./git-bash.ts";
40
38
  import { createPlanPolicy, BASH_BLOCKED_GUIDANCE, BLOCKED_GUIDANCE, PLAN_SAFE_TOOLS } from "./policy.ts";
41
39
  import { createPlanRuntime } from "./runtime.ts";
42
40
  import { registerTools } from "./tools.ts";
43
- import { createPlanWebReview } from "./web.ts";
41
+ import { createLazyPlanWebReview } from "./web-lazy.ts";
44
42
 
45
43
  /** Marker embedded in the injected prompt and used to filter stale context. */
46
44
  export const PLAN_MODE_MARKER = "[PLAN MODE ACTIVE]";
@@ -63,7 +61,7 @@ export function planModeContext(goal?: PublishedGoal | null): string {
63
61
  "",
64
62
  `- Do not modify anything except the plan file: while planning, ${READ_ONLY_SUMMARY}.`,
65
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.",
66
- "- 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.",
67
65
  "- Delegate broad exploration with subagent.",
68
66
  `- To choose between approaches, ask with ${ASK_USER_QUESTION_TOOL} before finalizing.`,
69
67
  ];
@@ -94,7 +92,7 @@ export default function planMode(pi: ExtensionAPI): void {
94
92
  const runtime = createPlanRuntime(pi, policy, { entryPath: extensionEntryPath() });
95
93
  /** The session this review serves as; its prefix lands on that session's one server. */
96
94
  let sessionId = "";
97
- const web = createPlanWebReview(runtime.plans, {
95
+ const web = createLazyPlanWebReview(runtime.plans, {
98
96
  mount: (spec) => requestMount(pi, spec),
99
97
  session: () => sessionId,
100
98
  });
@@ -108,33 +106,36 @@ export default function planMode(pi: ExtensionAPI): void {
108
106
  default: false,
109
107
  });
110
108
 
111
- pi.registerShortcut(Key.ctrlAlt("p"), {
112
- description: "Toggle plan mode",
113
- handler: (ctx) => {
114
- runtime.toggle(ctx);
115
- ctx.ui.notify(planModeNotice(runtime.isEnabled()), "info");
116
- },
117
- });
118
-
119
- // Another extension that offers plan mode (the goal workflow) asks on this
120
- // channel instead of calling a tool it cannot reach from a command context. It
121
- // 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.
122
113
  const unsubscribes: Array<() => void> = [];
123
- const listenForEntryRequests = (): void => {
114
+ const listenForRequests = (): void => {
124
115
  for (const unsubscribe of unsubscribes) unsubscribe();
125
116
  unsubscribes.length = 0;
126
- unsubscribes.push(
127
- pi.events.on(PLAN_ENTER_REQUEST, (data) => {
128
- const request = data as Partial<PlanEnterRequest> | undefined;
129
- if (!request?.ctx || typeof request.respond !== "function") return;
130
- if (!runtime.isEnabled()) runtime.enable(request.ctx);
131
- request.respond(true);
132
- }),
133
- );
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
+ });
134
135
  };
135
136
 
136
137
  pi.on("session_start", (_event, ctx) => {
137
- listenForEntryRequests();
138
+ listenForRequests();
138
139
  runtime.restore(ctx);
139
140
  });
140
141
  pi.on("session_tree", (_event, ctx) => runtime.restore(ctx));
@@ -161,12 +162,12 @@ export default function planMode(pi: ExtensionAPI): void {
161
162
  * proceed. Two layers: the policy (readers, `readOnlyHint`, deny list), then
162
163
  * the path backstop (a hint on a path-carrying tool is only a claim).
163
164
  */
164
- const blockedReason = (toolName: string, input: unknown): string | undefined => {
165
- // Plan-owned tools are the sanctioned exception: `write_plan` saves the
166
- // artifact, `exit_plan_mode` presents it, `cancel_plan_mode` restores access,
167
- // and `enter_plan_mode` answers "already" to a call that was in flight when
168
- // plan mode came on. The source-path check keeps a same-named tool from
169
- // 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.
170
171
  if (runtime.isOwnTool(toolName)) return undefined;
171
172
  // `subagent` stays active, but only as read-only delegation. The flag is
172
173
  // forced in place (the child enforces it through `--tools`), so the model
@@ -183,12 +184,10 @@ export default function planMode(pi: ExtensionAPI): void {
183
184
  }
184
185
  // `board` stays active so planning can read the board it is about to add cards
185
186
  // to. The action is the truth here, not the annotation: the same tool writes, so
186
- // `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.
187
188
  if (toolName === BOARD_TOOL) {
188
189
  if (isBoardRead(input)) return undefined;
189
- const action = (input as { action?: unknown } | undefined)?.action;
190
- const verb = typeof action === "string" && action.trim() !== "" ? action.trim() : "board";
191
- 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);
192
191
  }
193
192
  const blocked = policy.check(toolName);
194
193
  if (blocked) return `Plan mode: ${blocked.reason}`;
@@ -201,9 +200,11 @@ export default function planMode(pi: ExtensionAPI): void {
201
200
  if (typeof command !== "string" || command.trim() === "") {
202
201
  return `Plan mode: an empty bash command is not allowed. ${BASH_BLOCKED_GUIDANCE}`;
203
202
  }
203
+ const { checkReadOnlyGit } = await import("./git-bash.ts");
204
204
  const verdict = checkReadOnlyGit(command);
205
205
  if (!verdict.ok) return `Plan mode: ${verdict.reason} ${BASH_BLOCKED_GUIDANCE}`;
206
206
  }
207
+ const { hasPathInput } = await import("./path-guard.ts");
207
208
  if (hasPathInput(input) && !PLAN_SAFE_TOOLS.includes(toolName)) {
208
209
  return `Plan mode: "${toolName}" takes a file path and is not a known reader. ${BLOCKED_GUIDANCE}`;
209
210
  }
@@ -214,9 +215,9 @@ export default function planMode(pi: ExtensionAPI): void {
214
215
  // above hides most of these; this is the second layer for tools already
215
216
  // declared in an in-flight request, and the guard for tools we did not know
216
217
  // about when the extension loaded (MCP servers, future extensions).
217
- pi.on("tool_call", (event) => {
218
+ pi.on("tool_call", async (event) => {
218
219
  if (!runtime.isEnabled()) return undefined;
219
- const reason = blockedReason(event.toolName, event.input);
220
+ const reason = await blockedReason(event.toolName, event.input);
220
221
  return reason ? { block: true, reason } : undefined;
221
222
  });
222
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++) {
@@ -33,13 +33,12 @@ import {
33
33
  unlinkSync,
34
34
  writeFileSync,
35
35
  } from "node:fs";
36
- import { homedir } from "node:os";
37
- import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
36
+ import { join } from "node:path";
38
37
  import { CONFIG_DIR_NAME, getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
39
38
  import { resolveEffectiveCwd } from "../../lib/env.ts";
40
39
  import { keepRevision, writeAtomic } from "./plan-files.ts";
41
40
  import { sanitizeLine, shortenPath } from "../../lib/format.ts";
42
- import { isInside, isInsideReal } from "../../lib/path.ts";
41
+ import { isContainedPlan, isPlanFileName, plansDirFor, resolvePlanArg } from "../../lib/plans/paths.ts";
43
42
  import { repoKeyFor } from "../../lib/repo-key.ts";
44
43
  import { carryFeedback, formatFeedback, normalizeAuthor, type PlanAnnotation } from "./feedback.ts";
45
44
  import { extractPlanSteps, matchesStepText, setStepStatuses, type StepUpdate } from "./steps.ts";
@@ -48,14 +47,6 @@ import { extractPlanSteps, matchesStepText, setStepStatuses, type StepUpdate } f
48
47
  export const MAX_PLAN_BYTES = 256 * 1024;
49
48
  /** Longest slug derived from a plan title. */
50
49
  const MAX_SLUG_LENGTH = 60;
51
- /** The agent-directory entry plans are filed under. */
52
- const PLANS_DIRNAME = "plans";
53
-
54
- /** The plan store: `<agent-dir>/plans`, or `<agent-dir>/plans/<key>` inside a repository. */
55
- function plansDir(agentDir: string, key?: string): string {
56
- return key === undefined ? join(agentDir, PLANS_DIRNAME) : join(agentDir, PLANS_DIRNAME, key);
57
- }
58
-
59
50
  /** A plan read from disk. */
60
51
  export interface StoredPlan {
61
52
  /** Absolute path. */
@@ -160,46 +151,6 @@ export function stamp(date: Date): string {
160
151
  return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}`;
161
152
  }
162
153
 
163
- /** A plan file name: markdown and not a dotfile, so the self-ignore file is never a target. */
164
- function isPlanFileName(path: string): boolean {
165
- const name = basename(path);
166
- return name.toLowerCase().endsWith(".md") && !name.startsWith(".");
167
- }
168
-
169
- /**
170
- * Whether `path` may be read, written, or deleted as a plan. Beyond lexical
171
- * containment (shared with the other path guards through `lib/path.ts`), this
172
- * rejects dotfiles (including the directory's own `.gitignore`) and resolves
173
- * symlinks, so a link placed inside the plans directory cannot redirect a read
174
- * or write outside it.
175
- */
176
- function isContainedPlan(dir: string, path: string): boolean {
177
- if (!isInside(dir, path) || !isPlanFileName(path)) return false;
178
- try {
179
- // A symlink itself must never be the target; a missing file is a new plan.
180
- if (lstatSync(path).isSymbolicLink()) return false;
181
- } catch {
182
- // Missing (ENOENT) is expected; any other error falls through to the
183
- // real-path check, which rejects the path if it cannot be resolved.
184
- }
185
- return isInsideReal(dir, dirname(path));
186
- }
187
-
188
- /**
189
- * Resolve a `plan_path` argument against the store.
190
- *
191
- * A plan lives outside the project, so a path relative to the working directory
192
- * can no longer name one. Absolute paths and the `~/…` form the tools display are
193
- * accepted, and a bare name resolves inside the store — which is what a reader of
194
- * a tool result has in hand.
195
- */
196
- function resolvePlanArg(dir: string, planPath: string): string {
197
- const text = planPath.trim();
198
- if (text === "~") return homedir();
199
- if (text.startsWith("~/") || text.startsWith(`~${sep}`)) return resolve(homedir(), text.slice(2));
200
- return isAbsolute(text) ? resolve(text) : resolve(dir, text);
201
- }
202
-
203
154
  function uniquePath(dir: string, base: string): string {
204
155
  const taken = (candidate: string): boolean => {
205
156
  try {
@@ -300,7 +251,7 @@ export function createPlanStore(pi: ExtensionAPI, options: { now?: () => Date }
300
251
  const cached = dirCache.get(base);
301
252
  if (cached) return cached;
302
253
  const repo = await repoKeyFor(pi, base);
303
- const dir = plansDir(getAgentDir(), repo.fromGit ? repo.key : undefined);
254
+ const dir = plansDirFor(getAgentDir(), repo);
304
255
  dirCache.set(base, { dir, root: repo.fromGit ? repo.root : undefined });
305
256
  if (repo.fromGit) {
306
257
  // The keyed directory is the store; a store an older version left in the
@@ -387,7 +338,7 @@ export function createPlanStore(pi: ExtensionAPI, options: { now?: () => Date }
387
338
 
388
339
  const setSteps = async (cwd: string, planPath: string, updates: StepUpdate[]): Promise<StoredPlan> => {
389
340
  const plan = await read(cwd, planPath);
390
- if (!plan) throw new Error("No plan file there; pass a plan_path written by write_plan.");
341
+ if (!plan) throw new Error("No plan file there; pass a plan_path written by `plan` action `write`.");
391
342
  const steps = extractPlanSteps(plan.content);
392
343
  const total = steps.length;
393
344
  const outOfRange = updates.find(
@@ -433,7 +384,7 @@ export function createPlanStore(pi: ExtensionAPI, options: { now?: () => Date }
433
384
  options: { author?: string } = {},
434
385
  ): Promise<StoredPlan> => {
435
386
  const plan = await read(cwd, planPath);
436
- if (!plan) throw new Error("No plan file there; pass a plan_path written by write_plan.");
387
+ if (!plan) throw new Error("No plan file there; pass a plan_path written by `plan` action `write`.");
437
388
  const author = normalizeAuthor(options.author);
438
389
  // An untagged entry is the writer's own; one tagged with a different name is
439
390
  // another writer's comment, so it is dropped rather than re-signed.
@@ -25,8 +25,6 @@ import {
25
25
  WEB_SEARCH_TOOL,
26
26
  } from "../../lib/tool-names.ts";
27
27
 
28
- import { ENTER_TOOL } from "./runtime.ts";
29
-
30
28
  /**
31
29
  * Tools that stay available while planning: structured readers (documents
32
30
  * included), the trackers, the git-only `bash`, and the known web/question
@@ -53,23 +51,23 @@ export const PLAN_SAFE_TOOLS = [
53
51
  */
54
52
  export const PLAN_ALLOWED_TOOLS = [...PLAN_SAFE_TOOLS, SUBAGENT_TOOL, BOARD_TOOL];
55
53
  /**
56
- * Tools always blocked while planning, even when annotated read-only.
57
- * `enter_plan_mode` is denied so it stays hidden while planning; the guard exempts
58
- * plan's own registration so a call already in flight can still answer "already".
54
+ * Tools always blocked while planning, even when annotated read-only. The `plan`
55
+ * tool is not one of them: plan mode keeps it active in both modes, because its
56
+ * actions are the mode's own verbs and each one refuses outside its mode.
59
57
  */
60
- const ALWAYS_BLOCKED_TOOLS = ["write", "edit", ENTER_TOOL];
58
+ const ALWAYS_BLOCKED_TOOLS = ["write", "edit"];
61
59
 
62
60
  /** Shown when a tool call is refused while planning. */
63
- export const BLOCKED_GUIDANCE = "Call exit_plan_mode and get approval before using it.";
61
+ export const BLOCKED_GUIDANCE = "Call `plan` action `exit` and get approval before using it.";
64
62
 
65
63
  /** Appended to a refused `bash` call; names the readers and the exit hatch. */
66
64
  export const BASH_BLOCKED_GUIDANCE =
67
- "Use the read, grep, find, and ls tools for files, or call exit_plan_mode and get approval before using other shell commands.";
65
+ "Use the read, grep, find, and ls tools for files, or call `plan` action `exit` and get approval before using other shell commands.";
68
66
 
69
67
  /**
70
68
  * Build plan mode's read-only policy: known readers and the plan/goal trackers
71
- * are allowed, write/edit/shell/`enter_plan_mode` are always blocked, and every
72
- * other tool must carry the MCP `readOnlyHint` to pass.
69
+ * are allowed, write/edit and the shells are always blocked, and every other tool
70
+ * must carry the MCP `readOnlyHint` to pass.
73
71
  */
74
72
  export function createPlanPolicy(pi: ExtensionAPI): ReadOnlyPolicy {
75
73
  return createReadOnlyPolicy({
@@ -0,0 +1,85 @@
1
+ /**
2
+ * The plan review gate, loaded when a review actually asks a human.
3
+ *
4
+ * `review.ts` owns the loop, the TUI screen (`tui.ts`), the compact remote prompt,
5
+ * and the baseline rule — about 1,000 lines that registration never needs:
6
+ * `/plan review` and the `plan` tool's `exit` action are the only two doors in. Both import this
7
+ * module instead, so the loop loads once, on the first review of the session.
8
+ *
9
+ * The types stay here rather than in `review.ts` because a type import is erased:
10
+ * the `plan` tool declares its `exit` details from them at registration time.
11
+ */
12
+
13
+ import type { ExtensionToolContext } from "@earendil-works/pi-coding-agent";
14
+ import type { SectionDigest } from "./plan-text.ts";
15
+ import type { PlanStore, StoredPlan } from "./plans.ts";
16
+ import type { PlanAnnotation } from "./feedback.ts";
17
+ import type { PlanReviewResult, PlanReviewSession, PlanWebReview } from "./web.ts";
18
+
19
+ /** A choice from the review screen, with the refinement the screen carried, if any. */
20
+ export interface PlanReviewChoice {
21
+ action: "approve" | "refine" | "keep" | "cancel";
22
+ refinement?: string;
23
+ }
24
+
25
+ /** What a review decided, and the plan state it decided on. */
26
+ export type ReviewDecision =
27
+ | { action: "approve"; plan: StoredPlan; comments: PlanAnnotation[] }
28
+ | {
29
+ action: "refine";
30
+ plan: StoredPlan;
31
+ /** The refinement request: the saved comments, or what the user typed. */
32
+ refinement: string;
33
+ comments: PlanAnnotation[];
34
+ /** True when the request came from the browser, which is reported differently. */
35
+ fromBrowser: boolean;
36
+ }
37
+ | { action: "keep"; plan: StoredPlan }
38
+ /** Leave plan mode without approving; the plan file stays for a later review. */
39
+ | { action: "cancel"; plan: StoredPlan };
40
+
41
+ /** What the previous review showed, so this one can mark what changed. */
42
+ export interface ReviewBaselines {
43
+ /** The sections the last review of `path` showed, when this branch has one. */
44
+ reviewed(path: string): SectionDigest[] | undefined;
45
+ /** Remember what a review just showed, as a session entry on this branch. */
46
+ markReviewed(path: string, content: string): void;
47
+ }
48
+
49
+ /** Everything one review needs: whom to ask, where to ask, and what to compare against. */
50
+ export interface ReviewPlanOptions {
51
+ ctx: ExtensionToolContext;
52
+ web: PlanWebReview;
53
+ /** Re-read the plan after a browser review that may have changed it. */
54
+ plans: PlanStore;
55
+ baselines: ReviewBaselines;
56
+ plan: StoredPlan;
57
+ /** Aborting the turn closes the browser server. */
58
+ signal?: AbortSignal;
59
+ }
60
+
61
+ /** The review module, as the loader returns it. */
62
+ export type ReviewModule = typeof import("./review.ts");
63
+
64
+ /** How a review reaches its implementation; production uses the dynamic import. */
65
+ export type ReviewLoader = () => Promise<ReviewModule>;
66
+
67
+ /** Ask the human about `plan` and return one decision, loading the gate on first use. */
68
+ export async function reviewPlan(options: ReviewPlanOptions, load?: ReviewLoader): Promise<ReviewDecision> {
69
+ const review = await (load ?? (() => import("./review.ts")))();
70
+ return review.reviewPlan(options);
71
+ }
72
+
73
+ /** The browser-only review `/plan review` opens, loaded on first use. */
74
+ export async function runBrowserReview(
75
+ web: PlanWebReview,
76
+ cwd: string,
77
+ plan: StoredPlan,
78
+ options: Parameters<ReviewModule["runBrowserReview"]>[3],
79
+ load?: ReviewLoader,
80
+ ): Promise<PlanReviewResult | undefined> {
81
+ const review = await (load ?? (() => import("./review.ts")))();
82
+ return review.runBrowserReview(web, cwd, plan, options);
83
+ }
84
+
85
+ export type { PlanReviewSession };
@@ -4,7 +4,7 @@
4
4
  * A review has three presentations — the scrollable TUI screen, a dialog-only
5
5
  * editor, and a remote turn's compact editor prompt — and one optional browser
6
6
  * server. This module owns the loop that switches between
7
- * them, so `exit_plan_mode` and `/plan review` share one lifecycle and only map
7
+ * them, so the `plan` tool's `exit` action and `/plan review` share one lifecycle and only map
8
8
  * the returned {@link ReviewDecision} to their own result.
9
9
  *
10
10
  * It also owns the review baseline: a review marks the content the human was
@@ -118,7 +118,7 @@ async function askPlanReview(
118
118
 
119
119
  /** How a browser review may decide, and what to report when it opens. */
120
120
  export interface BrowserReviewOptions {
121
- /** Whether the browser may approve the plan (only the `exit_plan_mode` flow). */
121
+ /** Whether the browser may approve the plan (only the `plan` tool's `exit` flow). */
122
122
  canApprove: boolean;
123
123
  /** The section digests the previous review showed, for the changed markers. */
124
124
  baseline?: SectionDigest[];