one-code-extension 0.1.3 → 0.3.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 (300) hide show
  1. package/LICENSE +20 -0
  2. package/README.md +200 -172
  3. package/agents/explore.md +9 -6
  4. package/agents/general-purpose.md +1 -1
  5. package/agents/plan.md +6 -4
  6. package/docs/guide/README.md +52 -0
  7. package/docs/guide/bring-your-claude-code-setup.md +126 -0
  8. package/docs/guide/configuration.md +171 -0
  9. package/docs/guide/differences-from-claude-code.md +150 -0
  10. package/docs/guide/doctor.md +86 -0
  11. package/docs/guide/hooks.md +109 -0
  12. package/docs/guide/installation.md +124 -0
  13. package/docs/guide/permissions-modes-and-auto-mode.md +450 -0
  14. package/docs/guide/providers-and-models.md +194 -0
  15. package/docs/guide/reference.md +224 -0
  16. package/docs/guide/sessions-and-context.md +190 -0
  17. package/docs/guide/skills-plugins-and-mcp.md +176 -0
  18. package/docs/guide/subagents-and-workflows.md +193 -0
  19. package/docs/guide/tasks-and-background-work.md +111 -0
  20. package/docs/guide/terminal-interface.md +155 -0
  21. package/docs/guide/tools.md +175 -0
  22. package/docs/guide/troubleshooting.md +175 -0
  23. package/extensions/ask-user/index.ts +65 -71
  24. package/extensions/ask-user/questions.ts +15 -28
  25. package/extensions/ask-user/widget.ts +533 -0
  26. package/extensions/auto-mode/actions.ts +9 -26
  27. package/extensions/auto-mode/classifier-prompt.ts +127 -0
  28. package/extensions/auto-mode/classifier.ts +305 -227
  29. package/extensions/auto-mode/config.ts +279 -77
  30. package/extensions/auto-mode/defaults.ts +17 -60
  31. package/extensions/auto-mode/instructions.ts +2 -1
  32. package/extensions/auto-mode/model-picker.ts +53 -31
  33. package/extensions/auto-mode/model-select.ts +153 -82
  34. package/extensions/auto-mode/paths.ts +13 -1
  35. package/extensions/auto-mode/pause.ts +8 -0
  36. package/extensions/auto-mode/prompt.ts +205 -238
  37. package/extensions/auto-mode/recoverability.ts +229 -0
  38. package/extensions/auto-mode/rules.ts +92 -75
  39. package/extensions/auto-mode/safety-floor.ts +43 -12
  40. package/extensions/auto-mode/sensitive.ts +11 -0
  41. package/extensions/auto-mode/setup-run.ts +245 -0
  42. package/extensions/auto-mode/setup.ts +235 -0
  43. package/extensions/auto-mode/shell-analysis.ts +332 -26
  44. package/extensions/auto-mode/transcript.ts +102 -0
  45. package/extensions/background/index.ts +256 -43
  46. package/extensions/background/monitor-batch.ts +58 -0
  47. package/extensions/background/registry.ts +9 -0
  48. package/extensions/background/wakeup.ts +45 -0
  49. package/extensions/bash/background.ts +47 -22
  50. package/extensions/bash/guards.ts +229 -0
  51. package/extensions/bash/index.ts +81 -19
  52. package/extensions/branding/index.ts +44 -18
  53. package/extensions/branding/startup.ts +23 -8
  54. package/extensions/claude-compat/index.ts +17 -2
  55. package/extensions/claude-context/index.ts +41 -12
  56. package/extensions/clear/index.ts +27 -0
  57. package/extensions/compaction/fit.ts +121 -0
  58. package/extensions/compaction/index.ts +176 -71
  59. package/extensions/compaction/prompt.ts +21 -1
  60. package/extensions/context-budget/budget.ts +61 -0
  61. package/extensions/context-budget/index.ts +66 -0
  62. package/extensions/context-management/index.ts +40 -16
  63. package/extensions/doctor/build.ts +226 -0
  64. package/extensions/doctor/cli.ts +99 -0
  65. package/extensions/doctor/compat.ts +532 -0
  66. package/extensions/doctor/dependencies.ts +186 -0
  67. package/extensions/doctor/fix-prompt.ts +125 -0
  68. package/extensions/doctor/index.ts +308 -0
  69. package/extensions/doctor/models.ts +231 -0
  70. package/extensions/doctor/presets.ts +208 -0
  71. package/extensions/doctor/report.ts +189 -0
  72. package/extensions/doctor/update-lookup.ts +46 -0
  73. package/extensions/doctor/viewer.ts +124 -0
  74. package/extensions/effort/index.ts +41 -4
  75. package/extensions/effort/slider.ts +33 -2
  76. package/extensions/file-tracker/index.ts +143 -20
  77. package/extensions/file-tracker/replay.ts +46 -0
  78. package/extensions/file-tracker/tracker.ts +51 -4
  79. package/extensions/footer/footer-line.ts +207 -0
  80. package/extensions/footer/index.ts +152 -0
  81. package/extensions/footer/pr.ts +53 -0
  82. package/extensions/hooks/executor.ts +57 -20
  83. package/extensions/hooks/formatter-notice.ts +76 -0
  84. package/extensions/hooks/index.ts +314 -73
  85. package/extensions/hooks/matcher.ts +35 -1
  86. package/extensions/hooks/plugin-hooks.ts +3 -3
  87. package/extensions/hooks/protocol.ts +58 -0
  88. package/extensions/hooks/subagent-bridge.ts +77 -0
  89. package/extensions/hooks/trust.ts +15 -4
  90. package/extensions/init/index.ts +26 -0
  91. package/extensions/init/prompt.ts +40 -0
  92. package/extensions/interrupted/index.ts +35 -0
  93. package/extensions/interrupted/line.ts +9 -0
  94. package/extensions/lib/abort.ts +15 -0
  95. package/extensions/lib/agent-loader.ts +183 -0
  96. package/extensions/lib/anthropic-payload.ts +15 -0
  97. package/extensions/lib/atomic-write.ts +56 -0
  98. package/extensions/lib/bridge-watch.ts +20 -0
  99. package/extensions/lib/cache-retention.ts +60 -0
  100. package/extensions/lib/capability-index.ts +466 -0
  101. package/extensions/lib/claude-context.ts +318 -27
  102. package/extensions/lib/claude-settings.ts +64 -0
  103. package/extensions/lib/consent-preview.ts +30 -0
  104. package/extensions/lib/deferred.ts +144 -13
  105. package/extensions/lib/favorites.ts +43 -0
  106. package/extensions/lib/fetch-timeout.ts +15 -0
  107. package/extensions/lib/frontmatter.ts +31 -0
  108. package/extensions/lib/git.ts +44 -2
  109. package/extensions/lib/hook-gate.ts +90 -0
  110. package/extensions/lib/interrupt.ts +94 -0
  111. package/extensions/lib/mcp-overrides.ts +61 -0
  112. package/extensions/lib/mcp-share.ts +25 -0
  113. package/extensions/lib/mcp-status.ts +34 -0
  114. package/extensions/lib/memory.ts +41 -6
  115. package/extensions/lib/model-facts.generated.json +2480 -0
  116. package/extensions/lib/model-facts.ts +162 -0
  117. package/extensions/lib/model-policy.ts +145 -189
  118. package/extensions/lib/model-tier.ts +480 -64
  119. package/extensions/lib/model-unusable.ts +37 -0
  120. package/extensions/lib/notifications.ts +320 -1
  121. package/extensions/lib/one-code-settings.ts +63 -0
  122. package/extensions/lib/original-command.ts +71 -0
  123. package/extensions/lib/package-version.ts +31 -0
  124. package/extensions/lib/paths.ts +39 -4
  125. package/extensions/lib/permission-gate.ts +192 -0
  126. package/extensions/lib/persisted-output.ts +18 -0
  127. package/extensions/lib/pi-ai-estimate.ts +160 -0
  128. package/extensions/lib/pi-version.ts +1 -1
  129. package/extensions/lib/plan-mode-channels.ts +11 -0
  130. package/extensions/lib/plugin-overrides.ts +24 -0
  131. package/extensions/lib/plugin-root.ts +44 -0
  132. package/extensions/lib/plugins.ts +233 -54
  133. package/extensions/lib/prefix-warm-gate.ts +157 -0
  134. package/extensions/lib/process-tree.ts +56 -0
  135. package/extensions/lib/reminders.ts +321 -68
  136. package/extensions/lib/scratchpad.ts +11 -8
  137. package/extensions/lib/session-lifecycle.ts +28 -0
  138. package/extensions/lib/settings-channels.ts +14 -0
  139. package/extensions/lib/shell-tasks.ts +54 -0
  140. package/extensions/lib/skill-overrides.ts +86 -0
  141. package/extensions/lib/skill-scan.ts +110 -0
  142. package/extensions/lib/text-width.ts +267 -0
  143. package/extensions/lib/tui-render.ts +281 -54
  144. package/extensions/lib/usage-bus.ts +75 -0
  145. package/extensions/lib/usage-tracker.ts +67 -0
  146. package/extensions/lib/worktree-isolation.ts +170 -0
  147. package/extensions/lsp/client.ts +69 -12
  148. package/extensions/lsp/format.ts +12 -2
  149. package/extensions/lsp/index.ts +273 -84
  150. package/extensions/lsp/install-hints.ts +43 -0
  151. package/extensions/lsp/keep-alive.ts +19 -0
  152. package/extensions/lsp/plugin-servers.ts +331 -0
  153. package/extensions/lsp/watcher.ts +157 -0
  154. package/extensions/mcp/announce.ts +99 -0
  155. package/extensions/mcp/client.ts +181 -22
  156. package/extensions/mcp/config.ts +55 -24
  157. package/extensions/mcp/index.ts +586 -86
  158. package/extensions/mcp/oauth/browser.ts +26 -0
  159. package/extensions/mcp/oauth/callback.ts +104 -0
  160. package/extensions/mcp/oauth/flow.ts +68 -0
  161. package/extensions/mcp/oauth/provider.ts +147 -0
  162. package/extensions/mcp/oauth/store.ts +86 -0
  163. package/extensions/mcp/panel/keys.ts +34 -0
  164. package/extensions/mcp/panel/model.ts +62 -0
  165. package/extensions/mcp/panel/render.ts +141 -0
  166. package/extensions/mcp/panel/state.ts +108 -0
  167. package/extensions/mcp/schema.ts +83 -6
  168. package/extensions/mcp/trust.ts +340 -0
  169. package/extensions/memory/entries.ts +163 -0
  170. package/extensions/memory/index.ts +119 -2
  171. package/extensions/memory/open-external.ts +97 -0
  172. package/extensions/memory/panel.ts +124 -0
  173. package/extensions/model-default/index.ts +74 -0
  174. package/extensions/notebook/index.ts +36 -18
  175. package/extensions/notebook/notebook.ts +110 -17
  176. package/extensions/permissions/index.ts +1012 -247
  177. package/extensions/permissions/matcher.ts +488 -57
  178. package/extensions/permissions/modes.ts +55 -7
  179. package/extensions/permissions/project-trust.ts +81 -0
  180. package/extensions/permissions/protected-paths.ts +16 -3
  181. package/extensions/permissions/rule-prose.ts +79 -0
  182. package/extensions/permissions/session-grant.ts +91 -0
  183. package/extensions/permissions/settings.ts +112 -33
  184. package/extensions/permissions/subagent-gate.ts +57 -0
  185. package/extensions/plan-mode/index.ts +143 -33
  186. package/extensions/plan-mode/reminder.ts +16 -19
  187. package/extensions/plan-mode/viewer.ts +40 -31
  188. package/extensions/plugins/counts.ts +68 -0
  189. package/extensions/plugins/index.ts +496 -32
  190. package/extensions/plugins/install/install.ts +167 -0
  191. package/extensions/plugins/install/paths.ts +23 -0
  192. package/extensions/plugins/install/registry.ts +72 -0
  193. package/extensions/plugins/marketplace/git.ts +103 -0
  194. package/extensions/plugins/marketplace/official.ts +36 -0
  195. package/extensions/plugins/marketplace/parse.ts +94 -0
  196. package/extensions/plugins/marketplace/registry.ts +60 -0
  197. package/extensions/plugins/marketplace/sync.ts +146 -0
  198. package/extensions/plugins/marketplace/types.ts +128 -0
  199. package/extensions/plugins/panel/keys.ts +52 -0
  200. package/extensions/plugins/panel/render.ts +315 -0
  201. package/extensions/plugins/panel/rows.ts +230 -0
  202. package/extensions/plugins/panel/state.ts +333 -0
  203. package/extensions/recap/index.ts +215 -0
  204. package/extensions/recap/prompt.ts +44 -0
  205. package/extensions/recap/scheduler.ts +87 -0
  206. package/extensions/search-tools/index.ts +0 -0
  207. package/extensions/search-tools/tools.ts +7 -5
  208. package/extensions/skill/index.ts +360 -34
  209. package/extensions/skill/invoke.ts +185 -0
  210. package/extensions/skill/listing.ts +21 -0
  211. package/extensions/skill/panel/keys.ts +41 -0
  212. package/extensions/skill/panel/render.ts +91 -0
  213. package/extensions/skill/panel/state.ts +115 -0
  214. package/extensions/spinner/index.ts +110 -0
  215. package/extensions/spinner/line.ts +51 -0
  216. package/extensions/spinner/verbs.ts +201 -0
  217. package/extensions/subagents/agents.ts +88 -44
  218. package/extensions/subagents/default-model.ts +62 -32
  219. package/extensions/subagents/delegation-steer.ts +23 -0
  220. package/extensions/subagents/hand-back-review.ts +67 -0
  221. package/extensions/subagents/index.ts +1545 -346
  222. package/extensions/subagents/live-runs.ts +293 -0
  223. package/extensions/subagents/model-select.ts +252 -82
  224. package/extensions/subagents/outcome.ts +90 -0
  225. package/extensions/subagents/panel-keys.ts +41 -0
  226. package/extensions/subagents/panel-render.ts +357 -0
  227. package/extensions/subagents/panel-widget.ts +330 -0
  228. package/extensions/subagents/pending-claim.ts +45 -0
  229. package/extensions/subagents/prose.ts +105 -0
  230. package/extensions/subagents/runner.ts +674 -0
  231. package/extensions/subagents/runs.ts +68 -1
  232. package/extensions/subagents/send-to-main-tool.ts +55 -0
  233. package/extensions/subagents/session-turns.ts +138 -0
  234. package/extensions/subagents/shell-panel.ts +198 -0
  235. package/extensions/subagents/usage.ts +16 -4
  236. package/extensions/subagents/worktree.ts +13 -0
  237. package/extensions/system-prompt/environment.ts +5 -3
  238. package/extensions/system-prompt/git-status.ts +93 -0
  239. package/extensions/system-prompt/index.ts +23 -1
  240. package/extensions/system-prompt/template.ts +34 -4
  241. package/extensions/system-prompt/tiers/common.ts +31 -6
  242. package/extensions/system-prompt/tiers/low.ts +13 -3
  243. package/extensions/system-prompt/tiers/mid.ts +21 -8
  244. package/extensions/system-reminder/index.ts +53 -9
  245. package/extensions/tasks/index.ts +65 -16
  246. package/extensions/tasks/store.ts +76 -3
  247. package/extensions/tool-search/announce.ts +42 -0
  248. package/extensions/tool-search/index.ts +136 -13
  249. package/extensions/turn-duration/index.ts +60 -0
  250. package/extensions/turn-duration/line.ts +22 -0
  251. package/extensions/turn-duration/span.ts +41 -0
  252. package/extensions/turn-duration/verbs.ts +24 -0
  253. package/extensions/web/backends.ts +421 -0
  254. package/extensions/web/index.ts +178 -21
  255. package/extensions/web-fetch/index.ts +74 -18
  256. package/extensions/web-fetch/summarize.ts +1 -1
  257. package/extensions/workflow/agent-session.ts +182 -112
  258. package/extensions/workflow/globals.ts +129 -106
  259. package/extensions/workflow/index.ts +343 -28
  260. package/extensions/workflow/records.ts +118 -0
  261. package/extensions/workflow/run-manager.ts +80 -31
  262. package/extensions/workflow/saved-workflows.ts +2 -1
  263. package/extensions/workflow/script-worker.mjs +110 -0
  264. package/extensions/workflow/types.ts +74 -8
  265. package/extensions/workflow/viewer.ts +562 -0
  266. package/extensions/workflow/vm-runtime.ts +118 -22
  267. package/extensions/workflow/widget.ts +126 -18
  268. package/extensions/worktree/guards.ts +240 -0
  269. package/extensions/worktree/index.ts +30 -5
  270. package/extensions/worktree/rewrite.ts +32 -16
  271. package/package.json +20 -10
  272. package/skills/code-review/SKILL.md +165 -0
  273. package/skills/fewer-permission-prompts/SKILL.md +61 -0
  274. package/skills/security-review/SKILL.md +178 -0
  275. package/skills/simplify/SKILL.md +70 -0
  276. package/themes/{one-code-light.json → onecode-light.json} +6 -1
  277. package/themes/{one-code.json → onecode.json} +6 -1
  278. package/types/app.d.ts +8 -1
  279. package/types/community-packages.d.ts +45 -2
  280. package/docs/decisions/auto-mode.md +0 -526
  281. package/docs/decisions/branding.md +0 -127
  282. package/docs/decisions/code-review-remediation.md +0 -106
  283. package/docs/decisions/compaction.md +0 -140
  284. package/docs/decisions/distribution.md +0 -168
  285. package/docs/decisions/hooks.md +0 -64
  286. package/docs/decisions/lsp.md +0 -58
  287. package/docs/decisions/mcp.md +0 -104
  288. package/docs/decisions/memory-state.md +0 -170
  289. package/docs/decisions/model-policy.md +0 -126
  290. package/docs/decisions/modes.md +0 -178
  291. package/docs/decisions/skills-plugins.md +0 -56
  292. package/docs/decisions/subagents-workflows.md +0 -225
  293. package/docs/decisions/system-prompt.md +0 -98
  294. package/docs/decisions/tools.md +0 -407
  295. package/docs/decisions/tui.md +0 -202
  296. package/docs/decisions.md +0 -148
  297. package/extensions/subagents/child.ts +0 -414
  298. package/extensions/subagents/rpc-turns.ts +0 -123
  299. package/extensions/todo/index.ts +0 -133
  300. package/extensions/workflow/permission-gate.ts +0 -71
@@ -1,41 +1,68 @@
1
1
  /**
2
2
  * plan-mode extension — Claude Code's EnterPlanMode / ExitPlanMode tools,
3
- * file-based: the plan lives at ~/.one-code/plans/<slug>.md, the one path plan
3
+ * file-based: the plan lives at ~/.onecode/plans/<slug>.md, the one path plan
4
4
  * mode may write. The path is announced to the model in an every-turn reminder
5
5
  * and to the permissions extension over the event bus, and exit_plan_mode
6
6
  * reads the file rather than taking the plan as a parameter.
7
7
  *
8
8
  * Mode state is owned by the permissions extension; this extension requests
9
9
  * changes over MODE_CHANNEL and reacts to transitions it observes on
10
- * PERMISSION_STATUS_CHANNEL (which fires on every setMode). Plan-file
11
- * allocation happens lazily in before_agent_start — the one hook that covers
12
- * all three ways into plan mode (the tool, ctrl+q, defaultMode: "plan") and
13
- * runs after every extension's session_start, so restoring a previous path
14
- * from the session branch can never race a fresh allocation.
10
+ * PERMISSION_STATUS_CHANNEL (which fires on every setMode). The plan block is
11
+ * (re)installed at two points: synchronously on the transition INTO plan mode
12
+ * (the status handler, with the last context seen), so a ctrl+q during a long
13
+ * turn puts the block on the queue before the turn's next request — until
14
+ * 2026-09-05 the mid-turn requests ran with no plan reminder while every edit
15
+ * was denied "see the plan-mode reminder" (STEERING-REVIEW-2026-09-05 M3) —
16
+ * and on before_agent_start, which covers a session that STARTS in plan mode
17
+ * before this extension has seen a context (defaultMode: "plan"; permissions'
18
+ * session_start runs first) and runs after every extension's session_start,
19
+ * so restoring a previous path from the session branch never races a fresh
20
+ * allocation.
15
21
  */
16
22
 
17
23
  import { existsSync, readFileSync } from "node:fs";
18
24
  import { join } from "node:path";
19
25
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
20
26
  import { Type } from "typebox";
27
+ import { DEFER_CHANNEL } from "../lib/deferred.ts";
21
28
  import { oneCodeStateDir } from "../lib/paths.ts";
22
29
  import { REMINDER_CHANNEL } from "../lib/reminders.ts";
23
30
  import { ccToolRenderers, safeThemePaint } from "../lib/tui-render.ts";
31
+ import type { PermissionMode } from "../permissions/matcher.ts";
24
32
  import { PERMISSION_STATUS_CHANNEL, type PermissionStatus } from "../permissions/modes.ts";
25
33
  import { buildPlanModeReminder } from "./reminder.ts";
26
34
  import { randomSlug } from "./slug.ts";
27
- import { clampOffset, decodeViewerKey, type PlanChoice, renderPlanViewer, wrapPlanText } from "./viewer.ts";
35
+ import { clampOffset, decodeViewerKey, initialPlanChoice, type PlanChoice, renderPlanViewer, wrapPlanText } from "./viewer.ts";
28
36
 
29
- export const MODE_CHANNEL = "one-code:set-permission-mode";
30
- /** Announces plan mode's one writable file; the permissions matcher consumes it. */
31
- export const PLAN_FILE_CHANNEL = "one-code:plan-file-path";
37
+ import { MODE_CHANNEL, PLAN_FILE_CHANNEL } from "../lib/plan-mode-channels.ts";
38
+ export { MODE_CHANNEL, PLAN_FILE_CHANNEL };
32
39
  /** Session entry type persisting the allocated path across resume/branch. */
33
- const PLAN_FILE_ENTRY = "plan-mode-file";
40
+ const PLAN_FILE_ENTRY = "one-code:plan-mode-file";
34
41
 
35
42
  const PLANS_DIR = () => join(oneCodeStateDir(), "plans");
36
43
 
44
+ /** How each mode is named in the approval choices. */
45
+ const MODE_NAMES: Record<PermissionMode, string> = {
46
+ default: "manual approvals",
47
+ acceptEdits: "auto-accept edits",
48
+ plan: "plan mode",
49
+ auto: "auto mode",
50
+ bypassPermissions: "bypass permissions",
51
+ dontAsk: "dontAsk",
52
+ };
53
+
37
54
  export default function planModeExtension(pi: ExtensionAPI) {
38
- let currentMode = "default";
55
+ /** The last mode broadcast by permissions; undefined until the first status arrives. */
56
+ let currentMode: string | undefined;
57
+ /**
58
+ * The mode the session was in when it entered plan mode, offered first on
59
+ * exit (Claude Code's ExitPlanMode restores `prePlanMode`). Unknown for a
60
+ * session that STARTED in plan mode — the first status seen is plan itself —
61
+ * so that session gets the fixed list. Until 2026-09-05 the exit could only
62
+ * escalate: a default-mode session that planned was offered auto
63
+ * (PERMISSIONS-REVIEW-2026-09-05 M4).
64
+ */
65
+ let modeBeforePlan: PermissionMode | undefined;
39
66
  let planFilePath: string | undefined;
40
67
 
41
68
  /** Restore the branch's plan file, else allocate a fresh slug. */
@@ -63,18 +90,37 @@ export default function planModeExtension(pi: ExtensionAPI) {
63
90
  const path = ensurePlanFile(ctx);
64
91
  pi.events.emit(PLAN_FILE_CHANNEL, { path });
65
92
  pi.events.emit(REMINDER_CHANNEL, {
66
- text: buildPlanModeReminder({ filePath: path, fileExists: existsSync(path) }),
93
+ text: buildPlanModeReminder(path),
67
94
  scope: "every-turn",
68
95
  key: "permission-mode",
96
+ placement: "sticky-append",
69
97
  });
70
98
  };
71
99
 
100
+ /** The last context seen, for a refresh triggered off the bus (no ctx of its own). */
101
+ let lastCtx: ExtensionContext | undefined;
102
+
72
103
  pi.events.on(PERMISSION_STATUS_CHANNEL, (data) => {
73
104
  const status = data as PermissionStatus;
74
- if (typeof status?.mode === "string") currentMode = status.mode;
105
+ if (typeof status?.mode !== "string") return;
106
+ const previous = currentMode;
107
+ currentMode = status.mode;
108
+ if (currentMode === "plan" && previous !== "plan") modeBeforePlan = previous as PermissionMode | undefined;
109
+ // Entering plan mode mid-turn: permissions' setMode has just dropped the
110
+ // shared "permission-mode" key (before broadcasting this status, so this
111
+ // re-add is not undone) and will announce the change on the next tool
112
+ // result; the standing block has to be back on the queue for that same
113
+ // request. The path is announced over PLAN_FILE_CHANNEL inside refresh,
114
+ // synchronously, so setMode's announcement can name it.
115
+ if (currentMode === "plan" && previous !== "plan" && lastCtx) refresh(lastCtx);
116
+ });
117
+
118
+ pi.on("session_start", (_event, ctx) => {
119
+ lastCtx = ctx;
75
120
  });
76
121
 
77
122
  pi.on("before_agent_start", (_event, ctx) => {
123
+ lastCtx = ctx;
78
124
  if (currentMode === "plan") refresh(ctx);
79
125
  });
80
126
 
@@ -83,16 +129,16 @@ export default function planModeExtension(pi: ExtensionAPI) {
83
129
  label: "Enter plan mode",
84
130
  ...ccToolRenderers("Enter plan mode"),
85
131
  description:
86
- "Enter plan mode for tasks that need investigation and design before changing anything. In plan mode only read-only tools are available, plus one writable file: the plan file whose path you are told, where you build the plan incrementally. Use for non-trivial multi-file work; skip it for simple direct changes.",
87
- promptSnippet: "Switch to read-only planning before non-trivial changes",
132
+ "Enter plan mode: read-only investigation to design an approach before changing anything. Most tasks do not need it.\n" +
133
+ "For a small or clearly-scoped change, act directly instead. Enter plan mode only for multi-file work whose design is genuinely unclear, or when the user asks for a plan. In plan mode only read-only tools are available, plus one writable file: the plan file whose path you are told, where you build the plan incrementally.",
88
134
  parameters: Type.Object({}),
89
135
  async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
90
136
  pi.events.emit(MODE_CHANNEL, { mode: "plan" });
91
- // setMode fires synchronously over the status channel, so currentMode
92
- // is already "plan"; announce the file in the tool result too so the
93
- // model can start writing this same turn.
137
+ // setMode broadcasts synchronously over the status channel, so the
138
+ // listener above has already installed the block and announced the
139
+ // path (or the mode was plan already and both stand); name the file in
140
+ // the tool result too so the model can start writing this same turn.
94
141
  const path = ensurePlanFile(ctx);
95
- refresh(ctx);
96
142
  return {
97
143
  content: [
98
144
  {
@@ -110,8 +156,8 @@ export default function planModeExtension(pi: ExtensionAPI) {
110
156
  label: "Exit plan mode",
111
157
  ...ccToolRenderers("Exit plan mode"),
112
158
  description:
113
- "Signal that planning is complete and ask the user to approve the plan. Takes no parameters: the plan is read from the plan file named in the plan-mode reminder, which you must have written before calling this. The user reviews that file's contents.",
114
- promptSnippet: "Present your plan file for user approval",
159
+ "Signal that planning is complete and ask the user to approve the plan.\n" +
160
+ "Takes no parameters: the plan is read from the plan file named in the plan-mode reminder, which you must have written before calling this. The user reviews that file's contents.",
115
161
  parameters: Type.Object({}),
116
162
  async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
117
163
  // Guard the out-of-sequence call: without this, a cold exit_plan_mode
@@ -145,45 +191,93 @@ export default function planModeExtension(pi: ExtensionAPI) {
145
191
  };
146
192
  }
147
193
 
194
+ // Plan approval is the user's: with no UI there is no one to approve, and
195
+ // until 2026-09-05 the model exited plan mode by itself here — a
196
+ // `-p --permission-mode plan` run was read-only in name only, since the
197
+ // user's allow rules applied the moment the mode became default
198
+ // (PERMISSIONS-REVIEW-2026-09-05 M4, measured: `git add -A` ran). The
199
+ // session stays in plan mode; the plan file is the run's product.
148
200
  if (!ctx.hasUI) {
149
- pi.events.emit(MODE_CHANNEL, { mode: "default" });
150
201
  return {
151
202
  content: [
152
- { type: "text", text: "Non-interactive session: plan recorded and plan mode exited. Proceed." },
203
+ {
204
+ type: "text",
205
+ text: `Plan approval needs an interactive session, and this one has no user to approve it. The session stays in plan mode; the plan is recorded at ${path} for the user to review. Do not try to implement it here.`,
206
+ },
153
207
  ],
154
- details: { plan, approved: true },
208
+ details: { planFilePath: path, plan, approved: false },
209
+ isError: true,
155
210
  };
156
211
  }
157
212
 
213
+ // The mode the session was in before planning leads the list (Claude
214
+ // Code restores it), then auto mode when a classifier model is reachable —
215
+ // the same gate the mode cycle uses (permissions/index.ts autoInCycle) —
216
+ // and the fixed choices. Each approving choice carries the mode it
217
+ // switches to; the final "Keep planning" choice has none and leaves the
218
+ // session in plan mode.
219
+ const autoAvailable = ctx.modelRegistry.getAvailable().length > 0;
220
+ const restore = modeBeforePlan && (modeBeforePlan !== "auto" || autoAvailable) ? modeBeforePlan : undefined;
221
+ const fixedModes: PermissionMode[] = [...(autoAvailable ? (["auto"] as const) : []), "acceptEdits", "default"];
222
+ const options: { label: string; mode?: PermissionMode; restore?: boolean }[] = [
223
+ ...(restore ? [{ label: `Approve — back to ${MODE_NAMES[restore]} (the mode before planning)`, mode: restore, restore: true }] : []),
224
+ ...fixedModes.filter((mode) => mode !== restore).map((mode) => ({ label: `Approve — ${MODE_NAMES[mode]}`, mode })),
225
+ { label: "Keep planning" },
226
+ ];
227
+ const choices = options.map((o) => o.label);
228
+
158
229
  const choice = await ctx.ui.custom<PlanChoice | null>((tui, theme, _keybindings, done) => {
159
230
  const paint = safeThemePaint(theme);
160
231
  const maxVisible = 12;
161
232
  let offset = 0;
162
- let selected: PlanChoice = 0;
233
+ let selected: PlanChoice = initialPlanChoice(options);
163
234
  let lineCount = 0;
235
+ // The dialog stays open while exit_plan_mode runs, so pi's working
236
+ // spinner drives ~8 frames a second — and re-wrapping the whole plan
237
+ // each frame burned 5–17 ms on a long plan (TUI-REVIEW M1). The plan
238
+ // is immutable for the dialog's life: wrap once per width, and cache
239
+ // the rendered frame by (width, offset, selected) so a steady frame
240
+ // costs nothing. invalidate() (theme swap) drops both.
241
+ // Only one width is ever live at a time (a resize is the only thing that
242
+ // changes it), so a single-slot memo is enough — like the model picker's.
243
+ let wrapped: { width: number; lines: string[] } | undefined;
244
+ let frame: { key: string; lines: string[] } | undefined;
245
+ const wrapAt = (wrapWidth: number): string[] => {
246
+ if (wrapped?.width !== wrapWidth) wrapped = { width: wrapWidth, lines: wrapPlanText(plan, wrapWidth) };
247
+ return wrapped.lines;
248
+ };
164
249
  return {
165
250
  render: (width: number) => {
166
- const lines = wrapPlanText(plan, Math.max(10, width - 1));
251
+ const lines = wrapAt(Math.max(10, width - 1));
167
252
  lineCount = lines.length;
168
253
  offset = clampOffset(offset, lineCount, maxVisible);
169
- return renderPlanViewer({ lines, offset, choice: selected, maxVisible }, paint, width);
254
+ const key = `${width}:${offset}:${selected}`;
255
+ if (frame?.key === key) return frame.lines;
256
+ const rendered = renderPlanViewer({ lines, offset, choice: selected, choices, maxVisible }, paint, width);
257
+ frame = { key, lines: rendered };
258
+ return rendered;
170
259
  },
171
260
  handleInput: (data: string) => {
172
261
  const key = decodeViewerKey(data, maxVisible);
173
262
  if (!key) return;
174
263
  if (key.kind === "cancel") return done(null);
175
264
  if (key.kind === "confirm") return done(selected);
176
- if (key.kind === "pick") return done(key.index);
265
+ if (key.kind === "pick") return key.index < options.length ? done(key.index) : undefined;
177
266
  if (key.kind === "scroll") offset = clampOffset(offset + key.delta, lineCount, maxVisible);
178
- else if (key.kind === "choice") selected = (((selected + key.delta) % 3) + 3) % 3 as PlanChoice;
267
+ else if (key.kind === "choice")
268
+ selected = (((selected + key.delta) % options.length) + options.length) % options.length;
179
269
  tui.requestRender();
180
270
  },
181
- invalidate: () => {},
271
+ invalidate: () => {
272
+ wrapped = undefined;
273
+ frame = undefined;
274
+ },
182
275
  };
183
276
  });
184
277
 
185
- if (choice === 0 || choice === 1) {
186
- pi.events.emit(MODE_CHANNEL, { mode: choice === 1 ? "acceptEdits" : "default" });
278
+ const picked = choice != null ? options[choice] : undefined;
279
+ if (picked?.mode) {
280
+ pi.events.emit(MODE_CHANNEL, { mode: picked.mode });
187
281
  return {
188
282
  content: [
189
283
  {
@@ -206,4 +300,20 @@ export default function planModeExtension(pi: ExtensionAPI) {
206
300
  };
207
301
  },
208
302
  });
303
+
304
+ // Claude Code defers both plan-mode tools behind ToolSearch: they are absent
305
+ // from the wire tool list and its system prompt never mentions plan mode, so
306
+ // the model must deliberately load them before planning. Keeping them
307
+ // always-active (with a prompt snippet) read as standing "plan first" policy
308
+ // to instruction-eager third-party models, which then entered plan mode for
309
+ // trivial tasks. User-initiated plan mode (mode cycling, defaultMode) is
310
+ // unaffected — that flows over MODE_CHANNEL, not through these tools.
311
+ pi.events.emit(DEFER_CHANNEL, {
312
+ name: "enter_plan_mode",
313
+ keywords: ["plan", "planning", "design", "approach", "architecture", "investigate", "read-only"],
314
+ });
315
+ pi.events.emit(DEFER_CHANNEL, {
316
+ name: "exit_plan_mode",
317
+ keywords: ["plan", "approve", "approval", "present", "finish planning"],
318
+ });
209
319
  }
@@ -1,32 +1,29 @@
1
1
  /**
2
- * Plan-mode reminder text (pure). Byte-identical for a given state so the
3
- * every-turn re-emit replaces the queue entry with the same string except when
4
- * the state genuinely changed (file created, different path).
2
+ * Plan-mode reminder text (pure). Byte-identical for a given plan-file path,
3
+ * whether or not the file exists yet: the block is a sticky-append reminder
4
+ * carried by every user message since plan mode came on, and the queue keeps
5
+ * that anchor only while the re-emitted text is unchanged. A text that flipped
6
+ * when the file appeared re-anchored the block and re-cached every message
7
+ * between entering plan mode and the first write (CACHE-REVIEW-2026-09-04 M2).
5
8
  *
6
- * Mirrors Claude Code's plan-mode system message: the plan lives in a file the
7
- * model builds incrementally — the one writable path in plan mode — and
8
- * exit_plan_mode reads that file rather than taking the plan as a parameter.
9
+ * Adapted from Claude Code's plan-mode system message: the plan lives in a
10
+ * file the model builds incrementally — the one writable path in plan mode —
11
+ * and exit_plan_mode reads that file rather than taking the plan as a
12
+ * parameter. Claude Code's own text does vary with the file's existence; ours
13
+ * folds both states into one sentence so the cached prefix survives.
9
14
  */
10
15
 
11
- export interface PlanReminderState {
12
- filePath: string;
13
- fileExists: boolean;
14
- }
15
-
16
- export function buildPlanModeReminder({ filePath, fileExists }: PlanReminderState): string {
17
- const fileLine = fileExists
18
- ? `Continue building your plan at ${filePath} with the write/edit tools — edit it incrementally rather than rewriting it from scratch.`
19
- : `No plan file exists yet — create it at ${filePath} with the write tool. Build the plan there incrementally as you investigate; do not present the plan as chat text.`;
20
-
16
+ export function buildPlanModeReminder(filePath: string): string {
17
+ const fileLine = `Build your plan at ${filePath} with the write/edit tools — create it if it does not exist yet, then edit it incrementally rather than rewriting it from scratch. Do not present the plan as chat text.`;
21
18
  return [
22
19
  `Plan mode is active. You may only use read-only tools; edit and write are blocked everywhere except one file. ${fileLine}`,
23
20
  "",
24
21
  "Workflow:",
25
- '1. Explore: delegate broad reconnaissance to `subagent` with `agent: "explore"` (up to 3 in parallel) rather than reading everything yourself.',
26
- '2. Design: delegate the implementation strategy to `subagent` with `agent: "plan"` once exploration has mapped the ground.',
22
+ '1. Explore: delegate broad reconnaissance to the `Agent` tool with `subagent_type: "explore"` (up to 3 in parallel) rather than reading everything yourself.',
23
+ '2. Design: delegate the implementation strategy to the `Agent` tool with `subagent_type: "plan"` once exploration has mapped the ground.',
27
24
  "3. Review the critical files yourself, and call `ask_user_question` for any decision you cannot make on the user's behalf.",
28
25
  `4. Write the final plan to ${filePath}: a Context section explaining why, your recommended approach (not several options), the critical files named, existing utilities to reuse, and a verification section.`,
29
- "5. Call `exit_plan_mode` to ask the user to approve the plan. It reads the plan file directly — you do not pass the plan as a parameter.",
26
+ "5. Call `exit_plan_mode` to ask the user to approve the plan. It reads the plan file directly — you do not pass the plan as a parameter. If `exit_plan_mode` is not in your active tools, load it first with tool_search (`select:exit_plan_mode`).",
30
27
  "",
31
28
  "End every plan-mode turn with either `ask_user_question` or `exit_plan_mode`. Never ask for plan approval in chat text.",
32
29
  ].join("\n");
@@ -10,10 +10,13 @@
10
10
  * painting so ANSI escapes never confuse the width accounting.
11
11
  */
12
12
 
13
- export const PLAN_CHOICES = ["Approve — manual approvals", "Approve — auto-accept edits", "Keep planning"] as const;
13
+ import { cutPlainText } from "../lib/tui-render.ts";
14
14
 
15
- /** What the dialog resolves to; index into PLAN_CHOICES, or null for cancel. */
16
- export type PlanChoice = 0 | 1 | 2;
15
+ /** Hard-wrap plain text to `width` columns, preserving blank lines. */
16
+ export { wrapPlainText as wrapPlanText } from "../lib/tui-render.ts";
17
+
18
+ /** What the dialog resolves to; index into the choices list, or null for cancel. */
19
+ export type PlanChoice = number;
17
20
 
18
21
  export type ViewerKey =
19
22
  | { kind: "scroll"; delta: number }
@@ -22,7 +25,12 @@ export type ViewerKey =
22
25
  | { kind: "confirm" }
23
26
  | { kind: "cancel" };
24
27
 
25
- /** Decode a raw terminal chunk; `page` is the scroll step for PgUp/PgDn. */
28
+ /**
29
+ * Decode a raw terminal chunk; `page` is the scroll step for PgUp/PgDn. Digit
30
+ * keys resolve to a raw 0-based `pick` index; the caller clamps it to the number
31
+ * of choices actually shown (the list is dynamic — auto mode joins only when a
32
+ * classifier model is reachable).
33
+ */
26
34
  export function decodeViewerKey(data: string, page: number): ViewerKey | undefined {
27
35
  switch (data) {
28
36
  case "\x1b[A":
@@ -48,36 +56,37 @@ export function decodeViewerKey(data: string, page: number): ViewerKey | undefin
48
56
  case "\x1b":
49
57
  case "\x03": // ctrl+c — same intent as escape while the dialog is focused
50
58
  return { kind: "cancel" };
51
- case "1":
52
- case "2":
53
- case "3":
54
- return { kind: "pick", index: (Number(data) - 1) as PlanChoice };
55
59
  default:
60
+ if (data.length === 1 && data >= "1" && data <= "9") {
61
+ return { kind: "pick", index: Number(data) - 1 };
62
+ }
56
63
  return undefined;
57
64
  }
58
65
  }
59
66
 
60
- /** Hard-wrap plain text to `width` columns, preserving blank lines. */
61
- export function wrapPlanText(text: string, width: number): string[] {
62
- const columns = Math.max(1, width);
63
- const out: string[] = [];
64
- for (const raw of text.replace(/\r\n/g, "\n").split("\n")) {
65
- const chars = [...raw];
66
- if (chars.length === 0) {
67
- out.push("");
68
- continue;
69
- }
70
- for (let i = 0; i < chars.length; i += columns) {
71
- out.push(chars.slice(i, i + columns).join(""));
72
- }
73
- }
74
- return out;
75
- }
76
-
77
67
  export function clampOffset(offset: number, total: number, visible: number): number {
78
68
  return Math.max(0, Math.min(offset, Math.max(0, total - visible)));
79
69
  }
80
70
 
71
+ /**
72
+ * The initially highlighted choice: the safest approving option ("manual
73
+ * approvals", mode "default"). Auto mode may lead the list so it is one
74
+ * keypress away, but Enter without arrowing must keep approving into manual
75
+ * mode — the pre-auto-option behavior — never silently switch the session
76
+ * into a more permissive mode.
77
+ */
78
+ /**
79
+ * The pre-selected choice: the option that restores the mode the session was
80
+ * in before planning when one is offered (Claude Code's ExitPlanMode restores
81
+ * `prePlanMode`), else "manual approvals", else the first option.
82
+ */
83
+ export function initialPlanChoice(options: readonly { mode?: string; restore?: boolean }[]): PlanChoice {
84
+ const restore = options.findIndex((o) => o.restore);
85
+ if (restore >= 0) return restore;
86
+ const index = options.findIndex((o) => o.mode === "default");
87
+ return index >= 0 ? index : 0;
88
+ }
89
+
81
90
  export type Paint = (color: string, text: string) => string;
82
91
 
83
92
  export interface PlanViewerView {
@@ -85,22 +94,22 @@ export interface PlanViewerView {
85
94
  lines: string[];
86
95
  offset: number;
87
96
  choice: PlanChoice;
97
+ /** Labels for the numbered choice list; the last is conventionally "Keep planning". */
98
+ choices: readonly string[];
88
99
  maxVisible?: number;
89
100
  }
90
101
 
91
102
  export function renderPlanViewer(view: PlanViewerView, paint: Paint, width: number): string[] {
92
103
  const maxVisible = view.maxVisible ?? 12;
93
104
  // Cut plain text before painting so escapes never enter the width math.
94
- const cut = (line: string) => {
95
- const chars = [...line];
96
- return chars.length > width ? `${chars.slice(0, Math.max(0, width - 1)).join("")}…` : line;
97
- };
105
+ const cut = (line: string) => cutPlainText(line, width);
98
106
  const rule = paint("dim", "─".repeat(Math.max(0, Math.min(width, 72))));
107
+ const pickHint = view.choices.length > 1 ? `1-${view.choices.length} pick · ` : "";
99
108
 
100
109
  const out: string[] = [];
101
110
  out.push("");
102
111
  out.push(paint("accent", cut("Approve this plan?")));
103
- out.push(paint("dim", cut("↑/↓ scroll · ←/→ switch choice · 1-3 pick · enter confirm · esc keep planning")));
112
+ out.push(paint("dim", cut(`↑/↓ scroll · ←/→ switch choice · ${pickHint}enter confirm · esc keep planning`)));
104
113
  out.push(rule);
105
114
 
106
115
  const offset = clampOffset(view.offset, view.lines.length, maxVisible);
@@ -113,7 +122,7 @@ export function renderPlanViewer(view: PlanViewerView, paint: Paint, width: numb
113
122
  }
114
123
  out.push(rule);
115
124
 
116
- for (const [index, label] of PLAN_CHOICES.entries()) {
125
+ for (const [index, label] of view.choices.entries()) {
117
126
  const numbered = `${index + 1}. ${label}`;
118
127
  out.push(
119
128
  index === view.choice ? paint("accent", cut(`❯ ${numbered}`)) : cut(` ${numbered}`),
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Install counts for the Discover tab.
3
+ *
4
+ * Public stats JSON from the official marketplace repository
5
+ * (`[{plugin: "name@marketplace", unique_installs}]`), cached under the plugin
6
+ * root for 24h. A fetch failure hides counts (undefined) — never shows zeros.
7
+ */
8
+
9
+ import { join } from "node:path";
10
+ import { readJsonFile, writeJsonAtomic } from "../lib/atomic-write.ts";
11
+ import { fetchWithTimeout } from "../lib/fetch-timeout.ts";
12
+
13
+ export const INSTALL_COUNTS_URL =
14
+ "https://raw.githubusercontent.com/anthropics/claude-plugins-official/refs/heads/stats/stats/plugin-installs.json";
15
+
16
+ const CACHE_FILE = "install-counts-cache.json";
17
+ const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
18
+ const FETCH_TIMEOUT_MS = 10_000;
19
+
20
+ interface CountsCache {
21
+ version: 1;
22
+ fetchedAt: string;
23
+ counts: Array<{ plugin: string; unique_installs: number }>;
24
+ }
25
+
26
+ function toMap(counts: CountsCache["counts"]): Map<string, number> {
27
+ const map = new Map<string, number>();
28
+ for (const entry of counts) {
29
+ if (typeof entry?.plugin === "string" && typeof entry?.unique_installs === "number") {
30
+ map.set(entry.plugin, entry.unique_installs);
31
+ }
32
+ }
33
+ return map;
34
+ }
35
+
36
+ /** Fresh cached counts, or undefined when absent/expired/malformed. */
37
+ export function readCachedCounts(root: string, now: Date = new Date()): Map<string, number> | undefined {
38
+ const cache = readJsonFile<CountsCache>(join(root, CACHE_FILE));
39
+ if (!cache || !Array.isArray(cache.counts) || typeof cache.fetchedAt !== "string") return undefined;
40
+ const fetchedAt = Date.parse(cache.fetchedAt);
41
+ if (Number.isNaN(fetchedAt) || now.getTime() - fetchedAt > CACHE_TTL_MS) return undefined;
42
+ return toMap(cache.counts);
43
+ }
44
+
45
+ /** Cached counts when fresh, else fetch + cache; undefined on failure. */
46
+ export async function fetchInstallCounts(root: string, now: Date = new Date()): Promise<Map<string, number> | undefined> {
47
+ const cached = readCachedCounts(root, now);
48
+ if (cached) return cached;
49
+
50
+ try {
51
+ const response = await fetchWithTimeout(INSTALL_COUNTS_URL, FETCH_TIMEOUT_MS);
52
+ if (!response.ok) return undefined;
53
+ const counts = (await response.json()) as CountsCache["counts"];
54
+ if (!Array.isArray(counts)) return undefined;
55
+ writeJsonAtomic(join(root, CACHE_FILE), { version: 1, fetchedAt: now.toISOString(), counts } satisfies CountsCache);
56
+ return toMap(counts);
57
+ } catch {
58
+ return undefined;
59
+ }
60
+ }
61
+
62
+ /** 999 → "999", 36400 → "36.4K", 1_200_000 → "1.2M" (trailing .0 stripped). */
63
+ export function formatInstallCount(count: number): string {
64
+ if (count < 1000) return String(count);
65
+ const scaled = count < 1_000_000 ? count / 1000 : count / 1_000_000;
66
+ const suffix = count < 1_000_000 ? "K" : "M";
67
+ return `${scaled.toFixed(1).replace(/\.0$/, "")}${suffix}`;
68
+ }