@astralyn/pi 0.82.6 → 0.82.7

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 (183) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +1 -1
  3. package/dist/cli/session-picker.d.ts.map +1 -1
  4. package/dist/cli/session-picker.js +11 -13
  5. package/dist/cli/session-picker.js.map +1 -1
  6. package/dist/core/extensions/types.d.ts +4 -0
  7. package/dist/core/extensions/types.d.ts.map +1 -1
  8. package/dist/core/extensions/types.js.map +1 -1
  9. package/dist/core/session-manager.d.ts.map +1 -1
  10. package/dist/core/session-manager.js +2 -2
  11. package/dist/core/session-manager.js.map +1 -1
  12. package/dist/core/tools/bash.d.ts +1 -5
  13. package/dist/core/tools/bash.d.ts.map +1 -1
  14. package/dist/core/tools/bash.js +16 -41
  15. package/dist/core/tools/bash.js.map +1 -1
  16. package/dist/core/tools/edit.d.ts.map +1 -1
  17. package/dist/core/tools/edit.js +42 -11
  18. package/dist/core/tools/edit.js.map +1 -1
  19. package/dist/core/tools/find.d.ts.map +1 -1
  20. package/dist/core/tools/find.js +6 -7
  21. package/dist/core/tools/find.js.map +1 -1
  22. package/dist/core/tools/grep.d.ts.map +1 -1
  23. package/dist/core/tools/grep.js +17 -8
  24. package/dist/core/tools/grep.js.map +1 -1
  25. package/dist/core/tools/ls.d.ts.map +1 -1
  26. package/dist/core/tools/ls.js +3 -3
  27. package/dist/core/tools/ls.js.map +1 -1
  28. package/dist/core/tools/read.d.ts.map +1 -1
  29. package/dist/core/tools/read.js +4 -4
  30. package/dist/core/tools/read.js.map +1 -1
  31. package/dist/core/tools/render-utils.d.ts +7 -0
  32. package/dist/core/tools/render-utils.d.ts.map +1 -1
  33. package/dist/core/tools/render-utils.js +11 -0
  34. package/dist/core/tools/render-utils.js.map +1 -1
  35. package/dist/core/tools/write.d.ts.map +1 -1
  36. package/dist/core/tools/write.js +2 -3
  37. package/dist/core/tools/write.js.map +1 -1
  38. package/dist/extensions/deepwiki/render.d.ts.map +1 -1
  39. package/dist/extensions/deepwiki/render.js +34 -22
  40. package/dist/extensions/deepwiki/render.js.map +1 -1
  41. package/dist/extensions/plan/constants.d.ts +32 -4
  42. package/dist/extensions/plan/constants.d.ts.map +1 -1
  43. package/dist/extensions/plan/constants.js +37 -6
  44. package/dist/extensions/plan/constants.js.map +1 -1
  45. package/dist/extensions/plan/index.d.ts +27 -0
  46. package/dist/extensions/plan/index.d.ts.map +1 -1
  47. package/dist/extensions/plan/index.js +150 -44
  48. package/dist/extensions/plan/index.js.map +1 -1
  49. package/dist/extensions/plan/schema.d.ts +5 -0
  50. package/dist/extensions/plan/schema.d.ts.map +1 -1
  51. package/dist/extensions/plan/schema.js.map +1 -1
  52. package/dist/extensions/plan/state.d.ts +11 -2
  53. package/dist/extensions/plan/state.d.ts.map +1 -1
  54. package/dist/extensions/plan/state.js +31 -9
  55. package/dist/extensions/plan/state.js.map +1 -1
  56. package/dist/extensions/plan/storage.d.ts +11 -3
  57. package/dist/extensions/plan/storage.d.ts.map +1 -1
  58. package/dist/extensions/plan/storage.js +45 -20
  59. package/dist/extensions/plan/storage.js.map +1 -1
  60. package/dist/extensions/plan/view.d.ts +15 -7
  61. package/dist/extensions/plan/view.d.ts.map +1 -1
  62. package/dist/extensions/plan/view.js +91 -16
  63. package/dist/extensions/plan/view.js.map +1 -1
  64. package/dist/extensions/question/dialog.d.ts.map +1 -1
  65. package/dist/extensions/question/dialog.js +29 -10
  66. package/dist/extensions/question/dialog.js.map +1 -1
  67. package/dist/extensions/question/index.d.ts.map +1 -1
  68. package/dist/extensions/question/index.js +7 -2
  69. package/dist/extensions/question/index.js.map +1 -1
  70. package/dist/extensions/question/render.d.ts +4 -8
  71. package/dist/extensions/question/render.d.ts.map +1 -1
  72. package/dist/extensions/question/render.js +202 -29
  73. package/dist/extensions/question/render.js.map +1 -1
  74. package/dist/extensions/question/results.d.ts.map +1 -1
  75. package/dist/extensions/question/results.js +1 -0
  76. package/dist/extensions/question/results.js.map +1 -1
  77. package/dist/extensions/question/schema.d.ts.map +1 -1
  78. package/dist/extensions/question/schema.js +2 -2
  79. package/dist/extensions/question/schema.js.map +1 -1
  80. package/dist/extensions/question/types.d.ts +1 -0
  81. package/dist/extensions/question/types.d.ts.map +1 -1
  82. package/dist/extensions/question/types.js +1 -1
  83. package/dist/extensions/question/types.js.map +1 -1
  84. package/dist/extensions/router/stream.d.ts +1 -0
  85. package/dist/extensions/router/stream.d.ts.map +1 -1
  86. package/dist/extensions/router/stream.js +44 -13
  87. package/dist/extensions/router/stream.js.map +1 -1
  88. package/dist/extensions/subagent/constants.d.ts +1 -0
  89. package/dist/extensions/subagent/constants.d.ts.map +1 -1
  90. package/dist/extensions/subagent/constants.js +1 -0
  91. package/dist/extensions/subagent/constants.js.map +1 -1
  92. package/dist/extensions/subagent/index.d.ts.map +1 -1
  93. package/dist/extensions/subagent/index.js +2 -0
  94. package/dist/extensions/subagent/index.js.map +1 -1
  95. package/dist/extensions/subagent/render.d.ts.map +1 -1
  96. package/dist/extensions/subagent/render.js +121 -48
  97. package/dist/extensions/subagent/render.js.map +1 -1
  98. package/dist/extensions/subagent/retry.d.ts +17 -0
  99. package/dist/extensions/subagent/retry.d.ts.map +1 -0
  100. package/dist/extensions/subagent/retry.js +41 -0
  101. package/dist/extensions/subagent/retry.js.map +1 -0
  102. package/dist/extensions/subagent/runner.d.ts.map +1 -1
  103. package/dist/extensions/subagent/runner.js +33 -5
  104. package/dist/extensions/subagent/runner.js.map +1 -1
  105. package/dist/extensions/subagent/sdk-runner.d.ts +6 -0
  106. package/dist/extensions/subagent/sdk-runner.d.ts.map +1 -1
  107. package/dist/extensions/subagent/sdk-runner.js +46 -24
  108. package/dist/extensions/subagent/sdk-runner.js.map +1 -1
  109. package/dist/extensions/subagent/types.d.ts +7 -0
  110. package/dist/extensions/subagent/types.d.ts.map +1 -1
  111. package/dist/extensions/subagent/types.js.map +1 -1
  112. package/dist/extensions/todo/index.d.ts.map +1 -1
  113. package/dist/extensions/todo/index.js +1 -1
  114. package/dist/extensions/todo/index.js.map +1 -1
  115. package/dist/extensions/todo/state.d.ts.map +1 -1
  116. package/dist/extensions/todo/state.js +10 -2
  117. package/dist/extensions/todo/state.js.map +1 -1
  118. package/dist/extensions/todo/view.d.ts +1 -1
  119. package/dist/extensions/todo/view.d.ts.map +1 -1
  120. package/dist/extensions/todo/view.js +179 -34
  121. package/dist/extensions/todo/view.js.map +1 -1
  122. package/dist/migrations.d.ts.map +1 -1
  123. package/dist/migrations.js +2 -1
  124. package/dist/migrations.js.map +1 -1
  125. package/dist/modes/interactive/components/armin.d.ts.map +1 -1
  126. package/dist/modes/interactive/components/armin.js +5 -0
  127. package/dist/modes/interactive/components/armin.js.map +1 -1
  128. package/dist/modes/interactive/components/bash-execution.d.ts +1 -0
  129. package/dist/modes/interactive/components/bash-execution.d.ts.map +1 -1
  130. package/dist/modes/interactive/components/bash-execution.js +4 -2
  131. package/dist/modes/interactive/components/bash-execution.js.map +1 -1
  132. package/dist/modes/interactive/components/countdown-timer.d.ts +13 -6
  133. package/dist/modes/interactive/components/countdown-timer.d.ts.map +1 -1
  134. package/dist/modes/interactive/components/countdown-timer.js +54 -17
  135. package/dist/modes/interactive/components/countdown-timer.js.map +1 -1
  136. package/dist/modes/interactive/components/extension-selector.d.ts.map +1 -1
  137. package/dist/modes/interactive/components/extension-selector.js +10 -6
  138. package/dist/modes/interactive/components/extension-selector.js.map +1 -1
  139. package/dist/modes/interactive/components/keybinding-hints.d.ts +11 -1
  140. package/dist/modes/interactive/components/keybinding-hints.d.ts.map +1 -1
  141. package/dist/modes/interactive/components/keybinding-hints.js +56 -0
  142. package/dist/modes/interactive/components/keybinding-hints.js.map +1 -1
  143. package/dist/modes/interactive/components/session-selector.d.ts +3 -0
  144. package/dist/modes/interactive/components/session-selector.d.ts.map +1 -1
  145. package/dist/modes/interactive/components/session-selector.js +52 -2
  146. package/dist/modes/interactive/components/session-selector.js.map +1 -1
  147. package/dist/modes/interactive/components/tool-execution.d.ts +10 -0
  148. package/dist/modes/interactive/components/tool-execution.d.ts.map +1 -1
  149. package/dist/modes/interactive/components/tool-execution.js +133 -18
  150. package/dist/modes/interactive/components/tool-execution.js.map +1 -1
  151. package/dist/modes/interactive/components/tool-group.d.ts +1 -0
  152. package/dist/modes/interactive/components/tool-group.d.ts.map +1 -1
  153. package/dist/modes/interactive/components/tool-group.js +5 -0
  154. package/dist/modes/interactive/components/tool-group.js.map +1 -1
  155. package/dist/modes/interactive/components/tree-selector.d.ts +10 -1
  156. package/dist/modes/interactive/components/tree-selector.d.ts.map +1 -1
  157. package/dist/modes/interactive/components/tree-selector.js +55 -2
  158. package/dist/modes/interactive/components/tree-selector.js.map +1 -1
  159. package/dist/modes/interactive/interactive-mode.d.ts +5 -0
  160. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  161. package/dist/modes/interactive/interactive-mode.js +72 -18
  162. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  163. package/dist/modes/interactive/theme/theme.d.ts.map +1 -1
  164. package/dist/modes/interactive/theme/theme.js +3 -2
  165. package/dist/modes/interactive/theme/theme.js.map +1 -1
  166. package/dist/utils/paths.d.ts +6 -0
  167. package/dist/utils/paths.d.ts.map +1 -1
  168. package/dist/utils/paths.js +8 -0
  169. package/dist/utils/paths.js.map +1 -1
  170. package/docs/bundled/README.md +1 -0
  171. package/docs/bundled/extensions/deepwiki.md +1 -1
  172. package/docs/bundled/extensions/plan.md +10 -9
  173. package/docs/bundled/extensions/question.md +7 -5
  174. package/docs/bundled/extensions/router.md +3 -4
  175. package/docs/bundled/extensions/statusline.md +0 -1
  176. package/docs/bundled/extensions/subagent.md +4 -3
  177. package/docs/bundled/extensions/todo.md +1 -1
  178. package/docs/bundled/tool-presentation.md +17 -16
  179. package/docs/extensions.md +20 -5
  180. package/docs/rpc.md +1 -1
  181. package/docs/usage.md +1 -1
  182. package/npm-shrinkwrap.json +6 -6
  183. package/package.json +1 -1
@@ -12,12 +12,31 @@ export const PLAN_FLAG_NAME = "plan";
12
12
  export const PLAN_ENTRY_TYPE = "plan-mode";
13
13
  export const PLAN_STATUS_KEY = "plan-mode";
14
14
  /**
15
- * Tools removed while planning. edit/write/bash grant write access directly;
16
- * subagent spawns child sessions that do not inherit the restricted tool set,
17
- * so leaving it active would let the model write through a child agent.
18
- * Other extension tools (question, todo, deepwiki, ...) stay available.
15
+ * customType of the post-compaction kickoff message. Sent via sendMessage so
16
+ * the LLM receives the full plan while the TUI renders a collapsed card.
19
17
  */
20
- export const PLAN_BLOCKED_TOOLS = new Set(["edit", "write", "bash", "subagent"]);
18
+ export const PLAN_KICKOFF_MESSAGE_TYPE = "plan-kickoff";
19
+ /**
20
+ * Tools blocked while planning: edit/write grant write access directly.
21
+ * bash stays available and is constrained to read-only inspection via the
22
+ * plan-mode system prompt (same contract as the explorer subagent), and
23
+ * subagent stays available but is limited to the built-in explorer profile
24
+ * by the tool_call guard. Other extension tools (question, todo, deepwiki,
25
+ * ...) stay available.
26
+ */
27
+ export const PLAN_BLOCKED_TOOLS = new Set(["edit", "write"]);
28
+ /**
29
+ * Exploration tools activated on entry. Mirrors the explorer subagent's
30
+ * EXPLORER_TOOLS (kept as a separate constant so plan does not depend on the
31
+ * subagent extension): the standalone search tools plus bash for read-only
32
+ * inspection. grep/find/ls are registered but not active in the default
33
+ * session tool set, so plan mode must activate them explicitly.
34
+ */
35
+ export const PLAN_EXPLORE_TOOLS = ["read", "grep", "find", "ls", "bash"];
36
+ /** Only subagent profile allowed while planning (read-only exploration). */
37
+ export const PLAN_ALLOWED_SUBAGENT = "explorer";
38
+ /** Name of the subagent extension tool, for the plan-mode tool_call guard. */
39
+ export const PLAN_SUBAGENT_TOOL_NAME = "subagent";
21
40
  /** Max characters of plan markdown echoed into the post-compaction kickoff message. */
22
41
  export const PLAN_EMBED_MAX_CHARS = 24_000;
23
42
  export const MENU_EXECUTE = "Start executing";
@@ -35,6 +54,15 @@ export const EXIT_PLAN_MENU_OPTIONS = [
35
54
  ];
36
55
  /** Esc maps to "keep planning", so the dismiss hint must not read as "discard". */
37
56
  export const EXIT_PLAN_CANCEL_HINT = "keep planning";
57
+ /**
58
+ * /plan panel (shown when the command runs while planning): exit is the first
59
+ * option, saved plans follow. Esc keeps planning with no side effects.
60
+ */
61
+ export const PLAN_PANEL_TITLE = "Plan mode";
62
+ export const PLAN_PANEL_EXIT = "Exit plan mode";
63
+ export const PLAN_PANEL_EXIT_DESCRIPTION = "restore full tool access";
64
+ export const PLAN_PANEL_CANCEL_HINT = "keep planning";
65
+ export const PLAN_PANEL_MAX_FILES = 20;
38
66
  export const EXIT_PLAN_TOOL_DESCRIPTION = "Submit the finished plan and ask the user to approve leaving plan mode. Present the complete plan as normal response text first, then call this tool with a short title and the same plan markdown; on approval the plan is saved to disk. The user chooses between executing with full context, compacting the context before executing, or continuing to plan. Only available while plan mode is active.";
39
67
  export const EXIT_PLAN_PROMPT_SNIPPET = "Submit the finished plan for user approval and leave plan mode";
40
68
  export const EXIT_PLAN_PROMPT_GUIDELINES = [
@@ -45,7 +73,10 @@ const PLAN_MODE_PROMPT = `[PLAN MODE]
45
73
  You are in plan mode: a read-only phase for exploring the codebase and agreeing on a plan with the user before any changes are made.
46
74
 
47
75
  Restrictions:
48
- - edit, write, bash, and subagent are unavailable; do not attempt changes or workarounds. Explore with read, grep, find, and ls instead.
76
+ - edit and write are unavailable; do not attempt changes or workarounds.
77
+ - bash is for read-only inspection only: git log/diff/blame/show/status, ls, wc, head, tail, cat, and similar. Never run anything that modifies state: no file creation or deletion, no redirect (>, >>) or heredoc writes, no temp files, no git commands that write (add, commit, checkout, restore, stash, clean), no installs, no network access.
78
+ - subagent is limited to the read-only explorer profile: always pass agent: "${PLAN_ALLOWED_SUBAGENT}"; other profiles are blocked while planning.
79
+ - Explore with read, grep, find, ls, read-only bash, and explorer subagents.
49
80
 
50
81
  Working style:
51
82
  - Answer questions from the code, not from the user: explore before asking.
@@ -1 +1 @@
1
- {"version":3,"file":"constants.js","sourceRoot":"","sources":["../../../src/extensions/plan/constants.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAIH,MAAM,CAAC,MAAM,mBAAmB,GAAG,WAAW,CAAC;AAC/C,MAAM,CAAC,MAAM,oBAAoB,GAAG,WAAW,CAAC;AAChD,MAAM,CAAC,MAAM,iBAAiB,GAAG,MAAM,CAAC;AACxC,MAAM,CAAC,MAAM,cAAc,GAAG,MAAM,CAAC;AACrC,MAAM,CAAC,MAAM,eAAe,GAAG,WAAW,CAAC;AAC3C,MAAM,CAAC,MAAM,eAAe,GAAG,WAAW,CAAC;AAE3C;;;;;GAKG;AACH,MAAM,CAAC,MAAM,kBAAkB,GAAwB,IAAI,GAAG,CAAC,CAAC,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,UAAU,CAAC,CAAC,CAAC;AAEtG,uFAAuF;AACvF,MAAM,CAAC,MAAM,oBAAoB,GAAG,MAAM,CAAC;AAE3C,MAAM,CAAC,MAAM,YAAY,GAAG,iBAAiB,CAAC;AAC9C,MAAM,CAAC,MAAM,oBAAoB,GAAG,+BAA+B,CAAC;AACpE,MAAM,CAAC,MAAM,kBAAkB,GAAG,eAAe,CAAC;AAElD;;;;GAIG;AACH,MAAM,CAAC,MAAM,sBAAsB,GAA4B;IAC9D,EAAE,KAAK,EAAE,YAAY,EAAE,WAAW,EAAE,mBAAmB,EAAE;IACzD,EAAE,KAAK,EAAE,oBAAoB,EAAE,WAAW,EAAE,qBAAqB,EAAE;IACnE,EAAE,KAAK,EAAE,kBAAkB,EAAE,WAAW,EAAE,uBAAuB,EAAE;CACnE,CAAC;AAEF,mFAAmF;AACnF,MAAM,CAAC,MAAM,qBAAqB,GAAG,eAAe,CAAC;AAErD,MAAM,CAAC,MAAM,0BAA0B,GACtC,4YAA4Y,CAAC;AAE9Y,MAAM,CAAC,MAAM,wBAAwB,GAAG,gEAAgE,CAAC;AAEzG,MAAM,CAAC,MAAM,2BAA2B,GAAG;IAC1C,+IAA+I;IAC/I,uJAAuJ;CACvJ,CAAC;AAEF,MAAM,gBAAgB,GAAG;;;;;;;;;;;;;qKAa4I,CAAC;AAEtK,4FAA4F;AAC5F,MAAM,UAAU,mBAAmB,CAAC,SAAmB,EAAU;IAChE,IAAI,CAAC,SAAS,CAAC,MAAM;QAAE,OAAO,gBAAgB,CAAC;IAC/C,MAAM,OAAO,GAAG,SAAS,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,KAAK,IAAI,EAAE,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAChE,OAAO,GAAG,gBAAgB;;;EAGzB,OAAO;0NACiN,CAAC;AAAA,CAC1N","sourcesContent":["/**\n * plan/constants.ts — tool identity, command names, and prompt copy.\n *\n * Name/label/command live here so `index.ts` only assembles the extension,\n * matching the todo/question constants.ts pattern. Prompt copy is the\n * model-facing contract; keep it stable.\n */\n\nimport type { SelectOption } from \"../../core/extensions/types.ts\";\n\nexport const EXIT_PLAN_TOOL_NAME = \"exit_plan\";\nexport const EXIT_PLAN_TOOL_LABEL = \"Exit Plan\";\nexport const PLAN_COMMAND_NAME = \"plan\";\nexport const PLAN_FLAG_NAME = \"plan\";\nexport const PLAN_ENTRY_TYPE = \"plan-mode\";\nexport const PLAN_STATUS_KEY = \"plan-mode\";\n\n/**\n * Tools removed while planning. edit/write/bash grant write access directly;\n * subagent spawns child sessions that do not inherit the restricted tool set,\n * so leaving it active would let the model write through a child agent.\n * Other extension tools (question, todo, deepwiki, ...) stay available.\n */\nexport const PLAN_BLOCKED_TOOLS: ReadonlySet<string> = new Set([\"edit\", \"write\", \"bash\", \"subagent\"]);\n\n/** Max characters of plan markdown echoed into the post-compaction kickoff message. */\nexport const PLAN_EMBED_MAX_CHARS = 24_000;\n\nexport const MENU_EXECUTE = \"Start executing\";\nexport const MENU_COMPACT_EXECUTE = \"Compact context, then execute\";\nexport const MENU_KEEP_PLANNING = \"Keep planning\";\n\n/**\n * Approval choices. The label is the decision, the description is the\n * trade-off, so the three options can be told apart without reading to the\n * end of a sentence. select() resolves to the label.\n */\nexport const EXIT_PLAN_MENU_OPTIONS: readonly SelectOption[] = [\n\t{ label: MENU_EXECUTE, description: \"keep full context\" },\n\t{ label: MENU_COMPACT_EXECUTE, description: \"best for long tasks\" },\n\t{ label: MENU_KEEP_PLANNING, description: \"refine before running\" },\n];\n\n/** Esc maps to \"keep planning\", so the dismiss hint must not read as \"discard\". */\nexport const EXIT_PLAN_CANCEL_HINT = \"keep planning\";\n\nexport const EXIT_PLAN_TOOL_DESCRIPTION =\n\t\"Submit the finished plan and ask the user to approve leaving plan mode. Present the complete plan as normal response text first, then call this tool with a short title and the same plan markdown; on approval the plan is saved to disk. The user chooses between executing with full context, compacting the context before executing, or continuing to plan. Only available while plan mode is active.\";\n\nexport const EXIT_PLAN_PROMPT_SNIPPET = \"Submit the finished plan for user approval and leave plan mode\";\n\nexport const EXIT_PLAN_PROMPT_GUIDELINES = [\n\t\"Call `exit_plan` only after presenting the complete plan as response text; make it the only tool call in that step and stop after it returns.\",\n\t\"`exit_plan` results are authoritative for plan-mode state: follow them even when the plan-mode system prompt block is still present later in the run.\",\n];\n\nconst PLAN_MODE_PROMPT = `[PLAN MODE]\nYou are in plan mode: a read-only phase for exploring the codebase and agreeing on a plan with the user before any changes are made.\n\nRestrictions:\n- edit, write, bash, and subagent are unavailable; do not attempt changes or workarounds. Explore with read, grep, find, and ls instead.\n\nWorking style:\n- Answer questions from the code, not from the user: explore before asking.\n- When product intent, scope, or risk tolerance is unclear, interview the user with the question tool. Focus on one decision per exchange (batch only tightly coupled decisions) and always include your recommendation and the trade-offs.\n- Skip the interview for small, clear tasks. Pick sensible defaults for low-risk details and state them in the plan.\n\nDelivering the plan:\n- When the plan is ready, present it in full as normal response text (goal, steps, verification criteria) so the user can read it, then call exit_plan with a short title and the same markdown as the plan parameter.\n- The exit_plan tool result is authoritative for mode state: this block can outlive plan mode within a run, so if the result says plan mode has ended, it has ended.`;\n\n/** Build the plan-mode system prompt block, listing plans already saved for this branch. */\nexport function buildPlanModePrompt(planFiles: string[]): string {\n\tif (!planFiles.length) return PLAN_MODE_PROMPT;\n\tconst listing = planFiles.map((path) => `- ${path}`).join(\"\\n\");\n\treturn `${PLAN_MODE_PROMPT}\n\nPlans already saved for this session (read them with the read tool):\n${listing}\nWhen revising a saved plan, read the latest one first, account for work already completed, and produce a revision that states what changed and why; set the revises parameter to the file name of the plan being revised.`;\n}\n"]}
1
+ {"version":3,"file":"constants.js","sourceRoot":"","sources":["../../../src/extensions/plan/constants.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAIH,MAAM,CAAC,MAAM,mBAAmB,GAAG,WAAW,CAAC;AAC/C,MAAM,CAAC,MAAM,oBAAoB,GAAG,WAAW,CAAC;AAChD,MAAM,CAAC,MAAM,iBAAiB,GAAG,MAAM,CAAC;AACxC,MAAM,CAAC,MAAM,cAAc,GAAG,MAAM,CAAC;AACrC,MAAM,CAAC,MAAM,eAAe,GAAG,WAAW,CAAC;AAC3C,MAAM,CAAC,MAAM,eAAe,GAAG,WAAW,CAAC;AAE3C;;;GAGG;AACH,MAAM,CAAC,MAAM,yBAAyB,GAAG,cAAc,CAAC;AAExD;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,kBAAkB,GAAwB,IAAI,GAAG,CAAC,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;AAElF;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,kBAAkB,GAAsB,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,CAAC,CAAC;AAE5F,4EAA4E;AAC5E,MAAM,CAAC,MAAM,qBAAqB,GAAG,UAAU,CAAC;AAEhD,8EAA8E;AAC9E,MAAM,CAAC,MAAM,uBAAuB,GAAG,UAAU,CAAC;AAElD,uFAAuF;AACvF,MAAM,CAAC,MAAM,oBAAoB,GAAG,MAAM,CAAC;AAE3C,MAAM,CAAC,MAAM,YAAY,GAAG,iBAAiB,CAAC;AAC9C,MAAM,CAAC,MAAM,oBAAoB,GAAG,+BAA+B,CAAC;AACpE,MAAM,CAAC,MAAM,kBAAkB,GAAG,eAAe,CAAC;AAElD;;;;GAIG;AACH,MAAM,CAAC,MAAM,sBAAsB,GAA4B;IAC9D,EAAE,KAAK,EAAE,YAAY,EAAE,WAAW,EAAE,mBAAmB,EAAE;IACzD,EAAE,KAAK,EAAE,oBAAoB,EAAE,WAAW,EAAE,qBAAqB,EAAE;IACnE,EAAE,KAAK,EAAE,kBAAkB,EAAE,WAAW,EAAE,uBAAuB,EAAE;CACnE,CAAC;AAEF,mFAAmF;AACnF,MAAM,CAAC,MAAM,qBAAqB,GAAG,eAAe,CAAC;AAErD;;;GAGG;AACH,MAAM,CAAC,MAAM,gBAAgB,GAAG,WAAW,CAAC;AAC5C,MAAM,CAAC,MAAM,eAAe,GAAG,gBAAgB,CAAC;AAChD,MAAM,CAAC,MAAM,2BAA2B,GAAG,0BAA0B,CAAC;AACtE,MAAM,CAAC,MAAM,sBAAsB,GAAG,eAAe,CAAC;AACtD,MAAM,CAAC,MAAM,oBAAoB,GAAG,EAAE,CAAC;AAEvC,MAAM,CAAC,MAAM,0BAA0B,GACtC,4YAA4Y,CAAC;AAE9Y,MAAM,CAAC,MAAM,wBAAwB,GAAG,gEAAgE,CAAC;AAEzG,MAAM,CAAC,MAAM,2BAA2B,GAAG;IAC1C,+IAA+I;IAC/I,uJAAuJ;CACvJ,CAAC;AAEF,MAAM,gBAAgB,GAAG;;;;;;+EAMsD,qBAAqB;;;;;;;;;;qKAUiE,CAAC;AAEtK,4FAA4F;AAC5F,MAAM,UAAU,mBAAmB,CAAC,SAAmB,EAAU;IAChE,IAAI,CAAC,SAAS,CAAC,MAAM;QAAE,OAAO,gBAAgB,CAAC;IAC/C,MAAM,OAAO,GAAG,SAAS,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,KAAK,IAAI,EAAE,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAChE,OAAO,GAAG,gBAAgB;;;EAGzB,OAAO;0NACiN,CAAC;AAAA,CAC1N","sourcesContent":["/**\n * plan/constants.ts — tool identity, command names, and prompt copy.\n *\n * Name/label/command live here so `index.ts` only assembles the extension,\n * matching the todo/question constants.ts pattern. Prompt copy is the\n * model-facing contract; keep it stable.\n */\n\nimport type { SelectOption } from \"../../core/extensions/types.ts\";\n\nexport const EXIT_PLAN_TOOL_NAME = \"exit_plan\";\nexport const EXIT_PLAN_TOOL_LABEL = \"Exit Plan\";\nexport const PLAN_COMMAND_NAME = \"plan\";\nexport const PLAN_FLAG_NAME = \"plan\";\nexport const PLAN_ENTRY_TYPE = \"plan-mode\";\nexport const PLAN_STATUS_KEY = \"plan-mode\";\n\n/**\n * customType of the post-compaction kickoff message. Sent via sendMessage so\n * the LLM receives the full plan while the TUI renders a collapsed card.\n */\nexport const PLAN_KICKOFF_MESSAGE_TYPE = \"plan-kickoff\";\n\n/**\n * Tools blocked while planning: edit/write grant write access directly.\n * bash stays available and is constrained to read-only inspection via the\n * plan-mode system prompt (same contract as the explorer subagent), and\n * subagent stays available but is limited to the built-in explorer profile\n * by the tool_call guard. Other extension tools (question, todo, deepwiki,\n * ...) stay available.\n */\nexport const PLAN_BLOCKED_TOOLS: ReadonlySet<string> = new Set([\"edit\", \"write\"]);\n\n/**\n * Exploration tools activated on entry. Mirrors the explorer subagent's\n * EXPLORER_TOOLS (kept as a separate constant so plan does not depend on the\n * subagent extension): the standalone search tools plus bash for read-only\n * inspection. grep/find/ls are registered but not active in the default\n * session tool set, so plan mode must activate them explicitly.\n */\nexport const PLAN_EXPLORE_TOOLS: readonly string[] = [\"read\", \"grep\", \"find\", \"ls\", \"bash\"];\n\n/** Only subagent profile allowed while planning (read-only exploration). */\nexport const PLAN_ALLOWED_SUBAGENT = \"explorer\";\n\n/** Name of the subagent extension tool, for the plan-mode tool_call guard. */\nexport const PLAN_SUBAGENT_TOOL_NAME = \"subagent\";\n\n/** Max characters of plan markdown echoed into the post-compaction kickoff message. */\nexport const PLAN_EMBED_MAX_CHARS = 24_000;\n\nexport const MENU_EXECUTE = \"Start executing\";\nexport const MENU_COMPACT_EXECUTE = \"Compact context, then execute\";\nexport const MENU_KEEP_PLANNING = \"Keep planning\";\n\n/**\n * Approval choices. The label is the decision, the description is the\n * trade-off, so the three options can be told apart without reading to the\n * end of a sentence. select() resolves to the label.\n */\nexport const EXIT_PLAN_MENU_OPTIONS: readonly SelectOption[] = [\n\t{ label: MENU_EXECUTE, description: \"keep full context\" },\n\t{ label: MENU_COMPACT_EXECUTE, description: \"best for long tasks\" },\n\t{ label: MENU_KEEP_PLANNING, description: \"refine before running\" },\n];\n\n/** Esc maps to \"keep planning\", so the dismiss hint must not read as \"discard\". */\nexport const EXIT_PLAN_CANCEL_HINT = \"keep planning\";\n\n/**\n * /plan panel (shown when the command runs while planning): exit is the first\n * option, saved plans follow. Esc keeps planning with no side effects.\n */\nexport const PLAN_PANEL_TITLE = \"Plan mode\";\nexport const PLAN_PANEL_EXIT = \"Exit plan mode\";\nexport const PLAN_PANEL_EXIT_DESCRIPTION = \"restore full tool access\";\nexport const PLAN_PANEL_CANCEL_HINT = \"keep planning\";\nexport const PLAN_PANEL_MAX_FILES = 20;\n\nexport const EXIT_PLAN_TOOL_DESCRIPTION =\n\t\"Submit the finished plan and ask the user to approve leaving plan mode. Present the complete plan as normal response text first, then call this tool with a short title and the same plan markdown; on approval the plan is saved to disk. The user chooses between executing with full context, compacting the context before executing, or continuing to plan. Only available while plan mode is active.\";\n\nexport const EXIT_PLAN_PROMPT_SNIPPET = \"Submit the finished plan for user approval and leave plan mode\";\n\nexport const EXIT_PLAN_PROMPT_GUIDELINES = [\n\t\"Call `exit_plan` only after presenting the complete plan as response text; make it the only tool call in that step and stop after it returns.\",\n\t\"`exit_plan` results are authoritative for plan-mode state: follow them even when the plan-mode system prompt block is still present later in the run.\",\n];\n\nconst PLAN_MODE_PROMPT = `[PLAN MODE]\nYou are in plan mode: a read-only phase for exploring the codebase and agreeing on a plan with the user before any changes are made.\n\nRestrictions:\n- edit and write are unavailable; do not attempt changes or workarounds.\n- bash is for read-only inspection only: git log/diff/blame/show/status, ls, wc, head, tail, cat, and similar. Never run anything that modifies state: no file creation or deletion, no redirect (>, >>) or heredoc writes, no temp files, no git commands that write (add, commit, checkout, restore, stash, clean), no installs, no network access.\n- subagent is limited to the read-only explorer profile: always pass agent: \"${PLAN_ALLOWED_SUBAGENT}\"; other profiles are blocked while planning.\n- Explore with read, grep, find, ls, read-only bash, and explorer subagents.\n\nWorking style:\n- Answer questions from the code, not from the user: explore before asking.\n- When product intent, scope, or risk tolerance is unclear, interview the user with the question tool. Focus on one decision per exchange (batch only tightly coupled decisions) and always include your recommendation and the trade-offs.\n- Skip the interview for small, clear tasks. Pick sensible defaults for low-risk details and state them in the plan.\n\nDelivering the plan:\n- When the plan is ready, present it in full as normal response text (goal, steps, verification criteria) so the user can read it, then call exit_plan with a short title and the same markdown as the plan parameter.\n- The exit_plan tool result is authoritative for mode state: this block can outlive plan mode within a run, so if the result says plan mode has ended, it has ended.`;\n\n/** Build the plan-mode system prompt block, listing plans already saved for this branch. */\nexport function buildPlanModePrompt(planFiles: string[]): string {\n\tif (!planFiles.length) return PLAN_MODE_PROMPT;\n\tconst listing = planFiles.map((path) => `- ${path}`).join(\"\\n\");\n\treturn `${PLAN_MODE_PROMPT}\n\nPlans already saved for this session (read them with the read tool):\n${listing}\nWhen revising a saved plan, read the latest one first, account for work already completed, and produce a revision that states what changed and why; set the revises parameter to the file name of the plan being revised.`;\n}\n"]}
@@ -1,3 +1,30 @@
1
+ /**
2
+ * plan — Claude-Code-style plan mode: read-only exploration and interview,
3
+ * then a user-approved exit that saves the plan and optionally compacts.
4
+ *
5
+ * Shape:
6
+ * - Entry (/plan or --plan) snapshots the active tool set and swaps to the
7
+ * exploration set (read/grep/find/ls/bash + exit_plan; edit/write removed,
8
+ * bash prompt-constrained to read-only, subagent guarded to the explorer
9
+ * profile); a tool_call guard covers requests already in flight before the
10
+ * tool-set change lands.
11
+ * - The plan text travels as an exit_plan parameter, so the model needs no
12
+ * write access to produce the plan file.
13
+ * - "Compact, then execute" keeps plan mode active until compaction completes
14
+ * (tools are restored in onComplete) and terminates the run via the tool
15
+ * result, so there is no window where the model holds full context plus
16
+ * write tools. The kickoff message embeds the plan itself — continuation
17
+ * never depends on the compaction summary preserving a path.
18
+ * - State is conversation-backed via custom entries and branch replay (same
19
+ * pattern as todo): /reload, /tree, resume, and fork all restore.
20
+ */
1
21
  import type { ExtensionAPI } from "../../core/extensions/types.ts";
22
+ /**
23
+ * First subagent profile in the call that is not allowed in plan mode, or
24
+ * undefined when every profile is the read-only explorer. An omitted or null
25
+ * agent resolves to the default "general" profile, so it is disallowed too.
26
+ * Args arrive off the wire, so every field is read defensively.
27
+ */
28
+ export declare function findDisallowedSubagentProfile(input: unknown): string | undefined;
2
29
  export default function plan(pi: ExtensionAPI): void;
3
30
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/extensions/plan/index.ts"],"names":[],"mappings":"AAmBA,OAAO,KAAK,EAEX,YAAY,EAGZ,MAAM,gCAAgC,CAAC;AAmDxC,MAAM,CAAC,OAAO,UAAU,IAAI,CAAC,EAAE,EAAE,YAAY,GAAG,IAAI,CAmTnD","sourcesContent":["/**\n * plan — Claude-Code-style plan mode: read-only exploration and interview,\n * then a user-approved exit that saves the plan and optionally compacts.\n *\n * Shape:\n * - Entry (/plan or --plan) removes write-capable tools (edit/write/bash/\n * subagent) and activates exit_plan; a tool_call guard covers requests\n * already in flight before the tool-set change lands.\n * - The plan text travels as an exit_plan parameter, so the model needs no\n * write access to produce the plan file.\n * - \"Compact, then execute\" keeps plan mode active until compaction completes\n * (tools are restored in onComplete) and terminates the run via the tool\n * result, so there is no window where the model holds full context plus\n * write tools. The kickoff message embeds the plan itself — continuation\n * never depends on the compaction summary preserving a path.\n * - State is conversation-backed via custom entries and branch replay (same\n * pattern as todo): /reload, /tree, resume, and fork all restore.\n */\nimport { Text } from \"@earendil-works/pi-tui\";\nimport type {\n\tAgentToolResult,\n\tExtensionAPI,\n\tExtensionContext,\n\tToolExecutionMode,\n} from \"../../core/extensions/types.ts\";\nimport {\n\tbuildPlanModePrompt,\n\tEXIT_PLAN_CANCEL_HINT,\n\tEXIT_PLAN_MENU_OPTIONS,\n\tEXIT_PLAN_PROMPT_GUIDELINES,\n\tEXIT_PLAN_PROMPT_SNIPPET,\n\tEXIT_PLAN_TOOL_DESCRIPTION,\n\tEXIT_PLAN_TOOL_LABEL,\n\tEXIT_PLAN_TOOL_NAME,\n\tMENU_COMPACT_EXECUTE,\n\tMENU_EXECUTE,\n\tMENU_KEEP_PLANNING,\n\tPLAN_BLOCKED_TOOLS,\n\tPLAN_COMMAND_NAME,\n\tPLAN_EMBED_MAX_CHARS,\n\tPLAN_ENTRY_TYPE,\n\tPLAN_FLAG_NAME,\n\tPLAN_STATUS_KEY,\n} from \"./constants.ts\";\nimport { type ExitPlanDetails, type ExitPlanParams, ExitPlanParamsSchema } from \"./schema.ts\";\nimport {\n\tclonePlanState,\n\tdisposePlanSession,\n\tEMPTY_PLAN_STATE,\n\tgetPlanState,\n\treplacePlanState,\n\treplayPlanFromBranch,\n\tsetActivePlanSession,\n} from \"./state.ts\";\nimport { savePlanFile } from \"./storage.ts\";\nimport { formatApprovalSubtitle, formatExitPlanCall, formatExitPlanResult } from \"./view.ts\";\n\ninterface PendingCompact {\n\ttitle: string;\n\tplan: string;\n\tplanPath: string;\n}\n\nfunction buildKickoffMessage(pending: PendingCompact): string {\n\tconst truncated = pending.plan.length > PLAN_EMBED_MAX_CHARS;\n\tconst body = truncated\n\t\t? `${pending.plan.slice(0, PLAN_EMBED_MAX_CHARS)}\\n\\n[Plan truncated — read the full plan at ${pending.planPath}]`\n\t\t: pending.plan;\n\treturn `Execute the approved plan \"${pending.title}\" (saved at ${pending.planPath}).\n\n${body}\n\nStart now. Track multi-step work with the todo tool, and report back instead of improvising if the plan turns out to need revision.`;\n}\n\nexport default function plan(pi: ExtensionAPI): void {\n\tlet menuOpen = false;\n\tlet pendingCompact: PendingCompact | undefined;\n\n\tpi.registerFlag(PLAN_FLAG_NAME, {\n\t\tdescription: \"Start in plan mode (read-only planning)\",\n\t\ttype: \"boolean\",\n\t\tdefault: false,\n\t});\n\n\t/** Remove blocked tools, activate exit_plan, and return what was actually removed. */\n\tfunction applyPlanTools(): string[] {\n\t\tconst active = pi.getActiveTools();\n\t\tconst removed = active.filter((name) => PLAN_BLOCKED_TOOLS.has(name));\n\t\tpi.setActiveTools([...new Set([...active.filter((name) => !PLAN_BLOCKED_TOOLS.has(name)), EXIT_PLAN_TOOL_NAME])]);\n\t\treturn removed;\n\t}\n\n\t/** Inverse of applyPlanTools: drop exit_plan, re-add exactly what was removed. */\n\tfunction restoreTools(removedTools: string[]): void {\n\t\tconst active = pi.getActiveTools().filter((name) => name !== EXIT_PLAN_TOOL_NAME);\n\t\tpi.setActiveTools([...new Set([...active, ...removedTools])]);\n\t}\n\n\tfunction persist(): void {\n\t\tpi.appendEntry(PLAN_ENTRY_TYPE, getPlanState());\n\t}\n\n\tfunction updateStatus(ctx: ExtensionContext): void {\n\t\tconst state = getPlanState();\n\t\tctx.ui.setStatus(PLAN_STATUS_KEY, state.planning ? ctx.ui.theme.fg(\"accent\", \"Plan Mode\") : undefined);\n\t}\n\n\tfunction enterPlanMode(ctx: ExtensionContext): void {\n\t\tconst state = getPlanState();\n\t\tif (state.planning) return;\n\t\tconst removedTools = applyPlanTools();\n\t\treplacePlanState({ planning: true, removedTools, awaitingCompact: false, planFiles: state.planFiles });\n\t\tpersist();\n\t\tupdateStatus(ctx);\n\t\tctx.ui.notify(\"Plan mode enabled: edit, write, bash, and subagent are disabled.\", \"info\");\n\t}\n\n\tfunction exitPlanMode(ctx: ExtensionContext, notifyText?: string): void {\n\t\tconst state = getPlanState();\n\t\trestoreTools(state.removedTools);\n\t\treplacePlanState({ ...state, planning: false, awaitingCompact: false, removedTools: [] });\n\t\tpersist();\n\t\tupdateStatus(ctx);\n\t\tif (notifyText) ctx.ui.notify(notifyText, \"info\");\n\t}\n\n\t/** Save the approved plan, index it in state, and name the session if unnamed. */\n\tfunction saveApprovedPlan(ctx: ExtensionContext, params: ExitPlanParams, title: string): string {\n\t\tconst planPath = savePlanFile({\n\t\t\tsessionId: ctx.sessionManager.getSessionId(),\n\t\t\ttitle,\n\t\t\tplan: params.plan,\n\t\t\tcwd: ctx.cwd,\n\t\t\trevises: params.revises,\n\t\t});\n\t\tconst state = getPlanState();\n\t\treplacePlanState({ ...state, planFiles: [...state.planFiles, planPath] });\n\t\tif (!pi.getSessionName()) pi.setSessionName(title);\n\t\treturn planPath;\n\t}\n\n\t/**\n\t * Rebuild state from the current branch and reconcile the live tool set.\n\t * An interrupted compact-and-execute approval (restart, resume, branch\n\t * switch) degrades to the keep-context exit so the approval is honored.\n\t */\n\tfunction syncFromBranch(ctx: ExtensionContext, options?: { applyFlag?: boolean }): void {\n\t\tsetActivePlanSession(ctx.sessionManager.getSessionId());\n\t\tconst before = getPlanState();\n\t\tconst replayed = replayPlanFromBranch(ctx);\n\t\tconst next = replayed ?? clonePlanState(EMPTY_PLAN_STATE);\n\t\tif (!replayed && options?.applyFlag && pi.getFlag(PLAN_FLAG_NAME) === true) {\n\t\t\tnext.planning = true;\n\t\t}\n\n\t\tlet interruptedCompact = false;\n\t\tif (next.awaitingCompact) {\n\t\t\tnext.planning = false;\n\t\t\tnext.awaitingCompact = false;\n\t\t\tinterruptedCompact = true;\n\t\t\tpendingCompact = undefined;\n\t\t}\n\n\t\tif (next.planning) {\n\t\t\t// The fresh removal set is authoritative for the eventual restore:\n\t\t\t// the live tool set may differ from what the entry recorded.\n\t\t\tnext.removedTools = applyPlanTools();\n\t\t} else {\n\t\t\tif (pi.getActiveTools().includes(EXIT_PLAN_TOOL_NAME)) {\n\t\t\t\trestoreTools(before.planning ? before.removedTools : next.removedTools);\n\t\t\t}\n\t\t\tnext.removedTools = [];\n\t\t}\n\n\t\treplacePlanState(next);\n\t\tif (interruptedCompact) {\n\t\t\tpersist();\n\t\t\tctx.ui.notify(\"Pending compaction was interrupted; plan mode exited with context kept.\", \"warning\");\n\t\t}\n\t\tupdateStatus(ctx);\n\t}\n\n\tpi.registerTool({\n\t\tname: EXIT_PLAN_TOOL_NAME,\n\t\tlabel: EXIT_PLAN_TOOL_LABEL,\n\t\tdescription: EXIT_PLAN_TOOL_DESCRIPTION,\n\t\tpromptSnippet: EXIT_PLAN_PROMPT_SNIPPET,\n\t\tpromptGuidelines: EXIT_PLAN_PROMPT_GUIDELINES,\n\t\tparameters: ExitPlanParamsSchema,\n\t\texecutionMode: \"sequential\" as ToolExecutionMode,\n\n\t\tasync execute(_toolCallId, params, signal, _onUpdate, ctx): Promise<AgentToolResult<ExitPlanDetails>> {\n\t\t\tsetActivePlanSession(ctx.sessionManager.getSessionId());\n\t\t\tconst state = getPlanState();\n\t\t\tconst title = params.title.trim() || \"plan\";\n\n\t\t\tif (!state.planning || state.awaitingCompact) {\n\t\t\t\treturn {\n\t\t\t\t\tcontent: [\n\t\t\t\t\t\t{ type: \"text\", text: \"Plan mode is not awaiting an exit; continue with your current instructions.\" },\n\t\t\t\t\t],\n\t\t\t\t\tdetails: { decision: \"cancelled\", title },\n\t\t\t\t};\n\t\t\t}\n\n\t\t\tif (!ctx.hasUI) {\n\t\t\t\t// json/print: no way to ask, so honor the least surprising default —\n\t\t\t\t// exit with context kept. Documented as a headless-mode semantic.\n\t\t\t\tconst planPath = saveApprovedPlan(ctx, params, title);\n\t\t\t\texitPlanMode(ctx);\n\t\t\t\treturn {\n\t\t\t\t\tcontent: [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\ttype: \"text\",\n\t\t\t\t\t\t\ttext: `Plan saved to ${planPath}. No interactive UI, so plan mode exited with context kept (authoritative: write tools are restored). Begin executing the plan now.`,\n\t\t\t\t\t\t},\n\t\t\t\t\t],\n\t\t\t\t\tdetails: { decision: \"execute\", title, planPath },\n\t\t\t\t};\n\t\t\t}\n\n\t\t\tlet choice: string | undefined;\n\t\t\tmenuOpen = true;\n\t\t\ttry {\n\t\t\t\tchoice = await ctx.ui.select(`Approve plan \"${title}\"?`, EXIT_PLAN_MENU_OPTIONS, {\n\t\t\t\t\tsignal,\n\t\t\t\t\tsubtitle: formatApprovalSubtitle(ctx),\n\t\t\t\t\tcancelHint: EXIT_PLAN_CANCEL_HINT,\n\t\t\t\t});\n\t\t\t} finally {\n\t\t\t\tmenuOpen = false;\n\t\t\t}\n\n\t\t\tif (signal?.aborted) {\n\t\t\t\t// Throwing lets Pi mark the result as an error, matching the todo convention.\n\t\t\t\tthrow new Error(\"Plan approval was interrupted; plan mode is still active.\");\n\t\t\t}\n\n\t\t\tif (choice === MENU_EXECUTE) {\n\t\t\t\tconst planPath = saveApprovedPlan(ctx, params, title);\n\t\t\t\texitPlanMode(ctx, \"Plan approved: executing with full context.\");\n\t\t\t\treturn {\n\t\t\t\t\tcontent: [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\ttype: \"text\",\n\t\t\t\t\t\t\ttext: `Plan approved and saved to ${planPath}. Plan mode has ended and write tools are restored (authoritative — overrides the plan-mode system prompt block). Begin executing the plan now; track multi-step work with the todo tool.`,\n\t\t\t\t\t\t},\n\t\t\t\t\t],\n\t\t\t\t\tdetails: { decision: \"execute\", title, planPath },\n\t\t\t\t};\n\t\t\t}\n\n\t\t\tif (choice === MENU_COMPACT_EXECUTE) {\n\t\t\t\tconst planPath = saveApprovedPlan(ctx, params, title);\n\t\t\t\t// Stay in plan mode (tools restricted) until compaction completes;\n\t\t\t\t// terminate ends the run so agent_settled can start the compaction.\n\t\t\t\treplacePlanState({ ...getPlanState(), awaitingCompact: true });\n\t\t\t\tpersist();\n\t\t\t\tpendingCompact = { title, plan: params.plan, planPath };\n\t\t\t\treturn {\n\t\t\t\t\tcontent: [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\ttype: \"text\",\n\t\t\t\t\t\t\ttext: `Plan approved and saved to ${planPath}. The context will now be compacted; stop here — execution restarts automatically after compaction.`,\n\t\t\t\t\t\t},\n\t\t\t\t\t],\n\t\t\t\t\tdetails: { decision: \"compactAndExecute\", title, planPath },\n\t\t\t\t\tterminate: true,\n\t\t\t\t};\n\t\t\t}\n\n\t\t\t// Explicit \"Keep planning\", Esc, or timeout: the only side-effect-free\n\t\t\t// choice, so any dismissal maps here.\n\t\t\tlet feedback: string | undefined;\n\t\t\tif (choice === MENU_KEEP_PLANNING) {\n\t\t\t\tfeedback =\n\t\t\t\t\tctx.mode === \"tui\"\n\t\t\t\t\t\t? await ctx.ui.editor(\"Feedback for the plan (optional):\", \"\")\n\t\t\t\t\t\t: await ctx.ui.input(\"Feedback for the plan (optional):\");\n\t\t\t}\n\t\t\tconst trimmedFeedback = feedback?.trim();\n\t\t\treturn {\n\t\t\t\tcontent: [\n\t\t\t\t\t{\n\t\t\t\t\t\ttype: \"text\",\n\t\t\t\t\t\ttext: trimmedFeedback\n\t\t\t\t\t\t\t? `The user wants to keep planning. Feedback:\\n${trimmedFeedback}`\n\t\t\t\t\t\t\t: \"The user wants to keep planning. Ask what should change, or keep refining the open points.\",\n\t\t\t\t\t},\n\t\t\t\t],\n\t\t\t\tdetails: { decision: \"keepPlanning\", title },\n\t\t\t};\n\t\t},\n\n\t\trenderCall(args, theme, context) {\n\t\t\tconst text = (context.lastComponent as Text | undefined) ?? new Text(\"\", 0, 0);\n\t\t\ttext.setText(formatExitPlanCall(args, theme, context.expanded));\n\t\t\treturn text;\n\t\t},\n\n\t\trenderResult(result, _options, theme, context) {\n\t\t\tconst text = (context.lastComponent as Text | undefined) ?? new Text(\"\", 0, 0);\n\t\t\ttext.setText(formatExitPlanResult(result, theme));\n\t\t\treturn text;\n\t\t},\n\t});\n\n\tpi.registerCommand(PLAN_COMMAND_NAME, {\n\t\tdescription: \"Toggle plan mode (read-only planning with user-approved exit)\",\n\t\thandler: async (_args, ctx) => {\n\t\t\tsetActivePlanSession(ctx.sessionManager.getSessionId());\n\t\t\tconst state = getPlanState();\n\t\t\tif (menuOpen) {\n\t\t\t\tctx.ui.notify(\"The exit_plan approval menu is open — answer it first.\", \"warning\");\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (state.awaitingCompact) {\n\t\t\t\tctx.ui.notify(\"Waiting for compaction to finish before leaving plan mode.\", \"warning\");\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (state.planning) {\n\t\t\t\texitPlanMode(ctx, \"Plan mode disabled: full tool access restored.\");\n\t\t\t} else {\n\t\t\t\tenterPlanMode(ctx);\n\t\t\t}\n\t\t},\n\t});\n\n\t// Defense in depth: the active-tools change lands on the next LLM request,\n\t// so a request already in flight when plan mode toggled can still carry\n\t// blocked tool calls.\n\tpi.on(\"tool_call\", async (event, ctx) => {\n\t\tsetActivePlanSession(ctx.sessionManager.getSessionId());\n\t\tconst state = getPlanState();\n\t\tif (!state.planning || !PLAN_BLOCKED_TOOLS.has(event.toolName)) return;\n\t\treturn {\n\t\t\tblock: true,\n\t\t\treason: `Plan mode: ${event.toolName} is disabled. Keep exploring with read-only tools, or call ${EXIT_PLAN_TOOL_NAME} to submit the plan for approval.`,\n\t\t};\n\t});\n\n\tpi.on(\"before_agent_start\", async (event, ctx) => {\n\t\tsetActivePlanSession(ctx.sessionManager.getSessionId());\n\t\tconst state = getPlanState();\n\t\tif (!state.planning) return;\n\t\treturn { systemPrompt: `${event.systemPrompt}\\n\\n${buildPlanModePrompt(state.planFiles)}` };\n\t});\n\n\t// agent_settled fires once per fully settled run (agent_end can repeat on\n\t// retries and queued continuations), so the approved compaction starts here.\n\tpi.on(\"agent_settled\", async (_event, ctx) => {\n\t\tsetActivePlanSession(ctx.sessionManager.getSessionId());\n\t\tif (!getPlanState().awaitingCompact || !pendingCompact) return;\n\t\tconst pending = pendingCompact;\n\t\tpendingCompact = undefined;\n\t\tctx.compact({\n\t\t\tcustomInstructions: `The user approved an implementation plan saved at ${pending.planPath}. Preserve decisions, constraints, and rejected alternatives from the planning discussion that are not already written in the plan file.`,\n\t\t\tonComplete: () => {\n\t\t\t\texitPlanMode(ctx, \"Context compacted; executing the plan.\");\n\t\t\t\tpi.sendUserMessage(buildKickoffMessage(pending));\n\t\t\t},\n\t\t\tonError: (error) => {\n\t\t\t\t// Never leave the user stuck read-only: degrade to keep-context execution.\n\t\t\t\texitPlanMode(ctx, `Compaction failed (${error.message}); executing with full context instead.`);\n\t\t\t\tpi.sendUserMessage(buildKickoffMessage(pending));\n\t\t\t},\n\t\t});\n\t});\n\n\tpi.on(\"session_start\", async (_event, ctx) => {\n\t\tsyncFromBranch(ctx, { applyFlag: true });\n\t});\n\n\tpi.on(\"session_tree\", async (_event, ctx) => {\n\t\tsyncFromBranch(ctx);\n\t});\n\n\tpi.on(\"session_shutdown\", async (_event, ctx) => {\n\t\tpendingCompact = undefined;\n\t\tdisposePlanSession(ctx.sessionManager.getSessionId());\n\t});\n}\n"]}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/extensions/plan/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAIH,OAAO,KAAK,EAEX,YAAY,EAGZ,MAAM,gCAAgC,CAAC;AAkExC;;;;;GAKG;AACH,wBAAgB,6BAA6B,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,GAAG,SAAS,CAYhF;AAED,MAAM,CAAC,OAAO,UAAU,IAAI,CAAC,EAAE,EAAE,YAAY,GAAG,IAAI,CA+YnD","sourcesContent":["/**\n * plan — Claude-Code-style plan mode: read-only exploration and interview,\n * then a user-approved exit that saves the plan and optionally compacts.\n *\n * Shape:\n * - Entry (/plan or --plan) snapshots the active tool set and swaps to the\n * exploration set (read/grep/find/ls/bash + exit_plan; edit/write removed,\n * bash prompt-constrained to read-only, subagent guarded to the explorer\n * profile); a tool_call guard covers requests already in flight before the\n * tool-set change lands.\n * - The plan text travels as an exit_plan parameter, so the model needs no\n * write access to produce the plan file.\n * - \"Compact, then execute\" keeps plan mode active until compaction completes\n * (tools are restored in onComplete) and terminates the run via the tool\n * result, so there is no window where the model holds full context plus\n * write tools. The kickoff message embeds the plan itself — continuation\n * never depends on the compaction summary preserving a path.\n * - State is conversation-backed via custom entries and branch replay (same\n * pattern as todo): /reload, /tree, resume, and fork all restore.\n */\n\nimport { basename } from \"node:path\";\nimport { Text } from \"@earendil-works/pi-tui\";\nimport type {\n\tAgentToolResult,\n\tExtensionAPI,\n\tExtensionContext,\n\tToolExecutionMode,\n} from \"../../core/extensions/types.ts\";\nimport {\n\tbuildPlanModePrompt,\n\tEXIT_PLAN_CANCEL_HINT,\n\tEXIT_PLAN_MENU_OPTIONS,\n\tEXIT_PLAN_PROMPT_GUIDELINES,\n\tEXIT_PLAN_PROMPT_SNIPPET,\n\tEXIT_PLAN_TOOL_DESCRIPTION,\n\tEXIT_PLAN_TOOL_LABEL,\n\tEXIT_PLAN_TOOL_NAME,\n\tMENU_COMPACT_EXECUTE,\n\tMENU_EXECUTE,\n\tMENU_KEEP_PLANNING,\n\tPLAN_ALLOWED_SUBAGENT,\n\tPLAN_BLOCKED_TOOLS,\n\tPLAN_COMMAND_NAME,\n\tPLAN_EMBED_MAX_CHARS,\n\tPLAN_ENTRY_TYPE,\n\tPLAN_EXPLORE_TOOLS,\n\tPLAN_FLAG_NAME,\n\tPLAN_KICKOFF_MESSAGE_TYPE,\n\tPLAN_PANEL_CANCEL_HINT,\n\tPLAN_PANEL_EXIT,\n\tPLAN_PANEL_EXIT_DESCRIPTION,\n\tPLAN_PANEL_MAX_FILES,\n\tPLAN_PANEL_TITLE,\n\tPLAN_STATUS_KEY,\n\tPLAN_SUBAGENT_TOOL_NAME,\n} from \"./constants.ts\";\nimport { type ExitPlanDetails, type ExitPlanParams, ExitPlanParamsSchema, type PlanKickoffDetails } from \"./schema.ts\";\nimport {\n\tclonePlanState,\n\tdisposePlanSession,\n\tEMPTY_PLAN_STATE,\n\tgetPlanState,\n\treplacePlanState,\n\treplayPlanFromBranch,\n\tsetActivePlanSession,\n} from \"./state.ts\";\nimport { listProjectPlanFiles, savePlanFile } from \"./storage.ts\";\nimport {\n\tformatApprovalSubtitle,\n\tformatExitPlanResult,\n\trenderExitPlanCall,\n\trenderPlanKickoffMessage,\n\tshortenPlanPath,\n} from \"./view.ts\";\n\ninterface PendingCompact {\n\ttitle: string;\n\tplan: string;\n\tplanPath: string;\n}\n\nfunction buildKickoffMessage(pending: PendingCompact): string {\n\tconst truncated = pending.plan.length > PLAN_EMBED_MAX_CHARS;\n\tconst body = truncated\n\t\t? `${pending.plan.slice(0, PLAN_EMBED_MAX_CHARS)}\\n\\n[Plan truncated — read the full plan at ${pending.planPath}]`\n\t\t: pending.plan;\n\treturn `Execute the approved plan \"${pending.title}\" (saved at ${pending.planPath}).\n\n${body}\n\nStart now. Track multi-step work with the todo tool, and report back instead of improvising if the plan turns out to need revision.`;\n}\n\n/**\n * First subagent profile in the call that is not allowed in plan mode, or\n * undefined when every profile is the read-only explorer. An omitted or null\n * agent resolves to the default \"general\" profile, so it is disallowed too.\n * Args arrive off the wire, so every field is read defensively.\n */\nexport function findDisallowedSubagentProfile(input: unknown): string | undefined {\n\tconst record = input && typeof input === \"object\" ? (input as Record<string, unknown>) : {};\n\tconst requested: unknown[] = Array.isArray(record.tasks)\n\t\t? record.tasks.map((task) =>\n\t\t\t\ttask && typeof task === \"object\" ? (task as Record<string, unknown>).agent : undefined,\n\t\t\t)\n\t\t: [record.agent];\n\tfor (const profile of requested) {\n\t\tconst name = typeof profile === \"string\" && profile.trim() ? profile.trim() : \"general\";\n\t\tif (name !== PLAN_ALLOWED_SUBAGENT) return name;\n\t}\n\treturn undefined;\n}\n\nexport default function plan(pi: ExtensionAPI): void {\n\tlet menuOpen = false;\n\tlet pendingCompact: PendingCompact | undefined;\n\n\tpi.registerMessageRenderer<PlanKickoffDetails>(PLAN_KICKOFF_MESSAGE_TYPE, renderPlanKickoffMessage);\n\n\t/**\n\t * Kickoff as a custom message, not a user message: the LLM still receives\n\t * the full kickoff text (custom messages convert to user-role context),\n\t * while the TUI renders the registered collapsed card instead of echoing\n\t * the whole plan again.\n\t */\n\tfunction sendKickoff(pending: PendingCompact): void {\n\t\tpi.sendMessage(\n\t\t\t{\n\t\t\t\tcustomType: PLAN_KICKOFF_MESSAGE_TYPE,\n\t\t\t\tcontent: buildKickoffMessage(pending),\n\t\t\t\tdisplay: true,\n\t\t\t\tdetails: { title: pending.title, planPath: pending.planPath },\n\t\t\t},\n\t\t\t{ triggerTurn: true },\n\t\t);\n\t}\n\n\tpi.registerFlag(PLAN_FLAG_NAME, {\n\t\tdescription: \"Start in plan mode (read-only planning)\",\n\t\ttype: \"boolean\",\n\t\tdefault: false,\n\t});\n\n\t/**\n\t * Swap to the exploration tool set and return the pre-entry snapshot.\n\t * Snapshot + delta rather than a fixed allowlist: edit/write are dropped,\n\t * the explore tools (grep/find/ls are registered but not active by\n\t * default) and exit_plan are added, and every other active tool — question,\n\t * todo, deepwiki, subagent, ... — stays active.\n\t */\n\tfunction applyPlanTools(): string[] {\n\t\tconst snapshot = pi.getActiveTools();\n\t\tpi.setActiveTools([\n\t\t\t...new Set([\n\t\t\t\t...snapshot.filter((name) => !PLAN_BLOCKED_TOOLS.has(name)),\n\t\t\t\t...PLAN_EXPLORE_TOOLS,\n\t\t\t\tEXIT_PLAN_TOOL_NAME,\n\t\t\t]),\n\t\t]);\n\t\treturn snapshot;\n\t}\n\n\t/**\n\t * Inverse of applyPlanTools: restore the snapshot verbatim. An empty\n\t * snapshot (legacy entries) only drops exit_plan — there is nothing\n\t * recorded to restore to.\n\t */\n\tfunction restoreTools(toolSnapshot: string[]): void {\n\t\tif (toolSnapshot.length > 0) {\n\t\t\tpi.setActiveTools([...new Set(toolSnapshot)].filter((name) => name !== EXIT_PLAN_TOOL_NAME));\n\t\t\treturn;\n\t\t}\n\t\tpi.setActiveTools(pi.getActiveTools().filter((name) => name !== EXIT_PLAN_TOOL_NAME));\n\t}\n\n\tfunction persist(): void {\n\t\tpi.appendEntry(PLAN_ENTRY_TYPE, getPlanState());\n\t}\n\n\tfunction updateStatus(ctx: ExtensionContext): void {\n\t\tconst state = getPlanState();\n\t\tctx.ui.setStatus(PLAN_STATUS_KEY, state.planning ? ctx.ui.theme.fg(\"accent\", \"Plan Mode\") : undefined);\n\t}\n\n\tfunction enterPlanMode(ctx: ExtensionContext): void {\n\t\tconst state = getPlanState();\n\t\tif (state.planning) return;\n\t\tconst toolSnapshot = applyPlanTools();\n\t\treplacePlanState({ planning: true, toolSnapshot, awaitingCompact: false, planFiles: state.planFiles });\n\t\tpersist();\n\t\tupdateStatus(ctx);\n\t\tctx.ui.notify(\n\t\t\t\"Plan mode: read-only exploration — edit/write disabled, bash constrained to read-only, subagent limited to explorer.\",\n\t\t\t\"info\",\n\t\t);\n\t}\n\n\tfunction exitPlanMode(ctx: ExtensionContext, notifyText?: string): void {\n\t\tconst state = getPlanState();\n\t\trestoreTools(state.toolSnapshot);\n\t\treplacePlanState({ ...state, planning: false, awaitingCompact: false, toolSnapshot: [] });\n\t\tpersist();\n\t\tupdateStatus(ctx);\n\t\tif (notifyText) ctx.ui.notify(notifyText, \"info\");\n\t}\n\n\t/** Save the approved plan, index it in state, and name the session if unnamed. */\n\tfunction saveApprovedPlan(ctx: ExtensionContext, params: ExitPlanParams, title: string): string {\n\t\tconst planPath = savePlanFile({\n\t\t\tsessionId: ctx.sessionManager.getSessionId(),\n\t\t\ttitle,\n\t\t\tplan: params.plan,\n\t\t\tcwd: ctx.cwd,\n\t\t\trevises: params.revises,\n\t\t});\n\t\tconst state = getPlanState();\n\t\treplacePlanState({ ...state, planFiles: [...state.planFiles, planPath] });\n\t\tif (!pi.getSessionName()) pi.setSessionName(title);\n\t\treturn planPath;\n\t}\n\n\t/**\n\t * Rebuild state from the current branch and reconcile the live tool set.\n\t * An interrupted compact-and-execute approval (restart, resume, branch\n\t * switch) degrades to the keep-context exit so the approval is honored.\n\t */\n\tfunction syncFromBranch(ctx: ExtensionContext, options?: { applyFlag?: boolean }): void {\n\t\tsetActivePlanSession(ctx.sessionManager.getSessionId());\n\t\tconst before = getPlanState();\n\t\tconst replayed = replayPlanFromBranch(ctx);\n\t\tconst next = replayed ?? clonePlanState(EMPTY_PLAN_STATE);\n\t\tif (!replayed && options?.applyFlag && pi.getFlag(PLAN_FLAG_NAME) === true) {\n\t\t\tnext.planning = true;\n\t\t}\n\n\t\tlet interruptedCompact = false;\n\t\tif (next.awaitingCompact) {\n\t\t\tnext.planning = false;\n\t\t\tnext.awaitingCompact = false;\n\t\t\tinterruptedCompact = true;\n\t\t\tpendingCompact = undefined;\n\t\t}\n\n\t\tif (next.planning) {\n\t\t\t// Re-applying is idempotent, but the returned live snapshot is only\n\t\t\t// authoritative when the live set was NOT already restricted: on a\n\t\t\t// branch switch while planning, re-snapshotting would capture the\n\t\t\t// plan-mode set, so the in-memory snapshot wins there.\n\t\t\tconst liveSnapshot = applyPlanTools();\n\t\t\tnext.toolSnapshot =\n\t\t\t\tbefore.planning && before.toolSnapshot.length > 0 ? [...before.toolSnapshot] : liveSnapshot;\n\t\t} else {\n\t\t\tif (pi.getActiveTools().includes(EXIT_PLAN_TOOL_NAME)) {\n\t\t\t\trestoreTools(before.planning ? before.toolSnapshot : next.toolSnapshot);\n\t\t\t}\n\t\t\tnext.toolSnapshot = [];\n\t\t}\n\n\t\treplacePlanState(next);\n\t\tif (interruptedCompact) {\n\t\t\tpersist();\n\t\t\tctx.ui.notify(\"Pending compaction was interrupted; plan mode exited with context kept.\", \"warning\");\n\t\t}\n\t\tupdateStatus(ctx);\n\t}\n\n\tpi.registerTool<typeof ExitPlanParamsSchema, ExitPlanDetails>({\n\t\tname: EXIT_PLAN_TOOL_NAME,\n\t\tlabel: EXIT_PLAN_TOOL_LABEL,\n\t\tdescription: EXIT_PLAN_TOOL_DESCRIPTION,\n\t\tpromptSnippet: EXIT_PLAN_PROMPT_SNIPPET,\n\t\tpromptGuidelines: EXIT_PLAN_PROMPT_GUIDELINES,\n\t\tparameters: ExitPlanParamsSchema,\n\t\texecutionMode: \"sequential\" as ToolExecutionMode,\n\t\trendersOwnProgress: true,\n\n\t\tasync execute(_toolCallId, params, signal, _onUpdate, ctx): Promise<AgentToolResult<ExitPlanDetails>> {\n\t\t\tsetActivePlanSession(ctx.sessionManager.getSessionId());\n\t\t\tconst state = getPlanState();\n\t\t\tconst title = params.title.trim() || \"plan\";\n\n\t\t\tif (!state.planning || state.awaitingCompact) {\n\t\t\t\treturn {\n\t\t\t\t\tcontent: [\n\t\t\t\t\t\t{ type: \"text\", text: \"Plan mode is not awaiting an exit; continue with your current instructions.\" },\n\t\t\t\t\t],\n\t\t\t\t\tdetails: { decision: \"cancelled\", title },\n\t\t\t\t};\n\t\t\t}\n\n\t\t\tif (!ctx.hasUI) {\n\t\t\t\t// json/print: no way to ask, so honor the least surprising default —\n\t\t\t\t// exit with context kept. Documented as a headless-mode semantic.\n\t\t\t\tconst planPath = saveApprovedPlan(ctx, params, title);\n\t\t\t\texitPlanMode(ctx);\n\t\t\t\treturn {\n\t\t\t\t\tcontent: [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\ttype: \"text\",\n\t\t\t\t\t\t\ttext: `Plan saved to ${planPath}. No interactive UI, so plan mode exited with context kept (authoritative: write tools are restored). Begin executing the plan now.`,\n\t\t\t\t\t\t},\n\t\t\t\t\t],\n\t\t\t\t\tdetails: { decision: \"execute\", title, planPath },\n\t\t\t\t};\n\t\t\t}\n\n\t\t\tlet choice: string | undefined;\n\t\t\tmenuOpen = true;\n\t\t\ttry {\n\t\t\t\tchoice = await ctx.ui.select(`Approve plan \"${title}\"?`, EXIT_PLAN_MENU_OPTIONS, {\n\t\t\t\t\tsignal,\n\t\t\t\t\tsubtitle: formatApprovalSubtitle(ctx),\n\t\t\t\t\tcancelHint: EXIT_PLAN_CANCEL_HINT,\n\t\t\t\t});\n\t\t\t} finally {\n\t\t\t\tmenuOpen = false;\n\t\t\t}\n\n\t\t\tif (signal?.aborted) {\n\t\t\t\t// Throwing lets Pi mark the result as an error, matching the todo convention.\n\t\t\t\tthrow new Error(\"Plan approval was interrupted; plan mode is still active.\");\n\t\t\t}\n\n\t\t\tif (choice === MENU_EXECUTE) {\n\t\t\t\tconst planPath = saveApprovedPlan(ctx, params, title);\n\t\t\t\texitPlanMode(ctx, \"Plan approved: executing with full context.\");\n\t\t\t\treturn {\n\t\t\t\t\tcontent: [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\ttype: \"text\",\n\t\t\t\t\t\t\ttext: `Plan approved and saved to ${planPath}. Plan mode has ended and write tools are restored (authoritative — overrides the plan-mode system prompt block). Begin executing the plan now; track multi-step work with the todo tool.`,\n\t\t\t\t\t\t},\n\t\t\t\t\t],\n\t\t\t\t\tdetails: { decision: \"execute\", title, planPath },\n\t\t\t\t};\n\t\t\t}\n\n\t\t\tif (choice === MENU_COMPACT_EXECUTE) {\n\t\t\t\tconst planPath = saveApprovedPlan(ctx, params, title);\n\t\t\t\t// Stay in plan mode (tools restricted) until compaction completes;\n\t\t\t\t// terminate ends the run so agent_settled can start the compaction.\n\t\t\t\treplacePlanState({ ...getPlanState(), awaitingCompact: true });\n\t\t\t\tpersist();\n\t\t\t\tpendingCompact = { title, plan: params.plan, planPath };\n\t\t\t\treturn {\n\t\t\t\t\tcontent: [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\ttype: \"text\",\n\t\t\t\t\t\t\ttext: `Plan approved and saved to ${planPath}. The context will now be compacted; stop here — execution restarts automatically after compaction.`,\n\t\t\t\t\t\t},\n\t\t\t\t\t],\n\t\t\t\t\tdetails: { decision: \"compactAndExecute\", title, planPath },\n\t\t\t\t\tterminate: true,\n\t\t\t\t};\n\t\t\t}\n\n\t\t\t// Explicit \"Keep planning\", Esc, or timeout: the only side-effect-free\n\t\t\t// choice, so any dismissal maps here.\n\t\t\tlet feedback: string | undefined;\n\t\t\tif (choice === MENU_KEEP_PLANNING) {\n\t\t\t\tfeedback =\n\t\t\t\t\tctx.mode === \"tui\"\n\t\t\t\t\t\t? await ctx.ui.editor(\"Feedback for the plan (optional):\", \"\")\n\t\t\t\t\t\t: await ctx.ui.input(\"Feedback for the plan (optional):\");\n\t\t\t}\n\t\t\tconst trimmedFeedback = feedback?.trim();\n\t\t\treturn {\n\t\t\t\tcontent: [\n\t\t\t\t\t{\n\t\t\t\t\t\ttype: \"text\",\n\t\t\t\t\t\ttext: trimmedFeedback\n\t\t\t\t\t\t\t? `The user wants to keep planning. Feedback:\\n${trimmedFeedback}`\n\t\t\t\t\t\t\t: \"The user wants to keep planning. Ask what should change, or keep refining the open points.\",\n\t\t\t\t\t},\n\t\t\t\t],\n\t\t\t\tdetails: { decision: \"keepPlanning\", title },\n\t\t\t};\n\t\t},\n\n\t\trenderCall(args, theme, context) {\n\t\t\treturn renderExitPlanCall(args, theme, context.expanded);\n\t\t},\n\n\t\trenderResult(result, _options, theme) {\n\t\t\treturn new Text(formatExitPlanResult(result, theme), 0, 0);\n\t\t},\n\t});\n\n\t/**\n\t * Plans offered in the /plan panel: this branch's plans first (newest\n\t * first), then the rest of the project's, deduped, bounded. Legacy plans\n\t * from other sessions' directories only appear via planFiles entries.\n\t */\n\tfunction collectPanelPlanFiles(ctx: ExtensionContext): string[] {\n\t\tconst state = getPlanState();\n\t\tconst seen = new Set<string>();\n\t\tconst files: string[] = [];\n\t\tfor (const path of [...[...state.planFiles].reverse(), ...listProjectPlanFiles(ctx.cwd)]) {\n\t\t\tconst key = path.replace(/\\\\/g, \"/\").toLowerCase();\n\t\t\tif (seen.has(key)) continue;\n\t\t\tseen.add(key);\n\t\t\tfiles.push(path);\n\t\t\tif (files.length >= PLAN_PANEL_MAX_FILES) break;\n\t\t}\n\t\treturn files;\n\t}\n\n\t/** Panel behind /plan while planning: pick a plan file or exit the mode. */\n\tasync function openPlanPanel(ctx: ExtensionContext): Promise<void> {\n\t\tconst files = collectPanelPlanFiles(ctx);\n\t\tconst fileOptions = files.map((path) => ({ label: basename(path), description: shortenPlanPath(path) }));\n\t\tconst options = [{ label: PLAN_PANEL_EXIT, description: PLAN_PANEL_EXIT_DESCRIPTION }, ...fileOptions];\n\t\tconst choice = await ctx.ui.select(PLAN_PANEL_TITLE, options, { cancelHint: PLAN_PANEL_CANCEL_HINT });\n\t\tif (choice === PLAN_PANEL_EXIT) {\n\t\t\texitPlanMode(ctx, \"Plan mode disabled: full tool access restored.\");\n\t\t\treturn;\n\t\t}\n\t\tif (!choice) return; // Esc: keep planning, no side effects.\n\t\tconst selected = files.find((path) => basename(path) === choice);\n\t\tif (selected) ctx.ui.pasteToEditor(selected);\n\t}\n\n\tpi.registerCommand(PLAN_COMMAND_NAME, {\n\t\tdescription: \"Enter plan mode; while planning, open the plan panel (pick a plan file or exit)\",\n\t\thandler: async (_args, ctx) => {\n\t\t\tsetActivePlanSession(ctx.sessionManager.getSessionId());\n\t\t\tconst state = getPlanState();\n\t\t\tif (menuOpen) {\n\t\t\t\tctx.ui.notify(\"The exit_plan approval menu is open — answer it first.\", \"warning\");\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (state.awaitingCompact) {\n\t\t\t\tctx.ui.notify(\"Waiting for compaction to finish before leaving plan mode.\", \"warning\");\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (!state.planning) {\n\t\t\t\tenterPlanMode(ctx);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (ctx.mode !== \"tui\") {\n\t\t\t\t// No panel affordances outside the TUI: keep the toggle semantic.\n\t\t\t\texitPlanMode(ctx, \"Plan mode disabled: full tool access restored.\");\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tawait openPlanPanel(ctx);\n\t\t},\n\t});\n\n\t// Defense in depth: the active-tools change lands on the next LLM request,\n\t// so a request already in flight when plan mode toggled can still carry\n\t// blocked tool calls.\n\tpi.on(\"tool_call\", async (event, ctx) => {\n\t\tsetActivePlanSession(ctx.sessionManager.getSessionId());\n\t\tconst state = getPlanState();\n\t\tif (!state.planning) return;\n\t\tif (PLAN_BLOCKED_TOOLS.has(event.toolName)) {\n\t\t\treturn {\n\t\t\t\tblock: true,\n\t\t\t\treason: `Plan mode: ${event.toolName} is disabled. Keep exploring with read-only tools, or call ${EXIT_PLAN_TOOL_NAME} to submit the plan for approval.`,\n\t\t\t};\n\t\t}\n\t\tif (event.toolName === PLAN_SUBAGENT_TOOL_NAME) {\n\t\t\tconst disallowed = findDisallowedSubagentProfile(event.input);\n\t\t\tif (disallowed !== undefined) {\n\t\t\t\treturn {\n\t\t\t\t\tblock: true,\n\t\t\t\t\treason: `Plan mode: subagent profile \"${disallowed}\" is blocked while planning. Delegate read-only exploration with agent: \"${PLAN_ALLOWED_SUBAGENT}\" (set it on every task in tasks mode).`,\n\t\t\t\t};\n\t\t\t}\n\t\t}\n\t});\n\n\tpi.on(\"before_agent_start\", async (event, ctx) => {\n\t\tsetActivePlanSession(ctx.sessionManager.getSessionId());\n\t\tconst state = getPlanState();\n\t\tif (!state.planning) return;\n\t\treturn { systemPrompt: `${event.systemPrompt}\\n\\n${buildPlanModePrompt(state.planFiles)}` };\n\t});\n\n\t// agent_settled fires once per fully settled run (agent_end can repeat on\n\t// retries and queued continuations), so the approved compaction starts here.\n\tpi.on(\"agent_settled\", async (_event, ctx) => {\n\t\tsetActivePlanSession(ctx.sessionManager.getSessionId());\n\t\tif (!getPlanState().awaitingCompact || !pendingCompact) return;\n\t\tconst pending = pendingCompact;\n\t\tpendingCompact = undefined;\n\t\tctx.compact({\n\t\t\tcustomInstructions: `The user approved an implementation plan saved at ${pending.planPath}. Preserve decisions, constraints, and rejected alternatives from the planning discussion that are not already written in the plan file.`,\n\t\t\tonComplete: () => {\n\t\t\t\texitPlanMode(ctx, \"Context compacted; executing the plan.\");\n\t\t\t\tsendKickoff(pending);\n\t\t\t},\n\t\t\tonError: (error) => {\n\t\t\t\t// Never leave the user stuck read-only: degrade to keep-context execution.\n\t\t\t\texitPlanMode(ctx, `Compaction failed (${error.message}); executing with full context instead.`);\n\t\t\t\tsendKickoff(pending);\n\t\t\t},\n\t\t});\n\t});\n\n\tpi.on(\"session_start\", async (_event, ctx) => {\n\t\tsyncFromBranch(ctx, { applyFlag: true });\n\t});\n\n\tpi.on(\"session_tree\", async (_event, ctx) => {\n\t\tsyncFromBranch(ctx);\n\t});\n\n\tpi.on(\"session_shutdown\", async (_event, ctx) => {\n\t\tpendingCompact = undefined;\n\t\tdisposePlanSession(ctx.sessionManager.getSessionId());\n\t});\n}\n"]}
@@ -3,9 +3,11 @@
3
3
  * then a user-approved exit that saves the plan and optionally compacts.
4
4
  *
5
5
  * Shape:
6
- * - Entry (/plan or --plan) removes write-capable tools (edit/write/bash/
7
- * subagent) and activates exit_plan; a tool_call guard covers requests
8
- * already in flight before the tool-set change lands.
6
+ * - Entry (/plan or --plan) snapshots the active tool set and swaps to the
7
+ * exploration set (read/grep/find/ls/bash + exit_plan; edit/write removed,
8
+ * bash prompt-constrained to read-only, subagent guarded to the explorer
9
+ * profile); a tool_call guard covers requests already in flight before the
10
+ * tool-set change lands.
9
11
  * - The plan text travels as an exit_plan parameter, so the model needs no
10
12
  * write access to produce the plan file.
11
13
  * - "Compact, then execute" keeps plan mode active until compaction completes
@@ -16,12 +18,13 @@
16
18
  * - State is conversation-backed via custom entries and branch replay (same
17
19
  * pattern as todo): /reload, /tree, resume, and fork all restore.
18
20
  */
21
+ import { basename } from "node:path";
19
22
  import { Text } from "@earendil-works/pi-tui";
20
- import { buildPlanModePrompt, EXIT_PLAN_CANCEL_HINT, EXIT_PLAN_MENU_OPTIONS, EXIT_PLAN_PROMPT_GUIDELINES, EXIT_PLAN_PROMPT_SNIPPET, EXIT_PLAN_TOOL_DESCRIPTION, EXIT_PLAN_TOOL_LABEL, EXIT_PLAN_TOOL_NAME, MENU_COMPACT_EXECUTE, MENU_EXECUTE, MENU_KEEP_PLANNING, PLAN_BLOCKED_TOOLS, PLAN_COMMAND_NAME, PLAN_EMBED_MAX_CHARS, PLAN_ENTRY_TYPE, PLAN_FLAG_NAME, PLAN_STATUS_KEY, } from "./constants.js";
23
+ import { buildPlanModePrompt, EXIT_PLAN_CANCEL_HINT, EXIT_PLAN_MENU_OPTIONS, EXIT_PLAN_PROMPT_GUIDELINES, EXIT_PLAN_PROMPT_SNIPPET, EXIT_PLAN_TOOL_DESCRIPTION, EXIT_PLAN_TOOL_LABEL, EXIT_PLAN_TOOL_NAME, MENU_COMPACT_EXECUTE, MENU_EXECUTE, MENU_KEEP_PLANNING, PLAN_ALLOWED_SUBAGENT, PLAN_BLOCKED_TOOLS, PLAN_COMMAND_NAME, PLAN_EMBED_MAX_CHARS, PLAN_ENTRY_TYPE, PLAN_EXPLORE_TOOLS, PLAN_FLAG_NAME, PLAN_KICKOFF_MESSAGE_TYPE, PLAN_PANEL_CANCEL_HINT, PLAN_PANEL_EXIT, PLAN_PANEL_EXIT_DESCRIPTION, PLAN_PANEL_MAX_FILES, PLAN_PANEL_TITLE, PLAN_STATUS_KEY, PLAN_SUBAGENT_TOOL_NAME, } from "./constants.js";
21
24
  import { ExitPlanParamsSchema } from "./schema.js";
22
25
  import { clonePlanState, disposePlanSession, EMPTY_PLAN_STATE, getPlanState, replacePlanState, replayPlanFromBranch, setActivePlanSession, } from "./state.js";
23
- import { savePlanFile } from "./storage.js";
24
- import { formatApprovalSubtitle, formatExitPlanCall, formatExitPlanResult } from "./view.js";
26
+ import { listProjectPlanFiles, savePlanFile } from "./storage.js";
27
+ import { formatApprovalSubtitle, formatExitPlanResult, renderExitPlanCall, renderPlanKickoffMessage, shortenPlanPath, } from "./view.js";
25
28
  function buildKickoffMessage(pending) {
26
29
  const truncated = pending.plan.length > PLAN_EMBED_MAX_CHARS;
27
30
  const body = truncated
@@ -33,25 +36,76 @@ ${body}
33
36
 
34
37
  Start now. Track multi-step work with the todo tool, and report back instead of improvising if the plan turns out to need revision.`;
35
38
  }
39
+ /**
40
+ * First subagent profile in the call that is not allowed in plan mode, or
41
+ * undefined when every profile is the read-only explorer. An omitted or null
42
+ * agent resolves to the default "general" profile, so it is disallowed too.
43
+ * Args arrive off the wire, so every field is read defensively.
44
+ */
45
+ export function findDisallowedSubagentProfile(input) {
46
+ const record = input && typeof input === "object" ? input : {};
47
+ const requested = Array.isArray(record.tasks)
48
+ ? record.tasks.map((task) => task && typeof task === "object" ? task.agent : undefined)
49
+ : [record.agent];
50
+ for (const profile of requested) {
51
+ const name = typeof profile === "string" && profile.trim() ? profile.trim() : "general";
52
+ if (name !== PLAN_ALLOWED_SUBAGENT)
53
+ return name;
54
+ }
55
+ return undefined;
56
+ }
36
57
  export default function plan(pi) {
37
58
  let menuOpen = false;
38
59
  let pendingCompact;
60
+ pi.registerMessageRenderer(PLAN_KICKOFF_MESSAGE_TYPE, renderPlanKickoffMessage);
61
+ /**
62
+ * Kickoff as a custom message, not a user message: the LLM still receives
63
+ * the full kickoff text (custom messages convert to user-role context),
64
+ * while the TUI renders the registered collapsed card instead of echoing
65
+ * the whole plan again.
66
+ */
67
+ function sendKickoff(pending) {
68
+ pi.sendMessage({
69
+ customType: PLAN_KICKOFF_MESSAGE_TYPE,
70
+ content: buildKickoffMessage(pending),
71
+ display: true,
72
+ details: { title: pending.title, planPath: pending.planPath },
73
+ }, { triggerTurn: true });
74
+ }
39
75
  pi.registerFlag(PLAN_FLAG_NAME, {
40
76
  description: "Start in plan mode (read-only planning)",
41
77
  type: "boolean",
42
78
  default: false,
43
79
  });
44
- /** Remove blocked tools, activate exit_plan, and return what was actually removed. */
80
+ /**
81
+ * Swap to the exploration tool set and return the pre-entry snapshot.
82
+ * Snapshot + delta rather than a fixed allowlist: edit/write are dropped,
83
+ * the explore tools (grep/find/ls are registered but not active by
84
+ * default) and exit_plan are added, and every other active tool — question,
85
+ * todo, deepwiki, subagent, ... — stays active.
86
+ */
45
87
  function applyPlanTools() {
46
- const active = pi.getActiveTools();
47
- const removed = active.filter((name) => PLAN_BLOCKED_TOOLS.has(name));
48
- pi.setActiveTools([...new Set([...active.filter((name) => !PLAN_BLOCKED_TOOLS.has(name)), EXIT_PLAN_TOOL_NAME])]);
49
- return removed;
88
+ const snapshot = pi.getActiveTools();
89
+ pi.setActiveTools([
90
+ ...new Set([
91
+ ...snapshot.filter((name) => !PLAN_BLOCKED_TOOLS.has(name)),
92
+ ...PLAN_EXPLORE_TOOLS,
93
+ EXIT_PLAN_TOOL_NAME,
94
+ ]),
95
+ ]);
96
+ return snapshot;
50
97
  }
51
- /** Inverse of applyPlanTools: drop exit_plan, re-add exactly what was removed. */
52
- function restoreTools(removedTools) {
53
- const active = pi.getActiveTools().filter((name) => name !== EXIT_PLAN_TOOL_NAME);
54
- pi.setActiveTools([...new Set([...active, ...removedTools])]);
98
+ /**
99
+ * Inverse of applyPlanTools: restore the snapshot verbatim. An empty
100
+ * snapshot (legacy entries) only drops exit_plan — there is nothing
101
+ * recorded to restore to.
102
+ */
103
+ function restoreTools(toolSnapshot) {
104
+ if (toolSnapshot.length > 0) {
105
+ pi.setActiveTools([...new Set(toolSnapshot)].filter((name) => name !== EXIT_PLAN_TOOL_NAME));
106
+ return;
107
+ }
108
+ pi.setActiveTools(pi.getActiveTools().filter((name) => name !== EXIT_PLAN_TOOL_NAME));
55
109
  }
56
110
  function persist() {
57
111
  pi.appendEntry(PLAN_ENTRY_TYPE, getPlanState());
@@ -64,16 +118,16 @@ export default function plan(pi) {
64
118
  const state = getPlanState();
65
119
  if (state.planning)
66
120
  return;
67
- const removedTools = applyPlanTools();
68
- replacePlanState({ planning: true, removedTools, awaitingCompact: false, planFiles: state.planFiles });
121
+ const toolSnapshot = applyPlanTools();
122
+ replacePlanState({ planning: true, toolSnapshot, awaitingCompact: false, planFiles: state.planFiles });
69
123
  persist();
70
124
  updateStatus(ctx);
71
- ctx.ui.notify("Plan mode enabled: edit, write, bash, and subagent are disabled.", "info");
125
+ ctx.ui.notify("Plan mode: read-only exploration — edit/write disabled, bash constrained to read-only, subagent limited to explorer.", "info");
72
126
  }
73
127
  function exitPlanMode(ctx, notifyText) {
74
128
  const state = getPlanState();
75
- restoreTools(state.removedTools);
76
- replacePlanState({ ...state, planning: false, awaitingCompact: false, removedTools: [] });
129
+ restoreTools(state.toolSnapshot);
130
+ replacePlanState({ ...state, planning: false, awaitingCompact: false, toolSnapshot: [] });
77
131
  persist();
78
132
  updateStatus(ctx);
79
133
  if (notifyText)
@@ -115,15 +169,19 @@ export default function plan(pi) {
115
169
  pendingCompact = undefined;
116
170
  }
117
171
  if (next.planning) {
118
- // The fresh removal set is authoritative for the eventual restore:
119
- // the live tool set may differ from what the entry recorded.
120
- next.removedTools = applyPlanTools();
172
+ // Re-applying is idempotent, but the returned live snapshot is only
173
+ // authoritative when the live set was NOT already restricted: on a
174
+ // branch switch while planning, re-snapshotting would capture the
175
+ // plan-mode set, so the in-memory snapshot wins there.
176
+ const liveSnapshot = applyPlanTools();
177
+ next.toolSnapshot =
178
+ before.planning && before.toolSnapshot.length > 0 ? [...before.toolSnapshot] : liveSnapshot;
121
179
  }
122
180
  else {
123
181
  if (pi.getActiveTools().includes(EXIT_PLAN_TOOL_NAME)) {
124
- restoreTools(before.planning ? before.removedTools : next.removedTools);
182
+ restoreTools(before.planning ? before.toolSnapshot : next.toolSnapshot);
125
183
  }
126
- next.removedTools = [];
184
+ next.toolSnapshot = [];
127
185
  }
128
186
  replacePlanState(next);
129
187
  if (interruptedCompact) {
@@ -140,6 +198,7 @@ export default function plan(pi) {
140
198
  promptGuidelines: EXIT_PLAN_PROMPT_GUIDELINES,
141
199
  parameters: ExitPlanParamsSchema,
142
200
  executionMode: "sequential",
201
+ rendersOwnProgress: true,
143
202
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
144
203
  setActivePlanSession(ctx.sessionManager.getSessionId());
145
204
  const state = getPlanState();
@@ -237,18 +296,50 @@ export default function plan(pi) {
237
296
  };
238
297
  },
239
298
  renderCall(args, theme, context) {
240
- const text = context.lastComponent ?? new Text("", 0, 0);
241
- text.setText(formatExitPlanCall(args, theme, context.expanded));
242
- return text;
299
+ return renderExitPlanCall(args, theme, context.expanded);
243
300
  },
244
- renderResult(result, _options, theme, context) {
245
- const text = context.lastComponent ?? new Text("", 0, 0);
246
- text.setText(formatExitPlanResult(result, theme));
247
- return text;
301
+ renderResult(result, _options, theme) {
302
+ return new Text(formatExitPlanResult(result, theme), 0, 0);
248
303
  },
249
304
  });
305
+ /**
306
+ * Plans offered in the /plan panel: this branch's plans first (newest
307
+ * first), then the rest of the project's, deduped, bounded. Legacy plans
308
+ * from other sessions' directories only appear via planFiles entries.
309
+ */
310
+ function collectPanelPlanFiles(ctx) {
311
+ const state = getPlanState();
312
+ const seen = new Set();
313
+ const files = [];
314
+ for (const path of [...[...state.planFiles].reverse(), ...listProjectPlanFiles(ctx.cwd)]) {
315
+ const key = path.replace(/\\/g, "/").toLowerCase();
316
+ if (seen.has(key))
317
+ continue;
318
+ seen.add(key);
319
+ files.push(path);
320
+ if (files.length >= PLAN_PANEL_MAX_FILES)
321
+ break;
322
+ }
323
+ return files;
324
+ }
325
+ /** Panel behind /plan while planning: pick a plan file or exit the mode. */
326
+ async function openPlanPanel(ctx) {
327
+ const files = collectPanelPlanFiles(ctx);
328
+ const fileOptions = files.map((path) => ({ label: basename(path), description: shortenPlanPath(path) }));
329
+ const options = [{ label: PLAN_PANEL_EXIT, description: PLAN_PANEL_EXIT_DESCRIPTION }, ...fileOptions];
330
+ const choice = await ctx.ui.select(PLAN_PANEL_TITLE, options, { cancelHint: PLAN_PANEL_CANCEL_HINT });
331
+ if (choice === PLAN_PANEL_EXIT) {
332
+ exitPlanMode(ctx, "Plan mode disabled: full tool access restored.");
333
+ return;
334
+ }
335
+ if (!choice)
336
+ return; // Esc: keep planning, no side effects.
337
+ const selected = files.find((path) => basename(path) === choice);
338
+ if (selected)
339
+ ctx.ui.pasteToEditor(selected);
340
+ }
250
341
  pi.registerCommand(PLAN_COMMAND_NAME, {
251
- description: "Toggle plan mode (read-only planning with user-approved exit)",
342
+ description: "Enter plan mode; while planning, open the plan panel (pick a plan file or exit)",
252
343
  handler: async (_args, ctx) => {
253
344
  setActivePlanSession(ctx.sessionManager.getSessionId());
254
345
  const state = getPlanState();
@@ -260,12 +351,16 @@ export default function plan(pi) {
260
351
  ctx.ui.notify("Waiting for compaction to finish before leaving plan mode.", "warning");
261
352
  return;
262
353
  }
263
- if (state.planning) {
264
- exitPlanMode(ctx, "Plan mode disabled: full tool access restored.");
265
- }
266
- else {
354
+ if (!state.planning) {
267
355
  enterPlanMode(ctx);
356
+ return;
357
+ }
358
+ if (ctx.mode !== "tui") {
359
+ // No panel affordances outside the TUI: keep the toggle semantic.
360
+ exitPlanMode(ctx, "Plan mode disabled: full tool access restored.");
361
+ return;
268
362
  }
363
+ await openPlanPanel(ctx);
269
364
  },
270
365
  });
271
366
  // Defense in depth: the active-tools change lands on the next LLM request,
@@ -274,12 +369,23 @@ export default function plan(pi) {
274
369
  pi.on("tool_call", async (event, ctx) => {
275
370
  setActivePlanSession(ctx.sessionManager.getSessionId());
276
371
  const state = getPlanState();
277
- if (!state.planning || !PLAN_BLOCKED_TOOLS.has(event.toolName))
372
+ if (!state.planning)
278
373
  return;
279
- return {
280
- block: true,
281
- reason: `Plan mode: ${event.toolName} is disabled. Keep exploring with read-only tools, or call ${EXIT_PLAN_TOOL_NAME} to submit the plan for approval.`,
282
- };
374
+ if (PLAN_BLOCKED_TOOLS.has(event.toolName)) {
375
+ return {
376
+ block: true,
377
+ reason: `Plan mode: ${event.toolName} is disabled. Keep exploring with read-only tools, or call ${EXIT_PLAN_TOOL_NAME} to submit the plan for approval.`,
378
+ };
379
+ }
380
+ if (event.toolName === PLAN_SUBAGENT_TOOL_NAME) {
381
+ const disallowed = findDisallowedSubagentProfile(event.input);
382
+ if (disallowed !== undefined) {
383
+ return {
384
+ block: true,
385
+ reason: `Plan mode: subagent profile "${disallowed}" is blocked while planning. Delegate read-only exploration with agent: "${PLAN_ALLOWED_SUBAGENT}" (set it on every task in tasks mode).`,
386
+ };
387
+ }
388
+ }
283
389
  });
284
390
  pi.on("before_agent_start", async (event, ctx) => {
285
391
  setActivePlanSession(ctx.sessionManager.getSessionId());
@@ -300,12 +406,12 @@ export default function plan(pi) {
300
406
  customInstructions: `The user approved an implementation plan saved at ${pending.planPath}. Preserve decisions, constraints, and rejected alternatives from the planning discussion that are not already written in the plan file.`,
301
407
  onComplete: () => {
302
408
  exitPlanMode(ctx, "Context compacted; executing the plan.");
303
- pi.sendUserMessage(buildKickoffMessage(pending));
409
+ sendKickoff(pending);
304
410
  },
305
411
  onError: (error) => {
306
412
  // Never leave the user stuck read-only: degrade to keep-context execution.
307
413
  exitPlanMode(ctx, `Compaction failed (${error.message}); executing with full context instead.`);
308
- pi.sendUserMessage(buildKickoffMessage(pending));
414
+ sendKickoff(pending);
309
415
  },
310
416
  });
311
417
  });