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:
Maksim Zheltoukhov
2026-10-12 13:00:07 +02:00
committed by GitHub
parent b3bed2f763
commit fe8b69edd4
5 changed files with 302 additions and 28 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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 = { apiKeyHelper: "",
...baseSettings, env: { ...configuredSettingsObject?.env, ...providerEnv },
apiKeyHelper: "", }
env: { ...baseSettings?.env, ...providerEnv }, : configuredSettingsObject;
};
}
const env = { const env = {
...process.env, ...process.env,
...userProvidedOptions?.env, ...userProvidedOptions?.env,

View File

@@ -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"],
}); });
}); });

View 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);
});
}
});