@posthog/agent 2.3.349 → 2.3.353

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@posthog/agent",
3
- "version": "2.3.349",
3
+ "version": "2.3.353",
4
4
  "repository": "https://github.com/PostHog/code",
5
5
  "description": "TypeScript agent framework wrapping Claude Agent SDK with Git-based task execution for PostHog",
6
6
  "exports": {
@@ -87,8 +87,8 @@
87
87
  "typescript": "^5.5.0",
88
88
  "vitest": "^2.1.8",
89
89
  "@posthog/shared": "1.0.0",
90
- "@posthog/git": "1.0.0",
91
- "@posthog/enricher": "1.0.0"
90
+ "@posthog/enricher": "1.0.0",
91
+ "@posthog/git": "1.0.0"
92
92
  },
93
93
  "dependencies": {
94
94
  "@agentclientprotocol/sdk": "0.19.0",
@@ -173,12 +173,12 @@ export const createPostToolUseHook =
173
173
  *
174
174
  * https://github.com/anthropics/claude-agent-sdk-typescript/issues/267
175
175
  */
176
- const SUBAGENT_REWRITES: Record<string, string> = {
176
+ export const SUBAGENT_REWRITES: Record<string, string> = {
177
177
  Explore: "ph-explore",
178
178
  };
179
179
 
180
180
  export const createSubagentRewriteHook =
181
- (logger: Logger): HookCallback =>
181
+ (logger: Logger, registeredAgents: ReadonlySet<string>): HookCallback =>
182
182
  async (input: HookInput, _toolUseID: string | undefined) => {
183
183
  if (input.hook_event_name !== "PreToolUse") {
184
184
  return { continue: true };
@@ -195,6 +195,13 @@ export const createSubagentRewriteHook =
195
195
  }
196
196
 
197
197
  const target = SUBAGENT_REWRITES[subagentType];
198
+ if (!registeredAgents.has(target)) {
199
+ logger.warn(
200
+ `[SubagentRewriteHook] Skipping rewrite ${subagentType} → ${target}: target agent not registered for this session. Falling back to built-in ${subagentType}.`,
201
+ );
202
+ return { continue: true };
203
+ }
204
+
198
205
  logger.info(
199
206
  `[SubagentRewriteHook] Rewriting subagent_type: ${subagentType} → ${target}`,
200
207
  );
@@ -2,7 +2,10 @@ import type {
2
2
  AgentSideConnection,
3
3
  RequestPermissionResponse,
4
4
  } from "@agentclientprotocol/sdk";
5
- import type { PermissionUpdate } from "@anthropic-ai/claude-agent-sdk";
5
+ import type {
6
+ PermissionRuleValue,
7
+ PermissionUpdate,
8
+ } from "@anthropic-ai/claude-agent-sdk";
6
9
  import { text } from "../../../utils/acp-content";
7
10
  import type { Logger } from "../../../utils/logger";
8
11
  import { toolInfoFromToolUse } from "../conversion/tool-use-to-acp";
@@ -347,7 +350,7 @@ async function handleDefaultPermissionFlow(
347
350
  const options = buildPermissionOptions(
348
351
  toolName,
349
352
  toolInput as Record<string, unknown>,
350
- session?.cwd,
353
+ session.settingsManager.getRepoRoot(),
351
354
  suggestions,
352
355
  );
353
356
 
@@ -374,17 +377,19 @@ async function handleDefaultPermissionFlow(
374
377
  response.outcome.optionId === "allow_always")
375
378
  ) {
376
379
  if (response.outcome.optionId === "allow_always") {
380
+ const rules = extractAllowRules(suggestions, toolName);
381
+ try {
382
+ await session.settingsManager.addAllowRules(rules);
383
+ } catch (error) {
384
+ context.logger.warn(
385
+ "[canUseTool] Failed to persist allow rules to repository settings",
386
+ { error: error instanceof Error ? error.message : String(error) },
387
+ );
388
+ }
377
389
  return {
378
390
  behavior: "allow",
379
391
  updatedInput: toolInput as Record<string, unknown>,
380
- updatedPermissions: suggestions ?? [
381
- {
382
- type: "addRules",
383
- rules: [{ toolName }],
384
- behavior: "allow",
385
- destination: "localSettings",
386
- },
387
- ],
392
+ updatedPermissions: buildSessionPermissions(suggestions, rules),
388
393
  };
389
394
  }
390
395
  return {
@@ -429,6 +434,44 @@ function handlePlanFileException(
429
434
  };
430
435
  }
431
436
 
437
+ function extractAllowRules(
438
+ suggestions: PermissionUpdate[] | undefined,
439
+ toolName: string,
440
+ ): PermissionRuleValue[] {
441
+ if (!suggestions || suggestions.length === 0) {
442
+ return [{ toolName }];
443
+ }
444
+ return suggestions
445
+ .filter(
446
+ (update) => update.type === "addRules" && update.behavior === "allow",
447
+ )
448
+ .flatMap((update) => ("rules" in update ? update.rules : []));
449
+ }
450
+
451
+ /**
452
+ * Forwards any non-addRules suggestions from the SDK (e.g. addDirectories)
453
+ * with their destination remapped to `session`. Our own allow rules are
454
+ * persisted via `settingsManager.addAllowRules`, so the SDK must not write
455
+ * them to its default per-cwd location.
456
+ */
457
+ function buildSessionPermissions(
458
+ suggestions: PermissionUpdate[] | undefined,
459
+ rules: PermissionRuleValue[],
460
+ ): PermissionUpdate[] {
461
+ const passthrough = (suggestions ?? [])
462
+ .filter(
463
+ (update) => !(update.type === "addRules" && update.behavior === "allow"),
464
+ )
465
+ .map((update) => ({ ...update, destination: "session" as const }));
466
+ if (rules.length === 0) {
467
+ return passthrough;
468
+ }
469
+ return [
470
+ { type: "addRules", rules, behavior: "allow", destination: "session" },
471
+ ...passthrough,
472
+ ];
473
+ }
474
+
432
475
  function extractDomainFromUrl(url: string): string | null {
433
476
  try {
434
477
  return new URL(url).hostname;
@@ -25,7 +25,7 @@ function permissionOptions(allowAlwaysLabel: string): PermissionOption[] {
25
25
  export function buildPermissionOptions(
26
26
  toolName: string,
27
27
  toolInput: Record<string, unknown>,
28
- cwd?: string,
28
+ repoRoot?: string,
29
29
  suggestions?: PermissionUpdate[],
30
30
  ): PermissionOption[] {
31
31
  if (BASH_TOOLS.has(toolName)) {
@@ -36,11 +36,11 @@ export function buildPermissionOptions(
36
36
 
37
37
  const command = toolInput?.command as string | undefined;
38
38
  const cmdName = command?.split(/\s+/)[0] ?? "this command";
39
- const cwdLabel = cwd ? ` in ${cwd}` : "";
39
+ const scopeLabel = repoRoot ? ` in ${repoRoot}` : "";
40
40
  const label = ruleContent ?? `\`${cmdName}\` commands`;
41
41
 
42
42
  return permissionOptions(
43
- `Yes, and don't ask again for ${label}${cwdLabel}`,
43
+ `Yes, and don't ask again for ${label}${scopeLabel}`,
44
44
  );
45
45
  }
46
46
 
@@ -0,0 +1,72 @@
1
+ import * as os from "node:os";
2
+ import * as path from "node:path";
3
+ import { describe, expect, it } from "vitest";
4
+ import { Logger } from "../../../utils/logger";
5
+ import { SUBAGENT_REWRITES } from "../hooks";
6
+ import { buildSessionOptions } from "./options";
7
+ import { SettingsManager } from "./settings";
8
+
9
+ function makeParams() {
10
+ const cwd = path.join(os.tmpdir(), `options-test-${Date.now()}`);
11
+ return {
12
+ cwd,
13
+ mcpServers: {},
14
+ permissionMode: "default" as const,
15
+ canUseTool: async () => ({ behavior: "allow" as const, updatedInput: {} }),
16
+ logger: new Logger(),
17
+ sessionId: "test-session",
18
+ isResume: false,
19
+ settingsManager: new SettingsManager(cwd),
20
+ };
21
+ }
22
+
23
+ describe("buildSessionOptions", () => {
24
+ it.each(Object.entries(SUBAGENT_REWRITES))(
25
+ 'registers rewrite target "%s" → "%s" in options.agents',
26
+ (_source, target) => {
27
+ const options = buildSessionOptions(makeParams());
28
+ const registered = new Set(Object.keys(options.agents ?? {}));
29
+
30
+ expect(
31
+ registered.has(target),
32
+ `Rewrite target "${target}" is not registered in options.agents — either register the agent in buildAgents or remove the rewrite.`,
33
+ ).toBe(true);
34
+ },
35
+ );
36
+
37
+ it("preserves caller-provided agents alongside defaults", () => {
38
+ const params = makeParams();
39
+ const options = buildSessionOptions({
40
+ ...params,
41
+ userProvidedOptions: {
42
+ agents: {
43
+ "custom-agent": {
44
+ description: "Custom",
45
+ prompt: "Custom prompt",
46
+ },
47
+ },
48
+ },
49
+ });
50
+
51
+ expect(options.agents?.["custom-agent"]).toBeDefined();
52
+ expect(options.agents?.["ph-explore"]).toBeDefined();
53
+ });
54
+
55
+ it("lets caller-provided agents override defaults by name", () => {
56
+ const params = makeParams();
57
+ const override = {
58
+ description: "Overridden",
59
+ prompt: "Overridden prompt",
60
+ };
61
+ const options = buildSessionOptions({
62
+ ...params,
63
+ userProvidedOptions: {
64
+ agents: {
65
+ "ph-explore": override,
66
+ },
67
+ },
68
+ });
69
+
70
+ expect(options.agents?.["ph-explore"]).toEqual(override);
71
+ });
72
+ });
@@ -117,6 +117,7 @@ function buildHooks(
117
117
  logger: Logger,
118
118
  enrichmentDeps: FileEnrichmentDeps | undefined,
119
119
  enrichedReadCache: EnrichedReadCache | undefined,
120
+ registeredAgents: ReadonlySet<string>,
120
121
  ): Options["hooks"] {
121
122
  const postToolUseHooks = [createPostToolUseHook({ onModeChange, logger })];
122
123
  if (enrichmentDeps && enrichedReadCache) {
@@ -136,13 +137,62 @@ function buildHooks(
136
137
  {
137
138
  hooks: [
138
139
  createPreToolUseHook(settingsManager, logger),
139
- createSubagentRewriteHook(logger),
140
+ createSubagentRewriteHook(logger, registeredAgents),
140
141
  ],
141
142
  },
142
143
  ],
143
144
  };
144
145
  }
145
146
 
147
+ /**
148
+ * Read-only Haiku-powered exploration agent. Registered under the `ph-explore`
149
+ * name rather than `Explore` to work around a Claude Agent SDK bug where
150
+ * `options.agents` cannot shadow built-in agent definitions. The
151
+ * `createSubagentRewriteHook` rewrites `subagent_type: "Explore"` to
152
+ * `"ph-explore"` so callers don't have to know about the alias.
153
+ */
154
+ const PH_EXPLORE_AGENT: NonNullable<Options["agents"]>[string] = {
155
+ description:
156
+ 'Fast agent for exploring and understanding codebases. Use this when you need to find files by pattern (eg. "src/components/**/*.tsx"), search for code or keywords (eg. "where is the auth middleware?"), or answer questions about how the codebase works (eg. "how does the session service handle reconnects?"). When calling this agent, specify a thoroughness level: "quick" for targeted lookups, "medium" for broader exploration, or "very thorough" for comprehensive analysis across multiple locations.',
157
+ model: "haiku",
158
+ prompt: `You are a fast, read-only codebase exploration agent.
159
+
160
+ Your job is to find files, search code, read the most relevant sources, and report findings clearly.
161
+
162
+ Rules:
163
+ - Never create, modify, delete, move, or copy files.
164
+ - Never use shell redirection or any command that changes system state.
165
+ - Use Glob for broad file pattern matching.
166
+ - Use Grep for searching file contents.
167
+ - Use Read when you know the exact file path to inspect.
168
+ - Use Bash only for safe read-only commands like ls, git status, git log, git diff, find, cat, head, and tail.
169
+ - Adapt your search approach based on the thoroughness level specified by the caller.
170
+ - Return file paths as absolute paths in your final response.
171
+ - Avoid using emojis.
172
+ - Wherever possible, spawn multiple parallel tool calls for grepping and reading files.
173
+ - Search efficiently, then read only the most relevant files.
174
+ - Return findings directly in your final response — do not create files.`,
175
+ tools: [
176
+ "Bash",
177
+ "Glob",
178
+ "Grep",
179
+ "Read",
180
+ "WebFetch",
181
+ "WebSearch",
182
+ "NotebookRead",
183
+ "TodoWrite",
184
+ ],
185
+ };
186
+
187
+ function buildAgents(
188
+ userAgents: Options["agents"],
189
+ ): NonNullable<Options["agents"]> {
190
+ return {
191
+ "ph-explore": PH_EXPLORE_AGENT,
192
+ ...(userAgents || {}),
193
+ };
194
+ }
195
+
146
196
  function getAbortController(
147
197
  userProvidedController: AbortController | undefined,
148
198
  ): AbortController {
@@ -256,6 +306,9 @@ export function buildSessionOptions(params: BuildOptionsParams): Options {
256
306
  ? []
257
307
  : { type: "preset", preset: "claude_code" });
258
308
 
309
+ const agents = buildAgents(params.userProvidedOptions?.agents);
310
+ const registeredAgentNames = new Set(Object.keys(agents));
311
+
259
312
  const options: Options = {
260
313
  ...params.userProvidedOptions,
261
314
  betas: ["context-1m-2025-08-07"],
@@ -269,6 +322,7 @@ export function buildSessionOptions(params: BuildOptionsParams): Options {
269
322
  canUseTool: params.canUseTool,
270
323
  executable: "node",
271
324
  tools,
325
+ agents,
272
326
  extraArgs: {
273
327
  ...params.userProvidedOptions?.extraArgs,
274
328
  "replay-user-messages": "",
@@ -285,6 +339,7 @@ export function buildSessionOptions(params: BuildOptionsParams): Options {
285
339
  params.logger,
286
340
  params.enrichmentDeps,
287
341
  params.enrichedReadCache,
342
+ registeredAgentNames,
288
343
  ),
289
344
  outputFormat: params.outputFormat,
290
345
  abortController: getAbortController(
@@ -0,0 +1,22 @@
1
+ import { listWorktrees } from "@posthog/git/queries";
2
+
3
+ /**
4
+ * Resolves the primary worktree (main repository) path for a given cwd.
5
+ *
6
+ * Secondary git worktrees share a `.git` common directory with the primary
7
+ * worktree. Returning the primary worktree path lets us scope per-repo
8
+ * settings — such as "don't ask again" permission rules — to a single
9
+ * location that every worktree of the same repository can read from.
10
+ *
11
+ * `git worktree list --porcelain` always emits the primary worktree first.
12
+ * Returns `cwd` when the directory is not inside a git repository or when
13
+ * git is unavailable.
14
+ */
15
+ export async function resolveMainRepoPath(cwd: string): Promise<string> {
16
+ try {
17
+ const worktrees = await listWorktrees(cwd);
18
+ return worktrees[0]?.path ?? cwd;
19
+ } catch {
20
+ return cwd;
21
+ }
22
+ }
@@ -0,0 +1,159 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import * as fs from "node:fs";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
6
+ import { resolveMainRepoPath } from "./repo-path";
7
+ import { SettingsManager } from "./settings";
8
+
9
+ function runGit(cwd: string, args: string[]): void {
10
+ execFileSync("git", args, { cwd, stdio: ["ignore", "ignore", "pipe"] });
11
+ }
12
+
13
+ describe("SettingsManager per-repo persistence", () => {
14
+ let mainRepo: string;
15
+ let worktree: string;
16
+ let tmpRoot: string;
17
+
18
+ beforeEach(async () => {
19
+ tmpRoot = await fs.promises.realpath(
20
+ await fs.promises.mkdtemp(path.join(os.tmpdir(), "settings-manager-")),
21
+ );
22
+ mainRepo = path.join(tmpRoot, "main");
23
+ worktree = path.join(tmpRoot, "wt");
24
+ await fs.promises.mkdir(mainRepo, { recursive: true });
25
+
26
+ runGit(mainRepo, ["init", "-b", "main"]);
27
+ runGit(mainRepo, ["config", "user.email", "test@example.com"]);
28
+ runGit(mainRepo, ["config", "user.name", "test"]);
29
+ runGit(mainRepo, ["commit", "--allow-empty", "-m", "init"]);
30
+ runGit(mainRepo, ["worktree", "add", "-b", "feat", worktree]);
31
+ });
32
+
33
+ afterEach(async () => {
34
+ await fs.promises.rm(tmpRoot, { recursive: true, force: true });
35
+ });
36
+
37
+ it("persists allow rules to the primary worktree when invoked from a secondary worktree", async () => {
38
+ const manager = new SettingsManager(worktree);
39
+ await manager.initialize();
40
+
41
+ await manager.addAllowRules([
42
+ { toolName: "Bash", ruleContent: "pnpm test:*" },
43
+ ]);
44
+
45
+ const repoLocalPath = path.join(mainRepo, ".claude", "settings.local.json");
46
+ const contents = JSON.parse(
47
+ await fs.promises.readFile(repoLocalPath, "utf-8"),
48
+ );
49
+ expect(contents.permissions.allow).toContain("Bash(pnpm test:*)");
50
+
51
+ const worktreeLocalPath = path.join(
52
+ worktree,
53
+ ".claude",
54
+ "settings.local.json",
55
+ );
56
+ expect(fs.existsSync(worktreeLocalPath)).toBe(false);
57
+ });
58
+
59
+ it("sees rules persisted by a sibling worktree after re-initialization", async () => {
60
+ const writer = new SettingsManager(worktree);
61
+ await writer.initialize();
62
+ await writer.addAllowRules([{ toolName: "TodoWrite" }]);
63
+
64
+ const sibling = path.join(tmpRoot, "wt2");
65
+ runGit(mainRepo, ["worktree", "add", "-b", "other", sibling]);
66
+
67
+ const reader = new SettingsManager(sibling);
68
+ await reader.initialize();
69
+ const decision = reader.checkPermission("TodoWrite", {});
70
+ expect(decision.decision).toBe("allow");
71
+ });
72
+
73
+ it("widens name-based matching for argumentless rules", async () => {
74
+ const manager = new SettingsManager(worktree);
75
+ await manager.initialize();
76
+
77
+ await manager.addAllowRules([{ toolName: "TodoWrite" }]);
78
+
79
+ expect(manager.checkPermission("TodoWrite", {}).decision).toBe("allow");
80
+ });
81
+
82
+ it("does not widen name-based matching when the rule has an argument", async () => {
83
+ // A rule *with* an argument for a tool we don't have an accessor for must
84
+ // not match regardless of the actual input — otherwise a deny rule like
85
+ // `Bash(rm -rf)` applied to a non-ACP Bash invocation would match any
86
+ // command.
87
+ const manager = new SettingsManager(worktree);
88
+ await manager.initialize();
89
+
90
+ await manager.addAllowRules([
91
+ { toolName: "UnknownTool", ruleContent: "something" },
92
+ ]);
93
+
94
+ expect(
95
+ manager.checkPermission("UnknownTool", { command: "anything" }).decision,
96
+ ).toBe("ask");
97
+ });
98
+
99
+ it("still allows ACP-prefixed Bash invocations when a Bash(...) rule is persisted", async () => {
100
+ const manager = new SettingsManager(worktree);
101
+ await manager.initialize();
102
+
103
+ await manager.addAllowRules([
104
+ { toolName: "Bash", ruleContent: "pnpm test:*" },
105
+ ]);
106
+
107
+ const decision = manager.checkPermission("mcp__acp__Bash", {
108
+ command: "pnpm test --filter agent",
109
+ });
110
+ expect(decision.decision).toBe("allow");
111
+ });
112
+
113
+ it("refuses to overwrite the file when existing contents cannot be parsed", async () => {
114
+ const manager = new SettingsManager(worktree);
115
+ await manager.initialize();
116
+
117
+ const filePath = path.join(mainRepo, ".claude", "settings.local.json");
118
+ const original = "{ this is not valid json";
119
+ await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
120
+ await fs.promises.writeFile(filePath, original);
121
+
122
+ await expect(
123
+ manager.addAllowRules([{ toolName: "TodoWrite" }]),
124
+ ).rejects.toThrow();
125
+
126
+ // File must be untouched — overwriting would wipe whatever the user had.
127
+ expect(await fs.promises.readFile(filePath, "utf-8")).toBe(original);
128
+ });
129
+
130
+ it("concurrent addAllowRules calls do not clobber each other", async () => {
131
+ const manager = new SettingsManager(worktree);
132
+ await manager.initialize();
133
+
134
+ await Promise.all([
135
+ manager.addAllowRules([{ toolName: "A" }]),
136
+ manager.addAllowRules([{ toolName: "B" }]),
137
+ manager.addAllowRules([{ toolName: "C" }]),
138
+ ]);
139
+
140
+ const filePath = path.join(mainRepo, ".claude", "settings.local.json");
141
+ const contents = JSON.parse(await fs.promises.readFile(filePath, "utf-8"));
142
+ expect(contents.permissions.allow).toEqual(
143
+ expect.arrayContaining(["A", "B", "C"]),
144
+ );
145
+ });
146
+ });
147
+
148
+ describe("resolveMainRepoPath", () => {
149
+ it("returns cwd when the directory is not inside a git repository", async () => {
150
+ const tmp = await fs.promises.realpath(
151
+ await fs.promises.mkdtemp(path.join(os.tmpdir(), "repo-path-")),
152
+ );
153
+ try {
154
+ expect(await resolveMainRepoPath(tmp)).toBe(tmp);
155
+ } finally {
156
+ await fs.promises.rm(tmp, { recursive: true, force: true });
157
+ }
158
+ });
159
+ });
@@ -1,7 +1,10 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
+ import type { PermissionRuleValue } from "@anthropic-ai/claude-agent-sdk";
4
5
  import { minimatch } from "minimatch";
6
+ import { AsyncMutex } from "../../../utils/async-mutex";
7
+ import { resolveMainRepoPath } from "./repo-path";
5
8
 
6
9
  const ACP_TOOL_NAME_PREFIX = "mcp__acp__";
7
10
 
@@ -86,7 +89,8 @@ function matchesRule(
86
89
  const ruleAppliesToTool =
87
90
  (rule.toolName === "Bash" && toolName === acpToolNames.bash) ||
88
91
  (rule.toolName === "Edit" && FILE_EDITING_TOOLS.includes(toolName)) ||
89
- (rule.toolName === "Read" && FILE_READING_TOOLS.includes(toolName));
92
+ (rule.toolName === "Read" && FILE_READING_TOOLS.includes(toolName)) ||
93
+ (rule.toolName === toolName && !rule.argument);
90
94
 
91
95
  if (!ruleAppliesToTool) {
92
96
  return false;
@@ -123,6 +127,23 @@ function matchesRule(
123
127
  return matchesGlob(rule.argument, actualArg, cwd);
124
128
  }
125
129
 
130
+ function formatRule(rule: PermissionRuleValue): string {
131
+ return rule.ruleContent
132
+ ? `${rule.toolName}(${rule.ruleContent})`
133
+ : rule.toolName;
134
+ }
135
+
136
+ async function writeFileAtomic(filePath: string, data: string): Promise<void> {
137
+ const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
138
+ await fs.promises.writeFile(tmpPath, data);
139
+ try {
140
+ await fs.promises.rename(tmpPath, filePath);
141
+ } catch (error) {
142
+ await fs.promises.rm(tmpPath, { force: true });
143
+ throw error;
144
+ }
145
+ }
146
+
126
147
  async function loadSettingsFile(
127
148
  filePath: string | undefined,
128
149
  ): Promise<ClaudeCodeSettings> {
@@ -143,6 +164,26 @@ async function loadSettingsFile(
143
164
  }
144
165
  }
145
166
 
167
+ /**
168
+ * Reads a settings file for a read-modify-write cycle. Unlike
169
+ * `loadSettingsFile`, this throws on any error other than ENOENT — we refuse
170
+ * to overwrite a file we couldn't parse, because doing so would wipe the
171
+ * user's existing settings (other allow/deny/ask rules, env, model, etc).
172
+ */
173
+ async function readSettingsFileForUpdate(
174
+ filePath: string,
175
+ ): Promise<ClaudeCodeSettings> {
176
+ try {
177
+ const content = await fs.promises.readFile(filePath, "utf-8");
178
+ return JSON.parse(content) as ClaudeCodeSettings;
179
+ } catch (error) {
180
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
181
+ return {};
182
+ }
183
+ throw error;
184
+ }
185
+ }
186
+
146
187
  export interface PermissionSettings {
147
188
  allow?: string[];
148
189
  deny?: string[];
@@ -177,8 +218,10 @@ export function getManagedSettingsPath(): string {
177
218
  return "/etc/claude-code/managed-settings.json";
178
219
  }
179
220
  }
221
+
180
222
  export class SettingsManager {
181
223
  private cwd: string;
224
+ private repoRoot: string;
182
225
  private userSettings: ClaudeCodeSettings = {};
183
226
  private projectSettings: ClaudeCodeSettings = {};
184
227
  private localSettings: ClaudeCodeSettings = {};
@@ -186,9 +229,11 @@ export class SettingsManager {
186
229
  private mergedSettings: ClaudeCodeSettings = {};
187
230
  private initialized = false;
188
231
  private initPromise: Promise<void> | null = null;
232
+ private writeMutex = new AsyncMutex();
189
233
 
190
234
  constructor(cwd: string) {
191
235
  this.cwd = cwd;
236
+ this.repoRoot = cwd;
192
237
  }
193
238
 
194
239
  async initialize(): Promise<void> {
@@ -211,11 +256,17 @@ export class SettingsManager {
211
256
  return path.join(this.cwd, ".claude", "settings.json");
212
257
  }
213
258
 
259
+ /**
260
+ * Local settings are anchored to the primary worktree so every worktree of
261
+ * the same repository shares a single `.claude/settings.local.json`. This
262
+ * avoids re-prompting for the same permission in every worktree.
263
+ */
214
264
  private getLocalSettingsPath(): string {
215
- return path.join(this.cwd, ".claude", "settings.local.json");
265
+ return path.join(this.repoRoot, ".claude", "settings.local.json");
216
266
  }
217
267
 
218
268
  private async loadAllSettings(): Promise<void> {
269
+ this.repoRoot = await resolveMainRepoPath(this.cwd);
219
270
  const [userSettings, projectSettings, localSettings, enterpriseSettings] =
220
271
  await Promise.all([
221
272
  loadSettingsFile(this.getUserSettingsPath()),
@@ -278,10 +329,6 @@ export class SettingsManager {
278
329
  }
279
330
 
280
331
  checkPermission(toolName: string, toolInput: unknown): PermissionCheckResult {
281
- if (!toolName.startsWith(ACP_TOOL_NAME_PREFIX)) {
282
- return { decision: "ask" };
283
- }
284
-
285
332
  const permissions = this.mergedSettings.permissions;
286
333
  if (!permissions) {
287
334
  return { decision: "ask" };
@@ -319,6 +366,45 @@ export class SettingsManager {
319
366
  return this.cwd;
320
367
  }
321
368
 
369
+ getRepoRoot(): string {
370
+ return this.repoRoot;
371
+ }
372
+
373
+ /**
374
+ * Persists allow rules to `<primary-worktree>/.claude/settings.local.json`.
375
+ * Because local settings are resolved against the primary worktree, every
376
+ * worktree of the same repository picks up the new rule on next load.
377
+ *
378
+ * Writes are serialised via `writeMutex` to prevent concurrent callers from
379
+ * clobbering each other, and use a temp-file + rename to keep the file
380
+ * consistent if the process dies mid-write.
381
+ */
382
+ async addAllowRules(rules: PermissionRuleValue[]): Promise<void> {
383
+ if (rules.length === 0) return;
384
+ if (!this.initialized) await this.initialize();
385
+ await this.writeMutex.acquire();
386
+ try {
387
+ const filePath = this.getLocalSettingsPath();
388
+ const existing = await readSettingsFileForUpdate(filePath);
389
+ const permissions: PermissionSettings = {
390
+ ...(existing.permissions ?? {}),
391
+ };
392
+ const current = new Set(permissions.allow ?? []);
393
+ for (const rule of rules) {
394
+ current.add(formatRule(rule));
395
+ }
396
+ permissions.allow = Array.from(current);
397
+ const next: ClaudeCodeSettings = { ...existing, permissions };
398
+ await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
399
+ await writeFileAtomic(filePath, `${JSON.stringify(next, null, 2)}\n`);
400
+
401
+ this.localSettings = next;
402
+ this.mergeAllSettings();
403
+ } finally {
404
+ this.writeMutex.release();
405
+ }
406
+ }
407
+
322
408
  async setCwd(cwd: string): Promise<void> {
323
409
  if (this.cwd === cwd) return;
324
410
  if (this.initPromise) await this.initPromise;