one-code-extension 0.1.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (300) hide show
  1. package/LICENSE +20 -0
  2. package/README.md +200 -172
  3. package/agents/explore.md +9 -6
  4. package/agents/general-purpose.md +1 -1
  5. package/agents/plan.md +6 -4
  6. package/docs/guide/README.md +52 -0
  7. package/docs/guide/bring-your-claude-code-setup.md +126 -0
  8. package/docs/guide/configuration.md +171 -0
  9. package/docs/guide/differences-from-claude-code.md +150 -0
  10. package/docs/guide/doctor.md +86 -0
  11. package/docs/guide/hooks.md +109 -0
  12. package/docs/guide/installation.md +124 -0
  13. package/docs/guide/permissions-modes-and-auto-mode.md +450 -0
  14. package/docs/guide/providers-and-models.md +194 -0
  15. package/docs/guide/reference.md +224 -0
  16. package/docs/guide/sessions-and-context.md +190 -0
  17. package/docs/guide/skills-plugins-and-mcp.md +176 -0
  18. package/docs/guide/subagents-and-workflows.md +193 -0
  19. package/docs/guide/tasks-and-background-work.md +111 -0
  20. package/docs/guide/terminal-interface.md +155 -0
  21. package/docs/guide/tools.md +175 -0
  22. package/docs/guide/troubleshooting.md +175 -0
  23. package/extensions/ask-user/index.ts +65 -71
  24. package/extensions/ask-user/questions.ts +15 -28
  25. package/extensions/ask-user/widget.ts +533 -0
  26. package/extensions/auto-mode/actions.ts +9 -26
  27. package/extensions/auto-mode/classifier-prompt.ts +127 -0
  28. package/extensions/auto-mode/classifier.ts +305 -227
  29. package/extensions/auto-mode/config.ts +279 -77
  30. package/extensions/auto-mode/defaults.ts +17 -60
  31. package/extensions/auto-mode/instructions.ts +2 -1
  32. package/extensions/auto-mode/model-picker.ts +53 -31
  33. package/extensions/auto-mode/model-select.ts +153 -82
  34. package/extensions/auto-mode/paths.ts +13 -1
  35. package/extensions/auto-mode/pause.ts +8 -0
  36. package/extensions/auto-mode/prompt.ts +205 -238
  37. package/extensions/auto-mode/recoverability.ts +229 -0
  38. package/extensions/auto-mode/rules.ts +92 -75
  39. package/extensions/auto-mode/safety-floor.ts +43 -12
  40. package/extensions/auto-mode/sensitive.ts +11 -0
  41. package/extensions/auto-mode/setup-run.ts +245 -0
  42. package/extensions/auto-mode/setup.ts +235 -0
  43. package/extensions/auto-mode/shell-analysis.ts +332 -26
  44. package/extensions/auto-mode/transcript.ts +102 -0
  45. package/extensions/background/index.ts +256 -43
  46. package/extensions/background/monitor-batch.ts +58 -0
  47. package/extensions/background/registry.ts +9 -0
  48. package/extensions/background/wakeup.ts +45 -0
  49. package/extensions/bash/background.ts +47 -22
  50. package/extensions/bash/guards.ts +229 -0
  51. package/extensions/bash/index.ts +81 -19
  52. package/extensions/branding/index.ts +44 -18
  53. package/extensions/branding/startup.ts +23 -8
  54. package/extensions/claude-compat/index.ts +17 -2
  55. package/extensions/claude-context/index.ts +41 -12
  56. package/extensions/clear/index.ts +27 -0
  57. package/extensions/compaction/fit.ts +121 -0
  58. package/extensions/compaction/index.ts +176 -71
  59. package/extensions/compaction/prompt.ts +21 -1
  60. package/extensions/context-budget/budget.ts +61 -0
  61. package/extensions/context-budget/index.ts +66 -0
  62. package/extensions/context-management/index.ts +40 -16
  63. package/extensions/doctor/build.ts +226 -0
  64. package/extensions/doctor/cli.ts +99 -0
  65. package/extensions/doctor/compat.ts +532 -0
  66. package/extensions/doctor/dependencies.ts +186 -0
  67. package/extensions/doctor/fix-prompt.ts +125 -0
  68. package/extensions/doctor/index.ts +308 -0
  69. package/extensions/doctor/models.ts +231 -0
  70. package/extensions/doctor/presets.ts +208 -0
  71. package/extensions/doctor/report.ts +189 -0
  72. package/extensions/doctor/update-lookup.ts +46 -0
  73. package/extensions/doctor/viewer.ts +124 -0
  74. package/extensions/effort/index.ts +41 -4
  75. package/extensions/effort/slider.ts +33 -2
  76. package/extensions/file-tracker/index.ts +143 -20
  77. package/extensions/file-tracker/replay.ts +46 -0
  78. package/extensions/file-tracker/tracker.ts +51 -4
  79. package/extensions/footer/footer-line.ts +207 -0
  80. package/extensions/footer/index.ts +152 -0
  81. package/extensions/footer/pr.ts +53 -0
  82. package/extensions/hooks/executor.ts +57 -20
  83. package/extensions/hooks/formatter-notice.ts +76 -0
  84. package/extensions/hooks/index.ts +314 -73
  85. package/extensions/hooks/matcher.ts +35 -1
  86. package/extensions/hooks/plugin-hooks.ts +3 -3
  87. package/extensions/hooks/protocol.ts +58 -0
  88. package/extensions/hooks/subagent-bridge.ts +77 -0
  89. package/extensions/hooks/trust.ts +15 -4
  90. package/extensions/init/index.ts +26 -0
  91. package/extensions/init/prompt.ts +40 -0
  92. package/extensions/interrupted/index.ts +35 -0
  93. package/extensions/interrupted/line.ts +9 -0
  94. package/extensions/lib/abort.ts +15 -0
  95. package/extensions/lib/agent-loader.ts +183 -0
  96. package/extensions/lib/anthropic-payload.ts +15 -0
  97. package/extensions/lib/atomic-write.ts +56 -0
  98. package/extensions/lib/bridge-watch.ts +20 -0
  99. package/extensions/lib/cache-retention.ts +60 -0
  100. package/extensions/lib/capability-index.ts +466 -0
  101. package/extensions/lib/claude-context.ts +318 -27
  102. package/extensions/lib/claude-settings.ts +64 -0
  103. package/extensions/lib/consent-preview.ts +30 -0
  104. package/extensions/lib/deferred.ts +144 -13
  105. package/extensions/lib/favorites.ts +43 -0
  106. package/extensions/lib/fetch-timeout.ts +15 -0
  107. package/extensions/lib/frontmatter.ts +31 -0
  108. package/extensions/lib/git.ts +44 -2
  109. package/extensions/lib/hook-gate.ts +90 -0
  110. package/extensions/lib/interrupt.ts +94 -0
  111. package/extensions/lib/mcp-overrides.ts +61 -0
  112. package/extensions/lib/mcp-share.ts +25 -0
  113. package/extensions/lib/mcp-status.ts +34 -0
  114. package/extensions/lib/memory.ts +41 -6
  115. package/extensions/lib/model-facts.generated.json +2480 -0
  116. package/extensions/lib/model-facts.ts +162 -0
  117. package/extensions/lib/model-policy.ts +145 -189
  118. package/extensions/lib/model-tier.ts +480 -64
  119. package/extensions/lib/model-unusable.ts +37 -0
  120. package/extensions/lib/notifications.ts +320 -1
  121. package/extensions/lib/one-code-settings.ts +63 -0
  122. package/extensions/lib/original-command.ts +71 -0
  123. package/extensions/lib/package-version.ts +31 -0
  124. package/extensions/lib/paths.ts +39 -4
  125. package/extensions/lib/permission-gate.ts +192 -0
  126. package/extensions/lib/persisted-output.ts +18 -0
  127. package/extensions/lib/pi-ai-estimate.ts +160 -0
  128. package/extensions/lib/pi-version.ts +1 -1
  129. package/extensions/lib/plan-mode-channels.ts +11 -0
  130. package/extensions/lib/plugin-overrides.ts +24 -0
  131. package/extensions/lib/plugin-root.ts +44 -0
  132. package/extensions/lib/plugins.ts +233 -54
  133. package/extensions/lib/prefix-warm-gate.ts +157 -0
  134. package/extensions/lib/process-tree.ts +56 -0
  135. package/extensions/lib/reminders.ts +321 -68
  136. package/extensions/lib/scratchpad.ts +11 -8
  137. package/extensions/lib/session-lifecycle.ts +28 -0
  138. package/extensions/lib/settings-channels.ts +14 -0
  139. package/extensions/lib/shell-tasks.ts +54 -0
  140. package/extensions/lib/skill-overrides.ts +86 -0
  141. package/extensions/lib/skill-scan.ts +110 -0
  142. package/extensions/lib/text-width.ts +267 -0
  143. package/extensions/lib/tui-render.ts +281 -54
  144. package/extensions/lib/usage-bus.ts +75 -0
  145. package/extensions/lib/usage-tracker.ts +67 -0
  146. package/extensions/lib/worktree-isolation.ts +170 -0
  147. package/extensions/lsp/client.ts +69 -12
  148. package/extensions/lsp/format.ts +12 -2
  149. package/extensions/lsp/index.ts +273 -84
  150. package/extensions/lsp/install-hints.ts +43 -0
  151. package/extensions/lsp/keep-alive.ts +19 -0
  152. package/extensions/lsp/plugin-servers.ts +331 -0
  153. package/extensions/lsp/watcher.ts +157 -0
  154. package/extensions/mcp/announce.ts +99 -0
  155. package/extensions/mcp/client.ts +181 -22
  156. package/extensions/mcp/config.ts +55 -24
  157. package/extensions/mcp/index.ts +586 -86
  158. package/extensions/mcp/oauth/browser.ts +26 -0
  159. package/extensions/mcp/oauth/callback.ts +104 -0
  160. package/extensions/mcp/oauth/flow.ts +68 -0
  161. package/extensions/mcp/oauth/provider.ts +147 -0
  162. package/extensions/mcp/oauth/store.ts +86 -0
  163. package/extensions/mcp/panel/keys.ts +34 -0
  164. package/extensions/mcp/panel/model.ts +62 -0
  165. package/extensions/mcp/panel/render.ts +141 -0
  166. package/extensions/mcp/panel/state.ts +108 -0
  167. package/extensions/mcp/schema.ts +83 -6
  168. package/extensions/mcp/trust.ts +340 -0
  169. package/extensions/memory/entries.ts +163 -0
  170. package/extensions/memory/index.ts +119 -2
  171. package/extensions/memory/open-external.ts +97 -0
  172. package/extensions/memory/panel.ts +124 -0
  173. package/extensions/model-default/index.ts +74 -0
  174. package/extensions/notebook/index.ts +36 -18
  175. package/extensions/notebook/notebook.ts +110 -17
  176. package/extensions/permissions/index.ts +1012 -247
  177. package/extensions/permissions/matcher.ts +488 -57
  178. package/extensions/permissions/modes.ts +55 -7
  179. package/extensions/permissions/project-trust.ts +81 -0
  180. package/extensions/permissions/protected-paths.ts +16 -3
  181. package/extensions/permissions/rule-prose.ts +79 -0
  182. package/extensions/permissions/session-grant.ts +91 -0
  183. package/extensions/permissions/settings.ts +112 -33
  184. package/extensions/permissions/subagent-gate.ts +57 -0
  185. package/extensions/plan-mode/index.ts +143 -33
  186. package/extensions/plan-mode/reminder.ts +16 -19
  187. package/extensions/plan-mode/viewer.ts +40 -31
  188. package/extensions/plugins/counts.ts +68 -0
  189. package/extensions/plugins/index.ts +496 -32
  190. package/extensions/plugins/install/install.ts +167 -0
  191. package/extensions/plugins/install/paths.ts +23 -0
  192. package/extensions/plugins/install/registry.ts +72 -0
  193. package/extensions/plugins/marketplace/git.ts +103 -0
  194. package/extensions/plugins/marketplace/official.ts +36 -0
  195. package/extensions/plugins/marketplace/parse.ts +94 -0
  196. package/extensions/plugins/marketplace/registry.ts +60 -0
  197. package/extensions/plugins/marketplace/sync.ts +146 -0
  198. package/extensions/plugins/marketplace/types.ts +128 -0
  199. package/extensions/plugins/panel/keys.ts +52 -0
  200. package/extensions/plugins/panel/render.ts +315 -0
  201. package/extensions/plugins/panel/rows.ts +230 -0
  202. package/extensions/plugins/panel/state.ts +333 -0
  203. package/extensions/recap/index.ts +215 -0
  204. package/extensions/recap/prompt.ts +44 -0
  205. package/extensions/recap/scheduler.ts +87 -0
  206. package/extensions/search-tools/index.ts +0 -0
  207. package/extensions/search-tools/tools.ts +7 -5
  208. package/extensions/skill/index.ts +360 -34
  209. package/extensions/skill/invoke.ts +185 -0
  210. package/extensions/skill/listing.ts +21 -0
  211. package/extensions/skill/panel/keys.ts +41 -0
  212. package/extensions/skill/panel/render.ts +91 -0
  213. package/extensions/skill/panel/state.ts +115 -0
  214. package/extensions/spinner/index.ts +110 -0
  215. package/extensions/spinner/line.ts +51 -0
  216. package/extensions/spinner/verbs.ts +201 -0
  217. package/extensions/subagents/agents.ts +88 -44
  218. package/extensions/subagents/default-model.ts +62 -32
  219. package/extensions/subagents/delegation-steer.ts +23 -0
  220. package/extensions/subagents/hand-back-review.ts +67 -0
  221. package/extensions/subagents/index.ts +1545 -346
  222. package/extensions/subagents/live-runs.ts +293 -0
  223. package/extensions/subagents/model-select.ts +252 -82
  224. package/extensions/subagents/outcome.ts +90 -0
  225. package/extensions/subagents/panel-keys.ts +41 -0
  226. package/extensions/subagents/panel-render.ts +357 -0
  227. package/extensions/subagents/panel-widget.ts +330 -0
  228. package/extensions/subagents/pending-claim.ts +45 -0
  229. package/extensions/subagents/prose.ts +105 -0
  230. package/extensions/subagents/runner.ts +674 -0
  231. package/extensions/subagents/runs.ts +68 -1
  232. package/extensions/subagents/send-to-main-tool.ts +55 -0
  233. package/extensions/subagents/session-turns.ts +138 -0
  234. package/extensions/subagents/shell-panel.ts +198 -0
  235. package/extensions/subagents/usage.ts +16 -4
  236. package/extensions/subagents/worktree.ts +13 -0
  237. package/extensions/system-prompt/environment.ts +5 -3
  238. package/extensions/system-prompt/git-status.ts +93 -0
  239. package/extensions/system-prompt/index.ts +23 -1
  240. package/extensions/system-prompt/template.ts +34 -4
  241. package/extensions/system-prompt/tiers/common.ts +31 -6
  242. package/extensions/system-prompt/tiers/low.ts +13 -3
  243. package/extensions/system-prompt/tiers/mid.ts +21 -8
  244. package/extensions/system-reminder/index.ts +53 -9
  245. package/extensions/tasks/index.ts +65 -16
  246. package/extensions/tasks/store.ts +76 -3
  247. package/extensions/tool-search/announce.ts +42 -0
  248. package/extensions/tool-search/index.ts +136 -13
  249. package/extensions/turn-duration/index.ts +60 -0
  250. package/extensions/turn-duration/line.ts +22 -0
  251. package/extensions/turn-duration/span.ts +41 -0
  252. package/extensions/turn-duration/verbs.ts +24 -0
  253. package/extensions/web/backends.ts +421 -0
  254. package/extensions/web/index.ts +178 -21
  255. package/extensions/web-fetch/index.ts +74 -18
  256. package/extensions/web-fetch/summarize.ts +1 -1
  257. package/extensions/workflow/agent-session.ts +182 -112
  258. package/extensions/workflow/globals.ts +129 -106
  259. package/extensions/workflow/index.ts +343 -28
  260. package/extensions/workflow/records.ts +118 -0
  261. package/extensions/workflow/run-manager.ts +80 -31
  262. package/extensions/workflow/saved-workflows.ts +2 -1
  263. package/extensions/workflow/script-worker.mjs +110 -0
  264. package/extensions/workflow/types.ts +74 -8
  265. package/extensions/workflow/viewer.ts +562 -0
  266. package/extensions/workflow/vm-runtime.ts +118 -22
  267. package/extensions/workflow/widget.ts +126 -18
  268. package/extensions/worktree/guards.ts +240 -0
  269. package/extensions/worktree/index.ts +30 -5
  270. package/extensions/worktree/rewrite.ts +32 -16
  271. package/package.json +20 -10
  272. package/skills/code-review/SKILL.md +165 -0
  273. package/skills/fewer-permission-prompts/SKILL.md +61 -0
  274. package/skills/security-review/SKILL.md +178 -0
  275. package/skills/simplify/SKILL.md +70 -0
  276. package/themes/{one-code-light.json → onecode-light.json} +6 -1
  277. package/themes/{one-code.json → onecode.json} +6 -1
  278. package/types/app.d.ts +8 -1
  279. package/types/community-packages.d.ts +45 -2
  280. package/docs/decisions/auto-mode.md +0 -526
  281. package/docs/decisions/branding.md +0 -127
  282. package/docs/decisions/code-review-remediation.md +0 -106
  283. package/docs/decisions/compaction.md +0 -140
  284. package/docs/decisions/distribution.md +0 -168
  285. package/docs/decisions/hooks.md +0 -64
  286. package/docs/decisions/lsp.md +0 -58
  287. package/docs/decisions/mcp.md +0 -104
  288. package/docs/decisions/memory-state.md +0 -170
  289. package/docs/decisions/model-policy.md +0 -126
  290. package/docs/decisions/modes.md +0 -178
  291. package/docs/decisions/skills-plugins.md +0 -56
  292. package/docs/decisions/subagents-workflows.md +0 -225
  293. package/docs/decisions/system-prompt.md +0 -98
  294. package/docs/decisions/tools.md +0 -407
  295. package/docs/decisions/tui.md +0 -202
  296. package/docs/decisions.md +0 -148
  297. package/extensions/subagents/child.ts +0 -414
  298. package/extensions/subagents/rpc-turns.ts +0 -123
  299. package/extensions/todo/index.ts +0 -133
  300. package/extensions/workflow/permission-gate.ts +0 -71
@@ -19,31 +19,41 @@
19
19
 
20
20
  import os from "node:os";
21
21
  import { join } from "node:path";
22
+ import type { Api, Model } from "@earendil-works/pi-ai";
22
23
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
23
- import {
24
- type ChildAction,
25
- renderActions,
26
- SUBAGENT_ACTIONS_CHANNEL,
27
- type SubagentActionsPayload,
28
- } from "../auto-mode/actions.ts";
24
+ import { type ChildAction, SUBAGENT_ACTIONS_CHANNEL, type SubagentActionsPayload } from "../auto-mode/actions.ts";
25
+ import { createTaskNotifier } from "../lib/notifications.ts";
26
+ import { MODEL_UNUSABLE_CHANNEL, type ModelUnusableEvent } from "../lib/model-unusable.ts";
27
+ import { customMessageText, notificationComponent } from "../lib/tui-render.ts";
29
28
  import { classify, createClassifierState } from "../auto-mode/classifier.ts";
30
29
  import {
31
30
  type AutoModeConfig,
32
31
  autoModeSettingsPaths,
33
32
  loadAutoModeConfig,
33
+ claudeUserPermissionAllow,
34
34
  loadAutoModeConfigWithDiagnostics,
35
+ oneCodePermissionAllow,
36
+ persistAutoModeSetup,
35
37
  persistClassifierModel,
38
+ removeOneCodePermissionAllow,
36
39
  } from "../auto-mode/config.ts";
40
+ import { auditPermissionAllow, renderProposal, settingsPatch } from "../auto-mode/setup.ts";
41
+ import { draftSetup, gatherFacts } from "../auto-mode/setup-run.ts";
37
42
  import { modelPickerComponent, type PickerEntry, pickerSpec, toPickerEntries } from "../auto-mode/model-picker.ts";
38
- import { DEFAULT_ALLOW, DEFAULT_ENVIRONMENT, DEFAULT_HARD_DENY, DEFAULT_SOFT_DENY } from "../auto-mode/defaults.ts";
43
+ import { DEFAULT_ENVIRONMENT } from "../auto-mode/defaults.ts";
44
+ import type { TranscriptEntry } from "../auto-mode/transcript.ts";
39
45
  import { appendDecision, type DecisionEntry, decisionEntry } from "../auto-mode/decision-log.ts";
40
46
  import { loadProjectInstructions } from "../auto-mode/instructions.ts";
41
47
  import { classifierCandidates, describeCandidate, findConfigured } from "../auto-mode/model-select.ts";
42
- import { resolveForContainment, toAbsolute } from "../auto-mode/paths.ts";
48
+ import { modelIdentity } from "../lib/model-policy.ts";
49
+ import { isWithin, resolveForContainment, toAbsolute } from "../auto-mode/paths.ts";
43
50
  import { PauseTracker } from "../auto-mode/pause.ts";
51
+ import { checkRecoverability } from "../auto-mode/recoverability.ts";
44
52
  import { safetyControlWrite } from "../auto-mode/safety-floor.ts";
53
+ import { isExecutionPrimitivePath, isSensitivePath } from "../auto-mode/sensitive.ts";
45
54
  import { analyzeShellCommand, type ShellEvidence } from "../auto-mode/shell-analysis.ts";
46
55
  import { projectMemoryDir } from "../lib/memory.ts";
56
+ import { sessionResultsDir } from "../lib/persisted-output.ts";
47
57
  import { sessionScratchpadDir } from "../lib/scratchpad.ts";
48
58
  import { REMINDER_CHANNEL } from "../lib/reminders.ts";
49
59
  import {
@@ -51,53 +61,120 @@ import {
51
61
  extractSubject,
52
62
  normalizeToolName,
53
63
  parseRule,
54
- parseRules,
64
+ parseRulesReport,
55
65
  type PermissionMode,
56
66
  type PermissionRule,
67
+ isPathSubjectTool,
57
68
  } from "./matcher.ts";
69
+ import { type SessionGrant, sessionGrant } from "./session-grant.ts";
58
70
  import { modeBadge, nextMode, PERMISSION_STATUS_CHANNEL, type PermissionStatus } from "./modes.ts";
59
- import { ORIGINAL_COMMAND_KEY } from "../worktree/rewrite.ts";
71
+ import { type ChildToolCall, type ChildGateDecision, SUBAGENT_GATE_CHANNEL } from "./subagent-gate.ts";
72
+ import { trackOriginalCommands } from "../lib/original-command.ts";
73
+ import { MODE_CHANNEL, PLAN_FILE_CHANNEL } from "../lib/plan-mode-channels.ts";
60
74
  import { isWritingTool } from "./protected-paths.ts";
61
- import { loadPermissionSettings, normalizePermissionMode, persistAllowRule, settingsPaths } from "./settings.ts";
75
+ import { denyRuleLines } from "./rule-prose.ts";
76
+ import { loadPermissionSettings, normalizePermissionMode, persistAllowRule, resolveStartupMode } from "./settings.ts";
77
+ import { MODE_ENV, resolvedOrSelf, runtimeProtectedDirs } from "../lib/permission-gate.ts";
78
+ import { CLASSIFIER_SETTING_CHANGED_CHANNEL } from "../lib/settings-channels.ts";
79
+ import { describeProjectAllow, persistProjectAllowApproval, projectAllowApproved } from "./project-trust.ts";
80
+ import { findProjectRoot } from "../lib/git.ts";
81
+ import { oneCodeProjectSettingsPath, oneCodeSettingsPath } from "../lib/one-code-settings.ts";
82
+ import { recordUsage } from "../lib/usage-bus.ts";
62
83
 
63
84
  const DENIED_BY_USER =
64
85
  "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.";
65
86
  const DENIED_NON_INTERACTIVE =
66
87
  "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.";
88
+ // Must agree with the plan-mode reminder (plan-mode/reminder.ts): the plan is
89
+ // built in the plan file and approved through exit_plan_mode, never presented
90
+ // as chat text. A denial that said "present your plan to the user instead"
91
+ // contradicted the reminder in the same turn.
67
92
  const DENIED_PLAN_MODE =
68
- "You are in plan mode: only read-only tools may run. Do not attempt mutations; present your plan to the user instead.";
93
+ "You are in plan mode: only read-only tools may run, and edit/write are limited to the plan file. This call was blocked. Keep investigating with read-only tools and build the plan in the plan file named in the plan-mode reminder; finish the turn with ask_user_question or exit_plan_mode, not by presenting the plan as chat text.";
69
94
  const DENIED_DONT_ASK =
70
95
  "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.";
71
96
  const DENIED_PROTECTED_PATH =
72
97
  "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.";
98
+ const DENIED_OUTSIDE_WORKING_DIR =
99
+ "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.";
100
+ // Returned to the MODEL, not the user: auto mode exists to run unattended, so a
101
+ // block is handed back so the model can accomplish the goal a safe way — mirrors
102
+ // Claude Code's own auto-mode denial message rather than halting for a prompt.
73
103
  const DENIED_BY_CLASSIFIER = (reason: string) =>
74
- `Blocked by the auto-mode approval classifier: ${reason}\n\nDo not retry the same call and do not try to work around the block. If you believe the action is what the user asked for, say so and let them decide.`;
104
+ `Permission for this action was denied by the auto-mode approval classifier. Reason: ${reason}\n\n` +
105
+ "If you have other tasks that don't depend on this action, continue with those. You *may* try to accomplish the goal a different, safe way (e.g. a less destructive command, or committing first so a change is recoverable), but do *not* try to work around or defeat this denial. If you believe this capability is essential, STOP and explain to the user what you were trying to do and why, and let them decide.";
75
106
  const BLOCKED_BY_TIMEOUT = (reason: string) =>
76
- `${reason}\n\nThis was not a judgement that the call is unsafe — the classifier could not respond in time. Tell the user what you were about to do so they can re-run interactively or pin a faster classifier; do not try to route around the gate.`;
107
+ `${reason}\n\nThis was not a judgement that the call is unsafe. Wait a moment and try this action again. ` +
108
+ "If it keeps failing, continue with other tasks that do not require this action and come back to it later — " +
109
+ "reading files, searching code, and other read-only operations do not require the classifier and can still be used. " +
110
+ "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.";
77
111
  const DENIED_SAFETY_FLOOR = (reason: string) =>
78
112
  `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.`;
113
+ // "Choose a different approach" is exactly what a literal reader does with a
114
+ // second command of the same effect: a tiny-tier model met `Bash(rm:*)` and
115
+ // deleted the file with `python3 -c "import os; os.remove(…)"` 3/3 times
116
+ // (WEAK-MODEL-REVIEW-2026-09-06 H2). A rule is the user's standing decision
117
+ // about a CLASS of action, so the denial has to say that equivalents are denied
118
+ // too — the wording the classifier's own denial already uses.
119
+ const DENIED_BY_RULE = (rule: string) =>
120
+ `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.`;
121
+
122
+ /** Truncate a subject for a permission prompt — shared by the main gate and the subagent bridge. */
123
+ const previewSubject = (subject: string) => (subject.length > 200 ? `${subject.slice(0, 200)}…` : subject);
124
+
79
125
  /**
80
- * Why a delegation call reaches the classifier. Auto mode judges the task before
81
- * the child starts, so the thing to weigh is the instruction being handed over,
82
- * not the tool call's mechanics.
126
+ * The ask-prompt title for a non-floor decision — shared by the main gate and
127
+ * the subagent bridge so a new `cause` is worded once. `actor` is the tool name,
128
+ * prefixed for a child ("subagent x's write").
83
129
  */
84
- const DELEGATION_ROUTE_REASONS: Record<string, string | undefined> = {
85
- subagent:
86
- "This spawns a subagent: a fresh agent loop that will act on the task text below with the same permissions as this session. Judge the delegated task itself — whether carrying it out would require anything the rules forbid — because the child will not refuse a task its parent should not have handed it.",
87
- workflow:
88
- "This launches a multi-agent workflow whose script fans work out to subagents. Judge what the script sets out to do, since its agents inherit this session's permissions.",
130
+ const askTitle = (actor: string, preview: string, cause: string, pausedResume: boolean): string => {
131
+ if (pausedResume) return `Auto mode is paused after repeated blocks — approve to resume.\n\n ${actor}: ${preview || "(no arguments)"}`;
132
+ if (cause === "protected-path")
133
+ return `Allow ${actor} to write a protected path?\n\n ${preview}\n\n This path configures your tooling or this agent, so allow rules do not pre-approve it.`;
134
+ if (cause === "working-dir") return `Allow ${actor} outside the working directory?\n\n ${preview || "(no arguments)"}`;
135
+ return `Allow ${actor}?\n\n ${preview || "(no arguments)"}`;
89
136
  };
137
+
138
+ /** Map a `decide()` deny result to its model-facing reason — shared by both gate paths. */
139
+ const denyReason = (result: { cause?: string; rule?: { raw?: string } }): string =>
140
+ result.cause === "plan-mode"
141
+ ? DENIED_PLAN_MODE
142
+ : result.cause === "protected-path"
143
+ ? DENIED_PROTECTED_PATH
144
+ : result.cause === "working-dir"
145
+ ? DENIED_OUTSIDE_WORKING_DIR
146
+ : result.cause === "mode"
147
+ ? DENIED_DONT_ASK
148
+ : DENIED_BY_RULE(result.rule?.raw ?? "deny");
149
+
150
+ /** Ask-prompt option labels — shared by both gate paths. */
90
151
  /**
91
- * The default: nothing more specific than "no rule pre-approved this". Stated
92
- * explicitly rather than left blank, because a classifier told nothing about why
93
- * it is being asked will supply its own answer.
152
+ * Modes that carry a standing `permission-mode` reminder block: auto installs
153
+ * its own in `setMode`, plan's is owned by the plan-mode extension (it knows
154
+ * the plan file) and re-installed from the status broadcast `setMode` sends.
155
+ * Startup announces exactly these; a new mode with a standing block is added
156
+ * here, next to the emitter it belongs to.
94
157
  */
95
- const RESIDUAL_ROUTE_REASON =
96
- "No permission rule covered this call and it is not a read or a working-directory edit, which is why it reaches you. There is nothing unusual about it beyond that — judge it on the rules and the user's request.";
97
- const DENIED_BY_RULE = (rule: string) =>
98
- `This tool call is denied by the permission rule "${rule}" in the user's settings. Do not retry it; choose a different approach.`;
158
+ const STANDING_REMINDER_MODES: ReadonlySet<PermissionMode> = new Set<PermissionMode>(["plan", "auto"]);
159
+
160
+ const YES = "Yes";
161
+ const NO = "No, tell the agent what to do differently";
162
+ /**
163
+ * The ask prompt's options: Yes, the scoped session grant when one exists
164
+ * (session-grant.ts — none for a protected path, the safety floor, or auto
165
+ * mode, where a minted rule could not or must not apply), No.
166
+ */
167
+ const askOptions = (grant: SessionGrant | undefined) => (grant ? [YES, grant.label, NO] : [YES, NO]);
168
+ const approved = (choice: string | undefined, grant: SessionGrant | undefined) =>
169
+ choice === YES || (grant !== undefined && choice === grant.label);
99
170
 
100
171
  export default function permissionsExtension(pi: ExtensionAPI) {
172
+ const notifyTask = createTaskNotifier(pi);
173
+ // The background hand-back review rides the same steered path as agent
174
+ // completions; render it the same compact way (full body on ctrl+o).
175
+ pi.registerMessageRenderer("subagent-review", (message, { expanded }, theme) =>
176
+ notificationComponent(theme, customMessageText(message.content), expanded),
177
+ );
101
178
  pi.registerFlag("permission-mode", {
102
179
  description:
103
180
  "Permission mode: default (alias: manual) | acceptEdits | plan | auto | bypassPermissions | dontAsk",
@@ -108,15 +185,27 @@ export default function permissionsExtension(pi: ExtensionAPI) {
108
185
  type: "boolean",
109
186
  });
110
187
 
111
- /**
112
- * Subagent children (pi subprocesses spawned by pi-subagents) inherit the
113
- * parent session's permission mode, Claude Code-style: the parent exports
114
- * it via env, the child's copy of this extension reads it back.
115
- */
116
- const MODE_ENV = "CC_PERMISSION_MODE";
117
- const isSubagentChild = process.env.PI_SUBAGENT_CHILD === "1";
118
-
119
- let mode: PermissionMode = "default";
188
+ // The live mode is published in the process environment (MODE_ENV) for the
189
+ // in-process child gate (lib/permission-gate.ts), which judges under it when
190
+ // no bridge to this extension is reachable. Subagents run in-process; there
191
+ // is no child *process* reading this any more.
192
+ //
193
+ // Auto is the shipped default: Claude Code 2.1.266 made auto its default
194
+ // permission mode ("Auto mode is now Claude Code's default permission mode"),
195
+ // so One Code follows. Deliberate divergences from CC's rollout, both the
196
+ // user's call: CC gates it behind a one-time first-run offer and pairs it with
197
+ // an OS sandbox for outside-cwd reads; One Code flips the default directly and
198
+ // has no sandbox yet (reads outside the working directory are still
199
+ // 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
202
+ // classifier model reachable the classifier fails closed. A user or project
203
+ // `defaultMode` (or `--permission-mode`) still overrides this; `auto` from a
204
+ // project file is still refused. See docs/decisions/auto-mode.md.
205
+ let mode: PermissionMode = "auto";
206
+ // Worktree-wrapped bash calls publish the model's original command here,
207
+ // keyed by pi's toolCallId (never read from `event.input` — model-writable).
208
+ const originalCommands = trackOriginalCommands(pi);
120
209
  /** Whether bypassPermissions is a stop on the cycle — only when the session started with it (Claude Code semantics). */
121
210
  let bypassInCycle = false;
122
211
  /** Whether auto mode is a stop on the cycle — only when a classifier model is reachable. */
@@ -124,7 +213,26 @@ export default function permissionsExtension(pi: ExtensionAPI) {
124
213
  let deny: PermissionRule[] = [];
125
214
  let ask: PermissionRule[] = [];
126
215
  let allow: PermissionRule[] = [];
216
+ /**
217
+ * The repository's own allow rules (project-trust.ts). Applied only once the
218
+ * user has consented to exactly this list; until then they are held here and
219
+ * the first call they would decide raises the consent dialog.
220
+ */
221
+ let projectAllow: PermissionRule[] = [];
222
+ let projectAllowRaw: string[] = [];
223
+ let projectAllowTrusted = false;
224
+ let projectAllowDeclined = false;
225
+ let projectRoot = "";
226
+ /**
227
+ * Rules minted by "Yes, and don't ask again … this session" (session-grant.ts).
228
+ * Never applied in auto mode: there the classifier is the boundary and a
229
+ * standing grant would be a bypass of it (PERMISSIONS-REVIEW-2026-09-05 M2).
230
+ * Cleared with the session (L1).
231
+ */
127
232
  const sessionAllows: PermissionRule[] = [];
233
+ const activeSessionAllows = () => (mode === "auto" ? [] : sessionAllows);
234
+ let unparsableRules: string[] = [];
235
+ let warnedUnparsable = "";
128
236
  /** Plan mode's one writable file, announced by the plan-mode extension. */
129
237
  let planFilePath: string | undefined;
130
238
  /**
@@ -134,6 +242,14 @@ export default function permissionsExtension(pi: ExtensionAPI) {
134
242
  */
135
243
  let memoryDirPath: string | undefined;
136
244
  let scratchpadDirPath: string | undefined;
245
+ /** Where oversized tool outputs are persisted this session — readable like the cwd. */
246
+ let resultsDirPath: string | undefined;
247
+ /** The session cwd's own realpath (macOS /var → /private/var), for decide()'s containment check. */
248
+ let resolvedCwd: string | undefined;
249
+ /** The per-repo One Code settings file, resolved once per session so the auto-mode floor need not re-walk for the project root on every tool call. */
250
+ let oneCodeProjectSettingsFile: string | undefined;
251
+ /** pi's own agent directory, protected like the static list (lib/permission-gate.ts runtimeProtectedDirs). */
252
+ let protectedDirs: string[] = [];
137
253
 
138
254
  /**
139
255
  * Mode changes arrive over the event bus too (plan-mode tools), where no ctx
@@ -142,12 +258,15 @@ export default function permissionsExtension(pi: ExtensionAPI) {
142
258
  */
143
259
  let badgeCtx: ExtensionContext | undefined;
144
260
 
261
+ /** True while the model is streaming — the badge carries the interrupt hint. */
262
+ let streaming = false;
263
+
145
264
  /**
146
265
  * The classifier the banner and badge should name: the pinned one once a
147
266
  * call has settled it, otherwise the chain's first candidate — the thing
148
267
  * that *will* screen the next call, worth showing before it happens.
149
268
  */
150
- const classifierForDisplay = (): { classifier?: string; pinned: boolean } => {
269
+ const classifierForDisplay = (sessionModel?: Model<Api>): { classifier?: string; pinned: boolean } => {
151
270
  if (classifierState.pinned) {
152
271
  return { classifier: `${classifierState.pinned.provider}/${classifierState.pinned.id}`, pinned: true };
153
272
  }
@@ -155,25 +274,34 @@ export default function permissionsExtension(pi: ExtensionAPI) {
155
274
  autoConfig ??= loadAutoModeConfig(os.homedir());
156
275
  const chain = classifierCandidates({
157
276
  available: badgeCtx.modelRegistry.getAvailable(),
158
- sessionModel: badgeCtx.model,
277
+ sessionModel: sessionModel ?? badgeCtx.model,
159
278
  configured: autoConfig.classifierModel,
160
- }).filter((entry) => !classifierState.rejected.has(`${entry.model.provider}/${entry.model.id}`));
279
+ configuredSetForContainment: autoConfig.classifierModelSetFor,
280
+ }).candidates.filter((entry) => !classifierState.rejected.has(`${entry.model.provider}/${entry.model.id}`));
161
281
  const first = chain[0];
162
282
  return { classifier: first ? `${first.model.provider}/${first.model.id}` : undefined, pinned: false };
163
283
  };
164
284
 
165
- const applyBadge = () => {
166
- // setStatus is a no-op outside the TUI, so this is safe unconditionally.
167
- badgeCtx?.ui.setStatus(
285
+ const applyBadge = (sessionModel?: Model<Api>) => {
286
+ // A below-editor widget, not a footer status: Claude Code renders the
287
+ // mode line directly under the input box, and the workflow status strip
288
+ // sorts itself below this line by re-setting on the status channel
289
+ // (setWidget re-inserts on update, so widget order is last-write order).
290
+ // setWidget is a no-op outside the TUI, so this is safe unconditionally.
291
+ badgeCtx?.ui.setWidget(
168
292
  "permission-mode",
169
- modeBadge(mode, {
170
- paused: pauseTracker.isPaused(),
171
- classifierModel: classifierState.pinned?.id,
172
- }),
293
+ [
294
+ modeBadge(mode, {
295
+ paused: pauseTracker.isPaused(),
296
+ classifierModel: classifierState.pinned?.id,
297
+ streaming,
298
+ }),
299
+ ],
300
+ { placement: "belowEditor" },
173
301
  );
174
302
  // The banner shows mode and classifier live; it listens on the bus
175
303
  // because jiti isolates module state between extensions.
176
- const display = mode === "auto" ? classifierForDisplay() : { pinned: false };
304
+ const display = mode === "auto" ? classifierForDisplay(sessionModel) : { pinned: false };
177
305
  pi.events.emit(PERMISSION_STATUS_CHANNEL, {
178
306
  mode,
179
307
  paused: pauseTracker.isPaused(),
@@ -182,20 +310,38 @@ export default function permissionsExtension(pi: ExtensionAPI) {
182
310
  } satisfies PermissionStatus);
183
311
  };
184
312
 
185
- /** Auto-mode state. Loaded lazily: most sessions never enter auto mode. */
313
+ /** Auto-mode state. Loaded lazily by every reader (`autoConfig ??= …`): most sessions never enter auto mode. */
186
314
  let autoConfig: AutoModeConfig | undefined;
315
+ /**
316
+ * `decide()`'s classifyAllShell input, loading the config itself like every
317
+ * other reader in this file — until 2026-09-05 the two gate call sites read
318
+ * `autoConfig?.classifyAllShell` bare and were correct only because painting
319
+ * the badge happened to load it first (PERMISSIONS-REVIEW-2026-09-05 L2).
320
+ */
321
+ const classifyAllShell = (): boolean | undefined =>
322
+ mode === "auto" ? (autoConfig ??= loadAutoModeConfig(os.homedir())).classifyAllShell : undefined;
187
323
 
188
324
  // Drop the cached config and classifier selection state, then refresh the badge.
189
325
  // Shared by the "set classifier model" and "clear" paths.
190
- const resetClassifierChoice = () => {
326
+ const resetClassifierChoice = (sessionModel?: Model<Api>) => {
191
327
  autoConfig = undefined; // reloaded lazily, now carrying any new model
192
328
  classifierState.pinned = undefined;
193
329
  classifierState.rejected.clear();
194
330
  classifierState.notified.clear();
195
- applyBadge();
331
+ classifierState.timeoutStreak = 0;
332
+ classifierState.chainCache = undefined;
333
+ applyBadge(sessionModel);
334
+ };
335
+ // Keyed by cwd: a worktree-isolated child classifies against ITS checkout's
336
+ // CLAUDE.md/AGENTS.md, and must not poison the cache the main agent's own
337
+ // calls (a different cwd) read for the rest of the session.
338
+ const projectInstructionsByCwd = new Map<string, string | undefined>();
339
+ const instructionsFor = (cwd: string): string | undefined => {
340
+ if (!projectInstructionsByCwd.has(cwd)) {
341
+ projectInstructionsByCwd.set(cwd, loadProjectInstructions(cwd, os.homedir()));
342
+ }
343
+ return projectInstructionsByCwd.get(cwd);
196
344
  };
197
- let projectInstructions: string | undefined;
198
- let instructionsLoaded = false;
199
345
  const pauseTracker = new PauseTracker();
200
346
  /**
201
347
  * Which model the classifier settled on. Held here so the choice is pinned for
@@ -203,6 +349,17 @@ export default function permissionsExtension(pi: ExtensionAPI) {
203
349
  * to be unusable is not retried on every tool call.
204
350
  */
205
351
  const classifierState = createClassifierState();
352
+ // What another role learned the hard way (a subagent's provider refused its
353
+ // model as not usable on this account) is a rejection here too — the roles
354
+ // share one selection floor, so they share one first pick (lib/model-unusable.ts).
355
+ pi.events.on(MODEL_UNUSABLE_CHANNEL, (data) => {
356
+ classifierState.rejected.add((data as ModelUnusableEvent).model);
357
+ });
358
+ /** Report each classifier reply's usage to the all-in footer cost. */
359
+ const onClassifierUsage = (usage: unknown) => recordUsage(pi, "classifier", usage);
360
+ /** A candidate the provider refused for this account: published so subagent/workflow selection skips it too (lib/model-unusable.ts). */
361
+ const onClassifierModelUnusable = (model: string, reason: string) =>
362
+ pi.events.emit(MODEL_UNUSABLE_CHANNEL, { model, reason } satisfies ModelUnusableEvent);
206
363
 
207
364
  /**
208
365
  * One JSONL line per gate decision when `autoMode.logDecisions` is set. The
@@ -221,26 +378,93 @@ export default function permissionsExtension(pi: ExtensionAPI) {
221
378
 
222
379
  /** What would be tried, in order, before anything has been pinned. */
223
380
  const describeChain = (ctx: ExtensionContext): string => {
224
- const chain = classifierCandidates({
381
+ const { candidates } = classifierCandidates({
225
382
  available: ctx.modelRegistry.getAvailable(),
226
383
  sessionModel: ctx.model,
227
384
  configured: autoConfig?.classifierModel,
385
+ configuredSetForContainment: autoConfig?.classifierModelSetFor,
228
386
  });
229
- return chain.length > 0 ? chain.map(describeCandidate).join(" → ") : "(no model available)";
387
+ return candidates.length > 0 ? candidates.map(describeCandidate).join(" → ") : "(no model available)";
388
+ };
389
+
390
+ // Session identity for the classifier's Session Context block (CC system[2]).
391
+ const classifierUsername = (() => {
392
+ try {
393
+ return os.userInfo().username;
394
+ } catch {
395
+ return "user";
396
+ }
397
+ })();
398
+
399
+ /**
400
+ * The classifier's `<transcript>`: user messages and tool inputs, in order,
401
+ * results stripped (transcript.ts renders it). The last entry is always the
402
+ * action under review — the tool_call handler appends the call being judged.
403
+ */
404
+ const transcript: TranscriptEntry[] = [];
405
+ const capTranscript = () => {
406
+ // Bound memory on a long unattended run; the renderer also caps by chars,
407
+ // and intent verification reads userMessages, not this, so trimming old
408
+ // lines here never weakens that check. Trim down to a lower watermark so the
409
+ // O(n) splice is amortized over the next ~100 pushes on the tool_call path.
410
+ if (transcript.length > 500) transcript.splice(0, transcript.length - 400);
411
+ };
412
+
413
+ /**
414
+ * Record a rule denial as its own `denied` transcript line, so the classifier
415
+ * can see that the user's rules already refused this class of action (it also
416
+ * switches on the stage-2 denial addendum). Rule denials only — a plan-mode,
417
+ * protected-path, working-dir or dontAsk block is a mode fact, not a standing
418
+ * user decision about a class of action, and each already has its own
419
+ * model-facing text.
420
+ */
421
+ const recordRuleDenial = (result: { cause?: string; rule?: { raw?: string } }, toolName: string, subject: string) => {
422
+ if (mode !== "auto") return;
423
+ if (result.cause === "plan-mode" || result.cause === "protected-path" || result.cause === "working-dir" || result.cause === "mode") return;
424
+ transcript.push({ kind: "denied", tool: normalizeToolName(toolName), subject, rule: result.rule?.raw ?? "deny" });
425
+ capTranscript();
426
+ };
427
+
428
+ /**
429
+ * The classifier's rule extras: the configured `hard_deny`/`soft_deny`/`allow`
430
+ * lists, plus one HARD BLOCK line per deny rule in the user's settings
431
+ * (rule-prose.ts). A rule binds only the spelling it names, so without this
432
+ * the classifier never learns what the user forbade and clears an
433
+ * equivalent-effect action the pattern happened to miss
434
+ * (WEAK-MODEL-REVIEW-2026-09-06 H2). Additive only: the rule already refused
435
+ * its literal form deterministically before anything reached the classifier.
436
+ *
437
+ * Rendered once per deny list rather than per call: `deny` is replaced
438
+ * wholesale by `reloadSettings`, so its identity is the cache key, and the
439
+ * output has to be byte-stable anyway or every classifier call would bust the
440
+ * cached ruleset prefix.
441
+ */
442
+ let renderedDeny: { source: PermissionRule[]; lines: string[] } | undefined;
443
+ const classifierRuleExtras = () => {
444
+ if (renderedDeny?.source !== deny) renderedDeny = { source: deny, lines: denyRuleLines(deny.map((rule) => rule.raw)) };
445
+ return { ...autoConfig, hardDeny: [...(autoConfig?.hardDeny ?? []), ...renderedDeny.lines] };
230
446
  };
231
447
 
232
448
  /**
233
449
  * The user's own messages, and only those — the classifier's "explicit intent"
234
- * tier must not be reachable from file contents or command output, or a
235
- * prompt injection could manufacture its own authorisation. pi's `input`
236
- * event fires for real user input, which is exactly that boundary.
450
+ * tier must not be reachable from file contents or command output, or a prompt
451
+ * injection could manufacture its own authorisation. pi's `input` event fires
452
+ * for real user input, which is exactly that boundary. Carried in FULL (not a
453
+ * rolling window): in a long unattended run the authorizing setup message must
454
+ * still clear a later action (decision 2 in docs/decisions/auto-mode.md).
237
455
  */
238
456
  const userMessages: string[] = [];
239
457
  pi.on("input", (event) => {
458
+ // Only what the user typed. A plugin command or skill body arrives as
459
+ // source "extension" and must not become "the user's own words" that an
460
+ // intent quote can cite (review P12; hooks make the same distinction).
461
+ if (event.source === "extension") return;
240
462
  const text = event.text?.trim();
241
463
  if (!text) return;
242
464
  userMessages.push(text);
243
- if (userMessages.length > 12) userMessages.shift();
465
+ if (userMessages.length > 1000) userMessages.shift();
466
+ transcript.push({ kind: "user", text });
467
+ capTranscript();
244
468
  });
245
469
 
246
470
  /**
@@ -254,31 +478,98 @@ export default function permissionsExtension(pi: ExtensionAPI) {
254
478
  input: Record<string, unknown>,
255
479
  subject: string,
256
480
  ctx: ExtensionContext,
257
- routedBecause: string,
481
+ /**
482
+ * Whether the deterministic containment fast-path may clear this call
483
+ * without the classifier. False for protected paths and for completed-run
484
+ * subagent reviews — those must always be judged.
485
+ */
486
+ containmentEligible = false,
487
+ /**
488
+ * Overrides for a bridged subagent call: its own cwd (a worktree, if
489
+ * isolated), the child action appended to a COPY of the transcript (the
490
+ * parent's live transcript is never mutated for a child), and a fresh signal
491
+ * (the parent's last ctx.signal may be from a settled turn and already
492
+ * aborted). All default to the parent's own values, so the main path is
493
+ * unchanged.
494
+ */
495
+ opts?: { cwd?: string; appendEntry?: TranscriptEntry; signal?: AbortSignal },
258
496
  ) => {
497
+ const cwd = opts?.cwd ?? ctx.cwd;
259
498
  autoConfig ??= loadAutoModeConfig(os.homedir());
260
- if (!instructionsLoaded) {
261
- projectInstructions = loadProjectInstructions(ctx.cwd, os.homedir());
262
- instructionsLoaded = true;
263
- }
499
+
500
+ const home = os.homedir();
501
+ const allow = () => ({ decision: "allow" as const, reason: "", tier: undefined });
264
502
 
265
503
  let evidence: ShellEvidence | undefined;
266
504
  if (normalizeToolName(toolName) === "bash" && subject) {
267
- evidence = analyzeShellCommand({ command: subject, cwd: ctx.cwd, home: os.homedir() });
505
+ evidence = analyzeShellCommand({ command: subject, cwd, home, protectedDirs });
268
506
  if (evidence.verdict === "safe") {
269
507
  logDecision(ctx, { tool: toolName, subject, outcome: "allow", source: "pre-gate" });
270
- return { decision: "allow" as const, reason: "", tier: undefined };
508
+ return allow();
509
+ }
510
+ // The command's only risk is an in-project delete or whole-tree reset.
511
+ // Auto mode trusts the project as the agent's sandbox — but, unlike Claude
512
+ // Code, only when git can put the bytes back. A recoverable destruction
513
+ // runs unattended with no classifier call; an unrecoverable one (untracked,
514
+ // dirty, not a repo) still reaches the classifier.
515
+ if (containmentEligible && evidence.containedNonNetwork) {
516
+ const targets = evidence.writes.filter((w) => !w.outsideCwd && w.resolved).map((w) => w.resolved as string);
517
+ // The same cwd the evidence resolved against — for a worktree-isolated
518
+ // child that is the worktree, not the parent checkout (ctx.cwd).
519
+ const rec = checkRecoverability(cwd, { targets, wholeTree: evidence.wholeTree });
520
+ if (rec.verdict === "recoverable") {
521
+ logDecision(ctx, { tool: toolName, subject, outcome: "allow", source: "pre-gate", reason: rec.reason });
522
+ return allow();
523
+ }
524
+ evidence.notes.push(`git recoverability: ${rec.reason}`);
525
+ }
526
+ } else if (containmentEligible && isWritingTool(normalizeToolName(toolName))) {
527
+ // A write/edit whose target is inside the project and not a credential
528
+ // path is ordinary sandbox work — Claude Code auto-approves it, and so do
529
+ // we (protected paths never reach here: decide() routes them with
530
+ // containmentEligible=false). Overwrites are not recoverability-gated the
531
+ // way deletes are: the file still exists, and edit-then-iterate is the
532
+ // core of unattended coding autonomy. Execution-primitive paths (build
533
+ // wrappers, CI workflows, editor auto-run config) are excluded exactly as
534
+ // the bash pre-gate excludes them — being in-project does not make a file
535
+ // that runs later without further approval safe to write unclassified.
536
+ const raw = input.file_path ?? input.path;
537
+ const target = typeof raw === "string" ? raw : undefined;
538
+ if (target) {
539
+ const absolute = toAbsolute(cwd, target, home);
540
+ const resolved = resolveForContainment(absolute);
541
+ const root = resolveForContainment(cwd) ?? cwd;
542
+ if (resolved && isWithin(root, resolved) && !isSensitivePath(absolute) && !isExecutionPrimitivePath(absolute)) {
543
+ logDecision(ctx, { tool: toolName, subject, outcome: "allow", source: "pre-gate" });
544
+ return allow();
545
+ }
271
546
  }
272
547
  }
273
548
 
549
+ // The current call was pushed onto `transcript` by the tool_call handler, so
550
+ // it is already the last entry — the action under review. `evidence` is used
551
+ // only for the containment fast-path above; CC's payload carries no separate
552
+ // static-analysis block, so it is not sent to the classifier.
274
553
  const verdict = await classify(
275
- { toolName, input, cwd: ctx.cwd, userMessages: [...userMessages], evidence, projectInstructions, routedBecause },
554
+ {
555
+ toolName,
556
+ transcript: opts?.appendEntry ? [...transcript, opts.appendEntry] : [...transcript],
557
+ userMessages: [...userMessages],
558
+ claudeMd: instructionsFor(cwd),
559
+ username: classifierUsername,
560
+ environment: autoConfig.environment,
561
+ // AutoModeConfig is structurally a RuleExtras; the user's own deny rules
562
+ // are appended to its hard list (classifierRuleExtras).
563
+ ruleExtras: classifierRuleExtras(),
564
+ },
276
565
  {
277
566
  registry: ctx.modelRegistry,
278
567
  sessionModel: ctx.model,
279
568
  config: autoConfig,
280
- signal: ctx.signal,
569
+ signal: opts?.signal ?? ctx.signal,
281
570
  state: classifierState,
571
+ onUsage: onClassifierUsage,
572
+ onModelUnusable: onClassifierModelUnusable,
282
573
  onNotice: (message, level) => {
283
574
  ctx.ui.notify(message, level);
284
575
  // The badge names the classifier, so it has to repaint when the first
@@ -305,13 +596,32 @@ export default function permissionsExtension(pi: ExtensionAPI) {
305
596
  const setMode = (next: PermissionMode) => {
306
597
  mode = next;
307
598
  process.env[MODE_ENV] = next;
599
+ // The standing block of the mode being left goes first, BEFORE the status
600
+ // broadcast in applyBadge: the plan-mode extension owns the shared
601
+ // "permission-mode" key while planning (it knows the plan file) and
602
+ // re-installs its block synchronously from that broadcast when the mode
603
+ // becomes plan — removing the key afterwards would undo that re-add and
604
+ // leave a turn entered mid-stream with no plan reminder until the next
605
+ // prompt (STEERING-REVIEW-2026-09-05 M3). Auto installs its own block below.
606
+ if (mode !== "auto") pi.events.emit(REMINDER_CHANNEL, { remove: true, key: "permission-mode" });
308
607
  applyBadge();
309
- // No plan branch: the plan-mode extension owns the "permission-mode"
310
- // reminder while planning (it knows the plan file) and re-emits it every
311
- // turn, so plan mode takes the generic path below — the announce is
312
- // wanted, and the key removal is undone before the next request goes out.
313
- // applyBadge above notifies that extension synchronously over the status
314
- // channel.
608
+ // Every switch is announced once on the tail of the next request (the next
609
+ // tool result mid-turn, the prompt between turns), so the model learns of
610
+ // the change where it reads next. The standing block carries the rules
611
+ // but rides the turn's user message, behind the model's own actions — so
612
+ // for plan mode the announcement, the one thing guaranteed to land
613
+ // mid-turn, also names the plan file (published by plan-mode's refresh
614
+ // inside the applyBadge broadcast above).
615
+ const planNote =
616
+ mode === "plan"
617
+ ? planFilePath
618
+ ? ` 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.`
619
+ : " Only read-only tools are available now, plus the plan file named in the plan-mode reminder."
620
+ : "";
621
+ pi.events.emit(REMINDER_CHANNEL, {
622
+ text: `The user's permission mode is now "${mode}".${planNote}`,
623
+ key: "permission-mode-change",
624
+ });
315
625
  if (mode === "auto") {
316
626
  pi.events.emit(REMINDER_CHANNEL, {
317
627
  text:
@@ -320,64 +630,140 @@ export default function permissionsExtension(pi: ExtensionAPI) {
320
630
  "Attempting to weaken this gate (editing permission settings, changing the mode, or routing work around it) is itself blocked.",
321
631
  scope: "every-turn",
322
632
  key: "permission-mode",
633
+ // Session state: rides every user message since auto mode came on, so
634
+ // the cached prefix holds turn to turn (lib/reminders.ts).
635
+ placement: "sticky-append",
323
636
  });
324
- } else {
325
- // Keyed so cycling through several modes announces only the one settled on.
326
- pi.events.emit(REMINDER_CHANNEL, {
327
- text: `The user's permission mode is now "${mode}".`,
328
- key: "permission-mode-change",
329
- });
330
- pi.events.emit(REMINDER_CHANNEL, { remove: true, key: "permission-mode" });
331
637
  }
332
638
  };
333
639
 
334
640
  const reloadSettings = (ctx: ExtensionContext) => {
335
641
  const settings = loadPermissionSettings(ctx.cwd, os.homedir());
336
- deny = parseRules(settings.deny);
337
- ask = parseRules(settings.ask);
338
- allow = parseRules(settings.allow);
642
+ const parsed = {
643
+ deny: parseRulesReport(settings.deny),
644
+ ask: parseRulesReport(settings.ask),
645
+ allow: parseRulesReport(settings.allow),
646
+ projectAllow: parseRulesReport(settings.projectAllow),
647
+ };
648
+ deny = parsed.deny.rules;
649
+ ask = parsed.ask.rules;
650
+ allow = parsed.allow.rules;
651
+ projectAllow = parsed.projectAllow.rules;
652
+ projectAllowRaw = settings.projectAllow;
653
+ // A linked worktree shares its main checkout's consent (findProjectRoot).
654
+ projectRoot = findProjectRoot(ctx.cwd) ?? ctx.cwd;
655
+ projectAllowTrusted = projectAllowApproved(projectRoot, projectAllowRaw);
656
+ projectAllowDeclined = false;
657
+ // A rule that fails to parse is a rule the user believes is in force and is
658
+ // not. Say so (once per distinct set) and list them in /permissions.
659
+ unparsableRules = [...parsed.deny.dropped, ...parsed.ask.dropped, ...parsed.allow.dropped, ...parsed.projectAllow.dropped];
660
+ const signature = unparsableRules.join("\n");
661
+ if (unparsableRules.length > 0 && ctx.hasUI && signature !== warnedUnparsable) {
662
+ warnedUnparsable = signature;
663
+ ctx.ui.notify(
664
+ `${unparsableRules.length} permission rule(s) could not be parsed and are ignored: ${unparsableRules.join(", ")}`,
665
+ "warning",
666
+ );
667
+ }
339
668
  // Dropped so edited autoMode rules and instruction files are picked up on
340
669
  // reload rather than staying cached for the life of the process.
341
670
  autoConfig = undefined;
342
- instructionsLoaded = false;
343
-
344
- const flagMode = normalizePermissionMode(pi.getFlag("permission-mode"));
345
- const inheritedMode = isSubagentChild ? normalizePermissionMode(process.env[MODE_ENV]) : undefined;
346
- if (pi.getFlag("dangerously-skip-permissions") === true) {
347
- mode = "bypassPermissions";
348
- } else if (flagMode) {
349
- mode = flagMode;
350
- } else if (inheritedMode) {
351
- mode = inheritedMode;
352
- } else if (settings.defaultMode) {
353
- mode = settings.defaultMode;
354
- }
671
+ projectInstructionsByCwd.clear();
672
+
673
+ // Claude Code's resolution order (flags, then settings' defaultMode), with
674
+ // bypassPermissions refused wherever a settings source disabled it — before
675
+ // 2026-09-05 (H3) that policy was silently not in force here.
676
+ const startup = resolveStartupMode(
677
+ [
678
+ pi.getFlag("dangerously-skip-permissions") === true ? "bypassPermissions" : undefined,
679
+ normalizePermissionMode(pi.getFlag("permission-mode")),
680
+ settings.defaultMode,
681
+ ],
682
+ settings,
683
+ );
684
+ if (startup.mode) mode = startup.mode;
685
+ if (startup.bypassRefused && ctx.hasUI) ctx.ui.notify("Bypass permissions mode was disabled by settings", "warning");
355
686
  bypassInCycle = mode === "bypassPermissions";
356
687
  // Auto mode needs a model to run its classifier on; with none reachable it
357
688
  // would block every call, so it stays out of the cycle instead — the same
358
- // thing Claude Code does when auto mode's requirements aren't met.
689
+ // thing Claude Code does when auto mode's requirements aren't met. The
690
+ // candidate chain ends in an unconditional fallback to any available model
691
+ // (model-select.ts), so "a model exists" is exactly "a classifier exists".
359
692
  autoInCycle = ctx.modelRegistry.getAvailable().length > 0;
360
- if (mode === "plan") setMode("plan");
693
+ // A mode that owns a standing reminder must be announced when the session
694
+ // STARTS in it, not only when the user switches into it. setMode is the
695
+ // only emitter of the block; until 2026-09-05 startup called it for plan
696
+ // alone, so `--permission-mode auto` / `defaultMode: "auto"` sessions ran
697
+ // with no auto-mode reminder at all (STEERING-REVIEW-2026-09-05 H2).
698
+ if (STANDING_REMINDER_MODES.has(mode)) setMode(mode);
361
699
  process.env[MODE_ENV] = mode;
362
700
  };
363
701
 
364
- pi.on("session_start", (_event, ctx) => {
702
+ pi.on("session_start", (event, ctx) => {
365
703
  badgeCtx = ctx;
704
+ lastReviewCtx = ctx;
705
+ sessionEpoch++;
706
+ // A new session (`/clear`, `/resume`, a fork) starts with a clean gate: the
707
+ // previous conversation's user messages are not intent evidence for this
708
+ // one, its "don't ask again" grants and its pause state do not carry over
709
+ // (PERMISSIONS-REVIEW-2026-09-05 L1). A reload keeps them — same conversation.
710
+ if (event.reason !== "reload") {
711
+ sessionAllows.length = 0;
712
+ transcript.length = 0;
713
+ userMessages.length = 0;
714
+ pauseTracker.reset();
715
+ }
366
716
  memoryDirPath = projectMemoryDir(ctx.cwd);
367
717
  scratchpadDirPath = sessionScratchpadDir(ctx.cwd, ctx.sessionManager.getSessionId());
718
+ // Resolved like the subjects compared against it (a symlinked parent, macOS /var).
719
+ resultsDirPath = resolvedOrSelf(sessionResultsDir(ctx));
720
+ resolvedCwd = resolveForContainment(ctx.cwd);
721
+ oneCodeProjectSettingsFile = oneCodeProjectSettingsPath(ctx.cwd, os.homedir());
722
+ protectedDirs = runtimeProtectedDirs();
368
723
  reloadSettings(ctx);
369
724
  applyBadge();
725
+ // Publish the subagent permission bridge (see subagent-gate.ts). The closure
726
+ // reads live parent state on each call, so emitting once at session start is
727
+ // enough; subagents captures it and threads it into child sessions.
728
+ pi.events.emit(SUBAGENT_GATE_CHANNEL, { decide: evaluateChildToolCall });
729
+ });
730
+
731
+ pi.on("model_select", (event, ctx) => {
732
+ badgeCtx = ctx;
733
+ lastReviewCtx = ctx;
734
+ autoInCycle = ctx.modelRegistry.getAvailable().length > 0;
735
+ resetClassifierChoice(event.model);
736
+ });
737
+
738
+ // The badge carries "· esc to interrupt" only while the model works (CC's
739
+ // mode line does the same).
740
+ pi.on("agent_start", (_event, ctx) => {
741
+ badgeCtx = ctx;
742
+ lastReviewCtx = ctx;
743
+ streaming = true;
744
+ applyBadge();
745
+ });
746
+ // Settle, not agent_end: `agent_end` fires per run, so flipping there made
747
+ // the badge's "· esc to interrupt" suffix flicker off and back on across a
748
+ // provider retry — the model is still working until the turn settles.
749
+ pi.on("agent_settled", () => {
750
+ streaming = false;
751
+ applyBadge();
370
752
  });
371
753
 
372
754
  // Mode-change requests from other extensions (e.g. plan-mode tools).
373
- pi.events.on("one-code:set-permission-mode", (data) => {
755
+ // `autoMode.classifierModel` was rewritten by another extension (`/doctor
756
+ // preset`): forget the pinned classifier and the cached config, exactly as
757
+ // `/auto-mode model` does after its own write, so the next call re-plans.
758
+ pi.events.on(CLASSIFIER_SETTING_CHANGED_CHANNEL, () => resetClassifierChoice(badgeCtx?.model));
759
+ pi.events.on(MODE_CHANNEL, (data) => {
374
760
  const requested = normalizePermissionMode((data as { mode?: unknown })?.mode);
375
761
  if (requested) setMode(requested);
376
762
  });
377
763
 
378
- // The plan-mode extension announces the plan file (see PLAN_FILE_CHANNEL
379
- // there); decide() then allows writes to that one path in plan mode.
380
- pi.events.on("one-code:plan-file-path", (data) => {
764
+ // The plan-mode extension announces the plan file on PLAN_FILE_CHANNEL;
765
+ // decide() then allows writes to that one path in plan mode.
766
+ pi.events.on(PLAN_FILE_CHANNEL, (data) => {
381
767
  const path = (data as { path?: unknown })?.path;
382
768
  if (typeof path === "string" && path) planFilePath = path;
383
769
  });
@@ -394,42 +780,103 @@ export default function permissionsExtension(pi: ExtensionAPI) {
394
780
  },
395
781
  });
396
782
 
397
- // The most recent turn's context, reused by background subagent reviews, which
398
- // fire off a channel event and so have no live ctx of their own.
783
+ // The most recent turn's context, reused by background subagent reviews (which
784
+ // fire off a channel event and so have no live ctx of their own) and by the
785
+ // subagent permission bridge (a child prompt renders on this parent ctx's UI).
399
786
  let lastReviewCtx: ExtensionContext | undefined;
787
+ /** Bumped per session_start: an async review that finishes after a /clear must not post into the new session. */
788
+ let sessionEpoch = 0;
789
+
790
+ // Serialize interactive prompts: a background/resident subagent can hit an "ask"
791
+ // while the main turn (or another child) is already awaiting one, and driving
792
+ // ctx.ui.select concurrently on one terminal is unverified. This chains them.
793
+ let promptChain: Promise<unknown> = Promise.resolve();
794
+ const serializePrompt = <T>(fn: () => Promise<T>): Promise<T> => {
795
+ const run = promptChain.then(fn, fn);
796
+ promptChain = run.then(
797
+ () => {},
798
+ () => {},
799
+ );
800
+ return run;
801
+ };
400
802
 
401
803
  pi.on("tool_call", async (event, ctx) => {
402
804
  lastReviewCtx = ctx;
403
805
  const normalizedTool = normalizeToolName(event.toolName);
404
806
  const subject = extractSubject(normalizedTool, event.input as Record<string, unknown>);
405
- // Resolved through symlinks so the protected-path check sees where a
406
- // write actually lands, not how the path is spelled. Only for writing
407
- // tools: a bash subject is a command line, not a path.
807
+ // Resolved through symlinks so the protected-path and working-directory
808
+ // checks see where a write lands or a read comes from, not how the path
809
+ // is spelled. Path tools only: a bash subject is a command line.
408
810
  const resolvedSubject =
409
- isWritingTool(normalizedTool) && subject
811
+ isPathSubjectTool(normalizedTool) && subject
410
812
  ? resolveForContainment(toAbsolute(ctx.cwd, subject, os.homedir()))
411
813
  : undefined;
412
814
  // In a worktree session, worktree's tool_call handler (which runs before this
413
- // one) cd-wraps bash commands for execution. Rule matching must evaluate the
414
- // model's original command, not the wrapper — otherwise every configured Bash
415
- // rule stops matching for the whole session. The classifier and safety floor
416
- // keep reading event.input (the wrapped command that actually runs).
417
- const original = (event.input as Record<string, unknown>)[ORIGINAL_COMMAND_KEY];
418
- const matchSubject = normalizedTool === "bash" && typeof original === "string" ? original : subject;
419
- const result = decide({
420
- toolName: event.toolName,
421
- subject: matchSubject,
422
- cwd: ctx.cwd,
423
- mode,
424
- deny,
425
- ask,
426
- allow: [...allow, ...sessionAllows],
427
- classifyAllShell: autoConfig?.classifyAllShell,
428
- resolvedSubject,
429
- planFilePath,
430
- memoryDirPath,
431
- scratchpadDirPath,
432
- });
815
+ // one) cd-wraps bash commands for execution and publishes the model's
816
+ // original command — and the worktree it runs in — over the bus under this
817
+ // call's id. Rule matching, the shell pre-gate, the recoverability judge and
818
+ // the prompt all evaluate that original against the worktree cwd: matched
819
+ // against the wrapper every configured Bash rule stopped matching, and
820
+ // analysed as the wrapper (a `cd` plus a newline) every call escalated to
821
+ // the classifier and the prompt showed `cd '…' && (…)` (PERMISSIONS-REVIEW-
822
+ // 2026-09-05 L3). The safety floor keeps reading event.input (the wrapped
823
+ // command that actually runs). The lookup is by toolCallId on purpose: a
824
+ // value inside `event.input` would be the model's to write, and rules would
825
+ // match a string of its choosing.
826
+ const original = normalizedTool === "bash" ? originalCommands.get(event.toolCallId) : undefined;
827
+ const matchSubject = original?.command ?? subject;
828
+ const callCwd = original?.cwd ?? ctx.cwd;
829
+
830
+ // Record every tool call into the classifier transcript (inputs only). In
831
+ // auto mode this is the running <transcript> the classifier reads, and this
832
+ // call is now its last entry — the action under review. For bash, record the
833
+ // model's original command, not the worktree cd-wrapper that actually runs.
834
+ if (mode === "auto") {
835
+ const recordedInput =
836
+ original !== undefined ? { command: original.command } : (event.input as Record<string, unknown>);
837
+ transcript.push({ kind: "tool", tool: normalizedTool, input: recordedInput });
838
+ capTranscript();
839
+ }
840
+
841
+ const decideWith = (allowRules: PermissionRule[]) =>
842
+ decide({
843
+ toolName: event.toolName,
844
+ subject: matchSubject,
845
+ cwd: callCwd,
846
+ mode,
847
+ deny,
848
+ ask,
849
+ allow: allowRules,
850
+ classifyAllShell: classifyAllShell(),
851
+ resolvedSubject,
852
+ resolvedCwd,
853
+ planFilePath,
854
+ memoryDirPath,
855
+ scratchpadDirPath,
856
+ resultsDirPath,
857
+ protectedDirs,
858
+ });
859
+ let result = decideWith([...allow, ...activeSessionAllows(), ...(projectAllowTrusted ? projectAllow : [])]);
860
+
861
+ // A repo-shipped allow rule would decide this call: ask the user to trust
862
+ // the repository's rule list first (once per list; project-trust.ts). No UI
863
+ // → the rules stay off and the call takes the normal path (fail closed).
864
+ if (result.decision !== "allow" && !projectAllowTrusted && !projectAllowDeclined && projectAllow.length > 0 && ctx.hasUI) {
865
+ const withProject = decideWith([...allow, ...activeSessionAllows(), ...projectAllow]);
866
+ if (withProject.decision === "allow" && withProject.rule && projectAllow.includes(withProject.rule)) {
867
+ const firing = withProject.rule.raw;
868
+ const { title, message } = describeProjectAllow(projectAllowRaw, firing);
869
+ const approved = (await serializePrompt(() => ctx.ui.confirm(title, message))) === true;
870
+ if (approved) {
871
+ projectAllowTrusted = true;
872
+ persistProjectAllowApproval(projectRoot, projectAllowRaw);
873
+ result = withProject;
874
+ } else {
875
+ projectAllowDeclined = true;
876
+ ctx.ui.notify("This repository's allow rules stay off for this session (its deny/ask rules still apply).", "info");
877
+ }
878
+ }
879
+ }
433
880
 
434
881
  /**
435
882
  * Auto mode's deterministic floor: a write to the files the gate is made
@@ -447,84 +894,84 @@ export default function permissionsExtension(pi: ExtensionAPI) {
447
894
  input: event.input as Record<string, unknown>,
448
895
  cwd: ctx.cwd,
449
896
  home: os.homedir(),
897
+ oneCodeProjectSettings: oneCodeProjectSettingsFile,
450
898
  })
451
899
  : undefined;
452
900
 
453
901
  if (result.decision === "allow" && !floorReason) return undefined;
454
902
 
903
+ // (dontAsk is the only mode that denies rather than allows unmatched calls.)
455
904
  if (result.decision === "deny") {
456
- if (result.cause === "plan-mode") return { block: true, reason: DENIED_PLAN_MODE };
457
- if (result.cause === "protected-path") return { block: true, reason: DENIED_PROTECTED_PATH };
458
- // The only mode that denies (rather than allows) unmatched calls is dontAsk.
459
- if (result.cause === "mode") return { block: true, reason: DENIED_DONT_ASK };
460
- return { block: true, reason: DENIED_BY_RULE(result.rule?.raw ?? "deny") };
905
+ recordRuleDenial(result, event.toolName, matchSubject);
906
+ return { block: true, reason: denyReason(result) };
461
907
  }
462
908
 
463
- let classifierBlock: string | undefined;
464
909
  if (floorReason) {
465
- // Never reaches the classifier: fall through to the prompt below, or
466
- // block outright where there is no one to ask.
910
+ // The floor never reaches the classifier. Auto mode is unattended, so a
911
+ // block goes back to the model rather than a per-action prompt (a prompt
912
+ // would hang the session); a non-auto interactive session still prompts,
913
+ // since editing your own settings by hand is legitimate.
467
914
  autoConfig ??= loadAutoModeConfig(os.homedir());
468
- if (!ctx.hasUI) {
915
+ if (mode === "auto" || !ctx.hasUI) {
469
916
  logDecision(ctx, { tool: event.toolName, subject, outcome: "block", source: "floor", reason: floorReason });
470
917
  return { block: true, reason: DENIED_SAFETY_FLOOR(floorReason) };
471
918
  }
472
919
  logDecision(ctx, { tool: event.toolName, subject, outcome: "prompt", source: "floor", reason: floorReason });
473
920
  } else if (result.decision === "classify") {
474
- // Auto mode pauses after repeated blocks and prompts instead, so a model
475
- // looping against the classifier reaches the user rather than grinding.
921
+ // Auto mode is for unattended runs: a block is returned to the MODEL so it
922
+ // can try a safe alternative — it is never raised as a per-action user
923
+ // prompt. The one exception is the loop-breaker: after repeated blocks the
924
+ // gate pauses and the next call falls through to a resume prompt, so a
925
+ // model grinding against the classifier reaches the user instead of
926
+ // burning the night.
476
927
  if (!pauseTracker.isPaused()) {
477
- const routedBecause =
478
- result.cause === "protected-path"
479
- ? `This call writes ${subject}, which is a protected path: it configures the user's tooling or this agent itself (git hooks and config, editor and container settings, shell rc files, package-manager config, this agent's own permission settings). It is inside the working directory — that is not what makes it risky. Writes here take effect later without any further approval, and permission allow rules deliberately do not cover them, which is why you are being asked rather than the rules deciding. Judge whether the user asked for this specific change.`
480
- : (DELEGATION_ROUTE_REASONS[normalizeToolName(event.toolName)] ?? RESIDUAL_ROUTE_REASON);
481
928
  const outcome = await runClassifier(
482
929
  event.toolName,
483
930
  event.input as Record<string, unknown>,
484
- subject,
931
+ matchSubject,
485
932
  ctx,
486
- routedBecause,
933
+ // Protected paths must always be judged; everything else may be cleared
934
+ // by the deterministic containment fast-path.
935
+ result.cause !== "protected-path",
936
+ // A worktree-wrapped command is analysed as the model wrote it, in the
937
+ // worktree it runs in (the classifier transcript already holds it).
938
+ original?.cwd ? { cwd: original.cwd } : undefined,
487
939
  );
488
940
  if (outcome.decision === "allow") {
489
941
  pauseTracker.recordAllow();
490
942
  return undefined;
491
943
  }
492
944
 
493
- // A timeout is not a block the model earned by looping against the
494
- // gate, so it does not count toward the auto-pause — and it is always
495
- // the user's to approve, so it never takes the hard-deny path.
945
+ // A timeout was not earned by looping against the gate, so it does not
946
+ // count toward the auto-pause. It is returned to the model as retryable
947
+ // (the call was never judged) rather than prompting — a prompt would
948
+ // hang an unattended session waiting on a classifier that was slow.
496
949
  if (outcome.tier === "timeout") {
497
- if (!ctx.hasUI) return { block: true, reason: BLOCKED_BY_TIMEOUT(outcome.reason) };
498
- classifierBlock = outcome.reason;
499
- } else {
500
- const tripped = pauseTracker.recordBlock({
501
- toolName: event.toolName,
502
- subject,
503
- reason: outcome.reason,
504
- tier: outcome.tier,
505
- ruleId: outcome.ruleId,
506
- raw: outcome.raw,
507
- });
508
- if (tripped) {
509
- const { lifetime } = pauseTracker.stats();
510
- ctx.ui.notify(
511
- `Auto mode paused after ${lifetime} blocked call${lifetime === 1 ? "" : "s"} — approving a prompt resumes it.`,
512
- "warning",
513
- );
514
- applyBadge();
515
- }
516
- // A hard-deny verdict is not the user's to override: prompting would
517
- // hand back exactly what the tier exists to refuse.
518
- if (outcome.tier === "hard_deny") {
519
- return { block: true, reason: DENIED_BY_CLASSIFIER(outcome.reason) };
520
- }
521
- // Non-interactively there is no one to ask, so the classifier's own
522
- // reason is more useful to the model than a generic refusal.
523
- if (!ctx.hasUI) return { block: true, reason: DENIED_BY_CLASSIFIER(outcome.reason) };
524
- classifierBlock = outcome.reason;
950
+ return { block: true, reason: BLOCKED_BY_TIMEOUT(outcome.reason) };
525
951
  }
952
+
953
+ const tripped = pauseTracker.recordBlock({
954
+ toolName: event.toolName,
955
+ subject: matchSubject,
956
+ reason: outcome.reason,
957
+ tier: outcome.tier,
958
+ ruleId: outcome.ruleId,
959
+ raw: outcome.raw,
960
+ });
961
+ if (tripped) {
962
+ const { lifetime } = pauseTracker.stats();
963
+ ctx.ui.notify(
964
+ `Auto mode paused after ${lifetime} blocked call${lifetime === 1 ? "" : "s"} — approving a prompt resumes it.`,
965
+ "warning",
966
+ );
967
+ applyBadge();
968
+ }
969
+ // Every classifier block (hard or soft) goes back to the model with the
970
+ // classifier's own reason, so it can find a safe alternative. Auto mode
971
+ // does not hand a soft block to the user as a per-action prompt.
972
+ return { block: true, reason: DENIED_BY_CLASSIFIER(outcome.reason) };
526
973
  }
527
- // Paused, or a soft block worth asking about: fall through and prompt.
974
+ // Paused: fall through to the resume prompt below.
528
975
  }
529
976
 
530
977
  // ask
@@ -532,57 +979,210 @@ export default function permissionsExtension(pi: ExtensionAPI) {
532
979
  return { block: true, reason: DENIED_NON_INTERACTIVE };
533
980
  }
534
981
 
535
- const preview = subject.length > 200 ? `${subject.slice(0, 200)}…` : subject;
982
+ const preview = previewSubject(matchSubject);
983
+ // Reaching a prompt from the classify branch only happens when auto mode is
984
+ // paused — this is the resume prompt, not a per-action approval.
985
+ const pausedResume = mode === "auto" && result.decision === "classify";
536
986
  const title = floorReason
537
987
  ? `Auto mode never auto-approves this — ${event.toolName} ${floorReason}.\n\n ${preview}\n\n Allow it this once?`
538
- : classifierBlock
539
- ? `Auto mode blocked this — allow it anyway?\n\n ${preview || "(no arguments)"}\n\n Classifier: ${classifierBlock}`
540
- : result.cause === "protected-path"
541
- ? `Allow ${event.toolName} to write a protected path?\n\n ${preview}\n\n This path configures your tooling or this agent, so allow rules do not pre-approve it.`
542
- : `Allow ${event.toolName}?\n\n ${preview || "(no arguments)"}`;
543
- const YES = "Yes";
544
- const YES_SESSION = "Yes, don't ask again this session";
545
- const NO = "No, tell the agent what to do differently";
546
- const choice = await ctx.ui.select(title, [YES, YES_SESSION, NO]);
988
+ : askTitle(event.toolName, preview, result.cause, pausedResume);
989
+ const grant = sessionGrant({
990
+ toolName: normalizedTool,
991
+ subject: matchSubject,
992
+ cwd: callCwd,
993
+ mode,
994
+ cause: result.cause,
995
+ floor: floorReason !== undefined,
996
+ home: os.homedir(),
997
+ });
998
+ const choice = await serializePrompt(() => ctx.ui.select(title, askOptions(grant)));
547
999
 
548
1000
  // The user's answer is itself a gate decision worth recording — it is the
549
1001
  // ground truth a drifting classifier gets calibrated against.
550
- if (mode === "auto" && (floorReason || classifierBlock)) {
1002
+ if (mode === "auto" && (floorReason || pausedResume)) {
551
1003
  logDecision(ctx, {
552
1004
  tool: event.toolName,
553
- subject,
554
- outcome: choice === YES || choice === YES_SESSION ? "allow" : "block",
1005
+ subject: matchSubject,
1006
+ outcome: approved(choice, grant) ? "allow" : "block",
555
1007
  source: "user",
556
- reason: floorReason ?? classifierBlock,
1008
+ reason: floorReason ?? "resume after pause",
557
1009
  });
558
1010
  }
559
1011
 
560
1012
  // Approving a prompted call is what resumes a paused auto mode.
561
- if (choice === YES || choice === YES_SESSION) {
562
- if (mode === "auto") {
563
- const wasPaused = pauseTracker.isPaused();
564
- pauseTracker.resume();
565
- if (wasPaused) applyBadge();
566
- }
1013
+ if (approved(choice, grant) && mode === "auto") {
1014
+ const wasPaused = pauseTracker.isPaused();
1015
+ pauseTracker.resume();
1016
+ if (wasPaused) applyBadge();
567
1017
  }
568
1018
 
569
1019
  if (choice === YES) return undefined;
570
- if (choice === YES_SESSION) {
571
- const tool = normalizeToolName(event.toolName);
572
- const rule = tool === "bash" && subject ? parseRule(`bash(${subject})`) : parseRule(tool);
573
- if (rule) sessionAllows.push(rule);
1020
+ if (grant && choice === grant.label) {
1021
+ sessionAllows.push(grant.rule);
574
1022
  return undefined;
575
1023
  }
576
1024
 
577
1025
  // The "tell the agent what to do differently" option has to actually carry
578
1026
  // the user's words, or the model is left guessing why it was stopped.
579
- const feedback = await ctx.ui.input("What should the agent do instead?", "Optional — press Esc to skip");
1027
+ const feedback = await serializePrompt(() => ctx.ui.input("What should the agent do instead?", "Optional — press Esc to skip"));
580
1028
  return {
581
1029
  block: true,
582
1030
  reason: feedback?.trim() ? `${DENIED_BY_USER}\n\nThe user said: ${feedback.trim()}` : DENIED_BY_USER,
583
1031
  };
584
1032
  });
585
1033
 
1034
+ /**
1035
+ * Evaluate ONE subagent tool call against the parent's live gate — the bridge
1036
+ * published on SUBAGENT_GATE_CHANNEL. Mirrors the main tool_call handler's
1037
+ * decision flow (mode, rules, safety floor, auto-mode classifier, and an
1038
+ * interactive ask), so a child inherits the parent's mode and hits the real gate
1039
+ * (findings §17.1). Differences from the main path, all deliberate: it never
1040
+ * mutates the parent's live transcript (the child action is appended to a copy
1041
+ * for the classifier) and uses the child's own cwd; child blocks do not COUNT
1042
+ * toward the parent's pauseTracker (a child is bounded by its wall-clock cap
1043
+ * and the hand-back return review instead) but an active pause IS honoured —
1044
+ * a paused session's child calls fall through to the resume prompt; and an ask
1045
+ * prompt renders on the parent's terminal via lastReviewCtx, serialized. No
1046
+ * parent UI reachable → fail closed.
1047
+ */
1048
+ const evaluateChildToolCall = async (call: ChildToolCall): Promise<ChildGateDecision> => {
1049
+ const ctx = lastReviewCtx;
1050
+ const { toolName, input, cwd } = call;
1051
+ const normalizedTool = normalizeToolName(toolName);
1052
+ const subject = extractSubject(normalizedTool, input);
1053
+ const resolvedSubject =
1054
+ isPathSubjectTool(normalizedTool) && subject
1055
+ ? resolveForContainment(toAbsolute(cwd, subject, os.homedir()))
1056
+ : undefined;
1057
+
1058
+ const result = decide({
1059
+ toolName,
1060
+ subject,
1061
+ cwd,
1062
+ mode,
1063
+ deny,
1064
+ ask,
1065
+ // Same rule set the main handler applies once the user has consented to
1066
+ // the repo's own allow list — a child must not prompt (or, headless, be
1067
+ // denied) for a call the parent would allow (SUBAGENT-REVIEW L5).
1068
+ allow: [...allow, ...activeSessionAllows(), ...(projectAllowTrusted ? projectAllow : [])],
1069
+ classifyAllShell: classifyAllShell(),
1070
+ resolvedSubject,
1071
+ // The child's own cwd (a worktree, if isolated) is its working directory.
1072
+ resolvedCwd: resolveForContainment(cwd),
1073
+ planFilePath,
1074
+ memoryDirPath,
1075
+ scratchpadDirPath,
1076
+ resultsDirPath,
1077
+ protectedDirs,
1078
+ });
1079
+
1080
+ // A child's cwd can be a worktree (different project → different per-repo
1081
+ // settings path), so the floor must derive it from THIS call's cwd, not the
1082
+ // parent-session-cached path. Omitting it lets safetyControlWrite derive
1083
+ // from `cwd`; the fs walk is acceptable on the (rarer) child path, and
1084
+ // correctness of the gate-control floor is not negotiable.
1085
+ const floorReason =
1086
+ mode === "auto"
1087
+ ? safetyControlWrite({ toolName: normalizedTool, input, cwd, home: os.homedir() })
1088
+ : undefined;
1089
+
1090
+ if (result.decision === "allow" && !floorReason) return undefined;
1091
+
1092
+ if (result.decision === "deny") {
1093
+ // A child's rule denial must NOT be recorded here: the child path never
1094
+ // mutates the parent's live transcript (docs/decisions/subagents-workflows.md
1095
+ // — child calls stay out of it). Pushing a `denied` entry would leak the
1096
+ // child's action into the parent and flip `afterRuleDenial` on for every
1097
+ // later MAIN classification. The child's classifier already learns the
1098
+ // user's forbidden classes from the rule extras.
1099
+ return { block: true, reason: denyReason(result) };
1100
+ }
1101
+
1102
+ // Auto mode: the classifier screens the child's call, exactly as it screens
1103
+ // the main agent's. A block is returned to the child (never a per-action
1104
+ // prompt), so it can find a safe alternative — auto mode runs unattended.
1105
+ // While the loop-breaker pause is in effect, the classifier is NOT consulted:
1106
+ // the call falls through to the resume prompt below, exactly as the main
1107
+ // handler's paused branch does — a pause is a full-session stop awaiting the
1108
+ // user, and child work must not continue through it unattended.
1109
+ if (!floorReason && result.decision === "classify" && !pauseTracker.isPaused()) {
1110
+ if (!ctx) return { block: true, reason: DENIED_NON_INTERACTIVE };
1111
+ const appendEntry: TranscriptEntry = {
1112
+ kind: "tool",
1113
+ tool: normalizedTool,
1114
+ input: normalizedTool === "bash" ? { command: subject } : input,
1115
+ };
1116
+ const outcome = await runClassifier(toolName, input, subject, ctx, result.cause !== "protected-path", {
1117
+ cwd,
1118
+ appendEntry,
1119
+ // The child's own turn signal, so an aborted child turn cancels the
1120
+ // classifier call; a fresh (never-aborted) signal only if the child
1121
+ // didn't supply one, so classify() still gets the signal it expects.
1122
+ signal: call.signal ?? new AbortController().signal,
1123
+ });
1124
+ if (outcome.decision === "allow") return undefined;
1125
+ if (outcome.tier === "timeout") return { block: true, reason: BLOCKED_BY_TIMEOUT(outcome.reason) };
1126
+ return { block: true, reason: DENIED_BY_CLASSIFIER(outcome.reason) };
1127
+ }
1128
+
1129
+ // Safety floor: a write to the gate's own config is NEVER auto-approved and
1130
+ // never prompted — in auto mode it is returned to the model, exactly as the
1131
+ // main handler does (floorReason is only ever set in auto mode). Prompting it
1132
+ // would let an inattentive "Yes" — or a prompt-injected child — defeat the one
1133
+ // control the classifier itself can't be trusted to enforce.
1134
+ if (floorReason) {
1135
+ if (ctx) logDecision(ctx, { tool: toolName, subject, outcome: "block", source: "floor", reason: floorReason });
1136
+ return { block: true, reason: DENIED_SAFETY_FLOOR(floorReason) };
1137
+ }
1138
+
1139
+ // ask: needs the user. Bubble it to the parent's terminal; no UI → fail closed.
1140
+ if (!ctx || !ctx.hasUI) return { block: true, reason: DENIED_NON_INTERACTIVE };
1141
+ const preview = previewSubject(subject);
1142
+ // Reaching here from the classify branch only happens while auto mode is
1143
+ // paused — this is the resume prompt, not a per-action approval.
1144
+ const pausedResume = mode === "auto" && result.decision === "classify";
1145
+ // Name the asking agent: two children prompting back-to-back are otherwise
1146
+ // indistinguishable on screen.
1147
+ const who = call.agent ? `subagent ${call.agent}'s` : "a subagent's";
1148
+ const title = askTitle(`${who} ${toolName}`, preview, result.cause, pausedResume);
1149
+ // The child's turn signal dismisses the dialog (as a denial) when the child
1150
+ // is stopped mid-prompt; a prompt whose child is already gone by the time
1151
+ // its turn in the chain comes is skipped outright. Otherwise a dead agent's
1152
+ // dialog stays on screen and every later prompt waits behind it (M5).
1153
+ const childPrompt = <T>(show: () => Promise<T>): Promise<T | undefined> =>
1154
+ serializePrompt(() => (call.signal?.aborted ? Promise.resolve(undefined) : show()));
1155
+ const grant = sessionGrant({ toolName: normalizedTool, subject, cwd, mode, cause: result.cause, home: os.homedir() });
1156
+ const choice = await childPrompt(() => ctx.ui.select(title, askOptions(grant), { signal: call.signal }));
1157
+ if (call.signal?.aborted) return { block: true, reason: "The agent was stopped while waiting for the user's approval." };
1158
+
1159
+ if (pausedResume) {
1160
+ logDecision(ctx, {
1161
+ tool: toolName,
1162
+ subject,
1163
+ outcome: approved(choice, grant) ? "allow" : "block",
1164
+ source: "user",
1165
+ reason: "resume after pause",
1166
+ });
1167
+ // Approving a prompted call is what resumes a paused auto mode.
1168
+ if (approved(choice, grant)) {
1169
+ pauseTracker.resume();
1170
+ applyBadge();
1171
+ }
1172
+ }
1173
+
1174
+ if (choice === YES) return undefined;
1175
+ if (grant && choice === grant.label) {
1176
+ sessionAllows.push(grant.rule);
1177
+ return undefined;
1178
+ }
1179
+ const feedback = await childPrompt(() => ctx.ui.input("What should the agent do instead?", "Optional — press Esc to skip", { signal: call.signal }));
1180
+ return {
1181
+ block: true,
1182
+ reason: feedback?.trim() ? `${DENIED_BY_USER}\n\nThe user said: ${feedback.trim()}` : DENIED_BY_USER,
1183
+ };
1184
+ };
1185
+
586
1186
  pi.registerCommand("permissions", {
587
1187
  description: "Show permission mode and loaded rules",
588
1188
  handler: async (_args, ctx) => {
@@ -608,7 +1208,13 @@ export default function permissionsExtension(pi: ExtensionAPI) {
608
1208
  `deny: ${fmt(deny)}`,
609
1209
  `ask: ${fmt(ask)}`,
610
1210
  `allow: ${fmt(allow)}`,
1211
+ ...(projectAllow.length > 0
1212
+ ? [
1213
+ `project allow (${projectAllowTrusted ? "trusted" : projectAllowDeclined ? "declined this session" : "awaiting consent"}): ${fmt(projectAllow)}`,
1214
+ ]
1215
+ : []),
611
1216
  `session allows: ${fmt(sessionAllows)}`,
1217
+ ...(unparsableRules.length > 0 ? [`unparsable rules (ignored): ${unparsableRules.join(", ")}`] : []),
612
1218
  ...autoLines,
613
1219
  ].join("\n"),
614
1220
  "info",
@@ -637,15 +1243,25 @@ export default function permissionsExtension(pi: ExtensionAPI) {
637
1243
  signal: AbortSignal | undefined,
638
1244
  ): Promise<string | undefined> => {
639
1245
  autoConfig ??= loadAutoModeConfig(os.homedir());
1246
+ // Present the child's actions as tool entries appended to the session
1247
+ // transcript, so the reviewer judges the sequence with the parent's context
1248
+ // in view (SESSION LINKING). reviewOnly runs a single stage-2 full-eval —
1249
+ // there is no harm floor to short-circuit for a run that already happened.
1250
+ const childEntries: TranscriptEntry[] = actions.map((action) => {
1251
+ const tool = normalizeToolName(action.toolName);
1252
+ return { kind: "tool", tool, input: tool === "bash" ? { command: action.subject } : { subject: action.subject } };
1253
+ });
640
1254
  const verdict = await classify(
641
1255
  {
642
- toolName: "subagent (completed run — review the actions below as a whole)",
643
- input: { actions: renderActions(actions) },
644
- cwd: ctx.cwd,
1256
+ toolName: "subagent-review",
1257
+ transcript: [...transcript, ...childEntries],
645
1258
  userMessages: [...userMessages],
646
- projectInstructions,
647
- routedBecause:
648
- "This subagent has already finished; you are reviewing what it did, as a whole, rather than approving anything. Its individual actions were each checked as it ran. Judge whether the sequence together amounts to something the rules forbid — a series of individually unremarkable steps can add up to one.",
1259
+ claudeMd: instructionsFor(ctx.cwd),
1260
+ username: classifierUsername,
1261
+ environment: autoConfig.environment,
1262
+ // AutoModeConfig is structurally a RuleExtras; the user's own deny rules
1263
+ // are appended to its hard list (classifierRuleExtras).
1264
+ ruleExtras: classifierRuleExtras(),
649
1265
  },
650
1266
  {
651
1267
  registry: ctx.modelRegistry,
@@ -654,10 +1270,13 @@ export default function permissionsExtension(pi: ExtensionAPI) {
654
1270
  signal,
655
1271
  state: classifierState,
656
1272
  onNotice: (message, level) => ctx.ui.notify(message, level),
1273
+ onUsage: onClassifierUsage,
1274
+ onModelUnusable: onClassifierModelUnusable,
1275
+ reviewOnly: true,
657
1276
  },
658
1277
  );
659
1278
  logDecision(ctx, {
660
- tool: "subagent",
1279
+ tool: "Agent",
661
1280
  subject,
662
1281
  outcome: verdict.decision,
663
1282
  source: "review",
@@ -667,7 +1286,7 @@ export default function permissionsExtension(pi: ExtensionAPI) {
667
1286
  });
668
1287
  if (verdict.decision === "allow") return undefined;
669
1288
  pauseTracker.recordBlock({
670
- toolName: "subagent",
1289
+ toolName: "Agent",
671
1290
  subject,
672
1291
  reason: verdict.reason,
673
1292
  tier: verdict.tier,
@@ -687,18 +1306,32 @@ export default function permissionsExtension(pi: ExtensionAPI) {
687
1306
  return;
688
1307
  }
689
1308
  // Background/resident: the spawning call already returned, so there is no
690
- // tool_result to attach to. Review now and surface any concern as its own
691
- // follow-up, alongside the completion report the model receives.
1309
+ // tool_result to attach to. The emitter holds its completion report until
1310
+ // we answer `onReview`, so the verdict travels with the report (a payload
1311
+ // without the callback gets the older standalone notice instead). Answer
1312
+ // synchronously when no review will run, so the report is never delayed
1313
+ // for nothing. A review that throws fails closed: the report goes out
1314
+ // flagged, not clean.
1315
+ const respond =
1316
+ payload.onReview ??
1317
+ ((flag: string | undefined) => {
1318
+ if (flag) notifyTask("subagent-review", flag);
1319
+ });
692
1320
  const ctx = lastReviewCtx;
693
- if (mode !== "auto" || payload.actions.length === 0 || pauseTracker.isPaused() || !ctx) return;
1321
+ if (mode !== "auto" || payload.actions.length === 0 || pauseTracker.isPaused() || !ctx) {
1322
+ respond(undefined);
1323
+ return;
1324
+ }
694
1325
  const label = payload.agentName ? `${payload.agentName} (background run)` : "background run";
695
- void reviewCompletedRun(payload.actions, ctx, label, new AbortController().signal).then((reason) => {
696
- if (!reason) return;
697
- pi.sendMessage(
698
- { customType: "subagent-review", content: [{ type: "text", text: reviewFlagged(reason) }], display: true, details: {} },
699
- { deliverAs: "followUp", triggerTurn: true },
700
- );
701
- });
1326
+ const epoch = sessionEpoch;
1327
+ // A review that outlives its session (a /clear mid-review) is dropped: the
1328
+ // agents were stopped with the old session and its ctx no longer renders.
1329
+ const respondIfCurrent = (flag: string | undefined) => {
1330
+ if (epoch === sessionEpoch) respond(flag);
1331
+ };
1332
+ reviewCompletedRun(payload.actions, ctx, label, new AbortController().signal)
1333
+ .then((reason) => respondIfCurrent(reason ? reviewFlagged(reason) : undefined))
1334
+ .catch((error) => respondIfCurrent(reviewFlagged(`the review itself failed (${(error as Error).message})`)));
702
1335
  });
703
1336
 
704
1337
  pi.on("tool_result", async (event, ctx) => {
@@ -730,13 +1363,15 @@ export default function permissionsExtension(pi: ExtensionAPI) {
730
1363
  return;
731
1364
  }
732
1365
  try {
733
- persistClassifierModel(resolved, os.homedir());
1366
+ // Stamp the session's containment so a later session on another provider
1367
+ // treats this cross-provider setting as stale (parity with /subagent).
1368
+ persistClassifierModel(resolved, os.homedir(), ctx.model ? modelIdentity(ctx.model).containment : undefined);
734
1369
  } catch (error) {
735
1370
  ctx.ui.notify(`Could not save classifier model: ${error instanceof Error ? error.message : String(error)}`, "error");
736
1371
  return;
737
1372
  }
738
1373
  resetClassifierChoice();
739
- ctx.ui.notify(`Auto-mode classifier set to ${resolved} (saved to ~/.claude/settings.json).`, "info");
1374
+ ctx.ui.notify(`Auto-mode classifier set to ${resolved} (saved to ~/.onecode/settings.json).`, "info");
740
1375
  };
741
1376
 
742
1377
  /** `/auto-mode model` — show the picker, or apply a named model / `clear`. */
@@ -783,12 +1418,128 @@ export default function permissionsExtension(pi: ExtensionAPI) {
783
1418
  if (chosen) await applyClassifierChoice(pickerSpec(chosen), ctx);
784
1419
  };
785
1420
 
1421
+ /**
1422
+ * `/auto-mode setup` — Claude Code's setup-wizard flow: ask how this
1423
+ * environment is used, gather facts (git/gh/CLAUDE.md, opt-in shell
1424
+ * history), have a capable model draft the Environment slots and rule
1425
+ * extras, review, persist to user settings, then audit permissions.allow
1426
+ * for rules that skip the classifier entirely.
1427
+ */
1428
+ const handleSetupSubcommand = async (ctx: ExtensionContext): Promise<void> => {
1429
+ if (!ctx.hasUI) {
1430
+ ctx.ui.notify("/auto-mode setup is interactive — run it in a live session.", "warning");
1431
+ return;
1432
+ }
1433
+ const usage = await ctx.ui.select("How do you use One Code in this environment?", [
1434
+ "Software development in this repo",
1435
+ "Mixed — coding and general tasks",
1436
+ "Mostly questions and analysis",
1437
+ ]);
1438
+ if (!usage) return;
1439
+ const history = await ctx.ui.select("Also scan recent shell history? (stays local except the drafting call; secrets are redacted)", [
1440
+ "Yes",
1441
+ "No",
1442
+ ]);
1443
+ if (!history) return;
1444
+
1445
+ ctx.ui.notify("Gathering environment facts (git, gh, CLAUDE.md)…", "info");
1446
+ const facts = await gatherFacts({
1447
+ cwd: ctx.cwd,
1448
+ home: os.homedir(),
1449
+ username: classifierUsername,
1450
+ usage,
1451
+ includeShellHistory: history === "Yes",
1452
+ });
1453
+ ctx.ui.notify("Drafting the auto-mode setup — this can take a minute…", "info");
1454
+ let draft: Awaited<ReturnType<typeof draftSetup>>;
1455
+ try {
1456
+ draft = await draftSetup(facts, {
1457
+ registry: ctx.modelRegistry,
1458
+ sessionModel: ctx.model,
1459
+ config: autoConfig ?? loadAutoModeConfig(os.homedir()),
1460
+ defaultEnvironment: DEFAULT_ENVIRONMENT,
1461
+ signal: ctx.signal,
1462
+ onNotice: (message, level) => ctx.ui.notify(message, level),
1463
+ onUsage: (usage) => recordUsage(pi, "setup", usage),
1464
+ });
1465
+ } catch (error) {
1466
+ ctx.ui.notify(`Auto-mode setup failed: ${(error as Error).message}. Nothing was written.`, "warning");
1467
+ return;
1468
+ }
1469
+
1470
+ ctx.ui.notify(`Proposed auto-mode setup\n\n${renderProposal(draft)}`, "info");
1471
+ const decision = await ctx.ui.select("Save this auto-mode setup to ~/.onecode/settings.json?", [
1472
+ "Looks good — save it",
1473
+ "Discard",
1474
+ ]);
1475
+ if (decision === "Looks good — save it") {
1476
+ try {
1477
+ persistAutoModeSetup(settingsPatch(draft), os.homedir());
1478
+ } catch (error) {
1479
+ ctx.ui.notify(`Could not write One Code settings: ${(error as Error).message}`, "warning");
1480
+ return;
1481
+ }
1482
+ autoConfig = loadAutoModeConfig(os.homedir());
1483
+ ctx.ui.notify("Saved. /auto-mode config shows the effective setup.", "info");
1484
+ } else {
1485
+ ctx.ui.notify("Discarded — nothing was written.", "info");
1486
+ }
1487
+
1488
+ // CC's "rules that skip checks": broad permissions.allow entries bypass the
1489
+ // classifier whether or not the setup above was saved, so the audit runs
1490
+ // either way. Entries that live in Claude Code's own settings are One Code's
1491
+ // to warn about, never to delete — only One Code's own file is editable here.
1492
+ const flagged = auditPermissionAllow(facts.permissionsAllow);
1493
+ if (flagged.length === 0) return;
1494
+ // Split by the file each rule lives in, not by exclusion — a rule in BOTH
1495
+ // files is removable from One Code's AND still live in Claude Code's, so it
1496
+ // must appear in both lists (removing the One Code copy alone would leave the
1497
+ // exposure and a false "fixed" impression).
1498
+ const oneCodeAllow = new Set(oneCodePermissionAllow(os.homedir()));
1499
+ const claudeAllow = new Set(claudeUserPermissionAllow(os.homedir()));
1500
+ const removable = flagged.filter((entry) => oneCodeAllow.has(entry.rule));
1501
+ const claudeSide = flagged.filter((entry) => claudeAllow.has(entry.rule));
1502
+
1503
+ ctx.ui.notify(
1504
+ "These permissions.allow entries are broad enough that matching commands never reach auto mode's checks:\n" +
1505
+ flagged.map((entry) => ` · ${entry.rule} — ${entry.why}`).join("\n"),
1506
+ "warning",
1507
+ );
1508
+ if (claudeSide.length > 0) {
1509
+ ctx.ui.notify(
1510
+ "These live in your Claude Code settings (~/.claude) and stay in force even after any One Code copy is removed. One Code never edits Claude Code's files — remove them there yourself if you want them gated:\n" +
1511
+ claudeSide.map((entry) => ` · ${entry.rule}`).join("\n"),
1512
+ "warning",
1513
+ );
1514
+ }
1515
+ if (removable.length === 0) return;
1516
+ const act = await ctx.ui.select(
1517
+ `Remove ${removable.length} broad ${removable.length === 1 ? "entry" : "entries"} from One Code settings (~/.onecode/settings.json)?`,
1518
+ ["Remove them", "Leave them"],
1519
+ );
1520
+ if (act === "Remove them") {
1521
+ try {
1522
+ const removed = removeOneCodePermissionAllow(
1523
+ removable.map((entry) => entry.rule),
1524
+ os.homedir(),
1525
+ );
1526
+ ctx.ui.notify(
1527
+ `Removed ${removed} allow ${removed === 1 ? "entry" : "entries"} from One Code settings. Removed entries can be restored by re-adding them verbatim.`,
1528
+ "info",
1529
+ );
1530
+ } catch (error) {
1531
+ ctx.ui.notify(`Could not update One Code settings: ${(error as Error).message}`, "warning");
1532
+ }
1533
+ }
1534
+ };
1535
+
786
1536
  pi.registerCommand("auto-mode", {
787
- description: "Auto-mode classifier: /auto-mode [defaults|config|model [provider/model-id|clear]]",
1537
+ description: "Auto-mode classifier: /auto-mode [setup|defaults|config|model [provider/model-id|clear]]",
788
1538
  getArgumentCompletions: () =>
789
1539
  [
790
- { value: "config", label: "effective rules" },
791
- { value: "defaults", label: "built-in rules" },
1540
+ { value: "setup", label: "analyze this environment and draft the config" },
1541
+ { value: "config", label: "effective environment" },
1542
+ { value: "defaults", label: "built-in environment" },
792
1543
  { value: "model", label: "choose the classifier model" },
793
1544
  ],
794
1545
  handler: async (args, ctx) => {
@@ -797,39 +1548,51 @@ export default function permissionsExtension(pi: ExtensionAPI) {
797
1548
  await handleModelSubcommand(rest.join(" ").trim(), ctx);
798
1549
  return;
799
1550
  }
1551
+ if (sub === "setup") {
1552
+ await handleSetupSubcommand(ctx);
1553
+ return;
1554
+ }
800
1555
  const which = sub ?? "config";
801
1556
  if (which !== "defaults" && which !== "config") {
802
1557
  ctx.ui.notify(
803
- `Unknown subcommand "${which}". Use: /auto-mode defaults | config | model [provider/model-id|clear]`,
1558
+ `Unknown subcommand "${which}". Use: /auto-mode setup | defaults | config | model [provider/model-id|clear]`,
804
1559
  "warning",
805
1560
  );
806
1561
  return;
807
1562
  }
808
- // "defaults" prints the built-in lists; "config" prints what the
809
- // classifier actually uses, with $defaults already spliced in. The
1563
+ // "defaults" prints the built-in Environment; "config" prints what the
1564
+ // classifier actually uses, with $defaults already spliced in. The rule
1565
+ // set's prose is Claude Code's fixed monolith and is not shown here — the
1566
+ // customization surface is the Environment plus the append-only
1567
+ // hard_deny/soft_deny/allow rule extras (the same schema Claude Code's
1568
+ // /auto-mode-setup writes; those settings work here unchanged). The
810
1569
  // config view re-reads disk so it shows the file as it is now, and
811
- // refreshes the gate's cached copy while it is at it.
1570
+ // refreshes the cached copy.
812
1571
  const loaded = which === "config" ? loadAutoModeConfigWithDiagnostics(os.homedir()) : undefined;
813
1572
  if (loaded) autoConfig = loaded.config;
814
- const shown = loaded
815
- ? loaded.config
816
- : {
817
- environment: DEFAULT_ENVIRONMENT,
818
- allow: DEFAULT_ALLOW,
819
- soft_deny: DEFAULT_SOFT_DENY,
820
- hard_deny: DEFAULT_HARD_DENY,
821
- };
1573
+ const shown = loaded ? loaded.config : { environment: DEFAULT_ENVIRONMENT };
822
1574
  const section = (title: string, entries: string[]) =>
823
1575
  `${title} (${entries.length}):\n${entries.map((entry) => ` - ${entry}`).join("\n")}`;
1576
+ const ruleSections =
1577
+ loaded && "hardDeny" in shown
1578
+ ? (
1579
+ [
1580
+ ["extra hard_deny rules", shown.hardDeny],
1581
+ ["extra soft_deny rules", shown.softDeny],
1582
+ ["extra allow rules", shown.allow],
1583
+ ] as const
1584
+ )
1585
+ .filter(([, entries]) => entries.length > 0)
1586
+ .map(([title, entries]) => section(title, entries))
1587
+ : [];
824
1588
  ctx.ui.notify(
825
1589
  [
826
1590
  which === "config"
827
- ? `read from: ${autoModeSettingsPaths(os.homedir()).join(", ")}\n(project settings are deliberately not read — a repo could otherwise grant itself allow rules)`
828
- : "built-in rules; add \"$defaults\" to a list in settings to keep them",
829
- section("hard_deny", shown.hard_deny),
830
- section("soft_deny", shown.soft_deny),
831
- section("allow", shown.allow),
1591
+ ? `read from: ${autoModeSettingsPaths(os.homedir()).join(", ")}\n(project settings are deliberately not read — a repo could otherwise grant itself permissions)`
1592
+ : 'built-in environment; add "$defaults" to autoMode.environment in settings to keep it while adding your own',
1593
+ "The built-in ruleset is Claude Code's fixed classifier ruleset (not shown); customize via the environment and the append-only hard_deny/soft_deny/allow extras.",
832
1594
  section("environment", shown.environment),
1595
+ ...ruleSections,
833
1596
  ...(which === "config" && "classifyAllShell" in shown
834
1597
  ? [
835
1598
  `classifyAllShell: ${shown.classifyAllShell}`,
@@ -866,8 +1629,10 @@ export default function permissionsExtension(pi: ExtensionAPI) {
866
1629
  ctx.ui.notify(`Could not parse rule: "${raw}". Format: Tool or Tool(pattern)`, "warning");
867
1630
  return;
868
1631
  }
869
- const paths = settingsPaths(ctx.cwd, os.homedir());
870
- const target = global ? paths.user : paths.local;
1632
+ // Allow rules are One Code's own state — never written into Claude Code's
1633
+ // files. `global` lands in ~/.onecode/settings.json; a project rule in a
1634
+ // per-repo file under ~/.onecode, keyed by the repo root.
1635
+ const target = global ? oneCodeSettingsPath(os.homedir()) : oneCodeProjectSettingsPath(ctx.cwd, os.homedir());
871
1636
  persistAllowRule(raw, target);
872
1637
  allow.push(rule);
873
1638
  ctx.ui.notify(`Added allow rule ${raw} to ${target}`, "info");