diff --git a/docs/air-extensions.md b/docs/air-extensions.md index 63ea2934..5a4b575d 100644 --- a/docs/air-extensions.md +++ b/docs/air-extensions.md @@ -17,6 +17,7 @@ It describes only this adapter. - [Claude tools and ACP fields](#claude-tools-and-acp-fields) - [Diff patch](#diff-patch) - [Plan file](#plan-file) +- [Session hooks](#session-hooks) - [Permission presentation](#permission-presentation) - [Goal](#goal) - [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. 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 The adapter uses standard ACP permission requests and responses. diff --git a/docs/model-configuration.md b/docs/model-configuration.md index 6cc58aab..b0505a64 100644 --- a/docs/model-configuration.md +++ b/docs/model-configuration.md @@ -42,12 +42,12 @@ node dist/index.js ## 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 | -| -------------------------------------------- | ----------------------------------------------------- | -| `_meta.claudeCode.options.settings` (caller) | Highest — used if present | -| `CLAUDE_MODEL_CONFIG` (env var) | Fallback — used only when caller provides no settings | +| Source | Priority | +| -------------------------------------------- | ---------------------------------------------- | +| `_meta.claudeCode.options.settings` (caller) | Highest — wins for each key it sets | +| `CLAUDE_MODEL_CONFIG` (env var) | Used for the keys that the caller does not set | ## Format details diff --git a/src/acp-agent.ts b/src/acp-agent.ts index f6faebcb..be7f3f39 100644 --- a/src/acp-agent.ts +++ b/src/acp-agent.ts @@ -9714,32 +9714,26 @@ export class ClaudeAcpAgent { // into the query. const resolvedProvider = this.resolveProviderConfig(); const providerEnv = createEnvForProvider(resolvedProvider); - const configuredSettings = - userProvidedOptions?.settings ?? - (modelConfig - ? { - ...(modelConfig.modelOverrides && { modelOverrides: modelConfig.modelOverrides }), - ...(modelConfig.availableModels && { availableModels: modelConfig.availableModels }), - } - : undefined); - const configuredSettingsObject = - typeof configuredSettings === "string" + const providedSettings = userProvidedOptions?.settings; + const providedSettingsObject = + typeof providedSettings === "string" ? (JSON.parse( - await fs.readFile(path.resolve(params.cwd, configuredSettings), "utf8"), + await fs.readFile(path.resolve(params.cwd, providedSettings), "utf8"), ) as Settings) - : configuredSettings; + : providedSettings; + const configuredSettingsObject = modelConfig + ? { ...modelConfig, ...providedSettingsObject } + : providedSettingsObject; // 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 // settings cannot silently restore a different ANTHROPIC_BASE_URL. - let settings = configuredSettings; - if (resolvedProvider) { - const baseSettings = configuredSettingsObject; - settings = { - ...baseSettings, - apiKeyHelper: "", - env: { ...baseSettings?.env, ...providerEnv }, - }; - } + const settings = resolvedProvider + ? { + ...configuredSettingsObject, + apiKeyHelper: "", + env: { ...configuredSettingsObject?.env, ...providerEnv }, + } + : configuredSettingsObject; const env = { ...process.env, ...userProvidedOptions?.env, diff --git a/src/tests/create-session-options.test.ts b/src/tests/create-session-options.test.ts index ac905732..de2c5864 100644 --- a/src/tests/create-session-options.test.ts +++ b/src/tests/create-session-options.test.ts @@ -656,9 +656,71 @@ describe("createSession options merging", () => { 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({ 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({ @@ -676,10 +738,10 @@ describe("createSession options merging", () => { }, }); - // _meta settings take precedence; env var is ignored entirely expect(capturedOptions!.settings).toEqual({ model: "claude-sonnet-4-6", modelOverrides: { "claude-opus-4-6": "meta-value" }, + availableModels: ["opus"], }); }); diff --git a/src/tests/session-hooks.test.ts b/src/tests/session-hooks.test.ts new file mode 100644 index 00000000..625fbcbc --- /dev/null +++ b/src/tests/session-hooks.test.ts @@ -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; + let settingsFiles: Record; + 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 { + 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 { + const response = await agent.prompt({ sessionId, prompt: [{ type: "text", text: "hi" }] }); + expect(response.stopReason).toBe("end_turn"); + } + + async function readSettingsFiles(): Promise> { + const files: Record = {}; + 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((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); + }); + } +});