@posthog/agent 2.3.351 → 2.3.354
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/dist/adapters/claude/permissions/permission-options.d.ts +1 -1
- package/dist/adapters/claude/permissions/permission-options.js +3 -3
- package/dist/adapters/claude/permissions/permission-options.js.map +1 -1
- package/dist/agent.js +5936 -135
- package/dist/agent.js.map +1 -1
- package/dist/posthog-api.js +1 -1
- package/dist/posthog-api.js.map +1 -1
- package/dist/server/agent-server.d.ts +2 -0
- package/dist/server/agent-server.js +222 -54
- package/dist/server/agent-server.js.map +1 -1
- package/dist/server/bin.cjs +220 -52
- package/dist/server/bin.cjs.map +1 -1
- package/package.json +3 -3
- package/src/adapters/claude/conversion/sdk-to-acp.ts +31 -1
- package/src/adapters/claude/permissions/permission-handlers.ts +53 -10
- package/src/adapters/claude/permissions/permission-options.ts +3 -3
- package/src/adapters/claude/session/repo-path.ts +22 -0
- package/src/adapters/claude/session/settings.test.ts +159 -0
- package/src/adapters/claude/session/settings.ts +92 -6
- package/src/server/agent-server.ts +54 -3
- package/src/server/question-relay.test.ts +124 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@posthog/agent",
|
|
3
|
-
"version": "2.3.
|
|
3
|
+
"version": "2.3.354",
|
|
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": {
|
|
@@ -86,9 +86,9 @@
|
|
|
86
86
|
"tsx": "^4.20.6",
|
|
87
87
|
"typescript": "^5.5.0",
|
|
88
88
|
"vitest": "^2.1.8",
|
|
89
|
-
"@posthog/
|
|
89
|
+
"@posthog/git": "1.0.0",
|
|
90
90
|
"@posthog/enricher": "1.0.0",
|
|
91
|
-
"@posthog/
|
|
91
|
+
"@posthog/shared": "1.0.0"
|
|
92
92
|
},
|
|
93
93
|
"dependencies": {
|
|
94
94
|
"@agentclientprotocol/sdk": "0.19.0",
|
|
@@ -618,6 +618,32 @@ export type ResultMessageHandlerResult = {
|
|
|
618
618
|
};
|
|
619
619
|
};
|
|
620
620
|
|
|
621
|
+
export type AgentErrorClassification =
|
|
622
|
+
| "upstream_stream_terminated"
|
|
623
|
+
| "upstream_connection_error"
|
|
624
|
+
| "agent_error";
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* Classify an error string surfaced by the Claude CLI via `is_error: true`
|
|
628
|
+
* result messages. Transient upstream-stream terminations (e.g. the fetch body
|
|
629
|
+
* from the LLM gateway is torn down mid-stream) are retriable; most other
|
|
630
|
+
* errors are not.
|
|
631
|
+
*/
|
|
632
|
+
export function classifyAgentError(
|
|
633
|
+
result: string | undefined,
|
|
634
|
+
): AgentErrorClassification {
|
|
635
|
+
if (!result) return "agent_error";
|
|
636
|
+
const text = result.trim();
|
|
637
|
+
// Anthropic SDK surfaces an undici fetch abort as "API Error: terminated".
|
|
638
|
+
if (/API Error:\s*terminated\b/i.test(text)) {
|
|
639
|
+
return "upstream_stream_terminated";
|
|
640
|
+
}
|
|
641
|
+
if (/API Error:\s*Connection error\b/i.test(text)) {
|
|
642
|
+
return "upstream_connection_error";
|
|
643
|
+
}
|
|
644
|
+
return "agent_error";
|
|
645
|
+
}
|
|
646
|
+
|
|
621
647
|
export function handleResultMessage(
|
|
622
648
|
message: SDKResultMessage,
|
|
623
649
|
): ResultMessageHandlerResult {
|
|
@@ -636,9 +662,13 @@ export function handleResultMessage(
|
|
|
636
662
|
return { shouldStop: true, stopReason: "max_tokens", usage };
|
|
637
663
|
}
|
|
638
664
|
if (message.is_error) {
|
|
665
|
+
const classification = classifyAgentError(message.result);
|
|
639
666
|
return {
|
|
640
667
|
shouldStop: true,
|
|
641
|
-
error: RequestError.internalError(
|
|
668
|
+
error: RequestError.internalError(
|
|
669
|
+
{ classification, result: message.result },
|
|
670
|
+
message.result,
|
|
671
|
+
),
|
|
642
672
|
usage,
|
|
643
673
|
};
|
|
644
674
|
}
|
|
@@ -2,7 +2,10 @@ import type {
|
|
|
2
2
|
AgentSideConnection,
|
|
3
3
|
RequestPermissionResponse,
|
|
4
4
|
} from "@agentclientprotocol/sdk";
|
|
5
|
-
import type {
|
|
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
|
|
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
|
-
|
|
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
|
|
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}${
|
|
43
|
+
`Yes, and don't ask again for ${label}${scopeLabel}`,
|
|
44
44
|
);
|
|
45
45
|
}
|
|
46
46
|
|
|
@@ -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.
|
|
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;
|
|
@@ -14,12 +14,17 @@ import {
|
|
|
14
14
|
import { type ServerType, serve } from "@hono/node-server";
|
|
15
15
|
import { getCurrentBranch } from "@posthog/git/queries";
|
|
16
16
|
import { Hono } from "hono";
|
|
17
|
+
import { z } from "zod";
|
|
17
18
|
import packageJson from "../../package.json" with { type: "json" };
|
|
18
19
|
import { POSTHOG_METHODS, POSTHOG_NOTIFICATIONS } from "../acp-extensions";
|
|
19
20
|
import {
|
|
20
21
|
createAcpConnection,
|
|
21
22
|
type InProcessAcpConnection,
|
|
22
23
|
} from "../adapters/acp-connection";
|
|
24
|
+
import {
|
|
25
|
+
type AgentErrorClassification,
|
|
26
|
+
classifyAgentError,
|
|
27
|
+
} from "../adapters/claude/conversion/sdk-to-acp";
|
|
23
28
|
import { selectRecentTurns } from "../adapters/claude/session/jsonl-hydration";
|
|
24
29
|
import type { PermissionMode } from "../execution-mode";
|
|
25
30
|
import { DEFAULT_CODEX_MODEL } from "../gateway-models";
|
|
@@ -51,6 +56,16 @@ import { type JwtPayload, JwtValidationError, validateJwt } from "./jwt";
|
|
|
51
56
|
import { jsonRpcRequestSchema, validateCommandParams } from "./schemas";
|
|
52
57
|
import type { AgentServerConfig } from "./types";
|
|
53
58
|
|
|
59
|
+
const agentErrorClassificationSchema = z.enum([
|
|
60
|
+
"upstream_stream_terminated",
|
|
61
|
+
"upstream_connection_error",
|
|
62
|
+
"agent_error",
|
|
63
|
+
]) satisfies z.ZodType<AgentErrorClassification>;
|
|
64
|
+
|
|
65
|
+
const errorWithClassificationSchema = z.object({
|
|
66
|
+
data: z.object({ classification: agentErrorClassificationSchema }),
|
|
67
|
+
});
|
|
68
|
+
|
|
54
69
|
type MessageCallback = (message: unknown) => void;
|
|
55
70
|
|
|
56
71
|
class NdJsonTap {
|
|
@@ -973,6 +988,41 @@ export class AgentServer {
|
|
|
973
988
|
await this.sendInitialTaskMessage(payload, preTaskRun);
|
|
974
989
|
}
|
|
975
990
|
|
|
991
|
+
private extractErrorClassification(error: unknown): {
|
|
992
|
+
classification: AgentErrorClassification;
|
|
993
|
+
message: string;
|
|
994
|
+
} {
|
|
995
|
+
const message =
|
|
996
|
+
error instanceof Error ? error.message : String(error ?? "");
|
|
997
|
+
|
|
998
|
+
// Prefer the structured `data` carried on RequestError if present.
|
|
999
|
+
const parsed = errorWithClassificationSchema.safeParse(error);
|
|
1000
|
+
if (parsed.success) {
|
|
1001
|
+
return { classification: parsed.data.data.classification, message };
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
return { classification: classifyAgentError(message), message };
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
private classifyAndSignalFailure(
|
|
1008
|
+
payload: JwtPayload,
|
|
1009
|
+
phase: "initial" | "resume",
|
|
1010
|
+
error: unknown,
|
|
1011
|
+
): Promise<void> {
|
|
1012
|
+
const { classification, message } = this.extractErrorClassification(error);
|
|
1013
|
+
const errorMessage =
|
|
1014
|
+
classification === "upstream_stream_terminated"
|
|
1015
|
+
? "Upstream LLM stream terminated"
|
|
1016
|
+
: classification === "upstream_connection_error"
|
|
1017
|
+
? "Upstream LLM connection error"
|
|
1018
|
+
: message || "Agent error";
|
|
1019
|
+
this.logger.error(`send_${phase}_task_message_failed`, {
|
|
1020
|
+
classification,
|
|
1021
|
+
message,
|
|
1022
|
+
});
|
|
1023
|
+
return this.signalTaskComplete(payload, "error", errorMessage);
|
|
1024
|
+
}
|
|
1025
|
+
|
|
976
1026
|
private async sendInitialTaskMessage(
|
|
977
1027
|
payload: JwtPayload,
|
|
978
1028
|
prefetchedRun?: TaskRun | null,
|
|
@@ -1087,7 +1137,7 @@ export class AgentServer {
|
|
|
1087
1137
|
if (this.session) {
|
|
1088
1138
|
await this.session.logWriter.flushAll();
|
|
1089
1139
|
}
|
|
1090
|
-
await this.
|
|
1140
|
+
await this.classifyAndSignalFailure(payload, "initial", error);
|
|
1091
1141
|
}
|
|
1092
1142
|
}
|
|
1093
1143
|
|
|
@@ -1176,7 +1226,7 @@ export class AgentServer {
|
|
|
1176
1226
|
if (this.session) {
|
|
1177
1227
|
await this.session.logWriter.flushAll();
|
|
1178
1228
|
}
|
|
1179
|
-
await this.
|
|
1229
|
+
await this.classifyAndSignalFailure(payload, "resume", error);
|
|
1180
1230
|
}
|
|
1181
1231
|
}
|
|
1182
1232
|
|
|
@@ -1657,6 +1707,7 @@ ${attributionInstructions}
|
|
|
1657
1707
|
private async signalTaskComplete(
|
|
1658
1708
|
payload: JwtPayload,
|
|
1659
1709
|
stopReason: string,
|
|
1710
|
+
errorMessage?: string,
|
|
1660
1711
|
): Promise<void> {
|
|
1661
1712
|
if (this.session?.payload.run_id === payload.run_id) {
|
|
1662
1713
|
try {
|
|
@@ -1684,7 +1735,7 @@ ${attributionInstructions}
|
|
|
1684
1735
|
try {
|
|
1685
1736
|
await this.posthogAPI.updateTaskRun(payload.task_id, payload.run_id, {
|
|
1686
1737
|
status,
|
|
1687
|
-
error_message:
|
|
1738
|
+
error_message: errorMessage ?? "Agent error",
|
|
1688
1739
|
});
|
|
1689
1740
|
this.logger.info("Task completion signaled", { status, stopReason });
|
|
1690
1741
|
} catch (error) {
|