Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
30 changes: 22 additions & 8 deletions src/main/agents/claude.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)) {
Expand All @@ -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 = {}
Expand All @@ -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
Expand Down Expand Up @@ -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
}
Expand All @@ -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
Expand Down
3 changes: 2 additions & 1 deletion src/main/agents/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
211 changes: 211 additions & 0 deletions src/main/claude-env.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
Loading
Loading