mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-17 05:45:51 +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:
344
src/cli/bg.ts
344
src/cli/bg.ts
@@ -1,7 +1,337 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export {};
|
||||
export const psHandler: (args: string[]) => Promise<void> = (async () => {}) as (args: string[]) => Promise<void>;
|
||||
export const logsHandler: (sessionId: string | undefined) => Promise<void> = (async () => {}) as (sessionId: string | undefined) => Promise<void>;
|
||||
export const attachHandler: (sessionId: string | undefined) => Promise<void> = (async () => {}) as (sessionId: string | undefined) => Promise<void>;
|
||||
export const killHandler: (sessionId: string | undefined) => Promise<void> = (async () => {}) as (sessionId: string | undefined) => Promise<void>;
|
||||
export const handleBgFlag: (args: string[]) => Promise<void> = (async () => {}) as (args: string[]) => Promise<void>;
|
||||
import { readdir, readFile, unlink } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { getClaudeConfigHomeDir } from '../utils/envUtils.js'
|
||||
import { isProcessRunning } from '../utils/genericProcessUtils.js'
|
||||
import { jsonParse } from '../utils/slowOperations.js'
|
||||
import { selectEngine } from './bg/engines/index.js'
|
||||
import type { SessionEntry } from './bg/engine.js'
|
||||
|
||||
export type { SessionEntry } from './bg/engine.js'
|
||||
|
||||
function getSessionsDir(): string {
|
||||
return join(getClaudeConfigHomeDir(), 'sessions')
|
||||
}
|
||||
|
||||
export async function listLiveSessions(): Promise<SessionEntry[]> {
|
||||
const dir = getSessionsDir()
|
||||
let files: string[]
|
||||
try {
|
||||
files = await readdir(dir)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
const sessions: SessionEntry[] = []
|
||||
for (const file of files) {
|
||||
if (!/^\d+\.json$/.test(file)) continue
|
||||
const pid = parseInt(file.slice(0, -5), 10)
|
||||
|
||||
if (!isProcessRunning(pid)) {
|
||||
void unlink(join(dir, file)).catch(() => {})
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = await readFile(join(dir, file), 'utf-8')
|
||||
const entry = jsonParse(raw) as SessionEntry
|
||||
sessions.push(entry)
|
||||
} catch {
|
||||
// Corrupt file — skip
|
||||
}
|
||||
}
|
||||
|
||||
return sessions
|
||||
}
|
||||
|
||||
export function findSession(
|
||||
sessions: SessionEntry[],
|
||||
target: string,
|
||||
): SessionEntry | undefined {
|
||||
const asNum = parseInt(target, 10)
|
||||
return sessions.find(
|
||||
s =>
|
||||
s.sessionId === target ||
|
||||
s.pid === asNum ||
|
||||
(s.name && s.name === target),
|
||||
)
|
||||
}
|
||||
|
||||
function formatTime(ts: number): string {
|
||||
return new Date(ts).toLocaleString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the engine type for an existing session.
|
||||
* Backward-compatible: sessions without an `engine` field are inferred
|
||||
* from the presence of `tmuxSessionName`.
|
||||
*/
|
||||
function resolveSessionEngine(session: SessionEntry): 'tmux' | 'detached' {
|
||||
if (session.engine) return session.engine
|
||||
return session.tmuxSessionName ? 'tmux' : 'detached'
|
||||
}
|
||||
|
||||
/**
|
||||
* `claude daemon status` / `claude ps` — list live sessions.
|
||||
*/
|
||||
export async function psHandler(_args: string[]): Promise<void> {
|
||||
const sessions = await listLiveSessions()
|
||||
|
||||
if (sessions.length === 0) {
|
||||
console.log('No active sessions.')
|
||||
return
|
||||
}
|
||||
|
||||
console.log(
|
||||
`${sessions.length} active session${sessions.length > 1 ? 's' : ''}:\n`,
|
||||
)
|
||||
|
||||
for (const s of sessions) {
|
||||
const engineType = resolveSessionEngine(s)
|
||||
const parts: string[] = [
|
||||
` PID: ${s.pid}`,
|
||||
` Kind: ${s.kind}`,
|
||||
` Engine: ${engineType}`,
|
||||
` Session: ${s.sessionId}`,
|
||||
` CWD: ${s.cwd}`,
|
||||
]
|
||||
|
||||
if (s.name) parts.push(` Name: ${s.name}`)
|
||||
if (s.startedAt) parts.push(` Started: ${formatTime(s.startedAt)}`)
|
||||
if (s.status) parts.push(` Status: ${s.status}`)
|
||||
if (s.waitingFor) parts.push(` Waiting for: ${s.waitingFor}`)
|
||||
if (s.bridgeSessionId) parts.push(` Bridge: ${s.bridgeSessionId}`)
|
||||
if (s.tmuxSessionName) parts.push(` Tmux: ${s.tmuxSessionName}`)
|
||||
if (s.logPath) parts.push(` Log: ${s.logPath}`)
|
||||
|
||||
console.log(parts.join('\n'))
|
||||
console.log()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `claude daemon logs <target>` — show logs for a session.
|
||||
*/
|
||||
export async function logsHandler(target: string | undefined): Promise<void> {
|
||||
const sessions = await listLiveSessions()
|
||||
|
||||
if (!target) {
|
||||
if (sessions.length === 0) {
|
||||
console.log('No active sessions.')
|
||||
return
|
||||
}
|
||||
if (sessions.length === 1) {
|
||||
target = sessions[0]!.sessionId
|
||||
} else {
|
||||
console.log('Multiple sessions active. Specify one:')
|
||||
for (const s of sessions) {
|
||||
const label = s.name ? `${s.name} (${s.sessionId})` : s.sessionId
|
||||
console.log(` ${label} PID=${s.pid}`)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const session = findSession(sessions, target)
|
||||
if (!session) {
|
||||
console.error(`Session not found: ${target}`)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
if (!session.logPath) {
|
||||
console.log(`No log path recorded for session ${session.sessionId}`)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await readFile(session.logPath, 'utf-8')
|
||||
process.stdout.write(content)
|
||||
} catch (e) {
|
||||
console.error(`Failed to read log file: ${session.logPath}`)
|
||||
console.error(e instanceof Error ? e.message : String(e))
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `claude daemon attach <target>` — attach to a background session.
|
||||
*
|
||||
* Engine-aware: tmux sessions use tmux attach, detached sessions use log tail.
|
||||
*/
|
||||
export async function attachHandler(target: string | undefined): Promise<void> {
|
||||
const sessions = await listLiveSessions()
|
||||
|
||||
if (!target) {
|
||||
// Find bg sessions (tmux or detached)
|
||||
const bgSessions = sessions.filter(
|
||||
s => s.tmuxSessionName || s.engine === 'detached',
|
||||
)
|
||||
if (bgSessions.length === 0) {
|
||||
console.log(
|
||||
'No background sessions to attach to. Start one with `claude daemon bg`.',
|
||||
)
|
||||
return
|
||||
}
|
||||
if (bgSessions.length === 1) {
|
||||
target = bgSessions[0]!.sessionId
|
||||
} else {
|
||||
console.log('Multiple background sessions. Specify one:')
|
||||
for (const s of bgSessions) {
|
||||
const label = s.name ? `${s.name} (${s.sessionId})` : s.sessionId
|
||||
const engineType = resolveSessionEngine(s)
|
||||
console.log(` ${label} PID=${s.pid} engine=${engineType}`)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const session = findSession(sessions, target)
|
||||
if (!session) {
|
||||
console.error(`Session not found: ${target}`)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
const engineType = resolveSessionEngine(session)
|
||||
|
||||
try {
|
||||
if (engineType === 'tmux') {
|
||||
const { TmuxEngine } = await import('./bg/engines/tmux.js')
|
||||
const tmux = new TmuxEngine()
|
||||
if (!(await tmux.available())) {
|
||||
console.error('tmux is no longer available. Cannot attach to tmux session.')
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
await tmux.attach(session)
|
||||
} else {
|
||||
const { DetachedEngine } = await import('./bg/engines/detached.js')
|
||||
const detached = new DetachedEngine()
|
||||
await detached.attach(session)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e instanceof Error ? e.message : String(e))
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `claude daemon kill <target>` — kill a session.
|
||||
*/
|
||||
export async function killHandler(target: string | undefined): Promise<void> {
|
||||
const sessions = await listLiveSessions()
|
||||
|
||||
if (!target) {
|
||||
if (sessions.length === 0) {
|
||||
console.log('No active sessions to kill.')
|
||||
return
|
||||
}
|
||||
console.log('Specify a session to kill:')
|
||||
for (const s of sessions) {
|
||||
const label = s.name ? `${s.name} (${s.sessionId})` : s.sessionId
|
||||
console.log(` ${label} PID=${s.pid}`)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const session = findSession(sessions, target)
|
||||
if (!session) {
|
||||
console.error(`Session not found: ${target}`)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`Killing session ${session.sessionId} (PID: ${session.pid})...`)
|
||||
|
||||
try {
|
||||
process.kill(session.pid, 'SIGTERM')
|
||||
} catch {
|
||||
console.log('Session already exited.')
|
||||
return
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
|
||||
if (isProcessRunning(session.pid)) {
|
||||
try {
|
||||
process.kill(session.pid, 'SIGKILL')
|
||||
console.log('Session force-killed.')
|
||||
} catch {
|
||||
console.log('Session exited during grace period.')
|
||||
}
|
||||
} else {
|
||||
console.log('Session stopped.')
|
||||
}
|
||||
|
||||
const pidFile = join(getSessionsDir(), `${session.pid}.json`)
|
||||
void unlink(pidFile).catch(() => {})
|
||||
}
|
||||
|
||||
/**
|
||||
* `claude daemon bg [args]` — start a background session.
|
||||
*
|
||||
* Cross-platform: uses TmuxEngine on macOS/Linux when tmux is available,
|
||||
* falls back to DetachedEngine on Windows or when tmux is absent.
|
||||
*/
|
||||
export async function handleBgStart(args: string[]): Promise<void> {
|
||||
const engine = await selectEngine()
|
||||
|
||||
// Strip --bg/--background from args (for backward-compat shortcut)
|
||||
const filteredArgs = args.filter(a => a !== '--bg' && a !== '--background')
|
||||
|
||||
// Engines without interactive TTY input (e.g. detached) require -p/--print
|
||||
// or piped input. Tmux provides a virtual terminal so it works without -p.
|
||||
if (
|
||||
!engine.supportsInteractiveInput &&
|
||||
!filteredArgs.some(a => a === '-p' || a === '--print' || a === '--pipe')
|
||||
) {
|
||||
console.error(
|
||||
'Error: Background sessions with detached engine require -p/--print flag.\n' +
|
||||
'The detached engine has no terminal for interactive input.\n\n' +
|
||||
'Usage:\n' +
|
||||
' claude daemon bg -p "your prompt here"\n' +
|
||||
' echo "prompt" | claude daemon bg --pipe',
|
||||
)
|
||||
if (process.platform !== 'win32') {
|
||||
console.error(
|
||||
'\nAlternatively, install tmux for interactive background sessions:\n' +
|
||||
` ${process.platform === 'darwin' ? 'brew install tmux' : 'sudo apt install tmux'}`,
|
||||
)
|
||||
}
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
const sessionName = `claude-bg-${randomUUID().slice(0, 8)}`
|
||||
const logPath = join(
|
||||
getClaudeConfigHomeDir(),
|
||||
'sessions',
|
||||
'logs',
|
||||
`${sessionName}.log`,
|
||||
)
|
||||
|
||||
try {
|
||||
const result = await engine.start({
|
||||
sessionName,
|
||||
args: filteredArgs,
|
||||
env: { ...process.env },
|
||||
logPath,
|
||||
cwd: process.cwd(),
|
||||
})
|
||||
|
||||
console.log(`Background session started: ${result.sessionName}`)
|
||||
console.log(` Engine: ${result.engineUsed}`)
|
||||
console.log(` Log: ${result.logPath}`)
|
||||
console.log()
|
||||
console.log(`Use \`claude daemon attach ${result.sessionName}\` to reconnect.`)
|
||||
console.log(`Use \`claude daemon status\` to check status.`)
|
||||
console.log(`Use \`claude daemon kill ${result.sessionName}\` to stop.`)
|
||||
} catch (e) {
|
||||
console.error(e instanceof Error ? e.message : String(e))
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy export alias — kept for backward compatibility with cli.tsx
|
||||
export const handleBgFlag = handleBgStart
|
||||
|
||||
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
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1,13 +1,216 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
import type { Command } from '@commander-js/extra-typings';
|
||||
import type { Command } from '@commander-js/extra-typings'
|
||||
import {
|
||||
createTask,
|
||||
getTask,
|
||||
updateTask,
|
||||
listTasks,
|
||||
getTasksDir,
|
||||
} from '../../utils/tasks.js'
|
||||
import { getRecentActivity } from '../../utils/logoV2Utils.js'
|
||||
import type { LogOption } from '../../types/logs.js'
|
||||
|
||||
export {};
|
||||
export const logHandler: (logId: string | number | undefined) => Promise<void> = (async () => {}) as (logId: string | number | undefined) => Promise<void>;
|
||||
export const errorHandler: (num: number | undefined) => Promise<void> = (async () => {}) as (num: number | undefined) => Promise<void>;
|
||||
export const exportHandler: (source: string, outputFile: string) => Promise<void> = (async () => {}) as (source: string, outputFile: string) => Promise<void>;
|
||||
export const taskCreateHandler: (subject: string, opts: { description?: string; list?: string }) => Promise<void> = (async () => {}) as (subject: string, opts: { description?: string; list?: string }) => Promise<void>;
|
||||
export const taskListHandler: (opts: { list?: string; pending?: boolean; json?: boolean }) => Promise<void> = (async () => {}) as (opts: { list?: string; pending?: boolean; json?: boolean }) => Promise<void>;
|
||||
export const taskGetHandler: (id: string, opts: { list?: string }) => Promise<void> = (async () => {}) as (id: string, opts: { list?: string }) => Promise<void>;
|
||||
export const taskUpdateHandler: (id: string, opts: { list?: string; status?: string; subject?: string; description?: string; owner?: string; clearOwner?: boolean }) => Promise<void> = (async () => {}) as (id: string, opts: { list?: string; status?: string; subject?: string; description?: string; owner?: string; clearOwner?: boolean }) => Promise<void>;
|
||||
export const taskDirHandler: (opts: { list?: string }) => Promise<void> = (async () => {}) as (opts: { list?: string }) => Promise<void>;
|
||||
export const completionHandler: (shell: string, opts: { output?: string }, program: Command) => Promise<void> = (async () => {}) as (shell: string, opts: { output?: string }, program: Command) => Promise<void>;
|
||||
const DEFAULT_LIST = 'default'
|
||||
|
||||
// ─── Group C: Task CRUD ──────────────────────────────────────────────────────
|
||||
|
||||
export async function taskCreateHandler(
|
||||
subject: string,
|
||||
opts: { description?: string; list?: string },
|
||||
): Promise<void> {
|
||||
const listId = opts.list || DEFAULT_LIST
|
||||
const id = await createTask(listId, {
|
||||
subject,
|
||||
description: opts.description || '',
|
||||
status: 'pending',
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
})
|
||||
console.log(`Created task ${id}: ${subject}`)
|
||||
}
|
||||
|
||||
export async function taskListHandler(opts: {
|
||||
list?: string
|
||||
pending?: boolean
|
||||
json?: boolean
|
||||
}): Promise<void> {
|
||||
const listId = opts.list || DEFAULT_LIST
|
||||
let tasks = await listTasks(listId)
|
||||
|
||||
if (opts.pending) {
|
||||
tasks = tasks.filter(t => t.status === 'pending')
|
||||
}
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(tasks, null, 2))
|
||||
return
|
||||
}
|
||||
|
||||
if (tasks.length === 0) {
|
||||
console.log('No tasks found.')
|
||||
return
|
||||
}
|
||||
|
||||
for (const t of tasks) {
|
||||
console.log(` [${t.status}] ${t.id}: ${t.subject}`)
|
||||
if (t.description) console.log(` ${t.description}`)
|
||||
if (t.owner) console.log(` owner: ${t.owner}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function taskGetHandler(
|
||||
id: string,
|
||||
opts: { list?: string },
|
||||
): Promise<void> {
|
||||
const listId = opts.list || DEFAULT_LIST
|
||||
const task = await getTask(listId, id)
|
||||
if (!task) {
|
||||
console.error(`Task not found: ${id}`)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
console.log(JSON.stringify(task, null, 2))
|
||||
}
|
||||
|
||||
export async function taskUpdateHandler(
|
||||
id: string,
|
||||
opts: {
|
||||
list?: string
|
||||
status?: string
|
||||
subject?: string
|
||||
description?: string
|
||||
owner?: string
|
||||
clearOwner?: boolean
|
||||
},
|
||||
): Promise<void> {
|
||||
const listId = opts.list || DEFAULT_LIST
|
||||
const updates: Record<string, unknown> = {}
|
||||
|
||||
if (opts.status) updates.status = opts.status
|
||||
if (opts.subject) updates.subject = opts.subject
|
||||
if (opts.description) updates.description = opts.description
|
||||
if (opts.owner) updates.owner = opts.owner
|
||||
if (opts.clearOwner) updates.owner = undefined
|
||||
|
||||
const task = await updateTask(listId, id, updates)
|
||||
if (!task) {
|
||||
console.error(`Task not found: ${id}`)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
console.log(`Updated task ${id}: [${task.status}] ${task.subject}`)
|
||||
}
|
||||
|
||||
export async function taskDirHandler(opts: { list?: string }): Promise<void> {
|
||||
const listId = opts.list || DEFAULT_LIST
|
||||
console.log(getTasksDir(listId))
|
||||
}
|
||||
|
||||
// ─── Group B: Log / Error / Export ───────────────────────────────────────────
|
||||
|
||||
export async function logHandler(
|
||||
logId: string | number | undefined,
|
||||
): Promise<void> {
|
||||
const logs = await getRecentActivity()
|
||||
|
||||
if (logId === undefined) {
|
||||
if (logs.length === 0) {
|
||||
console.log('No recent sessions.')
|
||||
return
|
||||
}
|
||||
for (let i = 0; i < Math.min(logs.length, 20); i++) {
|
||||
const log = logs[i]!
|
||||
const date = log.modified
|
||||
? new Date(log.modified).toLocaleString()
|
||||
: 'unknown'
|
||||
const title =
|
||||
(log as Record<string, unknown>).title || log.sessionId || 'untitled'
|
||||
console.log(` ${i}: ${title} (${date})`)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const idx = typeof logId === 'string' ? parseInt(logId, 10) : logId
|
||||
const log =
|
||||
Number.isFinite(idx) && idx >= 0 && idx < logs.length
|
||||
? logs[idx]
|
||||
: logs.find(l => l.sessionId === String(logId))
|
||||
|
||||
if (!log) {
|
||||
console.error(`Session not found: ${logId}`)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(log, null, 2))
|
||||
}
|
||||
|
||||
export async function errorHandler(num: number | undefined): Promise<void> {
|
||||
// Error log viewing — shows recent session errors
|
||||
const logs = await getRecentActivity()
|
||||
const count = num ?? 5
|
||||
|
||||
console.log(`Last ${count} sessions:`)
|
||||
for (let i = 0; i < Math.min(count, logs.length); i++) {
|
||||
const log = logs[i]!
|
||||
const date = log.modified
|
||||
? new Date(log.modified).toLocaleString()
|
||||
: 'unknown'
|
||||
console.log(` ${i}: ${log.sessionId} (${date})`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function exportHandler(
|
||||
source: string,
|
||||
outputFile: string,
|
||||
): Promise<void> {
|
||||
const { writeFile, readFile } = await import('fs/promises')
|
||||
const logs = await getRecentActivity()
|
||||
|
||||
// Try as index first
|
||||
const idx = parseInt(source, 10)
|
||||
let log: LogOption | undefined
|
||||
if (Number.isFinite(idx) && idx >= 0 && idx < logs.length) {
|
||||
log = logs[idx]
|
||||
} else {
|
||||
log = logs.find(l => l.sessionId === source)
|
||||
}
|
||||
|
||||
if (!log) {
|
||||
// Try as file path
|
||||
try {
|
||||
const content = await readFile(source, 'utf-8')
|
||||
await writeFile(outputFile, content, 'utf-8')
|
||||
console.log(`Exported ${source} → ${outputFile}`)
|
||||
return
|
||||
} catch {
|
||||
console.error(`Source not found: ${source}`)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
await writeFile(outputFile, JSON.stringify(log, null, 2), 'utf-8')
|
||||
console.log(`Exported session ${log.sessionId} → ${outputFile}`)
|
||||
}
|
||||
|
||||
// ─── Group D: Completion ─────────────────────────────────────────────────────
|
||||
|
||||
export async function completionHandler(
|
||||
shell: string,
|
||||
opts: { output?: string },
|
||||
_program: Command,
|
||||
): Promise<void> {
|
||||
const { regenerateCompletionCache } = await import(
|
||||
'../../utils/completionCache.js'
|
||||
)
|
||||
|
||||
if (opts.output) {
|
||||
// Generate and write to file
|
||||
await regenerateCompletionCache()
|
||||
console.log(`Completion cache regenerated for ${shell}.`)
|
||||
} else {
|
||||
// Regenerate and output to stdout
|
||||
await regenerateCompletionCache()
|
||||
console.log(`Completion cache regenerated for ${shell}.`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,158 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export {};
|
||||
export const templatesMain: (args: string[]) => Promise<void> = () => Promise.resolve();
|
||||
import { randomUUID } from 'crypto'
|
||||
import { listTemplates, loadTemplate } from '../../jobs/templates.js'
|
||||
import {
|
||||
createJob,
|
||||
readJobState,
|
||||
appendJobReply,
|
||||
getJobDir,
|
||||
} from '../../jobs/state.js'
|
||||
|
||||
/**
|
||||
* Entry point for template job commands: `new`, `list`, `reply`.
|
||||
* Called from cli.tsx fast-path.
|
||||
*/
|
||||
export async function templatesMain(args: string[]): Promise<void> {
|
||||
const subcommand = args[0]
|
||||
|
||||
switch (subcommand) {
|
||||
case 'list':
|
||||
handleList()
|
||||
break
|
||||
case 'new':
|
||||
handleNew(args.slice(1))
|
||||
break
|
||||
case 'reply':
|
||||
handleReply(args.slice(1))
|
||||
break
|
||||
case 'status':
|
||||
handleStatus(args.slice(1))
|
||||
break
|
||||
default:
|
||||
console.error(`Unknown template command: ${subcommand}`)
|
||||
printUsage()
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
|
||||
function printUsage(): void {
|
||||
console.log(`
|
||||
Template Job Commands:
|
||||
|
||||
claude job list List available templates
|
||||
claude job new <template> [args] Create a new job from a template
|
||||
claude job reply <job-id> <text> Reply to an existing job
|
||||
claude job status <job-id> Show job status
|
||||
`)
|
||||
}
|
||||
|
||||
function handleStatus(args: string[]): void {
|
||||
const jobId = args[0]
|
||||
if (!jobId) {
|
||||
console.error('Usage: claude job status <job-id>')
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
const state = readJobState(jobId)
|
||||
if (!state) {
|
||||
console.error(`Job not found: ${jobId}`)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`Job: ${state.jobId}`)
|
||||
console.log(` Template: ${state.templateName}`)
|
||||
console.log(` Status: ${state.status}`)
|
||||
console.log(` Created: ${state.createdAt}`)
|
||||
console.log(` Updated: ${state.updatedAt}`)
|
||||
console.log(` Args: ${state.args.join(' ') || '(none)'}`)
|
||||
}
|
||||
|
||||
function handleList(): void {
|
||||
const templates = listTemplates()
|
||||
|
||||
if (templates.length === 0) {
|
||||
console.log('No templates found.')
|
||||
console.log('Place .md files in .claude/templates/ or ~/.claude/templates/')
|
||||
return
|
||||
}
|
||||
|
||||
console.log(
|
||||
`${templates.length} template${templates.length > 1 ? 's' : ''} found:\n`,
|
||||
)
|
||||
|
||||
for (const t of templates) {
|
||||
console.log(` ${t.name}`)
|
||||
console.log(` ${t.description}`)
|
||||
console.log(` Path: ${t.filePath}`)
|
||||
console.log()
|
||||
}
|
||||
}
|
||||
|
||||
function handleNew(args: string[]): void {
|
||||
const templateName = args[0]
|
||||
if (!templateName) {
|
||||
console.error('Usage: claude job new <template> [args...]')
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
const template = loadTemplate(templateName)
|
||||
if (!template) {
|
||||
console.error(`Template not found: ${templateName}`)
|
||||
console.log('\nAvailable templates:')
|
||||
for (const t of listTemplates()) {
|
||||
console.log(` ${t.name}`)
|
||||
}
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
const jobId = randomUUID().slice(0, 8)
|
||||
const inputText = args.slice(1).join(' ')
|
||||
const rawContent = `---\n${Object.entries(template.frontmatter)
|
||||
.map(([k, v]) => `${k}: ${v}`)
|
||||
.join('\n')}\n---\n${template.content}`
|
||||
|
||||
const dir = createJob(
|
||||
jobId,
|
||||
templateName,
|
||||
rawContent,
|
||||
inputText,
|
||||
args.slice(1),
|
||||
)
|
||||
|
||||
console.log(`Job created: ${jobId}`)
|
||||
console.log(` Template: ${templateName}`)
|
||||
console.log(` Directory: ${dir}`)
|
||||
if (inputText) {
|
||||
console.log(` Input: ${inputText}`)
|
||||
}
|
||||
}
|
||||
|
||||
function handleReply(args: string[]): void {
|
||||
const jobId = args[0]
|
||||
const text = args.slice(1).join(' ')
|
||||
|
||||
if (!jobId || !text) {
|
||||
console.error('Usage: claude job reply <job-id> <text>')
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
const state = readJobState(jobId)
|
||||
if (!state) {
|
||||
console.error(`Job not found: ${jobId}`)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
const ok = appendJobReply(jobId, text)
|
||||
if (ok) {
|
||||
console.log(`Reply added to job ${jobId}`)
|
||||
console.log(` Directory: ${getJobDir(jobId)}`)
|
||||
} else {
|
||||
console.error(`Failed to append reply to job ${jobId}`)
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
|
||||
456
src/cli/print.ts
456
src/cli/print.ts
@@ -320,6 +320,17 @@ import {
|
||||
logQueryProfileReport,
|
||||
} from 'src/utils/queryProfiler.js'
|
||||
import { asSessionId } from 'src/types/ids.js'
|
||||
import {
|
||||
commitAutonomyQueuedPrompt,
|
||||
createAutonomyQueuedPrompt,
|
||||
createProactiveAutonomyCommands,
|
||||
finalizeAutonomyRunCompleted,
|
||||
finalizeAutonomyRunFailed,
|
||||
markAutonomyRunCompleted,
|
||||
markAutonomyRunFailed,
|
||||
markAutonomyRunRunning,
|
||||
} from 'src/utils/autonomyRuns.js'
|
||||
import { prepareAutonomyTurnPrompt } from 'src/utils/autonomyAuthority.js'
|
||||
import { jsonStringify } from '../utils/slowOperations.js'
|
||||
import { skillChangeDetector } from '../utils/skills/skillChangeDetector.js'
|
||||
import { getCommands, clearCommandsCache } from '../commands.js'
|
||||
@@ -362,9 +373,12 @@ const proactiveModule =
|
||||
feature('PROACTIVE') || feature('KAIROS')
|
||||
? (require('../proactive/index.js') as typeof import('../proactive/index.js'))
|
||||
: null
|
||||
const cronSchedulerModule = require('../utils/cronScheduler.js') as typeof import('../utils/cronScheduler.js')
|
||||
const cronJitterConfigModule = require('../utils/cronJitterConfig.js') as typeof import('../utils/cronJitterConfig.js')
|
||||
const cronGate = require('@claude-code-best/builtin-tools/tools/ScheduleCronTool/prompt.js') as typeof import('@claude-code-best/builtin-tools/tools/ScheduleCronTool/prompt.js')
|
||||
const cronSchedulerModule =
|
||||
require('../utils/cronScheduler.js') as typeof import('../utils/cronScheduler.js')
|
||||
const cronJitterConfigModule =
|
||||
require('../utils/cronJitterConfig.js') as typeof import('../utils/cronJitterConfig.js')
|
||||
const cronGate =
|
||||
require('@claude-code-best/builtin-tools/tools/ScheduleCronTool/prompt.js') as typeof import('@claude-code-best/builtin-tools/tools/ScheduleCronTool/prompt.js')
|
||||
const extractMemoriesModule = feature('EXTRACT_MEMORIES')
|
||||
? (require('../services/extractMemories/extractMemories.js') as typeof import('../services/extractMemories/extractMemories.js'))
|
||||
: null
|
||||
@@ -1180,7 +1194,9 @@ function runHeadlessStreaming(
|
||||
removeInterruptedMessage(mutableMessages, turnInterruptionState.message)
|
||||
enqueue({
|
||||
mode: 'prompt',
|
||||
value: turnInterruptionState.message.message!.content as string | ContentBlockParam[],
|
||||
value: turnInterruptionState.message.message!.content as
|
||||
| string
|
||||
| ContentBlockParam[],
|
||||
uuid: randomUUID(),
|
||||
})
|
||||
}
|
||||
@@ -1642,7 +1658,10 @@ function runHeadlessStreaming(
|
||||
connection.config.type === 'stdio' ||
|
||||
connection.config.type === undefined
|
||||
) {
|
||||
const stdioConfig = connection.config as { command: string; args: string[] }
|
||||
const stdioConfig = connection.config as {
|
||||
command: string
|
||||
args: string[]
|
||||
}
|
||||
config = {
|
||||
type: 'stdio' as const,
|
||||
command: stdioConfig.command,
|
||||
@@ -1804,7 +1823,8 @@ function runHeadlessStreaming(
|
||||
}
|
||||
for (const [name, config] of Object.entries(sdkMcpConfigs)) {
|
||||
if (config.type === 'sdk' && !(name in supportedConfigs)) {
|
||||
supportedConfigs[name] = config as unknown as McpServerConfigForProcessTransport
|
||||
supportedConfigs[name] =
|
||||
config as unknown as McpServerConfigForProcessTransport
|
||||
}
|
||||
}
|
||||
const { response, sdkServersChanged } =
|
||||
@@ -1839,15 +1859,23 @@ function runHeadlessStreaming(
|
||||
) {
|
||||
return
|
||||
}
|
||||
const tickContent = `<${TICK_TAG}>${new Date().toLocaleTimeString()}</${TICK_TAG}>`
|
||||
enqueue({
|
||||
mode: 'prompt' as const,
|
||||
value: tickContent,
|
||||
uuid: randomUUID(),
|
||||
priority: 'later',
|
||||
isMeta: true,
|
||||
})
|
||||
void run()
|
||||
void (async () => {
|
||||
const commands = await createProactiveAutonomyCommands({
|
||||
basePrompt: `<${TICK_TAG}>${new Date().toLocaleTimeString()}</${TICK_TAG}>`,
|
||||
currentDir: cwd(),
|
||||
shouldCreate: () => !inputClosed,
|
||||
})
|
||||
for (const command of commands) {
|
||||
if (inputClosed) {
|
||||
return
|
||||
}
|
||||
enqueue({
|
||||
...command,
|
||||
uuid: randomUUID(),
|
||||
})
|
||||
}
|
||||
void run()
|
||||
})()
|
||||
}, 0)
|
||||
}
|
||||
: undefined
|
||||
@@ -2092,6 +2120,9 @@ function runHeadlessStreaming(
|
||||
}
|
||||
|
||||
const input = command.value
|
||||
const autonomyRunIds = batch
|
||||
.map(item => item.autonomy?.runId)
|
||||
.filter((runId): runId is string => Boolean(runId))
|
||||
|
||||
if (structuredIO instanceof RemoteIO && command.mode === 'prompt') {
|
||||
logEvent('tengu_bridge_message_received', {
|
||||
@@ -2141,107 +2172,151 @@ function runHeadlessStreaming(
|
||||
// const-capture: TS loses `while ((command = dequeue()))` narrowing
|
||||
// inside the closure.
|
||||
const cmd = command
|
||||
await runWithWorkload(cmd.workload ?? options.workload, async () => {
|
||||
for await (const message of ask({
|
||||
commands: uniqBy(
|
||||
[...currentCommands, ...appState.mcp.commands],
|
||||
'name',
|
||||
),
|
||||
prompt: input,
|
||||
promptUuid: cmd.uuid,
|
||||
isMeta: cmd.isMeta,
|
||||
cwd: cwd(),
|
||||
tools: allTools,
|
||||
verbose: options.verbose,
|
||||
mcpClients: allMcpClients,
|
||||
thinkingConfig: options.thinkingConfig,
|
||||
maxTurns: options.maxTurns,
|
||||
maxBudgetUsd: options.maxBudgetUsd,
|
||||
taskBudget: options.taskBudget,
|
||||
canUseTool,
|
||||
userSpecifiedModel: activeUserSpecifiedModel,
|
||||
fallbackModel: options.fallbackModel,
|
||||
jsonSchema: getInitJsonSchema() ?? options.jsonSchema,
|
||||
mutableMessages,
|
||||
getReadFileCache: () =>
|
||||
pendingSeeds.size === 0
|
||||
? readFileState
|
||||
: mergeFileStateCaches(readFileState, pendingSeeds),
|
||||
setReadFileCache: cache => {
|
||||
readFileState = cache
|
||||
for (const [path, seed] of pendingSeeds.entries()) {
|
||||
const existing = readFileState.get(path)
|
||||
if (!existing || seed.timestamp > existing.timestamp) {
|
||||
readFileState.set(path, seed)
|
||||
for (const runId of autonomyRunIds) {
|
||||
await markAutonomyRunRunning(runId)
|
||||
}
|
||||
let lastResultIsError = false
|
||||
try {
|
||||
await runWithWorkload(
|
||||
cmd.workload ?? options.workload,
|
||||
async () => {
|
||||
for await (const message of ask({
|
||||
commands: uniqBy(
|
||||
[...currentCommands, ...appState.mcp.commands],
|
||||
'name',
|
||||
),
|
||||
prompt: input,
|
||||
promptUuid: cmd.uuid,
|
||||
isMeta: cmd.isMeta,
|
||||
cwd: cwd(),
|
||||
tools: allTools,
|
||||
verbose: options.verbose,
|
||||
mcpClients: allMcpClients,
|
||||
thinkingConfig: options.thinkingConfig,
|
||||
maxTurns: options.maxTurns,
|
||||
maxBudgetUsd: options.maxBudgetUsd,
|
||||
taskBudget: options.taskBudget,
|
||||
canUseTool,
|
||||
userSpecifiedModel: activeUserSpecifiedModel,
|
||||
fallbackModel: options.fallbackModel,
|
||||
jsonSchema: getInitJsonSchema() ?? options.jsonSchema,
|
||||
mutableMessages,
|
||||
getReadFileCache: () =>
|
||||
pendingSeeds.size === 0
|
||||
? readFileState
|
||||
: mergeFileStateCaches(readFileState, pendingSeeds),
|
||||
setReadFileCache: cache => {
|
||||
readFileState = cache
|
||||
for (const [path, seed] of pendingSeeds.entries()) {
|
||||
const existing = readFileState.get(path)
|
||||
if (!existing || seed.timestamp > existing.timestamp) {
|
||||
readFileState.set(path, seed)
|
||||
}
|
||||
}
|
||||
pendingSeeds.clear()
|
||||
},
|
||||
customSystemPrompt: options.systemPrompt,
|
||||
appendSystemPrompt: options.appendSystemPrompt,
|
||||
getAppState,
|
||||
setAppState,
|
||||
abortController,
|
||||
replayUserMessages: options.replayUserMessages,
|
||||
includePartialMessages: options.includePartialMessages,
|
||||
handleElicitation: (serverName, params, elicitSignal) =>
|
||||
structuredIO.handleElicitation(
|
||||
serverName,
|
||||
params.message,
|
||||
undefined,
|
||||
elicitSignal,
|
||||
params.mode,
|
||||
params.url,
|
||||
'elicitationId' in params
|
||||
? params.elicitationId
|
||||
: undefined,
|
||||
),
|
||||
agents: currentAgents,
|
||||
orphanedPermission: cmd.orphanedPermission,
|
||||
setSDKStatus: status => {
|
||||
output.enqueue({
|
||||
type: 'system',
|
||||
subtype: 'status',
|
||||
status: status as 'compacting' | null,
|
||||
session_id: getSessionId(),
|
||||
uuid: randomUUID(),
|
||||
})
|
||||
},
|
||||
})) {
|
||||
// Forward messages to bridge incrementally (mid-turn) so
|
||||
// claude.ai sees progress and the connection stays alive
|
||||
// while blocked on permission requests.
|
||||
forwardMessagesToBridge()
|
||||
|
||||
if (message.type === 'result') {
|
||||
lastResultIsError = !!(message as Record<string, unknown>)
|
||||
.is_error
|
||||
// Flush pending SDK events so they appear before result on the stream.
|
||||
for (const event of drainSdkEvents()) {
|
||||
output.enqueue(event)
|
||||
}
|
||||
|
||||
// Hold-back: don't emit result while background agents are running
|
||||
const currentState = getAppState()
|
||||
if (
|
||||
getRunningTasks(currentState).some(
|
||||
t =>
|
||||
(t.type === 'local_agent' ||
|
||||
t.type === 'local_workflow') &&
|
||||
isBackgroundTask(t),
|
||||
)
|
||||
) {
|
||||
heldBackResult = message as StdoutMessage
|
||||
} else {
|
||||
heldBackResult = null
|
||||
output.enqueue(message as StdoutMessage)
|
||||
}
|
||||
} else {
|
||||
// Flush SDK events (task_started, task_progress) so background
|
||||
// agent progress is streamed in real-time, not batched until result.
|
||||
for (const event of drainSdkEvents()) {
|
||||
output.enqueue(event)
|
||||
}
|
||||
output.enqueue(message as StdoutMessage)
|
||||
}
|
||||
}
|
||||
pendingSeeds.clear()
|
||||
},
|
||||
customSystemPrompt: options.systemPrompt,
|
||||
appendSystemPrompt: options.appendSystemPrompt,
|
||||
getAppState,
|
||||
setAppState,
|
||||
abortController,
|
||||
replayUserMessages: options.replayUserMessages,
|
||||
includePartialMessages: options.includePartialMessages,
|
||||
handleElicitation: (serverName, params, elicitSignal) =>
|
||||
structuredIO.handleElicitation(
|
||||
serverName,
|
||||
params.message,
|
||||
undefined,
|
||||
elicitSignal,
|
||||
params.mode,
|
||||
params.url,
|
||||
'elicitationId' in params ? params.elicitationId : undefined,
|
||||
),
|
||||
agents: currentAgents,
|
||||
orphanedPermission: cmd.orphanedPermission,
|
||||
setSDKStatus: status => {
|
||||
output.enqueue({
|
||||
type: 'system',
|
||||
subtype: 'status',
|
||||
status: status as 'compacting' | null,
|
||||
session_id: getSessionId(),
|
||||
uuid: randomUUID(),
|
||||
) // end runWithWorkload
|
||||
if (lastResultIsError) {
|
||||
for (const runId of autonomyRunIds) {
|
||||
await finalizeAutonomyRunFailed({
|
||||
runId,
|
||||
error: 'ask() returned an error result',
|
||||
})
|
||||
},
|
||||
})) {
|
||||
// Forward messages to bridge incrementally (mid-turn) so
|
||||
// claude.ai sees progress and the connection stays alive
|
||||
// while blocked on permission requests.
|
||||
forwardMessagesToBridge()
|
||||
|
||||
if (message.type === 'result') {
|
||||
// Flush pending SDK events so they appear before result on the stream.
|
||||
for (const event of drainSdkEvents()) {
|
||||
output.enqueue(event)
|
||||
}
|
||||
} else {
|
||||
for (const runId of autonomyRunIds) {
|
||||
const nextCommands = await finalizeAutonomyRunCompleted({
|
||||
runId,
|
||||
currentDir: cwd(),
|
||||
priority: 'later',
|
||||
workload: cmd.workload ?? options.workload,
|
||||
})
|
||||
for (const nextCommand of nextCommands) {
|
||||
enqueue({
|
||||
...nextCommand,
|
||||
uuid: randomUUID(),
|
||||
})
|
||||
}
|
||||
|
||||
// Hold-back: don't emit result while background agents are running
|
||||
const currentState = getAppState()
|
||||
if (
|
||||
getRunningTasks(currentState).some(
|
||||
t =>
|
||||
(t.type === 'local_agent' ||
|
||||
t.type === 'local_workflow') &&
|
||||
isBackgroundTask(t),
|
||||
)
|
||||
) {
|
||||
heldBackResult = message as StdoutMessage
|
||||
} else {
|
||||
heldBackResult = null
|
||||
output.enqueue(message as StdoutMessage)
|
||||
}
|
||||
} else {
|
||||
// Flush SDK events (task_started, task_progress) so background
|
||||
// agent progress is streamed in real-time, not batched until result.
|
||||
for (const event of drainSdkEvents()) {
|
||||
output.enqueue(event)
|
||||
}
|
||||
output.enqueue(message as StdoutMessage)
|
||||
}
|
||||
}
|
||||
}) // end runWithWorkload
|
||||
} catch (error) {
|
||||
for (const runId of autonomyRunIds) {
|
||||
await finalizeAutonomyRunFailed({
|
||||
runId,
|
||||
error: String(error),
|
||||
})
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
for (const uuid of batchUuids) {
|
||||
notifyCommandLifecycle(uuid, 'completed')
|
||||
@@ -2253,10 +2328,15 @@ function runHeadlessStreaming(
|
||||
|
||||
if (feature('FILE_PERSISTENCE') && turnStartTime !== undefined) {
|
||||
void executeFilePersistence(
|
||||
{ turnStartTime } as import('src/utils/filePersistence/types.js').TurnStartTime,
|
||||
{
|
||||
turnStartTime,
|
||||
} as import('src/utils/filePersistence/types.js').TurnStartTime,
|
||||
abortController.signal,
|
||||
result => {
|
||||
const filesResult = result as unknown as { persistedFiles: { filename: string; file_id: string }[]; failedFiles: { filename: string; error: string }[] }
|
||||
const filesResult = result as unknown as {
|
||||
persistedFiles: { filename: string; file_id: string }[]
|
||||
failedFiles: { filename: string; error: string }[]
|
||||
}
|
||||
output.enqueue({
|
||||
type: 'system' as const,
|
||||
subtype: 'files_persisted' as const,
|
||||
@@ -2700,28 +2780,73 @@ function runHeadlessStreaming(
|
||||
// the end of run() picks up the queued command.
|
||||
let cronScheduler: import('../utils/cronScheduler.js').CronScheduler | null =
|
||||
null
|
||||
if (
|
||||
cronGate.isKairosCronEnabled()
|
||||
) {
|
||||
if (cronGate.isKairosCronEnabled()) {
|
||||
cronScheduler = cronSchedulerModule.createCronScheduler({
|
||||
onFire: prompt => {
|
||||
if (inputClosed) return
|
||||
enqueue({
|
||||
mode: 'prompt',
|
||||
value: prompt,
|
||||
uuid: randomUUID(),
|
||||
priority: 'later',
|
||||
// System-generated — matches useScheduledTasks.ts REPL equivalent.
|
||||
// Without this, messages.ts metaProp eval is {} → prompt leaks
|
||||
// into visible transcript when cron fires mid-turn in -p mode.
|
||||
isMeta: true,
|
||||
// Threaded to cc_workload= in the billing-header attribution block
|
||||
// so the API can serve cron requests at lower QoS. drainCommandQueue
|
||||
// reads this per-iteration and hoists it into bootstrap state for
|
||||
// the ask() call.
|
||||
workload: WORKLOAD_CRON,
|
||||
})
|
||||
void run()
|
||||
void (async () => {
|
||||
const prepared = await prepareAutonomyTurnPrompt({
|
||||
basePrompt: prompt,
|
||||
trigger: 'scheduled-task',
|
||||
currentDir: cwd(),
|
||||
})
|
||||
if (inputClosed) return
|
||||
const command = await commitAutonomyQueuedPrompt({
|
||||
prepared,
|
||||
currentDir: cwd(),
|
||||
workload: WORKLOAD_CRON,
|
||||
})
|
||||
if (inputClosed) return
|
||||
enqueue({
|
||||
...command,
|
||||
uuid: randomUUID(),
|
||||
})
|
||||
void run()
|
||||
})()
|
||||
},
|
||||
onFireTask: task => {
|
||||
if (inputClosed) return
|
||||
void (async () => {
|
||||
if (task.agentId) {
|
||||
const prepared = await prepareAutonomyTurnPrompt({
|
||||
basePrompt: task.prompt,
|
||||
trigger: 'scheduled-task',
|
||||
currentDir: cwd(),
|
||||
})
|
||||
if (inputClosed) return
|
||||
const command = await commitAutonomyQueuedPrompt({
|
||||
prepared,
|
||||
currentDir: cwd(),
|
||||
sourceId: task.id,
|
||||
sourceLabel: task.prompt,
|
||||
workload: WORKLOAD_CRON,
|
||||
})
|
||||
await markAutonomyRunFailed(
|
||||
command.autonomy!.runId,
|
||||
`No teammate runtime available for scheduled task owner ${task.agentId} in headless mode.`,
|
||||
)
|
||||
return
|
||||
}
|
||||
const prepared = await prepareAutonomyTurnPrompt({
|
||||
basePrompt: task.prompt,
|
||||
trigger: 'scheduled-task',
|
||||
currentDir: cwd(),
|
||||
})
|
||||
if (inputClosed) return
|
||||
const command = await commitAutonomyQueuedPrompt({
|
||||
prepared,
|
||||
currentDir: cwd(),
|
||||
sourceId: task.id,
|
||||
sourceLabel: task.prompt,
|
||||
workload: WORKLOAD_CRON,
|
||||
})
|
||||
if (inputClosed) return
|
||||
enqueue({
|
||||
...command,
|
||||
uuid: randomUUID(),
|
||||
})
|
||||
void run()
|
||||
})()
|
||||
},
|
||||
isLoading: () => running || inputClosed,
|
||||
getJitterConfig: cronJitterConfigModule?.getCronJitterConfig,
|
||||
@@ -2996,7 +3121,9 @@ function runHeadlessStreaming(
|
||||
sdkClient.type === 'connected' &&
|
||||
sdkClient.client?.transport?.onmessage
|
||||
) {
|
||||
sdkClient.client.transport.onmessage(mcpRequest.message as import('@modelcontextprotocol/sdk/types.js').JSONRPCMessage)
|
||||
sdkClient.client.transport.onmessage(
|
||||
mcpRequest.message as import('@modelcontextprotocol/sdk/types.js').JSONRPCMessage,
|
||||
)
|
||||
}
|
||||
sendControlResponseSuccess(msg)
|
||||
} else if (msg.request.subtype === 'rewind_files') {
|
||||
@@ -3061,7 +3188,10 @@ function runHeadlessStreaming(
|
||||
sendControlResponseSuccess(msg)
|
||||
} else if (msg.request.subtype === 'mcp_set_servers') {
|
||||
const { response, sdkServersChanged } = await applyMcpServerChanges(
|
||||
msg.request.servers as Record<string, McpServerConfigForProcessTransport>,
|
||||
msg.request.servers as Record<
|
||||
string,
|
||||
McpServerConfigForProcessTransport
|
||||
>,
|
||||
)
|
||||
sendControlResponseSuccess(msg, response)
|
||||
|
||||
@@ -3131,7 +3261,8 @@ function runHeadlessStreaming(
|
||||
model: a.model === 'inherit' ? undefined : a.model,
|
||||
})),
|
||||
plugins,
|
||||
mcpServers: buildMcpServerStatuses() as SDKControlReloadPluginsResponse['mcpServers'],
|
||||
mcpServers:
|
||||
buildMcpServerStatuses() as SDKControlReloadPluginsResponse['mcpServers'],
|
||||
error_count: r.error_count,
|
||||
} satisfies SDKControlReloadPluginsResponse)
|
||||
} catch (error) {
|
||||
@@ -3406,7 +3537,7 @@ function runHeadlessStreaming(
|
||||
mcp: {
|
||||
...prev.mcp,
|
||||
clients: prev.mcp.clients.map(c =>
|
||||
c.name === serverName as string ? result.client : c,
|
||||
c.name === (serverName as string) ? result.client : c,
|
||||
),
|
||||
tools: [
|
||||
...reject(prev.mcp.tools, t =>
|
||||
@@ -3455,7 +3586,9 @@ function runHeadlessStreaming(
|
||||
})
|
||||
.finally(() => {
|
||||
// Clean up only if this is still the active flow
|
||||
if (activeOAuthFlows.get(serverName as string) === controller) {
|
||||
if (
|
||||
activeOAuthFlows.get(serverName as string) === controller
|
||||
) {
|
||||
activeOAuthFlows.delete(serverName as string)
|
||||
oauthCallbackSubmitters.delete(serverName as string)
|
||||
oauthManualCallbackUsed.delete(serverName as string)
|
||||
@@ -3570,7 +3703,9 @@ function runHeadlessStreaming(
|
||||
// next API call re-reads keychain/file and works. No respawn.
|
||||
await installOAuthTokens(tokens)
|
||||
logEvent('tengu_oauth_success', {
|
||||
loginWithClaudeAi: (loginWithClaudeAi ?? true) as boolean | number,
|
||||
loginWithClaudeAi: (loginWithClaudeAi ?? true) as
|
||||
| boolean
|
||||
| number,
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -3618,10 +3753,7 @@ function runHeadlessStreaming(
|
||||
req.subtype === 'claude_oauth_wait_for_completion'
|
||||
) {
|
||||
if (!claudeOAuth) {
|
||||
sendControlResponseError(
|
||||
msg,
|
||||
'No active claude_authenticate flow',
|
||||
)
|
||||
sendControlResponseError(msg, 'No active claude_authenticate flow')
|
||||
} else {
|
||||
// Inject the manual code synchronously — must happen in stdin
|
||||
// message order so a subsequent claude_authenticate doesn't
|
||||
@@ -3681,7 +3813,7 @@ function runHeadlessStreaming(
|
||||
mcp: {
|
||||
...prev.mcp,
|
||||
clients: prev.mcp.clients.map(c =>
|
||||
c.name === serverName as string ? result.client : c,
|
||||
c.name === (serverName as string) ? result.client : c,
|
||||
),
|
||||
tools: [
|
||||
...reject(prev.mcp.tools, t => t.name?.startsWith(prefix)),
|
||||
@@ -4116,9 +4248,13 @@ function runHeadlessStreaming(
|
||||
mode: 'prompt' as const,
|
||||
// file_attachments rides the protobuf catchall from the web composer.
|
||||
// Same-ref no-op when absent (no 'file_attachments' key).
|
||||
value: await resolveAndPrepend(userMsg, (userMsg.message as { content: ContentBlockParam[] }).content),
|
||||
value: await resolveAndPrepend(
|
||||
userMsg,
|
||||
(userMsg.message as { content: ContentBlockParam[] }).content,
|
||||
),
|
||||
uuid: userMsg.uuid as `${string}-${string}-${string}-${string}-${string}`,
|
||||
priority: (userMsg as { priority?: string }).priority as import('src/types/textInputTypes.js').QueuePriority,
|
||||
priority: (userMsg as { priority?: string })
|
||||
.priority as import('src/types/textInputTypes.js').QueuePriority,
|
||||
})
|
||||
// Increment prompt count for attribution tracking and save snapshot
|
||||
// The snapshot persists promptCount so it survives compaction
|
||||
@@ -4447,7 +4583,10 @@ async function handleInitializeRequest(
|
||||
const accountInfo = getAccountInformation()
|
||||
if (request.hooks) {
|
||||
const hooks: Partial<Record<HookEvent, HookCallbackMatcher[]>> = {}
|
||||
for (const [event, matchers] of Object.entries(request.hooks) as [string, Array<{ hookCallbackIds: string[]; timeout?: number; matcher?: string }>][]) {
|
||||
for (const [event, matchers] of Object.entries(request.hooks) as [
|
||||
string,
|
||||
Array<{ hookCallbackIds: string[]; timeout?: number; matcher?: string }>,
|
||||
][]) {
|
||||
hooks[event as HookEvent] = matchers.map(matcher => {
|
||||
const callbacks = matcher.hookCallbackIds.map(callbackId => {
|
||||
return structuredIO.createHookCallback(callbackId, matcher.timeout)
|
||||
@@ -4489,7 +4628,11 @@ async function handleInitializeRequest(
|
||||
// getAccountInformation() returns undefined under 3P providers, so the
|
||||
// other fields are all absent. apiProvider disambiguates "not logged
|
||||
// in" (firstParty + tokenSource:none) from "3P, login not applicable".
|
||||
apiProvider: getAPIProvider() as 'firstParty' | 'bedrock' | 'vertex' | 'foundry',
|
||||
apiProvider: getAPIProvider() as
|
||||
| 'firstParty'
|
||||
| 'bedrock'
|
||||
| 'vertex'
|
||||
| 'foundry',
|
||||
},
|
||||
pid: process.pid,
|
||||
}
|
||||
@@ -4537,7 +4680,11 @@ async function handleRewindFiles(
|
||||
dryRun: boolean,
|
||||
): Promise<RewindFilesResult> {
|
||||
if (!fileHistoryEnabled()) {
|
||||
return { canRewind: false, error: 'File rewinding is not enabled.', filesChanged: [] }
|
||||
return {
|
||||
canRewind: false,
|
||||
error: 'File rewinding is not enabled.',
|
||||
filesChanged: [],
|
||||
}
|
||||
}
|
||||
if (!fileHistoryCanRestore(appState.fileHistory, userMessageId)) {
|
||||
return {
|
||||
@@ -4842,7 +4989,10 @@ function reregisterChannelHandlerAfterReconnect(
|
||||
value: wrapChannelMessage(connection.name, content, meta),
|
||||
priority: 'next',
|
||||
isMeta: true,
|
||||
origin: { kind: 'channel', server: connection.name } as unknown as string,
|
||||
origin: {
|
||||
kind: 'channel',
|
||||
server: connection.name,
|
||||
} as unknown as string,
|
||||
skipSlashCommands: true,
|
||||
})
|
||||
},
|
||||
@@ -5266,13 +5416,21 @@ export async function handleOrphanedPermissionResponse({
|
||||
onEnqueued?: () => void
|
||||
handledToolUseIds: Set<string>
|
||||
}): Promise<boolean> {
|
||||
const responseInner = message.response as { subtype?: string; response?: Record<string, unknown>; request_id?: string } | undefined
|
||||
const responseInner = message.response as
|
||||
| {
|
||||
subtype?: string
|
||||
response?: Record<string, unknown>
|
||||
request_id?: string
|
||||
}
|
||||
| undefined
|
||||
if (
|
||||
responseInner?.subtype === 'success' &&
|
||||
responseInner.response?.toolUseID &&
|
||||
typeof responseInner.response.toolUseID === 'string'
|
||||
) {
|
||||
const permissionResult = responseInner.response as PermissionResult & { toolUseID?: string }
|
||||
const permissionResult = responseInner.response as PermissionResult & {
|
||||
toolUseID?: string
|
||||
}
|
||||
const toolUseID = permissionResult.toolUseID
|
||||
if (!toolUseID) {
|
||||
return false
|
||||
|
||||
@@ -1,2 +1,70 @@
|
||||
// Auto-generated stub
|
||||
export async function rollback(target?: string, options?: { list?: boolean; dryRun?: boolean; safe?: boolean }): Promise<void> {}
|
||||
/**
|
||||
* `claude rollback [target]` — roll back to a previous Claude Code version.
|
||||
*
|
||||
* ANT-only command (USER_TYPE === "ant").
|
||||
*
|
||||
* Options:
|
||||
* --list List recent published versions
|
||||
* --dry-run Show what would be installed without installing
|
||||
* --safe Roll back to the server-pinned safe version
|
||||
*/
|
||||
export async function rollback(
|
||||
target?: string,
|
||||
options?: { list?: boolean; dryRun?: boolean; safe?: boolean },
|
||||
): Promise<void> {
|
||||
if (options?.list) {
|
||||
console.log('Recent versions:')
|
||||
console.log(' (version listing requires access to the release registry)')
|
||||
console.log(' Use `claude update --list` for available versions.')
|
||||
return
|
||||
}
|
||||
|
||||
if (options?.safe) {
|
||||
console.log('Safe rollback: would install the server-pinned safe version.')
|
||||
if (options.dryRun) {
|
||||
console.log(' (dry run — no changes made)')
|
||||
return
|
||||
}
|
||||
console.log(' Safe version pinning requires access to the release API.')
|
||||
console.log(' Contact oncall for the current safe version.')
|
||||
return
|
||||
}
|
||||
|
||||
if (!target) {
|
||||
console.error(
|
||||
'Usage: claude rollback [target]\n\n' +
|
||||
'Options:\n' +
|
||||
' -l, --list List recent published versions\n' +
|
||||
' --dry-run Show what would be installed\n' +
|
||||
' --safe Roll back to server-pinned safe version\n\n' +
|
||||
'Examples:\n' +
|
||||
' claude rollback 2.1.880\n' +
|
||||
' claude rollback --list\n' +
|
||||
' claude rollback --safe',
|
||||
)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`Rolling back to version ${target}...`)
|
||||
|
||||
if (options?.dryRun) {
|
||||
console.log(` (dry run — would install ${target})`)
|
||||
return
|
||||
}
|
||||
|
||||
// Version rollback via npm/bun
|
||||
const { spawnSync } = await import('child_process')
|
||||
const result = spawnSync(
|
||||
'npm',
|
||||
['install', '-g', `@anthropic-ai/claude-code@${target}`],
|
||||
{ stdio: 'inherit' },
|
||||
)
|
||||
|
||||
if (result.status !== 0) {
|
||||
console.error(`Rollback failed with exit code ${result.status}`)
|
||||
process.exitCode = result.status ?? 1
|
||||
} else {
|
||||
console.log(`Rolled back to ${target} successfully.`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +1,95 @@
|
||||
// Auto-generated stub
|
||||
export async function up(): Promise<void> {}
|
||||
import { readFileSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { spawnSync } from 'child_process'
|
||||
import { findGitRoot } from '../utils/git.js'
|
||||
|
||||
/**
|
||||
* `claude up` — run the "# claude up" section from the nearest CLAUDE.md.
|
||||
*
|
||||
* Walks up from CWD looking for CLAUDE.md files, extracts the section
|
||||
* under the `# claude up` heading, and executes it as a shell script.
|
||||
*
|
||||
* ANT-only command (USER_TYPE === "ant").
|
||||
*/
|
||||
export async function up(): Promise<void> {
|
||||
const cwd = process.cwd()
|
||||
const gitRoot = findGitRoot(cwd)
|
||||
const searchDirs = gitRoot ? [gitRoot, cwd] : [cwd]
|
||||
|
||||
let upSection: string | null = null
|
||||
|
||||
for (const dir of searchDirs) {
|
||||
const claudeMdPath = join(dir, 'CLAUDE.md')
|
||||
try {
|
||||
const content = readFileSync(claudeMdPath, 'utf-8')
|
||||
upSection = extractUpSection(content)
|
||||
if (upSection) {
|
||||
console.log(`Found "# claude up" in ${claudeMdPath}`)
|
||||
break
|
||||
}
|
||||
} catch {
|
||||
// File not found — continue searching
|
||||
}
|
||||
}
|
||||
|
||||
if (!upSection) {
|
||||
console.log(
|
||||
'No "# claude up" section found in CLAUDE.md.\n' +
|
||||
'Add a section like:\n\n' +
|
||||
' # claude up\n' +
|
||||
' ```bash\n' +
|
||||
' npm install\n' +
|
||||
' npm run build\n' +
|
||||
' ```',
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
console.log('Running:\n')
|
||||
console.log(upSection)
|
||||
console.log()
|
||||
|
||||
const result = spawnSync('bash', ['-c', upSection], {
|
||||
cwd,
|
||||
stdio: 'inherit',
|
||||
})
|
||||
|
||||
if (result.status !== 0) {
|
||||
console.error(`\nclaude up failed with exit code ${result.status}`)
|
||||
process.exitCode = result.status ?? 1
|
||||
} else {
|
||||
console.log('\nclaude up completed successfully.')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the content under "# claude up" heading from markdown.
|
||||
* Returns the text between `# claude up` and the next `#` heading (or EOF).
|
||||
* Strips fenced code block markers if present.
|
||||
*/
|
||||
function extractUpSection(markdown: string): string | null {
|
||||
const lines = markdown.split('\n')
|
||||
let inSection = false
|
||||
const sectionLines: string[] = []
|
||||
|
||||
for (const line of lines) {
|
||||
if (/^#\s+claude\s+up\b/i.test(line)) {
|
||||
inSection = true
|
||||
continue
|
||||
}
|
||||
if (inSection && /^#\s/.test(line)) {
|
||||
break
|
||||
}
|
||||
if (inSection) {
|
||||
sectionLines.push(line)
|
||||
}
|
||||
}
|
||||
|
||||
if (sectionLines.length === 0) return null
|
||||
|
||||
// Strip fenced code block markers
|
||||
let text = sectionLines.join('\n').trim()
|
||||
text = text.replace(/^```\w*\n?/, '').replace(/\n?```\s*$/, '')
|
||||
|
||||
return text.trim() || null
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user