mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-25 09:35:52 +00:00
ToolCallLocation.path and Diff.path emitted over ACP must be absolute and POSIX-style per the v1 spec (tool-calls.mdx:304-306). The previous code used platform-specific `node:path` which on Windows prepends the drive letter (e.g. "D:\...") to POSIX-style inputs like "/Users/test/project" — silently corrupting paths emitted to ACP clients on Windows hosts. CI runs on Linux so the latent issue went undetected. Switch to `node:path/posix` so the path normalisation is host-OS independent. Co-Authored-By: Claude <noreply@anthropic.com>
207 lines
6.5 KiB
TypeScript
207 lines
6.5 KiB
TypeScript
/**
|
|
* Shared utilities for the ACP service.
|
|
* Ported from claude-agent-acp-main/src/utils.ts and acp-agent.ts helpers.
|
|
*/
|
|
import { Writable } from 'node:stream'
|
|
import type { PermissionMode } from '../../entrypoints/sdk/coreTypes.generated.js'
|
|
|
|
// ── Pushable ──────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* A pushable async iterable: allows you to push items and consume them
|
|
* with for-await. Useful for bridging push-based and async-iterator-based code.
|
|
*/
|
|
export class Pushable<T> implements AsyncIterable<T> {
|
|
private queue: T[] = []
|
|
private resolvers: ((value: IteratorResult<T>) => void)[] = []
|
|
private done = false
|
|
|
|
push(item: T) {
|
|
if (this.resolvers.length > 0) {
|
|
const resolve = this.resolvers.shift()!
|
|
resolve({ value: item, done: false })
|
|
} else {
|
|
this.queue.push(item)
|
|
}
|
|
}
|
|
|
|
end() {
|
|
this.done = true
|
|
while (this.resolvers.length > 0) {
|
|
const resolve = this.resolvers.shift()!
|
|
resolve({ value: undefined as unknown as T, done: true })
|
|
}
|
|
}
|
|
|
|
[Symbol.asyncIterator](): AsyncIterator<T> {
|
|
return {
|
|
next: (): Promise<IteratorResult<T>> => {
|
|
if (this.queue.length > 0) {
|
|
const value = this.queue.shift()!
|
|
return Promise.resolve({ value, done: false })
|
|
}
|
|
if (this.done) {
|
|
return Promise.resolve({
|
|
value: undefined as unknown as T,
|
|
done: true,
|
|
})
|
|
}
|
|
return new Promise<IteratorResult<T>>(resolve => {
|
|
this.resolvers.push(resolve)
|
|
})
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Stream helpers ────────────────────────────────────────────────
|
|
|
|
export function nodeToWebWritable(
|
|
nodeStream: Writable,
|
|
): WritableStream<Uint8Array> {
|
|
return new WritableStream<Uint8Array>({
|
|
write(chunk) {
|
|
return new Promise<void>((resolve, reject) => {
|
|
nodeStream.write(Buffer.from(chunk), err => {
|
|
if (err) reject(err)
|
|
else resolve()
|
|
})
|
|
})
|
|
},
|
|
})
|
|
}
|
|
|
|
// ── unreachable ───────────────────────────────────────────────────
|
|
|
|
export function unreachable(
|
|
value: never,
|
|
logger: { error: (...args: unknown[]) => void } = console,
|
|
): void {
|
|
let valueAsString: unknown
|
|
try {
|
|
valueAsString = JSON.stringify(value)
|
|
} catch {
|
|
valueAsString = value
|
|
}
|
|
logger.error(`Unexpected case: ${valueAsString}`)
|
|
}
|
|
|
|
// ── Permission mode resolution ────────────────────────────────────
|
|
|
|
// Bypass Permissions doesn't work if we are a root/sudo user
|
|
const IS_ROOT =
|
|
typeof process.geteuid === 'function'
|
|
? process.geteuid() === 0
|
|
: typeof process.getuid === 'function'
|
|
? process.getuid() === 0
|
|
: false
|
|
const ALLOW_BYPASS = !IS_ROOT || !!process.env.IS_SANDBOX
|
|
|
|
const PERMISSION_MODE_ALIASES: Record<string, PermissionMode> = {
|
|
auto: 'auto',
|
|
default: 'default',
|
|
acceptedits: 'acceptEdits',
|
|
dontask: 'dontAsk',
|
|
plan: 'plan',
|
|
bypasspermissions: 'bypassPermissions',
|
|
bypass: 'bypassPermissions',
|
|
}
|
|
|
|
export function resolvePermissionMode(
|
|
defaultMode?: unknown,
|
|
source = 'permissions.defaultMode',
|
|
): PermissionMode {
|
|
if (defaultMode === undefined) {
|
|
return 'default'
|
|
}
|
|
|
|
if (typeof defaultMode !== 'string') {
|
|
throw new Error(`Invalid ${source}: expected a string.`)
|
|
}
|
|
|
|
const normalized = defaultMode.trim().toLowerCase()
|
|
if (normalized === '') {
|
|
throw new Error(`Invalid ${source}: expected a non-empty string.`)
|
|
}
|
|
|
|
const mapped = PERMISSION_MODE_ALIASES[normalized]
|
|
if (!mapped) {
|
|
throw new Error(`Invalid ${source}: ${defaultMode}.`)
|
|
}
|
|
|
|
if (mapped === 'bypassPermissions' && !ALLOW_BYPASS) {
|
|
throw new Error(
|
|
`Invalid ${source}: bypassPermissions is not available when running as root.`,
|
|
)
|
|
}
|
|
|
|
return mapped
|
|
}
|
|
|
|
// ── Session fingerprint ───────────────────────────────────────────
|
|
|
|
/**
|
|
* Compute a stable fingerprint of the session-defining params so we can
|
|
* detect when a loadSession/resumeSession call requires tearing down and
|
|
* recreating the underlying QueryEngine.
|
|
*/
|
|
export function computeSessionFingerprint(params: {
|
|
cwd: string
|
|
mcpServers?: Array<{ name: string; [key: string]: unknown }>
|
|
}): string {
|
|
const servers = [...(params.mcpServers ?? [])].sort((a, b) =>
|
|
a.name.localeCompare(b.name),
|
|
)
|
|
return JSON.stringify({ cwd: params.cwd, mcpServers: servers })
|
|
}
|
|
|
|
// ── Title sanitization ────────────────────────────────────────────
|
|
|
|
const MAX_TITLE_LENGTH = 256
|
|
|
|
export function sanitizeTitle(text: string): string {
|
|
const sanitized = text
|
|
.replace(/[\r\n]+/g, ' ')
|
|
.replace(/\s+/g, ' ')
|
|
.trim()
|
|
if (sanitized.length <= MAX_TITLE_LENGTH) {
|
|
return sanitized
|
|
}
|
|
return sanitized.slice(0, MAX_TITLE_LENGTH - 1) + '…'
|
|
}
|
|
|
|
// ── Path display helpers ──────────────────────────────────────────
|
|
|
|
// POSIX semantics so paths are normalised consistently regardless of host OS.
|
|
// ACP paths are always POSIX-style (see bridge/paths.ts for the same rationale).
|
|
import * as path from 'node:path/posix'
|
|
|
|
/**
|
|
* Convert an absolute file path to a project-relative path for display.
|
|
* Returns the original path if it's outside the project directory or if no cwd is provided.
|
|
*/
|
|
export function toDisplayPath(filePath: string, cwd?: string): string {
|
|
if (!cwd) return filePath
|
|
const resolvedCwd = path.resolve(cwd)
|
|
const resolvedFile = path.resolve(filePath)
|
|
if (
|
|
resolvedFile.startsWith(resolvedCwd + path.sep) ||
|
|
resolvedFile === resolvedCwd
|
|
) {
|
|
return path.relative(resolvedCwd, resolvedFile)
|
|
}
|
|
return filePath
|
|
}
|
|
|
|
// ── Markdown helpers ──────────────────────────────────────────────
|
|
|
|
export function markdownEscape(text: string): string {
|
|
let escape = '```'
|
|
for (const m of text.matchAll(/^```+/gm) ?? []) {
|
|
while (m[0].length >= escape.length) {
|
|
escape += '`'
|
|
}
|
|
}
|
|
return escape + '\n' + text + (text.endsWith('\n') ? '' : '\n') + escape
|
|
}
|