diff --git a/CLAUDE.md b/CLAUDE.md index a960532b8..28d1edb03 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -617,6 +617,27 @@ hard dependency on `gh`. undocumented `useSystemClaudeForJsonMode: true` setting in `config.json` flips json-mode back to PATH `claude` for diagnostics / version comparison — no UI, edit the JSON directly. +- **Claude home is per worktree, not per Ness process.** Folder-scoped + env tooling (mise, direnv) can set `CLAUDE_CONFIG_DIR` for a directory + tree, and a login shell is the only thing that knows the answer. + Terminal tabs get it for free via `zsh -ilc`; everything else goes + through `src/main/claude-env.ts`, which runs the user's login shell in + the worktree once (sentinel-wrapped `env -0`, cached per path, + re-probed on spawn after a minute). Chat tabs spawn the bundled binary + with that captured env, and every `~/.claude/projects` lookup + (`--resume` decision, transcripts, context/cost trackers, fork) and + the hooks install resolve through `claudeConfigDirFor(cwd)` / + `knownClaudeConfigDirs()`. Never hardcode `homedir()/.claude` in main + — a worktree whose shell points elsewhere would silently lose resume, + status, and cost. The probe is a full shell startup (~1s on a heavy + rc file), so it must stay off the hot path: sync readers only read + the cache, and async entry points (`startJsonClaudeSession`, the + `agent:*` request handlers) are the ones that await it. Probes run + two at a time through a queue — the boot warm-up covers every + worktree, and fifty login shells started together all blow the + timeout — with a spawn's `resolveShellEnv` jumping ahead of queued + warm-ups. A failed probe is never served as an answer: `warmShellEnv` + leaves it alone, `resolveShellEnv` retries it. ## Workflow conventions diff --git a/src/main/agents/claude.ts b/src/main/agents/claude.ts index 2d88d8ae2..eda5eaaac 100644 --- a/src/main/agents/claude.ts +++ b/src/main/agents/claude.ts @@ -1,7 +1,7 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync } from 'fs' import { join } from 'path' -import { homedir } from 'os' import { log } from '../debug' +import { claudeProjectsDirFor, knownClaudeConfigDirs } from '../claude-env' import { makeHookCommand } from '../hooks' import { shellQuote } from '../shell-quote' import type { AgentSpawnOpts } from './index' @@ -32,8 +32,8 @@ interface SettingsFile { [key: string]: unknown } -function globalSettingsPath(): string { - return join(homedir(), '.claude', 'settings.json') +function settingsPathIn(configDir: string): string { + return join(configDir, 'settings.json') } function worktreeSettingsPath(worktreePath: string): string { @@ -71,7 +71,11 @@ function removeOldHarnessEntries(entries: HookEntry[]): HookEntry[] { } export function hooksInstalled(): boolean { - const settings = readSettings(globalSettingsPath()) + return knownClaudeConfigDirs().some((dir) => hooksInstalledIn(settingsPathIn(dir))) +} + +function hooksInstalledIn(path: string): boolean { + const settings = readSettings(path) const hooks = settings.hooks if (!hooks) return false for (const entries of Object.values(hooks)) { @@ -82,8 +86,15 @@ export function hooksInstalled(): boolean { return false } +/** Every Claude home a Ness worktree resolves to gets the hooks: Claude + * only reads the settings.json under its own CLAUDE_CONFIG_DIR, so a + * worktree whose shell points elsewhere would otherwise report no + * status at all. */ export function installHooks(): void { - const path = globalSettingsPath() + for (const dir of knownClaudeConfigDirs()) installHooksInto(settingsPathIn(dir)) +} + +function installHooksInto(path: string): void { log('hooks', `installing Claude hooks into ${path}`) const settings = readSettings(path) if (!settings.hooks) settings.hooks = {} @@ -103,7 +114,10 @@ export function installHooks(): void { /** Remove our entries from ~/.claude/settings.json but leave any user-authored * hooks + unrelated keys intact. No-op if we're not installed. */ export function uninstallHooks(): void { - const path = globalSettingsPath() + for (const dir of knownClaudeConfigDirs()) uninstallHooksFrom(settingsPathIn(dir)) +} + +function uninstallHooksFrom(path: string): void { if (!existsSync(path)) return const settings = readSettings(path) if (!settings.hooks) return @@ -140,7 +154,7 @@ export function stripHooksFromWorktree(worktreePath: string): boolean { export function sessionFileExists(cwd: string, sessionId: string): boolean { try { const encoded = cwd.replace(/[^a-zA-Z0-9]/g, '-') - return existsSync(join(homedir(), '.claude', 'projects', encoded, `${sessionId}.jsonl`)) + return existsSync(join(claudeProjectsDirFor(cwd), encoded, `${sessionId}.jsonl`)) } catch { return false } @@ -149,7 +163,7 @@ export function sessionFileExists(cwd: string, sessionId: string): boolean { export function latestSessionId(cwd: string): string | null { try { const encoded = cwd.replace(/[^a-zA-Z0-9]/g, '-') - const dir = join(homedir(), '.claude', 'projects', encoded) + const dir = join(claudeProjectsDirFor(cwd), encoded) const files = readdirSync(dir).filter((f) => f.endsWith('.jsonl')) if (files.length === 0) return null let bestId: string | null = null diff --git a/src/main/agents/index.ts b/src/main/agents/index.ts index ce9cc1444..b1a9d5021 100644 --- a/src/main/agents/index.ts +++ b/src/main/agents/index.ts @@ -26,7 +26,8 @@ export interface AgentModule { * assigns its own ID and Ness discovers it from the first hook event. */ assignsSessionId: boolean /** Install status hooks at the agent's user-scope settings file - * (~/.claude/settings.json for Claude, ~/.codex/hooks.json for Codex). + * (settings.json in every Claude home a worktree resolves to — see + * claude-env.ts — or ~/.codex/hooks.json for Codex). * The hook command is gated on $HARNESS_TERMINAL_ID so sessions spawned * outside Ness are untouched. */ installHooks(): void diff --git a/src/main/claude-env.test.ts b/src/main/claude-env.test.ts new file mode 100644 index 000000000..63e62e645 --- /dev/null +++ b/src/main/claude-env.test.ts @@ -0,0 +1,211 @@ +import { describe, it, expect, beforeEach, afterAll, vi } from 'vitest' +import { mkdtempSync, mkdirSync, writeFileSync, chmodSync, rmSync, existsSync } from 'fs' +import { homedir } from 'os' +import { join } from 'path' + +vi.mock('./debug', () => ({ + log: () => {} +})) + +let currentShell = '/bin/sh' +vi.mock('./user-shell', () => ({ + resolveUserShell: () => currentShell +})) + +const tmpRoot = mkdtempSync(join(require('os').tmpdir(), 'harness-claude-env-')) + +function fakeShell(name: string, body: string): string { + const path = join(tmpRoot, name) + writeFileSync(path, `#!/bin/sh\n${body}\n`) + chmodSync(path, 0o755) + return path +} + +const launcherConfigDir = process.env.CLAUDE_CONFIG_DIR + +afterAll(() => { + rmSync(tmpRoot, { recursive: true, force: true }) + if (launcherConfigDir !== undefined) process.env.CLAUDE_CONFIG_DIR = launcherConfigDir +}) + +beforeEach(async () => { + const { resetClaudeEnvCacheForTests } = await import('./claude-env') + resetClaudeEnvCacheForTests() + delete process.env.CLAUDE_CONFIG_DIR +}) + +const BEGIN = '__HARNESS_ENV_BEGIN__' +const END = '__HARNESS_ENV_END__' + +describe('parseEnvProbeOutput', () => { + it('splits NUL-separated KEY=value pairs between the sentinels', async () => { + const { parseEnvProbeOutput } = await import('./claude-env') + const stdout = `${BEGIN}PATH=/usr/bin\0CLAUDE_CONFIG_DIR=/home/u/.claude-oss\0${END}` + expect(parseEnvProbeOutput(stdout)).toEqual({ + PATH: '/usr/bin', + CLAUDE_CONFIG_DIR: '/home/u/.claude-oss' + }) + }) + + it('keeps values containing = and newlines intact', async () => { + const { parseEnvProbeOutput } = await import('./claude-env') + const stdout = `${BEGIN}A=x=y\0B=line1\nline2\0${END}` + expect(parseEnvProbeOutput(stdout)).toEqual({ A: 'x=y', B: 'line1\nline2' }) + }) + + it('tolerates rc-file noise before and after the sentinels', async () => { + const { parseEnvProbeOutput } = await import('./claude-env') + const stdout = `starship init: ok\n${BEGIN}X=1\0${END}goodbye\n` + expect(parseEnvProbeOutput(stdout)).toEqual({ X: '1' }) + }) + + it('returns null when sentinels are missing', async () => { + const { parseEnvProbeOutput } = await import('./claude-env') + expect(parseEnvProbeOutput('no sentinels')).toBeNull() + expect(parseEnvProbeOutput(`${BEGIN}X=1\0`)).toBeNull() + expect(parseEnvProbeOutput('')).toBeNull() + }) +}) + +describe('captureShellEnv', () => { + it('runs the probe in the requested cwd and returns the env', async () => { + const { captureShellEnv } = await import('./claude-env') + const shell = fakeShell( + 'cwd-shell', + `printf '${BEGIN}'; printf 'PROBE_CWD=%s\\0CLAUDE_CONFIG_DIR=%s/.other\\0' "$(pwd -P)" "$HOME"; printf '${END}'` + ) + const env = await captureShellEnv(shell, tmpRoot, 2000) + expect(env?.PROBE_CWD).toBe(require('fs').realpathSync(tmpRoot)) + expect(env?.CLAUDE_CONFIG_DIR).toBe(join(homedir(), '.other')) + }) + + it('returns null when the shell prints no sentinels', async () => { + const { captureShellEnv } = await import('./claude-env') + const shell = fakeShell('silent-shell', 'exit 0') + expect(await captureShellEnv(shell, tmpRoot, 2000)).toBeNull() + }) + + it('returns null on timeout', async () => { + const { captureShellEnv } = await import('./claude-env') + const shell = fakeShell('hang-shell', 'sleep 30') + expect(await captureShellEnv(shell, tmpRoot, 200)).toBeNull() + }) + + it('returns null when the cwd does not exist', async () => { + const { captureShellEnv } = await import('./claude-env') + const shell = fakeShell('any-shell', `printf '${BEGIN}X=1\\0${END}'`) + expect(await captureShellEnv(shell, join(tmpRoot, 'missing'), 2000)).toBeNull() + }) +}) + +describe('claudeConfigDirFor', () => { + it('falls back to ~/.claude when nothing is cached and Ness has no override', async () => { + const { claudeConfigDirFor } = await import('./claude-env') + expect(claudeConfigDirFor('/some/worktree')).toBe(join(homedir(), '.claude')) + }) + + it('uses the captured CLAUDE_CONFIG_DIR for the cwd and its subdirectories', async () => { + const { claudeConfigDirFor, resolveShellEnv, knownClaudeConfigDirs } = await import('./claude-env') + const shell = fakeShell('oss-shell', `printf '${BEGIN}CLAUDE_CONFIG_DIR=/home/u/.claude-oss\\0${END}'`) + currentShell = shell + + await resolveShellEnv(tmpRoot) + expect(claudeConfigDirFor(tmpRoot)).toBe('/home/u/.claude-oss') + expect(claudeConfigDirFor(join(tmpRoot, 'sub', 'dir'))).toBe('/home/u/.claude-oss') + expect(claudeConfigDirFor(`${tmpRoot}-sibling`)).toBe(join(homedir(), '.claude')) + expect(knownClaudeConfigDirs()).toContain('/home/u/.claude-oss') + expect(knownClaudeConfigDirs()).toContain(join(homedir(), '.claude')) + }) + + it('ignores Ness’s own CLAUDE_CONFIG_DIR once the shell has answered for the cwd', async () => { + const { claudeConfigDirFor, resolveShellEnv } = await import('./claude-env') + const shell = fakeShell('plain-shell', `printf '${BEGIN}PATH=/usr/bin\\0${END}'`) + currentShell = shell + process.env.CLAUDE_CONFIG_DIR = '/launcher/home' + expect(claudeConfigDirFor(tmpRoot)).toBe('/launcher/home') + await resolveShellEnv(tmpRoot) + expect(claudeConfigDirFor(tmpRoot)).toBe(join(homedir(), '.claude')) + }) + + it('lets the settings override win over the shell', async () => { + const { claudeConfigDirFor, resolveShellEnv, setExplicitClaudeConfigDirSource } = await import( + './claude-env' + ) + const shell = fakeShell('oss-shell2', `printf '${BEGIN}CLAUDE_CONFIG_DIR=/home/u/.claude-oss\\0${END}'`) + currentShell = shell + await resolveShellEnv(tmpRoot) + setExplicitClaudeConfigDirSource(() => '/explicit/home') + expect(claudeConfigDirFor(tmpRoot)).toBe('/explicit/home') + }) +}) + +describe('resolveShellEnv caching', () => { + it('dedups concurrent probes and keeps the last good capture over a failed refresh', async () => { + const { resolveShellEnv, warmShellEnv, cachedShellEnv } = await import('./claude-env') + const marker = join(tmpRoot, 'fail-next') + const shell = fakeShell( + 'flaky-shell', + `if [ -e "${marker}" ]; then exit 0; fi; printf '${BEGIN}CLAUDE_CONFIG_DIR=/good\\0${END}'` + ) + currentShell = shell + const [a, b] = await Promise.all([resolveShellEnv(tmpRoot), resolveShellEnv(tmpRoot)]) + expect(a).toBe(b) + expect(cachedShellEnv(tmpRoot)?.CLAUDE_CONFIG_DIR).toBe('/good') + + writeFileSync(marker, '') + expect(await warmShellEnv(tmpRoot)).toEqual(a) + vi.useFakeTimers({ toFake: ['Date'] }) + try { + vi.setSystemTime(Date.now() + 120_000) + expect((await resolveShellEnv(tmpRoot))?.CLAUDE_CONFIG_DIR).toBe('/good') + } finally { + vi.useRealTimers() + } + }) + + it('retries a failed probe on the next resolve instead of serving the failure', async () => { + const { resolveShellEnv, warmShellEnv } = await import('./claude-env') + const marker = join(tmpRoot, 'broken-until-removed') + writeFileSync(marker, '') + const shell = fakeShell( + 'recovering-shell', + `if [ -e "${marker}" ]; then exit 0; fi; printf '${BEGIN}CLAUDE_CONFIG_DIR=/late\\0${END}'` + ) + currentShell = shell + expect(await warmShellEnv(tmpRoot)).toBeNull() + expect(await warmShellEnv(tmpRoot)).toBeNull() + rmSync(marker) + expect(await warmShellEnv(tmpRoot)).toBeNull() + expect((await resolveShellEnv(tmpRoot))?.CLAUDE_CONFIG_DIR).toBe('/late') + }) + + it('runs a few probes at a time and lets a resolve overtake queued warm-ups', async () => { + const { resolveShellEnv, warmShellEnv } = await import('./claude-env') + const slots = join(tmpRoot, 'slots') + const violation = join(tmpRoot, 'too-many-at-once') + mkdirSync(slots, { recursive: true }) + const shell = fakeShell( + 'slow-shell', + [ + `touch "${slots}/$$"`, + `if [ "$(ls "${slots}" | wc -l)" -gt 2 ]; then touch "${violation}"; fi`, + 'sleep 0.3', + `rm -f "${slots}/$$"`, + `printf '${BEGIN}CWD=%s\\0${END}' "$(pwd)"` + ].join('\n') + ) + currentShell = shell + const dirs = Array.from({ length: 7 }, (_, i) => join(tmpRoot, `wt${i}`)) + for (const d of dirs) mkdirSync(d, { recursive: true }) + + const done: string[] = [] + const warms = dirs.slice(0, 6).map((d) => warmShellEnv(d).then(() => done.push(d))) + const urgent = resolveShellEnv(dirs[6]).then(() => done.push(dirs[6])) + await Promise.all([...warms, urgent]) + + expect(existsSync(violation)).toBe(false) + // Two warm-ups are already running when the resolve lands, so it is + // the third to start; without priority it would be the last. + expect(done.indexOf(dirs[6])).toBeLessThanOrEqual(3) + }) +}) diff --git a/src/main/claude-env.ts b/src/main/claude-env.ts new file mode 100644 index 000000000..63ed29850 --- /dev/null +++ b/src/main/claude-env.ts @@ -0,0 +1,247 @@ +// The environment a Claude session sees depends on where it starts: +// folder-scoped tooling (mise, direnv, asdf) sets variables such as +// CLAUDE_CONFIG_DIR per directory tree, and only a login shell started in +// that directory produces them. Terminal tabs get this for free because +// they run through `zsh -ilc`. The bundled json-mode binary is spawned +// directly, and everything that reads Claude's home on Ness's behalf +// (session lookups for --resume, transcripts, hook install) runs in main +// with Ness's own env — so all of those need the shell's answer for the +// worktree in question, which is what this module captures and caches. +// +// A probe costs one full login-shell startup (~1s with a heavy rc file), +// so it never runs on the hot path: spawn paths await `resolveShellEnv`, +// which only re-probes when the entry is older than FRESH_MS, and sync +// readers consult the cache without side effects. Probes also never run +// all at once: the boot warm-up covers every worktree, and fifty login +// shells starting together starve each other past any timeout, which +// would cache a failure for the very spawn that needed the answer. + +import { spawn } from 'child_process' +import { homedir } from 'os' +import { join } from 'path' +import { resolveUserShell } from './user-shell' +import { log } from './debug' + +const BEGIN = '__HARNESS_ENV_BEGIN__' +const END = '__HARNESS_ENV_END__' +const PROBE = `printf '${BEGIN}'; /usr/bin/env -0; printf '${END}'` +const DEFAULT_TIMEOUT_MS = 10_000 +const FRESH_MS = 60_000 +const MAX_CONCURRENT_PROBES = 2 + +export type ShellEnv = Record + +interface CacheEntry { + env: ShellEnv | null + capturedAt: number +} + +const cache = new Map() +const inflight = new Map>() +const queue: Array<{ cwd: string; start: () => void }> = [] +let running = 0 +let explicitConfigDir: () => string | undefined = () => undefined + +export function parseEnvProbeOutput(stdout: string): ShellEnv | null { + const beginIdx = stdout.indexOf(BEGIN) + if (beginIdx < 0) return null + const start = beginIdx + BEGIN.length + const endIdx = stdout.indexOf(END, start) + if (endIdx < 0) return null + const env: ShellEnv = {} + for (const entry of stdout.slice(start, endIdx).split('\0')) { + const eq = entry.indexOf('=') + if (eq <= 0) continue + env[entry.slice(0, eq)] = entry.slice(eq + 1) + } + return env +} + +export async function captureShellEnv( + shellPath: string, + cwd: string, + timeoutMs: number = DEFAULT_TIMEOUT_MS +): Promise { + return new Promise((resolve) => { + let stdout = '' + let settled = false + const finish = (value: ShellEnv | null): void => { + if (settled) return + settled = true + resolve(value) + } + + let child + try { + child = spawn(shellPath, ['-ilc', PROBE], { + cwd, + stdio: ['ignore', 'pipe', 'ignore'] + }) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + log('claude-env', `spawn failed (shell=${shellPath} cwd=${cwd}): ${msg}`) + finish(null) + return + } + + const timer = setTimeout(() => { + log('claude-env', `probe timed out after ${timeoutMs}ms (cwd=${cwd})`) + try { + child.kill('SIGKILL') + } catch { + // already dead + } + finish(null) + }, timeoutMs) + + try { + child.stdout?.setEncoding('utf8') + child.stdout?.on('data', (chunk: string) => { + stdout += chunk + }) + child.on('error', (err) => { + clearTimeout(timer) + log('claude-env', `spawn error (shell=${shellPath} cwd=${cwd}): ${err.message}`) + finish(null) + }) + child.on('close', () => { + clearTimeout(timer) + const captured = parseEnvProbeOutput(stdout) + if (captured == null) { + log('claude-env', `probe output missing sentinels (cwd=${cwd})`) + } + finish(captured) + }) + } catch (err) { + clearTimeout(timer) + const msg = err instanceof Error ? err.message : String(err) + log('claude-env', `probe wiring failed (cwd=${cwd}): ${msg}`) + finish(null) + } + }) +} + +function pump(): void { + while (running < MAX_CONCURRENT_PROBES && queue.length > 0) { + running++ + queue.shift()!.start() + } +} + +/** Queue a probe for `cwd`. `urgent` puts it ahead of queued warm-ups — + * a spawn waiting on its answer should not sit behind the boot sweep. */ +function refresh(cwd: string, urgent: boolean): Promise { + const pending = inflight.get(cwd) + if (pending) { + if (urgent) { + const idx = queue.findIndex((q) => q.cwd === cwd) + if (idx > 0) queue.unshift(...queue.splice(idx, 1)) + } + return pending + } + const probe = new Promise((resolve) => { + const start = (): void => { + captureShellEnv(resolveUserShell(), cwd) + .then(resolve) + .finally(() => { + running-- + pump() + }) + } + if (urgent) queue.unshift({ cwd, start }) + else queue.push({ cwd, start }) + pump() + }) + .then((env) => { + // A failed probe must not erase a good earlier capture — a transient + // timeout would otherwise flip the worktree back to the default home. + const kept = env ?? cache.get(cwd)?.env ?? null + cache.set(cwd, { env: kept, capturedAt: Date.now() }) + if (env?.CLAUDE_CONFIG_DIR) { + log('claude-env', `cwd=${cwd} CLAUDE_CONFIG_DIR=${env.CLAUDE_CONFIG_DIR}`) + } + return kept + }) + .finally(() => { + inflight.delete(cwd) + }) + inflight.set(cwd, probe) + return probe +} + +/** The login-shell environment for `cwd`, re-probed when the cached copy + * is older than FRESH_MS so env changes made while Ness runs are picked + * up by the next spawn. A cached failure is never an answer: it is + * retried on every call. Null when the probe failed and nothing better + * is cached. */ +export function resolveShellEnv(cwd: string): Promise { + const hit = cache.get(cwd) + if (hit?.env && Date.now() - hit.capturedAt < FRESH_MS) return Promise.resolve(hit.env) + return refresh(cwd, true) +} + +/** Probe `cwd` only if it has never been probed. Background use only; + * a spawn that needs the answer goes through `resolveShellEnv`. */ +export function warmShellEnv(cwd: string): Promise { + const hit = cache.get(cwd) + if (hit) return Promise.resolve(hit.env) + return refresh(cwd, false) +} + +/** Last capture for `cwd` or its nearest cached ancestor (tabs can run in + * a subdirectory of their worktree). Pure cache read — never probes. */ +export function cachedShellEnv(cwd: string): ShellEnv | null { + const exact = cache.get(cwd) + if (exact) return exact.env + let best: { path: string; env: ShellEnv | null } | null = null + for (const [path, entry] of cache) { + if (!cwd.startsWith(path.endsWith('/') ? path : `${path}/`)) continue + if (!best || path.length > best.path.length) best = { path, env: entry.env } + } + return best?.env ?? null +} + +/** Settings-level override (the user's `claudeEnvVars` map) beats anything + * the shell reports, matching the precedence both spawn paths apply. */ +export function setExplicitClaudeConfigDirSource(source: () => string | undefined): void { + explicitConfigDir = source +} + +export function defaultClaudeConfigDir(): string { + return join(homedir(), '.claude') +} + +/** Claude's home for sessions started in `cwd`. Falls back to Ness's own + * env, then ~/.claude, when nothing has been captured for the path. */ +export function claudeConfigDirFor(cwd: string): string { + const explicit = explicitConfigDir() + if (explicit) return explicit + const env = cachedShellEnv(cwd) ?? process.env + return env.CLAUDE_CONFIG_DIR || defaultClaudeConfigDir() +} + +export function claudeProjectsDirFor(cwd: string): string { + return join(claudeConfigDirFor(cwd), 'projects') +} + +/** Every Claude home Ness has seen a worktree resolve to, plus the + * defaults. Used where there is no single cwd to key off (cost roll-ups, + * hook install). */ +export function knownClaudeConfigDirs(): string[] { + const dirs = new Set([defaultClaudeConfigDir()]) + if (process.env.CLAUDE_CONFIG_DIR) dirs.add(process.env.CLAUDE_CONFIG_DIR) + const explicit = explicitConfigDir() + if (explicit) dirs.add(explicit) + for (const entry of cache.values()) { + if (entry.env?.CLAUDE_CONFIG_DIR) dirs.add(entry.env.CLAUDE_CONFIG_DIR) + } + return [...dirs] +} + +export function resetClaudeEnvCacheForTests(): void { + cache.clear() + inflight.clear() + queue.length = 0 + running = 0 + explicitConfigDir = () => undefined +} diff --git a/src/main/context-tracker.ts b/src/main/context-tracker.ts index 7a8bb7095..30af86505 100644 --- a/src/main/context-tracker.ts +++ b/src/main/context-tracker.ts @@ -21,7 +21,7 @@ // panel being open and measures 18-140ms on 9-50MB transcripts. import { existsSync, readFileSync, statSync } from 'fs' -import { homedir } from 'os' +import { claudeConfigDirFor, claudeProjectsDirFor } from './claude-env' import { join } from 'path' import type { Store } from './store' import type { StateEvent } from '../shared/state' @@ -40,9 +40,7 @@ import { log } from './debug' /** Same path encoding claude uses for its own session files. */ function transcriptPathFor(worktreePath: string, sessionId: string): string { return join( - homedir(), - '.claude', - 'projects', + claudeProjectsDirFor(worktreePath), worktreePath.replace(/[^a-zA-Z0-9]/g, '-'), `${sessionId}.jsonl` ) @@ -57,7 +55,7 @@ function memoryCharsFor(worktreePath: string): number { for (const p of [ join(worktreePath, 'CLAUDE.md'), join(worktreePath, '.claude', 'CLAUDE.md'), - join(homedir(), '.claude', 'CLAUDE.md') + join(claudeConfigDirFor(worktreePath), 'CLAUDE.md') ]) { try { if (existsSync(p)) total += statSync(p).size diff --git a/src/main/cost-aggregator.ts b/src/main/cost-aggregator.ts index e75a14f31..3c2e170db 100644 --- a/src/main/cost-aggregator.ts +++ b/src/main/cost-aggregator.ts @@ -16,7 +16,7 @@ // per-session breakdown attribution matches the live CostPanel exactly. import { readdir, stat, readFile } from 'fs/promises' -import { homedir } from 'os' +import { knownClaudeConfigDirs } from './claude-env' import { join } from 'path' import { newFoldState, @@ -48,8 +48,20 @@ export function clearCostAggregatorCache(): void { export async function getAllSessionCosts( opts: { sinceMs?: number; projectsDir?: string } = {} ): Promise { - const projectsDir = - opts.projectsDir ?? join(homedir(), '.claude', 'projects') + const projectsDirs = opts.projectsDir + ? [opts.projectsDir] + : knownClaudeConfigDirs().map((dir) => join(dir, 'projects')) + const out: SessionCostSummary[] = [] + for (const projectsDir of projectsDirs) { + out.push(...(await collectSessionCosts(projectsDir, opts.sinceMs))) + } + return out +} + +async function collectSessionCosts( + projectsDir: string, + sinceMs: number | undefined +): Promise { let dirs: string[] try { dirs = await readdir(projectsDir) @@ -87,7 +99,7 @@ export async function getAllSessionCosts( if (!fileStat.isFile()) continue const mtimeMs = fileStat.mtimeMs - if (opts.sinceMs != null && mtimeMs < opts.sinceMs) continue + if (sinceMs != null && mtimeMs < sinceMs) continue const cached = cache.get(filePath) let summary: SessionCostSummary | null @@ -107,7 +119,7 @@ export async function getAllSessionCosts( } if (!summary || summary.turns === 0) continue - if (opts.sinceMs != null && summary.lastAt < opts.sinceMs) continue + if (sinceMs != null && summary.lastAt < sinceMs) continue out.push(summary) } } diff --git a/src/main/cost-tracker.ts b/src/main/cost-tracker.ts index 01f87632f..122d7fc73 100644 --- a/src/main/cost-tracker.ts +++ b/src/main/cost-tracker.ts @@ -31,7 +31,7 @@ // outputs keep costing money as long as they sit in the context. import { readFileSync } from 'fs' -import { homedir } from 'os' +import { claudeProjectsDirFor } from './claude-env' import { join } from 'path' import type { Store } from './store' import type { StateEvent } from '../shared/state' @@ -246,9 +246,7 @@ export class CostTracker { function jsonClaudeTranscriptPath(worktreePath: string, sessionId: string): string { return join( - homedir(), - '.claude', - 'projects', + claudeProjectsDirFor(worktreePath), worktreePath.replace(/[^a-zA-Z0-9]/g, '-'), `${sessionId}.jsonl` ) diff --git a/src/main/index.ts b/src/main/index.ts index dbdd0d0ca..dcfd54414 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -30,6 +30,12 @@ import type { Server as HttpServer } from 'http' import type { ServerTransport } from '../shared/transport/transport' import { detectRuntime } from './paths' import { fixPathFromLoginShell } from './path-fix' +import { + knownClaudeConfigDirs, + resolveShellEnv, + setExplicitClaudeConfigDirSource, + warmShellEnv +} from './claude-env' import { parseCliFlags, USAGE, type CliFlags } from './cli-args' import { PlaywrightBrowserManager } from './browser-manager-playwright' import type { BrowserManagerLike } from './browser-manager-types' @@ -407,6 +413,9 @@ if (configLoadError && runtime !== 'electron') { const store = new Store( buildInitialAppState(config, { hasGithubToken: hasSecret('githubToken'), configLoadError }) ) +setExplicitClaudeConfigDirSource( + () => store.getSnapshot().state.settings.claudeEnvVars?.CLAUDE_CONFIG_DIR || undefined +) // Scan for user-authored themes once the store exists. Done as a // dispatch (rather than seeding into buildInitialAppState) so the @@ -997,6 +1006,7 @@ const panesFSM = new PanesFSM(store, { }, getLatestClaudeSessionId: async (wtPath) => { const kind = store.getSnapshot().state.settings.defaultAgent ?? 'claude' + if (kind === 'claude') await resolveShellEnv(wtPath) return getAgent(kind).latestSessionId(wtPath) }, getDefaultAgentKind: () => toAgentKind(store.getSnapshot().state.settings.defaultAgent), @@ -1010,15 +1020,16 @@ const panesFSM = new PanesFSM(store, { // could disconnect without intending to kill agents). Tab-close / // restart / clear events are the actual lifecycle boundary. killTabPty: (tabId) => ptyManager.kill(tabId), - killJsonClaude: (sessionId) => jsonClaudeManager.kill(sessionId), + killJsonClaude: (sessionId) => killJsonClaudeSession(sessionId), clearJsonClaudeSession: (sessionId) => store.dispatch({ type: 'jsonClaude/sessionCleared', payload: { sessionId } }), startJsonClaudeWithPrompt: (sessionId, worktreePath, initialPrompt) => { - startJsonClaudeSession(sessionId, worktreePath) - if (initialPrompt) jsonClaudeManager.send(sessionId, initialPrompt) + void startJsonClaudeSession(sessionId, worktreePath).then((running) => { + if (running && initialPrompt) jsonClaudeManager.send(sessionId, initialPrompt) + }) }, startJsonClaude: (sessionId, worktreePath) => { - startJsonClaudeSession(sessionId, worktreePath) + void startJsonClaudeSession(sessionId, worktreePath) } }) @@ -1040,28 +1051,62 @@ function findJsonClaudeTabModel(sessionId: string): string | undefined { return undefined } +const startingJsonClaude = new Map }>() + /** Single source of truth for "spin up the json-claude subprocess for * this sessionId". Used by the jsonClaude:start IPC handler, the * panesFSM's startJsonClaudeWithPrompt + startJsonClaude options * (kickoff + wake), so all paths produce the same dispatch + seed + * create order. Idempotent — JsonClaudeManager.create() short-circuits - * if the instance is already running. */ -function startJsonClaudeSession(sessionId: string, worktreePath: string): void { - if (jsonClaudeManager.hasSession(sessionId)) return - store.dispatch({ - type: 'jsonClaude/sessionStarted', - payload: { - sessionId, - worktreePath, - defaultPermissionMode: - store.getSnapshot().state.settings.jsonModeDefaultPermissionMode + * if the instance is already running, and a second call for an id whose + * start is still waiting on the worktree's shell env joins that start + * rather than racing it. Resolves true once the session is running, + * whichever caller created it: the kickoff prompt must land even when + * the renderer's own start beat the one carrying the prompt. */ +function startJsonClaudeSession(sessionId: string, worktreePath: string): Promise { + if (jsonClaudeManager.hasSession(sessionId)) return Promise.resolve(true) + const pending = startingJsonClaude.get(sessionId) + if (pending) return pending.done + const token = { cancelled: false, done: Promise.resolve(false) } + token.done = (async () => { + // Both the --resume decision and the subprocess env key off the Claude + // home the worktree's shell reports; resolve it before touching either. + try { + await resolveShellEnv(worktreePath) + } finally { + if (startingJsonClaude.get(sessionId) === token) startingJsonClaude.delete(sessionId) } - }) - jsonClaudeManager.seedFromTranscript(sessionId, worktreePath) - const permMode = - store.getSnapshot().state.jsonClaude.sessions[sessionId]?.permissionMode || - 'default' - jsonClaudeManager.create(sessionId, worktreePath, permMode, findJsonClaudeTabModel(sessionId)) + if (token.cancelled) return false + if (jsonClaudeManager.hasSession(sessionId)) return true + store.dispatch({ + type: 'jsonClaude/sessionStarted', + payload: { + sessionId, + worktreePath, + defaultPermissionMode: + store.getSnapshot().state.settings.jsonModeDefaultPermissionMode + } + }) + jsonClaudeManager.seedFromTranscript(sessionId, worktreePath) + const permMode = + store.getSnapshot().state.jsonClaude.sessions[sessionId]?.permissionMode || + 'default' + jsonClaudeManager.create(sessionId, worktreePath, permMode, findJsonClaudeTabModel(sessionId)) + return true + })() + startingJsonClaude.set(sessionId, token) + return token.done +} + +/** Kill a chat subprocess, or a start for it that is still waiting on the + * shell-env probe — closing a tab in that window must not leave an orphan. */ +function killJsonClaudeSession(sessionId: string): void { + const pending = startingJsonClaude.get(sessionId) + if (pending) { + pending.cancelled = true + startingJsonClaude.delete(sessionId) + } + jsonClaudeManager.kill(sessionId) } /** Resolve what the new worktree's first agent tab should start from, @@ -1077,6 +1122,7 @@ async function resolveForkedKickoff(args: { const { createdPath, initialPrompt, forkSource, baseRef } = args if (!forkSource) return { initialPrompt } + await Promise.all([resolveShellEnv(forkSource.worktreePath), resolveShellEnv(createdPath)]) const outcome = forkTranscript({ sourceSessionId: forkSource.sessionId, sourceWorktreePath: forkSource.worktreePath, @@ -1225,6 +1271,29 @@ const autoSleepMonitor = new AutoSleepMonitor(store, panesFSM) // subscribes to the store, never owns slice state. const wakeLockController = new WakeLockController(store) +// Folder-scoped env tooling (mise, direnv) can point a worktree at a +// different Claude home. Probe each worktree's login shell once, so the +// synchronous readers (transcript lookups, context + cost trackers) know +// the home before the first tab spawns, and hook any home that is new. +const hookedClaudeHomes = new Set() + +function hookNewClaudeHomes(): void { + if (store.getSnapshot().state.hooks.consent !== 'accepted') return + const dirs = knownClaudeConfigDirs() + if (dirs.every((dir) => hookedClaudeHomes.has(dir))) return + getAgent('claude').installHooks() + for (const dir of dirs) hookedClaudeHomes.add(dir) +} + +store.subscribe((event) => { + if (event.type !== 'worktrees/listChanged') return + // Sweeping the whole list is fine here: warmShellEnv is a cache hit for + // every path already probed, and listChanged is infrequent. + for (const wt of store.getSnapshot().state.worktrees.list) { + void warmShellEnv(wt.path).then(hookNewClaudeHomes) + } +}) + /** Install agent status hooks at the user-scope settings file for both * supported agents. Called once when consent flips to 'accepted'. The * hook command is env-gated on $HARNESS_TERMINAL_ID, so it no-ops for @@ -1236,9 +1305,11 @@ function installHooksGlobally(): void { for (const agent of [getAgent('claude'), getAgent('codex'), getAgent('cursor')]) { agent.installHooks() } + for (const dir of knownClaudeConfigDirs()) hookedClaudeHomes.add(dir) } function uninstallHooksGlobally(): void { + hookedClaudeHomes.clear() for (const agent of [getAgent('claude'), getAgent('codex'), getAgent('cursor')]) { agent.uninstallHooks() } @@ -3396,26 +3467,31 @@ function registerIpcHandlers(): void { return true }) - transport.onRequest('agent:sessionFileExists', (_ctx, cwd: string, sessionId: string, agentKind?: string): boolean => { + transport.onRequest('agent:sessionFileExists', async (_ctx, cwd: string, sessionId: string, agentKind?: string): Promise => { const kind = toAgentKind(agentKind) + if (kind === 'claude') await resolveShellEnv(cwd) return getAgent(kind).sessionFileExists(cwd, sessionId) }) - transport.onRequest('agent:latestSessionId', (_ctx, cwd: string, agentKind?: string): string | null => { + transport.onRequest('agent:latestSessionId', async (_ctx, cwd: string, agentKind?: string): Promise => { const kind = toAgentKind(agentKind) + if (kind === 'claude') await resolveShellEnv(cwd) return getAgent(kind).latestSessionId(cwd) }) transport.onRequest( 'agent:buildSpawnArgs', - (_ctx, agentKind: string, opts: { + async (_ctx, agentKind: string, opts: { terminalId: string; cwd: string; sessionId?: string; initialPrompt?: string; teleportSessionId?: string; sessionName?: string; modelOverride?: string - }): string => { + }): Promise => { const kind = toAgentKind(agentKind) const agent = getAgent(kind) + // --resume vs --session-id is decided by looking for the transcript + // under the worktree's Claude home, which only the shell can tell us. + if (kind === 'claude') await resolveShellEnv(opts.cwd) const command = kind === 'claude' ? (config.claudeCommand || agent.defaultCommand) @@ -3811,7 +3887,7 @@ function registerIpcHandlers(): void { // both JsonModeChats mount and both fire startJsonClaude. // startJsonClaudeSession's hasSession() short-circuit covers that — // no double-dispatch of sessionStarted, no double-spawn. - startJsonClaudeSession(sessionId, cwd) + void startJsonClaudeSession(sessionId, cwd) return true }) transport.onSignal( @@ -3832,7 +3908,7 @@ function registerIpcHandlers(): void { } ) transport.onRequest('jsonClaude:kill', (_ctx, sessionId: string) => { - jsonClaudeManager.kill(sessionId) + killJsonClaudeSession(sessionId) return true }) transport.onRequest('jsonClaude:interrupt', (_ctx, sessionId: string) => { @@ -4081,8 +4157,8 @@ function registerIpcHandlers(): void { // startJsonClaudeSession is idempotent + reads the new tab.model // through findJsonClaudeTabModel, so the fresh spawn gets // --model --resume . - jsonClaudeManager.kill(sessionId) - startJsonClaudeSession(sessionId, worktreePath) + killJsonClaudeSession(sessionId) + void startJsonClaudeSession(sessionId, worktreePath) return { ok: true } } ) diff --git a/src/main/json-claude-manager.ts b/src/main/json-claude-manager.ts index aec0b1427..1507af5c7 100644 --- a/src/main/json-claude-manager.ts +++ b/src/main/json-claude-manager.ts @@ -9,18 +9,19 @@ // MCP server is the bundled out/main/permission-prompt-mcp.js (see // ApprovalBridge for the socket side of that). // -// Memory isolation: Claude Code's auto-memory subsystem writes to -// ~/.claude/projects//memory/ by default. Running this as a -// child process would pollute the user's personal memory dir with -// conversations they didn't have in their "real" Claude Code sessions. -// We redirect that by setting CLAUDE_CONFIG_DIR to a per-session temp -// dir so all project-dir-derived reads and writes land there instead. +// Environment: the bundled binary is spawned without a login shell, so +// the subprocess env is the one captured from the worktree's shell +// (claude-env.ts) rather than Ness's own — that is what makes a +// folder-scoped CLAUDE_CONFIG_DIR (mise, direnv) apply to chat tabs the +// same way it applies to terminal tabs. Transcript paths resolve +// through the same capture. Auto-memory is disabled per session via +// CLAUDE_CODE_DISABLE_AUTO_MEMORY so chat tabs don't scribble into the +// user's project memory. import { spawn, type ChildProcessWithoutNullStreams } from 'child_process' import { randomUUID } from 'crypto' import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs' import { createRequire } from 'module' -import { homedir } from 'os' import { dirname, join, sep } from 'path' import { isPackaged, resolveBundledMcpScript } from './paths' import type { Store } from './store' @@ -37,6 +38,7 @@ import type { ClaudeLaunchSettings } from './claude-launch' import { log } from './debug' import { shellQuote } from './shell-quote' import { resolveUserShell, loginShellCommandArgs } from './user-shell' +import { cachedShellEnv, claudeProjectsDirFor } from './claude-env' interface JsonClaudeInstance { proc: ChildProcessWithoutNullStreams @@ -184,9 +186,7 @@ export function bundledClaudeBinPath(): string { function transcriptPathFor(sessionId: string, worktreePath: string): string { return join( - homedir(), - '.claude', - 'projects', + claudeProjectsDirFor(worktreePath), worktreePath.replace(/[^a-zA-Z0-9]/g, '-'), `${sessionId}.jsonl` ) @@ -686,12 +686,15 @@ export class JsonClaudeManager { ) const envVars = this.opts.getClaudeEnvVars() || {} - // Build env for the subprocess. We start from process.env, scrub - // env vars that the user-scope Ness hooks key off of so the - // subprocess's hook firings don't masquerade as the parent agent - // tab, then layer on json-claude-specific vars. + // Build env for the subprocess. The bundled binary is spawned without + // a login shell, so start from the env a shell would produce in this + // worktree (folder-scoped CLAUDE_CONFIG_DIR, mise/direnv PATH, …) the + // way a terminal tab's `zsh -ilc` does — falling back to process.env + // when no capture exists yet. Scrub env vars that the user-scope Ness + // hooks key off of so the subprocess's hook firings don't masquerade + // as the parent agent tab, then layer on json-claude-specific vars. const childEnv: Record = {} - for (const [k, v] of Object.entries(process.env)) { + for (const [k, v] of Object.entries(cachedShellEnv(worktreePath) ?? process.env)) { if (typeof v === 'string') childEnv[k] = v } delete childEnv.CLAUDE_HARNESS_ID diff --git a/vitest.config.ts b/vitest.config.ts index bc7aa83bd..361851b39 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,6 +2,7 @@ import {defineConfig, defaultExclude} from 'vitest/config' export default defineConfig({ test: { + setupFiles: ['./vitest.setup.ts'], // `tsc -b` emits compiled copies of every *.test.ts into out/, so without // this vitest runs each test twice — once from source, once from stale build output. exclude: [...defaultExclude, 'out/**', 'dist-headless/**', 'release/**'] diff --git a/vitest.setup.ts b/vitest.setup.ts new file mode 100644 index 000000000..e07fb1c44 --- /dev/null +++ b/vitest.setup.ts @@ -0,0 +1,4 @@ +// Claude-home lookups fall back to the environment Ness was launched +// with, so a developer whose shell points CLAUDE_CONFIG_DIR elsewhere +// would otherwise see every ~/.claude-based fixture miss. +delete process.env.CLAUDE_CONFIG_DIR