import { mkdir, readFile, unlink, writeFile } from 'fs/promises' import { join } from 'path' import { getSessionId } from '../../bootstrap/state.js' import { registerCleanup } from '../../utils/cleanupRegistry.js' import { logForDebugging } from '../../utils/debug.js' import { getClaudeConfigHomeDir } from '../../utils/envUtils.js' import { jsonParse, jsonStringify } from '../../utils/slowOperations.js' import { getErrnoCode } from '../errors.js' const LOCK_FILENAME = 'computer-use.lock' // Holds the unregister function for the shutdown cleanup handler. // Set when the lock is acquired, cleared when released. let unregisterCleanup: (() => void) | undefined type ComputerUseLock = { readonly sessionId: string readonly pid: number readonly acquiredAt: number } export type AcquireResult = | { readonly kind: 'acquired'; readonly fresh: boolean } | { readonly kind: 'blocked'; readonly by: string } export type CheckResult = | { readonly kind: 'free' } | { readonly kind: 'held_by_self' } | { readonly kind: 'blocked'; readonly by: string } const FRESH: AcquireResult = { kind: 'acquired', fresh: true } const REENTRANT: AcquireResult = { kind: 'acquired', fresh: false } function isComputerUseLock(value: unknown): value is ComputerUseLock { if (typeof value !== 'object' || value === null) return false return ( 'sessionId' in value && typeof value.sessionId === 'string' && 'pid' in value && typeof value.pid === 'number' ) } function getLockPath(): string { return join(getClaudeConfigHomeDir(), LOCK_FILENAME) } async function readLock(): Promise { try { const raw = await readFile(getLockPath(), 'utf8') const parsed: unknown = jsonParse(raw) return isComputerUseLock(parsed) ? parsed : undefined } catch { return undefined } } /** * Check whether a process is still running (signal 0 probe). * * Note: there is a small window for PID reuse — if the owning process * exits and an unrelated process is assigned the same PID, the check * will return true. This is extremely unlikely in practice. */ function isProcessRunning(pid: number): boolean { try { process.kill(pid, 0) return true } catch { return false } } /** * Attempt to create the lock file atomically with O_EXCL. * Returns true on success, false if the file already exists. * Throws for other errors. */ async function tryCreateExclusive(lock: ComputerUseLock): Promise { try { await writeFile(getLockPath(), jsonStringify(lock), { flag: 'wx' }) return true } catch (e: unknown) { if (getErrnoCode(e) === 'EEXIST') return false throw e } } /** * Register a shutdown cleanup handler so the lock is released even if * turn-end cleanup is never reached (e.g. the user runs /exit while * a tool call is in progress). */ function registerLockCleanup(): void { unregisterCleanup?.() unregisterCleanup = registerCleanup(async () => { await releaseComputerUseLock() }) } /** * Check lock state without acquiring. Used for `request_access` / * `list_granted_applications` — the package's `defersLockAcquire` contract: * these tools check but don't take the lock, so the enter-notification and * overlay don't fire while the model is only asking for permission. * * Does stale-PID recovery (unlinks) so a dead session's lock doesn't block * `request_access`. Does NOT create — that's `tryAcquireComputerUseLock`'s job. */ export async function checkComputerUseLock(): Promise { const existing = await readLock() if (!existing) return { kind: 'free' } if (existing.sessionId === getSessionId()) return { kind: 'held_by_self' } if (isProcessRunning(existing.pid)) { return { kind: 'blocked', by: existing.sessionId } } logForDebugging( `Recovering stale computer-use lock from session ${existing.sessionId} (PID ${existing.pid})`, ) await unlink(getLockPath()).catch(() => {}) return { kind: 'free' } } /** * Zero-syscall check: does THIS process believe it holds the lock? * True iff `tryAcquireComputerUseLock` succeeded and `releaseComputerUseLock` * hasn't run yet. Used to gate the per-turn release in `cleanup.ts` so * non-CU turns don't touch disk. */ export function isLockHeldLocally(): boolean { return unregisterCleanup !== undefined } /** * Try to acquire the computer-use lock for the current session. * * `{kind: 'acquired', fresh: true}` — first tool call of a CU turn. Callers fire * enter notifications on this. `{kind: 'acquired', fresh: false}` — re-entrant, * same session already holds it. `{kind: 'blocked', by}` — another live session * holds it. * * Uses O_EXCL (open 'wx') for atomic test-and-set — the OS guarantees at * most one process sees the create succeed. If the file already exists, * we check ownership and PID liveness; for a stale lock we unlink and * retry the exclusive create once. If two sessions race to recover the * same stale lock, only one create succeeds (the other reads the winner). */ export async function tryAcquireComputerUseLock(): Promise { const sessionId = getSessionId() const lock: ComputerUseLock = { sessionId, pid: process.pid, acquiredAt: Date.now(), } await mkdir(getClaudeConfigHomeDir(), { recursive: true }) // Fresh acquisition. if (await tryCreateExclusive(lock)) { registerLockCleanup() return FRESH } const existing = await readLock() // Corrupt/unparseable — treat as stale (can't extract a blocking ID). if (!existing) { await unlink(getLockPath()).catch(() => {}) if (await tryCreateExclusive(lock)) { registerLockCleanup() return FRESH } return { kind: 'blocked', by: (await readLock())?.sessionId ?? 'unknown' } } // Already held by this session. if (existing.sessionId === sessionId) return REENTRANT // Another live session holds it — blocked. if (isProcessRunning(existing.pid)) { return { kind: 'blocked', by: existing.sessionId } } // Stale lock — recover. Unlink then retry the exclusive create. // If another session is also recovering, one EEXISTs and reads the winner. logForDebugging( `Recovering stale computer-use lock from session ${existing.sessionId} (PID ${existing.pid})`, ) await unlink(getLockPath()).catch(() => {}) if (await tryCreateExclusive(lock)) { registerLockCleanup() return FRESH } return { kind: 'blocked', by: (await readLock())?.sessionId ?? 'unknown' } } /** * Release the computer-use lock if the current session owns it. Returns * `true` if we actually unlinked the file (i.e., we held it) — callers fire * exit notifications on this. Idempotent: subsequent calls return `false`. */ export async function releaseComputerUseLock(): Promise { unregisterCleanup?.() unregisterCleanup = undefined const existing = await readLock() if (!existing || existing.sessionId !== getSessionId()) return false try { await unlink(getLockPath()) logForDebugging('Released computer-use lock') return true } catch { return false } }