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 from 637c908 dropped 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 from 637c908 dropped 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:
claude-code-best
2026-04-16 20:59:29 +08:00
committed by GitHub
parent a02dc0bded
commit c8d08d235b
137 changed files with 13267 additions and 837 deletions

View File

@@ -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

View 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)
})
})

View 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')
})
})

View 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
View 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>
}

View 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)
}
}

View 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()
}

View 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
View 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
}
})
})
}

View File

@@ -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}.`)
}
}

View File

@@ -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
}
}

View File

@@ -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

View File

@@ -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.`)
}
}

View File

@@ -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
}