mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 04:30:59 +00:00
feat: accept IDE session hooks without dropping model settings (#1265)
IDEs can pass their own Claude hooks through `_meta.claudeCode.options.settings.hooks` on `session/new` and `session/load`. Before this change, any session settings replaced `CLAUDE_MODEL_CONFIG` entirely, so adding a single hook dropped the deployment's `modelOverrides` / `availableModels`. The two are now merged, and the session settings win per key. The wrapper does not merge hooks itself: Claude Code already runs the hooks of every settings tier, so user and project hooks keep running next to the session ones. Hooks stay scoped to their session, and a `session/load` with different settings recreates the query, which replaces or removes them. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
parent
b3bed2f763
commit
fe8b69edd4
@@ -17,6 +17,7 @@ It describes only this adapter.
|
|||||||
- [Claude tools and ACP fields](#claude-tools-and-acp-fields)
|
- [Claude tools and ACP fields](#claude-tools-and-acp-fields)
|
||||||
- [Diff patch](#diff-patch)
|
- [Diff patch](#diff-patch)
|
||||||
- [Plan file](#plan-file)
|
- [Plan file](#plan-file)
|
||||||
|
- [Session hooks](#session-hooks)
|
||||||
- [Permission presentation](#permission-presentation)
|
- [Permission presentation](#permission-presentation)
|
||||||
- [Goal](#goal)
|
- [Goal](#goal)
|
||||||
- [Recommended config values](#recommended-config-values)
|
- [Recommended config values](#recommended-config-values)
|
||||||
@@ -496,6 +497,21 @@ The plan text goes out as before in these cases:
|
|||||||
AIR then shows the in-memory `plan` text.
|
AIR then shows the in-memory `plan` text.
|
||||||
A client that is not AIR always gets the whole SDK input, like upstream.
|
A client that is not AIR always gets the whole SDK input, like upstream.
|
||||||
|
|
||||||
|
## Session hooks
|
||||||
|
|
||||||
|
The adapter accepts session hooks from any client, without a capability.
|
||||||
|
AIR puts the hooks into `_meta.claudeCode.options.settings.hooks` of `session/new` and `session/load`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "claudeCode": { "options": { "settings": { "hooks": { "Stop": [{ "hooks": [{ "type": "command", "command": "..." }] }] } } } }
|
||||||
|
```
|
||||||
|
|
||||||
|
- The settings go to the flag settings tier of that session only. Other sessions and the settings files do not change.
|
||||||
|
- Claude Code runs the hooks of every settings tier, so the user, project and IDE hooks of one event all run. `src/tests/session-hooks.test.ts` checks this with the Claude Code binary.
|
||||||
|
- `CLAUDE_MODEL_CONFIG` stays in effect (see [Model configuration](model-configuration.md#precedence)).
|
||||||
|
- A `session/load` with different settings recreates the query, so it applies the new hooks.
|
||||||
|
- Claude asks for no hook trust step.
|
||||||
|
|
||||||
## Permission presentation
|
## Permission presentation
|
||||||
|
|
||||||
The adapter uses standard ACP permission requests and responses.
|
The adapter uses standard ACP permission requests and responses.
|
||||||
|
|||||||
@@ -42,12 +42,12 @@ node dist/index.js
|
|||||||
|
|
||||||
## Precedence
|
## Precedence
|
||||||
|
|
||||||
When an ACP caller provides `settings` via `_meta.claudeCode.options.settings` in the `sessions/create` request, `CLAUDE_MODEL_CONFIG` is ignored entirely. The env var is a deployment-level fallback for cases where the caller does not configure model settings itself.
|
An ACP caller can provide `settings` via `_meta.claudeCode.options.settings` in `session/new` or `session/load`, as an object or a path to a settings file. The adapter merges `CLAUDE_MODEL_CONFIG` into those settings, so a caller that only adds hooks keeps the model settings. When both set the same key, the caller wins; the merge is shallow, so a caller's `modelOverrides` replaces the env var's map.
|
||||||
|
|
||||||
| Source | Priority |
|
| Source | Priority |
|
||||||
| -------------------------------------------- | ----------------------------------------------------- |
|
| -------------------------------------------- | ---------------------------------------------- |
|
||||||
| `_meta.claudeCode.options.settings` (caller) | Highest — used if present |
|
| `_meta.claudeCode.options.settings` (caller) | Highest — wins for each key it sets |
|
||||||
| `CLAUDE_MODEL_CONFIG` (env var) | Fallback — used only when caller provides no settings |
|
| `CLAUDE_MODEL_CONFIG` (env var) | Used for the keys that the caller does not set |
|
||||||
|
|
||||||
## Format details
|
## Format details
|
||||||
|
|
||||||
|
|||||||
@@ -9714,32 +9714,26 @@ export class ClaudeAcpAgent {
|
|||||||
// into the query.
|
// into the query.
|
||||||
const resolvedProvider = this.resolveProviderConfig();
|
const resolvedProvider = this.resolveProviderConfig();
|
||||||
const providerEnv = createEnvForProvider(resolvedProvider);
|
const providerEnv = createEnvForProvider(resolvedProvider);
|
||||||
const configuredSettings =
|
const providedSettings = userProvidedOptions?.settings;
|
||||||
userProvidedOptions?.settings ??
|
const providedSettingsObject =
|
||||||
(modelConfig
|
typeof providedSettings === "string"
|
||||||
? {
|
|
||||||
...(modelConfig.modelOverrides && { modelOverrides: modelConfig.modelOverrides }),
|
|
||||||
...(modelConfig.availableModels && { availableModels: modelConfig.availableModels }),
|
|
||||||
}
|
|
||||||
: undefined);
|
|
||||||
const configuredSettingsObject =
|
|
||||||
typeof configuredSettings === "string"
|
|
||||||
? (JSON.parse(
|
? (JSON.parse(
|
||||||
await fs.readFile(path.resolve(params.cwd, configuredSettings), "utf8"),
|
await fs.readFile(path.resolve(params.cwd, providedSettings), "utf8"),
|
||||||
) as Settings)
|
) as Settings)
|
||||||
: configuredSettings;
|
: providedSettings;
|
||||||
|
const configuredSettingsObject = modelConfig
|
||||||
|
? { ...modelConfig, ...providedSettingsObject }
|
||||||
|
: providedSettingsObject;
|
||||||
// Claude Code applies env from settings.json after the subprocess env. Put
|
// Claude Code applies env from settings.json after the subprocess env. Put
|
||||||
// an active ACP route in the programmatic settings tier too so user/project
|
// an active ACP route in the programmatic settings tier too so user/project
|
||||||
// settings cannot silently restore a different ANTHROPIC_BASE_URL.
|
// settings cannot silently restore a different ANTHROPIC_BASE_URL.
|
||||||
let settings = configuredSettings;
|
const settings = resolvedProvider
|
||||||
if (resolvedProvider) {
|
? {
|
||||||
const baseSettings = configuredSettingsObject;
|
...configuredSettingsObject,
|
||||||
settings = {
|
|
||||||
...baseSettings,
|
|
||||||
apiKeyHelper: "",
|
apiKeyHelper: "",
|
||||||
env: { ...baseSettings?.env, ...providerEnv },
|
env: { ...configuredSettingsObject?.env, ...providerEnv },
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
: configuredSettingsObject;
|
||||||
const env = {
|
const env = {
|
||||||
...process.env,
|
...process.env,
|
||||||
...userProvidedOptions?.env,
|
...userProvidedOptions?.env,
|
||||||
|
|||||||
@@ -656,9 +656,71 @@ describe("createSession options merging", () => {
|
|||||||
expect(capturedOptions!.settings).toBeUndefined();
|
expect(capturedOptions!.settings).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ignores env var when _meta provides settings", async () => {
|
it("keeps model settings when session settings add hooks", async () => {
|
||||||
process.env.CLAUDE_MODEL_CONFIG = JSON.stringify({
|
process.env.CLAUDE_MODEL_CONFIG = JSON.stringify({
|
||||||
modelOverrides: { "claude-opus-4-6": "us.anthropic.claude-opus-4-6-v1" },
|
modelOverrides: { "claude-opus-4-6": "us.anthropic.claude-opus-4-6-v1" },
|
||||||
|
availableModels: ["opus"],
|
||||||
|
});
|
||||||
|
const hooks = { PostToolUse: [{ hooks: [{ type: "command", command: "echo ready" }] }] };
|
||||||
|
|
||||||
|
await agent.newSession({
|
||||||
|
cwd: process.cwd(),
|
||||||
|
mcpServers: [],
|
||||||
|
_meta: { claudeCode: { options: { settings: { hooks } } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(capturedOptions!.settings).toEqual({
|
||||||
|
modelOverrides: { "claude-opus-4-6": "us.anthropic.claude-opus-4-6-v1" },
|
||||||
|
availableModels: ["opus"],
|
||||||
|
hooks,
|
||||||
|
});
|
||||||
|
// The CLI adds these hooks to the ones in the user and project settings.
|
||||||
|
expect(capturedOptions!.settingSources).toEqual(["user", "project", "local"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies session hooks on session/load with model settings", async () => {
|
||||||
|
process.env.CLAUDE_MODEL_CONFIG = JSON.stringify({ availableModels: ["opus"] });
|
||||||
|
const cwd = process.cwd();
|
||||||
|
const { sessionId } = await agent.newSession({ cwd, mcpServers: [] });
|
||||||
|
const hooks = { Stop: [{ hooks: [{ type: "command", command: "echo done" }] }] };
|
||||||
|
|
||||||
|
await agent.loadSession({
|
||||||
|
sessionId,
|
||||||
|
cwd,
|
||||||
|
mcpServers: [],
|
||||||
|
_meta: { claudeCode: { options: { settings: { hooks } } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(capturedOptions!.resume).toBe(sessionId);
|
||||||
|
expect(capturedOptions!.settings).toEqual({ availableModels: ["opus"], hooks });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("merges model settings with a session settings file", async () => {
|
||||||
|
process.env.CLAUDE_MODEL_CONFIG = JSON.stringify({ availableModels: ["opus"] });
|
||||||
|
const settingsDir = fs.mkdtempSync(path.join(os.tmpdir(), "claude-acp-settings-"));
|
||||||
|
const settingsFile = path.join(settingsDir, "settings.json");
|
||||||
|
fs.writeFileSync(settingsFile, JSON.stringify({ hooks: { Stop: [] } }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await agent.newSession({
|
||||||
|
cwd: process.cwd(),
|
||||||
|
mcpServers: [],
|
||||||
|
_meta: { claudeCode: { options: { settings: settingsFile } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(capturedOptions!.settings).toEqual({
|
||||||
|
availableModels: ["opus"],
|
||||||
|
hooks: { Stop: [] },
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(settingsDir, { recursive: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers session settings when both sources set the same field", async () => {
|
||||||
|
process.env.CLAUDE_MODEL_CONFIG = JSON.stringify({
|
||||||
|
modelOverrides: { "claude-opus-4-6": "us.anthropic.claude-opus-4-6-v1" },
|
||||||
|
availableModels: ["opus"],
|
||||||
});
|
});
|
||||||
|
|
||||||
await agent.newSession({
|
await agent.newSession({
|
||||||
@@ -676,10 +738,10 @@ describe("createSession options merging", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// _meta settings take precedence; env var is ignored entirely
|
|
||||||
expect(capturedOptions!.settings).toEqual({
|
expect(capturedOptions!.settings).toEqual({
|
||||||
model: "claude-sonnet-4-6",
|
model: "claude-sonnet-4-6",
|
||||||
modelOverrides: { "claude-opus-4-6": "meta-value" },
|
modelOverrides: { "claude-opus-4-6": "meta-value" },
|
||||||
|
availableModels: ["opus"],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
202
src/tests/session-hooks.test.ts
Normal file
202
src/tests/session-hooks.test.ts
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
/**
|
||||||
|
* Session hooks against the real Claude Code binary. A local stub of the
|
||||||
|
* Messages API answers every turn, so the test needs no network or account.
|
||||||
|
* The CLI merges the settings tiers, so a mock SDK cannot show that the user
|
||||||
|
* and project hooks still run next to the hooks of `_meta.claudeCode.options`.
|
||||||
|
*/
|
||||||
|
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
||||||
|
import http from "node:http";
|
||||||
|
import type { AddressInfo } from "node:net";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import type { NewSessionRequest } from "@agentclientprotocol/sdk";
|
||||||
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { ClaudeAcpAgent, type AcpClient } from "../acp-agent.js";
|
||||||
|
|
||||||
|
function sse(type: string, data: object): string {
|
||||||
|
return `event: ${type}\ndata: ${JSON.stringify({ type, ...data })}\n\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const REPLY =
|
||||||
|
sse("message_start", {
|
||||||
|
message: {
|
||||||
|
id: "msg_stub",
|
||||||
|
type: "message",
|
||||||
|
role: "assistant",
|
||||||
|
model: "claude-sonnet-4-5",
|
||||||
|
content: [],
|
||||||
|
stop_reason: null,
|
||||||
|
stop_sequence: null,
|
||||||
|
usage: { input_tokens: 1, output_tokens: 0 },
|
||||||
|
},
|
||||||
|
}) +
|
||||||
|
sse("content_block_start", { index: 0, content_block: { type: "text", text: "" } }) +
|
||||||
|
sse("content_block_delta", { index: 0, delta: { type: "text_delta", text: "ok" } }) +
|
||||||
|
sse("content_block_stop", { index: 0 }) +
|
||||||
|
sse("message_delta", {
|
||||||
|
delta: { stop_reason: "end_turn", stop_sequence: null },
|
||||||
|
usage: { output_tokens: 1 },
|
||||||
|
}) +
|
||||||
|
sse("message_stop", {});
|
||||||
|
|
||||||
|
describe("session hooks with the Claude Code binary", () => {
|
||||||
|
let root: string;
|
||||||
|
let cwd: string;
|
||||||
|
let home: string;
|
||||||
|
let log: string;
|
||||||
|
let server: http.Server;
|
||||||
|
let agent: ClaudeAcpAgent;
|
||||||
|
let env: Record<string, string | undefined>;
|
||||||
|
let settingsFiles: Record<string, string>;
|
||||||
|
let logCursor = 0;
|
||||||
|
|
||||||
|
/** A SessionStart hook that writes `tag` to the log. */
|
||||||
|
function hooks(tag: string) {
|
||||||
|
return {
|
||||||
|
SessionStart: [{ hooks: [{ type: "command", command: `echo ${tag} >> "${log}"` }] }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function meta(tag?: string): NewSessionRequest["_meta"] {
|
||||||
|
return { claudeCode: { options: { env, ...(tag && { settings: { hooks: hooks(tag) } }) } } };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The hooks that ran since the previous call. Hooks of one event run in parallel. */
|
||||||
|
async function hooksSinceLastCheck(): Promise<string[]> {
|
||||||
|
const lines = (await readFile(log, "utf8").catch(() => "")).split("\n").filter(Boolean);
|
||||||
|
const added = lines.slice(logCursor);
|
||||||
|
logCursor = lines.length;
|
||||||
|
return added.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A turn finishes after the SessionStart hooks, and it persists the session for a load. */
|
||||||
|
async function finishTurn(sessionId: string): Promise<void> {
|
||||||
|
const response = await agent.prompt({ sessionId, prompt: [{ type: "text", text: "hi" }] });
|
||||||
|
expect(response.stopReason).toBe("end_turn");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readSettingsFiles(): Promise<Record<string, string>> {
|
||||||
|
const files: Record<string, string> = {};
|
||||||
|
for (const dir of [path.join(home, ".claude"), path.join(cwd, ".claude")]) {
|
||||||
|
for (const name of await readdir(dir)) {
|
||||||
|
if (name.startsWith("settings")) {
|
||||||
|
files[path.join(dir, name)] = await readFile(path.join(dir, name), "utf8");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
root = await mkdtemp(path.join(tmpdir(), "claude-acp-session-hooks-"));
|
||||||
|
cwd = path.join(root, "workspace");
|
||||||
|
home = path.join(root, "home");
|
||||||
|
log = path.join(root, "hooks.log");
|
||||||
|
await mkdir(path.join(cwd, ".claude"), { recursive: true });
|
||||||
|
await mkdir(path.join(home, ".claude"), { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
path.join(home, ".claude", "settings.json"),
|
||||||
|
JSON.stringify({ hooks: hooks("user") }),
|
||||||
|
);
|
||||||
|
await writeFile(
|
||||||
|
path.join(cwd, ".claude", "settings.json"),
|
||||||
|
JSON.stringify({ hooks: hooks("project") }),
|
||||||
|
);
|
||||||
|
settingsFiles = await readSettingsFiles();
|
||||||
|
// vitest.config.ts points every test at `/usr/bin/false` in place of the
|
||||||
|
// CLI. This file opts in to the real binary, and isolates the agent process
|
||||||
|
// too: it reads user settings and runs `claude auth status` itself.
|
||||||
|
vi.stubEnv("CLAUDE_CODE_EXECUTABLE", undefined);
|
||||||
|
vi.stubEnv("HOME", home);
|
||||||
|
vi.stubEnv("CLAUDE_CONFIG_DIR", path.join(home, ".claude"));
|
||||||
|
|
||||||
|
server = http.createServer((req, res) => {
|
||||||
|
req.resume();
|
||||||
|
req.on("end", () => {
|
||||||
|
if (req.url?.startsWith("/v1/messages/count_tokens")) {
|
||||||
|
res.writeHead(200, { "content-type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ input_tokens: 1 }));
|
||||||
|
} else if (req.url?.startsWith("/v1/messages")) {
|
||||||
|
res.writeHead(200, { "content-type": "text/event-stream" });
|
||||||
|
res.end(REPLY);
|
||||||
|
} else {
|
||||||
|
res.writeHead(200);
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||||
|
|
||||||
|
env = {
|
||||||
|
...process.env,
|
||||||
|
HOME: home,
|
||||||
|
CLAUDE_CONFIG_DIR: path.join(home, ".claude"),
|
||||||
|
ANTHROPIC_API_KEY: "sk-ant-test",
|
||||||
|
ANTHROPIC_BASE_URL: `http://127.0.0.1:${(server.address() as AddressInfo).port}`,
|
||||||
|
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1",
|
||||||
|
};
|
||||||
|
const client = {
|
||||||
|
sessionUpdate: async () => {},
|
||||||
|
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
|
||||||
|
} as unknown as AcpClient;
|
||||||
|
agent = new ClaudeAcpAgent(client, { log: () => {}, error: () => {} });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await agent?.dispose();
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
await new Promise((resolve) => server?.close(resolve));
|
||||||
|
// The CLI may still write the transcript of the last session after dispose.
|
||||||
|
if (root) await rm(root, { recursive: true, force: true, maxRetries: 10 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// With CLAUDE_MODEL_CONFIG, the adapter passes the merged settings inline.
|
||||||
|
for (const modelConfig of [
|
||||||
|
undefined,
|
||||||
|
'{"modelOverrides":{"claude-opus-4-6":"claude-opus-4-6"}}',
|
||||||
|
]) {
|
||||||
|
describe(modelConfig ? "with CLAUDE_MODEL_CONFIG" : "without CLAUDE_MODEL_CONFIG", () => {
|
||||||
|
const original = process.env.CLAUDE_MODEL_CONFIG;
|
||||||
|
beforeEach(() => {
|
||||||
|
if (modelConfig) process.env.CLAUDE_MODEL_CONFIG = modelConfig;
|
||||||
|
else delete process.env.CLAUDE_MODEL_CONFIG;
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
if (original === undefined) delete process.env.CLAUDE_MODEL_CONFIG;
|
||||||
|
else process.env.CLAUDE_MODEL_CONFIG = original;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runs the user, project and IDE hooks of one event, only in their session", async () => {
|
||||||
|
const withHooks = await agent.newSession({ cwd, mcpServers: [], _meta: meta("ide") });
|
||||||
|
await finishTurn(withHooks.sessionId);
|
||||||
|
expect(await hooksSinceLastCheck()).toEqual(["ide", "project", "user"]);
|
||||||
|
|
||||||
|
const withoutHooks = await agent.newSession({ cwd, mcpServers: [], _meta: meta() });
|
||||||
|
await finishTurn(withoutHooks.sessionId);
|
||||||
|
expect(await hooksSinceLastCheck()).toEqual(["project", "user"]);
|
||||||
|
|
||||||
|
expect(await readSettingsFiles()).toEqual(settingsFiles);
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
it("replaces and removes IDE hooks on session/load", async () => {
|
||||||
|
const { sessionId } = await agent.newSession({
|
||||||
|
cwd,
|
||||||
|
mcpServers: [],
|
||||||
|
_meta: meta("ide-old"),
|
||||||
|
});
|
||||||
|
await finishTurn(sessionId);
|
||||||
|
expect(await hooksSinceLastCheck()).toEqual(["ide-old", "project", "user"]);
|
||||||
|
|
||||||
|
await agent.loadSession({ sessionId, cwd, mcpServers: [], _meta: meta("ide-new") });
|
||||||
|
await finishTurn(sessionId);
|
||||||
|
expect(await hooksSinceLastCheck()).toEqual(["ide-new", "project", "user"]);
|
||||||
|
|
||||||
|
await agent.loadSession({ sessionId, cwd, mcpServers: [], _meta: meta() });
|
||||||
|
await finishTurn(sessionId);
|
||||||
|
expect(await hooksSinceLastCheck()).toEqual(["project", "user"]);
|
||||||
|
|
||||||
|
expect(await readSettingsFiles()).toEqual(settingsFiles);
|
||||||
|
}, 60_000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user