mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-17 13:55:50 +00:00
Feat/integrate lint preview (#285)
* feat: 适配 zed acp 协议 * docs: 完善 acp 文档 * feat: integrate feature branches + daemon/job 命令层级化 + 跨平台后台引擎 Cherry-picked from origin/lint/preview (637c908), excluding lint-only changes. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: correct detectMimeFromBase64 to decode raw bytes from base64 Cherry-picked from origin/lint/preview (ee36954). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: daemon 子进程 spawn 跨平台修复 + CliLaunchSpec 集中化重构 Cherry-picked from origin/lint/preview (c5f52cd), excluding lint-only formatting changes. - 新建 src/utils/cliLaunch.ts: 集中化 CLI 子进程启动层 - 修复 --daemon-worker=kind 等号格式解析 - 修复 daemon/bg fast path 缺少 setShellIfWindows() - 修复 checkPathExists 用 existsSync 替代 execSync('dir') - 7 个 spawn 站点迁移到 CliLaunchSpec Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: merge tsconfig.base.json into tsconfig.json with full compiler options The cherry-pick from637c908dropped jsx/strict/etc settings when removing tsconfig.base.json. This commit restores them in a single tsconfig.json. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: merge tsconfig.base.json into tsconfig.json with full compiler options The cherry-pick from637c908dropped jsx/strict/etc settings when removing tsconfig.base.json. This commit restores them in a single tsconfig.json. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
15
src/cli/bg/__tests__/detached.test.ts
Normal file
15
src/cli/bg/__tests__/detached.test.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { DetachedEngine } from '../engines/detached.js'
|
||||
|
||||
describe('DetachedEngine', () => {
|
||||
test('name is "detached"', () => {
|
||||
const engine = new DetachedEngine()
|
||||
expect(engine.name).toBe('detached')
|
||||
})
|
||||
|
||||
test('available always returns true', async () => {
|
||||
const engine = new DetachedEngine()
|
||||
const result = await engine.available()
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
})
|
||||
37
src/cli/bg/__tests__/engine.test.ts
Normal file
37
src/cli/bg/__tests__/engine.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
|
||||
describe('selectEngine', () => {
|
||||
test('returns engine with valid BgEngine interface', async () => {
|
||||
const { selectEngine } = await import('../engines/index.js')
|
||||
const engine = await selectEngine()
|
||||
expect(engine.name).toBeDefined()
|
||||
expect(['tmux', 'detached']).toContain(engine.name)
|
||||
expect(typeof engine.available).toBe('function')
|
||||
expect(typeof engine.start).toBe('function')
|
||||
expect(typeof engine.attach).toBe('function')
|
||||
})
|
||||
|
||||
test('engine.available() returns a boolean', async () => {
|
||||
const { selectEngine } = await import('../engines/index.js')
|
||||
const engine = await selectEngine()
|
||||
const result = await engine.available()
|
||||
expect(typeof result).toBe('boolean')
|
||||
})
|
||||
})
|
||||
|
||||
describe('SessionEntry type', () => {
|
||||
test('engine field accepts tmux or detached', async () => {
|
||||
// Verify the module loads and exports the expected interface shape
|
||||
const mod = await import('../engine.js')
|
||||
expect(mod).toBeDefined()
|
||||
const entry = {
|
||||
pid: 123,
|
||||
sessionId: 'test',
|
||||
cwd: '/tmp',
|
||||
startedAt: Date.now(),
|
||||
kind: 'bg',
|
||||
engine: 'detached' as const,
|
||||
}
|
||||
expect(entry.engine).toBe('detached')
|
||||
})
|
||||
})
|
||||
8
src/cli/bg/__tests__/tail.test.ts
Normal file
8
src/cli/bg/__tests__/tail.test.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
|
||||
describe('tailLog', () => {
|
||||
test('module exports tailLog function', async () => {
|
||||
const mod = await import('../tail.js')
|
||||
expect(typeof mod.tailLog).toBe('function')
|
||||
})
|
||||
})
|
||||
49
src/cli/bg/engine.ts
Normal file
49
src/cli/bg/engine.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* BgEngine — cross-platform background session engine abstraction.
|
||||
*
|
||||
* Implementations:
|
||||
* TmuxEngine — macOS/Linux with tmux installed
|
||||
* DetachedEngine — Windows, or macOS/Linux without tmux (fallback)
|
||||
*/
|
||||
|
||||
export interface SessionEntry {
|
||||
pid: number
|
||||
sessionId: string
|
||||
cwd: string
|
||||
startedAt: number
|
||||
kind: string
|
||||
name?: string
|
||||
logPath?: string
|
||||
entrypoint?: string
|
||||
status?: string
|
||||
waitingFor?: string
|
||||
updatedAt?: number
|
||||
bridgeSessionId?: string
|
||||
agent?: string
|
||||
tmuxSessionName?: string
|
||||
engine?: 'tmux' | 'detached'
|
||||
}
|
||||
|
||||
export interface BgStartOptions {
|
||||
sessionName: string
|
||||
args: string[]
|
||||
env: Record<string, string | undefined>
|
||||
logPath: string
|
||||
cwd: string
|
||||
}
|
||||
|
||||
export interface BgStartResult {
|
||||
pid: number
|
||||
sessionName: string
|
||||
logPath: string
|
||||
engineUsed: 'tmux' | 'detached'
|
||||
}
|
||||
|
||||
export interface BgEngine {
|
||||
readonly name: 'tmux' | 'detached'
|
||||
/** Whether the engine provides a TTY for interactive REPL input. */
|
||||
readonly supportsInteractiveInput: boolean
|
||||
available(): Promise<boolean>
|
||||
start(opts: BgStartOptions): Promise<BgStartResult>
|
||||
attach(session: SessionEntry): Promise<void>
|
||||
}
|
||||
54
src/cli/bg/engines/detached.ts
Normal file
54
src/cli/bg/engines/detached.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { closeSync, mkdirSync, openSync } from 'fs'
|
||||
import { dirname } from 'path'
|
||||
import { buildCliLaunch, spawnCli } from '../../../utils/cliLaunch.js'
|
||||
import type { BgEngine, BgStartOptions, BgStartResult, SessionEntry } from '../engine.js'
|
||||
import { tailLog } from '../tail.js'
|
||||
|
||||
export class DetachedEngine implements BgEngine {
|
||||
readonly name = 'detached' as const
|
||||
readonly supportsInteractiveInput = false
|
||||
|
||||
async available(): Promise<boolean> {
|
||||
return true
|
||||
}
|
||||
|
||||
async start(opts: BgStartOptions): Promise<BgStartResult> {
|
||||
mkdirSync(dirname(opts.logPath), { recursive: true })
|
||||
|
||||
const logFd = openSync(opts.logPath, 'a')
|
||||
|
||||
const launch = buildCliLaunch(opts.args, {
|
||||
env: {
|
||||
...opts.env,
|
||||
CLAUDE_CODE_SESSION_KIND: 'bg',
|
||||
CLAUDE_CODE_SESSION_NAME: opts.sessionName,
|
||||
CLAUDE_CODE_SESSION_LOG: opts.logPath,
|
||||
} as NodeJS.ProcessEnv,
|
||||
})
|
||||
|
||||
const child = spawnCli(launch, {
|
||||
detached: true,
|
||||
stdio: ['ignore', logFd, logFd],
|
||||
cwd: opts.cwd,
|
||||
})
|
||||
|
||||
child.unref()
|
||||
closeSync(logFd)
|
||||
|
||||
const pid = child.pid ?? 0
|
||||
|
||||
return {
|
||||
pid,
|
||||
sessionName: opts.sessionName,
|
||||
logPath: opts.logPath,
|
||||
engineUsed: 'detached',
|
||||
}
|
||||
}
|
||||
|
||||
async attach(session: SessionEntry): Promise<void> {
|
||||
if (!session.logPath) {
|
||||
throw new Error(`Session ${session.sessionId} has no log path.`)
|
||||
}
|
||||
await tailLog(session.logPath)
|
||||
}
|
||||
}
|
||||
17
src/cli/bg/engines/index.ts
Normal file
17
src/cli/bg/engines/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export type { BgEngine, BgStartOptions, BgStartResult, SessionEntry } from '../engine.js'
|
||||
|
||||
export async function selectEngine(): Promise<import('../engine.js').BgEngine> {
|
||||
if (process.platform === 'win32') {
|
||||
const { DetachedEngine } = await import('./detached.js')
|
||||
return new DetachedEngine()
|
||||
}
|
||||
|
||||
const { TmuxEngine } = await import('./tmux.js')
|
||||
const tmux = new TmuxEngine()
|
||||
if (await tmux.available()) {
|
||||
return tmux
|
||||
}
|
||||
|
||||
const { DetachedEngine } = await import('./detached.js')
|
||||
return new DetachedEngine()
|
||||
}
|
||||
75
src/cli/bg/engines/tmux.ts
Normal file
75
src/cli/bg/engines/tmux.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { spawnSync } from 'child_process'
|
||||
import { execFileNoThrow } from '../../../utils/execFileNoThrow.js'
|
||||
import { buildCliLaunch, quoteCliLaunch } from '../../../utils/cliLaunch.js'
|
||||
import type { BgEngine, BgStartOptions, BgStartResult, SessionEntry } from '../engine.js'
|
||||
|
||||
export class TmuxEngine implements BgEngine {
|
||||
readonly name = 'tmux' as const
|
||||
readonly supportsInteractiveInput = true
|
||||
|
||||
async available(): Promise<boolean> {
|
||||
const { code } = await execFileNoThrow('tmux', ['-V'], { useCwd: false })
|
||||
return code === 0
|
||||
}
|
||||
|
||||
async start(opts: BgStartOptions): Promise<BgStartResult> {
|
||||
const launch = buildCliLaunch(opts.args, {
|
||||
env: {
|
||||
...opts.env,
|
||||
CLAUDE_CODE_SESSION_KIND: 'bg',
|
||||
CLAUDE_CODE_SESSION_NAME: opts.sessionName,
|
||||
CLAUDE_CODE_SESSION_LOG: opts.logPath,
|
||||
CLAUDE_CODE_TMUX_SESSION: opts.sessionName,
|
||||
} as NodeJS.ProcessEnv,
|
||||
})
|
||||
|
||||
const cmd = quoteCliLaunch(launch)
|
||||
|
||||
const result = spawnSync(
|
||||
'tmux',
|
||||
['new-session', '-d', '-s', opts.sessionName, cmd],
|
||||
{ stdio: 'inherit', env: launch.env },
|
||||
)
|
||||
|
||||
if (result.status !== 0) {
|
||||
throw new Error('Failed to create tmux session.')
|
||||
}
|
||||
|
||||
// tmux doesn't directly report the child PID; we return 0.
|
||||
// The actual session process writes its own PID file.
|
||||
return {
|
||||
pid: 0,
|
||||
sessionName: opts.sessionName,
|
||||
logPath: opts.logPath,
|
||||
engineUsed: 'tmux',
|
||||
}
|
||||
}
|
||||
|
||||
async attach(session: SessionEntry): Promise<void> {
|
||||
if (!session.tmuxSessionName) {
|
||||
throw new Error(`Session ${session.sessionId} has no tmux session name.`)
|
||||
}
|
||||
|
||||
const result = spawnSync(
|
||||
'tmux',
|
||||
['attach-session', '-t', session.tmuxSessionName],
|
||||
{ stdio: 'inherit' },
|
||||
)
|
||||
|
||||
if (result.status !== 0) {
|
||||
throw new Error(
|
||||
`Failed to attach to tmux session '${session.tmuxSessionName}'.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getTmuxInstallHint(): string {
|
||||
if (process.platform === 'darwin') {
|
||||
return 'Install with: brew install tmux'
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
return 'tmux is not natively available on Windows. Consider using WSL.'
|
||||
}
|
||||
return 'Install with: sudo apt install tmux (or your package manager)'
|
||||
}
|
||||
70
src/cli/bg/tail.ts
Normal file
70
src/cli/bg/tail.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import {
|
||||
openSync,
|
||||
readSync,
|
||||
closeSync,
|
||||
statSync,
|
||||
watchFile,
|
||||
unwatchFile,
|
||||
createReadStream,
|
||||
} from 'fs'
|
||||
import { createInterface } from 'readline'
|
||||
|
||||
/**
|
||||
* Cross-platform real-time log output. Ctrl+C exits tail without killing
|
||||
* the background process.
|
||||
*
|
||||
* Strategy:
|
||||
* 1. Read existing content and output to stdout
|
||||
* 2. Use fs.watchFile() (polling-based — works everywhere including Windows)
|
||||
* 3. On change, read new bytes from the last known position
|
||||
* 4. SIGINT exits cleanly
|
||||
*/
|
||||
export async function tailLog(logPath: string): Promise<void> {
|
||||
let position = 0
|
||||
|
||||
// Output existing content
|
||||
try {
|
||||
const stat = statSync(logPath)
|
||||
position = stat.size
|
||||
if (position > 0) {
|
||||
const stream = createReadStream(logPath, { start: 0, end: position - 1 })
|
||||
const rl = createInterface({ input: stream })
|
||||
for await (const line of rl) {
|
||||
process.stdout.write(line + '\n')
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// File may not exist yet — that's fine
|
||||
}
|
||||
|
||||
console.log('\n[tail] Watching for new output... (Ctrl+C to detach)\n')
|
||||
|
||||
return new Promise<void>(resolve => {
|
||||
const onSignal = (): void => {
|
||||
unwatchFile(logPath)
|
||||
process.removeListener('SIGINT', onSignal)
|
||||
console.log('\n[tail] Detached from session.')
|
||||
resolve()
|
||||
}
|
||||
process.on('SIGINT', onSignal)
|
||||
|
||||
watchFile(logPath, { interval: 300 }, () => {
|
||||
try {
|
||||
const stat = statSync(logPath)
|
||||
if (stat.size <= position) return
|
||||
|
||||
const fd = openSync(logPath, 'r')
|
||||
try {
|
||||
const buf = Buffer.alloc(stat.size - position)
|
||||
readSync(fd, buf, 0, buf.length, position)
|
||||
process.stdout.write(buf)
|
||||
position = stat.size
|
||||
} finally {
|
||||
closeSync(fd)
|
||||
}
|
||||
} catch {
|
||||
// File may have been deleted or truncated
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user