one-code-extension 0.1.4 → 0.3.1

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 +222 -177
  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 +128 -0
  8. package/docs/guide/configuration.md +173 -0
  9. package/docs/guide/differences-from-claude-code.md +152 -0
  10. package/docs/guide/doctor.md +88 -0
  11. package/docs/guide/hooks.md +111 -0
  12. package/docs/guide/installation.md +126 -0
  13. package/docs/guide/permissions-modes-and-auto-mode.md +452 -0
  14. package/docs/guide/providers-and-models.md +196 -0
  15. package/docs/guide/reference.md +226 -0
  16. package/docs/guide/sessions-and-context.md +192 -0
  17. package/docs/guide/skills-plugins-and-mcp.md +178 -0
  18. package/docs/guide/subagents-and-workflows.md +195 -0
  19. package/docs/guide/tasks-and-background-work.md +113 -0
  20. package/docs/guide/terminal-interface.md +157 -0
  21. package/docs/guide/tools.md +177 -0
  22. package/docs/guide/troubleshooting.md +177 -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 -217
  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
@@ -28,15 +28,59 @@ export interface CycleOptions {
28
28
  autoInCycle: boolean;
29
29
  }
30
30
 
31
+ /** Strip a trailing release-date stamp (`-20250601`), noise in a model id. */
32
+ function stripDateSuffix(id: string): string {
33
+ return id.replace(/-20\d{6}$/, "");
34
+ }
35
+
31
36
  /**
32
- * Shorten a model id for the footer, which has little room. Drops an OpenRouter
33
- * style `vendor/` prefix and a trailing date stamp, both of which are noise once
34
- * the family name is visible: `anthropic/claude-haiku-4-5-20251001` → `haiku-4-5`.
37
+ * Shorten a model id for a compact slot. Drops an OpenRouter style `vendor/`
38
+ * prefix and a trailing date stamp, both of which are noise once the family name
39
+ * is visible: `anthropic/claude-haiku-4-5-20251001` → `haiku-4-5`.
35
40
  */
36
41
  export function shortModelName(id: string): string {
37
42
  const afterSlash = id.slice(id.lastIndexOf("/") + 1);
38
- const undated = afterSlash.replace(/-20\d{6}$/, "");
39
- return undated.replace(/^(claude|gpt|gemini|llama|grok|mistral|deepseek|qwen)-/, "");
43
+ return stripDateSuffix(afterSlash).replace(/^(claude|gpt|gemini|llama|grok|mistral|deepseek|qwen)-/, "");
44
+ }
45
+
46
+ /**
47
+ * Model as `(provider) id`, e.g. `(openrouter) google/gemini-3.7-flash`. One
48
+ * Code runs on any provider, so the provider and the full vendor-qualified id
49
+ * both matter — a bare short name hides which model an aggregator route points
50
+ * at. Only the noisy trailing date (`-20250601`) is trimmed. Used by the footer
51
+ * and the startup banner so both name models the same way.
52
+ */
53
+ export function formatModel(provider: string, id: string): string {
54
+ const undated = stripDateSuffix(id);
55
+ return provider ? `(${provider}) ${undated}` : undated;
56
+ }
57
+
58
+ /**
59
+ * Same as `formatModel` for a combined `provider/id` spec (what the subagent
60
+ * status carries): the first path segment is the provider, the rest is the id
61
+ * (which may itself contain slashes, e.g. `openrouter/google/gemini-3.7-flash`).
62
+ */
63
+ export function formatModelSpec(spec: string): string {
64
+ const slash = spec.indexOf("/");
65
+ if (slash < 0) return formatModel("", spec);
66
+ return formatModel(spec.slice(0, slash), spec.slice(slash + 1));
67
+ }
68
+
69
+ /**
70
+ * pi's placeholder model id when no provider is configured yet. A fresh machine
71
+ * with no key reports `{ provider: "unknown", id: "unknown" }`, which
72
+ * `formatModel` would render as the meaningless `(unknown) unknown`
73
+ * (distribution review 2026-09-09, M3). The banner and footer show "none"
74
+ * instead, so the first screen reads as "no provider" rather than a broken model.
75
+ */
76
+ export const PLACEHOLDER_MODEL = "unknown";
77
+
78
+ /**
79
+ * True when `id` names a real selected model, not pi's no-provider placeholder.
80
+ * A type predicate so callers narrow the id to `string` in the true branch.
81
+ */
82
+ export function isRealModel(id: string | undefined): id is string {
83
+ return !!id && id !== PLACEHOLDER_MODEL;
40
84
  }
41
85
 
42
86
  /**
@@ -54,9 +98,13 @@ export const CYCLE_KEY_HINT = "ctrl+q to cycle";
54
98
  * the way Claude Code's footer does, so the shortcut sits next to the state it
55
99
  * changes.
56
100
  */
57
- export function modeBadge(mode: PermissionMode, opts?: { paused?: boolean; classifierModel?: string }): string {
101
+ export function modeBadge(
102
+ mode: PermissionMode,
103
+ opts?: { paused?: boolean; classifierModel?: string; streaming?: boolean },
104
+ ): string {
58
105
  const base = badgeBase(mode, opts);
59
- return `${base} (${CYCLE_KEY_HINT})`;
106
+ // Claude Code appends the interrupt hint to this line while the model works.
107
+ return `${base} (${CYCLE_KEY_HINT})${opts?.streaming ? " · esc to interrupt" : ""}`;
60
108
  }
61
109
 
62
110
  function badgeBase(mode: PermissionMode, opts?: { paused?: boolean; classifierModel?: string }): string {
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Consent for a repository's own allow rules. `.claude/settings.json` and
3
+ * `.claude/settings.local.json` travel with the checkout, so an allow rule in
4
+ * them is a grant made by whoever committed the file, not by the user running
5
+ * One Code — and in auto mode a rule beats the classifier, so a cloned repo
6
+ * shipping `Bash(curl:*)` would exfiltrate unclassified. Project hooks and
7
+ * project `.mcp.json` servers already need a once-per-config consent
8
+ * (hooks/trust.ts, mcp/trust.ts); this is the same shape for allow rules:
9
+ * approval keyed to a hash of the rule list, persisted under `~/.onecode`, so
10
+ * any change to the rules re-prompts. Deny and ask rules from the same files
11
+ * never prompt — they can only tighten the gate.
12
+ *
13
+ * The prompt is lazy: it appears the first time a project rule would be the
14
+ * deciding allow, not at startup (most sessions never hit one). A declined
15
+ * prompt sticks for the process, not on disk. Pure fs + hashing here; the
16
+ * permissions extension owns the dialog.
17
+ */
18
+
19
+ import { createHash } from "node:crypto";
20
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
21
+ import { dirname, join } from "node:path";
22
+ import { oneCodeStateDir } from "../lib/paths.ts";
23
+
24
+ interface ApprovalStore {
25
+ version: 1;
26
+ approvals: Record<string, { rulesHash: string; approvedAt: string }>;
27
+ }
28
+
29
+ export function projectAllowStorePath(): string {
30
+ return join(oneCodeStateDir(), "permissions", "project-allow-approvals.json");
31
+ }
32
+
33
+ /** Order-insensitive: the same rules in another order are one consent. */
34
+ export function hashProjectAllow(rules: readonly string[]): string {
35
+ return createHash("sha256").update(JSON.stringify([...rules].sort())).digest("hex");
36
+ }
37
+
38
+ export function readStoredProjectAllowApproval(projectRoot: string, storePath = projectAllowStorePath()): string | undefined {
39
+ try {
40
+ const store = JSON.parse(readFileSync(storePath, "utf-8")) as ApprovalStore;
41
+ return store.approvals?.[projectRoot]?.rulesHash;
42
+ } catch {
43
+ return undefined;
44
+ }
45
+ }
46
+
47
+ /** True when the stored consent covers exactly this rule list. */
48
+ export function projectAllowApproved(projectRoot: string, rules: readonly string[], storePath = projectAllowStorePath()): boolean {
49
+ return rules.length > 0 && readStoredProjectAllowApproval(projectRoot, storePath) === hashProjectAllow(rules);
50
+ }
51
+
52
+ export function persistProjectAllowApproval(projectRoot: string, rules: readonly string[], storePath = projectAllowStorePath()): void {
53
+ let store: ApprovalStore = { version: 1, approvals: {} };
54
+ try {
55
+ const existing = JSON.parse(readFileSync(storePath, "utf-8")) as ApprovalStore;
56
+ if (existing && typeof existing === "object" && existing.approvals) store = existing;
57
+ } catch {
58
+ // Fresh store.
59
+ }
60
+ store.approvals[projectRoot] = { rulesHash: hashProjectAllow(rules), approvedAt: new Date().toISOString() };
61
+ try {
62
+ mkdirSync(dirname(storePath), { recursive: true });
63
+ writeFileSync(storePath, `${JSON.stringify(store, null, "\t")}\n`);
64
+ } catch {
65
+ // Approval still holds for this session; it will just re-prompt next run.
66
+ }
67
+ }
68
+
69
+ /** The consent dialog's text: what the repository wants pre-approved, and which rule fired now. */
70
+ export function describeProjectAllow(rules: readonly string[], firing: string): { title: string; message: string } {
71
+ const shown = rules.slice(0, 8);
72
+ const more = rules.length > shown.length ? `\n… and ${rules.length - shown.length} more` : "";
73
+ return {
74
+ title: "Trust this repository's allow rules?",
75
+ message:
76
+ `This repository's .claude settings pre-approve ${rules.length} permission rule(s):\n${shown.join("\n")}${more}\n\n` +
77
+ `The current call matches "${firing}" and would run without a prompt` +
78
+ " (in auto mode, without the classifier). Whoever committed the file granted this, not you." +
79
+ " Approval is remembered until the rules change; declining keeps them off for this session.",
80
+ };
81
+ }
@@ -29,15 +29,22 @@ const PROTECTED_DIRS = [
29
29
  // One Code's own state dir — the deny-direction twin of protecting `.claude`:
30
30
  // as generated state moves here, a write that reconfigures the gate must
31
31
  // stay just as guarded in its new home.
32
- ".one-code",
32
+ ".onecode",
33
+ // Run-at-login and on-PATH locations (PERMISSIONS-REVIEW-2026-09-05 M7): a
34
+ // launchd agent plist runs at the next login, a fish config at the next
35
+ // shell, and `~/.local/bin` is where the `pi` shim and user tools live.
36
+ // Lowercase — segments are compared case-folded.
37
+ "library/launchagents",
38
+ ".config/fish",
39
+ ".local/bin",
33
40
  ];
34
41
 
35
42
  /**
36
43
  * `.claude/worktrees` is where the agent keeps its own git worktrees, and
37
- * `.one-code/plans` holds plan-mode documents (rendered to the user, never
44
+ * `.onecode/plans` holds plan-mode documents (rendered to the user, never
38
45
  * executed) — ordinary working space rather than configuration.
39
46
  */
40
- const PROTECTED_DIR_EXCEPTIONS = [".claude/worktrees", ".one-code/plans"];
47
+ const PROTECTED_DIR_EXCEPTIONS = [".claude/worktrees", ".onecode/plans"];
41
48
 
42
49
  const PROTECTED_FILES = new Set([
43
50
  ".gitconfig",
@@ -77,6 +84,10 @@ const PROTECTED_FILES = new Set([
77
84
  "pyrightconfig.json",
78
85
  ".mcp.json",
79
86
  ".claude.json",
87
+ // Claude Code's managed (organisation) settings: the highest-authority
88
+ // permission and auto-mode configuration. Protected in every mode, not only
89
+ // where the auto-mode safety floor covers it (review P9).
90
+ "managed-settings.json",
80
91
  ]);
81
92
 
82
93
  // `isWritingTool` / `WRITING_TOOLS` live in auto-mode/paths.ts (the shared lower
@@ -88,6 +99,8 @@ export { isWritingTool } from "../auto-mode/paths.ts";
88
99
  * Whether a path is protected. Matched on path *segments* so the check works
89
100
  * for absolute paths, cwd-relative ones, and any depth of nesting — a
90
101
  * `packages/app/.vscode/settings.json` is as protected as a top-level one.
102
+ * pi's own agent directory is protected at runtime, not here — its location
103
+ * is `getAgentDir()`'s to know (`DecideInput.protectedDirs`).
91
104
  */
92
105
  export function isProtectedPath(candidate: string, cwd?: string): boolean {
93
106
  const forward = candidate.replace(/\\/g, "/").toLowerCase();
@@ -0,0 +1,79 @@
1
+ /**
2
+ * The user's own deny rules, rendered for the approval classifier (pure).
3
+ *
4
+ * A permission rule binds the spelling it names and nothing else. `Bash(rm:*)`
5
+ * refuses `rm scripts/build.sh` and has no opinion about
6
+ * `python3 -c "import os; os.remove('scripts/build.sh')"`, which is how a
7
+ * tiny-tier model deleted the file anyway, 3 times out of 3
8
+ * (WEAK-MODEL-REVIEW-2026-09-06 H2). The rule cannot be widened
9
+ * deterministically — an earlier attempt remembered the denied path and refused
10
+ * every later call naming it, which caught the retry but also refused a
11
+ * harmless later edit of the same file for the rest of the session, and never
12
+ * caught a FIRST attempt (nothing has been denied yet, so there is nothing to
13
+ * remember).
14
+ *
15
+ * Only something that judges by effect can generalize a rule, so the rules go
16
+ * to the classifier. This is one-directional, like every other model layer
17
+ * here: the rule still fires deterministically on its literal form before any
18
+ * model is consulted, so the classifier can only add blocks the spelling missed
19
+ * and can never clear something a rule already refused.
20
+ *
21
+ * The lines are appended at Claude Code's own HARD BLOCK injection point
22
+ * (`classifier-prompt.ts buildRuleset`), which is the sanctioned append-only
23
+ * surface, so prompt fidelity is untouched. They are indexed by the same scan
24
+ * as the built-in rules (`rules.ts`), so a verdict citing one grounds.
25
+ */
26
+
27
+ import { parseRule } from "./matcher.ts";
28
+
29
+ /** Bound on how many rules are described, so a long deny list cannot dominate the ruleset. */
30
+ const MAX_RULES = 25;
31
+
32
+ /**
33
+ * Bound on the quoted rule spec, so a pathological pattern cannot dominate
34
+ * either. The bound is on the SPEC and not on the finished line: clipping the
35
+ * line would cut the instruction off its own rule.
36
+ */
37
+ const MAX_SPEC = 120;
38
+
39
+ /**
40
+ * A rule name must survive `ruleNameFromLine`, which cuts at the first `:` or
41
+ * ` [`. Rule specs contain both (`Bash(rm:*)`, `Read(~/**)`), so the name
42
+ * carries a punctuation-stripped form and the verbatim spec goes in the body.
43
+ */
44
+ function nameFragment(spec: string): string {
45
+ // The rule's own spelling, not the normalized tool name: this name is what the
46
+ // user is shown when a verdict cites the rule, so `WebFetch` should not come
47
+ // back as `web_fetch`.
48
+ const cleaned = spec
49
+ .replace(/[:[\]`*()]/g, " ")
50
+ .replace(/\s+/g, " ")
51
+ .trim();
52
+ return cleaned.slice(0, 60).trim();
53
+ }
54
+
55
+ /**
56
+ * One HARD BLOCK line per deny rule. `ask` rules are deliberately NOT rendered:
57
+ * an ask rule asks for a decision, and auto mode already routes those to the
58
+ * classifier — turning them into block rules would convert "check with me" into
59
+ * "never", which is not what the user wrote.
60
+ */
61
+ export function denyRuleLines(deny: readonly string[]): string[] {
62
+ const lines: string[] = [];
63
+ const seen = new Set<string>();
64
+ for (const raw of deny) {
65
+ if (lines.length >= MAX_RULES) break;
66
+ const rule = parseRule(raw);
67
+ if (!rule) continue;
68
+ const full = rule.raw.replace(/[\n\r]+/g, " ").trim();
69
+ if (!full || seen.has(full)) continue;
70
+ seen.add(full);
71
+ const spec = full.length > MAX_SPEC ? `${full.slice(0, MAX_SPEC - 1)}…` : full;
72
+ lines.push(
73
+ `User Deny Rule ${nameFragment(spec)}: the user's own settings deny \`${spec}\`. ` +
74
+ "That is the user's standing decision about a CLASS of action, made in advance, so user intent does NOT clear it. " +
75
+ "BLOCK the action under review if it would achieve what that rule forbids, whatever command, interpreter, script or tool it is spelled with.",
76
+ );
77
+ }
78
+ return lines;
79
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * What "Yes, and don't ask again this session" grants (pure).
3
+ *
4
+ * Until 2026-09-05 the option minted a bare tool rule for every tool but bash:
5
+ * one approved in-project write granted every write to every path for the rest
6
+ * of the process, one fetch every URL, one MCP call every argument, and in auto
7
+ * mode the minted rule stood as a classifier bypass for that tool
8
+ * (PERMISSIONS-REVIEW-2026-09-05 M2). Claude Code's equivalents are scoped —
9
+ * "allow all edits during this session" is working-directory-scoped, WebFetch
10
+ * grants a domain — and the option names what it grants. So does this: the
11
+ * grant is scoped by the subject's kind (permissions/matcher.ts `subjectKind`),
12
+ * the label says exactly what it covers, and there is no grant at all where a
13
+ * rule could not matter (a protected path, the auto-mode safety floor — both are
14
+ * checked before allow rules) or where the classifier is the boundary (auto
15
+ * mode: session allows are never applied there).
16
+ */
17
+
18
+ import { dirname } from "node:path";
19
+ import { toAbsolute } from "../auto-mode/paths.ts";
20
+ import { escapeLiteral, isAtOrInsideDir, normalizeToolName, parseRule, type PermissionMode, type PermissionRule, subjectKind, urlHost } from "./matcher.ts";
21
+
22
+ export interface SessionGrant {
23
+ /** The rule to add to the session's allow list when the user picks this option. */
24
+ rule: PermissionRule;
25
+ /** The option label, naming the scope granted. */
26
+ label: string;
27
+ }
28
+
29
+ export interface SessionGrantInput {
30
+ toolName: string;
31
+ /** The subject the prompt shows (the original command in a worktree session). */
32
+ subject: string;
33
+ /** The directory the call runs in (a worktree's path in a worktree session). */
34
+ cwd: string;
35
+ mode: PermissionMode;
36
+ /** The `decide()` cause behind the prompt. */
37
+ cause: string;
38
+ /** The auto-mode safety floor raised this prompt — no rule can cover it. */
39
+ floor?: boolean;
40
+ home: string;
41
+ }
42
+
43
+ /** `~`-abbreviated path for a label. */
44
+ function displayPath(path: string, home: string): string {
45
+ return path === home ? "~" : path.startsWith(`${home}/`) ? `~/${path.slice(home.length + 1)}` : path;
46
+ }
47
+
48
+ /** Claude Code's `//absolute` rule form for a directory and everything under it. */
49
+ function absoluteDirPattern(dir: string): string {
50
+ return `/${dir.replace(/\/+$/, "")}/**`;
51
+ }
52
+
53
+ export function sessionGrant(input: SessionGrantInput): SessionGrant | undefined {
54
+ const { subject, cwd, mode, cause, home } = input;
55
+ // Auto mode never applies session allows (the classifier is the boundary and
56
+ // its prompts are resume/floor prompts, not per-action approvals); protected
57
+ // paths and the safety floor are judged before allow rules, so a rule minted
58
+ // here could never cover them.
59
+ if (mode === "auto" || input.floor || cause === "protected-path") return undefined;
60
+
61
+ const tool = normalizeToolName(input.toolName);
62
+ const mint = (raw: string, label: string): SessionGrant | undefined => {
63
+ const rule = parseRule(raw);
64
+ return rule ? { rule, label } : undefined;
65
+ };
66
+
67
+ switch (subjectKind(tool)) {
68
+ case "command":
69
+ // The approved command, as an exact literal — never a glob.
70
+ if (!subject) return undefined;
71
+ return mint(`${tool}(${escapeLiteral(subject)})`, "Yes, and don't ask again for this exact command this session");
72
+ case "path": {
73
+ if (!subject) return undefined;
74
+ const absolute = toAbsolute(cwd, subject, home);
75
+ // Inside the working directory this is Claude Code's "allow all edits
76
+ // during this session"; outside, the grant stops at the file's directory.
77
+ if (isAtOrInsideDir(absolute, cwd, cwd)) {
78
+ return mint(`${tool}(${absoluteDirPattern(cwd)})`, `Yes, and allow ${tool} anywhere in the working directory this session`);
79
+ }
80
+ const dir = dirname(absolute);
81
+ return mint(`${tool}(${absoluteDirPattern(dir)})`, `Yes, and allow ${tool} under ${displayPath(dir, home)} this session`);
82
+ }
83
+ case "url": {
84
+ const host = urlHost(subject);
85
+ if (!host) return undefined;
86
+ return mint(`${tool}(domain:${host})`, `Yes, and don't ask again for ${host} this session`);
87
+ }
88
+ case "text":
89
+ return mint(tool, `Yes, and don't ask again for ${tool} this session`);
90
+ }
91
+ }
@@ -2,23 +2,55 @@
2
2
  * Claude Code settings.json permissions loading/merging/persistence (pure fs).
3
3
  *
4
4
  * Sources, lowest to highest precedence:
5
- * ~/.claude/settings.json (user)
6
- * <cwd>/.claude/settings.json (project, checked in)
7
- * <cwd>/.claude/settings.local.json (project, personal)
5
+ * ~/.claude/settings.json (user, borrowed — read only)
6
+ * ~/.onecode/settings.json (One Code global — read + write)
7
+ * <cwd>/.claude/settings.json (project, checked in — read only)
8
+ * <cwd>/.claude/settings.local.json (project, personal — read only)
9
+ * ~/.onecode/projects/<slug>/settings.json (One Code per repo — read + write)
10
+ * <managed-settings.json> (organisation policy — read only, highest)
8
11
  *
9
- * allow/deny/ask arrays concatenate across sources; defaultMode from the most
10
- * specific source wins. Unknown keys in the files are preserved on write.
12
+ * allow/deny/ask arrays concatenate across sources — except that the two
13
+ * in-repo files' allow rules land in `projectAllow`, gated behind consent
14
+ * (review P10); defaultMode from the most specific `.claude` source wins, with
15
+ * managed settings above all (the One Code files contribute rules only).
16
+ * One Code persists the rules it records to its own files, never into Claude
17
+ * Code's — `persistAllowRule` is pointed at a One Code path by its caller.
18
+ * Unknown keys in the files are preserved on write.
11
19
  */
12
20
 
13
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
14
- import { dirname, join } from "node:path";
21
+ import { managedSettingsPaths } from "../auto-mode/config.ts";
22
+ import { readSettingsFile as readClaudeSettingsFile, settingsPaths } from "../lib/claude-settings.ts";
23
+ import {
24
+ oneCodeProjectSettingsPath,
25
+ oneCodeSettingsPath,
26
+ readSettingsForWrite,
27
+ writeSettings,
28
+ } from "../lib/one-code-settings.ts";
15
29
  import type { PermissionMode } from "./matcher.ts";
16
30
 
31
+ export { settingsPaths };
32
+
17
33
  export interface PermissionSettings {
34
+ /** Allow rules from sources the user wrote or that outrank the repo (user, One Code, managed). */
18
35
  allow: string[];
36
+ /**
37
+ * Allow rules from the repository's own `.claude/settings.json` /
38
+ * `settings.local.json`. A checked-in file pre-approving `Bash(curl:*)` is a
39
+ * grant the user never made, so these apply only after a once-per-config
40
+ * consent (project-trust.ts) — deny and ask rules from the same files load
41
+ * freely, since they can only tighten the gate.
42
+ */
43
+ projectAllow: string[];
19
44
  deny: string[];
20
45
  ask: string[];
21
46
  defaultMode?: PermissionMode;
47
+ /**
48
+ * Claude Code's `permissions.disableBypassPermissionsMode: "disable"` seen in
49
+ * any source: bypassPermissions is refused however it is requested (flag,
50
+ * `--dangerously-skip-permissions`, settings). A restriction, so every scope
51
+ * may set it — the repo included.
52
+ */
53
+ disableBypassPermissionsMode?: boolean;
22
54
  }
23
55
 
24
56
  interface ClaudeSettingsFile {
@@ -27,10 +59,26 @@ interface ClaudeSettingsFile {
27
59
  deny?: string[];
28
60
  ask?: string[];
29
61
  defaultMode?: string;
62
+ disableBypassPermissionsMode?: string;
30
63
  };
31
64
  [key: string]: unknown;
32
65
  }
33
66
 
67
+ /**
68
+ * Modes a repository's own files (`<cwd>/.claude/settings.json`,
69
+ * `settings.local.json`) may NOT select — honoured from user and managed scope
70
+ * only. Both files live in the checkout, so honouring these there lets a cloned
71
+ * repo grant itself the mode: `auto`, whose classifier is what contains it
72
+ * (Claude Code makes the same exclusion), and `bypassPermissions`, which has no
73
+ * classifier, no prompt and no protected path at all — the footer badge is the
74
+ * only sign (PERMISSIONS-REVIEW-2026-09-05 H3, measured: a checked-in
75
+ * `bypassPermissions` ran an outside-project write with no prompt). `dontAsk`
76
+ * and `acceptEdits` stay honoured: neither can approve what default mode would
77
+ * not (dontAsk turns asks into denials; acceptEdits is confined to the working
78
+ * directory).
79
+ */
80
+ export const MODES_NEVER_FROM_PROJECT: ReadonlySet<PermissionMode> = new Set<PermissionMode>(["auto", "bypassPermissions"]);
81
+
34
82
  const MODES: PermissionMode[] = ["default", "acceptEdits", "plan", "bypassPermissions", "dontAsk", "auto"];
35
83
 
36
84
  /** Display-name aliases Claude Code accepts wherever a mode is named. */
@@ -47,39 +95,41 @@ export function normalizePermissionMode(value: unknown): PermissionMode | undefi
47
95
  }
48
96
 
49
97
  function readSettingsFile(path: string): ClaudeSettingsFile | undefined {
50
- if (!existsSync(path)) return undefined;
51
- try {
52
- return JSON.parse(readFileSync(path, "utf-8")) as ClaudeSettingsFile;
53
- } catch {
54
- return undefined;
55
- }
56
- }
57
-
58
- export function settingsPaths(cwd: string, home: string): { user: string; project: string; local: string } {
59
- return {
60
- user: join(home, ".claude", "settings.json"),
61
- project: join(cwd, ".claude", "settings.json"),
62
- local: join(cwd, ".claude", "settings.local.json"),
63
- };
98
+ // Shared reader (lib/claude-settings.ts) — this module narrows to the
99
+ // permissions-relevant shape.
100
+ return readClaudeSettingsFile(path) as ClaudeSettingsFile | undefined;
64
101
  }
65
102
 
66
103
  export function loadPermissionSettings(cwd: string, home: string): PermissionSettings {
67
104
  const paths = settingsPaths(cwd, home);
68
- const merged: PermissionSettings = { allow: [], deny: [], ask: [] };
105
+ const merged: PermissionSettings = { allow: [], projectAllow: [], deny: [], ask: [] };
106
+
107
+ // One Code writes the rules it records (via /allow, the auto-mode setup) to its
108
+ // own files, never into Claude Code's — so its own files are read alongside the
109
+ // borrowed `.claude` ladder. They contribute allow/deny/ask rules only;
110
+ // `defaultMode` stays sourced from the `.claude` files (nothing here writes it,
111
+ // and keeping the auto-mode reasoning to one place avoids a second `auto` path).
112
+ const oneCodeGlobal = oneCodeSettingsPath(home);
113
+ const oneCodeProject = oneCodeProjectSettingsPath(cwd, home);
69
114
 
70
- for (const path of [paths.user, paths.project, paths.local]) {
115
+ // Managed settings last: Claude Code's organisation policy outranks every
116
+ // user/project file, for rules and for defaultMode alike (review P14).
117
+ const managed = managedSettingsPaths();
118
+ for (const path of [paths.user, oneCodeGlobal, paths.project, paths.local, oneCodeProject, ...managed]) {
71
119
  const file = readSettingsFile(path);
72
120
  const perms = file?.permissions;
73
121
  if (!perms) continue;
74
- if (Array.isArray(perms.allow)) merged.allow.push(...perms.allow.filter((r) => typeof r === "string"));
122
+ const allowTarget = path === paths.project || path === paths.local ? merged.projectAllow : merged.allow;
123
+ if (Array.isArray(perms.allow)) allowTarget.push(...perms.allow.filter((r) => typeof r === "string"));
75
124
  if (Array.isArray(perms.deny)) merged.deny.push(...perms.deny.filter((r) => typeof r === "string"));
76
125
  if (Array.isArray(perms.ask)) merged.ask.push(...perms.ask.filter((r) => typeof r === "string"));
126
+ if (perms.disableBypassPermissionsMode === "disable") merged.disableBypassPermissionsMode = true;
127
+ if (path === oneCodeGlobal || path === oneCodeProject) continue;
77
128
  const defaultMode = normalizePermissionMode(perms.defaultMode);
78
- // `auto` is honoured from user settings only. Both project files live in the
79
- // repository, so accepting it there would let a checked-in file put the
80
- // session into auto mode — a repo granting itself the looser mode whose
81
- // classifier is what contains it. Claude Code makes the same exclusion.
82
- if (defaultMode && !(defaultMode === "auto" && path !== paths.user)) {
129
+ // The modes a repo may not grant itself (MODES_NEVER_FROM_PROJECT) are
130
+ // honoured from user and managed scope only.
131
+ const fromProject = path === paths.project || path === paths.local;
132
+ if (defaultMode && !(fromProject && MODES_NEVER_FROM_PROJECT.has(defaultMode))) {
83
133
  merged.defaultMode = defaultMode;
84
134
  }
85
135
  }
@@ -87,12 +137,41 @@ export function loadPermissionSettings(cwd: string, home: string): PermissionSet
87
137
  return merged;
88
138
  }
89
139
 
90
- /** Append an allow rule to a settings file, creating it if needed. */
140
+ /**
141
+ * The mode a session starts in, from the modes requested in Claude Code's
142
+ * precedence order (`--dangerously-skip-permissions`, `--permission-mode`,
143
+ * settings' `defaultMode`), skipping `bypassPermissions` wherever a settings
144
+ * source disabled it (CC's `permissionSetup.ts`). `mode` is undefined when
145
+ * nothing usable was requested — the caller keeps its live mode, so a
146
+ * mid-session reload never undoes a ctrl+q switch. `bypassRefused` says a
147
+ * bypass request was skipped, for CC's "disabled by settings" notification.
148
+ */
149
+ export function resolveStartupMode(
150
+ requested: (PermissionMode | undefined)[],
151
+ settings: Pick<PermissionSettings, "disableBypassPermissionsMode">,
152
+ ): { mode?: PermissionMode; bypassRefused: boolean } {
153
+ let bypassRefused = false;
154
+ for (const candidate of requested) {
155
+ if (!candidate) continue;
156
+ if (candidate === "bypassPermissions" && settings.disableBypassPermissionsMode) {
157
+ bypassRefused = true;
158
+ continue;
159
+ }
160
+ return { mode: candidate, bypassRefused };
161
+ }
162
+ return { bypassRefused };
163
+ }
164
+
165
+ /**
166
+ * Append an allow rule to a One Code settings file, creating it if needed.
167
+ * Strict read + atomic write, like the other `~/.onecode` writers: a malformed
168
+ * file is not silently clobbered (it may also hold classifierModel/subagentModel),
169
+ * and a half-written file is never visible to a concurrent reader.
170
+ */
91
171
  export function persistAllowRule(rule: string, filePath: string): void {
92
- const file = readSettingsFile(filePath) ?? {};
172
+ const file = readSettingsForWrite(filePath) as ClaudeSettingsFile;
93
173
  const permissions = (file.permissions ??= {});
94
174
  const allow = (permissions.allow ??= []);
95
175
  if (!allow.includes(rule)) allow.push(rule);
96
- mkdirSync(dirname(filePath), { recursive: true });
97
- writeFileSync(filePath, `${JSON.stringify(file, null, 2)}\n`);
176
+ writeSettings(filePath, file as Record<string, unknown>);
98
177
  }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * The parent→child permission bridge.
3
+ *
4
+ * In-process subagent sessions get a `noExtensions` loader, so the real
5
+ * `permissions` extension never loads inside them. To match Claude Code — where a
6
+ * subagent's tool calls go through the *same* permission pipeline as the main
7
+ * agent (mode inheritance, the auto-mode classifier, and interactive prompts that
8
+ * bubble to the user; findings §17.1) — the parent's `permissions` extension
9
+ * publishes a decision closure on this channel. `subagents` captures it and
10
+ * threads it into the child runner, whose `permission-gate` calls it for every
11
+ * child tool call instead of its fail-closed local fallback.
12
+ *
13
+ * The closure is invoked in-process from the child's `tool_call` handler; it runs
14
+ * against the parent's live mode/rules/ctx (so a prompt renders on the parent's
15
+ * terminal). A plain function over `pi.events` is the only transport that works:
16
+ * each child session has its own EventBus, so `pi.events` does not cross the
17
+ * boundary — but the parent's `subagents` and `permissions` extensions share one,
18
+ * and the closure is carried the rest of the way as an ordinary reference (the
19
+ * same shape as `MCP_TOOLS_CHANNEL` sharing tool definitions).
20
+ */
21
+
22
+ import { type BridgeWatchApi, watchBridge } from "../lib/bridge-watch.ts";
23
+
24
+ export const SUBAGENT_GATE_CHANNEL = "one-code:subagent-permission-gate";
25
+
26
+ /** One child tool call to evaluate. `cwd` is the child's runtime cwd (a worktree, if isolated). */
27
+ export interface ChildToolCall {
28
+ toolName: string;
29
+ input: Record<string, unknown>;
30
+ cwd: string;
31
+ /**
32
+ * The child's live turn signal: a classifier call is aborted when the child
33
+ * turn is, and a prompt bubbled to the user is dismissed (as a denial) when
34
+ * the child is stopped mid-prompt — otherwise a dead agent's dialog stays on
35
+ * screen and holds the prompt chain (SUBAGENT-REVIEW M5).
36
+ */
37
+ signal?: AbortSignal;
38
+ /** The child session's id (what the gate can see); the runner maps it to `agent`. */
39
+ sessionId?: string;
40
+ /** The run name of the asking agent, for the prompt title — set by the runner, not the gate. */
41
+ agent?: string;
42
+ }
43
+
44
+ /** A permission decision: `undefined` = allow; otherwise block with a model-facing reason. */
45
+ export type ChildGateDecision = { block: true; reason: string } | undefined;
46
+
47
+ /** The parent's decision closure, threaded into a child's permission gate. */
48
+ export type PermissionBridge = (call: ChildToolCall) => Promise<ChildGateDecision>;
49
+
50
+ export interface SubagentGatePayload {
51
+ decide: PermissionBridge;
52
+ }
53
+
54
+ /** A lazy getter for the latest published permission bridge (`lib/bridge-watch.ts`); undefined until the permissions extension publishes. */
55
+ export function watchPermissionBridge(pi: BridgeWatchApi): () => PermissionBridge | undefined {
56
+ return watchBridge(pi, SUBAGENT_GATE_CHANNEL, (data) => (data as SubagentGatePayload | undefined)?.decide);
57
+ }