From 76a9781ba566ab511ed8ec9b08ebf53425229f39 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sat, 26 Sep 2026 13:57:27 +0200 Subject: [PATCH] Read Claude history from the provider's own config directory (#5437) A provider that extends claude and sets CLAUDE_CONFIG_DIR in its env writes transcripts and settings.json under that directory, but the daemon looked them up under its own CLAUDE_CONFIG_DIR or ~/.claude. Agents on that provider reopened with an empty timeline after a daemon restart, their sessions were missing from the import list, and models from its settings.json were missing from the catalog. Resolve the config directory from the same environment the provider is launched with, and drop the test-only configDir client option. Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../agent/providers/claude/agent.test.ts | 6 +- .../server/agent/providers/claude/agent.ts | 24 ++- .../agent/providers/claude/models.test.ts | 19 +++ .../server/agent/providers/claude/models.ts | 11 +- .../agent/providers/claude/project-dir.ts | 7 +- ...de-provider-config-dir-history.e2e.test.ts | 137 ++++++++++++++++++ 6 files changed, 179 insertions(+), 25 deletions(-) create mode 100644 packages/server/src/server/daemon-e2e/claude-provider-config-dir-history.e2e.test.ts diff --git a/packages/server/src/server/agent/providers/claude/agent.test.ts b/packages/server/src/server/agent/providers/claude/agent.test.ts index 96022d851d..c908e17cf5 100644 --- a/packages/server/src/server/agent/providers/claude/agent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.test.ts @@ -428,7 +428,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => { logger, resolveBinary: async () => "/test/claude/bin", resolveVersion: async () => "2.1.219", - configDir: emptyConfigDir, + runtimeSettings: { env: { CLAUDE_CONFIG_DIR: emptyConfigDir } }, }); const { models } = await client.fetchCatalog({ scope: "workspace", @@ -475,7 +475,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => { resolveVersion: async () => { throw new Error("unrecognized version output"); }, - configDir: emptyConfigDir, + runtimeSettings: { env: { CLAUDE_CONFIG_DIR: emptyConfigDir } }, }); const { models } = await client.fetchCatalog({ scope: "workspace", @@ -497,7 +497,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => { logger, resolveBinary: async () => "/test/claude/bin", resolveVersion: async () => "2.1.219", - configDir: emptyConfigDir, + runtimeSettings: { env: { CLAUDE_CONFIG_DIR: emptyConfigDir } }, }); const { models } = await client.fetchCatalog({ scope: "workspace", diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index b4b516fe1e..04fef2b1f6 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -2,7 +2,6 @@ import type { ChildProcess } from "node:child_process"; import { randomUUID } from "node:crypto"; import fs from "node:fs"; import { promises } from "node:fs"; -import os from "node:os"; import path from "node:path"; import { type AgentDefinition, @@ -83,7 +82,7 @@ import { type ClaudeRewindSdk, } from "./rewind.js"; import { normalizeProviderReplayTimestamp } from "../../provider-history-timestamps.js"; -import { claudeProjectDirSync } from "./project-dir.js"; +import { claudeConfigDir, claudeProjectDirSync } from "./project-dir.js"; import { THINKING_APPLIES_NEXT_TURN_NOTICE } from "../../provider-notices.js"; import { isProviderImageMarkdown, @@ -409,7 +408,6 @@ interface ClaudeAgentClientOptions { queryFactory?: ClaudeQueryFactory; resolveBinary?: () => Promise; resolveVersion?: (signal?: AbortSignal) => Promise; - configDir?: string; rewindSdk?: ClaudeRewindSdk; } @@ -1506,7 +1504,6 @@ export class ClaudeAgentClient implements AgentClient { private readonly queryFactory?: ClaudeQueryFactory; private readonly resolveBinary: () => Promise; private readonly resolveVersion: (signal?: AbortSignal) => Promise; - private readonly configDir?: string; private readonly rewindSdk: ClaudeRewindSdk; constructor(options: ClaudeAgentClientOptions) { @@ -1518,7 +1515,6 @@ export class ClaudeAgentClient implements AgentClient { this.resolveVersion = options.resolveVersion ?? ((signal) => resolveClaudeCodeVersion(this.runtimeSettings, signal)); - this.configDir = options.configDir; this.rewindSdk = options.rewindSdk ?? realClaudeRewindSdk; } @@ -1592,12 +1588,11 @@ export class ClaudeAgentClient implements AgentClient { } catch (error) { this.logger.warn({ err: error }, "Failed to resolve Claude Code version for model catalog"); } + const env = this.buildProviderEnv(); const models = await runProviderRefreshActivity(context, "settings", () => - getClaudeModelsWithSettings(this.logger, this.configDir, claudeCodeVersion), - ); - const modeCatalog = claudeModeCatalog( - createProviderEnv({ baseEnv: process.env, runtimeSettings: this.runtimeSettings }), + getClaudeModelsWithSettings(this.logger, claudeConfigDir(env), claudeCodeVersion), ); + const modeCatalog = claudeModeCatalog(env); return { models, ...modeCatalog, @@ -1605,12 +1600,15 @@ export class ClaudeAgentClient implements AgentClient { } async resolveDefaultModeId({ env: launchEnv }: ResolveAgentDefaultModeInput): Promise { - const env = createProviderEnv({ + return claudeModeCatalog(this.buildProviderEnv(launchEnv)).defaultModeId; + } + + private buildProviderEnv(launchEnv?: Record): NodeJS.ProcessEnv { + return createProviderEnv({ baseEnv: process.env, runtimeSettings: this.runtimeSettings, overlays: [launchEnv], }); - return claudeModeCatalog(env).defaultModeId; } async listFeatures(config: AgentSessionConfig): Promise { @@ -1624,7 +1622,7 @@ export class ClaudeAgentClient implements AgentClient { async listImportableSessions( options?: ListImportableSessionsOptions, ): Promise { - const configDir = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude"); + const configDir = claudeConfigDir(this.buildProviderEnv()); const sessionsRoot = options?.cwd ? claudeProjectDirSync(options.cwd, { configDir }) : path.join(configDir, "projects"); @@ -5051,7 +5049,7 @@ class ClaudeAgentSession implements AgentSession { private resolveHistoryPath(sessionId: string): string | null { const cwd = this.config.cwd; if (!cwd) return null; - const configDir = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude"); + const configDir = claudeConfigDir(this.buildSdkEnv()); const candidates = [cwd]; try { const realCwd = fs.realpathSync(cwd); diff --git a/packages/server/src/server/agent/providers/claude/models.test.ts b/packages/server/src/server/agent/providers/claude/models.test.ts index 5a10980fbf..af725ca047 100644 --- a/packages/server/src/server/agent/providers/claude/models.test.ts +++ b/packages/server/src/server/agent/providers/claude/models.test.ts @@ -274,6 +274,25 @@ describe("ClaudeAgentClient.fetchCatalog", () => { expect(models.some((model) => model.id === "claude-fable-5-1")).toBe(true); }); + it("reads settings.json from the provider's own CLAUDE_CONFIG_DIR", async () => { + const daemonConfigDir = await createClaudeConfigDir({}); + const providerConfigDir = await createClaudeConfigDir({ model: "glm-5.1" }); + vi.stubEnv("CLAUDE_CONFIG_DIR", daemonConfigDir); + const client = new ClaudeAgentClient({ + logger: createTestLogger(), + resolveVersion: async () => "2.1.280", + runtimeSettings: { env: { CLAUDE_CONFIG_DIR: providerConfigDir } }, + }); + + const { models } = await client.fetchCatalog({ + scope: "workspace", + cwd: os.tmpdir(), + force: true, + }); + + expect(models.map((model) => model.id)).toContain("glm-5.1"); + }); + it("falls back to hardcoded models when settings.json is missing", async () => { const configDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-models-")); createdClaudeConfigDirs.push(configDir); diff --git a/packages/server/src/server/agent/providers/claude/models.ts b/packages/server/src/server/agent/providers/claude/models.ts index 603f21c92c..6b341fa9ea 100644 --- a/packages/server/src/server/agent/providers/claude/models.ts +++ b/packages/server/src/server/agent/providers/claude/models.ts @@ -1,5 +1,4 @@ import * as fs from "node:fs/promises"; -import * as os from "node:os"; import * as path from "node:path"; import type { Logger } from "pino"; @@ -51,7 +50,7 @@ export function findClaudeModel( export async function getClaudeModelsWithSettings( logger: Logger, - configDir?: string, + configDir: string, claudeCodeVersion?: string, ): Promise { const hardcodedModels = getClaudeModels(claudeCodeVersion); @@ -79,9 +78,9 @@ export async function getClaudeModelsWithSettings( async function readClaudeSettingsModels( logger: Logger, - configDir?: string, + configDir: string, ): Promise { - const settingsPath = path.join(resolveClaudeConfigDir(configDir), "settings.json"); + const settingsPath = path.join(configDir, "settings.json"); let parsed: unknown; try { @@ -116,10 +115,6 @@ async function readClaudeSettingsModels( return models; } -function resolveClaudeConfigDir(configDir?: string): string { - return configDir ?? process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude"); -} - function addSettingsModel( models: AgentModelDefinition[], value: unknown, diff --git a/packages/server/src/server/agent/providers/claude/project-dir.ts b/packages/server/src/server/agent/providers/claude/project-dir.ts index 6a89711eea..ccac0841de 100644 --- a/packages/server/src/server/agent/providers/claude/project-dir.ts +++ b/packages/server/src/server/agent/providers/claude/project-dir.ts @@ -66,6 +66,11 @@ function hashSuffix(input: string): string { return Math.abs(hash).toString(36); } +// Claude Code keeps its settings and transcripts here for a process launched with `env`. +export function claudeConfigDir(env: NodeJS.ProcessEnv): string { + return env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude"); +} + function resolveConfigDir(options?: ClaudeProjectDirOptions): string { - return options?.configDir ?? process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude"); + return options?.configDir ?? claudeConfigDir(process.env); } diff --git a/packages/server/src/server/daemon-e2e/claude-provider-config-dir-history.e2e.test.ts b/packages/server/src/server/daemon-e2e/claude-provider-config-dir-history.e2e.test.ts new file mode 100644 index 0000000000..29779fd1ef --- /dev/null +++ b/packages/server/src/server/daemon-e2e/claude-provider-config-dir-history.e2e.test.ts @@ -0,0 +1,137 @@ +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { claudeProjectDirSync } from "../agent/providers/claude/project-dir.js"; +import { DaemonClient } from "../test-utils/daemon-client.js"; +import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo-daemon.js"; + +const PROVIDER_ID = "claude-secondary"; +const SESSION_ID = "provider-config-dir-session"; + +function timelineText(entries: ReadonlyArray<{ item: { type: string; text?: string } }>): string { + return entries + .filter( + (entry): entry is { item: { type: "user_message" | "assistant_message"; text: string } } => + entry.item.type === "user_message" || entry.item.type === "assistant_message", + ) + .map((entry) => entry.item.text) + .join("\n"); +} + +describe("daemon E2E - Claude history lives in the provider's CLAUDE_CONFIG_DIR", () => { + let tempRoot: string; + let paseoHomeRoot: string; + let providerConfigDir: string; + let cwd: string; + let prevClaudeConfigDir: string | undefined; + let daemon: TestPaseoDaemon | undefined; + let client: DaemonClient | undefined; + + beforeEach(() => { + tempRoot = mkdtempSync(path.join(tmpdir(), "claude-provider-config-dir-")); + paseoHomeRoot = path.join(tempRoot, "paseo-home"); + providerConfigDir = path.join(tempRoot, "custom-claude-dir"); + cwd = path.join(tempRoot, "repo"); + mkdirSync(paseoHomeRoot, { recursive: true }); + mkdirSync(cwd, { recursive: true }); + + const projectDir = claudeProjectDirSync(cwd, { configDir: providerConfigDir }); + mkdirSync(projectDir, { recursive: true }); + writeFileSync( + path.join(projectDir, `${SESSION_ID}.jsonl`), + [ + { + type: "user", + uuid: "user-uuid-1", + sessionId: SESSION_ID, + cwd, + message: { role: "user", content: "hello from the custom config dir" }, + }, + { + type: "assistant", + sessionId: SESSION_ID, + cwd, + message: { role: "assistant", content: "reply from the custom config dir" }, + }, + ] + .map((entry) => `${JSON.stringify(entry)}\n`) + .join(""), + "utf8", + ); + + // The daemon's own environment does not name the directory; only the provider's env does. + prevClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR; + delete process.env.CLAUDE_CONFIG_DIR; + }); + + afterEach(async () => { + await client?.close().catch(() => undefined); + await daemon?.close().catch(() => undefined); + client = undefined; + daemon = undefined; + rmSync(tempRoot, { recursive: true, force: true }); + if (prevClaudeConfigDir === undefined) { + delete process.env.CLAUDE_CONFIG_DIR; + } else { + process.env.CLAUDE_CONFIG_DIR = prevClaudeConfigDir; + } + }, 60_000); + + async function startDaemon(): Promise { + daemon = await createTestPaseoDaemon({ + paseoHomeRoot, + cleanup: false, + providerOverrides: { + [PROVIDER_ID]: { + extends: "claude", + label: "Claude (secondary)", + env: { CLAUDE_CONFIG_DIR: providerConfigDir }, + }, + }, + }); + client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + await client.connect(); + await client.fetchAgents({ subscribe: {} }); + return client; + } + + async function stopDaemon(): Promise { + await client?.close(); + await daemon?.close(); + client = undefined; + daemon = undefined; + } + + test("an agent's timeline survives a daemon restart", async () => { + const firstClient = await startDaemon(); + const agent = await firstClient.importAgent({ + provider: PROVIDER_ID, + sessionId: SESSION_ID, + cwd, + }); + await stopDaemon(); + + const restartedClient = await startDaemon(); + const timeline = await restartedClient.fetchAgentTimeline(agent.id, { + direction: "tail", + limit: 0, + projection: "canonical", + }); + + const text = timelineText(timeline.entries); + expect(text).toContain("hello from the custom config dir"); + expect(text).toContain("reply from the custom config dir"); + }, 60_000); + + test("the import list finds the provider's sessions", async () => { + const daemonClient = await startDaemon(); + const recent = await daemonClient.fetchRecentProviderSessions({ + cwd, + providers: [PROVIDER_ID], + }); + + expect(recent.entries.map((entry) => entry.providerHandleId)).toContain(SESSION_ID); + }, 60_000); +});