one-code-extension 0.4.1 → 0.6.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 (439) hide show
  1. package/README.md +16 -5
  2. package/agents/general-purpose.md +2 -2
  3. package/docs/guide/README.md +2 -1
  4. package/docs/guide/bring-your-claude-code-setup.md +60 -9
  5. package/docs/guide/configuration.md +48 -14
  6. package/docs/guide/differences-from-claude-code.md +40 -28
  7. package/docs/guide/doctor.md +20 -13
  8. package/docs/guide/hooks.md +18 -3
  9. package/docs/guide/installation.md +26 -4
  10. package/docs/guide/permissions-modes-and-auto-mode.md +225 -49
  11. package/docs/guide/providers-and-models.md +137 -54
  12. package/docs/guide/reference.md +27 -15
  13. package/docs/guide/sessions-and-context.md +69 -16
  14. package/docs/guide/skills-plugins-and-mcp.md +57 -20
  15. package/docs/guide/subagents-and-workflows.md +95 -29
  16. package/docs/guide/tasks-and-background-work.md +101 -26
  17. package/docs/guide/terminal-interface.md +24 -12
  18. package/docs/guide/tools.md +46 -5
  19. package/docs/guide/troubleshooting.md +13 -9
  20. package/docs/guide/windows.md +58 -31
  21. package/extensions/artifacts/gallery.ts +227 -0
  22. package/extensions/artifacts/index.ts +288 -0
  23. package/extensions/artifacts/store.ts +570 -0
  24. package/extensions/artifacts/viewer.ts +199 -0
  25. package/extensions/ask-user/description.ts +67 -0
  26. package/extensions/ask-user/dialogs.ts +80 -0
  27. package/extensions/ask-user/index.ts +37 -33
  28. package/extensions/ask-user/questions.ts +32 -7
  29. package/extensions/ask-user/widget.ts +45 -25
  30. package/extensions/auto-mode/actions.ts +6 -0
  31. package/extensions/auto-mode/cache.ts +88 -0
  32. package/extensions/auto-mode/classifier-prompt.ts +5 -4
  33. package/extensions/auto-mode/classifier.ts +290 -65
  34. package/extensions/auto-mode/config.ts +72 -20
  35. package/extensions/auto-mode/decision-log.ts +2 -0
  36. package/extensions/auto-mode/defaults.ts +1 -1
  37. package/extensions/auto-mode/denials.ts +124 -0
  38. package/extensions/auto-mode/git-checkout-programs.ts +187 -0
  39. package/extensions/auto-mode/git-status-meta.ts +159 -0
  40. package/extensions/auto-mode/history.ts +189 -0
  41. package/extensions/auto-mode/instructions.ts +58 -52
  42. package/extensions/auto-mode/model-select.ts +159 -134
  43. package/extensions/auto-mode/paths.ts +57 -5
  44. package/extensions/auto-mode/pause.ts +57 -2
  45. package/extensions/auto-mode/prompt.ts +37 -21
  46. package/extensions/auto-mode/read-only-options.ts +724 -0
  47. package/extensions/auto-mode/resolved-paths-meta.ts +133 -0
  48. package/extensions/auto-mode/rules.ts +18 -1
  49. package/extensions/auto-mode/safety-floor.ts +361 -18
  50. package/extensions/auto-mode/setup-run.ts +20 -14
  51. package/extensions/auto-mode/shell-analysis.ts +764 -490
  52. package/extensions/auto-mode/shell-parse.ts +885 -0
  53. package/extensions/auto-mode/system-note.ts +33 -0
  54. package/extensions/auto-mode/transcript.ts +80 -51
  55. package/extensions/background/cron.ts +642 -0
  56. package/extensions/background/index.ts +614 -248
  57. package/extensions/background/loop-fire.ts +173 -0
  58. package/extensions/background/loop-preamble.ts +59 -0
  59. package/extensions/background/loop-skill.ts +163 -0
  60. package/extensions/background/monitor-batch.ts +53 -0
  61. package/extensions/background/noop-fold.ts +128 -0
  62. package/extensions/background/switch-guard.ts +56 -0
  63. package/extensions/background/wakeup.ts +178 -55
  64. package/extensions/bash/background.ts +53 -17
  65. package/extensions/bash/child.ts +7 -0
  66. package/extensions/bash/description.ts +188 -0
  67. package/extensions/bash/guards.ts +15 -67
  68. package/extensions/bash/index.ts +66 -35
  69. package/extensions/branding/compaction-keep.ts +63 -0
  70. package/extensions/branding/declined.ts +22 -0
  71. package/extensions/branding/index.ts +133 -9
  72. package/extensions/branding/prompt-editor.ts +53 -6
  73. package/extensions/branding/queued-edit.ts +31 -0
  74. package/extensions/branding/replaced-builtins.ts +68 -0
  75. package/extensions/branding/startup.ts +33 -71
  76. package/extensions/btw/index.ts +250 -78
  77. package/extensions/btw/panel.ts +228 -100
  78. package/extensions/btw/prompt.ts +41 -4
  79. package/extensions/cache-marks/index.ts +42 -0
  80. package/extensions/claude-compat/index.ts +7 -11
  81. package/extensions/claude-context/index.ts +436 -63
  82. package/extensions/compaction/cut.ts +157 -0
  83. package/extensions/compaction/index.ts +219 -75
  84. package/extensions/compaction/kept-thinking.ts +62 -0
  85. package/extensions/compaction/prompt.ts +15 -12
  86. package/extensions/context-budget/budget.ts +16 -2
  87. package/extensions/context-budget/index.ts +35 -15
  88. package/extensions/context-management/index.ts +36 -28
  89. package/extensions/doctor/build.ts +59 -8
  90. package/extensions/doctor/builtins.ts +84 -0
  91. package/extensions/doctor/cli.ts +7 -1
  92. package/extensions/doctor/compat.ts +88 -23
  93. package/extensions/doctor/dependencies.ts +41 -5
  94. package/extensions/doctor/fix-prompt.ts +9 -2
  95. package/extensions/doctor/index.ts +102 -58
  96. package/extensions/doctor/models.ts +102 -63
  97. package/extensions/doctor/presets.ts +42 -27
  98. package/extensions/doctor/report.ts +19 -2
  99. package/extensions/doctor/viewer.ts +49 -27
  100. package/extensions/effort/index.ts +141 -25
  101. package/extensions/effort/settings.ts +21 -0
  102. package/extensions/effort/slider.ts +24 -20
  103. package/extensions/exit/index.ts +32 -1
  104. package/extensions/file-tracker/index.ts +420 -42
  105. package/extensions/file-tracker/replay.ts +137 -24
  106. package/extensions/file-tracker/restore.ts +66 -0
  107. package/extensions/file-tracker/results.ts +66 -0
  108. package/extensions/file-tracker/shell-reads.ts +211 -0
  109. package/extensions/file-tracker/tracker.ts +45 -15
  110. package/extensions/footer/footer-line.ts +45 -24
  111. package/extensions/footer/index.ts +47 -2
  112. package/extensions/footer/pr.ts +34 -5
  113. package/extensions/hooks/compaction.ts +7 -0
  114. package/extensions/hooks/executor.ts +32 -30
  115. package/extensions/hooks/index.ts +209 -58
  116. package/extensions/hooks/matcher.ts +3 -0
  117. package/extensions/hooks/prompt-context.ts +59 -0
  118. package/extensions/hooks/protocol.ts +41 -3
  119. package/extensions/hooks/settings.ts +50 -19
  120. package/extensions/hooks/subagent-bridge.ts +2 -0
  121. package/extensions/hooks/trust.ts +26 -12
  122. package/extensions/idle-compact/index.ts +119 -0
  123. package/extensions/idle-compact/policy.ts +143 -0
  124. package/extensions/init/index.ts +11 -10
  125. package/extensions/init/prompt.ts +25 -32
  126. package/extensions/interrupted/index.ts +13 -1
  127. package/extensions/kept-thinking/index.ts +27 -0
  128. package/extensions/ledger/index.ts +46 -0
  129. package/extensions/ledger/wire.ts +154 -0
  130. package/extensions/lib/agent-cron.ts +146 -0
  131. package/extensions/lib/agent-loader.ts +18 -3
  132. package/extensions/lib/agent-view.ts +85 -0
  133. package/extensions/lib/anthropic-auth.ts +98 -0
  134. package/extensions/lib/anthropic-payload.ts +82 -1
  135. package/extensions/lib/anthropic-server-call.ts +230 -0
  136. package/extensions/lib/anthropic-server-tools.ts +485 -0
  137. package/extensions/lib/app-launch.mjs +94 -0
  138. package/extensions/lib/argument-hints.ts +190 -0
  139. package/extensions/lib/atomic-write.ts +6 -2
  140. package/extensions/lib/auto-memory.ts +75 -0
  141. package/extensions/lib/bash-parser.ts +84 -0
  142. package/extensions/lib/btw-fork.ts +93 -0
  143. package/extensions/lib/child-extensions.ts +52 -0
  144. package/extensions/lib/child-writes.ts +45 -0
  145. package/extensions/lib/claude-context.ts +515 -100
  146. package/extensions/lib/claude-external-includes.ts +68 -0
  147. package/extensions/lib/claude-id.ts +22 -0
  148. package/extensions/lib/claude-rules.ts +323 -0
  149. package/extensions/lib/claude-settings.ts +76 -4
  150. package/extensions/lib/compaction-boundary.ts +36 -0
  151. package/extensions/lib/compaction-keep.mjs +83 -0
  152. package/extensions/lib/config-mode.ts +112 -0
  153. package/extensions/lib/consent-dialogs.ts +63 -0
  154. package/extensions/lib/consent-preview.ts +5 -1
  155. package/extensions/lib/consent-stores.ts +48 -0
  156. package/extensions/lib/context-facts.ts +97 -0
  157. package/extensions/lib/covered-messages.ts +23 -0
  158. package/extensions/lib/deferred.ts +359 -25
  159. package/extensions/lib/dock-rows.ts +60 -0
  160. package/extensions/lib/empty-tool-result.ts +27 -0
  161. package/extensions/lib/environment-block.ts +76 -0
  162. package/extensions/lib/fetch-timeout.ts +24 -6
  163. package/extensions/{system-prompt → lib}/git-status.ts +51 -13
  164. package/extensions/lib/git.ts +75 -13
  165. package/extensions/lib/grammars/LICENSE-tree-sitter-bash +21 -0
  166. package/extensions/lib/grammars/README.md +2 -0
  167. package/extensions/lib/grammars/tree-sitter-bash.wasm +0 -0
  168. package/extensions/lib/guide-docs.ts +45 -0
  169. package/extensions/lib/headless-output.ts +46 -0
  170. package/extensions/lib/hook-gate.ts +7 -7
  171. package/extensions/lib/interrupt.ts +20 -4
  172. package/extensions/lib/key-input.ts +455 -0
  173. package/extensions/lib/keys.ts +14 -1
  174. package/extensions/lib/ledger/fold-memo.ts +49 -0
  175. package/extensions/lib/ledger/fold.ts +265 -0
  176. package/extensions/lib/ledger/layout.ts +72 -0
  177. package/extensions/lib/ledger/plan.ts +102 -0
  178. package/extensions/lib/ledger/records.ts +276 -0
  179. package/extensions/lib/ledger/state.ts +47 -0
  180. package/extensions/lib/ledger/validate.ts +141 -0
  181. package/extensions/lib/ledger/wire.ts +632 -0
  182. package/extensions/lib/local-command.ts +71 -19
  183. package/extensions/lib/mcp-overrides.ts +1 -1
  184. package/extensions/lib/mcp-share.ts +46 -0
  185. package/extensions/lib/memory-content.ts +146 -0
  186. package/extensions/lib/memory-index.ts +18 -0
  187. package/extensions/lib/memory.ts +134 -123
  188. package/extensions/lib/model-catalog/huggingface.json +178 -0
  189. package/extensions/lib/model-catalog/models-dev.json +18533 -0
  190. package/extensions/lib/model-catalog/openrouter.json +5050 -0
  191. package/extensions/lib/model-catalog-data.ts +513 -0
  192. package/extensions/lib/model-catalog.ts +462 -0
  193. package/extensions/lib/model-choice-warnings.ts +137 -0
  194. package/extensions/lib/model-pairings.ts +152 -0
  195. package/extensions/{auto-mode → lib}/model-picker.ts +26 -43
  196. package/extensions/lib/model-policy.ts +112 -15
  197. package/extensions/lib/model-tier.ts +283 -355
  198. package/extensions/lib/model-unusable.ts +1 -1
  199. package/extensions/lib/newer-model.ts +100 -0
  200. package/extensions/lib/notices.ts +87 -0
  201. package/extensions/lib/notifications.ts +357 -73
  202. package/extensions/lib/one-code-settings.ts +74 -8
  203. package/extensions/lib/open-browser.ts +63 -0
  204. package/extensions/lib/openrouter-generation.ts +94 -0
  205. package/extensions/lib/operation-deadline.ts +30 -0
  206. package/extensions/lib/original-command.ts +4 -4
  207. package/extensions/lib/package-version.ts +1 -1
  208. package/extensions/lib/panel-focus.ts +134 -0
  209. package/extensions/lib/paths.ts +17 -2
  210. package/extensions/lib/permission-gate.ts +72 -6
  211. package/extensions/lib/persisted-output.ts +60 -8
  212. package/extensions/lib/pi-ai-estimate.ts +19 -3
  213. package/extensions/lib/pi-version.ts +38 -2
  214. package/extensions/lib/plan-mode-channels.ts +42 -5
  215. package/extensions/lib/plugins.ts +26 -17
  216. package/extensions/lib/powershell-parse-script.ts +208 -0
  217. package/extensions/lib/powershell-parser.ts +344 -0
  218. package/extensions/lib/previous-mark.ts +101 -0
  219. package/extensions/lib/process-state.ts +48 -0
  220. package/extensions/lib/process-tree.ts +132 -8
  221. package/extensions/lib/prompt-options.ts +82 -0
  222. package/extensions/lib/queued-delivery.ts +44 -0
  223. package/extensions/lib/reminders.ts +370 -358
  224. package/extensions/lib/replaced-builtins.mjs +199 -0
  225. package/extensions/lib/replay-call.ts +70 -0
  226. package/extensions/lib/request-replay.ts +547 -0
  227. package/extensions/lib/scratchpad.ts +81 -14
  228. package/extensions/lib/send-now-channel.ts +7 -0
  229. package/extensions/lib/session-model-tier.ts +51 -0
  230. package/extensions/lib/session-work.ts +64 -0
  231. package/extensions/lib/settings-channels.ts +4 -4
  232. package/extensions/lib/shell-exit-semantics.ts +170 -0
  233. package/extensions/lib/shell-quote.ts +11 -0
  234. package/extensions/lib/shell-result.ts +70 -0
  235. package/extensions/lib/shell-spawn.ts +70 -6
  236. package/extensions/lib/shell-tool.ts +75 -25
  237. package/extensions/lib/side-call-cache.ts +124 -0
  238. package/extensions/lib/side-call-run.ts +96 -0
  239. package/extensions/lib/side-call-usage.ts +38 -0
  240. package/extensions/lib/side-call.ts +24 -1
  241. package/extensions/lib/single-flight.ts +18 -0
  242. package/extensions/lib/skill-body.ts +42 -0
  243. package/extensions/lib/skill-scan.ts +63 -24
  244. package/extensions/lib/spill-file.ts +140 -0
  245. package/extensions/lib/subagent-handback.ts +112 -0
  246. package/extensions/lib/system-role.ts +156 -0
  247. package/extensions/lib/terminal-text.ts +114 -0
  248. package/extensions/lib/text-width.ts +47 -16
  249. package/extensions/lib/timer-ops.ts +20 -0
  250. package/extensions/lib/tool-additions.ts +129 -0
  251. package/extensions/lib/tool-call-corruption.ts +134 -0
  252. package/extensions/lib/tool-path.ts +66 -0
  253. package/extensions/lib/tool-variants.ts +69 -0
  254. package/extensions/lib/tui-render.ts +255 -27
  255. package/extensions/lib/turn-budget-layout.ts +159 -0
  256. package/extensions/lib/update-check.mjs +15 -2
  257. package/extensions/lib/usage-bus.ts +1 -1
  258. package/extensions/lib/user-denial.ts +38 -0
  259. package/extensions/lib/workspace-channel.ts +16 -0
  260. package/extensions/lib/worktree-channel.ts +31 -3
  261. package/extensions/lib/worktree-isolation.ts +40 -10
  262. package/extensions/lsp/client.ts +8 -9
  263. package/extensions/lsp/index.ts +76 -12
  264. package/extensions/lsp/keep-alive.ts +1 -1
  265. package/extensions/lsp/trust.ts +119 -0
  266. package/extensions/lsp/watcher.ts +1 -1
  267. package/extensions/mcp/announce.ts +90 -43
  268. package/extensions/mcp/client.ts +161 -48
  269. package/extensions/mcp/config.ts +132 -8
  270. package/extensions/mcp/discovery.ts +19 -0
  271. package/extensions/mcp/index.ts +220 -121
  272. package/extensions/mcp/oauth/browser.ts +38 -17
  273. package/extensions/mcp/oauth/flow.ts +25 -17
  274. package/extensions/mcp/oauth/provider.ts +14 -8
  275. package/extensions/mcp/oauth/store.ts +34 -16
  276. package/extensions/mcp/panel/keys.ts +12 -11
  277. package/extensions/mcp/panel/render.ts +9 -6
  278. package/extensions/mcp/schema.ts +104 -5
  279. package/extensions/mcp/trust.ts +164 -73
  280. package/extensions/memory/entries.ts +23 -14
  281. package/extensions/memory/index.ts +134 -28
  282. package/extensions/memory/open-external.ts +33 -18
  283. package/extensions/memory/panel.ts +135 -26
  284. package/extensions/model-default/index.ts +86 -19
  285. package/extensions/newer-model/index.ts +31 -0
  286. package/extensions/notebook/description.ts +18 -0
  287. package/extensions/notebook/index.ts +6 -5
  288. package/extensions/notebook/notebook.ts +1 -1
  289. package/extensions/permissions/index.ts +1426 -398
  290. package/extensions/permissions/matcher.ts +425 -74
  291. package/extensions/permissions/modes.ts +11 -4
  292. package/extensions/permissions/panel/host.ts +130 -0
  293. package/extensions/permissions/panel/keys.ts +48 -0
  294. package/extensions/permissions/panel/render.ts +450 -0
  295. package/extensions/permissions/panel/state.ts +550 -0
  296. package/extensions/permissions/powershell-cmdlets.ts +198 -0
  297. package/extensions/permissions/powershell-paths.ts +197 -0
  298. package/extensions/permissions/powershell-rules.ts +207 -280
  299. package/extensions/permissions/powershell-tree.ts +557 -0
  300. package/extensions/permissions/project-trust.ts +27 -11
  301. package/extensions/permissions/protected-paths.ts +14 -2
  302. package/extensions/permissions/session-grant.ts +6 -0
  303. package/extensions/permissions/settings.ts +166 -31
  304. package/extensions/permissions/subagent-gate.ts +25 -0
  305. package/extensions/permissions/workspace.ts +78 -0
  306. package/extensions/plan-mode/index.ts +276 -92
  307. package/extensions/plan-mode/reminder.ts +89 -21
  308. package/extensions/plan-mode/run-state.ts +26 -0
  309. package/extensions/plan-mode/texts.ts +177 -0
  310. package/extensions/plan-mode/viewer.ts +92 -27
  311. package/extensions/plugins/counts.ts +3 -3
  312. package/extensions/plugins/index.ts +64 -46
  313. package/extensions/plugins/install/install.ts +16 -0
  314. package/extensions/plugins/marketplace/sync.ts +9 -3
  315. package/extensions/plugins/marketplace/types.ts +3 -1
  316. package/extensions/plugins/panel/keys.ts +21 -24
  317. package/extensions/plugins/panel/render.ts +12 -6
  318. package/extensions/plugins/shell-expand.ts +107 -0
  319. package/extensions/plugins/template.ts +32 -15
  320. package/extensions/powershell/child.ts +7 -0
  321. package/extensions/powershell/description.ts +38 -6
  322. package/extensions/powershell/guards.ts +1 -1
  323. package/extensions/powershell/index.ts +14 -7
  324. package/extensions/recap/index.ts +73 -71
  325. package/extensions/recap/prompt.ts +2 -2
  326. package/extensions/recap/scheduler.ts +3 -5
  327. package/extensions/search-tools/grep-merge.ts +39 -0
  328. package/extensions/search-tools/index.ts +0 -0
  329. package/extensions/send-now/chord.ts +62 -0
  330. package/extensions/send-now/index.ts +133 -0
  331. package/extensions/session-title/index.ts +3 -0
  332. package/extensions/session-title/title.ts +4 -1
  333. package/extensions/skill/announce.ts +30 -0
  334. package/extensions/skill/code-review.ts +97 -0
  335. package/extensions/skill/index.ts +469 -132
  336. package/extensions/skill/invoke.ts +118 -75
  337. package/extensions/skill/listing.ts +133 -9
  338. package/extensions/skill/panel/keys.ts +15 -16
  339. package/extensions/skill/panel/render.ts +5 -2
  340. package/extensions/skill/panel/state.ts +12 -1
  341. package/extensions/subagents/agent-args.ts +39 -0
  342. package/extensions/subagents/agent-description.ts +132 -0
  343. package/extensions/subagents/agents.ts +146 -31
  344. package/extensions/subagents/default-model.ts +2 -3
  345. package/extensions/subagents/delegation-steer.ts +4 -4
  346. package/extensions/subagents/fork-cache.ts +82 -0
  347. package/extensions/subagents/guide-agent.ts +189 -0
  348. package/extensions/subagents/hand-back-review.ts +3 -0
  349. package/extensions/subagents/index.ts +1594 -852
  350. package/extensions/subagents/live-runs.ts +26 -6
  351. package/extensions/subagents/model-select.ts +38 -25
  352. package/extensions/subagents/outcome.ts +5 -2
  353. package/extensions/subagents/panel-keys.ts +73 -31
  354. package/extensions/subagents/panel-render.ts +120 -96
  355. package/extensions/subagents/panel-widget.ts +60 -89
  356. package/extensions/subagents/parent-mode.ts +50 -0
  357. package/extensions/subagents/pending-claim.ts +18 -1
  358. package/extensions/subagents/runner.ts +135 -80
  359. package/extensions/subagents/runs.ts +44 -5
  360. package/extensions/subagents/send-to-main-tool.ts +1 -1
  361. package/extensions/subagents/session-turns.ts +32 -8
  362. package/extensions/subagents/shell-panel.ts +36 -178
  363. package/extensions/subagents/tasks-dialog.ts +170 -0
  364. package/extensions/subagents/viewport-mirror.ts +95 -0
  365. package/extensions/subagents/worktree.ts +57 -13
  366. package/extensions/system-prompt/environment.ts +19 -19
  367. package/extensions/system-prompt/idle-turn.ts +65 -0
  368. package/extensions/system-prompt/index.ts +165 -47
  369. package/extensions/system-prompt/template.ts +34 -51
  370. package/extensions/system-prompt/tiers/common.ts +23 -64
  371. package/extensions/system-prompt/tiers/frontier.ts +32 -11
  372. package/extensions/system-prompt/tiers/low.ts +67 -45
  373. package/extensions/system-prompt/tiers/mid.ts +96 -38
  374. package/extensions/system-reminder/index.ts +204 -70
  375. package/extensions/tasks/descriptions.ts +190 -0
  376. package/extensions/tasks/index.ts +120 -41
  377. package/extensions/tasks/store.ts +83 -16
  378. package/extensions/tool-call-corruption/child.ts +7 -0
  379. package/extensions/tool-call-corruption/index.ts +237 -0
  380. package/extensions/tool-search/index.ts +366 -144
  381. package/extensions/tool-style/index.ts +68 -10
  382. package/extensions/tool-style/write-description.ts +40 -0
  383. package/extensions/tool-style/write-render.ts +237 -0
  384. package/extensions/turn-duration/index.ts +7 -3
  385. package/extensions/turn-duration/line.ts +47 -4
  386. package/extensions/web/backends.ts +6 -9
  387. package/extensions/web/description.ts +50 -0
  388. package/extensions/web/index.ts +118 -53
  389. package/extensions/web/native-call.ts +15 -0
  390. package/extensions/web/native-results.ts +41 -0
  391. package/extensions/web-fetch/description.ts +42 -0
  392. package/extensions/web-fetch/extract.ts +53 -6
  393. package/extensions/web-fetch/index.ts +125 -76
  394. package/extensions/web-fetch/page-content.ts +19 -0
  395. package/extensions/web-fetch/response.ts +49 -0
  396. package/extensions/web-fetch/summarize.ts +9 -10
  397. package/extensions/workflow/agent-session.ts +108 -18
  398. package/extensions/workflow/args.ts +24 -0
  399. package/extensions/workflow/description.ts +67 -0
  400. package/extensions/workflow/globals.ts +17 -3
  401. package/extensions/workflow/index.ts +157 -162
  402. package/extensions/workflow/records.ts +1 -0
  403. package/extensions/workflow/run-manager.ts +33 -6
  404. package/extensions/workflow/saved-workflows.ts +3 -2
  405. package/extensions/workflow/types.ts +22 -1
  406. package/extensions/workflow/viewer.ts +54 -24
  407. package/extensions/workflow/widget.ts +20 -0
  408. package/extensions/worktree/descriptions.ts +91 -0
  409. package/extensions/worktree/guards.ts +316 -54
  410. package/extensions/worktree/index.ts +162 -44
  411. package/extensions/worktree/powershell-guards.ts +167 -0
  412. package/extensions/worktree/rewrite.ts +14 -16
  413. package/package.json +21 -7
  414. package/skills/artifact-design/SKILL.md +85 -0
  415. package/skills/code-review/SKILL.md +20 -20
  416. package/skills/code-review/cells/arguments-and-flags.md +19 -0
  417. package/skills/code-review/cells/high-3x5-recall-verify.md +137 -0
  418. package/skills/code-review/cells/high-8-inline.md +122 -0
  419. package/skills/code-review/cells/low-1-pass-max4.md +27 -0
  420. package/skills/code-review/cells/low-1-pass-min-files-4.md +28 -0
  421. package/skills/code-review/cells/max-5x5-verify-sweep.md +162 -0
  422. package/skills/code-review/cells/medium-3x5-verify.md +132 -0
  423. package/skills/code-review/cells/minimal-prompt.md +32 -0
  424. package/skills/code-review/cells/xhigh-10-inline-sweep.md +149 -0
  425. package/skills/code-review/cells/xhigh-5x5-verify-sweep.md +162 -0
  426. package/skills/fewer-permission-prompts/SKILL.md +1 -1
  427. package/skills/loop/SKILL.md +11 -0
  428. package/skills/security-review/SKILL.md +2 -5
  429. package/skills/simplify/SKILL.md +7 -5
  430. package/skills/workflow-authoring/SKILL.md +143 -0
  431. package/themes/onecode-light.json +1 -1
  432. package/themes/onecode.json +1 -1
  433. package/types/app.d.ts +58 -0
  434. package/types/community-packages.d.ts +26 -1
  435. package/extensions/auto-mode/recoverability.ts +0 -229
  436. package/extensions/lib/capability-index.ts +0 -466
  437. package/extensions/lib/model-facts.generated.json +0 -2675
  438. package/extensions/lib/model-facts.ts +0 -162
  439. package/extensions/tool-search/announce.ts +0 -42
@@ -22,14 +22,20 @@ import { join } from "node:path";
22
22
  import type { Api, Model } from "@earendil-works/pi-ai";
23
23
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
24
24
  import { type ChildAction, SUBAGENT_ACTIONS_CHANNEL, type SubagentActionsPayload } from "../auto-mode/actions.ts";
25
- import type { HandBackVerdict } from "../lib/notifications.ts";
25
+ import { guideDocsDirs } from "../lib/guide-docs.ts";
26
+ import { TURN_FAILED_CHANNEL, type TurnFailedEvent } from "../lib/interrupt.ts";
27
+ import { handBackWarning, type HandBackVerdict } from "../lib/notifications.ts";
26
28
  import { MODEL_UNUSABLE_CHANNEL, type ModelUnusableEvent } from "../lib/model-unusable.ts";
27
29
 
28
- import { classify, createClassifierState } from "../auto-mode/classifier.ts";
30
+ import { classifierCacheKey, classifierChain, classify, createClassifierState, releaseClassifierResources } from "../auto-mode/classifier.ts";
29
31
  import {
30
32
  type AutoModeConfig,
31
33
  autoModeSettingsPaths,
34
+ listAutoModeEntries,
32
35
  loadAutoModeConfig,
36
+ oneCodeEnvironmentExtendsDefault,
37
+ type SourcedAutoModeEntry,
38
+ updateOneCodeAutoModeList,
33
39
  claudeUserPermissionAllow,
34
40
  loadAutoModeConfigWithDiagnostics,
35
41
  oneCodePermissionAllow,
@@ -37,28 +43,43 @@ import {
37
43
  persistClassifierModel,
38
44
  removeOneCodePermissionAllow,
39
45
  } from "../auto-mode/config.ts";
46
+ import { modelPickerComponent, type PickerEntry, pickerSpec, toPickerEntries } from "../lib/model-picker.ts";
47
+ import { findConfigured, modelIdentity } from "../lib/model-policy.ts";
48
+ import { CLASSIFIER_SETTING_CHANGED_CHANNEL } from "../lib/settings-channels.ts";
49
+ import { readSuggestNewerModels } from "../lib/one-code-settings.ts";
40
50
  import { auditPermissionAllow, renderProposal, settingsPatch } from "../auto-mode/setup.ts";
41
51
  import { draftSetup, gatherFacts } from "../auto-mode/setup-run.ts";
42
- import { modelPickerComponent, type PickerEntry, pickerSpec, toPickerEntries } from "../auto-mode/model-picker.ts";
43
52
  import { DEFAULT_ENVIRONMENT } from "../auto-mode/defaults.ts";
53
+ import { buildRuleset } from "../auto-mode/classifier-prompt.ts";
44
54
  import type { TranscriptEntry } from "../auto-mode/transcript.ts";
55
+ import { childHistoryCursor, classifierHistory, CLASSIFIER_TOOL_META, CLASSIFIER_USER_INPUT, type HistoryCursor, messageText, userMessageDigest } from "../auto-mode/history.ts";
45
56
  import { appendDecision, type DecisionEntry, decisionEntry } from "../auto-mode/decision-log.ts";
46
57
  import { loadProjectInstructions } from "../auto-mode/instructions.ts";
47
- import { classifierCandidates, describeCandidate, findConfigured } from "../auto-mode/model-select.ts";
48
- import { modelIdentity } from "../lib/model-policy.ts";
49
- import { isWithin, resolveForContainment, toAbsolute } from "../auto-mode/paths.ts";
50
- import { PauseTracker } from "../auto-mode/pause.ts";
51
- import { checkRecoverability } from "../auto-mode/recoverability.ts";
52
- import { safetyControlWrite } from "../auto-mode/safety-floor.ts";
58
+ import { describeCandidate } from "../auto-mode/model-select.ts";
59
+ import { conflictingPathArguments, isWithin, resolveForContainment, toAbsolute } from "../auto-mode/paths.ts";
60
+ import { DenialStore, denialInputKey, permissionGrantedMessage } from "../auto-mode/denials.ts";
61
+ import { PauseTracker, unattendedPromptNotice, unattendedPromptTimeoutMs } from "../auto-mode/pause.ts";
62
+ import { safetyControlWrite, safetyFloorBlockText } from "../auto-mode/safety-floor.ts";
53
63
  import { isExecutionPrimitivePath, isSensitivePath } from "../auto-mode/sensitive.ts";
54
- import { analyzeShellCommand, type ShellEvidence } from "../auto-mode/shell-analysis.ts";
55
- import { isGitStatusCommand, powershellReadOnly } from "./powershell-rules.ts";
64
+ import { analyzeShellCommand } from "../auto-mode/shell-analysis.ts";
65
+ import { bashParserReady, bashParserUnavailable } from "../lib/bash-parser.ts";
66
+ import type { PowerShellParse } from "../lib/powershell-parser.ts";
67
+ import { powerShellParser } from "../lib/shell-spawn.ts";
68
+ import { powershellReadOnly } from "./powershell-rules.ts";
56
69
  import { isShellTool } from "./matcher.ts";
57
- import { gitStatusClean } from "../lib/git.ts";
70
+ import { gitStatusOutput } from "../lib/git.ts";
71
+ import { gitStatusMeta, gitStatusMetaArgs, reachesIgnoredFiles, wantsGitStatusMeta } from "../auto-mode/git-status-meta.ts";
72
+ import { actionResolvedPaths } from "../auto-mode/resolved-paths-meta.ts";
73
+ import { autoMemoryEnabled } from "../lib/auto-memory.ts";
58
74
  import { projectMemoryDir } from "../lib/memory.ts";
75
+ import { queueNotice } from "../lib/notices.ts";
76
+ import { canShowCustomUi, rpcReadOnlyText } from "../lib/headless-output.ts";
59
77
  import { sessionResultsDir } from "../lib/persisted-output.ts";
60
- import { sessionScratchpadDir } from "../lib/scratchpad.ts";
61
- import { REMINDER_CHANNEL } from "../lib/reminders.ts";
78
+ import { privateSessionScratchpadDir } from "../lib/scratchpad.ts";
79
+ import { CONTEXT_ORDER, REMINDER_CHANNEL, type ReminderPayload } from "../lib/reminders.ts";
80
+ import { readState, readStringsState, recordState } from "../lib/ledger/state.ts";
81
+ import { processState } from "../lib/process-state.ts";
82
+ import { type EntryLike, readRecord } from "../lib/ledger/records.ts";
62
83
  import {
63
84
  decide,
64
85
  extractSubject,
@@ -70,23 +91,65 @@ import {
70
91
  isPathSubjectTool,
71
92
  } from "./matcher.ts";
72
93
  import { type SessionGrant, sessionGrant } from "./session-grant.ts";
73
- import { modeBadge, nextMode, PERMISSION_STATUS_CHANNEL, type PermissionStatus, CYCLE_KEY } from "./modes.ts";
74
- import { type ChildToolCall, type ChildGateDecision, SUBAGENT_GATE_CHANNEL } from "./subagent-gate.ts";
94
+ import { formatModel, modeBadge, nextMode, PERMISSION_MODE_STATE, PERMISSION_STATUS_CHANNEL, type PermissionStatus, CYCLE_KEY } from "./modes.ts";
95
+ import { intrinsicTier, usesClaudeCodeFastPaths } from "../lib/model-tier.ts";
96
+ import { type ChildToolCall, type ChildGateDecision, sessionPermissionBridge, SUBAGENT_GATE_CHANNEL } from "./subagent-gate.ts";
75
97
  import { trackOriginalCommands } from "../lib/original-command.ts";
98
+ import { followWorkCwd } from "../lib/worktree-channel.ts";
76
99
  import { MODE_CHANNEL, PLAN_FILE_CHANNEL } from "../lib/plan-mode-channels.ts";
77
100
  import { isWritingTool } from "./protected-paths.ts";
78
101
  import { denyRuleLines } from "./rule-prose.ts";
79
- import { loadPermissionSettings, normalizePermissionMode, persistAllowRule, resolveStartupMode } from "./settings.ts";
80
- import { MODE_ENV, resolvedOrSelf, runtimeProtectedDirs } from "../lib/permission-gate.ts";
81
- import { CLASSIFIER_SETTING_CHANGED_CHANNEL } from "../lib/settings-channels.ts";
82
- import { describeProjectAllow, persistProjectAllowApproval, projectAllowApproved } from "./project-trust.ts";
102
+ import {
103
+ listPermissionRules,
104
+ listWorkspaceDirectories,
105
+ loadPermissionSettings,
106
+ markOutsideReadPromptSeen,
107
+ normalizePermissionMode,
108
+ outsideReadPromptSeen,
109
+ persistBlockOutsideReads,
110
+ persistAllowRule,
111
+ persistPermissionRule,
112
+ removePermissionRule,
113
+ resolveStartupMode,
114
+ type RuleSource,
115
+ persistWorkspaceDirectory,
116
+ removeWorkspaceDirectory,
117
+ type SourcedDirectory,
118
+ type SourcedRule,
119
+ } from "./settings.ts";
120
+ import { MODE_ENV, parsePowerShell, resolvedOrSelf, runtimeProtectedDirs, runtimeSecretPaths } from "../lib/permission-gate.ts";
121
+ import { describeProjectAllow, persistProjectAllowApproval, projectAllowApproved, projectDirectoryConsentEntry, type TrustFiring } from "./project-trust.ts";
122
+ import { parseAddDirFlag, tooBroadForWorkspace, validateWorkspaceDirectory } from "./workspace.ts";
123
+ import { WORKSPACE_CHANNEL, type WorkspaceAnnouncement } from "../lib/workspace-channel.ts";
83
124
  import { findProjectRoot } from "../lib/git.ts";
84
125
  import { oneCodeProjectSettingsPath, oneCodeSettingsPath } from "../lib/one-code-settings.ts";
85
126
  import { recordUsage } from "../lib/usage-bus.ts";
86
- import { registerLocalCommand } from "../lib/local-command.ts";
127
+ import { announceLocalCommand, registerLocalCommand } from "../lib/local-command.ts";
128
+ import { claudeSourcesOn } from "../lib/config-mode.ts";
129
+ import { claudeJsonPath, tildify, tryRealpath } from "../lib/paths.ts";
130
+ import { openPermissionsPanel, type PermissionsPanelHost } from "./panel/host.ts";
131
+ import { PANEL_FOCUS_CHANNEL, PanelFocus, panelUi } from "../lib/panel-focus.ts";
132
+ import {
133
+ AUTO_SECTION_LABELS,
134
+ AUTO_SECTIONS,
135
+ type AutoEntryRow,
136
+ type AutoModeView,
137
+ type Destination,
138
+ type PanelState,
139
+ RULE_TABS,
140
+ type RuleRow,
141
+ type RuleTab,
142
+ type WorkspaceDirRow,
143
+ } from "./panel/state.ts";
144
+ import { builtinRuleCounts } from "../auto-mode/rules.ts";
145
+ import { autoModeSystemNote } from "../auto-mode/system-note.ts";
146
+ import { announceArgumentHint } from "../lib/argument-hints.ts";
147
+ import { userDenialText } from "../lib/user-denial.ts";
148
+ import { liveUiCtx } from "../lib/tui-render.ts";
149
+
150
+ /** Shown when a permission prompt is held behind an open panel (serializePrompt). */
151
+ const PROMPT_WAITS_FOR_PANEL = "A permission request is waiting. Close this panel to answer it.";
87
152
 
88
- const DENIED_BY_USER =
89
- "The user doesn't want to proceed with this tool use. The tool use was rejected. Adjust your approach based on the user's feedback instead of retrying the same call.";
90
153
  const DENIED_NON_INTERACTIVE =
91
154
  "Permission required but this session is non-interactive, so the user cannot approve the call. It was blocked. Only pre-approved tools can run here; work within those, or ask the user to re-run interactively or with an allow rule / --dangerously-skip-permissions.";
92
155
  // Must agree with the plan-mode reminder (plan-mode/reminder.ts): the plan is
@@ -99,6 +162,26 @@ const DENIED_DONT_ASK =
99
162
  "Permission mode is dontAsk: anything that would normally prompt the user is denied instead. Only pre-approved tools can run; work within those, or tell the user which allow rule would unblock you.";
100
163
  const DENIED_PROTECTED_PATH =
101
164
  "That path is protected: it configures the user's tooling or this agent itself, so writes to it are never auto-approved and allow rules do not cover them. Achieve the goal another way, or ask the user to make the change.";
165
+ /** A timed resume prompt nobody answered (Claude Code's denial-limit fallback timing). */
166
+ const DENIED_UNANSWERED_RESUME =
167
+ "Auto mode is paused after repeated blocks, and nobody answered the approval prompt for this call in time, so it was denied to avoid blocking an unattended session. Do not retry it; continue with work that does not need it, or stop and wait for the user.";
168
+ /** Claude Code's refusal when `blockReadsOutsideWorkingDirectories` is set. */
169
+ const DENIED_BLOCKED_OUTSIDE_READ =
170
+ "That path is outside the working directories; the permissions.blockReadsOutsideWorkingDirectories setting blocks reads outside the working directories. Ask the user to add the directory with /add-dir, or to remove that setting.";
171
+ /** Claude Code's refusal right after the user picks "block" in the first-read prompt. */
172
+ const DENIED_CHOSE_BLOCK_OUTSIDE_READS =
173
+ "The user chose to block reads outside the working directories (permissions.blockReadsOutsideWorkingDirectories). Ask the user to add the directory with /add-dir, or to remove that setting.";
174
+ /** Claude Code's first outside-read prompt in auto mode (2.1.282): title, question, answers. */
175
+ const OUTSIDE_READ_TITLE = "Read outside the working directories";
176
+ const OUTSIDE_READ_QUESTION = "Allow reads outside the working directories?";
177
+ const OUTSIDE_READ_ANSWERS = {
178
+ allow: "Yes, keep allowing reads outside the working directories",
179
+ block: "No, block reads outside the working directories from now on",
180
+ ask_again: "No, ask again next time",
181
+ } as const;
182
+ /** Claude Code's explainer, without its sandbox sentences (One Code has no sandbox) and with One Code's settings file. */
183
+ const OUTSIDE_READ_EXPLAINER = (settingsFile: string) =>
184
+ `Auto mode reads outside the working directories without asking. Yes or Block settles this question; Ask again asks on the next outside read. Block: the file tools refuse reads outside the working directories in every project. To undo, remove permissions.blockReadsOutsideWorkingDirectories from ${settingsFile}.`;
102
185
  const DENIED_OUTSIDE_WORKING_DIR =
103
186
  "That path is outside the working directory, which needs the user's approval, and permission mode is dontAsk (anything that would prompt is denied instead). Work inside the project, or tell the user which allow rule (e.g. Read(~/dir/**)) would unblock you.";
104
187
  // Returned to the MODEL, not the user: auto mode exists to run unattended, so a
@@ -111,9 +194,21 @@ const BLOCKED_BY_TIMEOUT = (reason: string) =>
111
194
  `${reason}\n\nThis was not a judgement that the call is unsafe. Wait a moment and try this action again. ` +
112
195
  "If it keeps failing, continue with other tasks that do not require this action and come back to it later — " +
113
196
  "reading files, searching code, and other read-only operations do not require the classifier and can still be used. " +
114
- "Do not try to route around the gate; if this specific action is essential, tell the user what you were about to do so they can re-run interactively or pin a faster classifier with /auto-mode model.";
115
- const DENIED_SAFETY_FLOOR = (reason: string) =>
116
- `Auto mode blocked this call without consulting the classifier: ${reason}. Writes to the gate's own configuration are never auto-approved. Do not retry or route around this; ask the user to make the change themselves.`;
197
+ "Do not try to route around the gate; if this specific action is essential, tell the user what you were about to do so they can re-run interactively or switch the session model.";
198
+ // The classifier ran but gave no verdict (a provider refusal or error, a
199
+ // truncated reply, no usable model): not a denial, and nothing to route around.
200
+ const BLOCKED_NOT_JUDGED = (reason: string) =>
201
+ `${reason}\n\nThis was not a judgement that the call is unsafe: the approval classifier gave no verdict, so the call did not run. ` +
202
+ "Do not rephrase it or route around the gate. Continue with other tasks that do not require this action — " +
203
+ "reading files, searching code, and other read-only operations do not require the classifier. " +
204
+ "If this action is essential, tell the user what you were about to do so they can re-run interactively or set another classifier model.";
205
+ /** The text a classifier block hands the model: a timeout, a call never judged, or a denial. */
206
+ const classifierBlockReason = (outcome: { tier?: string; noVerdict?: boolean; reason: string }): string =>
207
+ outcome.tier === "timeout"
208
+ ? BLOCKED_BY_TIMEOUT(outcome.reason)
209
+ : outcome.noVerdict
210
+ ? BLOCKED_NOT_JUDGED(outcome.reason)
211
+ : DENIED_BY_CLASSIFIER(outcome.reason);
117
212
  // "Choose a different approach" is exactly what a literal reader does with a
118
213
  // second command of the same effect: a tiny-tier model met `Bash(rm:*)` and
119
214
  // deleted the file with `python3 -c "import os; os.remove(…)"` 3/3 times
@@ -123,6 +218,11 @@ const DENIED_SAFETY_FLOOR = (reason: string) =>
123
218
  const DENIED_BY_RULE = (rule: string) =>
124
219
  `This tool call is denied by the permission rule "${rule}" in the user's settings. The rule is the user's standing decision about this class of action: do not retry it, and do not achieve the same effect another way (a different command, a script, or another tool). Continue with work that does not depend on it, and tell the user what was denied and by which rule.`;
125
220
 
221
+ /** The hidden message a /permissions retry starts its turn with. */
222
+ const PERMISSION_RETRY_TYPE = "one-code:permission-retry";
223
+ /** Claude Code's denial notification: the tool, the reason cut to 80 columns, and where to act on it. */
224
+ const deniedNotice = (toolName: string, reason: string): string =>
225
+ `${toolName} denied by auto mode · ${reason.length > 80 ? `${reason.slice(0, 79)}…` : reason} · /permissions`;
126
226
  /** Truncate a subject for a permission prompt — shared by the main gate and the subagent bridge. */
127
227
  const previewSubject = (subject: string) => (subject.length > 200 ? `${subject.slice(0, 200)}…` : subject);
128
228
 
@@ -140,16 +240,22 @@ const askTitle = (actor: string, preview: string, cause: string, pausedResume: b
140
240
  };
141
241
 
142
242
  /** Map a `decide()` deny result to its model-facing reason — shared by both gate paths. */
143
- const denyReason = (result: { cause?: string; rule?: { raw?: string } }): string =>
144
- result.cause === "plan-mode"
145
- ? DENIED_PLAN_MODE
146
- : result.cause === "protected-path"
147
- ? DENIED_PROTECTED_PATH
148
- : result.cause === "working-dir"
149
- ? DENIED_OUTSIDE_WORKING_DIR
150
- : result.cause === "mode"
151
- ? DENIED_DONT_ASK
152
- : DENIED_BY_RULE(result.rule?.raw ?? "deny");
243
+ const denyReason = (result: { cause?: string; rule?: { raw?: string } }): string => {
244
+ switch (result.cause) {
245
+ case "plan-mode":
246
+ return DENIED_PLAN_MODE;
247
+ case "protected-path":
248
+ return DENIED_PROTECTED_PATH;
249
+ case "blocked-outside-read":
250
+ return DENIED_BLOCKED_OUTSIDE_READ;
251
+ case "working-dir":
252
+ return DENIED_OUTSIDE_WORKING_DIR;
253
+ case "mode":
254
+ return DENIED_DONT_ASK;
255
+ default:
256
+ return DENIED_BY_RULE(result.rule?.raw ?? "deny");
257
+ }
258
+ };
153
259
 
154
260
  /** Ask-prompt option labels — shared by both gate paths. */
155
261
  /**
@@ -160,6 +266,24 @@ const denyReason = (result: { cause?: string; rule?: { raw?: string } }): string
160
266
  * here, next to the emitter it belongs to.
161
267
  */
162
268
  const STANDING_REMINDER_MODES: ReadonlySet<PermissionMode> = new Set<PermissionMode>(["plan", "auto"]);
269
+ /** The `one-code:state` key for the directories added to the workspace for this session. */
270
+ const WORKSPACE_DIRS_STATE = "workspace-dirs";
271
+
272
+ /**
273
+ * What the user granted "for this session" and what auto mode's loop-breaker
274
+ * counted. This is process state (lib/process-state.ts): pi re-runs this
275
+ * factory at a /reload, which is the same conversation and keeps them
276
+ * (decisions/modes.md), and every other session start clears them. Only the
277
+ * main session loads this extension (children reach it through the bridge),
278
+ * so one slot per process is one per conversation.
279
+ */
280
+ interface SessionGate {
281
+ /** Rules minted by "Yes, and don't ask again … this session" (session-grant.ts). */
282
+ allows: PermissionRule[];
283
+ pause: PauseTracker;
284
+ /** Classifier denials, and the one-shot grants `/permissions` mints from them. */
285
+ denials: DenialStore;
286
+ }
163
287
 
164
288
  const YES = "Yes";
165
289
  const NO = "No, tell the agent what to do differently";
@@ -180,6 +304,10 @@ export default function permissionsExtension(pi: ExtensionAPI) {
180
304
  "Permission mode: default (alias: manual) | acceptEdits | plan | auto | bypassPermissions | dontAsk",
181
305
  type: "string",
182
306
  });
307
+ pi.registerFlag("add-dir", {
308
+ description: `Additional workspace directories: readable without a prompt, writable in acceptEdits (separate several with "${process.platform === "win32" ? ";" : ":"}")`,
309
+ type: "string",
310
+ });
183
311
  pi.registerFlag("dangerously-skip-permissions", {
184
312
  description: "Skip all permission prompts (Claude Code compatible)",
185
313
  type: "boolean",
@@ -197,19 +325,32 @@ export default function permissionsExtension(pi: ExtensionAPI) {
197
325
  // an OS sandbox for outside-cwd reads; One Code flips the default directly and
198
326
  // has no sandbox yet (reads outside the working directory are still
199
327
  // classified/asked, never auto-read). The auto-mode safety architecture still
200
- // holds — the deterministic safety floor, classifier-verdicts-verified, the
201
- // git-recoverability gate, and the classifier tier floor — and with no
328
+ // holds — the deterministic safety floor, classifier-verdicts-verified and
329
+ // the classifier tier floor — and with no
202
330
  // classifier model reachable the classifier fails closed. A user or project
203
331
  // `defaultMode` (or `--permission-mode`) still overrides this; `auto` from a
204
- // project file is still refused. See docs/decisions/auto-mode.md.
332
+ // project file is still refused. See working-docs/decisions/auto-mode.md.
205
333
  let mode: PermissionMode = "auto";
334
+ /** Whether the session model gets Claude Code's fast paths, for the model-switch notice. */
335
+ let gateFastPaths: boolean | undefined;
336
+ /** `permissions.blockReadsOutsideWorkingDirectories` from any settings source. */
337
+ let blockOutsideReads = false;
338
+ /** The first outside-read prompt was answered on this machine (read once, then kept). */
339
+ let outsideReadSeen: boolean | undefined;
206
340
  // Worktree-wrapped bash calls publish the model's original command here,
207
341
  // keyed by pi's toolCallId (never read from `event.input` — model-writable).
208
342
  const originalCommands = trackOriginalCommands(pi);
343
+ // Worktree restores publish before this extension's session_start. Keep
344
+ // that state until worktree publishes its next enter, exit or branch reset.
345
+ const workCwd = followWorkCwd(pi.events);
209
346
  /** Whether bypassPermissions is a stop on the cycle — only when the session started with it (Claude Code semantics). */
210
347
  let bypassInCycle = false;
211
348
  /** Whether auto mode is a stop on the cycle — only when a classifier model is reachable. */
212
349
  let autoInCycle = false;
350
+ /** The mode the branch's latest `one-code:state` entry holds: the mode follows the branch. */
351
+ let branchMode: PermissionMode | undefined;
352
+ /** Whether a request went out on this branch: only then was its model told the branch's mode. */
353
+ let requestOnBranch = false;
213
354
  let deny: PermissionRule[] = [];
214
355
  let ask: PermissionRule[] = [];
215
356
  let allow: PermissionRule[] = [];
@@ -227,12 +368,14 @@ export default function permissionsExtension(pi: ExtensionAPI) {
227
368
  * Rules minted by "Yes, and don't ask again … this session" (session-grant.ts).
228
369
  * Never applied in auto mode: there the classifier is the boundary and a
229
370
  * standing grant would be a bypass of it (PERMISSIONS-REVIEW-2026-09-05 M2).
230
- * Cleared with the session (L1).
371
+ * Cleared with the session (L1), kept by a reload (SessionGate).
231
372
  */
232
- const sessionAllows: PermissionRule[] = [];
373
+ const sessionGate = processState<SessionGate>("permissions:session-gate", () => ({ allows: [], pause: new PauseTracker(), denials: new DenialStore() }));
374
+ const sessionAllows = sessionGate.allows;
233
375
  const activeSessionAllows = () => (mode === "auto" ? [] : sessionAllows);
234
376
  let unparsableRules: string[] = [];
235
377
  let warnedUnparsable = "";
378
+ let warnedTooBroad = "";
236
379
  /** Plan mode's one writable file, announced by the plan-mode extension. */
237
380
  let planFilePath: string | undefined;
238
381
  /**
@@ -258,6 +401,69 @@ export default function permissionsExtension(pi: ExtensionAPI) {
258
401
  let oneCodeProjectSettingsFile: string | undefined;
259
402
  /** pi's own agent directory, protected like the static list (lib/permission-gate.ts runtimeProtectedDirs). */
260
403
  let protectedDirs: string[] = [];
404
+ /** The harness's own secret stores, never read unclassified outside the working space (runtimeSecretPaths). */
405
+ let secretPaths: string[] = [];
406
+ /** The harness's readable session dirs, resolved at session start; `readableRoots` adds the workspace to them. */
407
+ let harnessReadableRoots: string[] = [];
408
+ /** The shipped docs the `one-code-guide` agent reads (lib/guide-docs.ts): readable, never writable. */
409
+ let docsDirPaths: string[] = [];
410
+ /**
411
+ * Workspace directories (workspace.ts): from settings, where the
412
+ * repository's own files apply only once the user trusts them, from
413
+ * `--add-dir`, and added for this session in /permissions or with /add-dir.
414
+ */
415
+ let settingsWorkspaceDirs: SourcedDirectory[] = [];
416
+ let flagWorkspaceDirs: string[] = [];
417
+ /**
418
+ * Added for this session. This is branch state: the /add-dir breadcrumb told
419
+ * the branch's model they are in its workspace. Recorded on every change and
420
+ * read back, validated again, at every session start and /tree switch.
421
+ */
422
+ const sessionWorkspaceDirs: string[] = [];
423
+ /** The workspace directories in force, as real paths: what the user and the system prompt see. */
424
+ let workspacePaths: string[] = [];
425
+ /** The same directories in containment's comparison form: what decide() and both shell pre-gates see. */
426
+ let workspaceDirs: string[] = [];
427
+ const isRepoSource = (source: RuleSource) => source === "project" || source === "project-local";
428
+ const repoWorkspaceDirs = () => settingsWorkspaceDirs.filter((dir) => isRepoSource(dir.source));
429
+ /** What the repository-trust consent covers: its allow rules and its workspace directories. */
430
+ const projectTrustList = () => [...projectAllowRaw, ...repoWorkspaceDirs().map((dir) => projectDirectoryConsentEntry(dir.raw))];
431
+ const refreshWorkspace = () => {
432
+ const trusted = settingsWorkspaceDirs.filter((dir) => projectAllowTrusted || !isRepoSource(dir.source)).map((dir) => dir.path);
433
+ workspacePaths = [...new Set([...trusted, ...flagWorkspaceDirs, ...sessionWorkspaceDirs].map((dir) => tryRealpath(dir) ?? dir))];
434
+ workspaceDirs = [...new Set(workspacePaths.map(resolvedOrSelf))];
435
+ readableRoots = [...harnessReadableRoots, ...docsDirPaths, ...workspaceDirs];
436
+ if (sessionStarted) announceWorkspace();
437
+ };
438
+ /** What the system prompt was last told the workspace is (lib/workspace-channel.ts). */
439
+ let announcedWorkspace: string | undefined;
440
+ let sessionStarted = false;
441
+ /**
442
+ * The environment block lists the workspace: a change mid-session reaches
443
+ * the next epoch's message 1 (a compaction), while inside the epoch the
444
+ * /add-dir output already told the model (R3-L4).
445
+ */
446
+ const announceWorkspace = () => {
447
+ const key = workspacePaths.join("\0");
448
+ if (key === announcedWorkspace) return;
449
+ announcedWorkspace = key;
450
+ pi.events.emit(WORKSPACE_CHANNEL, { dirs: workspacePaths } satisfies WorkspaceAnnouncement);
451
+ };
452
+ const recordSessionWorkspace = () => recordState(pi, WORKSPACE_DIRS_STATE, [...sessionWorkspaceDirs]);
453
+ /**
454
+ * Take the branch's session directories. Each is validated again, as a live
455
+ * /add-dir is: the stored list says only that the user once added it, so a
456
+ * directory that is gone, or one too broad for a workspace, is not applied.
457
+ */
458
+ const restoreSessionWorkspace = (ctx: ExtensionContext) => {
459
+ const restored: string[] = [];
460
+ for (const dir of readStringsState(ctx, WORKSPACE_DIRS_STATE)) {
461
+ const checked = validateWorkspaceDirectory(dir, ctx.cwd, os.homedir(), restored);
462
+ if ("path" in checked) restored.push(checked.path);
463
+ else if (ctx.hasUI) ctx.ui.notify(`${dir}, added earlier in this conversation, is no longer in the workspace: ${checked.error}`, "warning");
464
+ }
465
+ sessionWorkspaceDirs.splice(0, sessionWorkspaceDirs.length, ...restored);
466
+ };
261
467
 
262
468
  /**
263
469
  * Mode changes arrive over the event bus too (plan-mode tools), where no ctx
@@ -279,18 +485,22 @@ export default function permissionsExtension(pi: ExtensionAPI) {
279
485
  return { classifier: `${classifierState.pinned.provider}/${classifierState.pinned.id}`, pinned: true };
280
486
  }
281
487
  if (!badgeCtx) return { pinned: false };
282
- autoConfig ??= loadAutoModeConfig(os.homedir());
283
- const chain = classifierCandidates({
284
- available: badgeCtx.modelRegistry.getAvailable(),
285
- sessionModel: sessionModel ?? badgeCtx.model,
286
- configured: autoConfig.classifierModel,
287
- configuredSetForContainment: autoConfig.classifierModelSetFor,
288
- }).candidates.filter((entry) => !classifierState.rejected.has(`${entry.model.provider}/${entry.model.id}`));
289
- const first = chain[0];
488
+ const first = selectionChain(badgeCtx, sessionModel).candidates.find((entry) => !classifierState.rejected.has(`${entry.model.provider}/${entry.model.id}`));
290
489
  return { classifier: first ? `${first.model.provider}/${first.model.id}` : undefined, pinned: false };
291
490
  };
292
491
 
293
- const applyBadge = (sessionModel?: Model<Api>) => {
492
+ /**
493
+ * The classifier chain for `sessionModel` (the ctx's by default), cached on
494
+ * the classifier state by the same signature the gate uses, so a repaint
495
+ * does not rank the catalog again (auto-mode/classifier.ts classifierChain).
496
+ */
497
+ const selectionChain = (ctx: ExtensionContext, sessionModel?: Model<Api>) => {
498
+ autoConfig ??= loadAutoModeConfig(os.homedir());
499
+ const registry = ctx.modelRegistry;
500
+ return classifierChain(classifierState, { available: () => registry.getAvailable(), sessionModel: sessionModel ?? ctx.model, config: autoConfig });
501
+ };
502
+
503
+ const applyBadge = (sessionModel?: Model<Api>, toolCallId?: string) => {
294
504
  // A below-editor widget, not a footer status: Claude Code renders the
295
505
  // mode line directly under the input box, and the workflow status strip
296
506
  // sorts itself below this line by re-setting on the status channel
@@ -301,7 +511,7 @@ export default function permissionsExtension(pi: ExtensionAPI) {
301
511
  [
302
512
  modeBadge(mode, {
303
513
  paused: pauseTracker.isPaused(),
304
- classifierModel: classifierState.pinned?.id,
514
+ classifier: classifierState.pinned?.id,
305
515
  streaming,
306
516
  }),
307
517
  ],
@@ -315,6 +525,8 @@ export default function permissionsExtension(pi: ExtensionAPI) {
315
525
  paused: pauseTracker.isPaused(),
316
526
  classifier: display.classifier,
317
527
  pinned: display.pinned,
528
+ bypassAvailable: bypassInCycle,
529
+ ...(toolCallId ? { toolCallId } : {}),
318
530
  } satisfies PermissionStatus);
319
531
  };
320
532
 
@@ -330,19 +542,27 @@ export default function permissionsExtension(pi: ExtensionAPI) {
330
542
  mode === "auto" ? (autoConfig ??= loadAutoModeConfig(os.homedir())).classifyAllShell : undefined;
331
543
 
332
544
  // Drop the cached config and classifier selection state, then refresh the badge.
333
- // Shared by the "set classifier model" and "clear" paths.
334
- const resetClassifierChoice = (sessionModel?: Model<Api>) => {
335
- autoConfig = undefined; // reloaded lazily, now carrying any new model
545
+ // A model switch must re-evaluate the capability and context-window requirements.
546
+ // `"batched"` (session_start, model_select) joins the info line with other
547
+ // extensions' lifecycle notices (lib/notices.ts).
548
+ const resetClassifierChoice = (sessionModel?: Model<Api>, announce: boolean | "batched" = true) => {
549
+ autoConfig = undefined; // reloaded lazily
336
550
  classifierState.pinned = undefined;
337
551
  classifierState.rejected.clear();
552
+ // A subset of `rejected`: kept alone it would block the retry and blame a
553
+ // refusal from the previous choice.
554
+ classifierState.refused.clear();
338
555
  classifierState.notified.clear();
339
556
  classifierState.timeoutStreak = 0;
340
557
  classifierState.chainCache = undefined;
341
558
  applyBadge(sessionModel);
559
+ if (announce) announceClassifierChoice(sessionModel, { batched: announce === "batched" });
342
560
  };
343
561
  // Keyed by cwd: a worktree-isolated child classifies against ITS checkout's
344
562
  // CLAUDE.md/AGENTS.md, and must not poison the cache the main agent's own
345
- // calls (a different cwd) read for the rest of the session.
563
+ // calls (a different cwd) read for the rest of the session. The main
564
+ // session's calls read the entered worktree's. Dropped where the agent's own
565
+ // instructions are read again: a session start and a compaction.
346
566
  const projectInstructionsByCwd = new Map<string, string | undefined>();
347
567
  const instructionsFor = (cwd: string): string | undefined => {
348
568
  if (!projectInstructionsByCwd.has(cwd)) {
@@ -350,7 +570,8 @@ export default function permissionsExtension(pi: ExtensionAPI) {
350
570
  }
351
571
  return projectInstructionsByCwd.get(cwd);
352
572
  };
353
- const pauseTracker = new PauseTracker();
573
+ const pauseTracker = sessionGate.pause;
574
+ const denials = sessionGate.denials;
354
575
  /**
355
576
  * Which model the classifier settled on. Held here so the choice is pinned for
356
577
  * the session rather than re-resolved per call, and so a model that turns out
@@ -384,14 +605,41 @@ export default function permissionsExtension(pi: ExtensionAPI) {
384
605
  }
385
606
  };
386
607
 
608
+ /**
609
+ * Announce the choice, and any warning about a chosen classifier, before any
610
+ * tool needs screening. Warnings first, then ONE info line: pi folds
611
+ * back-to-back info notices into a single status line (findings §63), so a
612
+ * second one would overwrite the first. `lead` opens that line (the
613
+ * confirmation `/auto-mode model` gives); `force` announces outside auto mode;
614
+ * `batched` hands the info line to the lifecycle notice owner (lib/notices.ts).
615
+ */
616
+ const announceClassifierChoice = (sessionModel?: Model<Api>, opts: { lead?: string; force?: boolean; batched?: boolean } = {}) => {
617
+ if ((mode !== "auto" && !opts.force) || !badgeCtx) return;
618
+ const choice = selectionChain(badgeCtx, sessionModel);
619
+ const suggestNewer = readSuggestNewerModels(os.homedir());
620
+ // Keyed by text, the same keys classify() uses, so each shows once.
621
+ const info: string[] = opts.lead ? [opts.lead] : [];
622
+ for (const notice of choice.notices) {
623
+ if (notice.fallbackReason || classifierState.notified.has(notice.text) || (!suggestNewer && notice.choiceWarning === "newer")) continue;
624
+ classifierState.notified.add(notice.text);
625
+ if (notice.level === "warning") badgeCtx.ui.notify(notice.text, "warning");
626
+ else info.push(notice.text);
627
+ }
628
+ const first = choice.candidates.find((entry) => !classifierState.rejected.has(`${entry.model.provider}/${entry.model.id}`));
629
+ const key = first ? `using:${first.model.provider}/${first.model.id}` : undefined;
630
+ if (first && key && !classifierState.notified.has(key)) {
631
+ classifierState.notified.add(key);
632
+ if (choice.fallback) classifierState.notified.add(choice.fallback.text);
633
+ info.push(choice.fallback?.text ?? `Auto mode will screen calls with ${describeCandidate(first)}.`);
634
+ }
635
+ if (info.length === 0) return;
636
+ if (opts.batched) queueNotice(pi.events, badgeCtx, "info", info.join("\n"));
637
+ else badgeCtx.ui.notify(info.join("\n"), "info");
638
+ };
639
+
387
640
  /** What would be tried, in order, before anything has been pinned. */
388
641
  const describeChain = (ctx: ExtensionContext): string => {
389
- const { candidates } = classifierCandidates({
390
- available: ctx.modelRegistry.getAvailable(),
391
- sessionModel: ctx.model,
392
- configured: autoConfig?.classifierModel,
393
- configuredSetForContainment: autoConfig?.classifierModelSetFor,
394
- });
642
+ const { candidates } = selectionChain(ctx);
395
643
  return candidates.length > 0 ? candidates.map(describeCandidate).join(" → ") : "(no model available)";
396
644
  };
397
645
 
@@ -404,19 +652,17 @@ export default function permissionsExtension(pi: ExtensionAPI) {
404
652
  }
405
653
  })();
406
654
 
655
+ // Only a cursor into pi's live branch, not a separate classifier history.
656
+ // A parent assistant message can contain calls not yet executed when a child
657
+ // asks for approval; those later calls must not become creation evidence.
658
+ let currentMainToolCallId: string | undefined;
407
659
  /**
408
- * The classifier's `<transcript>`: user messages and tool inputs, in order,
409
- * results stripped (transcript.ts renders it). The last entry is always the
410
- * action under review — the tool_call handler appends the call being judged.
660
+ * The main calls of the turn in flight, in order: pi persists them with their
661
+ * assistant message before any runs. Cleared when the turn ends, when every
662
+ * one has a result (auto-mode/history.ts childHistoryCursor).
411
663
  */
412
- const transcript: TranscriptEntry[] = [];
413
- const capTranscript = () => {
414
- // Bound memory on a long unattended run; the renderer also caps by chars,
415
- // and intent verification reads userMessages, not this, so trimming old
416
- // lines here never weakens that check. Trim down to a lower watermark so the
417
- // O(n) splice is amortized over the next ~100 pushes on the tool_call path.
418
- if (transcript.length > 500) transcript.splice(0, transcript.length - 400);
419
- };
664
+ let inFlightMainCalls: string[] = [];
665
+ const childCursor = (startedBy: string | undefined) => childHistoryCursor(startedBy, inFlightMainCalls, currentMainToolCallId);
420
666
 
421
667
  /**
422
668
  * Record a rule denial as its own `denied` transcript line, so the classifier
@@ -426,11 +672,10 @@ export default function permissionsExtension(pi: ExtensionAPI) {
426
672
  * user decision about a class of action, and each already has its own
427
673
  * model-facing text.
428
674
  */
429
- const recordRuleDenial = (result: { cause?: string; rule?: { raw?: string } }, toolName: string, subject: string) => {
675
+ const recordRuleDenial = (result: { cause?: string; rule?: { raw?: string } }, toolCallId: string, subject: string) => {
430
676
  if (mode !== "auto") return;
431
677
  if (result.cause === "plan-mode" || result.cause === "protected-path" || result.cause === "working-dir" || result.cause === "mode") return;
432
- transcript.push({ kind: "denied", tool: normalizeToolName(toolName), subject, rule: result.rule?.raw ?? "deny" });
433
- capTranscript();
678
+ pi.appendEntry(CLASSIFIER_TOOL_META, { toolCallId, deniedSubject: subject, rule: result.rule?.raw ?? "deny" });
434
679
  };
435
680
 
436
681
  /**
@@ -453,37 +698,37 @@ export default function permissionsExtension(pi: ExtensionAPI) {
453
698
  return { ...autoConfig, hardDeny: [...(autoConfig?.hardDeny ?? []), ...renderedDeny.lines] };
454
699
  };
455
700
 
456
- /**
457
- * The user's own messages, and only those — the classifier's "explicit intent"
458
- * tier must not be reachable from file contents or command output, or a prompt
459
- * injection could manufacture its own authorisation. pi's `input` event fires
460
- * for real user input, which is exactly that boundary. Carried in FULL (not a
461
- * rolling window): in a long unattended run the authorizing setup message must
462
- * still clear a later action (decision 2 in docs/decisions/auto-mode.md).
463
- */
464
- const userMessages: string[] = [];
701
+ // Persist input provenance beside the user message, not another transcript.
702
+ // pi stores extension-generated user turns in the same role; without this
703
+ // marker a resume could promote a skill body into the user's own intent.
704
+ const pendingInputs: { text: string; user: boolean }[] = [];
465
705
  pi.on("input", (event) => {
466
- // Only what the user typed. A plugin command or skill body arrives as
467
- // source "extension" and must not become "the user's own words" that an
468
- // intent quote can cite (review P12; hooks make the same distinction).
469
- if (event.source === "extension") return;
470
706
  const text = event.text?.trim();
471
707
  if (!text) return;
472
- userMessages.push(text);
473
- if (userMessages.length > 1000) userMessages.shift();
474
- transcript.push({ kind: "user", text });
475
- capTranscript();
708
+ pendingInputs.push({ text, user: event.source !== "extension" });
709
+ // Handled commands never produce a user message. Bound only this pending
710
+ // association queue; the active conversation itself is never truncated.
711
+ if (pendingInputs.length > 256) pendingInputs.shift();
712
+ });
713
+ pi.on("message_end", (event) => {
714
+ if (event.message.role !== "user") return;
715
+ const text = messageText(event.message.content);
716
+ const at = pendingInputs.findIndex((input) => text.trim() === input.text || text.trim().startsWith(`${input.text}\n`));
717
+ const input = at >= 0 ? pendingInputs.splice(at, 1)[0] : undefined;
718
+ // A digest identifies the message; the text is already in the session.
719
+ pi.appendEntry(CLASSIFIER_USER_INPUT, { timestamp: event.message.timestamp, messageDigest: userMessageDigest(text), userText: input?.user ? input.text : null });
476
720
  });
477
721
 
478
722
  /**
479
723
  * Run the deterministic pre-gate, then the classifier. The pre-gate may only
480
724
  * ever conclude "safe" (see auto-mode/shell-analysis.ts); when it does, the
481
- * classifier call is skipped entirely, which is what keeps read-heavy work
482
- * from paying classifier latency on every call.
725
+ * classifier call is skipped entirely. A read-only command never gets here:
726
+ * `decide()` allows it as the shell tools' own check, in every mode. What the
727
+ * pre-gate still clears here is a safe line that writes inside the project
728
+ * (a redirect).
483
729
  */
484
730
  const runClassifier = async (
485
731
  toolName: string,
486
- input: Record<string, unknown>,
487
732
  subject: string,
488
733
  ctx: ExtensionContext,
489
734
  /**
@@ -500,7 +745,7 @@ export default function permissionsExtension(pi: ExtensionAPI) {
500
745
  * aborted). All default to the parent's own values, so the main path is
501
746
  * unchanged.
502
747
  */
503
- opts?: { cwd?: string; appendEntry?: TranscriptEntry; signal?: AbortSignal },
748
+ opts?: { cwd?: string; appendEntry?: TranscriptEntry; toolCallId?: string; history?: HistoryCursor; signal?: AbortSignal; powershellParse?: PowerShellParse },
504
749
  ) => {
505
750
  const cwd = opts?.cwd ?? ctx.cwd;
506
751
  autoConfig ??= loadAutoModeConfig(os.homedir());
@@ -508,37 +753,17 @@ export default function permissionsExtension(pi: ExtensionAPI) {
508
753
  const home = os.homedir();
509
754
  const allow = () => ({ decision: "allow" as const, reason: "", tier: undefined });
510
755
 
511
- let evidence: ShellEvidence | undefined;
512
756
  if (normalizeToolName(toolName) === "bash" && subject) {
513
- evidence = analyzeShellCommand({ command: subject, cwd, home, protectedDirs, readableRoots });
514
- if (evidence.verdict === "safe") {
757
+ if (analyzeShellCommand({ command: subject, cwd, home, protectedDirs, readableRoots }).verdict === "safe") {
515
758
  logDecision(ctx, { tool: toolName, subject, outcome: "allow", source: "pre-gate" });
516
759
  return allow();
517
760
  }
518
- // The command's only risk is an in-project delete or whole-tree reset.
519
- // Auto mode trusts the project as the agent's sandbox — but, unlike Claude
520
- // Code, only when git can put the bytes back. A recoverable destruction
521
- // runs unattended with no classifier call; an unrecoverable one (untracked,
522
- // dirty, not a repo) still reaches the classifier.
523
- if (containmentEligible && evidence.containedNonNetwork) {
524
- const targets = evidence.writes.filter((w) => !w.outsideCwd && w.resolved).map((w) => w.resolved as string);
525
- // The same cwd the evidence resolved against — for a worktree-isolated
526
- // child that is the worktree, not the parent checkout (ctx.cwd).
527
- const rec = checkRecoverability(cwd, { targets, wholeTree: evidence.wholeTree });
528
- if (rec.verdict === "recoverable") {
529
- logDecision(ctx, { tool: toolName, subject, outcome: "allow", source: "pre-gate", reason: rec.reason });
530
- return allow();
531
- }
532
- evidence.notes.push(`git recoverability: ${rec.reason}`);
533
- }
534
761
  } else if (normalizeToolName(toolName) === "powershell" && subject) {
535
- // PowerShell has no pre-gate in v1 beyond Claude Code's read-only cmdlet
536
- // allowlist (powershell-rules.ts): a read-only line runs unclassified,
537
- // everything else — every write, delete or unknown executable — goes to
538
- // the classifier. No containment fast path either: the recoverability
539
- // judge understands bash deletes, not `Remove-Item`, so PowerShell
540
- // destruction is always classified (docs/decisions/windows.md).
541
- if (powershellReadOnly(subject, { cwd, home, readableRoots }).readOnly) {
762
+ // A line PowerShell's own parse proves read-only runs unclassified;
763
+ // contained edits were cleared in decide() where the model's tier allows
764
+ // them, and everything else goes to the classifier
765
+ // (decisions/windows.md "The PowerShell pre-gate reads PowerShell's own parse").
766
+ if (powershellReadOnly({ parse: opts?.powershellParse, cwd, home, readableRoots }).readOnly) {
542
767
  logDecision(ctx, { tool: toolName, subject, outcome: "allow", source: "pre-gate" });
543
768
  return allow();
544
769
  }
@@ -546,16 +771,16 @@ export default function permissionsExtension(pi: ExtensionAPI) {
546
771
  // A write/edit whose target is inside the project and not a credential
547
772
  // path is ordinary sandbox work — Claude Code auto-approves it, and so do
548
773
  // we (protected paths never reach here: decide() routes them with
549
- // containmentEligible=false). Overwrites are not recoverability-gated the
550
- // way deletes are: the file still exists, and edit-then-iterate is the
551
- // core of unattended coding autonomy. Execution-primitive paths (build
774
+ // containmentEligible=false). Edit-then-iterate is the core of
775
+ // unattended coding autonomy. Execution-primitive paths (build
552
776
  // wrappers, CI workflows, editor auto-run config) are excluded exactly as
553
777
  // the bash pre-gate excludes them — being in-project does not make a file
554
778
  // that runs later without further approval safe to write unclassified.
555
- const raw = input.file_path ?? input.path;
556
- const target = typeof raw === "string" ? raw : undefined;
557
- if (target) {
558
- const absolute = toAbsolute(cwd, target, home);
779
+ // `subject` is pathArgument's pick, the field every other gate judged;
780
+ // picking a field again here once approved a different file from the
781
+ // one written (AUTO-MODE-SECURITY-REVIEW-2026-09-24 H1).
782
+ if (subject) {
783
+ const absolute = toAbsolute(cwd, subject, home);
559
784
  const resolved = resolveForContainment(absolute);
560
785
  const root = resolveForContainment(cwd) ?? cwd;
561
786
  if (resolved && isWithin(root, resolved) && !isSensitivePath(absolute) && !isExecutionPrimitivePath(absolute)) {
@@ -565,14 +790,26 @@ export default function permissionsExtension(pi: ExtensionAPI) {
565
790
  }
566
791
  }
567
792
 
568
- // The current call was pushed onto `transcript` by the tool_call handler, so
569
- // it is already the last entry — the action under review. `evidence` is used
570
- // only for the containment fast-path above; CC's payload carries no separate
571
- // static-analysis block, so it is not sent to the classifier.
793
+ // Project pi's active context on every call. Compaction, resume and /tree
794
+ // therefore change classifier context at the same boundary as the agent's.
795
+ const { transcript: history, userMessages } = classifierHistory(ctx.sessionManager.getBranch(), opts?.toolCallId
796
+ ? { beforeToolCallId: opts.toolCallId }
797
+ : (opts?.history ?? { throughToolCallId: currentMainToolCallId }));
798
+ const action = opts?.appendEntry;
799
+ const shell = normalizeToolName(toolName) === "powershell" ? "powershell" : isShellTool(normalizeToolName(toolName)) || normalizeToolName(toolName) === "monitor" ? "bash" : undefined;
800
+ if (shell && subject && wantsGitStatusMeta(shell, subject)) {
801
+ const porcelain = await gitStatusOutput(cwd, gitStatusMetaArgs(reachesIgnoredFiles(shell, subject)));
802
+ const gitStatus = porcelain === undefined ? undefined : gitStatusMeta(porcelain);
803
+ if (gitStatus) {
804
+ history.push({ kind: "meta", gitStatus });
805
+ if (opts?.toolCallId) pi.appendEntry(CLASSIFIER_TOOL_META, { toolCallId: opts.toolCallId, gitStatus });
806
+ }
807
+ }
808
+ const resolvedPaths = actionResolvedPaths(normalizeToolName(toolName), subject, opts?.powershellParse, { cwd, home, roots: [cwd, ...workspaceDirs] });
572
809
  const verdict = await classify(
573
810
  {
574
811
  toolName,
575
- transcript: opts?.appendEntry ? [...transcript, opts.appendEntry] : [...transcript],
812
+ transcript: [...history, ...(resolvedPaths ? [{ kind: "resolved-paths" as const, resolvedPaths }] : []), ...(action ? [action] : [])],
576
813
  userMessages: [...userMessages],
577
814
  claudeMd: instructionsFor(cwd),
578
815
  username: classifierUsername,
@@ -587,6 +824,7 @@ export default function permissionsExtension(pi: ExtensionAPI) {
587
824
  config: autoConfig,
588
825
  signal: opts?.signal ?? ctx.signal,
589
826
  state: classifierState,
827
+ cacheKey: classifierCacheKey(ctx.sessionManager.getSessionId()),
590
828
  onUsage: onClassifierUsage,
591
829
  onModelUnusable: onClassifierModelUnusable,
592
830
  onNotice: (message, level) => {
@@ -607,56 +845,79 @@ export default function permissionsExtension(pi: ExtensionAPI) {
607
845
  ruleId: verdict.ruleId,
608
846
  reason: verdict.reason || undefined,
609
847
  raw: verdict.raw,
848
+ transcriptTooLong: verdict.transcriptTooLong,
610
849
  model: classifierState.pinned ? `${classifierState.pinned.provider}/${classifierState.pinned.id}` : undefined,
611
850
  });
612
851
  return verdict;
613
852
  };
614
853
 
615
- const setMode = (next: PermissionMode) => {
854
+ /** `startup`: the session opens in this mode, so there is no switch to announce, only the mode's standing block. */
855
+ const setMode = (next: PermissionMode, startup = false, toolCallId?: string) => {
856
+ const changed = mode !== next;
857
+ /** The mode the branch recorded before this switch; its model was told it once a request went out. */
858
+ const told = branchMode;
616
859
  mode = next;
617
860
  process.env[MODE_ENV] = next;
618
- // The standing block of the mode being left goes first, BEFORE the status
861
+ // Close the standing block of the mode being left BEFORE the status
619
862
  // broadcast in applyBadge: the plan-mode extension owns the shared
620
863
  // "permission-mode" key while planning (it knows the plan file) and
621
864
  // re-installs its block synchronously from that broadcast when the mode
622
865
  // becomes plan — removing the key afterwards would undo that re-add and
623
866
  // leave a turn entered mid-stream with no plan reminder until the next
624
867
  // prompt (STEERING-REVIEW-2026-09-05 M3). Auto installs its own block below.
625
- if (mode !== "auto") pi.events.emit(REMINDER_CHANNEL, { remove: true, key: "permission-mode" });
626
- applyBadge();
868
+ if (mode !== "auto" && !(mode === "plan" && (startup ? branchMode === "plan" : !changed))) {
869
+ pi.events.emit(REMINDER_CHANNEL, { remove: true, key: "permission-mode" });
870
+ }
871
+ // The mode follows the branch: recorded on every change, so a resume, a
872
+ // fork, /reload or a /tree switch starts from it (decisions/modes.md).
873
+ if (mode !== branchMode) {
874
+ recordState(pi, PERMISSION_MODE_STATE, mode);
875
+ branchMode = mode;
876
+ }
877
+ applyBadge(undefined, toolCallId);
627
878
  // Every switch is announced once on the tail of the next request (the next
628
879
  // tool result mid-turn, the prompt between turns), so the model learns of
629
880
  // the change where it reads next. The standing block carries the rules
630
- // but rides the turn's user message, behind the model's own actions — so
631
- // for plan mode the announcement, the one thing guaranteed to land
632
- // mid-turn, also names the plan file (published by plan-mode's refresh
633
- // inside the applyBadge broadcast above).
881
+ // from that point forward; the announcement also names the plan file
882
+ // (published by plan-mode's refresh inside the applyBadge broadcast above).
634
883
  const planNote =
635
884
  mode === "plan"
636
885
  ? planFilePath
637
886
  ? ` Only read-only tools are available now, plus one writable file: your plan file at ${planFilePath}. Build the plan there, then call exit_plan_mode.`
638
887
  : " Only read-only tools are available now, plus the plan file named in the plan-mode reminder."
639
888
  : "";
640
- pi.events.emit(REMINDER_CHANNEL, {
641
- text: `The user's permission mode is now "${mode}".${planNote}`,
642
- key: "permission-mode-change",
643
- });
889
+ // Not at startup: "is now" beside the mode's own standing block read as a
890
+ // duplicate to both GPT-6 models in the 2026-10-04 self-test, and Claude
891
+ // Code announces only a switch. A startup that leaves the mode the branch
892
+ // recorded (a flag, a stored bypass that needs its flag again) is one.
893
+ if (!startup || (told !== undefined && told !== mode && requestOnBranch)) {
894
+ pi.events.emit(REMINDER_CHANNEL, {
895
+ text: `The user's permission mode is now "${mode}".${planNote}`,
896
+ key: "permission-mode-change",
897
+ toolCallId,
898
+ });
899
+ }
644
900
  if (mode === "auto") {
645
901
  pi.events.emit(REMINDER_CHANNEL, {
646
902
  text:
647
903
  "Auto mode is active: your tool calls run without per-action prompts, but each one is screened by an approval classifier that blocks anything irreversible, destructive, or aimed outside this environment. " +
648
- "Work normally — do not narrate the classifier or try to phrase calls to get past it. If a call is blocked, take the block at face value: explain what you were trying to do and let the user decide, rather than retrying it a different way. " +
904
+ "Work normally — do not narrate the classifier or try to phrase calls to get past it. If a call is blocked, take the block at face value: do not retry it, and do not reach the same result another way. Continue with work that does not depend on it, or explain what you were trying to do and let the user decide. " +
649
905
  "Attempting to weaken this gate (editing permission settings, changing the mode, or routing work around it) is itself blocked.",
650
- scope: "every-turn",
651
906
  key: "permission-mode",
652
907
  // Session state: rides every user message since auto mode came on, so
653
908
  // the cached prefix holds turn to turn (lib/reminders.ts).
654
909
  placement: "sticky-append",
910
+ toolCallId,
655
911
  });
656
912
  }
657
913
  };
658
914
 
659
- const reloadSettings = (ctx: ExtensionContext) => {
915
+ /**
916
+ * Re-read the permission rules only: what a rule edit in /permissions needs.
917
+ * The mode, a declined project-rule prompt and auto mode's cached config are
918
+ * left alone (reloadSettings resets those for a new session).
919
+ */
920
+ const reloadRules = (ctx: ExtensionContext) => {
660
921
  const settings = loadPermissionSettings(ctx.cwd, os.homedir());
661
922
  const parsed = {
662
923
  deny: parseRulesReport(settings.deny),
@@ -669,10 +930,24 @@ export default function permissionsExtension(pi: ExtensionAPI) {
669
930
  allow = parsed.allow.rules;
670
931
  projectAllow = parsed.projectAllow.rules;
671
932
  projectAllowRaw = settings.projectAllow;
933
+ blockOutsideReads = settings.blockReadsOutsideWorkingDirectories === true;
672
934
  // A linked worktree shares its main checkout's consent (findProjectRoot).
673
935
  projectRoot = findProjectRoot(ctx.cwd) ?? ctx.cwd;
674
- projectAllowTrusted = projectAllowApproved(projectRoot, projectAllowRaw);
675
- projectAllowDeclined = false;
936
+ // A settings file is held to what /add-dir and --add-dir refuse: `/` or `~`
937
+ // there would make almost the whole disk working space.
938
+ const tooBroad: string[] = [];
939
+ settingsWorkspaceDirs = listWorkspaceDirectories(ctx.cwd, os.homedir()).filter((dir) => {
940
+ const refusal = tooBroadForWorkspace(tryRealpath(dir.path) ?? dir.path, os.homedir());
941
+ if (refusal) tooBroad.push(`${dir.raw} in ${tildify(dir.settingsPath, os.homedir())}: ${refusal}`);
942
+ return !refusal;
943
+ });
944
+ const tooBroadSignature = tooBroad.join("\n");
945
+ if (tooBroad.length > 0 && ctx.hasUI && tooBroadSignature !== warnedTooBroad) {
946
+ warnedTooBroad = tooBroadSignature;
947
+ ctx.ui.notify(`Ignored permissions.additionalDirectories entries: ${tooBroad.join("; ")}`, "warning");
948
+ }
949
+ projectAllowTrusted = projectAllowApproved(projectRoot, projectTrustList());
950
+ refreshWorkspace();
676
951
  // A rule that fails to parse is a rule the user believes is in force and is
677
952
  // not. Say so (once per distinct set) and list them in /permissions.
678
953
  unparsableRules = [...parsed.deny.dropped, ...parsed.ask.dropped, ...parsed.allow.dropped, ...parsed.projectAllow.dropped];
@@ -684,6 +959,16 @@ export default function permissionsExtension(pi: ExtensionAPI) {
684
959
  "warning",
685
960
  );
686
961
  }
962
+ return settings;
963
+ };
964
+
965
+ /**
966
+ * `launching`: the process is starting (reason "startup"), when its flags
967
+ * pick the mode. `carried`: the mode a /clear or /new carries onto its empty branch.
968
+ */
969
+ const reloadSettings = (ctx: ExtensionContext, launching: boolean, carried: PermissionMode | undefined) => {
970
+ const settings = reloadRules(ctx);
971
+ projectAllowDeclined = false;
687
972
  // Dropped so edited autoMode rules and instruction files are picked up on
688
973
  // reload rather than staying cached for the life of the process.
689
974
  autoConfig = undefined;
@@ -692,68 +977,184 @@ export default function permissionsExtension(pi: ExtensionAPI) {
692
977
  // Claude Code's resolution order (flags, then settings' defaultMode), with
693
978
  // bypassPermissions refused wherever a settings source disabled it — before
694
979
  // 2026-09-05 (H3) that policy was silently not in force here.
695
- const startup = resolveStartupMode(
696
- [
697
- pi.getFlag("dangerously-skip-permissions") === true ? "bypassPermissions" : undefined,
698
- normalizePermissionMode(pi.getFlag("permission-mode")),
699
- settings.defaultMode,
700
- ],
701
- settings,
702
- );
980
+ // The mode follows the branch: at launch the flags win over it, and after
981
+ // that (/reload, /resume, /fork, /clear in the same process) the branch's
982
+ // own mode wins over the launch flags, so a switch the user made is kept.
983
+ // A stored bypassPermissions is never restored by itself: it needs its flag.
984
+ const bypassFlag = pi.getFlag("dangerously-skip-permissions") === true ? ("bypassPermissions" as const) : undefined;
985
+ const modeFlag = normalizePermissionMode(pi.getFlag("permission-mode"));
986
+ const fromBranch = branchMode ?? carried;
987
+ const restored = fromBranch === "bypassPermissions" ? undefined : fromBranch;
988
+ const startup = resolveStartupMode(launching ? [bypassFlag, modeFlag, restored, settings.defaultMode] : [restored, bypassFlag, modeFlag, settings.defaultMode], settings);
703
989
  if (startup.mode) mode = startup.mode;
704
990
  if (startup.bypassRefused && ctx.hasUI) ctx.ui.notify("Bypass permissions mode was disabled by settings", "warning");
705
991
  bypassInCycle = mode === "bypassPermissions";
706
992
  // Auto mode needs a model to run its classifier on; with none reachable it
707
993
  // would block every call, so it stays out of the cycle instead — the same
708
994
  // thing Claude Code does when auto mode's requirements aren't met. The
709
- // candidate chain ends in an unconditional fallback to any available model
710
- // (model-select.ts), so "a model exists" is exactly "a classifier exists".
995
+ // classifier stays on this session's provider, falling back to the session
996
+ // model when no capable window-compatible alternative is available.
711
997
  autoInCycle = ctx.modelRegistry.getAvailable().length > 0;
712
998
  // A mode that owns a standing reminder must be announced when the session
713
999
  // STARTS in it, not only when the user switches into it. setMode is the
714
1000
  // only emitter of the block; until 2026-09-05 startup called it for plan
715
1001
  // alone, so `--permission-mode auto` / `defaultMode: "auto"` sessions ran
716
1002
  // with no auto-mode reminder at all (STEERING-REVIEW-2026-09-05 H2).
717
- if (STANDING_REMINDER_MODES.has(mode)) setMode(mode);
1003
+ if (STANDING_REMINDER_MODES.has(mode)) setMode(mode, true);
718
1004
  process.env[MODE_ENV] = mode;
719
1005
  };
720
1006
 
1007
+ // Claude Code's auto-mode note joins the mid-conversation system message
1008
+ // when the conversation opens in auto mode (auto-mode/system-note.ts). It is
1009
+ // live state, rendered only by a request that opens an epoch, so message 1
1010
+ // never changes inside one; a compaction or a model switch decides it again.
1011
+ pi.on("turn_start", (_event, ctx) => {
1012
+ const note = mode === "auto" && ctx.model ? autoModeSystemNote(ctx.model) : undefined;
1013
+ if (!note) {
1014
+ pi.events.emit(REMINDER_CHANNEL, { remove: true, key: "auto-mode-note" });
1015
+ return;
1016
+ }
1017
+ pi.events.emit(REMINDER_CHANNEL, {
1018
+ text: note,
1019
+ key: "auto-mode-note",
1020
+ placement: "first-prepend",
1021
+ order: CONTEXT_ORDER.autoModeNote,
1022
+ systemRoleOnly: true,
1023
+ } satisfies ReminderPayload);
1024
+ });
1025
+
1026
+ // A turn's calls are all persisted before the first runs, and all have a
1027
+ // result by turn_end (an aborted run ends the agent instead).
1028
+ pi.on("turn_start", () => {
1029
+ inFlightMainCalls = [];
1030
+ });
1031
+ pi.on("turn_end", () => {
1032
+ inFlightMainCalls = [];
1033
+ });
1034
+ pi.on("agent_end", () => {
1035
+ inFlightMainCalls = [];
1036
+ });
1037
+
721
1038
  pi.on("session_start", (event, ctx) => {
1039
+ childPermissionSession?.abort();
1040
+ childPermissionSession = new AbortController();
722
1041
  badgeCtx = ctx;
723
1042
  lastReviewCtx = ctx;
724
1043
  sessionEpoch++;
1044
+ panels.reset();
1045
+ gateFastPaths = usesClaudeCodeFastPaths(ctx.model);
1046
+ // Not awaited: the grammar loads in a few milliseconds, and the gate
1047
+ // awaits it per call. Only a failed load is worth telling the user about.
1048
+ void bashParserReady().then(() => {
1049
+ const why = bashParserUnavailable();
1050
+ if (why && ctx.hasUI) ctx.ui.notify(`One Code: ${why}. Every bash command will be escalated or prompted.`, "error");
1051
+ });
725
1052
  // A new session (`/clear`, `/resume`, a fork) starts with a clean gate: the
726
1053
  // previous conversation's user messages are not intent evidence for this
727
- // one, its "don't ask again" grants and its pause state do not carry over
728
- // (PERMISSIONS-REVIEW-2026-09-05 L1). A reload keeps them — same conversation.
1054
+ // one, and its "don't ask again" grants, approved denials and pause state
1055
+ // do not carry over (PERMISSIONS-REVIEW-2026-09-05 L1). A reload keeps
1056
+ // them, the same conversation: they are process state (SessionGate),
1057
+ // which the factory pi re-runs there does not reset.
729
1058
  if (event.reason !== "reload") {
730
1059
  sessionAllows.length = 0;
731
- transcript.length = 0;
732
- userMessages.length = 0;
1060
+ currentMainToolCallId = undefined;
1061
+ inFlightMainCalls = [];
1062
+ pendingInputs.length = 0;
733
1063
  pauseTracker.reset();
1064
+ denials.reset();
734
1065
  }
735
- memoryDirPath = projectMemoryDir(ctx.cwd);
736
- scratchpadDirPath = sessionScratchpadDir(ctx.cwd, ctx.sessionManager.getSessionId());
1066
+ // The directories this branch was told it added follow it, so a reload, a
1067
+ // resume and a fork keep them and a /clear starts without.
1068
+ restoreSessionWorkspace(ctx);
1069
+ // With auto-memory off the folder is nobody's working space: no grant.
1070
+ memoryDirPath = autoMemoryEnabled(ctx.cwd) ? projectMemoryDir(ctx.cwd) : undefined;
1071
+ // Working space only when it is private to this user: another local user
1072
+ // who owns the directory on a shared /tmp could swap what is written there.
1073
+ scratchpadDirPath = privateSessionScratchpadDir(ctx.cwd, ctx.sessionManager.getSessionId());
737
1074
  // Resolved like the subjects compared against it (a symlinked parent, macOS /var).
738
1075
  resultsDirPath = resolvedOrSelf(sessionResultsDir(ctx));
739
1076
  sessionDirPath = resolvedOrSelf(ctx.sessionManager.getSessionDir());
740
- readableRoots = [memoryDirPath, scratchpadDirPath, resultsDirPath, sessionDirPath].filter((d): d is string => !!d).map(resolvedOrSelf);
1077
+ harnessReadableRoots = [memoryDirPath, scratchpadDirPath, resultsDirPath, sessionDirPath].filter((d): d is string => !!d).map(resolvedOrSelf);
1078
+ docsDirPaths = guideDocsDirs().map(resolvedOrSelf);
1079
+ // `--add-dir`: each directory is validated like one added in the panel.
1080
+ flagWorkspaceDirs = [];
1081
+ for (const entry of parseAddDirFlag(pi.getFlag("add-dir") as string | undefined)) {
1082
+ const checked = validateWorkspaceDirectory(entry, ctx.cwd, os.homedir(), flagWorkspaceDirs);
1083
+ if ("path" in checked) flagWorkspaceDirs.push(checked.path);
1084
+ else if (ctx.hasUI) ctx.ui.notify(`--add-dir ${entry}: ${checked.error}`, "warning");
1085
+ }
741
1086
  resolvedCwd = resolveForContainment(ctx.cwd);
742
1087
  oneCodeProjectSettingsFile = oneCodeProjectSettingsPath(ctx.cwd, os.homedir());
743
1088
  protectedDirs = runtimeProtectedDirs();
744
- reloadSettings(ctx);
745
- applyBadge();
746
- // Publish the subagent permission bridge (see subagent-gate.ts). The closure
747
- // reads live parent state on each call, so emitting once at session start is
748
- // enough; subagents captures it and threads it into child sessions.
749
- pi.events.emit(SUBAGENT_GATE_CHANNEL, { decide: evaluateChildToolCall });
1089
+ secretPaths = runtimeSecretPaths(os.homedir());
1090
+ branchMode = normalizePermissionMode(readState(ctx, PERMISSION_MODE_STATE));
1091
+ requestOnBranch = (ctx.sessionManager.getBranch() as EntryLike[]).some((entry) => readRecord(entry)?.kind === "request");
1092
+ // Claude Code keeps the mode across /clear: the new branch starts from the
1093
+ // mode this process last published (MODE_ENV), and records it.
1094
+ const carried = event.reason === "new" && !branchMode ? normalizePermissionMode(process.env[MODE_ENV]) : undefined;
1095
+ reloadSettings(ctx, event.reason === "startup", carried);
1096
+ if (!STANDING_REMINDER_MODES.has(mode)) setMode(mode, true);
1097
+ // The system prompt lists the workspace as the session starts (lib/workspace-channel.ts).
1098
+ sessionStarted = true;
1099
+ announcedWorkspace = undefined;
1100
+ announceWorkspace();
1101
+ resetClassifierChoice(ctx.model, "batched");
1102
+ // The bridge reads live mode/rules, but belongs to this parent session only.
1103
+ pi.events.emit(SUBAGENT_GATE_CHANNEL, { decide: sessionPermissionBridge(evaluateChildToolCall, childPermissionSession.signal) });
1104
+ });
1105
+
1106
+ // A /tree switch selects a branch whose model was told that branch's mode:
1107
+ // take it, as a resume would, without announcing a switch. A stored
1108
+ // bypassPermissions is never taken.
1109
+ pi.on("session_tree", (_event, ctx) => {
1110
+ // The selected branch's model was told its own session directories.
1111
+ restoreSessionWorkspace(ctx);
1112
+ refreshWorkspace();
1113
+ const onBranch = normalizePermissionMode(readState(ctx, PERMISSION_MODE_STATE));
1114
+ if (!onBranch || onBranch === "bypassPermissions" || onBranch === mode) return;
1115
+ branchMode = onBranch;
1116
+ setMode(onBranch, true);
1117
+ });
1118
+
1119
+ // claude-context re-reads the instruction files at a compaction, and the
1120
+ // summary's epoch renders them, so the classifier judges against the same
1121
+ // set from then on. Unchanged files give the same bytes, so its cache holds;
1122
+ // an edit costs one classifier cache write at this boundary.
1123
+ pi.on("session_compact", () => {
1124
+ projectInstructionsByCwd.clear();
1125
+ });
1126
+
1127
+ // The published bridge stays: aborted, it denies every call a retained child
1128
+ // makes, while a parent without this extension keeps the local gate.
1129
+ pi.on("session_shutdown", (_event, ctx) => {
1130
+ childPermissionSession?.abort();
1131
+ lastReviewCtx = undefined;
1132
+ sessionEpoch++;
1133
+ // Children screen through this session's key too (evaluateChildToolCall
1134
+ // uses lastReviewCtx), so one release covers their calls.
1135
+ releaseClassifierResources(ctx.sessionManager.getSessionId());
750
1136
  });
751
1137
 
752
1138
  pi.on("model_select", (event, ctx) => {
753
1139
  badgeCtx = ctx;
754
1140
  lastReviewCtx = ctx;
755
1141
  autoInCycle = ctx.modelRegistry.getAvailable().length > 0;
756
- resetClassifierChoice(event.model);
1142
+ resetClassifierChoice(event.model, "batched");
1143
+ // The gate reads the model's tier on every call, so a switch takes effect
1144
+ // on the next one; a switch across the workhorse/cheap line says so.
1145
+ const fastPaths = usesClaudeCodeFastPaths(event.model);
1146
+ if (gateFastPaths !== undefined && fastPaths !== gateFastPaths && event.model) {
1147
+ const name = `${formatModel(event.model.provider, event.model.id)} (${intrinsicTier(event.model)} tier)`;
1148
+ queueNotice(
1149
+ pi.events,
1150
+ ctx,
1151
+ "info",
1152
+ fastPaths
1153
+ ? `${name}: permissions now follow Claude Code's fast paths.`
1154
+ : `${name}: permissions now use One Code's stricter checks, so more calls prompt or go to the auto-mode classifier.`,
1155
+ );
1156
+ }
1157
+ gateFastPaths = fastPaths;
757
1158
  });
758
1159
 
759
1160
  // The badge carries "· esc to interrupt" only while the model works (CC's
@@ -763,6 +1164,9 @@ export default function permissionsExtension(pi: ExtensionAPI) {
763
1164
  lastReviewCtx = ctx;
764
1165
  streaming = true;
765
1166
  applyBadge();
1167
+ // Start the PowerShell parser before the first PowerShell call needs it,
1168
+ // never awaited: its first start can take seconds on Windows.
1169
+ if (mode !== "bypassPermissions" && pi.getActiveTools().includes("powershell")) void powerShellParser()?.parse("");
766
1170
  });
767
1171
  // Settle, not agent_end: `agent_end` fires per run, so flipping there made
768
1172
  // the badge's "· esc to interrupt" suffix flicker off and back on across a
@@ -778,8 +1182,14 @@ export default function permissionsExtension(pi: ExtensionAPI) {
778
1182
  // `/auto-mode model` does after its own write, so the next call re-plans.
779
1183
  pi.events.on(CLASSIFIER_SETTING_CHANGED_CHANNEL, () => resetClassifierChoice(badgeCtx?.model));
780
1184
  pi.events.on(MODE_CHANNEL, (data) => {
781
- const requested = normalizePermissionMode((data as { mode?: unknown })?.mode);
782
- if (requested) setMode(requested);
1185
+ const request = data as { mode?: unknown; toolCallId?: unknown } | undefined;
1186
+ const requested = normalizePermissionMode(request?.mode);
1187
+ if (!requested) return;
1188
+ // Another extension asks (plan mode's exit restores the mode it came from,
1189
+ // read from the branch). A stored value never grants a permission by
1190
+ // itself: bypass only where this session has it, else the default mode.
1191
+ const allowed = requested === "bypassPermissions" && !bypassInCycle ? "default" : requested;
1192
+ setMode(allowed, false, typeof request?.toolCallId === "string" ? request.toolCallId : undefined);
783
1193
  });
784
1194
 
785
1195
  // The plan-mode extension announces the plan file on PLAN_FILE_CHANNEL;
@@ -794,7 +1204,7 @@ export default function permissionsExtension(pi: ExtensionAPI) {
794
1204
  // that terminals don't already own (ctrl+m *is* Enter's byte, alt needs
795
1205
  // option-as-meta configured on macOS) — except on Windows and WSL, where
796
1206
  // pi binds ctrl+q itself and the key is alt+m (lib/keys.ts) — see
797
- // docs/decisions.md.
1207
+ // working-docs/decisions.md.
798
1208
  pi.registerShortcut(CYCLE_KEY, {
799
1209
  description: "Cycle permission mode",
800
1210
  handler: (ctx) => {
@@ -809,13 +1219,29 @@ export default function permissionsExtension(pi: ExtensionAPI) {
809
1219
  let lastReviewCtx: ExtensionContext | undefined;
810
1220
  /** Bumped per session_start: an async review that finishes after a /clear must not post into the new session. */
811
1221
  let sessionEpoch = 0;
1222
+ let childPermissionSession: AbortController | undefined;
812
1223
 
813
1224
  // Serialize interactive prompts: a background/resident subagent can hit an "ask"
814
1225
  // while the main turn (or another child) is already awaiting one, and driving
815
1226
  // ctx.ui.select concurrently on one terminal is unverified. This chains them.
1227
+ // A prompt also waits while a panel holds the keyboard (/permissions, the
1228
+ // agent view, a picker): pi would put the prompt in the panel's place, the
1229
+ // panel would not come back, and an Enter meant for the panel would answer
1230
+ // the prompt. Claude Code holds its permission dialog the same way.
816
1231
  let promptChain: Promise<unknown> = Promise.resolve();
817
- const serializePrompt = <T>(fn: () => Promise<T>): Promise<T> => {
818
- const run = promptChain.then(fn, fn);
1232
+ /** Calls the user rejected without a reason: the turn stops once pi records their result. */
1233
+ const abortAfterResult = new Set<string>();
1234
+ const panels = new PanelFocus();
1235
+ pi.events.on(PANEL_FOCUS_CHANNEL, (data) => panels.note(data));
1236
+ const serializePrompt = <T>(fn: () => Promise<T>, signal?: AbortSignal): Promise<T> => {
1237
+ const show = async () => {
1238
+ if (panels.busy && !signal?.aborted) {
1239
+ liveUiCtx(lastReviewCtx)?.ui.notify(PROMPT_WAITS_FOR_PANEL, "info");
1240
+ await panels.whenClear(signal);
1241
+ }
1242
+ return fn();
1243
+ };
1244
+ const run = promptChain.then(show, show);
819
1245
  promptChain = run.then(
820
1246
  () => {},
821
1247
  () => {},
@@ -823,53 +1249,143 @@ export default function permissionsExtension(pi: ExtensionAPI) {
823
1249
  return run;
824
1250
  };
825
1251
 
1252
+ /** A context overflow judged nothing: ask once, or stop a headless turn. */
1253
+ const transcriptOverflow = async (ctx: ExtensionContext, toolName: string, subject: string, signal = ctx.signal): Promise<ChildGateDecision> => {
1254
+ if (!ctx.hasUI || ctx.mode === "print") {
1255
+ const reason = "auto mode classifier transcript exceeded context window in headless mode";
1256
+ ctx.ui.notify(`${reason}. Compact the session (/compact) and continue.`, "error");
1257
+ // A failed one-shot run, not a user cancel (exit/index.ts).
1258
+ pi.events.emit(TURN_FAILED_CHANNEL, { reason } satisfies TurnFailedEvent);
1259
+ ctx.abort();
1260
+ return { block: true, reason };
1261
+ }
1262
+ const reason = "Auto mode classifier transcript exceeded context window — falling back to manual approval";
1263
+ ctx.ui.notify(reason, "warning");
1264
+ logDecision(ctx, { tool: toolName, subject, outcome: "prompt", source: "user", transcriptTooLong: true, reason });
1265
+ const choice = await serializePrompt(() => signal?.aborted ? Promise.resolve(undefined) : ctx.ui.select(
1266
+ `${reason}\n\n${askTitle(toolName, previewSubject(subject), "mode", false)}`,
1267
+ askOptions(undefined), { signal },
1268
+ ), signal);
1269
+ const allowed = !signal?.aborted && choice === YES;
1270
+ logDecision(ctx, { tool: toolName, subject, outcome: allowed ? "allow" : "block", source: "user", transcriptTooLong: true, reason });
1271
+ if (allowed) return undefined;
1272
+ if (!signal?.aborted && choice === NO) {
1273
+ const feedback = await serializePrompt(() => signal?.aborted ? Promise.resolve(undefined) : ctx.ui.input(
1274
+ "What should the agent do instead?", "Optional — press Esc to skip", { signal },
1275
+ ), signal);
1276
+ return { block: true, reason: userDenialText(feedback) };
1277
+ }
1278
+ return { block: true, reason: "Manual approval was not granted after the auto-mode classifier transcript exceeded its context window." };
1279
+ };
1280
+
1281
+ /**
1282
+ * Show an ask prompt through `queue`, timed when it is a paused-auto-mode
1283
+ * resume prompt that Claude Code's denial-limit fallback times (the first
1284
+ * after a consecutive-limit pause). pi shows a live countdown and resolves
1285
+ * an expired dialog as dismissed, so `unanswered` tells the two apart,
1286
+ * measured from when the dialog showed (not when it queued); the caller
1287
+ * then denies the call without asking for feedback. Shared by both gate paths.
1288
+ */
1289
+ const askMaybeTimed = async (
1290
+ pausedResume: boolean,
1291
+ title: string,
1292
+ queue: (show: () => Promise<string | undefined>) => Promise<string | undefined>,
1293
+ select: (title: string, timeout: number | undefined) => Promise<string | undefined>,
1294
+ ): Promise<{ choice: string | undefined; unanswered: boolean }> => {
1295
+ if (!pausedResume || !pauseTracker.takeTimedPrompt()) return { choice: await queue(() => select(title, undefined)), unanswered: false };
1296
+ const timeout = unattendedPromptTimeoutMs();
1297
+ let shownAt: number | undefined;
1298
+ const choice = await queue(() => {
1299
+ shownAt = Date.now();
1300
+ return select(`${title}\n\n${unattendedPromptNotice(timeout)}`, timeout);
1301
+ });
1302
+ return { choice, unanswered: choice === undefined && shownAt !== undefined && Date.now() - shownAt >= timeout - Math.min(1500, timeout / 10) };
1303
+ };
1304
+
1305
+ /**
1306
+ * Claude Code's one-time prompt before auto mode's first read outside the
1307
+ * working directories (2.1.282, findings §33): raised only in an interactive
1308
+ * session that has not answered it, on this machine or in Claude Code. Yes
1309
+ * and Block settle it for good; Ask again, or dismissing, refuses this read
1310
+ * and asks on the next. Queued behind other prompts, and re-checked once its
1311
+ * turn comes, so parallel outside reads ask once.
1312
+ */
1313
+ const firstOutsideRead = (ctx: ExtensionContext, toolName: string, path: string, signal = ctx.signal): Promise<"allow" | "block" | "ask_again"> => {
1314
+ const home = os.homedir();
1315
+ const settled = () => (outsideReadSeen ??= outsideReadPromptSeen(oneCodeSettingsPath(home), claudeSourcesOn() ? claudeJsonPath(home) : undefined));
1316
+ if (!ctx.hasUI) return Promise.resolve("allow");
1317
+ // Settled while this call waited (a parallel read's Block included).
1318
+ if (settled()) return Promise.resolve(blockOutsideReads ? "block" : "allow");
1319
+ return serializePrompt(async () => {
1320
+ if (signal?.aborted) return "ask_again";
1321
+ if (settled()) return blockOutsideReads ? "block" : "allow";
1322
+ const settingsFile = tildify(oneCodeSettingsPath(home), home);
1323
+ const title = `${OUTSIDE_READ_TITLE}\n\n ${toolName} ${path}\n\n${OUTSIDE_READ_QUESTION}\n\n${OUTSIDE_READ_EXPLAINER(settingsFile)}`;
1324
+ const choice = await ctx.ui.select(title, Object.values(OUTSIDE_READ_ANSWERS), { signal });
1325
+ if (signal?.aborted || (choice !== OUTSIDE_READ_ANSWERS.allow && choice !== OUTSIDE_READ_ANSWERS.block)) return "ask_again";
1326
+ outsideReadSeen = true;
1327
+ markOutsideReadPromptSeen(oneCodeSettingsPath(home));
1328
+ if (choice === OUTSIDE_READ_ANSWERS.allow) return "allow";
1329
+ persistBlockOutsideReads(oneCodeSettingsPath(home));
1330
+ blockOutsideReads = true;
1331
+ return "block";
1332
+ }, signal);
1333
+ };
1334
+
1335
+ /** The block reason for a first-read prompt answered with Block or Ask again; undefined to run the read. */
1336
+ const outsideReadRefusal = async (ctx: ExtensionContext, toolName: string, path: string, signal = ctx.signal): Promise<string | undefined> => {
1337
+ const answer = await firstOutsideRead(ctx, toolName, path, signal);
1338
+ return answer === "allow" ? undefined : answer === "block" ? DENIED_CHOSE_BLOCK_OUTSIDE_READS : userDenialText();
1339
+ };
1340
+
1341
+ /**
1342
+ * An allowed call in auto mode breaks the block streak and ends a
1343
+ * consecutive-limit pause (`PauseTracker.recordAllow`), whatever allowed
1344
+ * it: a rule, a read, a fast path, the classifier or a /permissions grant.
1345
+ * A subagent's calls never touch the streak, as its blocks never count.
1346
+ */
1347
+ const noteAllow = () => {
1348
+ if (pauseTracker.recordAllow()) applyBadge();
1349
+ };
1350
+
826
1351
  pi.on("tool_call", async (event, ctx) => {
827
1352
  lastReviewCtx = ctx;
1353
+ // Settled long before the first call; a failed load leaves every bash
1354
+ // command unparseable, which escalates or prompts.
1355
+ await bashParserReady();
828
1356
  const normalizedTool = normalizeToolName(event.toolName);
1357
+ const conflict = isPathSubjectTool(normalizedTool) ? conflictingPathArguments(event.input as Record<string, unknown>) : undefined;
1358
+ if (conflict) return { block: true, reason: conflict };
829
1359
  const subject = extractSubject(normalizedTool, event.input as Record<string, unknown>);
830
- // Resolved through symlinks so the protected-path and working-directory
831
- // checks see where a write lands or a read comes from, not how the path
832
- // is spelled. Path tools only: a bash subject is a command line.
833
- const resolvedSubject =
834
- isPathSubjectTool(normalizedTool) && subject
835
- ? resolveForContainment(toAbsolute(ctx.cwd, subject, os.homedir()))
836
- : undefined;
837
1360
  // In a worktree session, worktree's tool_call handler (which runs before this
838
1361
  // one) cd-wraps bash commands for execution and publishes the model's
839
1362
  // original command — and the worktree it runs in — over the bus under this
840
- // call's id. Rule matching, the shell pre-gate, the recoverability judge and
841
- // the prompt all evaluate that original against the worktree cwd: matched
842
- // against the wrapper every configured Bash rule stopped matching, and
843
- // analysed as the wrapper (a `cd` plus a newline) every call escalated to
844
- // the classifier and the prompt showed `cd '…' && (…)` (PERMISSIONS-REVIEW-
845
- // 2026-09-05 L3). The safety floor keeps reading event.input (the wrapped
1363
+ // call's id. Rule matching, the shell pre-gate and the prompt all evaluate
1364
+ // that original against the worktree cwd: matched against the wrapper
1365
+ // every configured Bash rule stopped matching, and analysed as the wrapper
1366
+ // (a `cd` plus a newline) every call escalated to the classifier and the
1367
+ // prompt showed `cd '…' && (…)` (PERMISSIONS-REVIEW-2026-09-05 L3). The safety floor keeps reading event.input (the wrapped
846
1368
  // command that actually runs). The lookup is by toolCallId on purpose: a
847
1369
  // value inside `event.input` would be the model's to write, and rules would
848
1370
  // match a string of its choosing.
849
- const original = isShellTool(normalizedTool) ? originalCommands.get(event.toolCallId) : undefined;
1371
+ // `monitor` is cd-wrapped the same way (worktree/rewrite.ts).
1372
+ const original = isShellTool(normalizedTool) || normalizedTool === "monitor" ? originalCommands.get(event.toolCallId) : undefined;
850
1373
  const matchSubject = original?.command ?? subject;
851
- const callCwd = original?.cwd ?? ctx.cwd;
1374
+ const callCwd = original?.cwd ?? workCwd(ctx.cwd);
1375
+ // File tools are rewritten to absolute worktree paths too. Both the
1376
+ // lexical and resolved working roots must follow entry; keeping the
1377
+ // main checkout's resolved root would still fast-path writes there.
1378
+ const resolvedCallCwd = callCwd === ctx.cwd ? resolvedCwd : resolveForContainment(callCwd);
1379
+ const resolvedSubject =
1380
+ isPathSubjectTool(normalizedTool) && subject
1381
+ ? resolveForContainment(toAbsolute(callCwd, subject, os.homedir()))
1382
+ : undefined;
1383
+ const powershellParse = await parsePowerShell(normalizedTool, matchSubject, mode);
852
1384
 
853
- // Record every tool call into the classifier transcript (inputs only). In
854
- // auto mode this is the running <transcript> the classifier reads, and this
855
- // call is now its last entry — the action under review. For bash, record the
856
- // model's original command, not the worktree cd-wrapper that actually runs.
857
- if (mode === "auto") {
858
- const recordedInput =
859
- original !== undefined ? { command: original.command } : (event.input as Record<string, unknown>);
860
- transcript.push({ kind: "tool", tool: normalizedTool, input: recordedInput });
861
- // Claude Code follows a `git status` call with a ground-truth line the
862
- // classifier can trust over the model's account of the tree
863
- // (`{"meta":{"gitStatus":{"clean":…}}}`, captured 2.1.276 — findings §22).
864
- const recordedCommand = typeof recordedInput.command === "string" ? recordedInput.command : undefined;
865
- if (isShellTool(normalizedTool) && recordedCommand && isGitStatusCommand(recordedCommand)) {
866
- const clean = await gitStatusClean(callCwd);
867
- if (clean !== undefined) transcript.push({ kind: "meta", gitStatus: { clean } });
868
- }
869
- capTranscript();
870
- }
1385
+ currentMainToolCallId = event.toolCallId;
1386
+ inFlightMainCalls.push(event.toolCallId);
871
1387
 
872
- const decideWith = (allowRules: PermissionRule[]) =>
1388
+ const decideWith = (allowRules: PermissionRule[], dirs: string[] = workspaceDirs) =>
873
1389
  decide({
874
1390
  toolName: event.toolName,
875
1391
  subject: matchSubject,
@@ -880,32 +1396,53 @@ export default function permissionsExtension(pi: ExtensionAPI) {
880
1396
  allow: allowRules,
881
1397
  classifyAllShell: classifyAllShell(),
882
1398
  resolvedSubject,
883
- resolvedCwd,
1399
+ resolvedCwd: resolvedCallCwd,
884
1400
  planFilePath,
885
1401
  memoryDirPath,
886
1402
  scratchpadDirPath,
887
1403
  resultsDirPath,
888
1404
  sessionDirPath,
889
1405
  protectedDirs,
1406
+ secretPaths,
1407
+ workspaceDirs: dirs,
1408
+ claudeCodeFastPaths: usesClaudeCodeFastPaths(ctx.model),
1409
+ blockReadsOutsideWorkingDirectories: blockOutsideReads,
1410
+ readOnlyDirs: docsDirPaths,
1411
+ powershellParse,
890
1412
  });
891
1413
  let result = decideWith([...allow, ...activeSessionAllows(), ...(projectAllowTrusted ? projectAllow : [])]);
892
1414
 
893
- // A repo-shipped allow rule would decide this call: ask the user to trust
894
- // the repository's rule list first (once per list; project-trust.ts). No UI
895
- // → the rules stay off and the call takes the normal path (fail closed).
896
- if (result.decision !== "allow" && !projectAllowTrusted && !projectAllowDeclined && projectAllow.length > 0 && ctx.hasUI) {
897
- const withProject = decideWith([...allow, ...activeSessionAllows(), ...projectAllow]);
898
- if (withProject.decision === "allow" && withProject.rule && projectAllow.includes(withProject.rule)) {
899
- const firing = withProject.rule.raw;
900
- const { title, message } = describeProjectAllow(projectAllowRaw, firing);
901
- const approved = (await serializePrompt(() => ctx.ui.confirm(title, message))) === true;
902
- if (approved) {
903
- projectAllowTrusted = true;
904
- persistProjectAllowApproval(projectRoot, projectAllowRaw);
905
- result = withProject;
906
- } else {
907
- projectAllowDeclined = true;
908
- ctx.ui.notify("This repository's allow rules stay off for this session (its deny/ask rules still apply).", "info");
1415
+ // A repo-shipped allow rule or workspace directory would decide this call:
1416
+ // ask the user to trust the repository's settings first (once per list;
1417
+ // project-trust.ts). No UI → they stay off and the call takes the normal
1418
+ // path (fail closed).
1419
+ const trustProject = async (withProject: typeof result, firing: TrustFiring) => {
1420
+ const repoDirs = repoWorkspaceDirs().map((dir) => dir.raw);
1421
+ const { title, message } = describeProjectAllow(projectAllowRaw, firing, repoDirs);
1422
+ const approved = (await serializePrompt(() => ctx.ui.confirm(title, message, { signal: ctx.signal }), ctx.signal)) === true;
1423
+ if (ctx.signal?.aborted) return;
1424
+ if (approved) {
1425
+ projectAllowTrusted = true;
1426
+ persistProjectAllowApproval(projectRoot, projectTrustList());
1427
+ refreshWorkspace();
1428
+ result = withProject;
1429
+ } else {
1430
+ projectAllowDeclined = true;
1431
+ ctx.ui.notify("This repository's allow rules and workspace directories stay off for this session (its deny/ask rules still apply).", "info");
1432
+ }
1433
+ };
1434
+ if (result.decision !== "allow" && !projectAllowTrusted && !projectAllowDeclined && ctx.hasUI) {
1435
+ const withRules = projectAllow.length > 0 ? decideWith([...allow, ...activeSessionAllows(), ...projectAllow]) : undefined;
1436
+ const repoDirs = repoWorkspaceDirs();
1437
+ if (withRules?.decision === "allow" && withRules.rule && projectAllow.includes(withRules.rule)) {
1438
+ await trustProject(withRules, { rule: withRules.rule.raw });
1439
+ } else if (repoDirs.length > 0) {
1440
+ const withDirs = decideWith([...allow, ...activeSessionAllows()], [...workspaceDirs, ...repoDirs.map((dir) => resolvedOrSelf(dir.path))]);
1441
+ // A read tool's allow is "tier" or "mode"; a shell read's is "read-only" ("plan-readonly" in plan mode).
1442
+ if (withDirs.decision === "allow" && (withDirs.cause === "tier" || withDirs.cause === "mode" || withDirs.cause === "read-only" || withDirs.cause === "plan-readonly")) {
1443
+ const target = resolvedSubject ?? toAbsolute(callCwd, matchSubject, os.homedir());
1444
+ const firing = repoDirs.find((dir) => isWithin(resolvedOrSelf(dir.path), target))?.raw ?? repoDirs[0].raw;
1445
+ await trustProject(withDirs, { dir: firing });
909
1446
  }
910
1447
  }
911
1448
  }
@@ -924,17 +1461,28 @@ export default function permissionsExtension(pi: ExtensionAPI) {
924
1461
  ? safetyControlWrite({
925
1462
  toolName: normalizeToolName(event.toolName),
926
1463
  input: event.input as Record<string, unknown>,
927
- cwd: ctx.cwd,
1464
+ cwd: callCwd,
928
1465
  home: os.homedir(),
929
1466
  oneCodeProjectSettings: oneCodeProjectSettingsFile,
930
1467
  })
931
1468
  : undefined;
932
1469
 
933
- if (result.decision === "allow" && !floorReason) return undefined;
1470
+ if (result.decision === "allow" && result.cause === "outside-read") {
1471
+ const refusal = await outsideReadRefusal(ctx, normalizedTool, matchSubject);
1472
+ if (refusal) return { block: true, reason: refusal };
1473
+ }
1474
+
1475
+ if (result.decision === "allow" && !floorReason) {
1476
+ if (mode === "auto") {
1477
+ if (result.cause === "read-only") logDecision(ctx, { tool: event.toolName, subject: matchSubject, outcome: "allow", source: "pre-gate" });
1478
+ noteAllow();
1479
+ }
1480
+ return undefined;
1481
+ }
934
1482
 
935
1483
  // (dontAsk is the only mode that denies rather than allows unmatched calls.)
936
1484
  if (result.decision === "deny") {
937
- recordRuleDenial(result, event.toolName, matchSubject);
1485
+ recordRuleDenial(result, event.toolCallId, matchSubject);
938
1486
  return { block: true, reason: denyReason(result) };
939
1487
  }
940
1488
 
@@ -946,10 +1494,24 @@ export default function permissionsExtension(pi: ExtensionAPI) {
946
1494
  autoConfig ??= loadAutoModeConfig(os.homedir());
947
1495
  if (mode === "auto" || !ctx.hasUI) {
948
1496
  logDecision(ctx, { tool: event.toolName, subject, outcome: "block", source: "floor", reason: floorReason });
949
- return { block: true, reason: DENIED_SAFETY_FLOOR(floorReason) };
1497
+ return { block: true, reason: safetyFloorBlockText(floorReason) };
950
1498
  }
951
1499
  logDecision(ctx, { tool: event.toolName, subject, outcome: "prompt", source: "floor", reason: floorReason });
952
1500
  } else if (result.decision === "classify") {
1501
+ // The exact call the user approved in /permissions after the classifier
1502
+ // denied it runs once without the classifier (auto-mode/denials.ts). Deny
1503
+ // rules and the safety floor were checked above, so no grant reaches them.
1504
+ const inputKey = denialInputKey(
1505
+ normalizedTool,
1506
+ original !== undefined ? { command: original.command } : (event.input as Record<string, unknown>),
1507
+ callCwd,
1508
+ isShellTool(normalizedTool),
1509
+ );
1510
+ if (denials.takeGrant(inputKey)) {
1511
+ logDecision(ctx, { tool: event.toolName, subject: matchSubject, outcome: "allow", source: "user", reason: "approved in /permissions" });
1512
+ noteAllow();
1513
+ return undefined;
1514
+ }
953
1515
  // Auto mode is for unattended runs: a block is returned to the MODEL so it
954
1516
  // can try a safe alternative — it is never raised as a per-action user
955
1517
  // prompt. The one exception is the loop-breaker: after repeated blocks the
@@ -959,7 +1521,6 @@ export default function permissionsExtension(pi: ExtensionAPI) {
959
1521
  if (!pauseTracker.isPaused()) {
960
1522
  const outcome = await runClassifier(
961
1523
  event.toolName,
962
- event.input as Record<string, unknown>,
963
1524
  matchSubject,
964
1525
  ctx,
965
1526
  // Protected paths must always be judged; everything else may be cleared
@@ -967,20 +1528,24 @@ export default function permissionsExtension(pi: ExtensionAPI) {
967
1528
  result.cause !== "protected-path",
968
1529
  // A worktree-wrapped command is analysed as the model wrote it, in the
969
1530
  // worktree it runs in (the classifier transcript already holds it).
970
- original?.cwd ? { cwd: original.cwd } : undefined,
1531
+ {
1532
+ cwd: callCwd, powershellParse, toolCallId: event.toolCallId,
1533
+ appendEntry: { kind: "tool", tool: normalizedTool, input: original !== undefined ? { command: original.command } : event.input as Record<string, unknown> },
1534
+ },
971
1535
  );
972
1536
  if (outcome.decision === "allow") {
973
- pauseTracker.recordAllow();
1537
+ noteAllow();
1538
+ denials.settle(inputKey);
974
1539
  return undefined;
975
1540
  }
976
1541
 
1542
+ if (outcome.transcriptTooLong) return transcriptOverflow(ctx, event.toolName, matchSubject);
1543
+
977
1544
  // A timeout was not earned by looping against the gate, so it does not
978
1545
  // count toward the auto-pause. It is returned to the model as retryable
979
1546
  // (the call was never judged) rather than prompting — a prompt would
980
1547
  // hang an unattended session waiting on a classifier that was slow.
981
- if (outcome.tier === "timeout") {
982
- return { block: true, reason: BLOCKED_BY_TIMEOUT(outcome.reason) };
983
- }
1548
+ if (outcome.tier === "timeout") return { block: true, reason: classifierBlockReason(outcome) };
984
1549
 
985
1550
  const tripped = pauseTracker.recordBlock({
986
1551
  toolName: event.toolName,
@@ -990,6 +1555,19 @@ export default function permissionsExtension(pi: ExtensionAPI) {
990
1555
  ruleId: outcome.ruleId,
991
1556
  raw: outcome.raw,
992
1557
  });
1558
+ // A verdict, not a failure to reach one, can be approved in /permissions.
1559
+ if (!outcome.noVerdict) {
1560
+ denials.record({
1561
+ toolName: normalizedTool,
1562
+ display: `${event.toolName}(${previewSubject(matchSubject)})`,
1563
+ fullDisplay: `${event.toolName}(${matchSubject})`,
1564
+ inputKey,
1565
+ reason: outcome.reason,
1566
+ ...(outcome.ruleId ? { rule: outcome.ruleId } : {}),
1567
+ timestamp: Date.now(),
1568
+ });
1569
+ if (ctx.hasUI) ctx.ui.notify(deniedNotice(event.toolName, outcome.reason), "warning");
1570
+ }
993
1571
  if (tripped) {
994
1572
  const { lifetime } = pauseTracker.stats();
995
1573
  ctx.ui.notify(
@@ -1000,8 +1578,9 @@ export default function permissionsExtension(pi: ExtensionAPI) {
1000
1578
  }
1001
1579
  // Every classifier block (hard or soft) goes back to the model with the
1002
1580
  // classifier's own reason, so it can find a safe alternative. Auto mode
1003
- // does not hand a soft block to the user as a per-action prompt.
1004
- return { block: true, reason: DENIED_BY_CLASSIFIER(outcome.reason) };
1581
+ // does not hand a soft block to the user as a per-action prompt. A call
1582
+ // the classifier never judged is said to be one, not a denial.
1583
+ return { block: true, reason: classifierBlockReason(outcome) };
1005
1584
  }
1006
1585
  // Paused: fall through to the resume prompt below.
1007
1586
  }
@@ -1027,7 +1606,14 @@ export default function permissionsExtension(pi: ExtensionAPI) {
1027
1606
  floor: floorReason !== undefined,
1028
1607
  home: os.homedir(),
1029
1608
  });
1030
- const choice = await serializePrompt(() => ctx.ui.select(title, askOptions(grant)));
1609
+ const { choice, unanswered } = await askMaybeTimed(pausedResume, title, (show) => serializePrompt(show, ctx.signal), (text, timeout) =>
1610
+ ctx.ui.select(text, askOptions(grant), { signal: ctx.signal, timeout }),
1611
+ );
1612
+ if (ctx.signal?.aborted) return { block: true, reason: "The turn was stopped while waiting for the user's approval." };
1613
+ if (unanswered) {
1614
+ logDecision(ctx, { tool: event.toolName, subject: matchSubject, outcome: "block", source: "user", reason: "resume prompt unanswered" });
1615
+ return { block: true, reason: DENIED_UNANSWERED_RESUME };
1616
+ }
1031
1617
 
1032
1618
  // The user's answer is itself a gate decision worth recording — it is the
1033
1619
  // ground truth a drifting classifier gets calibrated against.
@@ -1055,12 +1641,27 @@ export default function permissionsExtension(pi: ExtensionAPI) {
1055
1641
  }
1056
1642
 
1057
1643
  // The "tell the agent what to do differently" option has to actually carry
1058
- // the user's words, or the model is left guessing why it was stopped.
1059
- const feedback = await serializePrompt(() => ctx.ui.input("What should the agent do instead?", "Optional — press Esc to skip"));
1060
- return {
1061
- block: true,
1062
- reason: feedback?.trim() ? `${DENIED_BY_USER}\n\nThe user said: ${feedback.trim()}` : DENIED_BY_USER,
1063
- };
1644
+ // the user's words, or the model is left guessing why it was stopped. Esc
1645
+ // on the prompt (no choice) rejects without asking.
1646
+ const feedback =
1647
+ choice === undefined
1648
+ ? undefined
1649
+ : await serializePrompt(() => ctx.ui.input("What should the agent do instead?", "Optional — press Esc to skip", { signal: ctx.signal }), ctx.signal);
1650
+ // Claude Code's cancelAndAbort: a rejection without the user's words stops
1651
+ // the turn and waits for them; with words, the model acts on them. pi reads
1652
+ // an abort before the block (the result would say "Operation aborted"), so
1653
+ // the abort waits for the recorded result; the run then ends as an abort,
1654
+ // which every reader of the run's outcome treats as the user's interrupt.
1655
+ // `terminate` ends pi's loop after the batch: otherwise it sent one more
1656
+ // request on the aborted signal, which came back as an error and printed
1657
+ // "Error: This operation was aborted" above the Interrupted line.
1658
+ const stop = !feedback?.trim();
1659
+ if (stop) abortAfterResult.add(event.toolCallId);
1660
+ return { block: true, reason: userDenialText(feedback), terminate: stop };
1661
+ });
1662
+
1663
+ pi.on("tool_execution_end", (event, ctx) => {
1664
+ if (abortAfterResult.delete(event.toolCallId)) ctx.abort();
1064
1665
  });
1065
1666
 
1066
1667
  /**
@@ -1081,11 +1682,14 @@ export default function permissionsExtension(pi: ExtensionAPI) {
1081
1682
  const ctx = lastReviewCtx;
1082
1683
  const { toolName, input, cwd } = call;
1083
1684
  const normalizedTool = normalizeToolName(toolName);
1685
+ const conflict = isPathSubjectTool(normalizedTool) ? conflictingPathArguments(input) : undefined;
1686
+ if (conflict) return { block: true, reason: conflict };
1084
1687
  const subject = extractSubject(normalizedTool, input);
1085
1688
  const resolvedSubject =
1086
1689
  isPathSubjectTool(normalizedTool) && subject
1087
1690
  ? resolveForContainment(toAbsolute(cwd, subject, os.homedir()))
1088
1691
  : undefined;
1692
+ const powershellParse = await parsePowerShell(normalizedTool, subject, mode);
1089
1693
 
1090
1694
  const result = decide({
1091
1695
  toolName,
@@ -1108,6 +1712,13 @@ export default function permissionsExtension(pi: ExtensionAPI) {
1108
1712
  resultsDirPath,
1109
1713
  sessionDirPath,
1110
1714
  protectedDirs,
1715
+ secretPaths,
1716
+ workspaceDirs,
1717
+ // The child's own model: a cheaper subagent gets the stricter gate.
1718
+ claudeCodeFastPaths: usesClaudeCodeFastPaths(call.model),
1719
+ blockReadsOutsideWorkingDirectories: blockOutsideReads,
1720
+ readOnlyDirs: docsDirPaths,
1721
+ powershellParse,
1111
1722
  });
1112
1723
 
1113
1724
  // A child's cwd can be a worktree (different project → different per-repo
@@ -1120,11 +1731,17 @@ export default function permissionsExtension(pi: ExtensionAPI) {
1120
1731
  ? safetyControlWrite({ toolName: normalizedTool, input, cwd, home: os.homedir() })
1121
1732
  : undefined;
1122
1733
 
1734
+ // A child's first outside read raises the prompt on the parent's terminal;
1735
+ // with no parent context there is no one to ask, as in a one-shot run.
1736
+ if (result.decision === "allow" && result.cause === "outside-read" && ctx) {
1737
+ const refusal = await outsideReadRefusal(ctx, normalizedTool, subject, call.signal);
1738
+ if (refusal) return { block: true, reason: refusal };
1739
+ }
1123
1740
  if (result.decision === "allow" && !floorReason) return undefined;
1124
1741
 
1125
1742
  if (result.decision === "deny") {
1126
1743
  // A child's rule denial must NOT be recorded here: the child path never
1127
- // mutates the parent's live transcript (docs/decisions/subagents-workflows.md
1744
+ // mutates the parent's live transcript (working-docs/decisions/subagents-workflows.md
1128
1745
  // — child calls stay out of it). Pushing a `denied` entry would leak the
1129
1746
  // child's action into the parent and flip `afterRuleDenial` on for every
1130
1747
  // later MAIN classification. The child's classifier already learns the
@@ -1146,17 +1763,20 @@ export default function permissionsExtension(pi: ExtensionAPI) {
1146
1763
  tool: normalizedTool,
1147
1764
  input: isShellTool(normalizedTool) ? { command: subject } : input,
1148
1765
  };
1149
- const outcome = await runClassifier(toolName, input, subject, ctx, result.cause !== "protected-path", {
1766
+ const outcome = await runClassifier(toolName, subject, ctx, result.cause !== "protected-path", {
1150
1767
  cwd,
1151
1768
  appendEntry,
1769
+ // The main session as far as the call that started this child's turn.
1770
+ history: childCursor(call.parentToolCallId),
1152
1771
  // The child's own turn signal, so an aborted child turn cancels the
1153
1772
  // classifier call; a fresh (never-aborted) signal only if the child
1154
1773
  // didn't supply one, so classify() still gets the signal it expects.
1155
1774
  signal: call.signal ?? new AbortController().signal,
1775
+ powershellParse,
1156
1776
  });
1157
1777
  if (outcome.decision === "allow") return undefined;
1158
- if (outcome.tier === "timeout") return { block: true, reason: BLOCKED_BY_TIMEOUT(outcome.reason) };
1159
- return { block: true, reason: DENIED_BY_CLASSIFIER(outcome.reason) };
1778
+ if (outcome.transcriptTooLong) return transcriptOverflow(ctx, toolName, subject, call.signal);
1779
+ return { block: true, reason: classifierBlockReason(outcome) };
1160
1780
  }
1161
1781
 
1162
1782
  // Safety floor: a write to the gate's own config is NEVER auto-approved and
@@ -1166,7 +1786,7 @@ export default function permissionsExtension(pi: ExtensionAPI) {
1166
1786
  // control the classifier itself can't be trusted to enforce.
1167
1787
  if (floorReason) {
1168
1788
  if (ctx) logDecision(ctx, { tool: toolName, subject, outcome: "block", source: "floor", reason: floorReason });
1169
- return { block: true, reason: DENIED_SAFETY_FLOOR(floorReason) };
1789
+ return { block: true, reason: safetyFloorBlockText(floorReason) };
1170
1790
  }
1171
1791
 
1172
1792
  // ask: needs the user. Bubble it to the parent's terminal; no UI → fail closed.
@@ -1184,10 +1804,16 @@ export default function permissionsExtension(pi: ExtensionAPI) {
1184
1804
  // its turn in the chain comes is skipped outright. Otherwise a dead agent's
1185
1805
  // dialog stays on screen and every later prompt waits behind it (M5).
1186
1806
  const childPrompt = <T>(show: () => Promise<T>): Promise<T | undefined> =>
1187
- serializePrompt(() => (call.signal?.aborted ? Promise.resolve(undefined) : show()));
1807
+ serializePrompt(() => (call.signal?.aborted ? Promise.resolve(undefined) : show()), call.signal);
1188
1808
  const grant = sessionGrant({ toolName: normalizedTool, subject, cwd, mode, cause: result.cause, home: os.homedir() });
1189
- const choice = await childPrompt(() => ctx.ui.select(title, askOptions(grant), { signal: call.signal }));
1809
+ const { choice, unanswered } = await askMaybeTimed(pausedResume, title, childPrompt, (text, timeout) =>
1810
+ ctx.ui.select(text, askOptions(grant), { signal: call.signal, timeout }),
1811
+ );
1190
1812
  if (call.signal?.aborted) return { block: true, reason: "The agent was stopped while waiting for the user's approval." };
1813
+ if (unanswered) {
1814
+ logDecision(ctx, { tool: toolName, subject, outcome: "block", source: "user", reason: "resume prompt unanswered" });
1815
+ return { block: true, reason: DENIED_UNANSWERED_RESUME };
1816
+ }
1191
1817
 
1192
1818
  if (pausedResume) {
1193
1819
  logDecision(ctx, {
@@ -1212,46 +1838,430 @@ export default function permissionsExtension(pi: ExtensionAPI) {
1212
1838
  const feedback = await childPrompt(() => ctx.ui.input("What should the agent do instead?", "Optional — press Esc to skip", { signal: call.signal }));
1213
1839
  return {
1214
1840
  block: true,
1215
- reason: feedback?.trim() ? `${DENIED_BY_USER}\n\nThe user said: ${feedback.trim()}` : DENIED_BY_USER,
1841
+ reason: userDenialText(feedback),
1216
1842
  };
1217
1843
  };
1218
1844
 
1219
- registerLocalCommand(pi, "permissions", {
1220
- description: "Show permission mode and loaded rules",
1221
- handler: async (args, ctx) => {
1222
- const fmt = (rules: PermissionRule[]) => (rules.length ? rules.map((r) => r.raw).join(", ") : "(none)");
1223
- const denials = pauseTracker.recentDenials();
1224
- const autoLines =
1225
- mode === "auto" || denials.length > 0
1226
- ? [
1227
- `auto mode: ${pauseTracker.isPaused() ? "paused (approve a prompt to resume)" : "active"}, ${
1228
- pauseTracker.stats().lifetime
1229
- } blocked this session`,
1230
- ...(denials.length > 0
1231
- ? [
1232
- "recently denied:",
1233
- ...denials.map((d) => ` ${d.toolName}(${d.subject.slice(0, 60)}) — ${d.reason}`),
1234
- ]
1235
- : []),
1236
- ]
1237
- : [];
1238
- ctx.ui.notify(
1239
- [
1240
- `mode: ${mode}`,
1241
- `deny: ${fmt(deny)}`,
1242
- `ask: ${fmt(ask)}`,
1243
- `allow: ${fmt(allow)}`,
1244
- ...(projectAllow.length > 0
1245
- ? [
1246
- `project allow (${projectAllowTrusted ? "trusted" : projectAllowDeclined ? "declined this session" : "awaiting consent"}): ${fmt(projectAllow)}`,
1247
- ]
1248
- : []),
1249
- `session allows: ${fmt(sessionAllows)}`,
1250
- ...(unparsableRules.length > 0 ? [`unparsable rules (ignored): ${unparsableRules.join(", ")}`] : []),
1251
- ...autoLines,
1252
- ].join("\n"),
1253
- "info",
1845
+ /** Where a rule came from, and why the panel cannot edit it when it cannot. */
1846
+ const describeSource = (rule: SourcedRule, home: string): { label: string; editable: boolean; note?: string } => {
1847
+ const where = tildify(rule.path, home);
1848
+ const readOnly = (label: string, note: string) => ({ label: `From ${label}`, editable: false, note });
1849
+ switch (rule.source) {
1850
+ case "onecode-user":
1851
+ return { label: `From One Code user settings (${where})`, editable: true };
1852
+ case "onecode-project":
1853
+ return { label: `From One Code project settings (${where})`, editable: true };
1854
+ case "claude-user":
1855
+ return readOnly(`Claude Code user settings (${where})`, `One Code does not edit Claude Code's files. Change this rule in ${where}.`);
1856
+ case "project":
1857
+ case "project-local": {
1858
+ const file = rule.source === "project" ? ".claude/settings.json" : ".claude/settings.local.json";
1859
+ const consent =
1860
+ rule.behavior !== "allow" ? "" : projectAllowTrusted ? " You trusted this repository's allow rules." : projectAllowDeclined ? " Its allow rules are off this session." : " Its allow rules apply only after you trust them.";
1861
+ return readOnly(`the repository's ${file}`, `This rule ships with the repository. Change it in ${file}.${consent}`);
1862
+ }
1863
+ case "managed":
1864
+ return readOnly("managed settings", "This rule is configured by managed settings and cannot be modified. Contact your system administrator for more information.");
1865
+ }
1866
+ };
1867
+
1868
+ const ruleRows = (cwd: string, home: string): Record<RuleTab, RuleRow[]> => {
1869
+ const rows: Record<RuleTab, RuleRow[]> = { allow: [], ask: [], deny: [] };
1870
+ for (const rule of listPermissionRules(cwd, home)) {
1871
+ const source = describeSource(rule, home);
1872
+ rows[rule.behavior].push({
1873
+ key: `${rule.source}\0${rule.path}\0${rule.raw}`,
1874
+ behavior: rule.behavior,
1875
+ raw: rule.raw,
1876
+ sourceLabel: source.label,
1877
+ editable: source.editable,
1878
+ ...(source.note ? { readOnlyNote: source.note } : {}),
1879
+ });
1880
+ }
1881
+ // "Don't ask again" grants live in memory for this session; deleting one ends it.
1882
+ for (const grant of sessionAllows) {
1883
+ rows.allow.push({ key: `session\0\0${grant.raw}`, behavior: "allow", raw: grant.raw, sourceLabel: "From this session (don't ask again)", editable: true });
1884
+ }
1885
+ for (const tab of RULE_TABS) rows[tab].sort((a, b) => a.raw.toLowerCase().localeCompare(b.raw.toLowerCase()));
1886
+ return rows;
1887
+ };
1888
+
1889
+ const ruleDestinations = (cwd: string, home: string): Destination[] => [
1890
+ { id: "onecode-project", label: "One Code project settings", description: `Saved in ${tildify(oneCodeProjectSettingsPath(cwd, home), home)}, for this project only` },
1891
+ { id: "onecode-user", label: "One Code user settings", description: `Saved in ${tildify(oneCodeSettingsPath(home), home)}, for every project` },
1892
+ ];
1893
+
1894
+ /** The status line under the panel's tabs. */
1895
+ const panelStatus = (): string => {
1896
+ const parts = [`Mode: ${mode}`];
1897
+ if (mode === "auto" || pauseTracker.stats().lifetime > 0) {
1898
+ parts.push(`${pauseTracker.stats().lifetime} blocked by auto mode this session`);
1899
+ if (pauseTracker.isPaused()) parts.push("auto mode paused (approve a prompt to resume)");
1900
+ }
1901
+ if (unparsableRules.length > 0) parts.push(`${unparsableRules.length} rule(s) could not be parsed and are ignored`);
1902
+ return parts.join(" · ");
1903
+ };
1904
+
1905
+ /** The text summary /permissions prints where no panel can open. */
1906
+ const permissionsSummary = (): string => {
1907
+ const fmt = (rules: PermissionRule[]) => (rules.length ? rules.map((r) => r.raw).join(", ") : "(none)");
1908
+ return [
1909
+ `mode: ${mode}`,
1910
+ `deny: ${fmt(deny)}`,
1911
+ `ask: ${fmt(ask)}`,
1912
+ `allow: ${fmt(allow)}`,
1913
+ ...(projectAllow.length > 0
1914
+ ? [`project allow (${projectAllowTrusted ? "trusted" : projectAllowDeclined ? "declined this session" : "awaiting consent"}): ${fmt(projectAllow)}`]
1915
+ : []),
1916
+ `session allows: ${fmt(sessionAllows)}`,
1917
+ ...(unparsableRules.length > 0 ? [`unparsable rules (ignored): ${unparsableRules.join(", ")}`] : []),
1918
+ ...(denials.list().length > 0 ? ["recently denied:", ...denials.list().map((d) => ` ${d.display} — ${d.reason}`)] : []),
1919
+ ].join("\n");
1920
+ };
1921
+
1922
+ /** Built-in rules per auto-mode section, counted once from the embedded ruleset. */
1923
+ let builtinCounts: ReturnType<typeof builtinRuleCounts> | undefined;
1924
+
1925
+ const autoSource = (entry: SourcedAutoModeEntry, home: string): { label: string; editable: boolean; note?: string } => {
1926
+ const where = tildify(entry.path, home);
1927
+ if (entry.source === "onecode-user") return { label: `From One Code user settings (${where})`, editable: true };
1928
+ if (entry.source === "claude-user") {
1929
+ return { label: `From Claude Code user settings (${where})`, editable: false, note: `One Code does not edit Claude Code's files. Change this entry in ${where}; it stays in effect here.` };
1930
+ }
1931
+ return { label: "From managed settings", editable: false, note: "This entry is configured by managed settings and cannot be modified. Contact your system administrator for more information." };
1932
+ };
1933
+
1934
+ const autoModeView = (home: string): AutoModeView => {
1935
+ const listed = listAutoModeEntries(home);
1936
+ const entries: AutoEntryRow[] = [];
1937
+ for (const section of AUTO_SECTIONS) {
1938
+ for (const entry of listed.filter((e) => e.key === section)) {
1939
+ const source = autoSource(entry, home);
1940
+ entries.push({
1941
+ key: `${entry.source}\0${entry.path}\0${section}\0${entry.text}`,
1942
+ section,
1943
+ text: entry.text,
1944
+ sourceLabel: source.label,
1945
+ editable: source.editable,
1946
+ ...(source.note ? { readOnlyNote: source.note } : {}),
1947
+ });
1948
+ }
1949
+ }
1950
+ const environment = loadAutoModeConfig(home).environment;
1951
+ const envSources = [...new Set(listed.filter((e) => e.key === "environment").map((e) => autoSource(e, home).label.replace(/^From /, "").replace(/ \(.*\)$/, "")))];
1952
+ const isDefault = envSources.length === 0;
1953
+ const extendsDefault = !isDefault && DEFAULT_ENVIRONMENT.every((line) => environment.includes(line));
1954
+ builtinCounts ??= builtinRuleCounts(buildRuleset(DEFAULT_ENVIRONMENT));
1955
+ return {
1956
+ builtins: builtinCounts,
1957
+ entries,
1958
+ environment: {
1959
+ lines: environment,
1960
+ summary: isDefault ? "Built-in default" : `${extendsDefault ? "Extends" : "Replaces"} the built-in default · from ${envSources.join(" and ")}`,
1961
+ isDefault,
1962
+ },
1963
+ };
1964
+ };
1965
+
1966
+ /** A panel row key for an auto-mode entry: source, file, section, text. */
1967
+ const parseAutoKey = (key: string) => {
1968
+ const [source, path, section, text] = key.split("\0");
1969
+ if (source !== "onecode-user") throw new Error(`One Code can only change auto mode rules in its own settings file; this one is in ${tildify(path, os.homedir())}.`);
1970
+ return { section: section as (typeof AUTO_SECTIONS)[number], text };
1971
+ };
1972
+ const autoLabel = (section: (typeof AUTO_SECTIONS)[number]) => AUTO_SECTION_LABELS[section].toLowerCase();
1973
+
1974
+ /**
1975
+ * Edit One Code's own `autoMode.environment` in pi's editor. It starts from
1976
+ * One Code's entries, or from the built-in default when nothing else sets
1977
+ * one (Claude Code starts from the full default text too). Saving it empty
1978
+ * removes the key, which restores the default. Returns the change line.
1979
+ */
1980
+ const editEnvironment = async (ctx: ExtensionContext, home: string): Promise<string | undefined> => {
1981
+ const listed = listAutoModeEntries(home).filter((e) => e.key === "environment");
1982
+ const own = listed.filter((e) => e.source === "onecode-user").map((e) => e.text);
1983
+ const others = listed.length - own.length;
1984
+ // `"$defaults"` in the list: the user's lines extend the built-in default,
1985
+ // which stays out of the editor and is kept on save.
1986
+ const extendsDefault = oneCodeEnvironmentExtendsDefault(home);
1987
+ const start = own.length > 0 ? own : others > 0 || extendsDefault ? [] : DEFAULT_ENVIRONMENT;
1988
+ const title =
1989
+ "Auto mode environment: one entry per line, `### ` lines are section headers. Save it empty to restore the built-in default." +
1990
+ (extendsDefault ? " These lines are added to the built-in default." : "") +
1991
+ (others > 0 ? ` These lines are added to the ${others} entries from Claude Code's user or managed settings.` : "");
1992
+ const edited = await ctx.ui.editor(title, start.join("\n"));
1993
+ if (edited === undefined) return undefined;
1994
+ const lines = edited.split("\n").map((line) => line.trimEnd()).filter((line) => line.trim().length > 0);
1995
+ if (lines.join("\n") === start.join("\n")) return undefined;
1996
+ updateOneCodeAutoModeList(
1997
+ "environment",
1998
+ (entries) => {
1999
+ const at = entries.findIndex((entry) => entry.trim() === "$defaults");
2000
+ if (at < 0 || lines.length === 0) return lines;
2001
+ return [...lines.slice(0, at), entries[at], ...lines.slice(at)];
2002
+ },
2003
+ home,
2004
+ );
2005
+ autoConfig = undefined;
2006
+ return lines.length > 0 ? `Saved your auto mode environment to ${tildify(oneCodeSettingsPath(home), home)}` : "Restored the built-in auto mode environment";
2007
+ };
2008
+
2009
+ /** Where a workspace directory came from, and why the panel cannot remove it when it cannot. */
2010
+ const workspaceRows = (home: string): WorkspaceDirRow[] => {
2011
+ const rows: WorkspaceDirRow[] = [];
2012
+ for (const dir of settingsWorkspaceDirs) {
2013
+ const where = tildify(dir.settingsPath, home);
2014
+ const key = `${dir.source}\0${dir.settingsPath}\0${dir.raw}`;
2015
+ if (dir.source === "onecode-user" || dir.source === "onecode-project") {
2016
+ rows.push({ key, path: dir.path, sourceLabel: `From One Code ${dir.source === "onecode-user" ? "user" : "project"} settings (${where})`, editable: true });
2017
+ } else if (isRepoSource(dir.source)) {
2018
+ const file = dir.source === "project" ? ".claude/settings.json" : ".claude/settings.local.json";
2019
+ const state = projectAllowTrusted ? "" : projectAllowDeclined ? ", off this session" : ", awaiting your trust";
2020
+ rows.push({
2021
+ key,
2022
+ path: dir.path,
2023
+ sourceLabel: `From the repository's ${file}${state}`,
2024
+ editable: false,
2025
+ readOnlyNote: `This directory ships with the repository. Change it in ${file}.${projectAllowTrusted ? "" : " It applies only after you trust the repository's settings, which you are asked the first time a call needs it."}`,
2026
+ });
2027
+ } else {
2028
+ const label = dir.source === "claude-user" ? `Claude Code user settings (${where})` : "managed settings";
2029
+ rows.push({ key, path: dir.path, sourceLabel: `From ${label}`, editable: false, readOnlyNote: dir.source === "claude-user" ? `One Code does not edit Claude Code's files. Change it in ${where}.` : "This directory is configured by managed settings and cannot be modified here." });
2030
+ }
2031
+ }
2032
+ for (const path of flagWorkspaceDirs) rows.push({ key: `flag\0\0${path}`, path, sourceLabel: "From --add-dir", editable: false, readOnlyNote: "Added with --add-dir for this run. Start One Code without it to leave it out." });
2033
+ for (const path of sessionWorkspaceDirs) rows.push({ key: `session\0\0${path}`, path, sourceLabel: "Added for this session", editable: true });
2034
+ return rows;
2035
+ };
2036
+
2037
+ /** Add a validated directory for this session, or remember it in One Code's project settings. Returns the change line. */
2038
+ const addWorkspaceDirectory = (ctx: ExtensionContext, path: string, remember: boolean): string => {
2039
+ const home = os.homedir();
2040
+ if (remember) {
2041
+ const target = oneCodeProjectSettingsPath(ctx.cwd, home);
2042
+ persistWorkspaceDirectory(path, target);
2043
+ reloadRules(ctx);
2044
+ return `Added directory ${path} to workspace and saved to ${tildify(target, home)}`;
2045
+ }
2046
+ if (!sessionWorkspaceDirs.includes(path)) {
2047
+ sessionWorkspaceDirs.push(path);
2048
+ recordSessionWorkspace();
2049
+ }
2050
+ refreshWorkspace();
2051
+ return `Added directory ${path} to workspace for this session`;
2052
+ };
2053
+
2054
+ /** The panel's I/O over the gate's state (panel/host.ts). */
2055
+ const panelHost = (ctx: ExtensionContext, home: string): PermissionsPanelHost => {
2056
+ return {
2057
+ view: () => ({
2058
+ denials: denials.list().map((d) => ({ id: d.id, display: d.display, ...(d.rule ? { rule: d.rule } : {}) })),
2059
+ rules: ruleRows(ctx.cwd, home),
2060
+ autoMode: autoModeView(home),
2061
+ destinations: ruleDestinations(ctx.cwd, home),
2062
+ ruleError: (raw) =>
2063
+ parseRule(raw) ? undefined : `Could not parse "${raw}". A rule is a tool name, optionally followed by a pattern in parentheses: Bash(npm test:*).`,
2064
+ workspace: { cwd: ctx.cwd, dirs: workspaceRows(home) },
2065
+ validateDir: (input) => validateWorkspaceDirectory(input, ctx.cwd, home, workspacePaths),
2066
+ }),
2067
+ status: panelStatus,
2068
+ addRule: (behavior, rule, destination) => {
2069
+ const target = destination === "onecode-user" ? oneCodeSettingsPath(home) : oneCodeProjectSettingsPath(ctx.cwd, home);
2070
+ if (!persistPermissionRule(behavior, rule, target)) throw new Error(`That ${behavior} rule is already in ${tildify(target, home)}.`);
2071
+ reloadRules(ctx);
2072
+ return `Added ${behavior} rule ${rule} to ${tildify(target, home)}`;
2073
+ },
2074
+ deleteRule: (behavior, key) => {
2075
+ const [source, path, raw] = key.split("\0");
2076
+ if (source === "session") {
2077
+ const index = sessionAllows.findIndex((grant) => grant.raw === raw);
2078
+ if (index >= 0) sessionAllows.splice(index, 1);
2079
+ } else if (source === ("onecode-user" satisfies RuleSource) || source === ("onecode-project" satisfies RuleSource)) {
2080
+ if (!removePermissionRule(behavior, raw, path)) throw new Error(`${raw} is no longer in ${tildify(path, home)}.`);
2081
+ reloadRules(ctx);
2082
+ } else {
2083
+ throw new Error(`One Code can only delete rules from its own settings files; ${raw} is in ${tildify(path, home)}.`);
2084
+ }
2085
+ return `Deleted ${behavior} rule ${raw}`;
2086
+ },
2087
+ // Auto mode rules go to One Code's user settings: autoMode is never read
2088
+ // from project settings (decisions/modes.md). The cached config is dropped
2089
+ // so the next classifier call reads the change.
2090
+ addAutoRule: (section, text) => {
2091
+ updateOneCodeAutoModeList(
2092
+ section,
2093
+ (entries) => {
2094
+ if (entries.includes(text)) throw new Error(`That ${autoLabel(section)} rule is already in your settings.`);
2095
+ return [...entries, text];
2096
+ },
2097
+ home,
2098
+ );
2099
+ autoConfig = undefined;
2100
+ return `Added auto mode ${autoLabel(section)} rule: ${text}`;
2101
+ },
2102
+ editAutoRule: (key, text) => {
2103
+ const { section, text: old } = parseAutoKey(key);
2104
+ updateOneCodeAutoModeList(
2105
+ section,
2106
+ (entries) => {
2107
+ const index = entries.indexOf(old);
2108
+ if (index < 0) throw new Error("That rule is no longer in your settings.");
2109
+ return entries.map((entry, i) => (i === index ? text : entry));
2110
+ },
2111
+ home,
2112
+ );
2113
+ autoConfig = undefined;
2114
+ return `Updated auto mode ${autoLabel(section)} rule: ${text}`;
2115
+ },
2116
+ deleteAutoRule: (key) => {
2117
+ const { section, text } = parseAutoKey(key);
2118
+ updateOneCodeAutoModeList(
2119
+ section,
2120
+ (entries) => {
2121
+ if (!entries.includes(text)) throw new Error("That rule is no longer in your settings.");
2122
+ return entries.filter((entry) => entry !== text);
2123
+ },
2124
+ home,
2125
+ );
2126
+ autoConfig = undefined;
2127
+ return `Deleted auto mode ${autoLabel(section)} rule: ${text}`;
2128
+ },
2129
+ addDir: (path, remember) => addWorkspaceDirectory(ctx, path, remember),
2130
+ removeDir: (key) => {
2131
+ const [source, settingsPath, raw] = key.split("\0");
2132
+ if (source === "session") {
2133
+ const index = sessionWorkspaceDirs.indexOf(raw);
2134
+ if (index >= 0) {
2135
+ sessionWorkspaceDirs.splice(index, 1);
2136
+ recordSessionWorkspace();
2137
+ }
2138
+ refreshWorkspace();
2139
+ } else if (source === ("onecode-user" satisfies RuleSource) || source === ("onecode-project" satisfies RuleSource)) {
2140
+ if (!removeWorkspaceDirectory(raw, settingsPath)) throw new Error(`${raw} is no longer in ${tildify(settingsPath, home)}.`);
2141
+ reloadRules(ctx);
2142
+ } else {
2143
+ throw new Error(`One Code can only remove directories it added; ${raw} comes from elsewhere.`);
2144
+ }
2145
+ return `Removed directory ${raw} from workspace`;
2146
+ },
2147
+ };
2148
+ };
2149
+
2150
+ /**
2151
+ * Open the panel, run the environment editor round trips, then tell the
2152
+ * model what happened: approvals mint their grants here, once, as in Claude
2153
+ * Code (a row toggled on and off again grants nothing). `command` names the
2154
+ * breadcrumb (`permissions`, `add-dir`).
2155
+ */
2156
+ /** `dismissed`: the command's stdout when the panel closes with nothing changed. */
2157
+ const runPermissionsPanel = async (ctx: ExtensionContext, command: string, args: string, prepare?: (state: PanelState) => void, dismissed?: string) => {
2158
+ const home = os.homedir();
2159
+ const host = panelHost(ctx, home);
2160
+ // The environment editor cannot open over the panel: close, edit, reopen.
2161
+ let session = await openPermissionsPanel(ctx, pi.events, host, undefined, prepare);
2162
+ while (session.editEnvironment) {
2163
+ session.editEnvironment = false;
2164
+ try {
2165
+ const change = await editEnvironment(ctx, home);
2166
+ if (change) session.changes.push(change);
2167
+ } catch (error) {
2168
+ session.state.notice = `Could not save the environment: ${(error as Error).message}`;
2169
+ }
2170
+ session = await openPermissionsPanel(ctx, pi.events, host, session);
2171
+ }
2172
+ const { state, changes } = session;
2173
+
2174
+ const approved = denials.approve(state.approved);
2175
+ const retried = approved.filter((d) => state.retry.has(d.id));
2176
+ // Model-facing: never the panel's clipped preview.
2177
+ const displays = approved.map((d) => d.fullDisplay);
2178
+ if (retried.length > 0) {
2179
+ // Claude Code's retry: a banner says what was allowed, and a turn starts
2180
+ // with the grant message.
2181
+ announceLocalCommand(pi, { name: command, args, stdout: changes.join("\n") });
2182
+ ctx.ui.notify(`Allowed ${retried.map((d) => d.display).join(", ")}`, "info");
2183
+ pi.sendMessage(
2184
+ { customType: PERMISSION_RETRY_TYPE, content: permissionGrantedMessage(displays), display: false },
2185
+ ctx.isIdle() ? { triggerTurn: true } : { deliverAs: "followUp", triggerTurn: true },
1254
2186
  );
2187
+ return;
2188
+ }
2189
+ if (approved.length === 0 && changes.length === 0) {
2190
+ if (dismissed) announceLocalCommand(pi, { name: command, args, stdout: dismissed });
2191
+ return;
2192
+ }
2193
+ const stdout = [...(approved.length > 0 ? [`Approved ${displays.join(", ")}`] : []), ...changes].join("\n");
2194
+ announceLocalCommand(pi, { name: command, args, stdout });
2195
+ // The grant message rides with the breadcrumb on the next prompt; no turn starts.
2196
+ if (approved.length > 0) pi.events.emit(REMINDER_CHANNEL, { text: `${permissionGrantedMessage(displays)}\n`, placement: "user-prepend", raw: true });
2197
+ };
2198
+
2199
+ /**
2200
+ * /permissions — Claude Code's panel (findings §33): approve or retry calls
2201
+ * the classifier denied, list, add or delete permission rules, manage auto
2202
+ * mode's own rules and environment, and the workspace directories.
2203
+ * Registered plainly, not through registerLocalCommand: the breadcrumb
2204
+ * carries what the panel did, so it is announced after the panel closes,
2205
+ * not before it opens.
2206
+ */
2207
+ pi.registerCommand("permissions", {
2208
+ description: "Review recently denied calls and manage permission rules, auto mode rules and workspace directories",
2209
+ handler: async (args: string, ctx: ExtensionContext) => {
2210
+ if (!canShowCustomUi(ctx)) {
2211
+ // One notice with the summary, not notifyRpcReadOnly's separate one:
2212
+ // pi shows only the last of back-to-back info notices (findings §63).
2213
+ const limitation = ctx.mode === "rpc" ? `${rpcReadOnlyText("/permissions", "manage rules or approve recently denied calls")}\n\n` : "";
2214
+ announceLocalCommand(pi, { name: "permissions", args, stdout: limitation.trim() });
2215
+ ctx.ui.notify(`${limitation}${permissionsSummary()}`, "info");
2216
+ return;
2217
+ }
2218
+ await runPermissionsPanel(ctx, "permissions", args, undefined, "Permissions dialog dismissed");
2219
+ },
2220
+ });
2221
+
2222
+ /**
2223
+ * /add-dir — Claude Code's: add a workspace directory. With a path it asks
2224
+ * whether to keep it for this session or remember it; without one it opens
2225
+ * the panel's Workspace tab on the path input.
2226
+ */
2227
+ announceArgumentHint(pi, "add-dir", "<path>");
2228
+ pi.registerCommand("add-dir", {
2229
+ description: "Add a workspace directory: /add-dir <path>",
2230
+ handler: async (args: string, ctx: ExtensionContext) => {
2231
+ const input = args.trim();
2232
+ if (!ctx.hasUI) {
2233
+ announceLocalCommand(pi, { name: "add-dir", args });
2234
+ ctx.ui.notify("/add-dir needs the interactive UI; start One Code with --add-dir instead.", "warning");
2235
+ return;
2236
+ }
2237
+ if (!input) {
2238
+ if (!canShowCustomUi(ctx)) {
2239
+ const message = "The workspace panel is unavailable in RPC. Use /add-dir <path> to add a directory.";
2240
+ announceLocalCommand(pi, { name: "add-dir", args, stdout: message });
2241
+ ctx.ui.notify(message, "info");
2242
+ return;
2243
+ }
2244
+ await runPermissionsPanel(ctx, "add-dir", args, (state) => {
2245
+ state.tab = "workspace";
2246
+ state.dialog = { kind: "addDir", draft: "" };
2247
+ });
2248
+ return;
2249
+ }
2250
+ const checked = validateWorkspaceDirectory(input, ctx.cwd, os.homedir(), workspacePaths);
2251
+ if ("error" in checked) {
2252
+ announceLocalCommand(pi, { name: "add-dir", args, stdout: checked.error });
2253
+ ctx.ui.notify(checked.error, "warning");
2254
+ return;
2255
+ }
2256
+ const choice = await ctx.ui.select(`Add ${checked.path} to the workspace? One Code will be able to read files in it and make edits when auto-accept edits is on.`, [
2257
+ "Yes, for this session",
2258
+ "Yes, and remember this directory",
2259
+ "No",
2260
+ ]);
2261
+ if (choice !== "Yes, for this session" && choice !== "Yes, and remember this directory") return;
2262
+ const change = addWorkspaceDirectory(ctx, checked.path, choice === "Yes, and remember this directory");
2263
+ announceLocalCommand(pi, { name: "add-dir", args, stdout: change });
2264
+ ctx.ui.notify(change, "info");
1255
2265
  },
1256
2266
  });
1257
2267
 
@@ -1263,18 +2273,21 @@ export default function permissionsExtension(pi: ExtensionAPI) {
1263
2273
  * surfaces a warning rather than blocking it: the work has already happened, so
1264
2274
  * the useful move is to make sure the model and the user see it.
1265
2275
  */
1266
- const reviewFlagged = (reason: string) =>
1267
- `<system-reminder>\nAuto mode reviewed this subagent's actions after it finished and flagged a concern: ${reason}\n\nTreat its output as unverified, do not act on it without checking, and tell the user what it did.\n</system-reminder>`;
2276
+ const reviewFlagged = (verdict: HandBackVerdict) => `<system-reminder>\n${handBackWarning(verdict)}\n</system-reminder>`;
1268
2277
 
1269
- // Classify a finished run's action sequence as a whole. Returns the concern to
2278
+ // Classify a finished run's action sequence as a whole. Returns the verdict to
1270
2279
  // surface, or undefined when the sequence is fine. Shared by the foreground
1271
- // (tool_result) and background (channel) paths.
2280
+ // (tool_result) and background (channel) paths. A review that reached no
2281
+ // verdict (an unreadable reply, a transcript past the limit, a timeout) is an
2282
+ // unreviewed run, not a finding.
1272
2283
  const reviewCompletedRun = async (
1273
2284
  actions: ChildAction[],
1274
2285
  ctx: ExtensionContext,
1275
2286
  subject: string,
1276
2287
  signal: AbortSignal | undefined,
1277
- ): Promise<string | undefined> => {
2288
+ /** The main-session call that started the reviewed turn, when known. */
2289
+ startedBy: string | undefined,
2290
+ ): Promise<HandBackVerdict | undefined> => {
1278
2291
  autoConfig ??= loadAutoModeConfig(os.homedir());
1279
2292
  // Present the child's actions as tool entries appended to the session
1280
2293
  // transcript, so the reviewer judges the sequence with the parent's context
@@ -1284,12 +2297,14 @@ export default function permissionsExtension(pi: ExtensionAPI) {
1284
2297
  const tool = normalizeToolName(action.toolName);
1285
2298
  return { kind: "tool", tool, input: isShellTool(tool) ? { command: action.subject } : { subject: action.subject } };
1286
2299
  });
2300
+ const { transcript, userMessages } = classifierHistory(ctx.sessionManager.getBranch(), childCursor(startedBy));
1287
2301
  const verdict = await classify(
1288
2302
  {
1289
2303
  toolName: "subagent-review",
1290
2304
  transcript: [...transcript, ...childEntries],
1291
2305
  userMessages: [...userMessages],
1292
- claudeMd: instructionsFor(ctx.cwd),
2306
+ // The main session's instructions, read where its own calls read them.
2307
+ claudeMd: instructionsFor(workCwd(ctx.cwd)),
1293
2308
  username: classifierUsername,
1294
2309
  environment: autoConfig.environment,
1295
2310
  // AutoModeConfig is structurally a RuleExtras; the user's own deny rules
@@ -1302,6 +2317,7 @@ export default function permissionsExtension(pi: ExtensionAPI) {
1302
2317
  config: autoConfig,
1303
2318
  signal,
1304
2319
  state: classifierState,
2320
+ cacheKey: classifierCacheKey(ctx.sessionManager.getSessionId()),
1305
2321
  onNotice: (message, level) => ctx.ui.notify(message, level),
1306
2322
  onUsage: onClassifierUsage,
1307
2323
  onModelUnusable: onClassifierModelUnusable,
@@ -1313,20 +2329,18 @@ export default function permissionsExtension(pi: ExtensionAPI) {
1313
2329
  subject,
1314
2330
  outcome: verdict.decision,
1315
2331
  source: "review",
2332
+ transcriptTooLong: verdict.transcriptTooLong,
2333
+ model: classifierState.pinned ? `${classifierState.pinned.provider}/${classifierState.pinned.id}` : undefined,
1316
2334
  tier: verdict.tier,
1317
2335
  ruleId: verdict.ruleId,
1318
2336
  reason: verdict.reason || undefined,
1319
2337
  });
1320
2338
  if (verdict.decision === "allow") return undefined;
1321
- pauseTracker.recordBlock({
1322
- toolName: "Agent",
1323
- subject,
1324
- reason: verdict.reason,
1325
- tier: verdict.tier,
1326
- ruleId: verdict.ruleId,
1327
- raw: verdict.raw,
1328
- });
1329
- return verdict.reason;
2339
+ // A completed run cannot be approved retroactively, so a review only warns.
2340
+ // It never counts toward the auto-mode pause: as in Claude Code, denials
2341
+ // are counted on the main permission path alone.
2342
+ const unavailable = verdict.transcriptTooLong || verdict.noVerdict || verdict.tier === "timeout";
2343
+ return { kind: unavailable ? "unavailable" : "blocked", reason: verdict.reason };
1330
2344
  };
1331
2345
 
1332
2346
  const childActions = new Map<string, ChildAction[]>();
@@ -1343,11 +2357,14 @@ export default function permissionsExtension(pi: ExtensionAPI) {
1343
2357
  // we answer `onReview`, so the verdict travels with the report. Answer
1344
2358
  // synchronously when no review will run, so the report is never delayed
1345
2359
  // for nothing. A review that throws fails closed: the report goes out
1346
- // flagged, not clean.
2360
+ // flagged, not clean. A pause does not skip it: the pause is about the
2361
+ // main loop's blocks, and a child already running when it tripped still
2362
+ // hands back a sequence nobody judged whole (PERMISSIONS-AUTOMODE-REVIEW
2363
+ // -2026-09-26 L1).
1347
2364
  const respond = payload.onReview;
1348
2365
  if (!respond) return; // every background emitter supplies the callback (hand-back-review.ts)
1349
2366
  const ctx = lastReviewCtx;
1350
- if (mode !== "auto" || payload.actions.length === 0 || pauseTracker.isPaused() || !ctx) {
2367
+ if (mode !== "auto" || payload.actions.length === 0 || !ctx) {
1351
2368
  respond(undefined);
1352
2369
  return;
1353
2370
  }
@@ -1358,8 +2375,8 @@ export default function permissionsExtension(pi: ExtensionAPI) {
1358
2375
  const respondIfCurrent = (verdict: HandBackVerdict | undefined) => {
1359
2376
  if (epoch === sessionEpoch) respond(verdict);
1360
2377
  };
1361
- reviewCompletedRun(payload.actions, ctx, label, new AbortController().signal)
1362
- .then((reason) => respondIfCurrent(reason ? { kind: "blocked", reason } : undefined))
2378
+ reviewCompletedRun(payload.actions, ctx, label, new AbortController().signal, payload.startedBy)
2379
+ .then(respondIfCurrent)
1363
2380
  .catch((error) => respondIfCurrent({ kind: "unavailable", reason: `the review itself failed (${(error as Error).message})` }));
1364
2381
  });
1365
2382
 
@@ -1367,86 +2384,14 @@ export default function permissionsExtension(pi: ExtensionAPI) {
1367
2384
  lastReviewCtx = ctx;
1368
2385
  const actions = childActions.get(event.toolCallId);
1369
2386
  if (actions) childActions.delete(event.toolCallId);
1370
- if (mode !== "auto" || !actions?.length || pauseTracker.isPaused()) return undefined;
1371
- const reason = await reviewCompletedRun(actions, ctx, "completed run", ctx.signal);
1372
- if (!reason) return undefined;
1373
- return { content: [{ type: "text" as const, text: reviewFlagged(reason) }, ...event.content] };
2387
+ // Reviewed while paused too, as the background path above is.
2388
+ if (mode !== "auto" || !actions?.length) return undefined;
2389
+ // The spawning call is this tool result's own: the run happened inside it.
2390
+ const verdict = await reviewCompletedRun(actions, ctx, "completed run", ctx.signal, event.toolCallId);
2391
+ if (!verdict) return undefined;
2392
+ return { content: [{ type: "text" as const, text: reviewFlagged(verdict) }, ...event.content] };
1374
2393
  });
1375
2394
 
1376
- /**
1377
- * Apply a chosen classifier model: validate auth first (a persisted model
1378
- * with no credentials would fail every call), persist to user scope, and
1379
- * release the session pin so the choice takes effect on the next call
1380
- * rather than after a restart.
1381
- */
1382
- const applyClassifierChoice = async (spec: string, ctx: ExtensionContext): Promise<void> => {
1383
- const model = findConfigured(ctx.modelRegistry.getAvailable(), spec);
1384
- if (!model) {
1385
- ctx.ui.notify(`No available model matches "${spec}" — check /auto-mode config for the catalog name.`, "error");
1386
- return;
1387
- }
1388
- const resolved = `${model.provider}/${model.id}`;
1389
- const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
1390
- if (!auth.ok) {
1391
- ctx.ui.notify(`Cannot use ${resolved}: ${auth.error}. Not saved.`, "error");
1392
- return;
1393
- }
1394
- try {
1395
- // Stamp the session's containment so a later session on another provider
1396
- // treats this cross-provider setting as stale (parity with /subagent).
1397
- persistClassifierModel(resolved, os.homedir(), ctx.model ? modelIdentity(ctx.model).containment : undefined);
1398
- } catch (error) {
1399
- ctx.ui.notify(`Could not save classifier model: ${error instanceof Error ? error.message : String(error)}`, "error");
1400
- return;
1401
- }
1402
- resetClassifierChoice();
1403
- ctx.ui.notify(`Auto-mode classifier set to ${resolved} (saved to ~/.onecode/settings.json).`, "info");
1404
- };
1405
-
1406
- /** `/auto-mode model` — show the picker, or apply a named model / `clear`. */
1407
- const handleModelSubcommand = async (remainder: string, ctx: ExtensionContext): Promise<void> => {
1408
- if (remainder === "clear") {
1409
- try {
1410
- persistClassifierModel(undefined, os.homedir());
1411
- } catch (error) {
1412
- ctx.ui.notify(`Could not update settings: ${error instanceof Error ? error.message : String(error)}`, "error");
1413
- return;
1414
- }
1415
- resetClassifierChoice();
1416
- ctx.ui.notify(`autoMode.classifierModel cleared. Auto mode will choose: ${describeChain(ctx)}`, "info");
1417
- return;
1418
- }
1419
- if (remainder) {
1420
- await applyClassifierChoice(remainder, ctx);
1421
- return;
1422
- }
1423
-
1424
- const available = ctx.modelRegistry.getAvailable();
1425
- if (available.length === 0) {
1426
- ctx.ui.notify("No models are available — authenticate a provider first.", "warning");
1427
- return;
1428
- }
1429
- // The picker needs focus and a terminal; elsewhere say what to type.
1430
- if (!ctx.hasUI || ctx.mode !== "tui") {
1431
- autoConfig ??= loadAutoModeConfig(os.homedir());
1432
- ctx.ui.notify(
1433
- `classifierModel: ${autoConfig.classifierModel ?? "(not set)"}. Set one with /auto-mode model <provider/model-id>, or clear it with /auto-mode model clear.`,
1434
- "info",
1435
- );
1436
- return;
1437
- }
1438
-
1439
- autoConfig ??= loadAutoModeConfig(os.homedir());
1440
- const current = autoConfig.classifierModel;
1441
- const entries = toPickerEntries(available);
1442
-
1443
- const chosen = await ctx.ui.custom<PickerEntry | null>((tui, theme, _keybindings, done) =>
1444
- modelPickerComponent({ entries, current }, tui, theme, done),
1445
- );
1446
-
1447
- if (chosen) await applyClassifierChoice(pickerSpec(chosen), ctx);
1448
- };
1449
-
1450
2395
  /**
1451
2396
  * `/auto-mode setup` — Claude Code's setup-wizard flow: ask how this
1452
2397
  * environment is used, gather facts (git/gh/CLAUDE.md, opt-in shell
@@ -1525,7 +2470,8 @@ export default function permissionsExtension(pi: ExtensionAPI) {
1525
2470
  // must appear in both lists (removing the One Code copy alone would leave the
1526
2471
  // exposure and a false "fixed" impression).
1527
2472
  const oneCodeAllow = new Set(oneCodePermissionAllow(os.homedir()));
1528
- const claudeAllow = new Set(claudeUserPermissionAllow(os.homedir()));
2473
+ // Independent mode reads no ~/.claude rule, so none of them is in force.
2474
+ const claudeAllow = new Set(claudeSourcesOn() ? claudeUserPermissionAllow(os.homedir()) : []);
1529
2475
  const removable = flagged.filter((entry) => oneCodeAllow.has(entry.rule));
1530
2476
  const claudeSide = flagged.filter((entry) => claudeAllow.has(entry.rule));
1531
2477
 
@@ -1562,8 +2508,89 @@ export default function permissionsExtension(pi: ExtensionAPI) {
1562
2508
  }
1563
2509
  };
1564
2510
 
2511
+ /**
2512
+ * Apply a chosen classifier model: validate auth first (a persisted model
2513
+ * with no credentials would fail every call), persist to user scope, release
2514
+ * the session pin so the choice takes effect on the next call, and say what
2515
+ * it costs (`lib/model-choice-warnings.ts`).
2516
+ */
2517
+ const applyClassifierChoice = async (spec: string, ctx: ExtensionContext): Promise<void> => {
2518
+ const model = findConfigured(ctx.modelRegistry.getAvailable(), spec);
2519
+ if (!model) {
2520
+ ctx.ui.notify(`No available model matches "${spec}". Run /auto-mode model for the list.`, "error");
2521
+ return;
2522
+ }
2523
+ const resolved = `${model.provider}/${model.id}`;
2524
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
2525
+ if (!auth.ok) {
2526
+ ctx.ui.notify(`Cannot use ${resolved}: ${auth.error}. Not saved.`, "error");
2527
+ return;
2528
+ }
2529
+ try {
2530
+ // Stamp the session's containment so a later session on another provider
2531
+ // treats this cross-provider setting as stale (parity with /subagent).
2532
+ persistClassifierModel(resolved, os.homedir(), ctx.model ? modelIdentity(ctx.model).containment : undefined);
2533
+ } catch (error) {
2534
+ ctx.ui.notify(`Could not save classifier model: ${error instanceof Error ? error.message : String(error)}`, "error");
2535
+ return;
2536
+ }
2537
+ // Re-plan, then announce the choice with its warnings, in any mode: the
2538
+ // setting applies whenever auto mode is on.
2539
+ resetClassifierChoice(ctx.model, false);
2540
+ announceClassifierChoice(ctx.model, { lead: `Auto-mode classifier set to ${resolved} (saved to ~/.onecode/settings.json).`, force: true });
2541
+ };
2542
+
2543
+ /** `/auto-mode model` — show the picker, or apply a named model / `clear`. */
2544
+ const handleModelSubcommand = async (remainder: string, ctx: ExtensionContext): Promise<void> => {
2545
+ if (remainder === "clear") {
2546
+ try {
2547
+ persistClassifierModel(undefined, os.homedir());
2548
+ } catch (error) {
2549
+ ctx.ui.notify(`Could not update settings: ${error instanceof Error ? error.message : String(error)}`, "error");
2550
+ return;
2551
+ }
2552
+ // One info line: pi folds back-to-back info notices (findings §63).
2553
+ resetClassifierChoice(ctx.model, false);
2554
+ announceClassifierChoice(ctx.model, { lead: `autoMode.classifierModel cleared. Auto mode chooses automatically: ${describeChain(ctx)}`, force: true });
2555
+ return;
2556
+ }
2557
+ if (remainder) {
2558
+ await applyClassifierChoice(remainder, ctx);
2559
+ return;
2560
+ }
2561
+ const available = ctx.modelRegistry.getAvailable();
2562
+ if (available.length === 0) {
2563
+ ctx.ui.notify("No models are available — authenticate a provider first.", "warning");
2564
+ return;
2565
+ }
2566
+ autoConfig ??= loadAutoModeConfig(os.homedir());
2567
+ // The picker needs focus and a terminal; elsewhere say what to type.
2568
+ if (!ctx.hasUI || ctx.mode !== "tui") {
2569
+ ctx.ui.notify(
2570
+ `classifierModel: ${autoConfig.classifierModel ?? "(not set: chosen automatically)"}. Set one with /auto-mode model <provider/model-id>, or clear it with /auto-mode model clear.`,
2571
+ "info",
2572
+ );
2573
+ return;
2574
+ }
2575
+ const chosen = await panelUi(pi.events, ctx.ui).custom<PickerEntry | null>((tui, theme, _keybindings, done) =>
2576
+ modelPickerComponent(
2577
+ {
2578
+ entries: toPickerEntries(available),
2579
+ current: autoConfig?.classifierModel,
2580
+ title: "Select the auto-mode classifier model",
2581
+ subtitle: "Screens auto-mode tool calls; /auto-mode model clear returns to the automatic choice",
2582
+ },
2583
+ tui,
2584
+ theme,
2585
+ done,
2586
+ ),
2587
+ );
2588
+ if (chosen) await applyClassifierChoice(pickerSpec(chosen), ctx);
2589
+ };
2590
+
1565
2591
  registerLocalCommand(pi, "auto-mode", {
1566
2592
  description: "Auto-mode classifier: /auto-mode [setup|defaults|config|model [provider/model-id|clear]]",
2593
+ argumentHint: "[setup|defaults|config|model [provider/model-id|clear]]",
1567
2594
  getArgumentCompletions: () =>
1568
2595
  [
1569
2596
  { value: "setup", label: "analyze this environment and draft the config" },
@@ -1573,14 +2600,14 @@ export default function permissionsExtension(pi: ExtensionAPI) {
1573
2600
  ],
1574
2601
  handler: async (args, ctx) => {
1575
2602
  const [sub, ...rest] = args.trim().split(/\s+/).filter(Boolean);
1576
- if (sub === "model") {
1577
- await handleModelSubcommand(rest.join(" ").trim(), ctx);
1578
- return;
1579
- }
1580
2603
  if (sub === "setup") {
1581
2604
  await handleSetupSubcommand(ctx);
1582
2605
  return;
1583
2606
  }
2607
+ if (sub === "model") {
2608
+ await handleModelSubcommand(rest.join(" ").trim(), ctx);
2609
+ return;
2610
+ }
1584
2611
  const which = sub ?? "config";
1585
2612
  if (which !== "defaults" && which !== "config") {
1586
2613
  ctx.ui.notify(
@@ -1625,8 +2652,8 @@ export default function permissionsExtension(pi: ExtensionAPI) {
1625
2652
  ...(which === "config" && "classifyAllShell" in shown
1626
2653
  ? [
1627
2654
  `classifyAllShell: ${shown.classifyAllShell}`,
2655
+ `classifierModel: ${"classifierModel" in shown && shown.classifierModel ? shown.classifierModel : "(not set: chosen automatically; /auto-mode model chooses one)"}`,
1628
2656
  `logDecisions: ${shown.logDecisions} (auto-mode-decisions.jsonl next to the session files)`,
1629
- `classifierModel: ${shown.classifierModel ?? "(not set — /auto-mode model chooses one)"}`,
1630
2657
  // Which model actually screens calls, and why — this reads the
1631
2658
  // user's prompts, so it should not take knowing the code to find out.
1632
2659
  `classifier in use: ${
@@ -1650,6 +2677,7 @@ export default function permissionsExtension(pi: ExtensionAPI) {
1650
2677
 
1651
2678
  registerLocalCommand(pi, "allow", {
1652
2679
  description: 'Persist an allow rule: /allow Bash(npm test:*) [global]',
2680
+ argumentHint: "<rule> [global]",
1653
2681
  handler: async (args, ctx) => {
1654
2682
  const global = /\s+global$/.test(args.trim());
1655
2683
  const raw = args.trim().replace(/\s+global$/, "");