π File detail
utils/computerUse/computerUseLock.ts
π― Use case
This file lives under βutils/β, which covers cross-cutting helpers (shell, tempfiles, settings, messages, process input, β¦). On the API surface it exposes AcquireResult, CheckResult, checkComputerUseLock, isLockHeldLocally, and tryAcquireComputerUseLock (and more) β mainly functions, hooks, or classes. Dependencies touch Node filesystem and Node path helpers. It composes internal code from bootstrap, utils, and errors (relative imports).
Generated from folder role, exports, dependency roots, and inline comments β not hand-reviewed for every path.
π§ Inline summary
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'
π€ Exports (heuristic)
AcquireResultCheckResultcheckComputerUseLockisLockHeldLocallytryAcquireComputerUseLockreleaseComputerUseLock
π External import roots
Package roots from from "β¦" (relative paths omitted).
fspath
π₯οΈ Source preview
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<ComputerUseLock | undefined> {
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<boolean> {
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<CheckResult> {
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<AcquireResult> {
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<boolean> {
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
}
}