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

@@ -0,0 +1,91 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import {
resetStateForTests,
setOriginalCwd,
setProjectRoot,
} from '../bootstrap/state'
import {
getSystemContext,
getUserContext,
setSystemPromptInjection,
} from '../context'
import { clearMemoryFileCaches } from '../utils/claudemd'
import { cleanupTempDir, createTempDir, writeTempFile } from '../../tests/mocks/file-system'
let tempDir = ''
let projectClaudeMdContent = ''
beforeEach(async () => {
tempDir = await createTempDir('context-baseline-')
projectClaudeMdContent = `baseline-${Date.now()}`
resetStateForTests()
setOriginalCwd(tempDir)
setProjectRoot(tempDir)
await writeTempFile(tempDir, 'CLAUDE.md', projectClaudeMdContent)
clearMemoryFileCaches()
getUserContext.cache.clear?.()
getSystemContext.cache.clear?.()
setSystemPromptInjection(null)
delete process.env.CLAUDE_CODE_DISABLE_CLAUDE_MDS
})
afterEach(async () => {
clearMemoryFileCaches()
getUserContext.cache.clear?.()
getSystemContext.cache.clear?.()
setSystemPromptInjection(null)
delete process.env.CLAUDE_CODE_DISABLE_CLAUDE_MDS
resetStateForTests()
if (tempDir) {
await cleanupTempDir(tempDir)
}
})
describe('context baseline', () => {
test('getUserContext includes currentDate and project CLAUDE.md content', async () => {
const ctx = await getUserContext()
expect(ctx.currentDate).toContain("Today's date is")
expect(ctx.claudeMd).toContain(projectClaudeMdContent)
})
test('CLAUDE_CODE_DISABLE_CLAUDE_MDS suppresses claudeMd loading', async () => {
process.env.CLAUDE_CODE_DISABLE_CLAUDE_MDS = '1'
const ctx = await getUserContext()
expect(ctx.currentDate).toContain("Today's date is")
expect(ctx.claudeMd).toBeUndefined()
})
test('setSystemPromptInjection clears the memoized user-context cache', async () => {
const first = await getUserContext()
process.env.CLAUDE_CODE_DISABLE_CLAUDE_MDS = '1'
const second = await getUserContext()
expect(first.claudeMd).toContain(projectClaudeMdContent)
expect(second.claudeMd).toContain(projectClaudeMdContent)
setSystemPromptInjection('cache-break')
const third = await getUserContext()
expect(third.claudeMd).toBeUndefined()
})
test('getSystemContext reflects system prompt injection after cache invalidation', async () => {
const first = await getSystemContext()
expect(first.gitStatus).toBeUndefined()
expect(first.cacheBreaker).toBeUndefined()
setSystemPromptInjection('baseline-cache-break')
const second = await getSystemContext()
if ('cacheBreaker' in second) {
expect(second.cacheBreaker).toContain('baseline-cache-break')
} else {
expect(second.gitStatus).toBeUndefined()
}
})
})

View File

@@ -1,3 +0,0 @@
// Auto-generated stub — replace with real implementation
export {};
export const AssistantSessionChooser: (props: Record<string, unknown>) => null = () => null;

View File

@@ -0,0 +1,54 @@
import * as React from 'react';
import { useState } from 'react';
import { Box, Text } from '@anthropic/ink';
import { Dialog } from '../components/design-system/Dialog.js';
import { ListItem } from '../components/design-system/ListItem.js';
import { useRegisterOverlay } from '../context/overlayContext.js';
import { useKeybindings } from '../keybindings/useKeybinding.js';
import type { AssistantSession } from './sessionDiscovery.js';
interface Props {
sessions: AssistantSession[];
onSelect: (id: string) => void;
onCancel: () => void;
}
/**
* Interactive session chooser for `claude assistant` when multiple
* CCR sessions are discovered. Renders a Dialog with up/down navigation.
*
* Session IDs are in `session_*` compat format — passed directly to
* createRemoteSessionConfig() for viewer attach.
*/
export function AssistantSessionChooser({ sessions, onSelect, onCancel }: Props): React.ReactNode {
useRegisterOverlay('assistant-session-chooser');
const [focusIndex, setFocusIndex] = useState(0);
useKeybindings(
{
'select:next': () => setFocusIndex(i => (i + 1) % sessions.length),
'select:previous': () => setFocusIndex(i => (i - 1 + sessions.length) % sessions.length),
'select:accept': () => onSelect(sessions[focusIndex]!.id),
},
{ context: 'Select' },
);
return (
<Dialog title="Select Assistant Session" onCancel={onCancel} hideInputGuide>
<Box flexDirection="column" gap={1}>
<Text>Multiple sessions found. Select one to attach:</Text>
<Box flexDirection="column">
{sessions.map((s, i) => (
<ListItem key={s.id} isFocused={focusIndex === i}>
<Box>
<Text>{s.title || s.id.slice(0, 20)}</Text>
<Text dimColor> [{s.status}]</Text>
</Box>
</ListItem>
))}
</Box>
<Text dimColor> navigate · Enter select · Esc cancel</Text>
</Box>
</Dialog>
);
}

View File

@@ -5,21 +5,20 @@ import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growt
/**
* Runtime gate for KAIROS features.
*
* Build-time: feature('KAIROS') must be on (checked by caller before
* this module is required).
* Two-layer gate:
* 1. Build-time: feature('KAIROS') must be on
* 2. Runtime: tengu_kairos_assistant GrowthBook flag (remote kill switch)
*
* Runtime: tengu_kairos_assistant GrowthBook flag acts as a remote kill
* switch, and kairosActive state must be true (set during bootstrap when
* the session qualifies for KAIROS features).
* Called by main.tsx BEFORE setKairosActive(true) — must NOT check
* kairosActive (that would deadlock: gate needs active, active needs gate).
* The caller (main.tsx L1826-1832) sets kairosActive after this returns true.
*/
export async function isKairosEnabled(): Promise<boolean> {
if (!feature('KAIROS')) {
return false
}
if (
!getFeatureValue_CACHED_MAY_BE_STALE('tengu_kairos_assistant', false)
) {
if (!getFeatureValue_CACHED_MAY_BE_STALE('tengu_kairos_assistant', false)) {
return false
}
return getKairosActive()
return true
}

View File

@@ -1,9 +1,64 @@
// Auto-generated stub — replace with real implementation
export {}
export const isAssistantMode: () => boolean = () => false
export const initializeAssistantTeam: () => Promise<void> = async () => {}
export const markAssistantForced: () => void = () => {}
export const isAssistantForced: () => boolean = () => false
export const getAssistantSystemPromptAddendum: () => string = () => ''
export const getAssistantActivationPath: () => string | undefined = () =>
undefined
import { readFileSync } from 'fs'
import { join } from 'path'
import { getKairosActive } from '../bootstrap/state.js'
import { getClaudeConfigHomeDir } from '../utils/envUtils.js'
let _assistantForced = false
/**
* Whether the current session is in assistant (KAIROS) daemon mode.
* Wraps the bootstrap kairosActive state set by main.tsx after gate check.
*/
export function isAssistantMode(): boolean {
return getKairosActive()
}
/**
* Mark this session as forced assistant mode (--assistant flag).
* Skips the GrowthBook gate check — daemon is pre-entitled.
*/
export function markAssistantForced(): void {
_assistantForced = true
}
export function isAssistantForced(): boolean {
return _assistantForced
}
/**
* Pre-create an in-process team so Agent(name) can spawn teammates
* without TeamCreate.
*
* Phase 1: returns undefined so main.tsx's `assistantTeamContext ?? computeInitialTeamContext()`
* correctly falls back. Returning {} would bypass the ?? operator since {} is truthy.
*
* Phase 2: should return a full team context object matching AppState.teamContext shape.
*/
export async function initializeAssistantTeam(): Promise<undefined> {
return undefined
}
/**
* Assistant-specific system prompt addendum loaded from ~/.claude/agents/assistant.md.
* Returns empty string if the file doesn't exist.
*/
export function getAssistantSystemPromptAddendum(): string {
try {
return readFileSync(
join(getClaudeConfigHomeDir(), 'agents', 'assistant.md'),
'utf-8',
)
} catch {
return ''
}
}
/**
* How assistant mode was activated. Used for diagnostics/analytics.
* - 'daemon': via --assistant flag (Agent SDK daemon)
* - 'gate': via GrowthBook gate check
*/
export function getAssistantActivationPath(): string | undefined {
if (!isAssistantMode()) return undefined
return _assistantForced ? 'daemon' : 'gate'
}

View File

@@ -1,3 +1,51 @@
// Auto-generated stub — replace with real implementation
export type AssistantSession = { id: string; [key: string]: unknown };
export const discoverAssistantSessions: () => Promise<AssistantSession[]> = () => Promise.resolve([]);
import { logForDebugging } from '../utils/debug.js'
/**
* Minimal session type for assistant discovery.
* Only `id` is consumed by main.tsx (L4757); other fields are for chooser display.
* ID format is `session_*` (compat prefix) — viewer endpoints use /v1/sessions/*.
*/
export type AssistantSession = {
id: string
title: string
status: string
created_at: string
}
/**
* Discover assistant sessions on Anthropic CCR.
*
* Reuses the existing fetchCodeSessionsFromSessionsAPI() which calls
* GET /v1/sessions with proper OAuth + anthropic-beta headers.
*
* Throws on failure — main.tsx L4720-4725 catch displays the error.
* Does NOT return [] on error (that would silently redirect to install wizard).
*/
export async function discoverAssistantSessions(): Promise<AssistantSession[]> {
const { fetchCodeSessionsFromSessionsAPI } = await import(
'../utils/teleport/api.js'
)
let allSessions
try {
allSessions = await fetchCodeSessionsFromSessionsAPI()
} catch (err) {
logForDebugging(
`[assistant:discovery] fetchCodeSessionsFromSessionsAPI failed: ${err}`,
)
throw err
}
// Filter to active/working sessions only — completed/archived are not attachable
return allSessions
.filter(
s =>
s.status === 'idle' || s.status === 'working' || s.status === 'waiting',
)
.map(s => ({
id: s.id,
title: s.title || 'Untitled',
status: s.status,
created_at: s.created_at ?? '',
}))
}

View File

@@ -12,6 +12,7 @@ import {
logEventAsync,
} from '../services/analytics/index.js'
import { isInBundledMode } from '../utils/bundledMode.js'
import { getBootstrapArgs, getScriptPath } from '../utils/cliLaunch.js'
import { logForDebugging } from '../utils/debug.js'
import { rcLog } from './rcDebugLog.js'
import { logForDiagnosticsNoPII } from '../utils/diagLogs.js'
@@ -111,17 +112,15 @@ function pollSleepDetectionThresholdMs(backoff: BackoffConfig): number {
/**
* Returns the args that must precede CLI flags when spawning a child claude
* process. In compiled binaries, process.execPath is the claude binary itself
* and args go directly to it. In npm installs (node running cli.js),
* process.execPath is the node runtime — the child spawn must pass the script
* path as the first arg, otherwise node interprets --sdk-url as a node option
* and exits with "bad option: --sdk-url". See anthropics/claude-code#28334.
* process. Delegates to the centralized cliLaunch module which handles
* bundled-vs-script mode, execArgv sanitization, and the Bun execArgv leak
* quirk. See anthropics/claude-code#28334.
*/
function spawnScriptArgs(): string[] {
if (isInBundledMode() || !process.argv[1]) {
return []
}
return [process.argv[1]]
const bootstrap = [...getBootstrapArgs()]
const script = getScriptPath()
if (script) bootstrap.push(script)
return bootstrap
}
/** Attempt to spawn a session; returns error string if spawn throws. */

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
}

View File

@@ -25,6 +25,7 @@ import ide from './commands/ide/index.js'
import init from './commands/init.js'
import initVerifiers from './commands/init-verifiers.js'
import keybindings from './commands/keybindings/index.js'
import lang from './commands/lang/index.js'
import login from './commands/login/index.js'
import logout from './commands/logout/index.js'
import installGitHubApp from './commands/install-github-app/index.js'
@@ -111,6 +112,13 @@ const ultraplan = feature('ULTRAPLAN')
? require('./commands/ultraplan.js').default
: null
const torch = feature('TORCH') ? require('./commands/torch.js').default : null
const daemonCmd =
feature('DAEMON') || feature('BG_SESSIONS')
? require('./commands/daemon/index.js').default
: null
const jobCmd = feature('TEMPLATES')
? require('./commands/job/index.js').default
: null
const peersCmd = feature('UDS_INBOX')
? (
require('./commands/peers/index.js') as typeof import('./commands/peers/index.js')
@@ -182,6 +190,7 @@ import sandboxToggle from './commands/sandbox-toggle/index.js'
import chrome from './commands/chrome/index.js'
import stickers from './commands/stickers/index.js'
import advisor from './commands/advisor.js'
import autonomy from './commands/autonomy.js'
import provider from './commands/provider.js'
import { logError } from './utils/log.js'
import { toError } from './utils/errors.js'
@@ -290,6 +299,7 @@ export const INTERNAL_ONLY_COMMANDS = [
const COMMANDS = memoize((): Command[] => [
addDir,
advisor,
autonomy,
provider,
agents,
branch,
@@ -315,6 +325,7 @@ const COMMANDS = memoize((): Command[] => [
ide,
init,
keybindings,
lang,
installGitHubApp,
installSlackApp,
mcp,
@@ -384,6 +395,8 @@ const COMMANDS = memoize((): Command[] => [
...(workflowsCmd ? [workflowsCmd] : []),
...(ultraplan ? [ultraplan] : []),
...(torch ? [torch] : []),
...(daemonCmd ? [daemonCmd] : []),
...(jobCmd ? [jobCmd] : []),
...(process.env.USER_TYPE === 'ant' && !process.env.IS_DEMO
? INTERNAL_ONLY_COMMANDS
: []),

View File

@@ -0,0 +1,246 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import autonomyCommand from '../autonomy'
import type { LocalCommandResult } from '../../types/command'
import {
resetStateForTests,
setOriginalCwd,
setProjectRoot,
} from '../../bootstrap/state'
function expectTextResult(
result: LocalCommandResult,
): asserts result is Extract<LocalCommandResult, { type: 'text' }> {
if (result.type !== 'text')
throw new Error(`Expected text result, got ${result.type}`)
}
import { listAutonomyFlows } from '../../utils/autonomyFlows'
import {
createAutonomyQueuedPrompt,
markAutonomyRunCompleted,
startManagedAutonomyFlowFromHeartbeatTask,
} from '../../utils/autonomyRuns'
import {
enqueuePendingNotification,
getCommandQueueSnapshot,
resetCommandQueue,
} from '../../utils/messageQueueManager'
import { cleanupTempDir, createTempDir } from '../../../tests/mocks/file-system'
let tempDir = ''
beforeEach(async () => {
tempDir = await createTempDir('autonomy-command-')
resetStateForTests()
resetCommandQueue()
setOriginalCwd(tempDir)
setProjectRoot(tempDir)
})
afterEach(async () => {
resetStateForTests()
resetCommandQueue()
if (tempDir) {
await cleanupTempDir(tempDir)
}
})
describe('/autonomy', () => {
test('status reports autonomy runs and managed flows separately', async () => {
const plainRun = await createAutonomyQueuedPrompt({
basePrompt: 'scheduled prompt',
trigger: 'scheduled-task',
rootDir: tempDir,
currentDir: tempDir,
sourceLabel: 'nightly',
})
expect(plainRun).not.toBeNull()
await markAutonomyRunCompleted(plainRun!.autonomy!.runId, tempDir)
await startManagedAutonomyFlowFromHeartbeatTask({
task: {
name: 'weekly-report',
interval: '7d',
prompt: 'Ship the weekly report',
steps: [
{
name: 'gather',
prompt: 'Gather weekly inputs',
},
{
name: 'draft',
prompt: 'Draft the weekly report',
},
],
},
rootDir: tempDir,
currentDir: tempDir,
})
const mod = await autonomyCommand.load()
const result = await mod.call('', {} as any)
expectTextResult(result)
expect(result.value).toContain('Autonomy runs: 2')
expect(result.value).toContain('Autonomy flows: 1')
expect(result.value).toContain('Completed: 1')
expect(result.value).toContain('Queued: 1')
})
test('runs subcommand lists recent autonomy runs', async () => {
const queued = await createAutonomyQueuedPrompt({
basePrompt: '<tick>12:00:00</tick>',
trigger: 'proactive-tick',
rootDir: tempDir,
currentDir: tempDir,
})
const mod = await autonomyCommand.load()
const result = await mod.call('runs 5', {} as any)
expectTextResult(result)
expect(result.value).toContain(queued!.autonomy!.runId)
expect(result.value).toContain('proactive-tick')
})
test('flows subcommand lists managed flows and flow subcommand shows detail', async () => {
await startManagedAutonomyFlowFromHeartbeatTask({
task: {
name: 'weekly-report',
interval: '7d',
prompt: 'Ship the weekly report',
steps: [
{
name: 'gather',
prompt: 'Gather weekly inputs',
},
{
name: 'draft',
prompt: 'Draft the weekly report',
},
],
},
rootDir: tempDir,
currentDir: tempDir,
})
const [flow] = await listAutonomyFlows(tempDir)
const mod = await autonomyCommand.load()
const flowsResult = await mod.call('flows 5', {} as any)
expectTextResult(flowsResult)
expect(flowsResult.value).toContain(flow!.flowId)
expect(flowsResult.value).toContain('managed')
const flowResult = await mod.call(`flow ${flow!.flowId}`, {} as any)
expectTextResult(flowResult)
expect(flowResult.value).toContain(`Flow: ${flow!.flowId}`)
expect(flowResult.value).toContain('Mode: managed')
expect(flowResult.value).toContain('Current step: gather')
})
test('flow resume queues the next waiting step', async () => {
const waitingStart = await startManagedAutonomyFlowFromHeartbeatTask({
task: {
name: 'weekly-report',
interval: '7d',
prompt: 'Ship the weekly report',
steps: [
{
name: 'gather',
prompt: 'Gather weekly inputs',
waitFor: 'manual',
},
{
name: 'draft',
prompt: 'Draft the weekly report',
},
],
},
rootDir: tempDir,
currentDir: tempDir,
})
expect(waitingStart).toBeNull()
const [flow] = await listAutonomyFlows(tempDir)
const mod = await autonomyCommand.load()
const result = await mod.call(`flow resume ${flow!.flowId}`, {} as any)
expectTextResult(result)
expect(result.value).toContain('Queued the next managed step')
expect(getCommandQueueSnapshot()).toHaveLength(1)
expect(getCommandQueueSnapshot()[0]!.autonomy?.flowId).toBe(flow!.flowId)
})
test('flow cancel removes queued managed steps and marks the flow cancelled', async () => {
const queued = await startManagedAutonomyFlowFromHeartbeatTask({
task: {
name: 'weekly-report',
interval: '7d',
prompt: 'Ship the weekly report',
steps: [
{
name: 'gather',
prompt: 'Gather weekly inputs',
},
{
name: 'draft',
prompt: 'Draft the weekly report',
},
],
},
rootDir: tempDir,
currentDir: tempDir,
})
expect(queued).not.toBeNull()
enqueuePendingNotification(queued!)
expect(getCommandQueueSnapshot()).toHaveLength(1)
const [flow] = await listAutonomyFlows(tempDir)
const mod = await autonomyCommand.load()
const result = await mod.call(`flow cancel ${flow!.flowId}`, {} as any)
const [cancelledFlow] = await listAutonomyFlows(tempDir)
expectTextResult(result)
expect(result.value).toContain('Cancelled flow')
expect(cancelledFlow!.status).toBe('cancelled')
expect(getCommandQueueSnapshot()).toHaveLength(0)
})
test('flow cancel refuses to rewrite a terminal managed flow', async () => {
const queued = await startManagedAutonomyFlowFromHeartbeatTask({
task: {
name: 'weekly-report',
interval: '7d',
prompt: 'Ship the weekly report',
steps: [
{
name: 'gather',
prompt: 'Gather weekly inputs',
},
],
},
rootDir: tempDir,
currentDir: tempDir,
})
await markAutonomyRunCompleted(queued!.autonomy!.runId, tempDir)
const [flow] = await listAutonomyFlows(tempDir)
const mod = await autonomyCommand.load()
const result = await mod.call(`flow cancel ${flow!.flowId}`, {} as any)
const [terminalFlow] = await listAutonomyFlows(tempDir)
expectTextResult(result)
expect(result.value).toContain('already terminal')
expect(terminalFlow!.status).toBe('succeeded')
})
test('invalid subcommands return usage text', async () => {
const mod = await autonomyCommand.load()
const result = await mod.call('unknown', {} as any)
expectTextResult(result)
expect(result.value).toContain('Usage: /autonomy')
})
})

View File

@@ -0,0 +1,48 @@
import { beforeEach, describe, expect, test } from 'bun:test'
import proactiveCommand from '../proactive'
import {
activateProactive,
deactivateProactive,
isProactiveActive,
} from '../../proactive/index'
beforeEach(() => {
deactivateProactive()
})
describe('/proactive baseline', () => {
test('invoking the command enables proactive mode and emits a system reminder', async () => {
const mod = await proactiveCommand.load()
let resultText: string | undefined
let options: Parameters<Parameters<typeof mod.call>[0]>[1] | undefined
await mod.call((result, opts) => {
resultText = result
options = opts
}, {} as any)
expect(isProactiveActive()).toBe(true)
expect(resultText).toContain('Proactive mode enabled')
expect(options?.display).toBe('system')
expect(options?.metaMessages?.[0]).toContain(
'Proactive mode is now enabled',
)
})
test('invoking the command again disables proactive mode', async () => {
const mod = await proactiveCommand.load()
activateProactive('test')
let resultText: string | undefined
let options: Parameters<Parameters<typeof mod.call>[0]>[1] | undefined
await mod.call((result, opts) => {
resultText = result
options = opts
}, {} as any)
expect(isProactiveActive()).toBe(false)
expect(resultText).toBe('Proactive mode disabled')
expect(options?.display).toBe('system')
})
})

View File

@@ -1,53 +0,0 @@
import * as React from 'react'
import type { LocalJSXCommandContext } from '../../commands.js'
import type { LocalJSXCommandOnDone } from '../../types/command.js'
import type { AppState } from '../../state/AppState.js'
/** Stub — install wizard is not yet restored. */
export async function computeDefaultInstallDir(): Promise<string> {
return ''
}
/** Stub — install wizard is not yet restored. */
export function NewInstallWizard(_props: {
defaultDir: string
onInstalled: (dir: string) => void
onCancel: () => void
onError: (message: string) => void
}): React.ReactNode {
return null
}
/**
* /assistant command implementation.
*
* Opens the Kairos assistant panel. In the current build the panel is
* rendered by the REPL layer when kairosActive is true; the slash command
* simply toggles visibility and prints a confirmation line.
*/
export async function call(
onDone: LocalJSXCommandOnDone,
context: LocalJSXCommandContext,
_args: string,
): Promise<React.ReactNode> {
const { setAppState, getAppState } = context
const current = getAppState()
const isVisible = (current as Record<string, unknown>).assistantPanelVisible
if (isVisible) {
setAppState((prev: AppState) => ({
...prev,
assistantPanelVisible: false,
} as AppState))
onDone('Assistant panel hidden.', { display: 'system' })
} else {
setAppState((prev: AppState) => ({
...prev,
assistantPanelVisible: true,
} as AppState))
onDone('Assistant panel opened.', { display: 'system' })
}
return null
}

View File

@@ -0,0 +1,175 @@
import * as React from 'react';
import { useEffect, useState } from 'react';
import { resolve } from 'path';
import { Box, Text } from '@anthropic/ink';
import { Dialog } from '../../components/design-system/Dialog.js';
import { ListItem } from '../../components/design-system/ListItem.js';
import { useRegisterOverlay } from '../../context/overlayContext.js';
import { useKeybindings } from '../../keybindings/useKeybinding.js';
import { findGitRoot } from '../../utils/git.js';
import { buildCliLaunch, spawnCli } from '../../utils/cliLaunch.js';
import { getKairosActive, setKairosActive } from '../../bootstrap/state.js';
import type { LocalJSXCommandContext } from '../../commands.js';
import type { LocalJSXCommandOnDone } from '../../types/command.js';
import type { AppState } from '../../state/AppState.js';
/**
* Compute the default directory for assistant daemon installation.
* Prefers git root of cwd; falls back to cwd itself.
*/
export async function computeDefaultInstallDir(): Promise<string> {
const cwd = process.cwd();
const gitRoot = findGitRoot(cwd);
return gitRoot || resolve(cwd);
}
interface WizardProps {
defaultDir: string;
onInstalled: (dir: string) => void;
onCancel: () => void;
onError: (message: string) => void;
}
/**
* Install wizard for assistant mode. Shown when `claude assistant` finds
* zero CCR sessions. Guides the user to start a daemon that registers
* a bridge → CCR cloud session.
*
* After installation, main.tsx tells the user to run `claude assistant`
* again in a few seconds (daemon needs time to register the bridge session).
*/
export function NewInstallWizard({ defaultDir, onInstalled, onCancel, onError }: WizardProps): React.ReactNode {
useRegisterOverlay('assistant-install-wizard');
const [focusIndex, setFocusIndex] = useState(0);
const [starting, setStarting] = useState(false);
useKeybindings(
{
'select:next': () => setFocusIndex(i => (i + 1) % 2),
'select:previous': () => setFocusIndex(i => (i - 1 + 2) % 2),
'select:accept': () => {
if (focusIndex === 0) {
startDaemon();
} else {
onCancel();
}
},
},
{ context: 'Select' },
);
function startDaemon(): void {
if (starting) return;
setStarting(true);
const dir = defaultDir || resolve('.');
try {
const launch = buildCliLaunch(['daemon', 'start', `--dir=${dir}`]);
const child = spawnCli(launch, {
cwd: dir,
stdio: 'ignore',
detached: true,
});
child.unref();
child.on('error', err => {
onError(`Failed to start daemon: ${err.message}`);
});
// Give the daemon a moment to initialize, then report success.
// The daemon still needs several more seconds to register the bridge
// and create a CCR session — main.tsx will tell the user to reconnect.
setTimeout(() => {
onInstalled(dir);
}, 1500);
} catch (err) {
onError(`Failed to start daemon: ${err instanceof Error ? err.message : String(err)}`);
}
}
if (starting) {
return (
<Dialog title="Assistant Setup" onCancel={onCancel} hideInputGuide>
<Text>Starting daemon in {defaultDir}...</Text>
</Dialog>
);
}
return (
<Dialog title="Assistant Setup" onCancel={onCancel} hideInputGuide>
<Box flexDirection="column" gap={1}>
<Text>No active assistant sessions found.</Text>
<Text>
Start a daemon in <Text bold>{defaultDir || '.'}</Text> to create a cloud session?
</Text>
<Box flexDirection="column">
<ListItem isFocused={focusIndex === 0}>
<Text>Start assistant daemon</Text>
</ListItem>
<ListItem isFocused={focusIndex === 1}>
<Text>Cancel</Text>
</ListItem>
</Box>
<Text dimColor>Enter to select · Esc to cancel</Text>
</Box>
</Dialog>
);
}
/**
* /assistant command implementation.
*
* First invocation activates KAIROS (sets kairosActive, enables brief
* and proactive tools). Subsequent invocations toggle the assistant panel.
*/
export async function call(
onDone: LocalJSXCommandOnDone,
context: LocalJSXCommandContext,
_args: string,
): Promise<React.ReactNode> {
const { setAppState, getAppState } = context;
// First invocation: activate KAIROS
if (!getKairosActive()) {
setKairosActive(true);
setAppState(
(prev: AppState) =>
({
...prev,
kairosEnabled: true,
assistantPanelVisible: true,
}) as AppState,
);
onDone('KAIROS assistant mode activated.', { display: 'system' });
return null;
}
// Subsequent invocations: toggle panel visibility
const current = getAppState();
const isVisible = (current as Record<string, unknown>).assistantPanelVisible;
if (isVisible) {
setAppState(
(prev: AppState) =>
({
...prev,
assistantPanelVisible: false,
}) as AppState,
);
onDone('Assistant panel hidden.', { display: 'system' });
} else {
setAppState(
(prev: AppState) =>
({
...prev,
assistantPanelVisible: true,
}) as AppState,
);
onDone('Assistant panel opened.', { display: 'system' });
}
return null;
}

View File

@@ -1,25 +1,21 @@
import { feature } from 'bun:bundle'
import { getKairosActive } from '../../bootstrap/state.js'
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'
/**
* Runtime gate for the /assistant command.
* Runtime gate for the /assistant command visibility.
*
* Build-time: feature('KAIROS') must be on (checked in commands.ts before
* the module is even required).
* Build-time: feature('KAIROS') must be on.
* Runtime: tengu_kairos_assistant GrowthBook flag (remote kill switch).
*
* Runtime: tengu_kairos_assistant GrowthBook flag acts as a remote kill
* switch, and kairosActive state must be true (set during bootstrap when
* the session qualifies for KAIROS features).
* Does NOT require kairosActive — the /assistant command is visible
* before activation so users can invoke it to activate KAIROS.
*/
export function isAssistantEnabled(): boolean {
if (!feature('KAIROS')) {
return false
}
if (
!getFeatureValue_CACHED_MAY_BE_STALE('tengu_kairos_assistant', false)
) {
if (!getFeatureValue_CACHED_MAY_BE_STALE('tengu_kairos_assistant', false)) {
return false
}
return getKairosActive()
return true
}

125
src/commands/autonomy.ts Normal file
View File

@@ -0,0 +1,125 @@
import type { Command, LocalCommandCall } from '../types/command.js'
import {
formatAutonomyFlowDetail,
formatAutonomyFlowsList,
formatAutonomyFlowsStatus,
getAutonomyFlowById,
listAutonomyFlows,
requestManagedAutonomyFlowCancel,
} from '../utils/autonomyFlows.js'
import {
formatAutonomyRunsList,
formatAutonomyRunsStatus,
listAutonomyRuns,
markAutonomyRunCancelled,
resumeManagedAutonomyFlowPrompt,
} from '../utils/autonomyRuns.js'
import {
enqueuePendingNotification,
removeByFilter,
} from '../utils/messageQueueManager.js'
function parseRunsLimit(raw?: string): number {
const parsed = Number.parseInt(raw ?? '', 10)
if (!Number.isFinite(parsed) || parsed <= 0) {
return 10
}
return Math.min(parsed, 50)
}
const call: LocalCommandCall = async (args: string) => {
const [subcommand = 'status', arg1, arg2] = args.trim().split(/\s+/, 3)
const runs = await listAutonomyRuns()
const flows = await listAutonomyFlows()
if (subcommand === 'runs') {
return {
type: 'text',
value: formatAutonomyRunsList(runs, parseRunsLimit(arg1)),
}
}
if (subcommand === 'flows') {
return {
type: 'text',
value: formatAutonomyFlowsList(flows, parseRunsLimit(arg1)),
}
}
if (subcommand === 'flow') {
if (arg1 === 'cancel') {
const flowId = arg2 ?? ''
const cancelled = await requestManagedAutonomyFlowCancel({ flowId })
if (!cancelled) {
return {
type: 'text',
value: 'Autonomy flow not found.',
}
}
if (!cancelled.accepted) {
return {
type: 'text',
value: `Autonomy flow ${flowId} is already terminal (${cancelled.flow.status}).`,
}
}
const removed = removeByFilter(cmd => cmd.autonomy?.flowId === flowId)
for (const command of removed) {
if (command.autonomy?.runId) {
await markAutonomyRunCancelled(command.autonomy.runId)
}
}
return {
type: 'text',
value:
cancelled.flow.status === 'running'
? `Cancellation requested for flow ${flowId}. The current step is still running, and no new steps will be started.`
: `Cancelled flow ${flowId}. Removed ${removed.length} queued step(s).`,
}
}
if (arg1 === 'resume') {
const flowId = arg2 ?? ''
const command = await resumeManagedAutonomyFlowPrompt({ flowId })
if (!command) {
return {
type: 'text',
value: 'Autonomy flow is not waiting or was not found.',
}
}
enqueuePendingNotification(command)
return {
type: 'text',
value: `Queued the next managed step for flow ${flowId}.`,
}
}
return {
type: 'text',
value: formatAutonomyFlowDetail(await getAutonomyFlowById(arg1 ?? '')),
}
}
if (subcommand !== 'status' && subcommand !== '') {
return {
type: 'text',
value:
'Usage: /autonomy [status|runs [limit]|flows [limit]|flow <id>|flow cancel <id>|flow resume <id>]',
}
}
return {
type: 'text',
value: [formatAutonomyRunsStatus(runs), formatAutonomyFlowsStatus(flows)].join('\n'),
}
}
const autonomy = {
type: 'local',
name: 'autonomy',
description:
'Inspect automatic autonomy runs recorded for proactive ticks and scheduled tasks',
supportsNonInteractive: true,
load: () => Promise.resolve({ call }),
} satisfies Command
export default autonomy

View File

@@ -0,0 +1,24 @@
import { describe, test, expect } from 'bun:test'
describe('/daemon command', () => {
test('index exports a valid Command', async () => {
const mod = await import('../index.js')
const cmd = mod.default
expect(cmd.name).toBe('daemon')
expect(cmd.type).toBe('local-jsx')
expect(typeof cmd.load).toBe('function')
expect(cmd.description).toContain('daemon')
})
test('daemon module exports call function', async () => {
const mod = await import('../daemon.js')
expect(typeof mod.call).toBe('function')
})
test('argumentHint lists subcommands', async () => {
const mod = await import('../index.js')
const cmd = mod.default
expect(cmd.argumentHint).toContain('status')
expect(cmd.argumentHint).toContain('bg')
})
})

View File

@@ -0,0 +1,57 @@
import type {
LocalJSXCommandOnDone,
LocalJSXCommandContext,
} from '../../types/command.js'
/**
* /daemon slash command — manages daemon and background sessions from the REPL.
*
* Subcommands: status | start | stop | bg | attach | logs | kill
* Default (no args): status
*/
export async function call(
onDone: LocalJSXCommandOnDone,
_context: LocalJSXCommandContext,
args: string,
): Promise<React.ReactNode> {
const parts = args ? args.trim().split(/\s+/) : []
const sub = parts[0] || 'status'
// attach is interactive/blocking — not available inside the REPL
if (sub === 'attach') {
onDone(
'Use `claude daemon attach` from the CLI. Attach is not available inside the REPL.',
{ display: 'system' },
)
return null
}
// For all other subcommands, capture console output and return via onDone
const lines = await captureConsole(async () => {
if (sub === 'bg') {
const bg = await import('../../cli/bg.js')
await bg.handleBgStart(parts.slice(1))
} else {
const { daemonMain } = await import('../../daemon/main.js')
await daemonMain([sub, ...parts.slice(1)])
}
})
onDone(lines.join('\n') || 'Done.', { display: 'system' })
return null
}
async function captureConsole(fn: () => Promise<void>): Promise<string[]> {
const lines: string[] = []
const origLog = console.log
const origError = console.error
console.log = (...a: unknown[]) => lines.push(a.map(String).join(' '))
console.error = (...a: unknown[]) => lines.push(a.map(String).join(' '))
try {
await fn()
} finally {
console.log = origLog
console.error = origError
}
return lines
}

View File

@@ -0,0 +1,17 @@
import type { Command } from '../../commands.js'
import { feature } from 'bun:bundle'
const daemon = {
type: 'local-jsx',
name: 'daemon',
description: 'Manage background sessions and daemon',
argumentHint: '[status|start|stop|bg|attach|logs|kill]',
isEnabled: () => {
if (feature('DAEMON')) return true
if (feature('BG_SESSIONS')) return true
return false
},
load: () => import('./daemon.js'),
} satisfies Command
export default daemon

View File

@@ -1,6 +1,7 @@
import { feature } from 'bun:bundle'
import type { Command } from '../commands.js'
import { maybeMarkProjectOnboardingComplete } from '../projectOnboardingState.js'
import { AUTONOMY_AGENTS_PATH_POSIX } from '../utils/autonomyAuthority.js'
import { isEnvTruthy } from '../utils/envUtils.js'
const OLD_INIT_PROMPT = `Please analyze this codebase and create a CLAUDE.md file, which will be given to future instances of Claude Code to operate in this repository.
@@ -43,7 +44,7 @@ Use AskUserQuestion to find out what the user wants:
## Phase 2: Explore the codebase
Launch a subagent to survey the codebase, and ask it to read key files to understand the project: manifest files (package.json, Cargo.toml, pyproject.toml, go.mod, pom.xml, etc.), README, Makefile/build configs, CI config, existing CLAUDE.md, .claude/rules/, AGENTS.md, .cursor/rules or .cursorrules, .github/copilot-instructions.md, .windsurfrules, .clinerules, .mcp.json.
Launch a subagent to survey the codebase, and ask it to read key files to understand the project: manifest files (package.json, Cargo.toml, pyproject.toml, go.mod, pom.xml, etc.), README, Makefile/build configs, CI config, existing CLAUDE.md, .claude/rules/, ${AUTONOMY_AGENTS_PATH_POSIX}, .cursor/rules or .cursorrules, .github/copilot-instructions.md, .windsurfrules, .clinerules, .mcp.json.
Detect:
- Build, test, and lint commands (especially non-standard ones)
@@ -105,7 +106,7 @@ Include:
- Repo etiquette (branch naming, PR conventions, commit style)
- Required env vars or setup steps
- Non-obvious gotchas or architectural decisions
- Important parts from existing AI coding tool configs if they exist (AGENTS.md, .cursor/rules, .cursorrules, .github/copilot-instructions.md, .windsurfrules, .clinerules)
- Important parts from existing AI coding tool configs if they exist (${AUTONOMY_AGENTS_PATH_POSIX}, .cursor/rules, .cursorrules, .github/copilot-instructions.md, .windsurfrules, .clinerules)
Exclude:
- File-by-file structure or component lists (Claude can discover these by reading the codebase)

View File

@@ -0,0 +1,25 @@
import { describe, test, expect } from 'bun:test'
describe('/job command', () => {
test('index exports a valid Command', async () => {
const mod = await import('../index.js')
const cmd = mod.default
expect(cmd.name).toBe('job')
expect(cmd.type).toBe('local-jsx')
expect(typeof cmd.load).toBe('function')
expect(cmd.description).toContain('job')
})
test('job module exports call function', async () => {
const mod = await import('../job.js')
expect(typeof mod.call).toBe('function')
})
test('argumentHint lists subcommands', async () => {
const mod = await import('../index.js')
const cmd = mod.default
expect(cmd.argumentHint).toContain('list')
expect(cmd.argumentHint).toContain('new')
expect(cmd.argumentHint).toContain('status')
})
})

16
src/commands/job/index.ts Normal file
View File

@@ -0,0 +1,16 @@
import type { Command } from '../../commands.js'
import { feature } from 'bun:bundle'
const job = {
type: 'local-jsx',
name: 'job',
description: 'Manage template jobs',
argumentHint: '[list|new|reply|status]',
isEnabled: () => {
if (feature('TEMPLATES')) return true
return false
},
load: () => import('./job.js'),
} satisfies Command
export default job

34
src/commands/job/job.tsx Normal file
View File

@@ -0,0 +1,34 @@
import type { LocalJSXCommandOnDone, LocalJSXCommandContext } from '../../types/command.js'
/**
* /job slash command — manages template jobs from inside the REPL.
*
* Subcommands: list | new <template> [args] | reply <id> <text> | status <id>
* Default (no args): list
*/
export async function call(
onDone: LocalJSXCommandOnDone,
_context: LocalJSXCommandContext,
args: string,
): Promise<React.ReactNode> {
const parts = args ? args.trim().split(/\s+/) : []
const sub = parts[0] || 'list'
// Capture console output so we can return it as onDone text
const lines: string[] = []
const origLog = console.log
const origError = console.error
console.log = (...a: unknown[]) => lines.push(a.map(String).join(' '))
console.error = (...a: unknown[]) => lines.push(a.map(String).join(' '))
try {
const { templatesMain } = await import('../../cli/handlers/templateJobs.js')
await templatesMain([sub, ...parts.slice(1)])
} finally {
console.log = origLog
console.error = origError
}
onDone(lines.join('\n') || 'Done.', { display: 'system' })
return null
}

View File

@@ -0,0 +1,12 @@
import type { Command } from '../../commands.js'
const lang = {
type: 'local-jsx',
name: 'lang',
description: 'Set display language (en/zh/auto)',
immediate: true,
argumentHint: '<en|zh|auto>',
load: () => import('./lang.js'),
} satisfies Command
export default lang

49
src/commands/lang/lang.ts Normal file
View File

@@ -0,0 +1,49 @@
import type { ToolUseContext } from '../../Tool.js'
import type {
LocalJSXCommandContext,
LocalJSXCommandOnDone,
} from '../../types/command.js'
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'
import {
type PreferredLanguage,
getLanguageDisplayName,
getResolvedLanguage,
} from '../../utils/language.js'
const VALID_LANGS: readonly PreferredLanguage[] = ['en', 'zh', 'auto']
export async function call(
onDone: LocalJSXCommandOnDone,
_context: ToolUseContext & LocalJSXCommandContext,
args: string,
): Promise<null> {
const arg = args.trim().toLowerCase()
if (!arg) {
const pref = getGlobalConfig().preferredLanguage ?? 'auto'
const resolved = getResolvedLanguage()
const suffix =
pref === 'auto' ? `${getLanguageDisplayName(resolved)}` : ''
onDone(`Language: ${getLanguageDisplayName(pref)}${suffix}`, {
display: 'system',
})
return null
}
if (!VALID_LANGS.includes(arg as PreferredLanguage)) {
onDone(`Invalid language "${arg}". Use: en, zh, or auto`, {
display: 'system',
})
return null
}
const lang = arg as PreferredLanguage
saveGlobalConfig(current => ({ ...current, preferredLanguage: lang }))
const resolved = getResolvedLanguage()
const suffix = lang === 'auto' ? `${getLanguageDisplayName(resolved)}` : ''
onDone(`Language set to ${getLanguageDisplayName(lang)}${suffix}`, {
display: 'system',
})
return null
}

View File

@@ -1,4 +1,4 @@
import { spawn, type ChildProcess } from 'child_process';
import { type ChildProcess } from 'child_process';
import { resolve } from 'path';
import * as React from 'react';
import { useEffect, useState } from 'react';
@@ -10,6 +10,7 @@ import { ListItem } from '../../components/design-system/ListItem.js';
import { useRegisterOverlay } from '../../context/overlayContext.js';
import { Box, Text } from '@anthropic/ink';
import { useKeybindings } from '../../keybindings/useKeybinding.js';
import { buildCliLaunch, spawnCli } from '../../utils/cliLaunch.js';
import type { ToolUseContext } from '../../Tool.js';
import type { LocalJSXCommandContext, LocalJSXCommandOnDone } from '../../types/command.js';
import { errorMessage } from '../../utils/errors.js';
@@ -202,9 +203,9 @@ async function checkPrerequisites(): Promise<string | null> {
function startDaemon(): void {
const dir = resolve('.');
const execArgs = [...process.execArgv, process.argv[1]!, 'daemon', 'start', `--dir=${dir}`];
const launch = buildCliLaunch(['daemon', 'start', `--dir=${dir}`]);
const child = spawn(process.execPath, execArgs, {
const child = spawnCli(launch, {
cwd: dir,
stdio: ['ignore', 'pipe', 'pipe'],
detached: false,

View File

@@ -1,6 +1,11 @@
import type { LocalCommandCall } from '../../types/command.js'
import { getSlaveClient } from '../../hooks/useMasterMonitor.js'
import { getPipeIpc } from '../../utils/pipeTransport.js'
import {
addSendOverride,
removeSendOverride,
removeMasterPipeMute,
} from '../../utils/pipeMuteState.js'
export const call: LocalCommandCall = async (args, context) => {
const currentState = context.getAppState()
@@ -48,6 +53,12 @@ export const call: LocalCommandCall = async (args, context) => {
}
try {
// Temporarily override mute for this slave so its response is visible.
// Override lasts until the slave emits 'done' or 'error' (cleared by
// useMasterMonitor's attachPipeEntryEmitter handler).
addSendOverride(targetName)
removeMasterPipeMute(targetName)
client.send({ type: 'relay_unmute' })
client.send({
type: 'prompt',
data: message,
@@ -89,6 +100,8 @@ export const call: LocalCommandCall = async (args, context) => {
value: `Sent to "${targetName}": ${message.slice(0, 100)}${message.length > 100 ? '...' : ''}`,
}
} catch (err) {
// Roll back override on send failure to prevent permanent unmute
removeSendOverride(targetName)
return {
type: 'text',
value: `Failed to send to "${targetName}": ${err instanceof Error ? err.message : String(err)}`,

View File

@@ -1 +1,19 @@
export default null
import type { Command, LocalJSXCommandOnDone } from '../types/command.js'
import type { ReactNode } from 'react'
const call = async (onDone: LocalJSXCommandOnDone): Promise<ReactNode> => {
onDone(
'torch: Reserved internal debug command. No implementation is available in this build.',
{ display: 'system' },
)
return null
}
export default {
type: 'local-jsx',
name: 'torch',
description: '[INTERNAL] Development debug command (reserved)',
isEnabled: () => true,
isHidden: true,
load: () => Promise.resolve({ call }),
} satisfies Command

View File

@@ -0,0 +1,61 @@
/**
* Tests for daemon/main.ts subcommand routing.
*
* The `status` and `bg` subcommands trigger dynamic imports of `cli/bg.ts`
* which depends on `envUtils.ts` → `lodash-es/memoize.js` (unavailable in
* raw test context without `bun run dev`'s define flags). We test only the
* self-contained subcommands: help and unknown.
*/
import { describe, test, expect, beforeEach, afterEach } from 'bun:test'
describe('daemonMain subcommand routing', () => {
const origLog = console.log
const origError = console.error
let logLines: string[]
beforeEach(() => {
logLines = []
console.log = (...a: unknown[]) => logLines.push(a.map(String).join(' '))
console.error = (...a: unknown[]) => logLines.push(a.map(String).join(' '))
})
afterEach(() => {
console.log = origLog
console.error = origError
process.exitCode = 0
})
test('unknown subcommand sets exitCode to 1', async () => {
const { daemonMain } = await import('../main.js')
await daemonMain(['unknown-command-xyz'])
expect(process.exitCode).toBe(1)
})
test('help subcommand prints usage', async () => {
const { daemonMain } = await import('../main.js')
await daemonMain(['help'])
const output = logLines.join('\n')
expect(output).toContain('SUBCOMMANDS')
expect(output).toContain('status')
expect(output).toContain('start')
expect(output).toContain('stop')
expect(output).toContain('bg')
expect(output).toContain('attach')
expect(output).toContain('logs')
expect(output).toContain('kill')
})
test('--help is alias for help', async () => {
const { daemonMain } = await import('../main.js')
await daemonMain(['--help'])
const output = logLines.join('\n')
expect(output).toContain('SUBCOMMANDS')
})
test('-h is alias for help', async () => {
const { daemonMain } = await import('../main.js')
await daemonMain(['-h'])
const output = logLines.join('\n')
expect(output).toContain('SUBCOMMANDS')
})
})

View File

@@ -0,0 +1,185 @@
/**
* Tests for src/daemon/state.ts
*
* Uses real temp directories and CLAUDE_CONFIG_DIR env var
* instead of mocking fs/envUtils, to avoid cross-test mock pollution.
*/
import { describe, expect, test, beforeEach, afterAll } from 'bun:test'
import { mkdtempSync, rmSync, existsSync, readFileSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import { getClaudeConfigHomeDir } from '../../utils/envUtils.js'
// ─── setup: real temp dir via env var ──────────────────────────────────────
const tempBase = mkdtempSync(join(tmpdir(), 'daemon-state-test-'))
beforeEach(() => {
// Clear lodash memoize cache so CLAUDE_CONFIG_DIR env var takes effect
if (
typeof getClaudeConfigHomeDir === 'function' &&
'cache' in getClaudeConfigHomeDir
) {
;(getClaudeConfigHomeDir as any).cache.clear?.()
}
const tempHome = mkdtempSync(join(tempBase, 'home-'))
process.env.CLAUDE_CONFIG_DIR = tempHome
})
afterAll(() => {
delete process.env.CLAUDE_CONFIG_DIR
// Clear memoize cache after all tests so other files see fresh state
if (
typeof getClaudeConfigHomeDir === 'function' &&
'cache' in getClaudeConfigHomeDir
) {
;(getClaudeConfigHomeDir as any).cache.clear?.()
}
try {
rmSync(tempBase, { recursive: true, force: true })
} catch {
// best-effort cleanup
}
})
// ─── import ─────────────────────────────────────────────────────────────────
const {
getDaemonStateFilePath,
writeDaemonState,
readDaemonState,
removeDaemonState,
queryDaemonStatus,
} = await import('../state.js')
// ─── tests ─────────────────────────────────────────────────────────────────
describe('getDaemonStateFilePath', () => {
test('returns default path with remote-control name', () => {
const p = getDaemonStateFilePath()
expect(p).toContain('daemon')
expect(p).toContain('remote-control.json')
})
test('returns path with custom name', () => {
const p = getDaemonStateFilePath('my-daemon')
expect(p).toContain('my-daemon.json')
})
})
describe('writeDaemonState', () => {
test('writes state JSON to disk', () => {
const state = {
pid: 1234,
cwd: '/test',
startedAt: '2026-01-01T00:00:00Z',
workerKinds: ['rcs'],
lastStatus: 'running' as const,
}
writeDaemonState(state, 'test')
const filePath = getDaemonStateFilePath('test')
expect(existsSync(filePath)).toBe(true)
const parsed = JSON.parse(readFileSync(filePath, 'utf-8'))
expect(parsed.pid).toBe(1234)
expect(parsed.cwd).toBe('/test')
})
test('creates directory recursively', () => {
writeDaemonState(
{
pid: 1,
cwd: '/',
startedAt: '',
workerKinds: [],
lastStatus: 'running',
},
'dir-test',
)
const filePath = getDaemonStateFilePath('dir-test')
expect(existsSync(filePath)).toBe(true)
})
})
describe('readDaemonState', () => {
test('returns null when no state file', () => {
expect(readDaemonState('nonexistent')).toBeNull()
})
test('returns parsed state when file exists', () => {
const state = {
pid: 42,
cwd: '/x',
startedAt: '',
workerKinds: [],
lastStatus: 'running' as const,
}
writeDaemonState(state, 'read-test')
const result = readDaemonState('read-test')
expect(result).not.toBeNull()
expect(result!.pid).toBe(42)
})
})
describe('removeDaemonState', () => {
test('removes existing state file', () => {
writeDaemonState(
{
pid: 1,
cwd: '/',
startedAt: '',
workerKinds: [],
lastStatus: 'running',
},
'rm-test',
)
const filePath = getDaemonStateFilePath('rm-test')
expect(existsSync(filePath)).toBe(true)
removeDaemonState('rm-test')
expect(existsSync(filePath)).toBe(false)
})
test('does not throw when file does not exist', () => {
expect(() => removeDaemonState('no-file')).not.toThrow()
})
})
describe('queryDaemonStatus', () => {
test('returns stopped when no state file', () => {
const result = queryDaemonStatus('empty')
expect(result.status).toBe('stopped')
expect(result.state).toBeUndefined()
})
test('returns running when PID is alive (current process)', () => {
writeDaemonState(
{
pid: process.pid,
cwd: process.cwd(),
startedAt: new Date().toISOString(),
workerKinds: ['test'],
lastStatus: 'running',
},
'alive-test',
)
const result = queryDaemonStatus('alive-test')
expect(result.status).toBe('running')
expect(result.state).toBeDefined()
expect(result.state!.pid).toBe(process.pid)
})
test('returns stale when PID is dead and cleans up', () => {
writeDaemonState(
{
pid: 999999,
cwd: '/',
startedAt: '',
workerKinds: [],
lastStatus: 'running',
},
'stale-test',
)
const result = queryDaemonStatus('stale-test')
expect(result.status).toBe('stale')
expect(existsSync(getDaemonStateFilePath('stale-test'))).toBe(false)
})
})

View File

@@ -1,6 +1,13 @@
import { spawn, type ChildProcess } from 'child_process'
import { type ChildProcess } from 'child_process'
import { resolve } from 'path'
import { buildCliLaunch, spawnCli } from '../utils/cliLaunch.js'
import { errorMessage } from '../utils/errors.js'
import {
writeDaemonState,
removeDaemonState,
queryDaemonStatus,
stopDaemonByPid,
} from './state.js'
/**
* Exit code used by workers for permanent (non-retryable) failures.
@@ -29,30 +36,62 @@ interface WorkerState {
* Daemon supervisor entry point. Called from `cli.tsx` via:
* `claude daemon [subcommand]`
*
* Starts and supervises long-running workers. Currently spawns one
* `remoteControl` worker that runs the headless bridge server.
* Manages the daemon supervisor AND background sessions under one namespace.
*
* Subcommands:
* (none) — start the supervisor with default workers
* start — same as no subcommand
* status — print worker status (TODO: IPC)
* stop send SIGTERM to supervisor (TODO: PID file)
* (none) — unified status (supervisor + sessions)
* start — start the supervisor with default workers
* stop — send SIGTERM to supervisor
* statusunified status (supervisor + sessions)
* ps — alias for status
* bg — start a background session
* attach — attach to a background session
* logs — show session logs
* kill — kill a session
*/
export async function daemonMain(args: string[]): Promise<void> {
const subcommand = args[0] || 'start'
const subcommand = args[0] || 'status'
switch (subcommand) {
// --- Supervisor management ---
case 'start':
await runSupervisor(args.slice(1))
break
case 'status':
console.log('daemon status: not yet implemented (requires IPC)')
break
case 'stop':
console.log('daemon stop: not yet implemented (requires PID file)')
await handleDaemonStop()
break
// --- Unified status ---
case 'status':
case 'ps':
await showUnifiedStatus()
break
// --- Session management (delegates to bg.ts) ---
case 'bg': {
const bg = await import('../cli/bg.js')
await bg.handleBgStart(args.slice(1))
break
}
case 'attach': {
const bg = await import('../cli/bg.js')
await bg.attachHandler(args[1])
break
}
case 'logs': {
const bg = await import('../cli/bg.js')
await bg.logsHandler(args[1])
break
}
case 'kill': {
const bg = await import('../cli/bg.js')
await bg.killHandler(args[1])
break
}
case '--help':
case '-h':
case 'help':
printHelp()
break
default:
@@ -64,17 +103,25 @@ export async function daemonMain(args: string[]): Promise<void> {
function printHelp(): void {
console.log(`
Claude Code Daemon — persistent background supervisor
Claude Code Daemon — background process management
USAGE
claude daemon [subcommand] [options]
claude daemon [subcommand]
SUBCOMMANDS
start Start the daemon supervisor (default)
status Show worker status
status Show daemon and session status (default)
start Start the daemon supervisor
stop Stop the daemon
bg Start a background session
attach Attach to a background session
logs Show session logs
kill Kill a session
help Show this help
OPTIONS
REPL
/daemon [subcommand] Same commands available in interactive mode
OPTIONS (for start)
--dir <path> Working directory (default: current)
--spawn-mode <mode> Worker spawn mode: same-dir | worktree (default: same-dir)
--capacity <N> Max concurrent sessions per worker (default: 4)
@@ -85,6 +132,63 @@ OPTIONS
`)
}
/**
* Show unified status: daemon supervisor + background sessions.
*/
async function showUnifiedStatus(): Promise<void> {
// 1. Daemon supervisor status
const result = queryDaemonStatus()
console.log('=== Daemon Supervisor ===')
switch (result.status) {
case 'running': {
const s = result.state!
console.log(` Status: running`)
console.log(` PID: ${s.pid}`)
console.log(` CWD: ${s.cwd}`)
console.log(` Started: ${s.startedAt}`)
console.log(` Workers: ${s.workerKinds.join(', ')}`)
break
}
case 'stopped':
console.log(' Status: stopped')
break
case 'stale':
console.log(' Status: stale (cleaned up)')
break
}
// 2. Background sessions
console.log('\n=== Background Sessions ===')
const bg = await import('../cli/bg.js')
await bg.psHandler([])
}
/**
* Stop a running daemon from another CLI process.
*/
async function handleDaemonStop(): Promise<void> {
const result = queryDaemonStatus()
if (result.status === 'stopped') {
console.log('daemon is not running')
return
}
if (result.status === 'stale') {
console.log('daemon was stale (cleaned up)')
return
}
console.log(`stopping daemon (PID: ${result.state!.pid})...`)
const stopped = await stopDaemonByPid()
if (stopped) {
console.log('daemon stopped')
} else {
console.log('daemon could not be stopped (may have already exited)')
}
}
/**
* Parse supervisor arguments from CLI.
*/
@@ -140,12 +244,22 @@ async function runSupervisor(args: string[]): Promise<void> {
},
]
// Write daemon state file so other CLI processes can query/stop us
writeDaemonState({
pid: process.pid,
cwd: dir,
startedAt: new Date().toISOString(),
workerKinds: workers.map(w => w.kind),
lastStatus: 'running',
})
const controller = new AbortController()
// Graceful shutdown
const shutdown = () => {
console.log('[daemon] supervisor shutting down...')
controller.abort()
removeDaemonState()
for (const w of workers) {
if (w.process && !w.process.killed) {
w.process.kill('SIGTERM')
@@ -222,17 +336,11 @@ function spawnWorker(
CLAUDE_CODE_SESSION_KIND: 'daemon-worker',
}
// Build the worker command: reuse the same entrypoint with --daemon-worker flag
const execArgs = [
...process.execArgv,
process.argv[1]!,
`--daemon-worker=${worker.kind}`,
]
console.log(`[daemon] spawning worker '${worker.kind}'`)
const child = spawn(process.execPath, execArgs, {
env,
const launch = buildCliLaunch([`--daemon-worker=${worker.kind}`], { env })
const child = spawnCli(launch, {
cwd: dir,
stdio: ['ignore', 'pipe', 'pipe'],
})

157
src/daemon/state.ts Normal file
View File

@@ -0,0 +1,157 @@
import { readFileSync, writeFileSync, mkdirSync, unlinkSync } from 'fs'
import { join, dirname } from 'path'
import { getClaudeConfigHomeDir } from '../utils/envUtils.js'
/**
* Daemon state persisted to disk so that `status` / `stop` can work
* from a different CLI process than the one that started the daemon.
*/
export interface DaemonStateData {
pid: number
cwd: string
startedAt: string
workerKinds: string[]
lastStatus: 'running' | 'stopped' | 'error'
}
export type DaemonStatus = 'running' | 'stopped' | 'stale'
/**
* Returns the path to the daemon state file for a given daemon name.
*/
export function getDaemonStateFilePath(name = 'remote-control'): string {
return join(getClaudeConfigHomeDir(), 'daemon', `${name}.json`)
}
/**
* Write daemon state to disk. Called by the supervisor on startup.
*/
export function writeDaemonState(
state: DaemonStateData,
name = 'remote-control',
): void {
const filePath = getDaemonStateFilePath(name)
mkdirSync(dirname(filePath), { recursive: true })
writeFileSync(filePath, JSON.stringify(state, null, 2), 'utf-8')
}
/**
* Read daemon state from disk. Returns null if no state file exists.
*/
export function readDaemonState(
name = 'remote-control',
): DaemonStateData | null {
const filePath = getDaemonStateFilePath(name)
try {
const raw = readFileSync(filePath, 'utf-8')
return JSON.parse(raw) as DaemonStateData
} catch {
return null
}
}
/**
* Remove the daemon state file.
*/
export function removeDaemonState(name = 'remote-control'): void {
const filePath = getDaemonStateFilePath(name)
try {
unlinkSync(filePath)
} catch {
// File may not exist — that's fine
}
}
/**
* Check if a process with the given PID is alive.
*/
function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0)
return true
} catch {
return false
}
}
/**
* Query the daemon status by reading the state file and probing the PID.
*
* Returns:
* - { status: 'running', state } — PID is alive
* - { status: 'stopped' } — no state file
* - { status: 'stale' } — state file exists but PID is dead (auto-cleaned)
*/
export function queryDaemonStatus(name = 'remote-control'): {
status: DaemonStatus
state?: DaemonStateData
} {
const state = readDaemonState(name)
if (!state) {
return { status: 'stopped' }
}
if (isProcessAlive(state.pid)) {
return { status: 'running', state }
}
// Stale — process is dead but state file remains
removeDaemonState(name)
return { status: 'stale' }
}
/**
* Stop a running daemon by sending SIGTERM, waiting, then SIGKILL if needed.
* Cleans up the state file afterward.
*
* @returns true if the daemon was stopped, false if it wasn't running
*/
export async function stopDaemonByPid(
name = 'remote-control',
timeoutMs = 10_000,
): Promise<boolean> {
const state = readDaemonState(name)
if (!state) {
return false
}
const { pid } = state
if (!isProcessAlive(pid)) {
removeDaemonState(name)
return false
}
// Send SIGTERM
try {
process.kill(pid, 'SIGTERM')
} catch {
removeDaemonState(name)
return false
}
// Wait for exit with timeout
const deadline = Date.now() + timeoutMs
const pollInterval = 200
while (Date.now() < deadline) {
if (!isProcessAlive(pid)) {
removeDaemonState(name)
return true
}
await new Promise(resolve => setTimeout(resolve, pollInterval))
}
// Force kill
try {
process.kill(pid, 'SIGKILL')
} catch {
// Already dead
}
// Brief wait for SIGKILL to take effect
await new Promise(resolve => setTimeout(resolve, 500))
removeDaemonState(name)
return true
}

View File

@@ -145,9 +145,10 @@ async function main(): Promise<void> {
// perf-sensitive. No enableConfigs(), no analytics sinks at this layer —
// workers are lean. If a worker kind needs configs/auth (assistant will),
// it calls them inside its run() fn.
if (feature('DAEMON') && args[0] === '--daemon-worker') {
if (feature('DAEMON') && (args[0] === '--daemon-worker' || args[0]?.startsWith('--daemon-worker='))) {
const kind = args[0] === '--daemon-worker' ? args[1] : args[0].split('=')[1]
const { runDaemonWorker } = await import('../daemon/workerRegistry.js')
await runDaemonWorker(args[1])
await runDaemonWorker(kind)
return
}
@@ -207,11 +208,18 @@ async function main(): Promise<void> {
return
}
// Fast-path for `claude daemon [subcommand]`: long-running supervisor.
if (feature('DAEMON') && args[0] === 'daemon') {
// Fast-path for `claude daemon [subcommand]`: unified daemon + session management.
// Handles both supervisor (start/stop) and background session (bg/attach/logs/kill)
// subcommands under one namespace.
if (
(feature('DAEMON') || feature('BG_SESSIONS')) &&
args[0] === 'daemon'
) {
profileCheckpoint('cli_daemon_path')
const { enableConfigs } = await import('../utils/config.js')
enableConfigs()
const { setShellIfWindows } = await import('../utils/windowsPaths.js')
setShellIfWindows()
const { initSinks } = await import('../utils/sinks.js')
initSinks()
const { daemonMain } = await import('../daemon/main.js')
@@ -219,51 +227,69 @@ async function main(): Promise<void> {
return
}
// Fast-path for `claude ps|logs|attach|kill` and `--bg`/`--background`.
// Session management against the ~/.claude/sessions/ registry. Flag
// literals are inlined so bg.js only loads when actually dispatching.
// Fast-path for `--bg`/`--background` shortcut → daemon bg.
if (
feature('BG_SESSIONS') &&
(args.includes('--bg') || args.includes('--background'))
) {
profileCheckpoint('cli_daemon_path')
const { enableConfigs } = await import('../utils/config.js')
enableConfigs()
const { setShellIfWindows } = await import('../utils/windowsPaths.js')
setShellIfWindows()
const bg = await import('../cli/bg.js')
await bg.handleBgStart(
args.filter(a => a !== '--bg' && a !== '--background'),
)
return
}
// Backward-compat: ps/logs/attach/kill → daemon <sub> (deprecated)
if (
feature('BG_SESSIONS') &&
(args[0] === 'ps' ||
args[0] === 'logs' ||
args[0] === 'attach' ||
args[0] === 'kill' ||
args.includes('--bg') ||
args.includes('--background'))
args[0] === 'kill')
) {
profileCheckpoint('cli_bg_path')
const mapped = args[0] === 'ps' ? 'status' : args[0]
console.error(
`[deprecated] Use: claude daemon ${mapped}${args[1] ? ' ' + args[1] : ''}`,
)
profileCheckpoint('cli_daemon_path')
const { enableConfigs } = await import('../utils/config.js')
enableConfigs()
const bg = await import('../cli/bg.js')
switch (args[0]) {
case 'ps':
await bg.psHandler(args.slice(1))
break
case 'logs':
await bg.logsHandler(args[1])
break
case 'attach':
await bg.attachHandler(args[1])
break
case 'kill':
await bg.killHandler(args[1])
break
default:
await bg.handleBgFlag(args)
}
const { setShellIfWindows } = await import('../utils/windowsPaths.js')
setShellIfWindows()
const { initSinks } = await import('../utils/sinks.js')
initSinks()
const { daemonMain } = await import('../daemon/main.js')
await daemonMain([args[0] === 'ps' ? 'status' : args[0]!, ...args.slice(1)])
return
}
// Fast-path for template job commands.
// Fast-path for `claude job <subcommand>`: template jobs.
if (feature('TEMPLATES') && args[0] === 'job') {
profileCheckpoint('cli_templates_path')
const { templatesMain } = await import('../cli/handlers/templateJobs.js')
await templatesMain(args.slice(1))
// process.exit (not return) — mountFleetView's Ink TUI can leave event
// loop handles that prevent natural exit.
// eslint-disable-next-line custom-rules/no-process-exit
process.exit(0)
}
// Backward-compat: new/list/reply → job <sub> (deprecated)
if (
feature('TEMPLATES') &&
(args[0] === 'new' || args[0] === 'list' || args[0] === 'reply')
) {
console.error(
`[deprecated] Use: claude job ${args[0]} ${args.slice(1).join(' ')}`.trim(),
)
profileCheckpoint('cli_templates_path')
const { templatesMain } = await import('../cli/handlers/templateJobs.js')
await templatesMain(args)
// process.exit (not return) — mountFleetView's Ink TUI can leave event
// loop handles that prevent natural exit.
// eslint-disable-next-line custom-rules/no-process-exit
process.exit(0)
}

View File

@@ -48,7 +48,6 @@ export function useAwaySummary(
'tengu_sedge_lantern',
false,
)
useEffect(() => {
if (!feature('AWAY_SUMMARY')) return
if (!gbEnabled) return

View File

@@ -18,6 +18,11 @@ import {
type PipeIpcSlaveState,
} from '../utils/pipeTransport.js'
import { logForDebugging } from '../utils/debug.js'
import {
isMasterPipeMuted,
hasSendOverride,
removeSendOverride,
} from '../utils/pipeMuteState.js'
/** Session history entry for pipe IPC monitoring. */
export type SessionEntry = {
@@ -113,6 +118,28 @@ function isMonitoredPipeEntryType(type: string): boolean {
return MONITORED_PIPE_ENTRY_TYPES.includes(type)
}
/** Business message types that should be dropped when a slave is muted. */
const MUTED_DROPPABLE_TYPES = new Set([
'prompt_ack',
'stream',
'tool_start',
'tool_result',
'done',
'error',
'permission_request',
'permission_cancel',
])
/**
* Centralized mute check used by both attachPipeEntryEmitter and
* useMasterMonitor's inline handler — keeps the two gates in sync.
*/
export function shouldDropMutedMessage(slaveName: string, msgType: string): boolean {
if (hasSendOverride(slaveName)) return false
if (!isMasterPipeMuted(slaveName)) return false
return MUTED_DROPPABLE_TYPES.has(msgType)
}
function pipeMessageToSessionEntry(
slaveName: string,
msg: PipeMessage,
@@ -153,6 +180,35 @@ function attachPipeEntryEmitter(name: string, client: PipeClient): void {
if (typeof client.on !== 'function') return
const handler = (msg: PipeMessage) => {
if (!isMonitoredPipeEntryType(msg.type)) return
// Mute gate: drop business messages from muted slaves
if (shouldDropMutedMessage(name, msg.type)) {
// Auto-deny permission_request to prevent slave deadlock
if (msg.type === 'permission_request') {
try {
const payload = JSON.parse(msg.data ?? '{}')
if (payload.requestId) {
client.send({
type: 'permission_response',
data: JSON.stringify({
requestId: payload.requestId,
behavior: 'deny',
feedback: 'Permission auto-denied: pipe is logically disconnected.',
}),
})
}
} catch {
// Malformed payload — safe to ignore
}
}
return
}
// Clear /send override when slave turn completes
if ((msg.type === 'done' || msg.type === 'error') && hasSendOverride(name)) {
removeSendOverride(name)
}
emitPipeEntry(name, pipeMessageToSessionEntry(name, msg))
}
_pipeEntryHandlers.set(name, handler)
@@ -166,14 +222,14 @@ function emitSlaveClientRegistryChanged(): void {
}
}
function subscribeToSlaveClientRegistry(listener: () => void): () => void {
export function subscribeToSlaveClientRegistry(listener: () => void): () => void {
_slaveClientRegistryListeners.add(listener)
return () => {
_slaveClientRegistryListeners.delete(listener)
}
}
function getSlaveClientRegistryVersion(): number {
export function getSlaveClientRegistryVersion(): number {
return _slaveClientRegistryVersion
}
@@ -248,13 +304,23 @@ export function useMasterMonitor(): void {
for (const [slaveName, client] of _slaveClients.entries()) {
const handler = (msg: PipeMessage) => {
const entry = pipeMessageToSessionEntry(slaveName, msg)
// Only record relevant message types
if (!isMonitoredPipeEntryType(msg.type)) {
return
}
// Mute gate (second gate, same helper as attachPipeEntryEmitter)
if (shouldDropMutedMessage(slaveName, msg.type)) {
return
}
// Clear /send override when slave turn completes
if ((msg.type === 'done' || msg.type === 'error') && hasSendOverride(slaveName)) {
removeSendOverride(slaveName)
}
const entry = pipeMessageToSessionEntry(slaveName, msg)
setAppState(prev => {
const slave = getPipeIpc(prev).slaves[slaveName]
if (!slave) return prev
@@ -294,6 +360,8 @@ export function useMasterMonitor(): void {
// Handle slave disconnect
const onDisconnect = () => {
logForDebugging(`[MasterMonitor] Slave "${slaveName}" disconnected`)
// Clear any lingering /send override before removing client
removeSendOverride(slaveName)
removeSlaveClient(slaveName)
setAppState(prev => {
const { [slaveName]: _removed, ...remainingSlaves } =

View File

@@ -246,6 +246,15 @@ function registerMessageHandlers(
}
})
// Handle relay mute/unmute from master
server.onMessage((msg: PipeMessage, _reply) => {
if (msg.type === 'relay_mute') {
pp().setRelayMuted(true)
} else if (msg.type === 'relay_unmute') {
pp().setRelayMuted(false)
}
})
// Handle detach
server.onMessage((msg: PipeMessage, _reply) => {
if (msg.type !== 'detach') return

View File

@@ -0,0 +1,141 @@
/**
* usePipeMuteSync — Sync master's UI selection state to slave relay mute flags.
*
* Watches routeMode, selectedPipes, slave client registry, and send-override
* changes. When a slave is deselected or routeMode switches to 'local', sends
* relay_mute. When re-selected, sends relay_unmute. Also maintains the
* master-side muted set for in-flight message filtering.
*
* Feature-gated by UDS_INBOX (conditional import in REPL.tsx).
*/
import { useEffect, useRef, useSyncExternalStore } from 'react'
import { useAppState } from '../state/AppState.js'
import { getPipeIpc } from '../utils/pipeTransport.js'
import {
setMasterMutedPipes,
clearMasterMutedPipes,
hasSendOverride,
clearSendOverrides,
subscribeSendOverride,
getSendOverrideVersion,
} from '../utils/pipeMuteState.js'
import {
getAllSlaveClients,
subscribeToSlaveClientRegistry,
getSlaveClientRegistryVersion,
} from './useMasterMonitor.js'
type UsePipeMuteSyncDeps = {
setToolUseConfirmQueue: (action: React.SetStateAction<Record<string, unknown>[]>) => void
}
export function usePipeMuteSync({
setToolUseConfirmQueue,
}: UsePipeMuteSyncDeps): void {
// Subscribe to individual scalars to avoid object-selector re-render churn
// (AppState.tsx warns against object-returning selectors)
const routeMode = useAppState(
s => (getPipeIpc(s).routeMode as 'selected' | 'local') ?? 'selected',
)
const selectedPipes: string[] = useAppState(
s => (getPipeIpc(s).selectedPipes as string[]) ?? [],
)
// Subscribe to slave client registry changes
const registryVersion = useSyncExternalStore(
subscribeToSlaveClientRegistry,
getSlaveClientRegistryVersion,
getSlaveClientRegistryVersion,
)
// Subscribe to send-override changes so mute recalculates after /send completes
const sendOverrideVersion = useSyncExternalStore(
subscribeSendOverride,
getSendOverrideVersion,
getSendOverrideVersion,
)
const prevMutedRef = useRef<Set<string>>(new Set())
useEffect(() => {
const slaves = getAllSlaveClients()
// Compute which slaves should be muted now
const nextMuted = new Set<string>()
if (routeMode === 'local') {
// All connected slaves muted
for (const name of slaves.keys()) {
if (!hasSendOverride(name)) {
nextMuted.add(name)
}
}
} else {
// routeMode === 'selected': mute slaves NOT in selectedPipes
const selectedSet = new Set(selectedPipes)
for (const name of slaves.keys()) {
if (!selectedSet.has(name) && !hasSendOverride(name)) {
nextMuted.add(name)
}
}
}
// Step 1: Update master-side muted set FIRST (before sending control packets)
setMasterMutedPipes(nextMuted)
const prevMuted = prevMutedRef.current
// Step 2: For newly muted slaves — abort pending permissions, then send relay_mute
for (const name of nextMuted) {
if (!prevMuted.has(name)) {
// Abort pending permission prompts for this slave
setToolUseConfirmQueue((queue: Record<string, unknown>[]) => {
const toAbort = queue.filter(
(item: Record<string, unknown>) => item.pipeName === name,
)
for (const item of toAbort) {
try {
;(item.onAbort as (() => void) | undefined)?.()
} catch {
// onAbort may throw if client disconnected — safe to ignore
}
}
return queue.filter((item: Record<string, unknown>) => item.pipeName !== name)
})
// Send relay_mute to slave
const client = slaves.get(name)
if (client?.connected) {
try {
client.send({ type: 'relay_mute' })
} catch {
// send may fail if socket is closing — non-fatal
}
}
}
}
// Step 3: For newly unmuted slaves — send relay_unmute
for (const name of prevMuted) {
if (!nextMuted.has(name)) {
const client = slaves.get(name)
if (client?.connected) {
try {
client.send({ type: 'relay_unmute' })
} catch {
// non-fatal
}
}
}
}
prevMutedRef.current = nextMuted
}, [routeMode, selectedPipes, registryVersion, sendOverrideVersion, setToolUseConfirmQueue])
// Cleanup on unmount: clear all master-side mute state
useEffect(() => {
return () => {
clearMasterMutedPipes()
clearSendOverrides()
}
}, [])
}

View File

@@ -90,6 +90,7 @@ export function usePipePermissionForward({
input: payload.input,
toolUseContext,
toolUseID: `pipe:${payload.requestId}`,
pipeName,
permissionResult: payload.permissionResult,
permissionPromptStartTimeMs:
payload.permissionPromptStartTimeMs,

View File

@@ -6,7 +6,7 @@
* `getPipeRelay()` singleton set by usePipeIpc's attach handler.
*/
import { useRef, useCallback } from 'react'
import { getPipeRelay } from '../utils/pipePermissionRelay.js'
import { getPipeRelay, isRelayMuted } from '../utils/pipePermissionRelay.js'
import type { PipeMessage } from '../utils/pipeTransport.js'
export type PipeRelayHandle = {
@@ -29,6 +29,9 @@ export function usePipeRelay(): PipeRelayHandle {
if (typeof relay !== 'function') {
return false
}
if (isRelayMuted()) {
return false
}
relay(message)
return true
},

View File

@@ -7,9 +7,12 @@ import {
} from '../tasks/InProcessTeammateTask/InProcessTeammateTask.js'
import { isKairosCronEnabled } from '@claude-code-best/builtin-tools/tools/ScheduleCronTool/prompt.js'
import type { Message } from '../types/message.js'
import { getCwd } from '../utils/cwd.js'
import { getCronJitterConfig } from '../utils/cronJitterConfig.js'
import { createCronScheduler } from '../utils/cronScheduler.js'
import { removeCronTasks } from '../utils/cronTasks.js'
import { createAutonomyQueuedPrompt } from '../utils/autonomyRuns.js'
import { markAutonomyRunFailed } from '../utils/autonomyRuns.js'
import { logForDebugging } from '../utils/debug.js'
import { enqueuePendingNotification } from '../utils/messageQueueManager.js'
import { createScheduledTaskFireMessage } from '../utils/messages.js'
@@ -68,50 +71,92 @@ export function useScheduledTasks({
// forward isMeta, so their messages remain visible in the
// transcript. This is acceptable since normal mode is not the
// primary use case for scheduled tasks.
const enqueueForLead = (prompt: string) =>
enqueuePendingNotification({
value: prompt,
mode: 'prompt',
priority: 'later',
isMeta: true,
// Threaded through to cc_workload= in the billing-header
// attribution block so the API can serve cron-initiated requests
// at lower QoS when capacity is tight. No human is actively
// waiting on this response.
const enqueueForLead = async (prompt: string) => {
const command = await createAutonomyQueuedPrompt({
basePrompt: prompt,
trigger: 'scheduled-task',
currentDir: getCwd(),
workload: WORKLOAD_CRON,
})
if (!command) {
return
}
enqueuePendingNotification(command)
}
const scheduler = createCronScheduler({
// Missed-task surfacing (onFire fallback). Teammate crons are always
// session-only (durable:false) so they never appear in the missed list,
// which is populated from disk at scheduler startup — this path only
// handles team-lead durable crons.
onFire: enqueueForLead,
onFire: prompt => {
void enqueueForLead(prompt)
},
// Normal fires receive the full CronTask so we can route by agentId.
onFireTask: task => {
if (task.agentId) {
const teammate = findTeammateTaskByAgentId(
task.agentId,
store.getState().tasks,
)
if (teammate && !isTerminalTaskStatus(teammate.status)) {
injectUserMessageToTeammate(teammate.id, task.prompt, setAppState)
void (async () => {
if (task.agentId) {
const teammate = findTeammateTaskByAgentId(
task.agentId,
store.getState().tasks,
)
if (teammate && !isTerminalTaskStatus(teammate.status)) {
const command = await createAutonomyQueuedPrompt({
basePrompt: task.prompt,
trigger: 'scheduled-task',
currentDir: getCwd(),
sourceId: task.id,
sourceLabel: task.prompt,
workload: WORKLOAD_CRON,
})
if (!command) {
return
}
const injected = injectUserMessageToTeammate(
teammate.id,
command.value as string,
{
autonomyRunId: command.autonomy?.runId,
origin: command.origin,
},
setAppState,
)
if (!injected && command.autonomy?.runId) {
await markAutonomyRunFailed(
command.autonomy.runId,
`Teammate ${task.agentId} exited before the scheduled message could be delivered.`,
)
}
return
}
// Teammate is gone — clean up the orphaned cron so it doesn't keep
// firing into nowhere every tick. One-shots would auto-delete on
// fire anyway, but recurring crons would loop until auto-expiry.
logForDebugging(
`[ScheduledTasks] teammate ${task.agentId} gone, removing orphaned cron ${task.id}`,
)
void removeCronTasks([task.id])
return
}
// Teammate is gone — clean up the orphaned cron so it doesn't keep
// firing into nowhere every tick. One-shots would auto-delete on
// fire anyway, but recurring crons would loop until auto-expiry.
logForDebugging(
`[ScheduledTasks] teammate ${task.agentId} gone, removing orphaned cron ${task.id}`,
const command = await createAutonomyQueuedPrompt({
basePrompt: task.prompt,
trigger: 'scheduled-task',
currentDir: getCwd(),
sourceId: task.id,
sourceLabel: task.prompt,
workload: WORKLOAD_CRON,
})
if (!command) {
return
}
const msg = createScheduledTaskFireMessage(
`Running scheduled task (${formatCronFireTime(new Date())})`,
)
void removeCronTasks([task.id])
return
}
const msg = createScheduledTaskFireMessage(
`Running scheduled task (${formatCronFireTime(new Date())})`,
)
setMessages(prev => [...prev, msg])
enqueueForLead(task.prompt)
setMessages(prev => [...prev, msg])
enqueuePendingNotification(command)
})()
},
isLoading: () => isLoadingRef.current,
assistantMode,

View File

@@ -0,0 +1,140 @@
/**
* Tests for src/jobs/classifier.ts
*
* Uses real temp directories instead of mocking fs to avoid
* cross-test mock pollution in bun test.
*
* classifier.ts takes jobDir as a parameter, so no envUtils mock needed.
*/
import { describe, expect, test, beforeEach, afterAll } from 'bun:test'
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import type { AssistantMessage } from '../../types/message.js'
import { classifyAndWriteState } from '../classifier.js'
// ─── setup: real temp dir ──────────────────────────────────────────────────
let tempBase: string
let jobDir: string
let stateFile: string
tempBase = mkdtempSync(join(tmpdir(), 'classifier-test-'))
function freshJobDir(): void {
jobDir = mkdtempSync(join(tempBase, 'job-'))
stateFile = join(jobDir, 'state.json')
}
// ─── helpers ────────────────────────────────────────────────────────────────
function makeAssistantMessage(
content: any[],
extra: Record<string, any> = {},
): AssistantMessage {
return {
type: 'assistant',
uuid: '00000000-0000-0000-0000-000000000000' as any,
message: {
role: 'assistant',
content,
...extra,
},
} as any
}
// ─── lifecycle ─────────────────────────────────────────────────────────────
beforeEach(() => {
freshJobDir()
})
afterAll(() => {
try {
rmSync(tempBase, { recursive: true, force: true })
} catch {
// best-effort cleanup
}
})
// ─── tests ──────────────────────────────────────────────────────────────────
describe('classifyAndWriteState', () => {
test('does nothing when state.json is missing', async () => {
await classifyAndWriteState(jobDir, [])
// stateFile should still not exist
let exists = false
try {
readFileSync(stateFile, 'utf-8')
exists = true
} catch {
// expected
}
expect(exists).toBe(false)
})
test('sets status to running when last message has tool_use block', async () => {
writeFileSync(
stateFile,
JSON.stringify({ status: 'created', updatedAt: '2026-01-01' }),
'utf-8',
)
const msg = makeAssistantMessage([
{ type: 'text', text: 'Let me check...' },
{ type: 'tool_use', id: 'toolu_1', name: 'bash', input: {} },
])
await classifyAndWriteState(jobDir, [msg])
const state = JSON.parse(readFileSync(stateFile, 'utf-8'))
expect(state.status).toBe('running')
})
test('sets status to completed when stop_reason is end_turn', async () => {
writeFileSync(
stateFile,
JSON.stringify({ status: 'running', updatedAt: '2026-01-01' }),
'utf-8',
)
const msg = makeAssistantMessage([{ type: 'text', text: 'All done.' }], {
stop_reason: 'end_turn',
})
await classifyAndWriteState(jobDir, [msg])
const state = JSON.parse(readFileSync(stateFile, 'utf-8'))
expect(state.status).toBe('completed')
})
test('sets status to running for empty messages (state exists)', async () => {
writeFileSync(
stateFile,
JSON.stringify({ status: 'created', updatedAt: '2026-01-01' }),
'utf-8',
)
await classifyAndWriteState(jobDir, [])
const state = JSON.parse(readFileSync(stateFile, 'utf-8'))
expect(state.status).toBe('running')
})
test('sets status to running when stop_reason is max_tokens', async () => {
writeFileSync(
stateFile,
JSON.stringify({ status: 'running', updatedAt: '2026-01-01' }),
'utf-8',
)
const msg = makeAssistantMessage([{ type: 'text', text: 'I need more' }], {
stop_reason: 'max_tokens',
})
await classifyAndWriteState(jobDir, [msg])
const state = JSON.parse(readFileSync(stateFile, 'utf-8'))
expect(state.status).toBe('running')
})
})

View File

@@ -0,0 +1,91 @@
/**
* Tests for src/jobs/state.ts
*
* Uses real temp directories and CLAUDE_CONFIG_DIR env var
* instead of mocking fs, to avoid cross-test mock pollution.
*/
import { describe, expect, test, beforeEach, afterAll } from 'bun:test'
import { mkdtempSync, rmSync, readFileSync, existsSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
// ─── setup: real temp dir via env var ──────────────────────────────────────
const tempBase = mkdtempSync(join(tmpdir(), 'jobs-state-test-'))
beforeEach(() => {
// Each test gets a fresh config dir
const tempHome = mkdtempSync(join(tempBase, 'home-'))
process.env.CLAUDE_CONFIG_DIR = tempHome
})
afterAll(() => {
delete process.env.CLAUDE_CONFIG_DIR
try {
rmSync(tempBase, { recursive: true, force: true })
} catch {
// best-effort cleanup
}
})
// ─── import ─────────────────────────────────────────────────────────────────
const { createJob, readJobState, appendJobReply, getJobDir } = await import(
'../state.js'
)
// ─── tests ──────────────────────────────────────────────────────────────────
describe('createJob', () => {
test('creates job directory and writes state, template, and input files', () => {
const dir = createJob('job-1', 'my-template', '# Template', 'hello', [
'--flag',
])
expect(dir).toContain('job-1')
expect(existsSync(dir)).toBe(true)
const stateFile = join(dir, 'state.json')
expect(existsSync(stateFile)).toBe(true)
const state = JSON.parse(readFileSync(stateFile, 'utf-8'))
expect(state.jobId).toBe('job-1')
expect(state.templateName).toBe('my-template')
expect(state.status).toBe('created')
expect(state.args).toEqual(['--flag'])
expect(readFileSync(join(dir, 'template.md'), 'utf-8')).toBe('# Template')
expect(readFileSync(join(dir, 'input.txt'), 'utf-8')).toBe('hello')
})
})
describe('readJobState', () => {
test('returns null when job does not exist', () => {
expect(readJobState('nonexistent')).toBeNull()
})
test('returns parsed state when job exists', () => {
createJob('job-2', 'tpl', 'content', 'input', [])
const result = readJobState('job-2')
expect(result).not.toBeNull()
expect(result!.jobId).toBe('job-2')
expect(result!.status).toBe('created')
})
})
describe('appendJobReply', () => {
test('returns false when job does not exist', () => {
expect(appendJobReply('no-job', 'hello')).toBe(false)
})
test('appends reply and updates state', () => {
createJob('job-3', 'tpl', 'content', 'input', [])
const result = appendJobReply('job-3', 'my reply')
expect(result).toBe(true)
const dir = getJobDir('job-3')
const repliesPath = join(dir, 'replies.jsonl')
expect(existsSync(repliesPath)).toBe(true)
const replyLine = JSON.parse(readFileSync(repliesPath, 'utf-8').trim())
expect(replyLine.text).toBe('my reply')
})
})

View File

@@ -0,0 +1,87 @@
/**
* Tests for src/jobs/templates.ts
*
* Uses real temp directories and CLAUDE_CONFIG_DIR env var
* instead of mocking fs, to avoid cross-test mock pollution.
*/
import { describe, expect, test, beforeEach, afterAll } from 'bun:test'
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
// ─── setup: real temp dir via env var ──────────────────────────────────────
const tempBase = mkdtempSync(join(tmpdir(), 'jobs-templates-test-'))
beforeEach(() => {
const tempHome = mkdtempSync(join(tempBase, 'home-'))
process.env.CLAUDE_CONFIG_DIR = tempHome
})
afterAll(() => {
delete process.env.CLAUDE_CONFIG_DIR
try {
rmSync(tempBase, { recursive: true, force: true })
} catch {
// best-effort cleanup
}
})
// ─── import ─────────────────────────────────────────────────────────────────
const { listTemplates, loadTemplate } = await import('../templates.js')
// ─── tests ──────────────────────────────────────────────────────────────────
describe('listTemplates', () => {
test('returns empty array when no template dirs exist', () => {
const result = listTemplates()
expect(result).toEqual([])
})
test('discovers templates from user-level dir', () => {
const userDir = join(process.env.CLAUDE_CONFIG_DIR!, 'templates')
mkdirSync(userDir, { recursive: true })
writeFileSync(
join(userDir, 'greeting.md'),
'---\ndescription: A greeting template\n---\nHello {{name}}',
'utf-8',
)
const result = listTemplates()
expect(result.length).toBe(1)
expect(result[0]!.name).toBe('greeting')
expect(result[0]!.description).toBe('A greeting template')
expect(result[0]!.content).toBe('Hello {{name}}')
})
test('skips non-md files', () => {
const userDir = join(process.env.CLAUDE_CONFIG_DIR!, 'templates')
mkdirSync(userDir, { recursive: true })
writeFileSync(join(userDir, 'notes.txt'), 'not a template', 'utf-8')
writeFileSync(join(userDir, 'data.json'), '{}', 'utf-8')
const result = listTemplates()
expect(result).toEqual([])
})
})
describe('loadTemplate', () => {
test('returns null when template not found', () => {
expect(loadTemplate('nonexistent')).toBeNull()
})
test('returns template by name', () => {
const userDir = join(process.env.CLAUDE_CONFIG_DIR!, 'templates')
mkdirSync(userDir, { recursive: true })
writeFileSync(
join(userDir, 'deploy.md'),
'---\ndescription: Deploy script\n---\nrun deploy',
'utf-8',
)
const result = loadTemplate('deploy')
expect(result).not.toBeNull()
expect(result!.name).toBe('deploy')
})
})

View File

@@ -1,3 +1,67 @@
// Auto-generated stub — replace with real implementation
export {};
export const classifyAndWriteState: (...args: unknown[]) => Promise<void> = () => Promise.resolve();
import { readFileSync, writeFileSync } from 'fs'
import { join } from 'path'
import type { AssistantMessage } from '../types/message.js'
/**
* Classify the job status from the turn's assistant messages and update state.json.
*
* Called by stopHooks.ts after each repl_main_thread turn when CLAUDE_JOB_DIR is set.
* Only the main thread calls this (not subagents).
*
* @param jobDir - Path to the job directory (from CLAUDE_JOB_DIR env)
* @param assistantMessages - Assistant messages from this turn
*/
export async function classifyAndWriteState(
jobDir: string,
assistantMessages: AssistantMessage[],
): Promise<void> {
const stateFile = join(jobDir, 'state.json')
let state: Record<string, unknown>
try {
state = JSON.parse(readFileSync(stateFile, 'utf-8'))
} catch {
// No state file or corrupt — not a valid job directory
return
}
const newStatus = classifyStatus(assistantMessages)
state.status = newStatus
state.updatedAt = new Date().toISOString()
writeFileSync(stateFile, JSON.stringify(state, null, 2), 'utf-8')
}
/**
* Determine job status from assistant messages.
*
* - Has tool_use blocks → still running (tools executing)
* - stop_reason === 'end_turn' → completed (model finished)
* - Otherwise → running
*/
function classifyStatus(messages: AssistantMessage[]): string {
if (messages.length === 0) return 'running'
const lastMessage = messages[messages.length - 1]!
const content = lastMessage.message?.content
// Check if the last message has tool_use blocks (still executing)
if (Array.isArray(content)) {
const hasToolUse = content.some(
block =>
typeof block === 'object' &&
block !== null &&
'type' in block &&
block.type === 'tool_use',
)
if (hasToolUse) return 'running'
}
// Check stop_reason via index signature
const stopReason = (lastMessage.message as Record<string, unknown>)
?.stop_reason
if (stopReason === 'end_turn') return 'completed'
if (stopReason === 'max_tokens') return 'running'
return 'running'
}

102
src/jobs/state.ts Normal file
View File

@@ -0,0 +1,102 @@
import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
import { join } from 'path'
import { getClaudeConfigHomeDir } from '../utils/envUtils.js'
export interface JobState {
jobId: string
templateName: string
createdAt: string
updatedAt: string
status: 'created' | 'running' | 'completed' | 'failed'
args: string[]
}
function getJobsDir(): string {
return join(getClaudeConfigHomeDir(), 'jobs')
}
export function getJobDir(jobId: string): string {
return join(getJobsDir(), jobId)
}
/**
* Create a new job directory with initial state.
*/
export function createJob(
jobId: string,
templateName: string,
templateContent: string,
inputText: string,
args: string[],
): string {
const dir = getJobDir(jobId)
mkdirSync(dir, { recursive: true })
const now = new Date().toISOString()
const state: JobState = {
jobId,
templateName,
createdAt: now,
updatedAt: now,
status: 'created',
args,
}
writeFileSync(
join(dir, 'state.json'),
JSON.stringify(state, null, 2),
'utf-8',
)
writeFileSync(join(dir, 'template.md'), templateContent, 'utf-8')
writeFileSync(join(dir, 'input.txt'), inputText, 'utf-8')
return dir
}
/**
* Read job state from disk.
*/
export function readJobState(jobId: string): JobState | null {
try {
const raw = readFileSync(join(getJobDir(jobId), 'state.json'), 'utf-8')
const parsed: unknown = JSON.parse(raw)
if (typeof parsed !== 'object' || parsed === null) return null
const obj = parsed as Record<string, unknown>
if (typeof obj.jobId !== 'string' || typeof obj.status !== 'string') {
return null
}
return obj as unknown as JobState
} catch {
return null
}
}
/**
* Append a reply to a job.
*/
export function appendJobReply(jobId: string, text: string): boolean {
const dir = getJobDir(jobId)
const state = readJobState(jobId)
if (!state) return false
const repliesPath = join(dir, 'replies.jsonl')
const entry = JSON.stringify({
text,
timestamp: new Date().toISOString(),
})
try {
appendFileSync(repliesPath, entry + '\n', 'utf-8')
} catch {
writeFileSync(repliesPath, entry + '\n', 'utf-8')
}
const updated = { ...state, updatedAt: new Date().toISOString() }
writeFileSync(
join(dir, 'state.json'),
JSON.stringify(updated, null, 2),
'utf-8',
)
return true
}

86
src/jobs/templates.ts Normal file
View File

@@ -0,0 +1,86 @@
import { readdirSync, readFileSync } from 'fs'
import { join, basename } from 'path'
import { parseFrontmatter } from '../utils/frontmatterParser.js'
import type { FrontmatterData } from '../utils/frontmatterParser.js'
import { getClaudeConfigHomeDir } from '../utils/envUtils.js'
import {
getProjectDirsUpToHome,
extractDescriptionFromMarkdown,
type ClaudeConfigDirectory,
} from '../utils/markdownConfigLoader.js'
export interface TemplateInfo {
name: string
description: string
filePath: string
frontmatter: FrontmatterData
content: string
}
/**
* Discover .claude/templates directories from CWD up to git root,
* plus the user-level ~/.claude/templates.
*/
function getTemplatesDirs(): string[] {
const projectDirs = getProjectDirsUpToHome(
'templates' as ClaudeConfigDirectory,
process.cwd(),
)
// User-level dir (getProjectDirsUpToHome stops before home)
const userDir = join(getClaudeConfigHomeDir(), 'templates')
try {
readdirSync(userDir)
return [...projectDirs, userDir]
} catch {
return projectDirs
}
}
/**
* List all available templates.
*/
export function listTemplates(): TemplateInfo[] {
const templates: TemplateInfo[] = []
const seenNames = new Set<string>()
for (const dir of getTemplatesDirs()) {
let files: string[]
try {
files = readdirSync(dir)
} catch {
continue
}
for (const file of files) {
if (!file.endsWith('.md')) continue
const name = basename(file, '.md')
if (seenNames.has(name)) continue
seenNames.add(name)
const filePath = join(dir, file)
try {
const raw = readFileSync(filePath, 'utf-8')
const { frontmatter, content } = parseFrontmatter(raw, filePath)
const description =
(typeof frontmatter.description === 'string'
? frontmatter.description
: '') || extractDescriptionFromMarkdown(content, 'No description')
templates.push({ name, description, filePath, frontmatter, content })
} catch {
// Skip unreadable files
}
}
}
return templates
}
/**
* Load a specific template by name.
*/
export function loadTemplate(name: string): TemplateInfo | null {
const all = listTemplates()
return all.find(t => t.name === name) ?? null
}

View File

@@ -1802,9 +1802,11 @@ async function run(): Promise<CommanderCommand> {
}
if (
feature("KAIROS") &&
assistantModule?.isAssistantMode() &&
assistantModule &&
(assistantModule.isAssistantForced() ||
(options as Record<string, unknown>).assistant === true) &&
// Spawned teammates share the leader's cwd + settings.json, so
// isAssistantMode() is true for them too. --agent-id being set
// the flag is true for them too. --agent-id being set
// means we ARE a spawned teammate (extractTeammateOptions runs
// ~170 lines later so check the raw commander option) — don't
// re-init the team or override teammateMode/proactive/brief.

View File

@@ -0,0 +1,80 @@
import { beforeEach, describe, expect, test } from 'bun:test'
import {
activateProactive,
deactivateProactive,
getActivationSource,
getNextTickAt,
isContextBlocked,
isProactiveActive,
isProactivePaused,
pauseProactive,
resumeProactive,
setContextBlocked,
setNextTickAt,
shouldTick,
subscribeToProactiveChanges,
} from '../index'
function resetProactiveState() {
activateProactive('reset')
setContextBlocked(false)
setNextTickAt(null)
deactivateProactive()
}
beforeEach(() => {
resetProactiveState()
})
describe('proactive state baseline', () => {
test('activateProactive enables proactive mode and records the source', () => {
activateProactive('baseline_test')
expect(isProactiveActive()).toBe(true)
expect(isProactivePaused()).toBe(false)
expect(isContextBlocked()).toBe(false)
expect(getActivationSource()).toBe('baseline_test')
expect(shouldTick()).toBe(true)
})
test('pauseProactive suppresses ticking and clears nextTickAt', () => {
activateProactive('pause_case')
setNextTickAt(Date.now() + 30_000)
pauseProactive()
expect(isProactivePaused()).toBe(true)
expect(getNextTickAt()).toBeNull()
expect(shouldTick()).toBe(false)
resumeProactive()
expect(isProactivePaused()).toBe(false)
expect(shouldTick()).toBe(true)
})
test('setContextBlocked clears nextTickAt and blocks ticking', () => {
activateProactive('blocked_case')
setNextTickAt(Date.now() + 5_000)
setContextBlocked(true)
expect(isContextBlocked()).toBe(true)
expect(getNextTickAt()).toBeNull()
expect(shouldTick()).toBe(false)
})
test('subscribers are notified on state changes', () => {
let notifications = 0
const unsubscribe = subscribeToProactiveChanges(() => {
notifications += 1
})
activateProactive('subscriber_case')
setNextTickAt(Date.now() + 1_000)
setContextBlocked(true)
deactivateProactive()
unsubscribe()
expect(notifications).toBeGreaterThanOrEqual(3)
})
})

View File

@@ -6,7 +6,10 @@
* proactive mode is active and not blocked.
*/
import { useEffect, useRef } from 'react'
import type { QueuedCommand } from '../types/textInputTypes.js'
import { TICK_TAG } from '../constants/xml.js'
import { getCwd } from '../utils/cwd.js'
import { createProactiveAutonomyCommands } from '../utils/autonomyRuns.js'
import {
isProactiveActive,
isProactivePaused,
@@ -24,8 +27,7 @@ type UseProactiveOpts = {
queuedCommandsLength: number
hasActiveLocalJsxUI: boolean
isInPlanMode: boolean
onSubmitTick: (prompt: string) => void
onQueueTick: (prompt: string) => void
onQueueTick: (command: QueuedCommand) => void
}
export function useProactive(opts: UseProactiveOpts): void {
@@ -70,14 +72,19 @@ export function useProactive(opts: UseProactiveOpts): void {
return
}
const tickContent = `<${TICK_TAG}>${new Date().toLocaleTimeString()}</${TICK_TAG}>`
// If nothing is in the queue, submit directly; otherwise queue
if (queuedCommandsLength === 0) {
optsRef.current.onSubmitTick(tickContent)
} else {
optsRef.current.onQueueTick(tickContent)
}
void (async () => {
const commands = await createProactiveAutonomyCommands({
basePrompt: `<${TICK_TAG}>${new Date().toLocaleTimeString()}</${TICK_TAG}>`,
currentDir: getCwd(),
})
for (const command of commands) {
// Always queue proactive turns. This avoids races where the prompt
// is built asynchronously, a user turn starts meanwhile, and a
// direct-submit path would silently drop the autonomy turn after
// consuming its heartbeat due-state.
optsRef.current.onQueueTick(command)
}
})()
// Schedule next tick
scheduleTick()

View File

@@ -14,18 +14,27 @@ import { dirname, join } from 'path';
import { tmpdir } from 'os';
import figures from 'figures';
// eslint-disable-next-line custom-rules/prefer-use-keybindings -- / n N Esc [ v are bare letters in transcript modal context, same class as g/G/j/k in ScrollKeybindingHandler
import { useInput } from '@anthropic/ink'
import { useSearchInput } from '../hooks/useSearchInput.js'
import { useTerminalSize } from '../hooks/useTerminalSize.js'
import { useSearchHighlight } from '@anthropic/ink'
import type { JumpHandle } from '../components/VirtualMessageList.js'
import { renderMessagesToPlainText } from '../utils/exportRenderer.js'
import { openFileInExternalEditor } from '../utils/editor.js'
import { writeFile } from 'fs/promises'
import { type TabStatusKind, Box, Text, useStdin, useTheme, useTerminalFocus, useTerminalTitle, useTabStatus } from '@anthropic/ink'
import { CostThresholdDialog } from '../components/CostThresholdDialog.js'
import { IdleReturnDialog } from '../components/IdleReturnDialog.js'
import * as React from 'react'
import { useInput } from '@anthropic/ink';
import { useSearchInput } from '../hooks/useSearchInput.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { useSearchHighlight } from '@anthropic/ink';
import type { JumpHandle } from '../components/VirtualMessageList.js';
import { renderMessagesToPlainText } from '../utils/exportRenderer.js';
import { openFileInExternalEditor } from '../utils/editor.js';
import { writeFile } from 'fs/promises';
import {
type TabStatusKind,
Box,
Text,
useStdin,
useTheme,
useTerminalFocus,
useTerminalTitle,
useTabStatus,
} from '@anthropic/ink';
import { CostThresholdDialog } from '../components/CostThresholdDialog.js';
import { IdleReturnDialog } from '../components/IdleReturnDialog.js';
import * as React from 'react';
import {
useEffect,
useMemo,
@@ -35,14 +44,11 @@ import {
useDeferredValue,
useLayoutEffect,
type RefObject,
} from 'react'
import { useNotifications } from '../context/notifications.js'
import { sendNotification } from '../services/notifier.js'
import {
startPreventSleep,
stopPreventSleep,
} from '../services/preventSleep.js'
import { useTerminalNotification, hasCursorUpViewportYankBug } from '@anthropic/ink'
} from 'react';
import { useNotifications } from '../context/notifications.js';
import { sendNotification } from '../services/notifier.js';
import { startPreventSleep, stopPreventSleep } from '../services/preventSleep.js';
import { useTerminalNotification, hasCursorUpViewportYankBug } from '@anthropic/ink';
import {
createFileStateCacheWithSizeLimit,
mergeFileStateCaches,
@@ -72,6 +78,11 @@ import { QueryGuard } from '../utils/QueryGuard.js';
import { isEnvTruthy } from '../utils/envUtils.js';
import { formatTokens, truncateToWidth } from '../utils/format.js';
import { consumeEarlyInput } from '../utils/earlyInput.js';
import {
finalizeAutonomyRunCompleted,
finalizeAutonomyRunFailed,
markAutonomyRunRunning,
} from '../utils/autonomyRuns.js';
import { setMemberActive } from '../utils/swarm/teamHelpers.js';
import {
@@ -155,9 +166,10 @@ import { CancelRequestHandler } from '../hooks/useCancelRequest.js';
import { useBackgroundTaskNavigation } from '../hooks/useBackgroundTaskNavigation.js';
import { useSwarmInitialization } from '../hooks/useSwarmInitialization.js';
import { useTeammateViewAutoExit } from '../hooks/useTeammateViewAutoExit.js';
import { errorMessage } from '../utils/errors.js';
import { errorMessage, toError } from '../utils/errors.js';
import { isHumanTurn } from '../utils/messagePredicates.js';
import { logError } from '../utils/log.js';
import { getCwd } from '../utils/cwd.js';
// Dead code elimination: conditional imports
/* eslint-disable custom-rules/no-process-env-top-level, @typescript-eslint/no-require-imports */
const useVoiceIntegration: typeof import('../hooks/useVoiceIntegration.js').useVoiceIntegration = feature('VOICE_MODE')
@@ -346,6 +358,7 @@ const usePipeRelay = feature('UDS_INBOX')
const usePipePermissionForward = feature('UDS_INBOX')
? require('../hooks/usePipePermissionForward.js').usePipePermissionForward
: () => undefined;
const usePipeMuteSync = feature('UDS_INBOX') ? require('../hooks/usePipeMuteSync.js').usePipeMuteSync : () => undefined;
const usePipeRouter = feature('UDS_INBOX')
? require('../hooks/usePipeRouter.js').usePipeRouter
: () => ({ routeToSelectedPipes: () => false });
@@ -465,21 +478,13 @@ import { UltraplanChoiceDialog } from '../components/ultraplan/UltraplanChoiceDi
import { UltraplanLaunchDialog } from '../components/ultraplan/UltraplanLaunchDialog.js';
import { launchUltraplan } from '../commands/ultraplan.js';
// Session manager removed - using AppState now
import type { RemoteSessionConfig } from '../remote/RemoteSessionManager.js'
import { REMOTE_SAFE_COMMANDS } from '../commands.js'
import type { RemoteMessageContent } from '../utils/teleport/api.js'
import {
FullscreenLayout,
useUnseenDivider,
computeUnseenDivider,
} from '../components/FullscreenLayout.js'
import {
isFullscreenEnvEnabled,
maybeGetTmuxMouseHint,
isMouseTrackingEnabled,
} from '../utils/fullscreen.js'
import { AlternateScreen } from '@anthropic/ink'
import { ScrollKeybindingHandler } from '../components/ScrollKeybindingHandler.js'
import type { RemoteSessionConfig } from '../remote/RemoteSessionManager.js';
import { REMOTE_SAFE_COMMANDS } from '../commands.js';
import type { RemoteMessageContent } from '../utils/teleport/api.js';
import { FullscreenLayout, useUnseenDivider, computeUnseenDivider } from '../components/FullscreenLayout.js';
import { isFullscreenEnvEnabled, maybeGetTmuxMouseHint, isMouseTrackingEnabled } from '../utils/fullscreen.js';
import { AlternateScreen } from '@anthropic/ink';
import { ScrollKeybindingHandler } from '../components/ScrollKeybindingHandler.js';
import {
useMessageActions,
MessageActionsKeybindings,
@@ -487,13 +492,10 @@ import {
type MessageActionsState,
type MessageActionsNav,
type MessageActionCaps,
} from '../components/messageActions.js'
import { setClipboard } from '@anthropic/ink'
import type { ScrollBoxHandle } from '@anthropic/ink'
import {
createAttachmentMessage,
getQueuedCommandAttachments,
} from '../utils/attachments.js'
} from '../components/messageActions.js';
import { setClipboard } from '@anthropic/ink';
import type { ScrollBoxHandle } from '@anthropic/ink';
import { createAttachmentMessage, getQueuedCommandAttachments } from '../utils/attachments.js';
// Stable empty array for hooks that accept MCPServerConnection[] — avoids
// creating a new [] literal on every render in remote mode, which would
@@ -1952,7 +1954,8 @@ export function REPL({
const content = lastAssistant.message?.content;
const contentArray = Array.isArray(content) ? content : [];
const inProgressToolUses = contentArray.filter(
(b): b is ContentBlock & { type: 'tool_use'; id: string } => b.type === 'tool_use' && inProgressToolUseIDs.has((b as { id: string }).id),
(b): b is ContentBlock & { type: 'tool_use'; id: string } =>
b.type === 'tool_use' && inProgressToolUseIDs.has((b as { id: string }).id),
);
return (
inProgressToolUses.length > 0 &&
@@ -3066,7 +3069,10 @@ export function REPL({
if (feature('PROACTIVE') || feature('KAIROS')) {
proactiveModule?.setContextBlocked(false);
}
} else if (newMessage.type === 'progress' && isEphemeralToolProgress(((newMessage as unknown as { data?: { type?: string } }).data?.type))) {
} else if (
newMessage.type === 'progress' &&
isEphemeralToolProgress((newMessage as unknown as { data?: { type?: string } }).data?.type)
) {
// Replace the previous ephemeral progress tick for the same tool
// call instead of appending. Sleep/Bash emit a tick per second and
// only the last one is rendered; appending blows up the messages
@@ -3198,7 +3204,10 @@ export function REPL({
// title silently fell through to the "Claude Code" default.
if (!titleDisabled && !sessionTitle && !agentTitle && !haikuTitleAttemptedRef.current) {
const firstUserMessage = newMessages.find(m => m.type === 'user' && !m.isMeta);
const text = firstUserMessage?.type === 'user' ? getContentText(firstUserMessage.message!.content as string | ContentBlockParam[]) : null;
const text =
firstUserMessage?.type === 'user'
? getContentText(firstUserMessage.message!.content as string | ContentBlockParam[])
: null;
// Skip synthetic breadcrumbs — slash-command output, prompt-skill
// expansions (/commit → <command-message>), local-command headers
// (/help → <command-name>), and bash-mode (!cmd → <bash-input>).
@@ -3353,10 +3362,16 @@ export function REPL({
}
if (feature('BUDDY') && typeof (globalThis as Record<string, unknown>).fireCompanionObserver === 'function') {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const _fireCompanionObserver = (globalThis as Record<string, any>).fireCompanionObserver as (msgs: unknown, cb: (r: unknown) => void) => void;
const _fireCompanionObserver = (globalThis as Record<string, unknown>).fireCompanionObserver as (
msgs: unknown,
cb: (r: unknown) => void,
) => void;
void _fireCompanionObserver(messagesRef.current, reaction =>
setAppState(prev => (prev.companionReaction === (reaction as typeof prev.companionReaction) ? prev : { ...prev, companionReaction: reaction as typeof prev.companionReaction })),
setAppState(prev =>
prev.companionReaction === (reaction as typeof prev.companionReaction)
? prev
: { ...prev, companionReaction: reaction as typeof prev.companionReaction },
),
);
}
@@ -3703,10 +3718,7 @@ export function REPL({
}
}
// Atomically: clear initial message, set permission mode and rules, and store plan for verification
const shouldStorePlanForVerification =
initialMsg.message.planContent && process.env.USER_TYPE === 'ant' && isEnvTruthy(undefined);
// Atomically: clear initial message, set permission mode and rules
setAppState(prev => {
// Build and apply permission updates (mode + allowedPrompts rules)
let updatedToolPermissionContext = initialMsg.mode
@@ -3729,13 +3741,6 @@ export function REPL({
...prev,
initialMessage: null,
toolPermissionContext: updatedToolPermissionContext,
...(shouldStorePlanForVerification ? {
pendingPlanVerification: {
plan: initialMsg.message.planContent as string,
verificationStarted: false,
verificationCompleted: false,
},
} : {}),
};
});
@@ -4299,7 +4304,7 @@ export function REPL({
});
}
} else {
injectUserMessageToTeammate(task.id, input, setAppState);
injectUserMessageToTeammate(task.id, input, undefined, setAppState);
}
setInputValue('');
helpers.setCursorOffset(0);
@@ -4804,7 +4809,7 @@ export function REPL({
// Submits incoming prompts from teammate messages or tasks mode as new turns
// Returns true if submission succeeded, false if a query is already running
const handleIncomingPrompt = useCallback(
(content: string, options?: { isMeta?: boolean }): boolean => {
(input: string | QueuedCommand, options?: { isMeta?: boolean }): boolean => {
if (queryGuard.isActive) return false;
// Defer to user-queued commands — user input always takes priority
@@ -4816,16 +4821,53 @@ export function REPL({
return false;
}
const queuedCommand =
typeof input === 'string'
? ({
value: input,
mode: 'prompt',
isMeta: options?.isMeta ? true : undefined,
} satisfies QueuedCommand)
: input;
const newAbortController = createAbortController();
setAbortController(newAbortController);
// Create a user message with the formatted content (includes XML wrapper)
const userMessage = createUserMessage({
content,
isMeta: options?.isMeta ? true : undefined,
content: queuedCommand.value as string,
isMeta: queuedCommand.isMeta ? true : undefined,
origin: queuedCommand.origin,
});
void onQuery([userMessage], newAbortController, true, [], mainLoopModel);
const autonomyRunId = queuedCommand.autonomy?.runId;
if (autonomyRunId) {
void markAutonomyRunRunning(autonomyRunId);
}
void onQuery([userMessage], newAbortController, true, [], mainLoopModel)
.then(() => {
if (autonomyRunId) {
void finalizeAutonomyRunCompleted({
runId: autonomyRunId,
currentDir: getCwd(),
priority: 'later',
}).then(nextCommands => {
for (const command of nextCommands) {
enqueue(command);
}
});
}
})
.catch((error: unknown) => {
if (autonomyRunId) {
void finalizeAutonomyRunFailed({
runId: autonomyRunId,
error: String(error),
});
}
logError(toError(error));
});
return true;
},
[onQuery, mainLoopModel, store],
@@ -4856,6 +4898,7 @@ export function REPL({
const pipeIpcState = useAppState(s => getPipeIpc(s as any));
usePipePermissionForward({ store, tools, setMessages, setToolUseConfirmQueue, getToolUseContext, mainLoopModel });
usePipeMuteSync({ setToolUseConfirmQueue });
// Pipe IPC lifecycle — extracted to usePipeIpc hook
usePipeIpc({ store, handleIncomingPrompt });
@@ -4898,8 +4941,7 @@ export function REPL({
queuedCommandsLength: queuedCommands.length,
hasActiveLocalJsxUI: isShowingLocalJSXCommand,
isInPlanMode: toolPermissionContext.mode === 'plan',
onSubmitTick: (prompt: string) => handleIncomingPrompt(prompt, { isMeta: true }),
onQueueTick: (prompt: string) => enqueue({ mode: 'prompt', value: prompt, isMeta: true }),
onQueueTick: (command: QueuedCommand) => enqueue(command),
});
// Abort the current operation when a 'now' priority message arrives
@@ -4952,16 +4994,11 @@ export function REPL({
if (!isLoading) return null;
// Find stop hook progress messages
const progressMsgs = messages.filter(
(m): m is ProgressMessage<HookProgress> => {
if (m.type !== 'progress') return false;
const data = m.data as Record<string, unknown>;
return (
data.type === 'hook_progress' &&
(data.hookEvent === 'Stop' || data.hookEvent === 'SubagentStop')
);
},
);
const progressMsgs = messages.filter((m): m is ProgressMessage<HookProgress> => {
if (m.type !== 'progress') return false;
const data = m.data as Record<string, unknown>;
return data.type === 'hook_progress' && (data.hookEvent === 'Stop' || data.hookEvent === 'SubagentStop');
});
if (progressMsgs.length === 0) return null;
// Get the most recent stop hook execution

View File

@@ -466,6 +466,7 @@ const LOCAL_GATE_DEFAULTS: Record<string, unknown> = {
tengu_birch_trellis: true, // Tree-sitter bash security analysis
tengu_collage_kaleidoscope: true, // macOS clipboard image reading
tengu_compact_cache_prefix: true, // Reuse prompt cache during compaction
tengu_kairos_assistant: true, // KAIROS assistant mode activation
tengu_kairos_cron_durable: true, // Persistent cron tasks
tengu_attribution_header: true, // API request attribution header
tengu_slate_prism: true, // Agent progress summaries
@@ -830,6 +831,16 @@ export function getFeatureValue_CACHED_MAY_BE_STALE<T>(
return localDefault !== undefined ? (localDefault as T) : defaultValue
}
// LOCAL_GATE_DEFAULTS take priority over remote values and disk cache.
// In fork/self-hosted deployments, the GrowthBook server may push false
// for gates we intentionally enable. Local defaults represent the
// project's intentional configuration and override everything except
// env/config overrides (which are explicit user intent).
const localDefault = getLocalGateDefault(feature)
if (localDefault !== undefined) {
return localDefault as T
}
// Log experiment exposure if data is available, otherwise defer until after init
if (experimentDataByFeature.has(feature)) {
logExposureForFeature(feature)
@@ -838,10 +849,6 @@ export function getFeatureValue_CACHED_MAY_BE_STALE<T>(
}
// In-memory payload is authoritative once processRemoteEvalPayload has run.
// Disk is also fresh by then (syncRemoteEvalToDisk runs synchronously inside
// init), so this is correctness-equivalent to the disk read below — but it
// skips the config JSON parse and is what onGrowthBookRefresh subscribers
// depend on to read fresh values the instant they're notified.
if (remoteEvalFeatureValues.has(feature)) {
return remoteEvalFeatureValues.get(feature) as T
}
@@ -853,14 +860,9 @@ export function getFeatureValue_CACHED_MAY_BE_STALE<T>(
return cached as T
}
} catch {
// Config not yet initialized — fall through to local gate defaults
// Config not yet initialized — fall through to defaultValue
}
// Disk cache miss (or config not initialized) — use local gate defaults
// before falling back to the caller's defaultValue. This covers:
// 1. GrowthBook "enabled" but never connected (caches empty)
// 2. Config not yet initialized (early in startup)
const localDefault = getLocalGateDefault(feature)
return localDefault !== undefined ? (localDefault as T) : defaultValue
return defaultValue
}
/**

View File

@@ -0,0 +1,487 @@
/**
* Tests for queryModelOpenAI in index.ts.
*
* Focused on the two bugs fixed:
* 1. stop_reason was always null in the assembled AssistantMessage because
* partialMessage (from message_start) has stop_reason: null, and the
* stop_reason captured from message_delta was never applied.
* 2. partialMessage was not reset to null after message_stop, so the safety
* fallback at the end of the loop would yield a second identical
* AssistantMessage (causing doubled content in the next API request).
*
* Strategy: mock getOpenAIClient + adaptOpenAIStreamToAnthropic so we can
* feed pre-built Anthropic events directly into queryModelOpenAI and inspect
* what it emits — without any real HTTP calls.
*/
import { describe, expect, test, mock, beforeEach, afterEach } from 'bun:test'
import type { BetaRawMessageStreamEvent } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
import type { AssistantMessage, StreamEvent } from '../../../../types/message.js'
// ─── helpers ─────────────────────────────────────────────────────────────────
/** Build a minimal message_start event */
function makeMessageStart(overrides: Record<string, any> = {}): BetaRawMessageStreamEvent {
return {
type: 'message_start',
message: {
id: 'msg_test',
type: 'message',
role: 'assistant',
content: [],
model: 'test-model',
stop_reason: null,
stop_sequence: null,
usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
...overrides,
},
} as any
}
/** Build a content_block_start event for the given block type */
function makeContentBlockStart(index: number, type: 'text' | 'tool_use' | 'thinking', extra: Record<string, any> = {}): BetaRawMessageStreamEvent {
const block =
type === 'text'
? { type: 'text', text: '' }
: type === 'tool_use'
? { type: 'tool_use', id: 'toolu_test', name: 'bash', input: {} }
: { type: 'thinking', thinking: '', signature: '' }
return { type: 'content_block_start', index, content_block: { ...block, ...extra } } as any
}
/** Build a text_delta content_block_delta event */
function makeTextDelta(index: number, text: string): BetaRawMessageStreamEvent {
return { type: 'content_block_delta', index, delta: { type: 'text_delta', text } } as any
}
/** Build an input_json_delta content_block_delta event */
function makeInputJsonDelta(index: number, json: string): BetaRawMessageStreamEvent {
return { type: 'content_block_delta', index, delta: { type: 'input_json_delta', partial_json: json } } as any
}
/** Build a thinking_delta content_block_delta event */
function makeThinkingDelta(index: number, thinking: string): BetaRawMessageStreamEvent {
return { type: 'content_block_delta', index, delta: { type: 'thinking_delta', thinking } } as any
}
/** Build a content_block_stop event */
function makeContentBlockStop(index: number): BetaRawMessageStreamEvent {
return { type: 'content_block_stop', index } as any
}
/** Build a message_delta event with stop_reason and output_tokens */
function makeMessageDelta(stopReason: string, outputTokens: number): BetaRawMessageStreamEvent {
return {
type: 'message_delta',
delta: { stop_reason: stopReason, stop_sequence: null },
usage: { output_tokens: outputTokens },
} as any
}
/** Build a message_stop event */
function makeMessageStop(): BetaRawMessageStreamEvent {
return { type: 'message_stop' } as any
}
/** Async generator from a fixed array of events */
async function* eventStream(events: BetaRawMessageStreamEvent[]) {
for (const e of events) yield e
}
/** Collect all outputs from queryModelOpenAI into typed buckets */
async function runQueryModel(
events: BetaRawMessageStreamEvent[],
envOverrides: Record<string, string | undefined> = {},
) {
// Wire events into the mocked stream adapter
_nextEvents = events
// Save + apply env overrides
const saved: Record<string, string | undefined> = {}
for (const [k, v] of Object.entries(envOverrides)) {
saved[k] = process.env[k]
if (v === undefined) delete process.env[k]
else process.env[k] = v
}
try {
// We inline mock.module inside the try block.
// Bun resolves mock.module at the call site synchronously (hoisted),
// so we register once per test file, then re-import each time.
const { queryModelOpenAI } = await import('../index.js')
const assistantMessages: AssistantMessage[] = []
const streamEvents: StreamEvent[] = []
const otherOutputs: any[] = []
const minimalOptions: any = {
model: 'test-model',
tools: [],
agents: [],
querySource: 'main_loop',
getToolPermissionContext: async () => ({
alwaysAllow: [],
alwaysDeny: [],
needsPermission: [],
mode: 'default',
isBypassingPermissions: false,
}),
}
for await (const item of queryModelOpenAI(
[],
{ type: 'text', text: '' } as any,
[],
new AbortController().signal,
minimalOptions,
)) {
if (item.type === 'assistant') {
assistantMessages.push(item as AssistantMessage)
} else if (item.type === 'stream_event') {
streamEvents.push(item as StreamEvent)
} else {
otherOutputs.push(item)
}
}
return { assistantMessages, streamEvents, otherOutputs }
} finally {
// Restore env
for (const [k, v] of Object.entries(saved)) {
if (v === undefined) delete process.env[k]
else process.env[k] = v
}
}
}
// ─── mock setup ──────────────────────────────────────────────────────────────
// We mock at module level. Bun's mock.module replaces the module for the
// entire file, so we configure the stream per-test via a shared variable.
let _nextEvents: BetaRawMessageStreamEvent[] = []
/** Captured arguments from the last chat.completions.create() call */
let _lastCreateArgs: Record<string, any> | null = null
mock.module('../client.js', () => ({
getOpenAIClient: () => ({
chat: {
completions: {
create: async (args: Record<string, any>) => {
_lastCreateArgs = args
return { [Symbol.asyncIterator]: async function* () {} }
},
},
},
}),
}))
mock.module('../streamAdapter.js', () => ({
adaptOpenAIStreamToAnthropic: (_stream: any, _model: string) => eventStream(_nextEvents),
}))
mock.module('../modelMapping.js', () => ({
resolveOpenAIModel: (m: string) => m,
}))
mock.module('../convertMessages.js', () => ({
anthropicMessagesToOpenAI: () => [],
}))
mock.module('../convertTools.js', () => ({
anthropicToolsToOpenAI: () => [],
anthropicToolChoiceToOpenAI: () => undefined,
}))
mock.module('../../../../utils/context.js', () => ({
MODEL_CONTEXT_WINDOW_DEFAULT: 200_000,
COMPACT_MAX_OUTPUT_TOKENS: 20_000,
CAPPED_DEFAULT_MAX_TOKENS: 8_000,
ESCALATED_MAX_TOKENS: 64_000,
is1mContextDisabled: () => false,
has1mContext: () => false,
modelSupports1M: () => false,
getModelMaxOutputTokens: () => ({ upperLimit: 8192, default: 8192 }),
getContextWindowForModel: () => 200_000,
getSonnet1mExpTreatmentEnabled: () => false,
calculateContextPercentages: () => ({ usedPercent: 0, remainingPercent: 100 }),
getMaxThinkingTokensForModel: () => 0,
}))
mock.module('../../../../utils/messages.js', () => ({
normalizeMessagesForAPI: (msgs: any) => msgs,
normalizeContentFromAPI: (blocks: any[]) => blocks,
createAssistantAPIErrorMessage: (opts: any) => ({
type: 'assistant',
message: { content: [{ type: 'text', text: opts.content }], apiError: opts.apiError },
uuid: 'error-uuid',
timestamp: new Date().toISOString(),
}),
}))
mock.module('../../../../utils/api.js', () => ({
toolToAPISchema: async (t: any) => t,
}))
mock.module('../../../../utils/toolSearch.js', () => ({
isToolSearchEnabled: async () => false,
extractDiscoveredToolNames: () => new Set(),
}))
mock.module('../../../../tools/ToolSearchTool/prompt.js', () => ({
isDeferredTool: () => false,
TOOL_SEARCH_TOOL_NAME: '__tool_search__',
}))
mock.module('../../../../cost-tracker.js', () => ({
addToTotalSessionCost: () => {},
}))
mock.module('../../../../utils/modelCost.js', () => ({
COST_TIER_3_15: {},
COST_TIER_15_75: {},
COST_TIER_5_25: {},
COST_TIER_30_150: {},
COST_HAIKU_35: {},
COST_HAIKU_45: {},
getOpus46CostTier: () => ({}),
MODEL_COSTS: {},
getModelCosts: () => ({}),
calculateUSDCost: () => 0,
calculateCostFromTokens: () => 0,
formatModelPricing: () => '',
getModelPricingString: () => undefined,
}))
mock.module('../../../../utils/debug.js', () => ({
logForDebugging: () => {},
logAntError: () => {},
isDebugMode: () => false,
isDebugToStdErr: () => false,
getDebugFilePath: () => null,
getDebugLogPath: () => '',
getDebugFilter: () => null,
getMinDebugLogLevel: () => 'debug',
enableDebugLogging: () => false,
setHasFormattedOutput: () => {},
getHasFormattedOutput: () => false,
flushDebugLogs: async () => {},
}))
// ─── tests ───────────────────────────────────────────────────────────────────
describe('queryModelOpenAI — stop_reason propagation', () => {
test('assembled AssistantMessage has stop_reason end_turn (not null)', async () => {
_nextEvents = [
makeMessageStart(),
makeContentBlockStart(0, 'text'),
makeTextDelta(0, 'Hello'),
makeContentBlockStop(0),
makeMessageDelta('end_turn', 10),
makeMessageStop(),
]
const { assistantMessages } = await runQueryModel(_nextEvents)
expect(assistantMessages).toHaveLength(1)
expect(assistantMessages[0]!.message.stop_reason).toBe('end_turn')
})
test('assembled AssistantMessage has stop_reason tool_use', async () => {
_nextEvents = [
makeMessageStart(),
makeContentBlockStart(0, 'tool_use'),
makeInputJsonDelta(0, '{"cmd":"ls"}'),
makeContentBlockStop(0),
makeMessageDelta('tool_use', 20),
makeMessageStop(),
]
const { assistantMessages } = await runQueryModel(_nextEvents)
expect(assistantMessages).toHaveLength(1)
expect(assistantMessages[0]!.message.stop_reason).toBe('tool_use')
})
test('assembled AssistantMessage has stop_reason max_tokens', async () => {
_nextEvents = [
makeMessageStart(),
makeContentBlockStart(0, 'text'),
makeTextDelta(0, 'truncated'),
makeContentBlockStop(0),
makeMessageDelta('max_tokens', 8192),
makeMessageStop(),
]
const { assistantMessages } = await runQueryModel(_nextEvents)
// Two assistant-typed items: the content message + the max_output_tokens error signal.
// The error signal is emitted as a synthetic assistant message by createAssistantAPIErrorMessage.
expect(assistantMessages).toHaveLength(2)
const contentMsg = assistantMessages[0]!
expect(contentMsg.message.stop_reason).toBe('max_tokens')
// Second item is the error signal (has apiError set)
const errorMsg = assistantMessages[1]!.message as any
expect(errorMsg.apiError).toBe('max_output_tokens')
})
test('stop_reason is null when no message_delta was received (safety fallback path)', async () => {
// Stream ends without message_stop — triggers the safety fallback branch.
// stop_reason stays null since no message_delta was ever seen.
_nextEvents = [
makeMessageStart(),
makeContentBlockStart(0, 'text'),
makeTextDelta(0, 'partial'),
makeContentBlockStop(0),
// No message_delta / message_stop
]
const { assistantMessages } = await runQueryModel(_nextEvents)
// Safety fallback should yield the partial content
expect(assistantMessages).toHaveLength(1)
expect(assistantMessages[0]!.message.stop_reason).toBeNull()
})
})
describe('queryModelOpenAI — usage accumulation', () => {
test('usage in assembled message reflects all four fields from message_delta', async () => {
// message_start has all fields=0 (trailing-chunk pattern: usage not yet available).
// message_delta carries the real values after stream ends.
// The spread in the message_delta handler must override all zeros from message_start,
// including cache_read_input_tokens which was previously missing from message_delta.
_nextEvents = [
makeMessageStart({ usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 } }),
makeContentBlockStart(0, 'text'),
makeTextDelta(0, 'response'),
makeContentBlockStop(0),
// message_delta carries all four Anthropic usage fields (as emitted by the fixed streamAdapter)
{
type: 'message_delta',
delta: { stop_reason: 'end_turn', stop_sequence: null },
usage: { input_tokens: 30011, output_tokens: 190, cache_read_input_tokens: 19904, cache_creation_input_tokens: 0 },
} as any,
makeMessageStop(),
]
const { assistantMessages } = await runQueryModel(_nextEvents)
expect(assistantMessages).toHaveLength(1)
const usage = assistantMessages[0]!.message.usage as any
expect(usage.input_tokens).toBe(30011)
expect(usage.output_tokens).toBe(190)
// cache_read_input_tokens from message_delta overrides the 0 from message_start
expect(usage.cache_read_input_tokens).toBe(19904)
expect(usage.cache_creation_input_tokens).toBe(0)
})
test('usage is zero when no usage events arrive (prevents false autocompact)', async () => {
// If usage stays 0, tokenCountWithEstimation will undercount — so at least
// verify the field exists and is numeric (to detect regressions).
_nextEvents = [
makeMessageStart(),
makeContentBlockStart(0, 'text'),
makeTextDelta(0, 'hi'),
makeContentBlockStop(0),
makeMessageDelta('end_turn', 0),
makeMessageStop(),
]
const { assistantMessages } = await runQueryModel(_nextEvents)
const usage = assistantMessages[0]!.message.usage as any
expect(typeof usage.input_tokens).toBe('number')
expect(typeof usage.output_tokens).toBe('number')
})
})
describe('queryModelOpenAI — no duplicate AssistantMessage (partialMessage reset)', () => {
test('yields exactly one AssistantMessage per message_stop when content is present', async () => {
_nextEvents = [
makeMessageStart(),
makeContentBlockStart(0, 'text'),
makeTextDelta(0, 'only once'),
makeContentBlockStop(0),
makeMessageDelta('end_turn', 5),
makeMessageStop(),
]
const { assistantMessages } = await runQueryModel(_nextEvents)
// Before the fix, partialMessage was not reset to null, so the safety
// fallback at the end of the loop would yield a second message with the
// same message.id — causing mergeAssistantMessages to concatenate content.
expect(assistantMessages).toHaveLength(1)
})
test('thinking + text response yields exactly one AssistantMessage', async () => {
_nextEvents = [
makeMessageStart(),
makeContentBlockStart(0, 'thinking'),
makeThinkingDelta(0, 'let me think'),
makeContentBlockStop(0),
makeContentBlockStart(1, 'text'),
makeTextDelta(1, 'answer'),
makeContentBlockStop(1),
makeMessageDelta('end_turn', 30),
makeMessageStop(),
]
const { assistantMessages } = await runQueryModel(_nextEvents)
expect(assistantMessages).toHaveLength(1)
})
test('safety fallback path still yields message when stream ends without message_stop', async () => {
// Simulates a stream that cuts off without the normal termination sequence.
_nextEvents = [
makeMessageStart(),
makeContentBlockStart(0, 'text'),
makeTextDelta(0, 'abrupt end'),
// No content_block_stop, no message_delta, no message_stop
]
const { assistantMessages } = await runQueryModel(_nextEvents)
expect(assistantMessages).toHaveLength(1)
})
})
describe('queryModelOpenAI — stream_events forwarded', () => {
test('every adapted event is also yielded as stream_event for real-time display', async () => {
_nextEvents = [
makeMessageStart(),
makeContentBlockStart(0, 'text'),
makeTextDelta(0, 'hello'),
makeContentBlockStop(0),
makeMessageDelta('end_turn', 5),
makeMessageStop(),
]
const { streamEvents } = await runQueryModel(_nextEvents)
const eventTypes = streamEvents.map(e => (e as any).event?.type)
expect(eventTypes).toContain('message_start')
expect(eventTypes).toContain('content_block_start')
expect(eventTypes).toContain('content_block_delta')
expect(eventTypes).toContain('content_block_stop')
expect(eventTypes).toContain('message_delta')
expect(eventTypes).toContain('message_stop')
})
})
describe('queryModelOpenAI — max_tokens forwarded to request', () => {
test('buildOpenAIRequestBody includes max_tokens in the request payload', async () => {
_nextEvents = [
makeMessageStart(),
makeContentBlockStart(0, 'text'),
makeTextDelta(0, 'hi'),
makeContentBlockStop(0),
makeMessageDelta('end_turn', 5),
makeMessageStop(),
]
await runQueryModel(_nextEvents)
expect(_lastCreateArgs).not.toBeNull()
expect(_lastCreateArgs!.max_tokens).toBe(8192)
})
})

View File

@@ -1,6 +1,21 @@
import { describe, expect, test } from 'bun:test'
import { adaptOpenAIStreamToAnthropic } from '../streamAdapter.js'
import type { ChatCompletionChunk } from 'openai/resources/chat/completions/completions.mjs'
import { join, dirname } from 'path'
import { fileURLToPath } from 'url'
import { readFileSync, writeFileSync, mkdirSync } from 'fs'
import { tmpdir } from 'os'
// Guard against mock pollution from queryModelOpenAI.test.ts which replaces
// ../streamAdapter.js process-wide via mock.module (bun has no un-mock API).
// We copy the source to a unique temp path so the import bypasses bun's
// module mock cache completely.
const _testDir = dirname(fileURLToPath(import.meta.url))
const _realSource = readFileSync(join(_testDir, '..', 'streamAdapter.ts'), 'utf-8')
const _tempDir = join(tmpdir(), `stream-adapter-test-${Date.now()}`)
mkdirSync(_tempDir, { recursive: true })
const _tempFile = join(_tempDir, 'streamAdapter.ts')
writeFileSync(_tempFile, _realSource, 'utf-8')
const { adaptOpenAIStreamToAnthropic } = await import(_tempFile)
/** Helper to create a mock async iterable from chunk array */
function mockStream(chunks: ChatCompletionChunk[]): AsyncIterable<ChatCompletionChunk> {
@@ -31,6 +46,11 @@ function makeChunk(overrides: Partial<ChatCompletionChunk> & any = {}): ChatComp
/** Collect all emitted Anthropic events from the stream adapter for assertion */
async function collectEvents(chunks: ChatCompletionChunk[]) {
const realModuleUrl = new URL(
`../streamAdapter.js?real=${Date.now()}-${Math.random().toString(36).slice(2)}`,
import.meta.url,
).href
const { adaptOpenAIStreamToAnthropic } = await import(realModuleUrl)
const events: any[] = []
for await (const event of adaptOpenAIStreamToAnthropic(mockStream(chunks), 'gpt-4o')) {
events.push(event)

View File

@@ -8,6 +8,7 @@ import {
} from '../utils/messages.js'
import { getSmallFastModel } from '../utils/model/model.js'
import { asSystemPrompt } from '../utils/systemPromptType.js'
import { getResolvedLanguage } from '../utils/language.js'
import { queryModelWithoutStreaming } from './api/claude.js'
import { getSessionMemoryContent } from './SessionMemory/sessionMemoryUtils.js'
@@ -15,11 +16,18 @@ import { getSessionMemoryContent } from './SessionMemory/sessionMemoryUtils.js'
// large sessions. 30 messages ≈ ~15 exchanges, plenty for "where we left off."
const RECENT_MESSAGE_WINDOW = 30
const PROMPT_EN =
'The user stepped away and is coming back. Write exactly 1-3 short sentences. Start by stating the high-level task — what they are building or debugging, not implementation details. Next: the concrete next step. Skip status reports and commit recaps.'
const PROMPT_ZH =
'用户离开后回来了。用中文写 1-3 句话。先说明用户在做什么(高层目标,不是实现细节),然后说明下一步具体操作。不要写状态报告或提交总结。'
function buildAwaySummaryPrompt(memory: string | null): string {
const memoryBlock = memory
? `Session memory (broader context):\n${memory}\n\n`
: ''
return `${memoryBlock}The user stepped away and is coming back. Write exactly 1-3 short sentences. Start by stating the high-level task — what they are building or debugging, not implementation details. Next: the concrete next step. Skip status reports and commit recaps.`
const prompt = getResolvedLanguage() === 'zh' ? PROMPT_ZH : PROMPT_EN
return `${memoryBlock}${prompt}`
}
/**

View File

@@ -0,0 +1,702 @@
import { mock, describe, test, expect, beforeEach } from 'bun:test'
// Mock @langfuse/otel before any imports
const mockForceFlush = mock(() => Promise.resolve())
const mockShutdown = mock(() => Promise.resolve())
mock.module('@langfuse/otel', () => ({
LangfuseSpanProcessor: class MockLangfuseSpanProcessor {
forceFlush = mockForceFlush
shutdown = mockShutdown
onStart = mock(() => {})
onEnd = mock(() => {})
},
}))
// Mock @opentelemetry/sdk-trace-base
mock.module('@opentelemetry/sdk-trace-base', () => ({
BasicTracerProvider: class MockBasicTracerProvider {
constructor(_opts?: unknown) {}
},
}))
// Mock @langfuse/tracing
const mockChildUpdate = mock(() => {})
const mockChildEnd = mock(() => {})
const mockRootUpdate = mock(() => {})
const mockRootEnd = mock(() => {})
// Mock LangfuseOtelSpanAttributes (re-exported from @langfuse/core)
const mockLangfuseOtelSpanAttributes: Record<string, string> = {
TRACE_SESSION_ID: 'session.id',
TRACE_USER_ID: 'user.id',
OBSERVATION_TYPE: 'observation.type',
OBSERVATION_INPUT: 'observation.input',
OBSERVATION_OUTPUT: 'observation.output',
OBSERVATION_MODEL: 'observation.model',
OBSERVATION_COMPLETION_START_TIME: 'observation.completionStartTime',
OBSERVATION_USAGE_DETAILS: 'observation.usageDetails',
}
const mockSpanContext = {
traceId: 'test-trace-id',
spanId: 'test-span-id',
traceFlags: 1,
}
const mockSetAttribute = mock(() => {})
// Child observation mock (returned by startObservation for tools/generations)
const mockStartObservation = mock(() => ({
id: 'test-span-id',
traceId: 'test-trace-id',
type: 'span',
otelSpan: {
spanContext: () => mockSpanContext,
setAttribute: mockSetAttribute,
},
update: mockRootUpdate,
end: mockRootEnd,
}))
const mockSetLangfuseTracerProvider = mock(() => {})
mock.module('@langfuse/tracing', () => ({
startObservation: mockStartObservation,
LangfuseOtelSpanAttributes: mockLangfuseOtelSpanAttributes,
propagateAttributes: mock((_params: unknown, fn?: () => void) => fn?.()),
setLangfuseTracerProvider: mockSetLangfuseTracerProvider,
}))
// Mock debug logger
mock.module('src/utils/debug.js', () => ({
logForDebugging: mock(() => {}),
logAntError: mock(() => {}),
isDebugToStdErr: () => false,
isDebugMode: () => false,
getDebugLogPath: () => '/tmp/debug.log',
}))
// Mock user module to avoid heavy dependency chain (execa, config, cwd, env, etc.)
mock.module('src/utils/user.js', () => ({
getCoreUserData: () => ({
email: 'test@example.com',
deviceId: 'test-device',
}),
getUserDataForLogging: () => ({}),
}))
describe('Langfuse integration', () => {
beforeEach(() => {
// Reset env
process.env.HOME = '/Users/testuser'
delete process.env.LANGFUSE_PUBLIC_KEY
delete process.env.LANGFUSE_SECRET_KEY
delete process.env.LANGFUSE_BASE_URL
delete process.env.LANGFUSE_USER_ID
mockStartObservation.mockClear()
mockRootUpdate.mockClear()
mockRootEnd.mockClear()
mockForceFlush.mockClear()
mockShutdown.mockClear()
mockSetAttribute.mockClear()
})
// ── sanitize tests ──────────────────────────────────────────────────────────
describe('sanitizeToolInput', () => {
test('replaces home dir in file_path', async () => {
const { sanitizeToolInput } = await import('../sanitize.js')
const home = process.env.HOME ?? '/Users/testuser'
const result = sanitizeToolInput('FileReadTool', {
file_path: `${home}/project/file.ts`,
}) as Record<string, string>
expect(result.file_path).toBe('~/project/file.ts')
})
test('redacts sensitive keys', async () => {
const { sanitizeToolInput } = await import('../sanitize.js')
const result = sanitizeToolInput('MCPTool', {
api_key: 'secret123',
token: 'abc',
}) as Record<string, string>
expect(result.api_key).toBe('[REDACTED]')
expect(result.token).toBe('[REDACTED]')
})
test('returns non-object input unchanged', async () => {
const { sanitizeToolInput } = await import('../sanitize.js')
expect(sanitizeToolInput('BashTool', 'raw string')).toBe('raw string')
expect(sanitizeToolInput('BashTool', null)).toBe(null)
})
})
describe('sanitizeToolOutput', () => {
test('redacts FileReadTool output', async () => {
const { sanitizeToolOutput } = await import('../sanitize.js')
const result = sanitizeToolOutput('FileReadTool', 'file content here')
expect(result).toBe('[file content redacted, 17 chars]')
})
test('redacts FileWriteTool output', async () => {
const { sanitizeToolOutput } = await import('../sanitize.js')
const result = sanitizeToolOutput('FileWriteTool', 'written content')
expect(result).toBe('[file content redacted, 15 chars]')
})
test('truncates BashTool output over 500 chars', async () => {
const { sanitizeToolOutput } = await import('../sanitize.js')
const longOutput = 'x'.repeat(600)
const result = sanitizeToolOutput('BashTool', longOutput)
expect(result).toContain('[truncated]')
expect(result.length).toBeLessThan(600)
})
test('does not truncate BashTool output under 500 chars', async () => {
const { sanitizeToolOutput } = await import('../sanitize.js')
const shortOutput = 'hello world'
expect(sanitizeToolOutput('BashTool', shortOutput)).toBe('hello world')
})
test('redacts ConfigTool output', async () => {
const { sanitizeToolOutput } = await import('../sanitize.js')
const result = sanitizeToolOutput('ConfigTool', 'config data')
expect(result).toBe('[ConfigTool output redacted, 11 chars]')
})
test('redacts MCPTool output', async () => {
const { sanitizeToolOutput } = await import('../sanitize.js')
const result = sanitizeToolOutput('MCPTool', 'mcp data')
expect(result).toBe('[MCPTool output redacted, 8 chars]')
})
})
describe('sanitizeGlobal', () => {
test('replaces home dir in strings', async () => {
const { sanitizeGlobal } = await import('../sanitize.js')
const home = process.env.HOME ?? '/Users/testuser'
expect(sanitizeGlobal(`path: ${home}/file`)).toBe('path: ~/file')
})
test('recursively sanitizes nested objects', async () => {
const { sanitizeGlobal } = await import('../sanitize.js')
const result = sanitizeGlobal({
nested: { api_key: 'secret', name: 'test' },
}) as Record<string, Record<string, string>>
expect(result.nested.api_key).toBe('[REDACTED]')
expect(result.nested.name).toBe('test')
})
test('returns non-string/object values unchanged', async () => {
const { sanitizeGlobal } = await import('../sanitize.js')
expect(sanitizeGlobal(42)).toBe(42)
expect(sanitizeGlobal(true)).toBe(true)
})
})
// ── client tests ────────────────────────────────────────────────────────────
describe('isLangfuseEnabled', () => {
test('returns false when keys not configured', async () => {
const { isLangfuseEnabled } = await import('../client.js')
expect(isLangfuseEnabled()).toBe(false)
})
test('returns true when both keys are set', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
const { isLangfuseEnabled } = await import('../client.js')
expect(isLangfuseEnabled()).toBe(true)
})
})
describe('initLangfuse', () => {
test('returns false when keys not configured', async () => {
const { initLangfuse } = await import('../client.js')
expect(initLangfuse()).toBe(false)
})
test('returns true when keys are configured', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
const { isLangfuseEnabled } = await import('../client.js')
expect(isLangfuseEnabled()).toBe(true)
})
test('is idempotent — multiple calls do not re-initialize', async () => {
const { initLangfuse } = await import('../client.js')
expect(() => {
initLangfuse()
initLangfuse()
}).not.toThrow()
})
})
describe('shutdownLangfuse', () => {
test('calls forceFlush and shutdown on processor', async () => {
const { shutdownLangfuse } = await import('../client.js')
await expect(shutdownLangfuse()).resolves.toBeUndefined()
})
})
// ── tracing tests ───────────────────────────────────────────────────────────
describe('createTrace', () => {
test('returns null when langfuse not enabled', async () => {
const { createTrace } = await import('../tracing.js')
const span = createTrace({
sessionId: 's1',
model: 'claude-3',
provider: 'firstParty',
})
expect(span).toBeNull()
})
test('creates root span when enabled', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
const { createTrace } = await import('../tracing.js')
const span = createTrace({
sessionId: 's1',
model: 'claude-3',
provider: 'firstParty',
input: [],
})
expect(span).not.toBeNull()
expect(mockStartObservation).toHaveBeenCalledWith(
'agent-run',
expect.objectContaining({
metadata: expect.objectContaining({
provider: 'firstParty',
model: 'claude-3',
agentType: 'main',
}),
}),
{ asType: 'agent' },
)
// Should set session.id attribute
expect(mockSetAttribute).toHaveBeenCalledWith('session.id', 's1')
})
})
describe('recordLLMObservation', () => {
test('no-ops when rootSpan is null', async () => {
const { recordLLMObservation } = await import('../tracing.js')
recordLLMObservation(null, {
model: 'm',
provider: 'firstParty',
input: [],
output: [],
usage: { input_tokens: 10, output_tokens: 5 },
})
expect(mockStartObservation).toHaveBeenCalledTimes(0)
})
test('records generation child observation via global startObservation', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
const { createTrace, recordLLMObservation } = await import(
'../tracing.js'
)
const span = createTrace({
sessionId: 's1',
model: 'claude-3',
provider: 'firstParty',
})
mockStartObservation.mockClear()
mockRootUpdate.mockClear()
mockRootEnd.mockClear()
recordLLMObservation(span, {
model: 'claude-3',
provider: 'firstParty',
input: [{ role: 'user', content: 'hello' }],
output: [{ role: 'assistant', content: 'hi' }],
usage: { input_tokens: 10, output_tokens: 5 },
})
// Should call the global startObservation with asType: 'generation' and parentSpanContext
expect(mockStartObservation).toHaveBeenCalledWith(
'ChatAnthropic',
expect.objectContaining({
model: 'claude-3',
}),
expect.objectContaining({
asType: 'generation',
parentSpanContext: mockSpanContext,
}),
)
expect(mockRootUpdate).toHaveBeenCalledWith(
expect.objectContaining({
usageDetails: { input: 10, output: 5 },
}),
)
expect(mockRootEnd).toHaveBeenCalled()
})
})
describe('recordToolObservation', () => {
test('no-ops when rootSpan is null', async () => {
const { recordToolObservation } = await import('../tracing.js')
recordToolObservation(null, {
toolName: 'BashTool',
toolUseId: 'id1',
input: {},
output: 'out',
})
})
test('records tool child observation via global startObservation', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
const { createTrace, recordToolObservation } = await import(
'../tracing.js'
)
const span = createTrace({
sessionId: 's1',
model: 'claude-3',
provider: 'firstParty',
})
mockStartObservation.mockClear()
mockRootUpdate.mockClear()
mockRootEnd.mockClear()
recordToolObservation(span, {
toolName: 'BashTool',
toolUseId: 'tu-1',
input: { command: 'ls' },
output: 'file.ts',
})
// Should call the global startObservation with asType: 'tool' and parentSpanContext
expect(mockStartObservation).toHaveBeenCalledWith(
'BashTool',
expect.objectContaining({
input: expect.any(Object),
}),
expect.objectContaining({
asType: 'tool',
parentSpanContext: mockSpanContext,
}),
)
expect(mockRootUpdate).toHaveBeenCalled()
expect(mockRootEnd).toHaveBeenCalled()
})
test('passes startTime to global startObservation', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
const { createTrace, recordToolObservation } = await import(
'../tracing.js'
)
const span = createTrace({
sessionId: 's1',
model: 'claude-3',
provider: 'firstParty',
})
mockStartObservation.mockClear()
const startTime = new Date('2026-01-01T00:00:00Z')
recordToolObservation(span, {
toolName: 'BashTool',
toolUseId: 'tu-2',
input: {},
output: 'out',
startTime,
})
expect(mockStartObservation).toHaveBeenCalledWith(
'BashTool',
expect.any(Object),
expect.objectContaining({
startTime,
parentSpanContext: mockSpanContext,
}),
)
})
test('sanitizes FileReadTool output', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
const { createTrace, recordToolObservation } = await import(
'../tracing.js'
)
const span = createTrace({
sessionId: 's1',
model: 'claude-3',
provider: 'firstParty',
})
mockRootUpdate.mockClear()
recordToolObservation(span, {
toolName: 'FileReadTool',
toolUseId: 'tu-2',
input: { file_path: '/tmp/file.ts' },
output: 'file content here',
})
expect(mockRootUpdate).toHaveBeenCalledWith(
expect.objectContaining({
output: '[file content redacted, 17 chars]',
}),
)
})
test('sets ERROR level for error observations', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
const { createTrace, recordToolObservation } = await import(
'../tracing.js'
)
const span = createTrace({
sessionId: 's1',
model: 'claude-3',
provider: 'firstParty',
})
mockRootUpdate.mockClear()
recordToolObservation(span, {
toolName: 'BashTool',
toolUseId: 'tu-3',
input: {},
output: 'error occurred',
isError: true,
})
expect(mockRootUpdate).toHaveBeenCalledWith(
expect.objectContaining({ level: 'ERROR' }),
)
})
})
describe('endTrace', () => {
test('no-ops when rootSpan is null', async () => {
const { endTrace } = await import('../tracing.js')
endTrace(null)
expect(mockRootEnd).not.toHaveBeenCalled()
})
test('calls span.end()', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
const { createTrace, endTrace } = await import('../tracing.js')
const span = createTrace({
sessionId: 's1',
model: 'claude-3',
provider: 'firstParty',
})
mockRootEnd.mockClear()
endTrace(span)
expect(mockRootEnd).toHaveBeenCalled()
})
test('calls span.update() with output when provided', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
const { createTrace, endTrace } = await import('../tracing.js')
const span = createTrace({
sessionId: 's1',
model: 'claude-3',
provider: 'firstParty',
})
mockRootUpdate.mockClear()
mockRootEnd.mockClear()
endTrace(span, 'final output')
expect(mockRootUpdate).toHaveBeenCalledWith(
expect.objectContaining({ output: 'final output' }),
)
expect(mockRootEnd).toHaveBeenCalled()
})
})
describe('createSubagentTrace', () => {
test('returns null when langfuse not enabled', async () => {
const { createSubagentTrace } = await import('../tracing.js')
const span = createSubagentTrace({
sessionId: 's1',
agentType: 'Explore',
agentId: 'agent-1',
model: 'claude-3',
provider: 'firstParty',
})
expect(span).toBeNull()
})
test('creates trace with agentType and agentId metadata', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
const { createSubagentTrace } = await import('../tracing.js')
const span = createSubagentTrace({
sessionId: 's1',
agentType: 'Explore',
agentId: 'agent-1',
model: 'claude-3',
provider: 'firstParty',
input: [{ role: 'user', content: 'search for X' }],
})
expect(span).not.toBeNull()
expect(mockStartObservation).toHaveBeenCalledWith(
'agent:Explore',
expect.objectContaining({
metadata: expect.objectContaining({
agentType: 'Explore',
agentId: 'agent-1',
provider: 'firstParty',
model: 'claude-3',
}),
}),
{ asType: 'agent' },
)
// Verify session.id attribute is set
expect(mockSetAttribute).toHaveBeenCalledWith('session.id', 's1')
})
test('returns null on SDK error', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
mockStartObservation.mockImplementationOnce(() => {
throw new Error('SDK error')
})
const { createSubagentTrace } = await import('../tracing.js')
const span = createSubagentTrace({
sessionId: 's1',
agentType: 'Plan',
agentId: 'agent-2',
model: 'claude-3',
provider: 'firstParty',
})
expect(span).toBeNull()
})
})
describe('createTrace with querySource', () => {
test('includes querySource in metadata', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
const { createTrace } = await import('../tracing.js')
const span = createTrace({
sessionId: 's1',
model: 'claude-3',
provider: 'firstParty',
querySource: 'user',
})
expect(span).not.toBeNull()
expect(mockStartObservation).toHaveBeenCalledWith(
'agent-run:user',
expect.objectContaining({
metadata: expect.objectContaining({
agentType: 'main',
querySource: 'user',
}),
}),
{ asType: 'agent' },
)
})
test('omits querySource when not provided', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
mockStartObservation.mockClear()
const { createTrace } = await import('../tracing.js')
createTrace({
sessionId: 's1',
model: 'claude-3',
provider: 'firstParty',
})
const calls = mockStartObservation.mock.calls as unknown[][]
const secondArg = calls[0]?.[1] as Record<string, unknown> | undefined
const metadata = (secondArg?.metadata ?? {}) as Record<string, unknown>
expect(metadata).not.toHaveProperty('querySource')
})
})
describe('nested agent scenario', () => {
test('sub-agent trace shares sessionId with parent', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
const { createTrace, createSubagentTrace } = await import('../tracing.js')
mockSetAttribute.mockClear()
// Create parent trace
const parentSpan = createTrace({
sessionId: 'shared-session',
model: 'claude-3',
provider: 'firstParty',
})
// Create sub-agent trace with same sessionId
const subSpan = createSubagentTrace({
sessionId: 'shared-session',
agentType: 'Explore',
agentId: 'agent-explore-1',
model: 'claude-3',
provider: 'firstParty',
})
expect(parentSpan).not.toBeNull()
expect(subSpan).not.toBeNull()
// Both should have set session.id attribute
const sessionAttributeCalls = mockSetAttribute.mock.calls.filter(
(call: unknown[]) =>
Array.isArray(call) &&
call[0] === 'session.id' &&
call[1] === 'shared-session',
)
expect(sessionAttributeCalls.length).toBeGreaterThanOrEqual(2)
})
test('query reuses passed langfuseTrace instead of creating new one', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
const { createSubagentTrace } = await import('../tracing.js')
const subTrace = createSubagentTrace({
sessionId: 's1',
agentType: 'Explore',
agentId: 'agent-1',
model: 'claude-3',
provider: 'firstParty',
})
expect(subTrace).not.toBeNull()
// Simulate query.ts logic: if langfuseTrace already set, don't create new one
const ownsTrace = false
const langfuseTrace = subTrace
expect(ownsTrace).toBe(false)
expect(langfuseTrace).toBe(subTrace)
})
})
describe('SDK exceptions do not affect main flow', () => {
test('createTrace returns null on SDK error', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
mockStartObservation.mockImplementationOnce(() => {
throw new Error('SDK error')
})
const { createTrace } = await import('../tracing.js')
const span = createTrace({
sessionId: 's1',
model: 'claude-3',
provider: 'firstParty',
})
expect(span).toBeNull()
})
test('recordLLMObservation silently fails on SDK error', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
const { createTrace, recordLLMObservation } = await import(
'../tracing.js'
)
const span = createTrace({
sessionId: 's1',
model: 'claude-3',
provider: 'firstParty',
})
// The next call to startObservation (for the generation) will throw
mockStartObservation.mockImplementationOnce(() => {
throw new Error('SDK error')
})
expect(() =>
recordLLMObservation(span, {
model: 'm',
provider: 'firstParty',
input: [],
output: [],
usage: { input_tokens: 1, output_tokens: 1 },
}),
).not.toThrow()
})
})
})

View File

@@ -9,19 +9,14 @@
* 4. Can be idle (waiting for work) or active (processing)
*/
import {
isTerminalTaskStatus,
type SetAppState,
type Task,
type TaskStateBase,
} from '../../Task.js'
import type { Message } from '../../types/message.js'
import { logForDebugging } from '../../utils/debug.js'
import { createUserMessage } from '../../utils/messages.js'
import { killInProcessTeammate } from '../../utils/swarm/spawnInProcess.js'
import { updateTaskState } from '../../utils/task/framework.js'
import type { InProcessTeammateTaskState } from './types.js'
import { appendCappedMessage, isInProcessTeammateTask } from './types.js'
import { isTerminalTaskStatus, type SetAppState, type Task, type TaskStateBase } from '../../Task.js';
import type { Message, MessageOrigin } from '../../types/message.js';
import { logForDebugging } from '../../utils/debug.js';
import { createUserMessage } from '../../utils/messages.js';
import { killInProcessTeammate } from '../../utils/swarm/spawnInProcess.js';
import { updateTaskState } from '../../utils/task/framework.js';
import type { InProcessTeammateTaskState, PendingTeammateUserMessage } from './types.js';
import { appendCappedMessage, isInProcessTeammateTask } from './types.js';
/**
* InProcessTeammateTask - Handles in-process teammate execution.
@@ -30,48 +25,41 @@ export const InProcessTeammateTask: Task = {
name: 'InProcessTeammateTask',
type: 'in_process_teammate',
async kill(taskId, setAppState) {
killInProcessTeammate(taskId, setAppState)
killInProcessTeammate(taskId, setAppState);
},
}
};
/**
* Request shutdown for a teammate.
*/
export function requestTeammateShutdown(
taskId: string,
setAppState: SetAppState,
): void {
export function requestTeammateShutdown(taskId: string, setAppState: SetAppState): void {
updateTaskState<InProcessTeammateTaskState>(taskId, setAppState, task => {
if (task.status !== 'running' || task.shutdownRequested) {
return task
return task;
}
return {
...task,
shutdownRequested: true,
}
})
};
});
}
/**
* Append a message to a teammate's conversation history.
* Used for zoomed view to show the teammate's conversation.
*/
export function appendTeammateMessage(
taskId: string,
message: Message,
setAppState: SetAppState,
): void {
export function appendTeammateMessage(taskId: string, message: Message, setAppState: SetAppState): void {
updateTaskState<InProcessTeammateTaskState>(taskId, setAppState, task => {
if (task.status !== 'running') {
return task
return task;
}
return {
...task,
messages: appendCappedMessage(task.messages, message),
}
})
};
});
}
/**
@@ -82,27 +70,47 @@ export function appendTeammateMessage(
export function injectUserMessageToTeammate(
taskId: string,
message: string,
options:
| {
autonomyRunId?: string;
origin?: MessageOrigin;
}
| undefined,
setAppState: SetAppState,
): void {
): boolean {
let injected = false;
updateTaskState<InProcessTeammateTaskState>(taskId, setAppState, task => {
// Allow message injection when teammate is running or idle (waiting for input)
// Only reject if teammate is in a terminal state
if (isTerminalTaskStatus(task.status)) {
logForDebugging(
`Dropping message for teammate task ${taskId}: task status is "${task.status}"`,
)
return task
logForDebugging(`Dropping message for teammate task ${taskId}: task status is "${task.status}"`);
return task;
}
injected = true;
const pendingMessage: PendingTeammateUserMessage = { message };
if (options?.autonomyRunId !== undefined) {
pendingMessage.autonomyRunId = options.autonomyRunId;
}
if (options?.origin !== undefined) {
pendingMessage.origin = options.origin;
}
const userMessageArgs: Parameters<typeof createUserMessage>[0] = {
content: message,
};
if (options?.origin !== undefined) {
userMessageArgs.origin = options.origin;
}
return {
...task,
pendingUserMessages: [...task.pendingUserMessages, message],
messages: appendCappedMessage(
task.messages,
createUserMessage({ content: message }),
),
}
})
pendingUserMessages: [...task.pendingUserMessages, pendingMessage],
messages: appendCappedMessage(task.messages, createUserMessage(userMessageArgs)),
};
});
return injected;
}
/**
@@ -115,30 +123,28 @@ export function findTeammateTaskByAgentId(
agentId: string,
tasks: Record<string, TaskStateBase>,
): InProcessTeammateTaskState | undefined {
let fallback: InProcessTeammateTaskState | undefined
let fallback: InProcessTeammateTaskState | undefined;
for (const task of Object.values(tasks)) {
if (isInProcessTeammateTask(task) && task.identity.agentId === agentId) {
// Prefer running tasks in case old killed tasks still exist in AppState
// alongside new running ones with the same agentId
if (task.status === 'running') {
return task
return task;
}
// Keep first match as fallback in case no running task exists
if (!fallback) {
fallback = task
fallback = task;
}
}
}
return fallback
return fallback;
}
/**
* Get all in-process teammate tasks from AppState.
*/
export function getAllInProcessTeammateTasks(
tasks: Record<string, TaskStateBase>,
): InProcessTeammateTaskState[] {
return Object.values(tasks).filter(isInProcessTeammateTask)
export function getAllInProcessTeammateTasks(tasks: Record<string, TaskStateBase>): InProcessTeammateTaskState[] {
return Object.values(tasks).filter(isInProcessTeammateTask);
}
/**
@@ -147,10 +153,8 @@ export function getAllInProcessTeammateTasks(
* and useBackgroundTaskNavigation — selectedIPAgentIndex maps into this
* array, so all three must agree on sort order.
*/
export function getRunningTeammatesSorted(
tasks: Record<string, TaskStateBase>,
): InProcessTeammateTaskState[] {
export function getRunningTeammatesSorted(tasks: Record<string, TaskStateBase>): InProcessTeammateTaskState[] {
return getAllInProcessTeammateTasks(tasks)
.filter(t => t.status === 'running')
.sort((a, b) => a.identity.agentName.localeCompare(b.identity.agentName))
.sort((a, b) => a.identity.agentName.localeCompare(b.identity.agentName));
}

View File

@@ -1,7 +1,7 @@
import type { TaskStateBase } from '../../Task.js'
import type { AgentToolResult } from '@claude-code-best/builtin-tools/tools/AgentTool/agentToolUtils.js'
import type { AgentDefinition } from '@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js'
import type { Message } from '../../types/message.js'
import type { Message, MessageOrigin } from '../../types/message.js'
import type { PermissionMode } from '../../utils/permissions/PermissionMode.js'
import type { AgentProgress } from '../LocalAgentTask/LocalAgentTask.js'
@@ -19,6 +19,12 @@ export type TeammateIdentity = {
parentSessionId: string // Leader's session ID
}
export type PendingTeammateUserMessage = {
message: string
autonomyRunId?: string
origin?: MessageOrigin
}
export type InProcessTeammateTaskState = TaskStateBase & {
type: 'in_process_teammate'
@@ -56,7 +62,7 @@ export type InProcessTeammateTaskState = TaskStateBase & {
inProgressToolUseIDs?: Set<string>
// Queue of user messages to deliver when viewing teammate transcript
pendingUserMessages: string[]
pendingUserMessages: PendingTeammateUserMessage[]
// UI: random spinner verbs (stable across re-renders, shared between components)
spinnerVerb?: string

View File

@@ -355,6 +355,19 @@ export type QueuedCommand = {
* unified the queue but lost the isolation the dual-queue accidentally had).
*/
agentId?: AgentId
/**
* Autonomy-run provenance for system-generated automatic turns.
* Used by the autonomy ledger to track queue → execution lifecycle.
*/
autonomy?: {
runId: string
trigger: 'scheduled-task' | 'proactive-tick' | 'managed-flow-step'
sourceId?: string
sourceLabel?: string
flowId?: string
flowStepId?: string
flowStepName?: string
}
}
/**

View File

@@ -0,0 +1,241 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import { join } from 'node:path'
import {
AUTONOMY_AGENTS_PATH_POSIX,
AUTONOMY_DIR,
buildAutonomyTurnPrompt,
loadAutonomyAuthority,
resetAutonomyAuthorityForTests,
} from '../autonomyAuthority'
import {
cleanupTempDir,
createTempDir,
createTempSubdir,
writeTempFile,
} from '../../../tests/mocks/file-system'
const AGENTS_REL = join(AUTONOMY_DIR, 'AGENTS.md')
const HEARTBEAT_REL = join(AUTONOMY_DIR, 'HEARTBEAT.md')
let tempDir = ''
beforeEach(async () => {
tempDir = await createTempDir('autonomy-authority-')
})
afterEach(async () => {
resetAutonomyAuthorityForTests()
if (tempDir) {
await cleanupTempDir(tempDir)
}
})
describe('autonomyAuthority', () => {
test('loadAutonomyAuthority merges AGENTS.md files from root to current directory', async () => {
const nestedDir = await createTempSubdir(tempDir, 'packages/app')
await writeTempFile(tempDir, AGENTS_REL, 'root authority')
await writeTempFile(nestedDir, AGENTS_REL, 'nested authority')
await writeTempFile(
tempDir,
HEARTBEAT_REL,
[
'# Heartbeat',
'tasks:',
' - name: inbox',
' interval: 30m',
' prompt: "Check inbox"',
].join('\n'),
)
const snapshot = await loadAutonomyAuthority({
rootDir: tempDir,
currentDir: nestedDir,
})
expect(snapshot.agentsFiles.map(file => file.relativePath)).toEqual([
AUTONOMY_AGENTS_PATH_POSIX,
`packages/app/${AUTONOMY_AGENTS_PATH_POSIX}`,
])
expect(snapshot.agentsContent).toContain('root authority')
expect(snapshot.agentsContent).toContain('nested authority')
expect(snapshot.heartbeatContent).toContain('# Heartbeat')
expect(snapshot.heartbeatTasks).toEqual([
{
name: 'inbox',
interval: '30m',
prompt: 'Check inbox',
steps: [],
},
])
})
test('loadAutonomyAuthority reads HEARTBEAT.md only from the workspace root', async () => {
const nestedDir = await createTempSubdir(tempDir, 'child')
await writeTempFile(
tempDir,
HEARTBEAT_REL,
'# Root heartbeat\nRemember the root task',
)
await writeTempFile(
nestedDir,
HEARTBEAT_REL,
'# Nested heartbeat\nThis should not be used',
)
const snapshot = await loadAutonomyAuthority({
rootDir: tempDir,
currentDir: nestedDir,
})
expect(snapshot.heartbeatFile?.path).toBe(join(tempDir, HEARTBEAT_REL))
expect(snapshot.heartbeatContent).toContain('Root heartbeat')
expect(snapshot.heartbeatContent).not.toContain('Nested heartbeat')
})
test('buildAutonomyTurnPrompt returns the original prompt when no authority files exist', async () => {
const prompt = await buildAutonomyTurnPrompt({
basePrompt: 'Run the scheduled task.',
trigger: 'scheduled-task',
rootDir: tempDir,
currentDir: tempDir,
})
expect(prompt).toBe('Run the scheduled task.')
})
test('buildAutonomyTurnPrompt injects AGENTS.md and HEARTBEAT.md for automated turns', async () => {
const nestedDir = await createTempSubdir(tempDir, 'nested')
await writeTempFile(tempDir, AGENTS_REL, 'root rules')
await writeTempFile(nestedDir, AGENTS_REL, 'nested rules')
await writeTempFile(tempDir, HEARTBEAT_REL, 'Check heartbeat directives')
const scheduledPrompt = await buildAutonomyTurnPrompt({
basePrompt: 'Review the nightly report.',
trigger: 'scheduled-task',
rootDir: tempDir,
currentDir: nestedDir,
})
const tickPrompt = await buildAutonomyTurnPrompt({
basePrompt: '<tick>12:00:00</tick>',
trigger: 'proactive-tick',
rootDir: tempDir,
currentDir: nestedDir,
})
expect(scheduledPrompt).toContain(
'This prompt was generated automatically. Follow the workspace authority below before acting.',
)
expect(scheduledPrompt).toContain('<autonomy_authority>')
expect(scheduledPrompt).toContain('root rules')
expect(scheduledPrompt).toContain('nested rules')
expect(scheduledPrompt).toContain('Check heartbeat directives')
expect(scheduledPrompt).toContain('Review the nightly report.')
expect(tickPrompt).toContain(
'This is an autonomous proactive turn. Follow the workspace authority below before acting.',
)
expect(tickPrompt).toContain('<tick>12:00:00</tick>')
})
test('proactive prompts surface due HEARTBEAT.md tasks only when their interval elapses', async () => {
await writeTempFile(
tempDir,
HEARTBEAT_REL,
[
'tasks:',
' - name: inbox',
' interval: 30m',
' prompt: "Check inbox"',
].join('\n'),
)
const first = await buildAutonomyTurnPrompt({
basePrompt: '<tick>12:00:00</tick>',
trigger: 'proactive-tick',
rootDir: tempDir,
currentDir: tempDir,
nowMs: 0,
})
const second = await buildAutonomyTurnPrompt({
basePrompt: '<tick>12:10:00</tick>',
trigger: 'proactive-tick',
rootDir: tempDir,
currentDir: tempDir,
nowMs: 10 * 60_000,
})
const third = await buildAutonomyTurnPrompt({
basePrompt: '<tick>12:31:00</tick>',
trigger: 'proactive-tick',
rootDir: tempDir,
currentDir: tempDir,
nowMs: 31 * 60_000,
})
expect(first).toContain('Due HEARTBEAT.md tasks:')
expect(first).toContain('- inbox (30m): Check inbox')
expect(second).not.toContain('Due HEARTBEAT.md tasks:')
expect(third).toContain('Due HEARTBEAT.md tasks:')
})
test('managed HEARTBEAT.md tasks parse nested steps and are not duplicated into the inline due-task section', async () => {
await writeTempFile(
tempDir,
HEARTBEAT_REL,
[
'tasks:',
' - name: inbox',
' interval: 30m',
' prompt: "Check inbox"',
' - name: weekly-report',
' interval: 7d',
' prompt: "Ship the weekly report"',
' steps:',
' - name: gather',
' prompt: "Gather weekly inputs"',
' - name: draft',
' prompt: "Draft the weekly report"',
' wait_for: manual',
].join('\n'),
)
const snapshot = await loadAutonomyAuthority({
rootDir: tempDir,
currentDir: tempDir,
})
const prompt = await buildAutonomyTurnPrompt({
basePrompt: '<tick>12:00:00</tick>',
trigger: 'proactive-tick',
rootDir: tempDir,
currentDir: tempDir,
nowMs: 0,
})
expect(snapshot.heartbeatTasks).toEqual([
{
name: 'inbox',
interval: '30m',
prompt: 'Check inbox',
steps: [],
},
{
name: 'weekly-report',
interval: '7d',
prompt: 'Ship the weekly report',
steps: [
{
name: 'gather',
prompt: 'Gather weekly inputs',
},
{
name: 'draft',
prompt: 'Draft the weekly report',
waitFor: 'manual',
},
],
},
])
expect(prompt).toContain('- inbox (30m): Check inbox')
expect(prompt).not.toContain('- weekly-report (7d): Ship the weekly report')
expect(prompt).not.toContain('- gather (')
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,117 @@
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
import { existsSync } from 'node:fs'
import { join } from 'node:path'
import { cleanupTempDir, createTempDir } from '../../../tests/mocks/file-system'
// Mock the lockfile module so tests don't need real file locks
mock.module('../lockfile.js', () => ({
lock: async (_file: string, _options?: unknown) => {
return async () => {}
},
}))
let tempDir = ''
beforeEach(async () => {
tempDir = await createTempDir('autonomy-persistence-')
})
afterEach(async () => {
if (tempDir) {
await cleanupTempDir(tempDir)
}
})
describe('withAutonomyPersistenceLock', () => {
test('runs fn and returns its result', async () => {
const { withAutonomyPersistenceLock } = await import(
'../autonomyPersistence'
)
const result = await withAutonomyPersistenceLock(tempDir, async () => {
return 42
})
expect(result).toBe(42)
})
test('creates the autonomy directory and lock file', async () => {
const { withAutonomyPersistenceLock } = await import(
'../autonomyPersistence'
)
await withAutonomyPersistenceLock(tempDir, async () => 'ok')
const autonomyDir = join(tempDir, '.claude', 'autonomy')
expect(existsSync(autonomyDir)).toBe(true)
})
test('propagates errors from the inner function', async () => {
const { withAutonomyPersistenceLock } = await import(
'../autonomyPersistence'
)
await expect(
withAutonomyPersistenceLock(tempDir, async () => {
throw new Error('inner failure')
}),
).rejects.toThrow('inner failure')
})
test('serializes concurrent calls on the same rootDir', async () => {
const { withAutonomyPersistenceLock } = await import(
'../autonomyPersistence'
)
const order: number[] = []
const first = withAutonomyPersistenceLock(tempDir, async () => {
order.push(1)
// Simulate async work
await new Promise(resolve => setTimeout(resolve, 20))
order.push(2)
return 'first'
})
const second = withAutonomyPersistenceLock(tempDir, async () => {
order.push(3)
return 'second'
})
const [r1, r2] = await Promise.all([first, second])
expect(r1).toBe('first')
expect(r2).toBe('second')
// The second call must wait for the first to finish
expect(order).toEqual([1, 2, 3])
})
test('allows parallel calls on different rootDirs', async () => {
const { withAutonomyPersistenceLock } = await import(
'../autonomyPersistence'
)
const tempDir2 = await createTempDir('autonomy-persistence-2-')
try {
const order: string[] = []
const first = withAutonomyPersistenceLock(tempDir, async () => {
order.push('a-start')
await new Promise(resolve => setTimeout(resolve, 10))
order.push('a-end')
return 'a'
})
const second = withAutonomyPersistenceLock(tempDir2, async () => {
order.push('b-start')
await new Promise(resolve => setTimeout(resolve, 10))
order.push('b-end')
return 'b'
})
const [r1, r2] = await Promise.all([first, second])
expect(r1).toBe('a')
expect(r2).toBe('b')
// Both should start before either ends (parallel)
expect(order.indexOf('a-start')).toBeLessThan(order.indexOf('a-end'))
expect(order.indexOf('b-start')).toBeLessThan(order.indexOf('b-end'))
} finally {
await cleanupTempDir(tempDir2)
}
})
})

View File

@@ -0,0 +1,421 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import { mkdir, writeFile } from 'fs/promises'
import { join } from 'path'
import {
resetStateForTests,
setCwdState,
setOriginalCwd,
setProjectRoot,
} from '../../bootstrap/state'
import {
formatAutonomyRunsList,
formatAutonomyRunsStatus,
listAutonomyRuns,
createAutonomyQueuedPrompt,
createProactiveAutonomyCommands,
finalizeAutonomyRunCompleted,
markAutonomyRunCompleted,
markAutonomyRunFailed,
markAutonomyRunRunning,
recoverManagedAutonomyFlowPrompt,
resolveAutonomyRunsPath,
startManagedAutonomyFlowFromHeartbeatTask,
} from '../autonomyRuns'
import {
formatAutonomyFlowsList,
getAutonomyFlowById,
listAutonomyFlows,
} from '../autonomyFlows'
import {
AUTONOMY_DIR,
resetAutonomyAuthorityForTests,
} from '../autonomyAuthority'
import { resetCommandQueue } from '../messageQueueManager'
import {
cleanupTempDir,
createTempDir,
createTempSubdir,
writeTempFile,
} from '../../../tests/mocks/file-system'
const AGENTS_REL = join(AUTONOMY_DIR, 'AGENTS.md')
const HEARTBEAT_REL = join(AUTONOMY_DIR, 'HEARTBEAT.md')
let tempDir = ''
beforeEach(async () => {
tempDir = await createTempDir('autonomy-runs-')
resetStateForTests()
resetAutonomyAuthorityForTests()
resetCommandQueue()
setOriginalCwd(tempDir)
setProjectRoot(tempDir)
})
afterEach(async () => {
resetStateForTests()
resetAutonomyAuthorityForTests()
resetCommandQueue()
if (tempDir) {
await cleanupTempDir(tempDir)
}
})
describe('autonomyRuns', () => {
test('createAutonomyQueuedPrompt records a queued automatic run and returns a prompt command', async () => {
const currentDir = await createTempSubdir(tempDir, 'nested')
await writeTempFile(tempDir, AGENTS_REL, 'root authority')
const command = await createAutonomyQueuedPrompt({
basePrompt: 'Review nightly report',
trigger: 'scheduled-task',
rootDir: tempDir,
currentDir,
sourceId: 'cron-1',
sourceLabel: 'nightly-report',
workload: 'cron',
})
const runs = await listAutonomyRuns(tempDir)
const flows = await listAutonomyFlows(tempDir)
expect(command).not.toBeNull()
expect(command!.mode).toBe('prompt')
expect(command!.isMeta).toBe(true)
expect(command!.autonomy?.trigger).toBe('scheduled-task')
expect(command!.autonomy?.sourceId).toBe('cron-1')
expect(command!.origin).toBeDefined()
expect(command!.value).toContain('root authority')
expect(runs).toHaveLength(1)
expect(runs[0]).toMatchObject({
runId: command!.autonomy?.runId,
runtime: 'automatic',
trigger: 'scheduled-task',
status: 'queued',
ownerKey: 'main-thread',
sourceId: 'cron-1',
sourceLabel: 'nightly-report',
})
expect(flows).toHaveLength(0)
expect(resolveAutonomyRunsPath(tempDir)).toContain('.claude')
})
test('createAutonomyQueuedPrompt defaults currentDir to the active cwd for nested authority', async () => {
const nestedDir = await createTempSubdir(tempDir, 'nested')
await writeTempFile(tempDir, AGENTS_REL, 'root authority')
await writeTempFile(nestedDir, AGENTS_REL, 'nested authority')
setOriginalCwd(nestedDir)
setCwdState(nestedDir)
const command = await createAutonomyQueuedPrompt({
basePrompt: '<tick>12:00:00</tick>',
trigger: 'proactive-tick',
rootDir: tempDir,
})
expect(command).not.toBeNull()
expect(command!.value).toContain('root authority')
expect(command!.value).toContain('nested authority')
})
test('markAutonomyRunRunning/completed/failed update persisted lifecycle state for plain runs', async () => {
const command = await createAutonomyQueuedPrompt({
basePrompt: '<tick>12:00:00</tick>',
trigger: 'proactive-tick',
rootDir: tempDir,
currentDir: tempDir,
})
expect(command).not.toBeNull()
const runId = command!.autonomy!.runId
await markAutonomyRunRunning(runId, tempDir, 100)
let runs = await listAutonomyRuns(tempDir)
expect(runs[0]).toMatchObject({
runId,
status: 'running',
startedAt: 100,
})
await markAutonomyRunCompleted(runId, tempDir, 200)
runs = await listAutonomyRuns(tempDir)
expect(runs[0]).toMatchObject({
runId,
status: 'completed',
endedAt: 200,
})
await markAutonomyRunFailed(runId, 'boom', tempDir, 300)
runs = await listAutonomyRuns(tempDir)
expect(runs[0]).toMatchObject({
runId,
status: 'failed',
endedAt: 300,
error: 'boom',
})
})
test('formatters produce readable status and run listings', async () => {
const first = await createAutonomyQueuedPrompt({
basePrompt: 'scheduled prompt',
trigger: 'scheduled-task',
rootDir: tempDir,
currentDir: tempDir,
sourceId: 'cron-1',
sourceLabel: 'nightly',
})
const second = await createAutonomyQueuedPrompt({
basePrompt: '<tick>12:00:00</tick>',
trigger: 'proactive-tick',
rootDir: tempDir,
currentDir: tempDir,
})
expect(first).not.toBeNull()
expect(second).not.toBeNull()
await markAutonomyRunRunning(first!.autonomy!.runId, tempDir, 100)
await markAutonomyRunCompleted(first!.autonomy!.runId, tempDir, 200)
await markAutonomyRunFailed(
second!.autonomy!.runId,
'stopped',
tempDir,
300,
)
const runs = await listAutonomyRuns(tempDir)
const status = formatAutonomyRunsStatus(runs)
const list = formatAutonomyRunsList(runs, 5)
const flows = await listAutonomyFlows(tempDir)
const flowList = formatAutonomyFlowsList(flows, 5)
expect(status).toContain('Autonomy runs: 2')
expect(status).toContain('Completed: 1')
expect(status).toContain('Failed: 1')
expect(list).toContain(first!.autonomy!.runId)
expect(list).toContain(second!.autonomy!.runId)
expect(list).toContain('nightly')
expect(list).toContain('stopped')
expect(flowList).toBe('No autonomy flows recorded.')
})
test('same-process concurrent run creation does not lose updates', async () => {
await Promise.all([
createAutonomyQueuedPrompt({
basePrompt: 'scheduled one',
trigger: 'scheduled-task',
rootDir: tempDir,
currentDir: tempDir,
sourceId: 'cron-1',
}),
createAutonomyQueuedPrompt({
basePrompt: 'scheduled two',
trigger: 'scheduled-task',
rootDir: tempDir,
currentDir: tempDir,
sourceId: 'cron-2',
}),
])
const runs = await listAutonomyRuns(tempDir)
expect(runs).toHaveLength(2)
expect(new Set(runs.map(run => run.sourceId))).toEqual(
new Set(['cron-1', 'cron-2']),
)
})
test('listAutonomyRuns keeps older persisted records by normalizing missing runtime and owner metadata', async () => {
const runsPath = resolveAutonomyRunsPath(tempDir)
await mkdir(join(tempDir, '.claude', 'autonomy'), { recursive: true })
await writeFile(
runsPath,
`${JSON.stringify(
{
runs: [
{
runId: 'legacy-run',
trigger: 'scheduled-task',
status: 'completed',
rootDir: tempDir,
promptPreview: 'legacy prompt',
createdAt: 123,
},
],
},
null,
2,
)}\n`,
'utf-8',
)
const [legacy] = await listAutonomyRuns(tempDir)
expect(legacy).toMatchObject({
runId: 'legacy-run',
runtime: 'automatic',
ownerKey: 'main-thread',
currentDir: tempDir,
status: 'completed',
})
})
test('createAutonomyQueuedPrompt does not consume heartbeat tasks or create runs when shouldCreate rejects commit', async () => {
await writeTempFile(
tempDir,
HEARTBEAT_REL,
[
'tasks:',
' - name: inbox',
' interval: 30m',
' prompt: "Check inbox"',
].join('\n'),
)
const skipped = await createAutonomyQueuedPrompt({
basePrompt: '<tick>12:00:00</tick>',
trigger: 'proactive-tick',
rootDir: tempDir,
currentDir: tempDir,
shouldCreate: () => false,
})
const committed = await createAutonomyQueuedPrompt({
basePrompt: '<tick>12:01:00</tick>',
trigger: 'proactive-tick',
rootDir: tempDir,
currentDir: tempDir,
})
const runs = await listAutonomyRuns(tempDir)
expect(skipped).toBeNull()
expect(committed).not.toBeNull()
expect(committed!.value).toContain('Due HEARTBEAT.md tasks:')
expect(runs).toHaveLength(1)
})
test('createProactiveAutonomyCommands queues one managed flow step command per due HEARTBEAT flow', async () => {
await writeTempFile(
tempDir,
HEARTBEAT_REL,
[
'tasks:',
' - name: inbox',
' interval: 30m',
' prompt: "Check inbox"',
' - name: weekly-report',
' interval: 7d',
' prompt: "Ship the weekly report"',
' steps:',
' - name: gather',
' prompt: "Gather weekly inputs"',
' - name: draft',
' prompt: "Draft the weekly report"',
].join('\n'),
)
const commands = await createProactiveAutonomyCommands({
basePrompt: '<tick>12:00:00</tick>',
rootDir: tempDir,
currentDir: tempDir,
})
const runs = await listAutonomyRuns(tempDir)
const flows = await listAutonomyFlows(tempDir)
expect(commands).toHaveLength(2)
expect(commands[0]!.autonomy?.trigger).toBe('proactive-tick')
expect(commands[0]!.value).toContain('- inbox (30m): Check inbox')
expect(commands[1]!.autonomy?.trigger).toBe('managed-flow-step')
expect(commands[1]!.value).toContain(
'This is step 1/2 of the managed autonomy flow',
)
expect(runs).toHaveLength(2)
expect(flows).toHaveLength(1)
expect(flows[0]).toMatchObject({
status: 'queued',
currentStep: 'gather',
goal: 'Ship the weekly report',
})
})
test('finalizeAutonomyRunCompleted advances managed flows to the next queued step', async () => {
const command = await startManagedAutonomyFlowFromHeartbeatTask({
task: {
name: 'weekly-report',
interval: '7d',
prompt: 'Ship the weekly report',
steps: [
{
name: 'gather',
prompt: 'Gather weekly inputs',
},
{
name: 'draft',
prompt: 'Draft the weekly report',
},
],
},
rootDir: tempDir,
currentDir: tempDir,
})
expect(command).not.toBeNull()
await markAutonomyRunRunning(command!.autonomy!.runId, tempDir, 100)
const nextCommands = await finalizeAutonomyRunCompleted({
runId: command!.autonomy!.runId,
rootDir: tempDir,
currentDir: tempDir,
})
const runs = await listAutonomyRuns(tempDir)
const [flow] = await listAutonomyFlows(tempDir)
const detail = await getAutonomyFlowById(flow!.flowId, tempDir)
expect(nextCommands).toHaveLength(1)
expect(nextCommands[0]!.autonomy?.trigger).toBe('managed-flow-step')
expect(nextCommands[0]!.value).toContain('Current step: draft')
expect(runs).toHaveLength(2)
expect(flow).toMatchObject({
status: 'queued',
currentStep: 'draft',
runCount: 2,
})
expect(detail?.stateJson?.steps.map(step => step.status)).toEqual([
'completed',
'queued',
])
})
test('recoverManagedAutonomyFlowPrompt rehydrates a queued managed step with the same run id', async () => {
const command = await startManagedAutonomyFlowFromHeartbeatTask({
task: {
name: 'weekly-report',
interval: '7d',
prompt: 'Ship the weekly report',
steps: [
{
name: 'gather',
prompt: 'Gather weekly inputs',
},
{
name: 'draft',
prompt: 'Draft the weekly report',
},
],
},
rootDir: tempDir,
currentDir: tempDir,
})
const [flow] = await listAutonomyFlows(tempDir)
const recovered = await recoverManagedAutonomyFlowPrompt({
flowId: flow!.flowId,
rootDir: tempDir,
currentDir: tempDir,
})
expect(recovered).not.toBeNull()
expect(recovered!.autonomy?.runId).toBe(command!.autonomy?.runId)
expect(recovered!.autonomy?.flowId).toBe(flow!.flowId)
})
})

View File

@@ -0,0 +1,79 @@
import { describe, expect, test } from 'bun:test'
import {
buildMissedTaskNotification,
isRecurringTaskAged,
} from '../cronScheduler'
describe('cronScheduler baseline helpers', () => {
test('isRecurringTaskAged returns false when maxAgeMs is zero', () => {
expect(
isRecurringTaskAged(
{ id: 'a', cron: '* * * * *', prompt: 'x', createdAt: 0, recurring: true },
10_000,
0,
),
).toBe(false)
})
test('isRecurringTaskAged only ages recurring non-permanent tasks', () => {
expect(
isRecurringTaskAged(
{ id: 'a', cron: '* * * * *', prompt: 'x', createdAt: 0 },
10_000,
100,
),
).toBe(false)
expect(
isRecurringTaskAged(
{
id: 'b',
cron: '* * * * *',
prompt: 'x',
createdAt: 0,
recurring: true,
permanent: true,
},
10_000,
100,
),
).toBe(false)
expect(
isRecurringTaskAged(
{ id: 'c', cron: '* * * * *', prompt: 'x', createdAt: 0, recurring: true },
10_000,
100,
),
).toBe(true)
})
test('buildMissedTaskNotification preserves AskUserQuestion safety instruction', () => {
const msg = buildMissedTaskNotification([
{
id: 'a1b2c3d4',
cron: '* * * * *',
prompt: 'check deployment',
createdAt: new Date('2026-04-12T10:00:00Z').getTime(),
},
])
expect(msg).toContain('AskUserQuestion')
expect(msg).toContain('Do NOT execute this prompt yet')
expect(msg).toContain('check deployment')
})
test('buildMissedTaskNotification widens the code fence when the prompt contains backticks', () => {
const msg = buildMissedTaskNotification([
{
id: 'z9y8x7w6',
cron: '* * * * *',
prompt: 'run ```dangerous``` only if approved',
createdAt: new Date('2026-04-12T10:00:00Z').getTime(),
},
])
expect(msg).toContain('````')
expect(msg).toContain('run ```dangerous``` only if approved')
})
})

View File

@@ -0,0 +1,203 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import { existsSync } from 'node:fs'
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import {
getSessionCronTasks,
resetStateForTests,
setOriginalCwd,
setProjectRoot,
} from '../../bootstrap/state'
import {
addCronTask,
findMissedTasks,
getCronFilePath,
hasCronTasksSync,
listAllCronTasks,
markCronTasksFired,
nextCronRunMs,
oneShotJitteredNextCronRunMs,
readCronTasks,
removeCronTasks,
writeCronTasks,
} from '../cronTasks'
import { cleanupTempDir, createTempDir } from '../../../tests/mocks/file-system'
let tempDir = ''
beforeEach(async () => {
tempDir = await createTempDir('cron-baseline-')
resetStateForTests()
setOriginalCwd(tempDir)
setProjectRoot(tempDir)
})
afterEach(async () => {
resetStateForTests()
if (tempDir) {
await cleanupTempDir(tempDir)
}
})
describe('cronTasks baseline', () => {
test('session-only cron tasks remain in memory and do not create the cron file', async () => {
const id = await addCronTask('* * * * *', 'session-only prompt', true, false)
const tasks = await listAllCronTasks()
expect(id).toHaveLength(8)
expect(getSessionCronTasks()).toHaveLength(1)
expect(tasks).toHaveLength(1)
expect(tasks[0]).toMatchObject({
id,
prompt: 'session-only prompt',
durable: false,
recurring: true,
})
expect(existsSync(getCronFilePath())).toBe(false)
})
test('durable cron tasks are written to .claude/scheduled_tasks.json', async () => {
const id = await addCronTask('* * * * *', 'durable prompt', true, true)
const filePath = getCronFilePath()
const fileTasks = await readCronTasks()
expect(existsSync(filePath)).toBe(true)
expect(filePath).toBe(join(tempDir, '.claude', 'scheduled_tasks.json'))
expect(fileTasks).toHaveLength(1)
expect(fileTasks[0]).toMatchObject({
id,
prompt: 'durable prompt',
recurring: true,
})
expect(fileTasks[0].durable).toBeUndefined()
})
test('writeCronTasks strips runtime-only durable flags from disk', async () => {
await writeCronTasks([
{
id: 'abc12345',
cron: '* * * * *',
prompt: 'strip durable',
createdAt: 123,
recurring: true,
durable: false,
},
])
const raw = await readFile(getCronFilePath(), 'utf-8')
expect(raw).not.toContain('"durable"')
})
test('hasCronTasksSync reflects whether the durable cron file has entries', async () => {
expect(hasCronTasksSync()).toBe(false)
await writeCronTasks([
{
id: 'sync0001',
cron: '* * * * *',
prompt: 'present',
createdAt: 1,
},
])
expect(hasCronTasksSync()).toBe(true)
})
test('daemon-style listAllCronTasks(dir) excludes session-only tasks', async () => {
await addCronTask('* * * * *', 'session prompt', true, false)
const durableId = await addCronTask('* * * * *', 'durable prompt', true, true)
const sessionView = await listAllCronTasks()
const daemonView = await listAllCronTasks(tempDir)
expect(sessionView).toHaveLength(2)
expect(daemonView).toHaveLength(1)
expect(daemonView[0]).toMatchObject({
id: durableId,
prompt: 'durable prompt',
})
})
test('removeCronTasks without dir removes session-only tasks from memory', async () => {
const sessionId = await addCronTask('* * * * *', 'remove me', true, false)
await removeCronTasks([sessionId])
expect(getSessionCronTasks()).toHaveLength(0)
expect(await listAllCronTasks()).toHaveLength(0)
})
test('removeCronTasks with dir does not mutate session-only task storage', async () => {
const sessionId = await addCronTask('* * * * *', 'keep session task', true, false)
await addCronTask('* * * * *', 'durable prompt', true, true)
await removeCronTasks([sessionId], tempDir)
expect(getSessionCronTasks()).toHaveLength(1)
expect(getSessionCronTasks()[0]?.id).toBe(sessionId)
})
test('markCronTasksFired persists lastFiredAt for durable tasks', async () => {
await writeCronTasks([
{
id: 'fire0001',
cron: '* * * * *',
prompt: 'persist fired',
createdAt: 100,
recurring: true,
},
])
await markCronTasksFired(['fire0001'], 123456789)
const tasks = await readCronTasks()
expect(tasks[0]?.lastFiredAt).toBe(123456789)
})
test('findMissedTasks returns tasks whose first scheduled run is in the past', () => {
const nowMs = new Date('2026-04-12T10:10:00').getTime()
const tasks = findMissedTasks(
[
{
id: 'missed01',
cron: '* * * * *',
prompt: 'old task',
createdAt: new Date('2026-04-12T10:00:00').getTime(),
},
{
id: 'future01',
cron: '59 23 31 12 *',
prompt: 'far future',
createdAt: nowMs,
},
],
nowMs,
)
expect(tasks.map(t => t.id)).toEqual(['missed01'])
})
test('nextCronRunMs returns null for invalid cron expressions', () => {
expect(nextCronRunMs('invalid cron', Date.now())).toBeNull()
})
test('oneShotJitteredNextCronRunMs never returns a time earlier than fromMs', () => {
const fromMs = new Date('2026-04-12T10:59:50').getTime()
const next = oneShotJitteredNextCronRunMs('0 11 * * *', fromMs, '00000000')
expect(next).not.toBeNull()
expect(next!).toBeGreaterThanOrEqual(fromMs)
})
test('jitteredNextCronRunMs returns the exact next fire time when no second match exists in range', () => {
const fromMs = new Date('2026-04-12T10:00:00').getTime()
const exact = nextCronRunMs('0 0 29 2 *', fromMs)
const jittered = oneShotJitteredNextCronRunMs('0 0 29 2 *', fromMs, '89abcdef')
expect(exact).not.toBeNull()
expect(jittered).not.toBeNull()
expect(jittered!).toBeGreaterThanOrEqual(fromMs)
})
})

View File

@@ -0,0 +1,82 @@
import { describe, test, expect, mock } from 'bun:test'
// Mock dependencies before importing the module under test
let mockPreferredLanguage: string | undefined
let mockSystemLocale: string | undefined
mock.module('src/utils/config.js', () => ({
getGlobalConfig: () => ({
preferredLanguage: mockPreferredLanguage,
}),
}))
mock.module('src/utils/intl.js', () => ({
getSystemLocaleLanguage: () => mockSystemLocale,
}))
const { getResolvedLanguage, getLanguageDisplayName } = await import(
'src/utils/language.js'
)
describe('getResolvedLanguage', () => {
test('returns en when config is explicitly en', () => {
mockPreferredLanguage = 'en'
mockSystemLocale = 'zh'
expect(getResolvedLanguage()).toBe('en')
})
test('returns zh when config is explicitly zh', () => {
mockPreferredLanguage = 'zh'
mockSystemLocale = 'en'
expect(getResolvedLanguage()).toBe('zh')
})
test('falls back to system locale zh when config is auto', () => {
mockPreferredLanguage = 'auto'
mockSystemLocale = 'zh'
expect(getResolvedLanguage()).toBe('zh')
})
test('falls back to en when config is auto and system locale is not zh', () => {
mockPreferredLanguage = 'auto'
mockSystemLocale = 'en'
expect(getResolvedLanguage()).toBe('en')
})
test('falls back to en when config is auto and system locale is undefined', () => {
mockPreferredLanguage = 'auto'
mockSystemLocale = undefined
expect(getResolvedLanguage()).toBe('en')
})
test('falls back to auto behavior when config preferredLanguage is undefined', () => {
mockPreferredLanguage = undefined
mockSystemLocale = 'zh'
expect(getResolvedLanguage()).toBe('zh')
})
test('defaults to en when both config and locale are undefined', () => {
mockPreferredLanguage = undefined
mockSystemLocale = undefined
expect(getResolvedLanguage()).toBe('en')
})
})
describe('getLanguageDisplayName', () => {
test('returns Auto (follow system) for auto', () => {
expect(getLanguageDisplayName('auto')).toBe('Auto (follow system)')
})
test('returns English for en', () => {
expect(getLanguageDisplayName('en')).toBe('English')
})
test('returns 中文 for zh', () => {
expect(getLanguageDisplayName('zh')).toBe('中文')
})
test('returns the input string for unknown language codes', () => {
expect(getLanguageDisplayName('fr')).toBe('fr')
expect(getLanguageDisplayName('unknown')).toBe('unknown')
})
})

View File

@@ -0,0 +1,124 @@
import { describe, test, expect, beforeEach } from 'bun:test'
import {
setMasterMutedPipes,
isMasterPipeMuted,
removeMasterPipeMute,
clearMasterMutedPipes,
addSendOverride,
removeSendOverride,
hasSendOverride,
clearSendOverrides,
} from '../pipeMuteState.js'
describe('setMasterMutedPipes', () => {
beforeEach(() => {
clearMasterMutedPipes()
clearSendOverrides()
})
test('sets muted pipes from iterable', () => {
setMasterMutedPipes(['pipe-a', 'pipe-b'])
expect(isMasterPipeMuted('pipe-a')).toBe(true)
expect(isMasterPipeMuted('pipe-b')).toBe(true)
expect(isMasterPipeMuted('pipe-c')).toBe(false)
})
test('replaces previous muted set', () => {
setMasterMutedPipes(['pipe-a'])
setMasterMutedPipes(['pipe-b'])
expect(isMasterPipeMuted('pipe-a')).toBe(false)
expect(isMasterPipeMuted('pipe-b')).toBe(true)
})
})
describe('isMasterPipeMuted', () => {
beforeEach(() => {
clearMasterMutedPipes()
})
test('returns false for unknown pipe', () => {
expect(isMasterPipeMuted('unknown')).toBe(false)
})
})
describe('removeMasterPipeMute', () => {
beforeEach(() => {
clearMasterMutedPipes()
})
test('removes a single muted pipe', () => {
setMasterMutedPipes(['pipe-a', 'pipe-b'])
removeMasterPipeMute('pipe-a')
expect(isMasterPipeMuted('pipe-a')).toBe(false)
expect(isMasterPipeMuted('pipe-b')).toBe(true)
})
test('no-ops for non-existent pipe', () => {
removeMasterPipeMute('nonexistent')
expect(isMasterPipeMuted('nonexistent')).toBe(false)
})
})
describe('clearMasterMutedPipes', () => {
test('clears all muted pipes', () => {
setMasterMutedPipes(['pipe-a', 'pipe-b', 'pipe-c'])
clearMasterMutedPipes()
expect(isMasterPipeMuted('pipe-a')).toBe(false)
expect(isMasterPipeMuted('pipe-b')).toBe(false)
expect(isMasterPipeMuted('pipe-c')).toBe(false)
})
})
describe('addSendOverride', () => {
beforeEach(() => {
clearSendOverrides()
})
test('adds a send override', () => {
addSendOverride('pipe-x')
expect(hasSendOverride('pipe-x')).toBe(true)
})
test('adding same override twice is idempotent', () => {
addSendOverride('pipe-x')
addSendOverride('pipe-x')
expect(hasSendOverride('pipe-x')).toBe(true)
})
})
describe('removeSendOverride', () => {
beforeEach(() => {
clearSendOverrides()
})
test('removes a send override', () => {
addSendOverride('pipe-x')
removeSendOverride('pipe-x')
expect(hasSendOverride('pipe-x')).toBe(false)
})
test('no-ops for non-existent override', () => {
removeSendOverride('nonexistent')
expect(hasSendOverride('nonexistent')).toBe(false)
})
})
describe('hasSendOverride', () => {
beforeEach(() => {
clearSendOverrides()
})
test('returns false when no overrides set', () => {
expect(hasSendOverride('pipe-x')).toBe(false)
})
})
describe('clearSendOverrides', () => {
test('clears all send overrides', () => {
addSendOverride('pipe-a')
addSendOverride('pipe-b')
clearSendOverrides()
expect(hasSendOverride('pipe-a')).toBe(false)
expect(hasSendOverride('pipe-b')).toBe(false)
})
})

View File

@@ -0,0 +1,93 @@
/**
* Tests for src/utils/taskSummary.ts
*
* Covers: shouldGenerateTaskSummary, maybeGenerateTaskSummary
*
* Note: bun:bundle's feature() is a compile-time construct and cannot be
* trivially mocked at test time. We test maybeGenerateTaskSummary (which
* is called unconditionally) and the rate-limit behavior indirectly.
*/
import { describe, expect, test, mock, beforeEach } from 'bun:test'
// ─── mocks ──────────────────────────────────────────────────────────────────
let _updateCalls: any[] = []
mock.module('bun:bundle', () => ({
feature: (_name: string) => false,
}))
mock.module('../concurrentSessions.js', () => ({
isBgSession: () => false,
updateSessionActivity: async (data: any) => {
_updateCalls.push(data)
},
}))
mock.module('../debug.js', () => ({
logForDebugging: () => {},
}))
// ─── import after mocks ─────────────────────────────────────────────────────
const { shouldGenerateTaskSummary, maybeGenerateTaskSummary } = await import(
'../taskSummary.js'
)
// ─── tests ──────────────────────────────────────────────────────────────────
beforeEach(() => {
_updateCalls = []
})
describe('shouldGenerateTaskSummary', () => {
test('returns false when feature is disabled', () => {
// bun:bundle feature mock returns false
expect(shouldGenerateTaskSummary()).toBe(false)
})
})
describe('maybeGenerateTaskSummary', () => {
test('does not throw with empty messages', () => {
expect(() =>
maybeGenerateTaskSummary({ forkContextMessages: [] }),
).not.toThrow()
})
test('does not throw with undefined messages', () => {
expect(() => maybeGenerateTaskSummary({})).not.toThrow()
})
test('does not throw with assistant message containing tool_use', () => {
expect(() =>
maybeGenerateTaskSummary({
forkContextMessages: [
{
type: 'assistant',
message: {
content: [
{ type: 'text', text: 'Let me check' },
{ type: 'tool_use', name: 'bash' },
],
},
},
],
}),
).not.toThrow()
})
test('does not throw with non-array content', () => {
expect(() =>
maybeGenerateTaskSummary({
forkContextMessages: [
{
type: 'assistant',
message: {
content: 'plain text response',
},
},
],
}),
).not.toThrow()
})
})

View File

@@ -0,0 +1,522 @@
import {
basename,
dirname,
isAbsolute,
join,
relative,
resolve,
} from 'node:path'
import { getProjectRoot } from '../bootstrap/state.js'
import { getCwd } from './cwd.js'
import { getFsImplementation } from './fsOperations.js'
import { normalizePathForConfigKey } from './path.js'
export const AUTONOMY_DIR = join('.claude', 'autonomy')
export const AUTONOMY_DIR_POSIX = '.claude/autonomy'
export const AUTONOMY_AGENTS_FILENAME = 'AGENTS.md'
export const AUTONOMY_HEARTBEAT_FILENAME = 'HEARTBEAT.md'
export const AUTONOMY_AGENTS_PATH_POSIX = `${AUTONOMY_DIR_POSIX}/${AUTONOMY_AGENTS_FILENAME}`
export const AUTONOMY_HEARTBEAT_PATH_POSIX = `${AUTONOMY_DIR_POSIX}/${AUTONOMY_HEARTBEAT_FILENAME}`
export type HeartbeatAuthorityTask = {
name: string
interval: string
prompt: string
steps: HeartbeatAuthorityTaskStep[]
}
export type HeartbeatAuthorityTaskStep = {
name: string
prompt: string
waitFor?: string
}
export type AutonomyAuthorityFile = {
path: string
relativePath: string
content: string
}
export type AutonomyAuthoritySnapshot = {
rootDir: string
currentDir: string
agentsFiles: AutonomyAuthorityFile[]
agentsContent: string | null
heartbeatFile: AutonomyAuthorityFile | null
heartbeatContent: string | null
heartbeatTasks: HeartbeatAuthorityTask[]
}
type AutonomyAuthorityParams = {
rootDir?: string
currentDir?: string
}
export type AutonomyTriggerKind =
| 'proactive-tick'
| 'scheduled-task'
| 'managed-flow-step'
export type PreparedAutonomyTurn = {
rootDir: string
currentDir: string
trigger: AutonomyTriggerKind
prompt: string
dueHeartbeatTasks: HeartbeatAuthorityTask[]
nowMs: number
}
const heartbeatTaskLastRunByKey = new Map<string, number>()
function isPathWithinRoot(rootDir: string, currentDir: string): boolean {
const delta = relative(rootDir, currentDir)
return delta === '' || (!delta.startsWith('..') && !isAbsolute(delta))
}
function listAuthorityDirectories(
rootDir: string,
currentDir: string,
): string[] {
const resolvedRoot = resolve(rootDir)
const resolvedCurrent = resolve(currentDir)
if (!isPathWithinRoot(resolvedRoot, resolvedCurrent)) {
return [resolvedRoot]
}
const dirs: string[] = []
let cursor = resolvedCurrent
for (;;) {
dirs.push(cursor)
if (cursor === resolvedRoot) {
break
}
const parent = dirname(cursor)
if (parent === cursor) {
break
}
cursor = parent
}
return dirs.reverse()
}
async function readAuthorityFile(
filePath: string,
rootDir: string,
): Promise<AutonomyAuthorityFile | null> {
try {
const content = (await getFsImplementation().readFile(filePath, {
encoding: 'utf-8',
})) as string
const trimmed = content.trim()
if (!trimmed) {
return null
}
return {
path: filePath,
relativePath:
normalizePathForConfigKey(relative(rootDir, filePath)) ||
basename(filePath),
content: trimmed,
}
} catch {
return null
}
}
function mergeAgentsAuthority(files: AutonomyAuthorityFile[]): string | null {
if (files.length === 0) {
return null
}
return files
.map(file => `## ${file.relativePath}\n${file.content}`)
.join('\n\n')
}
export function parseHeartbeatAuthorityTasks(
content: string,
): HeartbeatAuthorityTask[] {
const tasks: HeartbeatAuthorityTask[] = []
const lines = content.split('\n')
const getIndent = (line: string): number =>
line.length - line.trimStart().length
const parseScalar = (line: string, key: string): string =>
line
.replace(key, '')
.trim()
.replace(/^["']|["']$/g, '')
function parseSteps(
startIndex: number,
stepsIndent: number,
): { steps: HeartbeatAuthorityTaskStep[]; nextIndex: number } {
const steps: HeartbeatAuthorityTaskStep[] = []
let index = startIndex
while (index < lines.length) {
const line = lines[index]!
const trimmed = line.trim()
const indent = getIndent(line)
if (!trimmed) {
index += 1
continue
}
if (indent <= stepsIndent) {
break
}
if (!trimmed.startsWith('- name:')) {
index += 1
continue
}
const stepIndent = indent
const name = parseScalar(trimmed, '- name:')
let prompt = ''
let waitFor: string | undefined
index += 1
while (index < lines.length) {
const nextLine = lines[index]!
const nextTrimmed = nextLine.trim()
const nextIndent = getIndent(nextLine)
if (!nextTrimmed) {
index += 1
continue
}
if (nextIndent <= stepIndent) {
break
}
if (nextTrimmed.startsWith('prompt:')) {
prompt = parseScalar(nextTrimmed, 'prompt:')
} else if (nextTrimmed.startsWith('wait_for:')) {
waitFor = parseScalar(nextTrimmed, 'wait_for:')
}
index += 1
}
if (name && prompt) {
steps.push({
name,
prompt,
...(waitFor ? { waitFor } : {}),
})
}
}
return { steps, nextIndex: index }
}
const tasksLineIndex = lines.findIndex(line => line.trim() === 'tasks:')
if (tasksLineIndex === -1) {
return tasks
}
const tasksIndent = getIndent(lines[tasksLineIndex]!)
let index = tasksLineIndex + 1
while (index < lines.length) {
const line = lines[index]!
const trimmed = line.trim()
const indent = getIndent(line)
if (!trimmed) {
index += 1
continue
}
if (indent <= tasksIndent) {
break
}
if (!trimmed.startsWith('- name:')) {
index += 1
continue
}
const taskIndent = indent
const name = parseScalar(trimmed, '- name:')
let interval = ''
let prompt = ''
let steps: HeartbeatAuthorityTaskStep[] = []
index += 1
while (index < lines.length) {
const nextLine = lines[index]!
const nextTrimmed = nextLine.trim()
const nextIndent = getIndent(nextLine)
if (!nextTrimmed) {
index += 1
continue
}
if (nextIndent <= tasksIndent) {
break
}
if (nextIndent === taskIndent && nextTrimmed.startsWith('- name:')) {
break
}
if (nextIndent <= taskIndent) {
break
}
if (nextTrimmed.startsWith('interval:')) {
interval = parseScalar(nextTrimmed, 'interval:')
index += 1
continue
}
if (nextTrimmed.startsWith('prompt:')) {
prompt = parseScalar(nextTrimmed, 'prompt:')
index += 1
continue
}
if (nextTrimmed === 'steps:') {
const parsed = parseSteps(index + 1, nextIndent)
steps = parsed.steps
index = parsed.nextIndex
continue
}
index += 1
}
if (name && interval && prompt) {
tasks.push({
name,
interval,
prompt,
steps,
})
}
}
return tasks
}
function parseHeartbeatIntervalMs(interval: string): number | null {
const match = interval.trim().match(/^(\d+)\s*(ms|s|m|h|d)?$/i)
if (!match) {
return null
}
const value = Number.parseInt(match[1]!, 10)
const unit = (match[2] ?? 'm').toLowerCase()
switch (unit) {
case 'ms':
return value
case 's':
return value * 1_000
case 'm':
return value * 60_000
case 'h':
return value * 60 * 60_000
case 'd':
return value * 24 * 60 * 60_000
default:
return null
}
}
function heartbeatTaskKey(
rootDir: string,
task: HeartbeatAuthorityTask,
): string {
return `${rootDir}::${task.name}::${task.interval}::${task.prompt}`
}
function collectDueHeartbeatTasks(
snapshot: AutonomyAuthoritySnapshot,
nowMs: number,
): HeartbeatAuthorityTask[] {
const due: HeartbeatAuthorityTask[] = []
for (const task of snapshot.heartbeatTasks) {
const intervalMs = parseHeartbeatIntervalMs(task.interval)
if (intervalMs == null) {
continue
}
const key = heartbeatTaskKey(snapshot.rootDir, task)
const lastRunMs = heartbeatTaskLastRunByKey.get(key)
if (lastRunMs !== undefined && nowMs - lastRunMs < intervalMs) {
continue
}
due.push(task)
}
return due
}
function markHeartbeatTasksConsumed(
snapshot: AutonomyAuthoritySnapshot,
tasks: HeartbeatAuthorityTask[],
nowMs: number,
): void {
for (const task of tasks) {
heartbeatTaskLastRunByKey.set(
heartbeatTaskKey(snapshot.rootDir, task),
nowMs,
)
}
}
export function resetAutonomyAuthorityForTests(): void {
heartbeatTaskLastRunByKey.clear()
}
export async function loadAutonomyAuthority(
params: AutonomyAuthorityParams = {},
): Promise<AutonomyAuthoritySnapshot> {
const rootDir = resolve(params.rootDir ?? getProjectRoot())
const currentDir = resolve(params.currentDir ?? getCwd())
const authorityDirs = listAuthorityDirectories(rootDir, currentDir)
const [agentsResults, heartbeatFile] = await Promise.all([
Promise.all(
authorityDirs.map(async dir =>
readAuthorityFile(
join(dir, AUTONOMY_DIR, AUTONOMY_AGENTS_FILENAME),
rootDir,
),
),
),
readAuthorityFile(
join(rootDir, AUTONOMY_DIR, AUTONOMY_HEARTBEAT_FILENAME),
rootDir,
),
])
const agentsFiles = agentsResults.filter(
(file): file is AutonomyAuthorityFile => file !== null,
)
return {
rootDir,
currentDir,
agentsFiles,
agentsContent: mergeAgentsAuthority(agentsFiles),
heartbeatFile,
heartbeatContent: heartbeatFile?.content ?? null,
heartbeatTasks: heartbeatFile
? parseHeartbeatAuthorityTasks(heartbeatFile.content)
: [],
}
}
export async function buildAutonomyTurnPrompt(params: {
basePrompt: string
trigger: AutonomyTriggerKind
rootDir?: string
currentDir?: string
nowMs?: number
}): Promise<string> {
const prepared = await prepareAutonomyTurnPrompt(params)
commitPreparedAutonomyTurn(prepared)
return prepared.prompt
}
export async function prepareAutonomyTurnPrompt(params: {
basePrompt: string
trigger: AutonomyTriggerKind
rootDir?: string
currentDir?: string
nowMs?: number
}): Promise<PreparedAutonomyTurn> {
const snapshot = await loadAutonomyAuthority({
rootDir: params.rootDir,
currentDir: params.currentDir,
})
const nowMs = params.nowMs ?? Date.now()
const dueHeartbeatTasks =
params.trigger === 'proactive-tick'
? collectDueHeartbeatTasks(snapshot, nowMs)
: []
const duePromptTasks = dueHeartbeatTasks.filter(
task => task.steps.length === 0,
)
const sections: string[] = []
if (snapshot.agentsContent) {
sections.push(
`Workspace authority from ${AUTONOMY_AGENTS_FILENAME}:\n${snapshot.agentsContent}`,
)
}
if (snapshot.heartbeatContent) {
sections.push(
`Workspace heartbeat guidance from ${AUTONOMY_HEARTBEAT_FILENAME}:\n${snapshot.heartbeatContent}`,
)
}
if (duePromptTasks.length > 0) {
sections.push(
[
`Due ${AUTONOMY_HEARTBEAT_FILENAME} tasks:`,
...duePromptTasks.map(
task => `- ${task.name} (${task.interval}): ${task.prompt}`,
),
].join('\n'),
)
}
if (sections.length === 0) {
return {
rootDir: snapshot.rootDir,
currentDir: snapshot.currentDir,
trigger: params.trigger,
prompt: params.basePrompt,
dueHeartbeatTasks,
nowMs,
}
}
const prelude =
params.trigger === 'proactive-tick'
? 'This is an autonomous proactive turn. Follow the workspace authority below before acting.'
: 'This prompt was generated automatically. Follow the workspace authority below before acting.'
return {
rootDir: snapshot.rootDir,
currentDir: snapshot.currentDir,
trigger: params.trigger,
prompt: [
prelude,
'<autonomy_authority>',
...sections,
'</autonomy_authority>',
params.basePrompt,
].join('\n\n'),
dueHeartbeatTasks,
nowMs,
}
}
export function commitPreparedAutonomyTurn(
prepared: PreparedAutonomyTurn,
): void {
if (
prepared.trigger !== 'proactive-tick' ||
prepared.dueHeartbeatTasks.length === 0
) {
return
}
const snapshot: AutonomyAuthoritySnapshot = {
rootDir: prepared.rootDir,
currentDir: prepared.currentDir,
agentsFiles: [],
agentsContent: null,
heartbeatFile: null,
heartbeatContent: null,
heartbeatTasks: prepared.dueHeartbeatTasks,
}
markHeartbeatTasksConsumed(
snapshot,
prepared.dueHeartbeatTasks,
prepared.nowMs,
)
}

1057
src/utils/autonomyFlows.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,48 @@
import { mkdir, writeFile } from 'fs/promises'
import { join, resolve } from 'path'
import { lock } from './lockfile.js'
const persistenceLocks = new Map<string, Promise<void>>()
export async function withAutonomyPersistenceLock<T>(
rootDir: string,
fn: () => Promise<T>,
): Promise<T> {
const key = resolve(rootDir)
const lockPath = join(key, '.claude', 'autonomy', '.lock')
const previous = persistenceLocks.get(key) ?? Promise.resolve()
let release!: () => void
const current = new Promise<void>(resolve => {
release = resolve
})
persistenceLocks.set(
key,
previous.then(() => current),
)
await previous
try {
await mkdir(join(key, '.claude', 'autonomy'), { recursive: true })
await writeFile(lockPath, '', { flag: 'a' })
const unlock = await lock(lockPath, {
lockfilePath: `${lockPath}.lock`,
retries: {
retries: 10,
factor: 1.2,
minTimeout: 10,
maxTimeout: 100,
},
})
try {
return await fn()
} finally {
await unlock().catch(() => {})
}
} finally {
release()
if (persistenceLocks.get(key) === current) {
persistenceLocks.delete(key)
}
}
}

797
src/utils/autonomyRuns.ts Normal file
View File

@@ -0,0 +1,797 @@
import { randomUUID } from 'crypto'
import { mkdir, writeFile } from 'fs/promises'
import { dirname, join, resolve } from 'path'
import { getProjectRoot } from '../bootstrap/state.js'
import type { MessageOrigin } from '../types/message.js'
import type { QueuedCommand } from '../types/textInputTypes.js'
import {
AUTONOMY_DIR,
buildAutonomyTurnPrompt,
commitPreparedAutonomyTurn,
prepareAutonomyTurnPrompt,
type AutonomyTriggerKind,
type HeartbeatAuthorityTask,
} from './autonomyAuthority.js'
import { getCwd } from './cwd.js'
import {
DEFAULT_AUTONOMY_OWNER_KEY,
getAutonomyFlowById,
markManagedAutonomyFlowStepCancelled,
markManagedAutonomyFlowStepCompleted,
markManagedAutonomyFlowStepFailed,
markManagedAutonomyFlowStepRunning,
queueManagedAutonomyFlowStepRun,
resumeManagedAutonomyFlow,
startManagedAutonomyFlow,
type AutonomyFlowRecord,
type AutonomyFlowSyncMode,
type ManagedAutonomyFlowStepDefinition,
} from './autonomyFlows.js'
import { withAutonomyPersistenceLock } from './autonomyPersistence.js'
import { getFsImplementation } from './fsOperations.js'
const AUTONOMY_RUNS_MAX = 200
const AUTONOMY_RUNS_RELATIVE_PATH = join(AUTONOMY_DIR, 'runs.json')
export type AutonomyRunStatus =
| 'queued'
| 'running'
| 'completed'
| 'failed'
| 'cancelled'
export type AutonomyRunRuntime = 'automatic' | 'flow_step'
export type AutonomyRunRecord = {
runId: string
runtime: AutonomyRunRuntime
trigger: AutonomyTriggerKind
status: AutonomyRunStatus
rootDir: string
currentDir: string
ownerKey: string
sourceId?: string
sourceLabel?: string
parentFlowId?: string
parentFlowKey?: string
parentFlowSyncMode?: AutonomyFlowSyncMode
flowStepId?: string
flowStepName?: string
promptPreview: string
createdAt: number
startedAt?: number
endedAt?: number
error?: string
}
type AutonomyRunsFile = {
runs: AutonomyRunRecord[]
}
type AutonomyRunFlowRef = {
flowId: string
flowKey: string
syncMode: AutonomyFlowSyncMode
ownerKey: string
stepId: string
stepName: string
}
function truncatePromptPreview(prompt: string): string {
const singleLine = prompt.replace(/\s+/g, ' ').trim()
return singleLine.length <= 240
? singleLine
: `${singleLine.slice(0, 237)}...`
}
/** A persisted record may lack fields that were added after the initial schema. */
type PersistedAutonomyRunRecord = Omit<
AutonomyRunRecord,
'runtime' | 'currentDir' | 'ownerKey'
> &
Partial<Pick<AutonomyRunRecord, 'runtime' | 'currentDir' | 'ownerKey'>>
function cloneRunRecord(run: AutonomyRunRecord): AutonomyRunRecord {
return { ...run }
}
function normalizePersistedRunRecord(
run: PersistedAutonomyRunRecord,
): AutonomyRunRecord {
return {
...run,
runtime: run.runtime === 'flow_step' ? 'flow_step' : 'automatic',
currentDir: run.currentDir ?? run.rootDir,
ownerKey: run.ownerKey ?? DEFAULT_AUTONOMY_OWNER_KEY,
}
}
export function resolveAutonomyRunsPath(
rootDir: string = getProjectRoot(),
): string {
return join(resolve(rootDir), AUTONOMY_RUNS_RELATIVE_PATH)
}
export async function listAutonomyRuns(
rootDir: string = getProjectRoot(),
): Promise<AutonomyRunRecord[]> {
try {
const raw = (await getFsImplementation().readFile(
resolveAutonomyRunsPath(rootDir),
{
encoding: 'utf-8',
},
)) as string
const parsed = JSON.parse(raw) as { runs?: unknown[] }
if (!Array.isArray(parsed.runs)) {
return []
}
return (parsed.runs as Record<string, unknown>[])
.filter(
(run): run is PersistedAutonomyRunRecord & Record<string, unknown> => {
return Boolean(
run &&
typeof run.runId === 'string' &&
typeof run.trigger === 'string' &&
typeof run.status === 'string' &&
typeof run.rootDir === 'string' &&
typeof run.promptPreview === 'string' &&
typeof run.createdAt === 'number',
)
},
)
.map(normalizePersistedRunRecord)
.sort((left, right) => right.createdAt - left.createdAt)
} catch {
return []
}
}
async function writeAutonomyRuns(
runs: AutonomyRunRecord[],
rootDir: string = getProjectRoot(),
): Promise<void> {
const path = resolveAutonomyRunsPath(rootDir)
await mkdir(dirname(path), { recursive: true })
await writeFile(
path,
`${JSON.stringify(
{
runs: runs
.slice()
.map(cloneRunRecord)
.sort((left, right) => right.createdAt - left.createdAt)
.slice(0, AUTONOMY_RUNS_MAX),
} satisfies AutonomyRunsFile,
null,
2,
)}\n`,
'utf-8',
)
}
async function updateAutonomyRun(
runId: string,
updater: (current: AutonomyRunRecord) => AutonomyRunRecord,
rootDir: string = getProjectRoot(),
): Promise<AutonomyRunRecord | null> {
return withAutonomyPersistenceLock(rootDir, async () => {
const runs = await listAutonomyRuns(rootDir)
const index = runs.findIndex(run => run.runId === runId)
if (index === -1) {
return null
}
const updated = cloneRunRecord(updater(cloneRunRecord(runs[index]!)))
runs[index] = updated
await writeAutonomyRuns(runs, rootDir)
return updated
})
}
export async function getAutonomyRunById(
runId: string,
rootDir: string = getProjectRoot(),
): Promise<AutonomyRunRecord | null> {
const runs = await listAutonomyRuns(rootDir)
return runs.find(run => run.runId === runId) ?? null
}
export async function createAutonomyRun(params: {
trigger: AutonomyTriggerKind
prompt: string
rootDir?: string
currentDir?: string
sourceId?: string
sourceLabel?: string
runtime?: AutonomyRunRuntime
ownerKey?: string
flow?: AutonomyRunFlowRef
nowMs?: number
}): Promise<AutonomyRunRecord> {
const rootDir = resolve(params.rootDir ?? getProjectRoot())
const currentDir = resolve(params.currentDir ?? rootDir)
const record: AutonomyRunRecord = {
runId: randomUUID(),
runtime: params.runtime ?? (params.flow ? 'flow_step' : 'automatic'),
trigger: params.trigger,
status: 'queued',
rootDir,
currentDir,
ownerKey:
params.flow?.ownerKey ?? params.ownerKey ?? DEFAULT_AUTONOMY_OWNER_KEY,
...(params.sourceId ? { sourceId: params.sourceId } : {}),
...(params.sourceLabel ? { sourceLabel: params.sourceLabel } : {}),
...(params.flow
? {
parentFlowId: params.flow.flowId,
parentFlowKey: params.flow.flowKey,
parentFlowSyncMode: params.flow.syncMode,
flowStepId: params.flow.stepId,
flowStepName: params.flow.stepName,
}
: {}),
promptPreview: truncatePromptPreview(params.prompt),
createdAt: params.nowMs ?? Date.now(),
}
await withAutonomyPersistenceLock(rootDir, async () => {
const runs = await listAutonomyRuns(rootDir)
runs.unshift(record)
await writeAutonomyRuns(runs, rootDir)
})
if (
record.parentFlowId &&
record.flowStepId &&
record.parentFlowSyncMode === 'managed'
) {
const stepIndex =
(
await getAutonomyFlowById(record.parentFlowId, rootDir)
)?.stateJson?.steps.findIndex(
step => step.stepId === record.flowStepId,
) ?? 0
await queueManagedAutonomyFlowStepRun({
flowId: record.parentFlowId,
stepId: record.flowStepId,
stepIndex: stepIndex >= 0 ? stepIndex : 0,
runId: record.runId,
rootDir,
nowMs: record.createdAt,
})
}
return record
}
function buildManagedFlowStepPrompt(
flow: AutonomyFlowRecord,
stepIndex: number,
): string {
const state = flow.stateJson
const step = state?.steps[stepIndex]
if (!state || !step) {
return flow.goal
}
const completed = state.steps
.slice(0, stepIndex)
.filter(candidate => candidate.status === 'completed')
.map(candidate => `- ${candidate.name}`)
const remaining = state.steps
.slice(stepIndex + 1)
.map(candidate => `- ${candidate.name}`)
return [
`This is step ${stepIndex + 1}/${state.steps.length} of the managed autonomy flow "${flow.goal}".`,
'<autonomy_flow>',
`Flow ID: ${flow.flowId}`,
`Flow source: ${flow.sourceLabel ?? flow.sourceId ?? 'automatic'}`,
`Current step: ${step.name}`,
completed.length > 0
? ['Completed steps:', ...completed].join('\n')
: 'Completed steps: none',
remaining.length > 0
? ['Remaining steps after this one:', ...remaining].join('\n')
: 'Remaining steps after this one: none',
'</autonomy_flow>',
step.prompt,
].join('\n\n')
}
async function createOrRecoverManagedFlowStepCommand(params: {
flowId: string
rootDir?: string
currentDir?: string
priority?: 'now' | 'next' | 'later'
workload?: string
}): Promise<QueuedCommand | null> {
const rootDir = resolve(params.rootDir ?? getProjectRoot())
const flow = await getAutonomyFlowById(params.flowId, rootDir)
if (!flow || flow.status !== 'queued' || !flow.stateJson) {
return null
}
const stepIndex = flow.stateJson.currentStepIndex
const step = flow.stateJson.steps[stepIndex]
if (!step) {
return null
}
if (step.status === 'queued' && step.runId) {
const run = await getAutonomyRunById(step.runId, rootDir)
if (run && run.status === 'queued' && !run.startedAt && !run.endedAt) {
const value = await buildAutonomyTurnPrompt({
basePrompt: buildManagedFlowStepPrompt(flow, stepIndex),
trigger: 'managed-flow-step',
rootDir,
currentDir: params.currentDir ?? flow.currentDir,
})
const origin = {
kind: 'autonomy',
trigger: 'managed-flow-step',
runId: run.runId,
...(run.sourceId ? { sourceId: run.sourceId } : {}),
} as unknown as MessageOrigin
return {
value,
mode: 'prompt',
priority: params.priority ?? 'later',
isMeta: true,
origin,
workload: params.workload,
autonomy: {
runId: run.runId,
trigger: 'managed-flow-step',
sourceId: run.sourceId,
sourceLabel: run.sourceLabel,
...(run.parentFlowId ? { flowId: run.parentFlowId } : {}),
...(run.flowStepId ? { flowStepId: run.flowStepId } : {}),
...(run.flowStepName ? { flowStepName: run.flowStepName } : {}),
},
}
}
return null
}
if (step.status !== 'pending' || step.runId) {
return null
}
return createAutonomyQueuedPrompt({
basePrompt: buildManagedFlowStepPrompt(flow, stepIndex),
trigger: 'managed-flow-step',
rootDir,
currentDir: params.currentDir ?? flow.currentDir,
sourceId: flow.sourceId ?? flow.flowId,
sourceLabel: flow.sourceLabel ?? flow.goal,
workload: params.workload,
priority: params.priority,
flow: {
flowId: flow.flowId,
flowKey: flow.flowKey,
syncMode: 'managed',
ownerKey: flow.ownerKey,
stepId: step.stepId,
stepName: step.name,
},
})
}
async function queueCurrentManagedFlowStepCommand(params: {
flowId: string
rootDir?: string
currentDir?: string
priority?: 'now' | 'next' | 'later'
workload?: string
}): Promise<QueuedCommand | null> {
return createOrRecoverManagedFlowStepCommand(params)
}
export async function startManagedAutonomyFlowFromHeartbeatTask(params: {
task: HeartbeatAuthorityTask
rootDir?: string
currentDir?: string
ownerKey?: string
priority?: 'now' | 'next' | 'later'
workload?: string
}): Promise<QueuedCommand | null> {
if (params.task.steps.length === 0) {
return null
}
const rootDir = resolve(params.rootDir ?? getProjectRoot())
const currentDir = resolve(params.currentDir ?? getCwd())
const started = await startManagedAutonomyFlow({
trigger: 'proactive-tick',
goal: params.task.prompt,
steps: params.task.steps.map<ManagedAutonomyFlowStepDefinition>(step => ({
name: step.name,
prompt: step.prompt,
...(step.waitFor ? { waitFor: step.waitFor } : {}),
})),
rootDir,
currentDir,
ownerKey: params.ownerKey,
sourceId: `heartbeat:${params.task.name}`,
sourceLabel: params.task.name,
})
if (!started) {
return null
}
return createOrRecoverManagedFlowStepCommand({
flowId: started.flow.flowId,
rootDir,
currentDir,
priority: params.priority,
workload: params.workload,
})
}
export async function markAutonomyRunRunning(
runId: string,
rootDir?: string,
nowMs?: number,
): Promise<AutonomyRunRecord | null> {
const updated = await updateAutonomyRun(
runId,
current => ({
...current,
status: 'running',
startedAt: nowMs ?? Date.now(),
}),
rootDir,
)
if (updated?.parentFlowId && updated.parentFlowSyncMode === 'managed') {
await markManagedAutonomyFlowStepRunning({
flowId: updated.parentFlowId,
runId: updated.runId,
rootDir,
nowMs: updated.startedAt,
})
}
return updated
}
export async function markAutonomyRunCompleted(
runId: string,
rootDir?: string,
nowMs?: number,
): Promise<AutonomyRunRecord | null> {
const updated = await updateAutonomyRun(
runId,
current => ({
...current,
status: 'completed',
endedAt: nowMs ?? Date.now(),
error: undefined,
}),
rootDir,
)
if (updated?.parentFlowId && updated.parentFlowSyncMode === 'managed') {
await markManagedAutonomyFlowStepCompleted({
flowId: updated.parentFlowId,
runId: updated.runId,
rootDir,
nowMs: updated.endedAt,
})
}
return updated
}
export async function markAutonomyRunFailed(
runId: string,
error: string,
rootDir?: string,
nowMs?: number,
): Promise<AutonomyRunRecord | null> {
const updated = await updateAutonomyRun(
runId,
current => ({
...current,
status: 'failed',
endedAt: nowMs ?? Date.now(),
error,
}),
rootDir,
)
if (updated?.parentFlowId && updated.parentFlowSyncMode === 'managed') {
await markManagedAutonomyFlowStepFailed({
flowId: updated.parentFlowId,
runId: updated.runId,
error,
rootDir,
nowMs: updated.endedAt,
})
}
return updated
}
export async function markAutonomyRunCancelled(
runId: string,
rootDir?: string,
nowMs?: number,
): Promise<AutonomyRunRecord | null> {
const updated = await updateAutonomyRun(
runId,
current => ({
...current,
status: 'cancelled',
endedAt: nowMs ?? Date.now(),
error: undefined,
}),
rootDir,
)
if (updated?.parentFlowId && updated.parentFlowSyncMode === 'managed') {
await markManagedAutonomyFlowStepCancelled({
flowId: updated.parentFlowId,
runId: updated.runId,
rootDir,
nowMs: updated.endedAt,
})
}
return updated
}
export async function finalizeAutonomyRunCompleted(params: {
runId: string
rootDir?: string
currentDir?: string
priority?: 'now' | 'next' | 'later'
workload?: string
nowMs?: number
}): Promise<QueuedCommand[]> {
const updated = await markAutonomyRunCompleted(
params.runId,
params.rootDir,
params.nowMs,
)
if (!updated?.parentFlowId || updated.parentFlowSyncMode !== 'managed') {
return []
}
const next = await queueCurrentManagedFlowStepCommand({
flowId: updated.parentFlowId,
rootDir: params.rootDir,
currentDir: params.currentDir ?? updated.currentDir,
priority: params.priority,
workload: params.workload,
})
return next ? [next] : []
}
export async function finalizeAutonomyRunFailed(params: {
runId: string
error: string
rootDir?: string
nowMs?: number
}): Promise<void> {
await markAutonomyRunFailed(
params.runId,
params.error,
params.rootDir,
params.nowMs,
)
}
export async function recoverManagedAutonomyFlowPrompt(params: {
flowId: string
rootDir?: string
currentDir?: string
priority?: 'now' | 'next' | 'later'
workload?: string
}): Promise<QueuedCommand | null> {
return createOrRecoverManagedFlowStepCommand(params)
}
export async function resumeManagedAutonomyFlowPrompt(params: {
flowId: string
rootDir?: string
currentDir?: string
priority?: 'now' | 'next' | 'later'
workload?: string
nowMs?: number
}): Promise<QueuedCommand | null> {
const resumed = await resumeManagedAutonomyFlow({
flowId: params.flowId,
rootDir: params.rootDir,
nowMs: params.nowMs,
})
if (!resumed) {
return recoverManagedAutonomyFlowPrompt({
flowId: params.flowId,
rootDir: params.rootDir,
currentDir: params.currentDir,
priority: params.priority,
workload: params.workload,
})
}
return createOrRecoverManagedFlowStepCommand({
flowId: resumed.flow.flowId,
rootDir: params.rootDir,
currentDir: params.currentDir ?? resumed.flow.currentDir,
priority: params.priority,
workload: params.workload,
})
}
export async function createAutonomyQueuedPrompt(params: {
trigger: AutonomyTriggerKind
basePrompt: string
rootDir?: string
currentDir?: string
sourceId?: string
sourceLabel?: string
workload?: string
priority?: 'now' | 'next' | 'later'
shouldCreate?: () => boolean
flow?: AutonomyRunFlowRef
}): Promise<QueuedCommand | null> {
const rootDir = resolve(params.rootDir ?? getProjectRoot())
const currentDir = resolve(params.currentDir ?? getCwd())
const prepared = await prepareAutonomyTurnPrompt({
basePrompt: params.basePrompt,
trigger: params.trigger,
rootDir,
currentDir,
})
if (params.shouldCreate && !params.shouldCreate()) {
return null
}
return commitAutonomyQueuedPrompt({
prepared,
rootDir,
currentDir,
sourceId: params.sourceId,
sourceLabel: params.sourceLabel,
workload: params.workload,
priority: params.priority,
flow: params.flow,
})
}
export async function commitAutonomyQueuedPrompt(params: {
prepared: Awaited<ReturnType<typeof prepareAutonomyTurnPrompt>>
rootDir?: string
currentDir?: string
sourceId?: string
sourceLabel?: string
workload?: string
priority?: 'now' | 'next' | 'later'
flow?: AutonomyRunFlowRef
}): Promise<QueuedCommand> {
const rootDir = resolve(
params.rootDir ?? params.prepared.rootDir ?? getProjectRoot(),
)
const currentDir = resolve(
params.currentDir ?? params.prepared.currentDir ?? getCwd(),
)
commitPreparedAutonomyTurn(params.prepared)
const value = params.prepared.prompt
const run = await createAutonomyRun({
trigger: params.prepared.trigger,
prompt: value,
rootDir,
currentDir,
sourceId: params.sourceId,
sourceLabel: params.sourceLabel,
flow: params.flow,
})
const origin = {
kind: 'autonomy',
trigger: params.prepared.trigger,
runId: run.runId,
...(params.sourceId ? { sourceId: params.sourceId } : {}),
} as unknown as MessageOrigin
return {
value,
mode: 'prompt',
priority: params.priority ?? 'later',
isMeta: true,
origin,
workload: params.workload,
autonomy: {
runId: run.runId,
trigger: params.prepared.trigger,
sourceId: params.sourceId,
sourceLabel: params.sourceLabel,
...(run.parentFlowId ? { flowId: run.parentFlowId } : {}),
...(run.flowStepId ? { flowStepId: run.flowStepId } : {}),
...(run.flowStepName ? { flowStepName: run.flowStepName } : {}),
},
}
}
export async function createProactiveAutonomyCommands(params: {
basePrompt: string
rootDir?: string
currentDir?: string
workload?: string
priority?: 'now' | 'next' | 'later'
shouldCreate?: () => boolean
}): Promise<QueuedCommand[]> {
const rootDir = resolve(params.rootDir ?? getProjectRoot())
const currentDir = resolve(params.currentDir ?? getCwd())
const prepared = await prepareAutonomyTurnPrompt({
basePrompt: params.basePrompt,
trigger: 'proactive-tick',
rootDir,
currentDir,
})
if (params.shouldCreate && !params.shouldCreate()) {
return []
}
const commands: QueuedCommand[] = [
await commitAutonomyQueuedPrompt({
prepared,
rootDir,
currentDir,
workload: params.workload,
priority: params.priority,
}),
]
for (const task of prepared.dueHeartbeatTasks) {
if (task.steps.length === 0) {
continue
}
if (params.shouldCreate && !params.shouldCreate()) {
break
}
const flowCommand = await startManagedAutonomyFlowFromHeartbeatTask({
task,
rootDir,
currentDir,
priority: params.priority,
workload: params.workload,
})
if (flowCommand) {
commands.push(flowCommand)
}
}
return commands
}
export function formatAutonomyRunsStatus(runs: AutonomyRunRecord[]): string {
const counts = {
queued: 0,
running: 0,
completed: 0,
failed: 0,
cancelled: 0,
}
for (const run of runs) {
counts[run.status] += 1
}
const latest = runs[0]
const latestLine = latest
? `Latest: ${latest.trigger} ${latest.status} (${new Date(latest.createdAt).toLocaleString()})`
: 'Latest: none'
return [
`Autonomy runs: ${runs.length}`,
`Queued: ${counts.queued}`,
`Running: ${counts.running}`,
`Completed: ${counts.completed}`,
`Failed: ${counts.failed}`,
`Cancelled: ${counts.cancelled}`,
latestLine,
].join('\n')
}
export function formatAutonomyRunsList(
runs: AutonomyRunRecord[],
limit = 10,
): string {
const slice = runs.slice(0, limit)
if (slice.length === 0) {
return 'No autonomy runs recorded.'
}
return slice
.map(run => {
const source = run.sourceLabel ?? run.sourceId ?? 'auto'
const flow =
run.parentFlowId && run.flowStepName
? ` | flow=${run.parentFlowId} step=${run.flowStepName}`
: ''
const ended =
run.endedAt != null
? ` -> ${new Date(run.endedAt).toLocaleTimeString()}`
: ''
const error = run.error ? ` | ${run.error}` : ''
return `${run.runId} | ${run.runtime} | ${run.trigger} | ${run.status} | ${source}${flow} | ${new Date(run.createdAt).toLocaleTimeString()}${ended}\n ${run.promptPreview}${error}`
})
.join('\n')
}

180
src/utils/cliLaunch.ts Normal file
View File

@@ -0,0 +1,180 @@
import { type ChildProcess, spawn, type SpawnOptions } from 'child_process'
import { isInBundledMode } from './bundledMode.js'
import { quote } from './bash/shellQuote.js'
/**
* CliLaunchSpec — normalized descriptor for spawning a child CLI process.
*
* Every site that re-execs the CLI (daemon workers, bg sessions, bridge
* sessions, assistant/RCS daemon launchers) should use this instead of
* manually assembling `[...process.execArgv, process.argv[1]!, ...]`.
*
* Centralizing the bootstrap contract prevents the class of bugs where
* individual spawn sites forget execArgv, windowsHide, or env propagation.
*/
export interface CliLaunchSpec {
/** Runtime binary path (e.g. bun, node). */
execPath: string
/** Full argument list including bootstrap args and CLI args. */
args: string[]
/** Environment for the child process. */
env: NodeJS.ProcessEnv
/** Whether to hide the console window on Windows. */
windowsHide: boolean
}
// ---------------------------------------------------------------------------
// Frozen bootstrap snapshot — computed once at module load time.
//
// Bun quirk (https://github.com/oven-sh/bun/issues/11673): in single-file
// executables, app arguments from process.argv can leak into process.execArgv.
// We snapshot and filter once, so every child gets a clean, stable set of
// runtime flags regardless of when buildCliLaunch is called.
// ---------------------------------------------------------------------------
/**
* Filter out leaked application arguments from process.execArgv.
* Only keep known runtime flags: -d (defines), --feature, --inspect variants.
*/
function sanitizeExecArgv(raw: readonly string[]): string[] {
const result: string[] = []
for (let i = 0; i < raw.length; i++) {
const arg = raw[i]!
// Bun define flags: -d KEY:VALUE or -dKEY:VALUE
if (arg === '-d' || arg.startsWith('-d ') || arg.startsWith('-d\t')) {
result.push(arg)
if (arg === '-d' && i + 1 < raw.length) {
result.push(raw[++i]!)
}
continue
}
if (arg.startsWith('-d') && arg.includes(':')) {
result.push(arg)
continue
}
// Bun feature flags: --feature NAME
if (arg === '--feature') {
result.push(arg)
if (i + 1 < raw.length) {
result.push(raw[++i]!)
}
continue
}
// Node/Bun inspect flags
if (/^--inspect(-brk)?(=|$)/.test(arg)) {
result.push(arg)
continue
}
// Keep other known runtime flags (e.g. --conditions, --experimental-*)
if (arg.startsWith('--') && !arg.includes('=') && i + 1 < raw.length) {
// Unknown two-part flag — skip conservatively in bundled mode only
if (isInBundledMode()) continue
result.push(arg)
result.push(raw[++i]!)
continue
}
if (arg.startsWith('-') && !isInBundledMode()) {
result.push(arg)
}
}
return result
}
const BOOTSTRAP_ARGS: readonly string[] = Object.freeze(
sanitizeExecArgv(process.execArgv),
)
const SCRIPT_PATH: string | undefined = process.argv[1]
const EXEC_PATH: string = process.execPath
const IS_WINDOWS = process.platform === 'win32'
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Build a normalized launch spec for spawning a child CLI process.
*
* @param cliArgs Arguments to pass to the CLI entrypoint (e.g. ['daemon', 'start'])
* @param opts.env Override environment (defaults to process.env)
*/
export function buildCliLaunch(
cliArgs: string[],
opts?: { env?: NodeJS.ProcessEnv },
): CliLaunchSpec {
const baseEnv = opts?.env ?? process.env
// In bundled mode the execPath IS the CLI binary — no script path needed.
// In script mode (dev / npm) we need the script path between runtime flags
// and CLI args so the runtime knows which file to execute.
const args: string[] =
isInBundledMode() || !SCRIPT_PATH
? [...BOOTSTRAP_ARGS, ...cliArgs]
: [...BOOTSTRAP_ARGS, SCRIPT_PATH, ...cliArgs]
// Ensure Windows children can discover git-bash without shelling out
const env: NodeJS.ProcessEnv = { ...baseEnv }
if (IS_WINDOWS) {
if (
process.env.CLAUDE_CODE_GIT_BASH_PATH &&
!env.CLAUDE_CODE_GIT_BASH_PATH
) {
env.CLAUDE_CODE_GIT_BASH_PATH = process.env.CLAUDE_CODE_GIT_BASH_PATH
}
if (process.env.SHELL && !env.SHELL) {
env.SHELL = process.env.SHELL
}
}
return {
execPath: EXEC_PATH,
args,
env,
windowsHide: IS_WINDOWS,
}
}
/**
* Spawn a child CLI process from a launch spec.
*
* Callers provide transport-level options (stdio, detached, cwd) while the
* spec handles bootstrap concerns (execPath, args, env, windowsHide).
*
* Windows note: `detached: true` on Windows creates a new console window
* (unlike Unix where it only creates a new process group). Node.js uses
* `windowsHide` to pass CREATE_NO_WINDOW, but Bun may not implement it.
* As a fallback, we always set both `windowsHide: true` and keep
* `detached` as-is — the child needs `detached` to outlive the parent.
*/
export function spawnCli(
spec: CliLaunchSpec,
spawnOpts: Omit<SpawnOptions, 'windowsHide'>,
): ChildProcess {
return spawn(spec.execPath, spec.args, {
...spawnOpts,
env: { ...spec.env, ...(spawnOpts.env as NodeJS.ProcessEnv) },
windowsHide: spec.windowsHide,
})
}
/**
* Quote a launch spec into a single shell command string (for tmux).
*/
export function quoteCliLaunch(spec: CliLaunchSpec): string {
return quote([spec.execPath, ...spec.args])
}
/**
* Get the frozen bootstrap args snapshot.
* Useful for call sites that need the raw args (e.g. bridgeMain deps).
*/
export function getBootstrapArgs(): readonly string[] {
return BOOTSTRAP_ARGS
}
/**
* Get the script path (process.argv[1] at startup).
* Returns undefined in bundled mode.
*/
export function getScriptPath(): string | undefined {
return SCRIPT_PATH
}

View File

@@ -334,6 +334,9 @@ export type GlobalConfig = {
overageCreditUpsellSeenCount?: number // Number of times the overage credit upsell has been shown
hasVisitedExtraUsage?: boolean // Whether the user has visited /extra-usage — hides credit upsells
// Display language preference
preferredLanguage?: 'auto' | 'en' | 'zh' // auto = follow system locale, en = English, zh = 中文
// Voice mode notice tracking
voiceNoticeSeenCount?: number // Number of times the voice-mode-available notice has been shown
voiceLangHintShownCount?: number // Number of times the /voice dictation-language hint has been shown
@@ -556,7 +559,6 @@ export type GlobalConfig = {
// Speculation configuration (ant-only)
speculationEnabled?: boolean // Whether speculation is enabled (default: true)
// Client data for server-side experiments (fetched during bootstrap).
clientDataCache?: Record<string, unknown> | null

View File

@@ -26,6 +26,12 @@ import { fileHistoryEnabled, fileHistoryMakeSnapshot } from './fileHistory.js'
import { gracefulShutdownSync } from './gracefulShutdown.js'
import { enqueue } from './messageQueueManager.js'
import { resolveSkillModelOverride } from './model/model.js'
import {
finalizeAutonomyRunCompleted,
finalizeAutonomyRunFailed,
markAutonomyRunFailed,
markAutonomyRunRunning,
} from './autonomyRuns.js'
import type { ProcessUserInputContext } from './processUserInput/processUserInput.js'
import { processUserInput } from './processUserInput/processUserInput.js'
import type { QueryGuard } from './QueryGuard.js'
@@ -460,6 +466,7 @@ async function executeUserInput(params: ExecuteUserInputParams): Promise<void> {
commands.every(c => c.workload === firstWorkload)
? firstWorkload
: undefined
let autonomyRunIds: string[] | undefined
// Wrap the entire turn (processUserInput loop + onQuery) in an
// AsyncLocalStorage context. This is the ONLY way to correctly
@@ -469,131 +476,159 @@ async function executeUserInput(params: ExecuteUserInputParams): Promise<void> {
// context — isolated from the parent's continuation. A process-global
// mutable slot would be clobbered at the detached closure's first
// await by this function's synchronous return path. See state.ts.
await runWithWorkload(turnWorkload, async () => {
for (let i = 0; i < commands.length; i++) {
const cmd = commands[i]!
const isFirst = i === 0
const result = await processUserInput({
input: cmd.value,
preExpansionInput: cmd.preExpansionValue,
mode: cmd.mode,
setToolJSX,
context: makeContext(),
pastedContents: isFirst ? cmd.pastedContents : undefined,
messages,
setUserInputOnProcessing: isFirst
? setUserInputOnProcessing
: undefined,
isAlreadyProcessing: !isFirst,
querySource,
canUseTool,
uuid: cmd.uuid,
ideSelection: isFirst ? ideSelection : undefined,
skipSlashCommands: cmd.skipSlashCommands,
bridgeOrigin: cmd.bridgeOrigin,
isMeta: cmd.isMeta,
skipAttachments: !isFirst,
})
// Stamp origin here rather than threading another arg through
// processUserInput → processUserInputBase → processTextPrompt → createUserMessage.
// Derive origin from mode for task-notifications — mirrors the origin
// derivation at messages.ts (case 'queued_command'); intentionally
// does NOT mirror its isMeta:true so idle-dequeued notifications stay
// visible in the transcript via UserAgentNotificationMessage.
const origin =
cmd.origin ??
(cmd.mode === 'task-notification'
? ({ kind: 'task-notification' } as const)
: undefined)
if (origin) {
for (const m of result.messages) {
if (m.type === 'user') m.origin = origin
try {
await runWithWorkload(turnWorkload, async () => {
for (let i = 0; i < commands.length; i++) {
const cmd = commands[i]!
const isFirst = i === 0
if (cmd.autonomy?.runId) {
;(autonomyRunIds ??= []).push(cmd.autonomy.runId)
await markAutonomyRunRunning(cmd.autonomy.runId)
}
const result = await processUserInput({
input: cmd.value,
preExpansionInput: cmd.preExpansionValue,
mode: cmd.mode,
setToolJSX,
context: makeContext(),
pastedContents: isFirst ? cmd.pastedContents : undefined,
messages,
setUserInputOnProcessing: isFirst
? setUserInputOnProcessing
: undefined,
isAlreadyProcessing: !isFirst,
querySource,
canUseTool,
uuid: cmd.uuid,
ideSelection: isFirst ? ideSelection : undefined,
skipSlashCommands: cmd.skipSlashCommands,
bridgeOrigin: cmd.bridgeOrigin,
isMeta: cmd.isMeta,
skipAttachments: !isFirst,
})
// Stamp origin here rather than threading another arg through
// processUserInput → processUserInputBase → processTextPrompt → createUserMessage.
// Derive origin from mode for task-notifications — mirrors the origin
// derivation at messages.ts (case 'queued_command'); intentionally
// does NOT mirror its isMeta:true so idle-dequeued notifications stay
// visible in the transcript via UserAgentNotificationMessage.
const origin =
cmd.origin ??
(cmd.mode === 'task-notification'
? ({ kind: 'task-notification' } as const)
: undefined)
if (origin) {
for (const m of result.messages) {
if (m.type === 'user') m.origin = origin
}
}
newMessages.push(...result.messages)
if (isFirst) {
shouldQuery = result.shouldQuery
allowedTools = result.allowedTools
model = result.model
effort = result.effort
nextInput = result.nextInput
submitNextInput = result.submitNextInput
}
}
newMessages.push(...result.messages)
if (isFirst) {
shouldQuery = result.shouldQuery
allowedTools = result.allowedTools
model = result.model
effort = result.effort
nextInput = result.nextInput
submitNextInput = result.submitNextInput
}
}
queryCheckpoint('query_process_user_input_end')
if (fileHistoryEnabled()) {
queryCheckpoint('query_file_history_snapshot_start')
newMessages.filter(selectableUserMessagesFilter).forEach(message => {
void fileHistoryMakeSnapshot(
(updater: (prev: FileHistoryState) => FileHistoryState) => {
setAppState(prev => ({
...prev,
fileHistory: updater(prev.fileHistory),
}))
},
message.uuid,
queryCheckpoint('query_process_user_input_end')
if (fileHistoryEnabled()) {
queryCheckpoint('query_file_history_snapshot_start')
newMessages.filter(selectableUserMessagesFilter).forEach(message => {
void fileHistoryMakeSnapshot(
(updater: (prev: FileHistoryState) => FileHistoryState) => {
setAppState(prev => ({
...prev,
fileHistory: updater(prev.fileHistory),
}))
},
message.uuid,
)
})
queryCheckpoint('query_file_history_snapshot_end')
}
if (newMessages.length) {
// History is now added in the caller (onSubmit) for direct user submissions.
// This ensures queued command processing (notifications, already-queued user input)
// doesn't add to history, since those either shouldn't be in history or were
// already added when originally queued.
resetHistory()
setToolJSX({
jsx: null,
shouldHidePromptInput: false,
clearLocalJSX: true,
})
const primaryCmd = commands[0]
const primaryMode = primaryCmd?.mode ?? 'prompt'
const primaryInput =
primaryCmd && typeof primaryCmd.value === 'string'
? primaryCmd.value
: undefined
const shouldCallBeforeQuery = primaryMode === 'prompt'
await onQuery(
newMessages,
abortController,
shouldQuery,
allowedTools ?? [],
model
? resolveSkillModelOverride(model, mainLoopModel)
: mainLoopModel,
shouldCallBeforeQuery ? onBeforeQuery : undefined,
primaryInput,
effort,
)
})
queryCheckpoint('query_file_history_snapshot_end')
}
if (newMessages.length) {
// History is now added in the caller (onSubmit) for direct user submissions.
// This ensures queued command processing (notifications, already-queued user input)
// doesn't add to history, since those either shouldn't be in history or were
// already added when originally queued.
resetHistory()
setToolJSX({
jsx: null,
shouldHidePromptInput: false,
clearLocalJSX: true,
})
const primaryCmd = commands[0]
const primaryMode = primaryCmd?.mode ?? 'prompt'
const primaryInput =
primaryCmd && typeof primaryCmd.value === 'string'
? primaryCmd.value
: undefined
const shouldCallBeforeQuery = primaryMode === 'prompt'
await onQuery(
newMessages,
abortController,
shouldQuery,
allowedTools ?? [],
model
? resolveSkillModelOverride(model, mainLoopModel)
: mainLoopModel,
shouldCallBeforeQuery ? onBeforeQuery : undefined,
primaryInput,
effort,
)
} else {
// Local slash commands that skip messages (e.g., /model, /theme).
// Release the guard BEFORE clearing toolJSX to prevent spinner flash —
// the spinner formula checks: (!toolJSX || showSpinner) && isLoading.
// If we clear toolJSX while the guard is still reserved, spinner briefly
// shows. The finally below also calls cancelReservation (no-op if idle).
queryGuard.cancelReservation()
setToolJSX({
jsx: null,
shouldHidePromptInput: false,
clearLocalJSX: true,
})
resetHistory()
setAbortController(null)
}
// Handle nextInput from commands that want to chain (e.g., /discover activation)
if (nextInput) {
if (submitNextInput) {
enqueue({ value: nextInput, mode: 'prompt' })
} else {
params.onInputChange(nextInput)
// Local slash commands that skip messages (e.g., /model, /theme).
// Release the guard BEFORE clearing toolJSX to prevent spinner flash —
// the spinner formula checks: (!toolJSX || showSpinner) && isLoading.
// If we clear toolJSX while the guard is still reserved, spinner briefly
// shows. The finally below also calls cancelReservation (no-op if idle).
queryGuard.cancelReservation()
setToolJSX({
jsx: null,
shouldHidePromptInput: false,
clearLocalJSX: true,
})
resetHistory()
setAbortController(null)
}
// Handle nextInput from commands that want to chain (e.g., /discover activation)
if (nextInput) {
if (submitNextInput) {
enqueue({ value: nextInput, mode: 'prompt' })
} else {
params.onInputChange(nextInput)
}
}
}) // end runWithWorkload — ALS context naturally scoped, no finally needed
if (autonomyRunIds?.length) {
for (const runId of autonomyRunIds) {
const nextCommands = await finalizeAutonomyRunCompleted({
runId,
priority: 'later',
workload: turnWorkload,
})
for (const nextCommand of nextCommands) {
enqueue(nextCommand)
}
}
}
}) // end runWithWorkload — ALS context naturally scoped, no finally needed
} catch (error) {
if (autonomyRunIds?.length) {
for (const runId of autonomyRunIds) {
await finalizeAutonomyRunFailed({
runId,
error: String(error),
})
}
}
throw error
}
} finally {
// Safety net: release the guard reservation if processUserInput threw
// or onQuery was skipped. No-op if onQuery already ran (guard is idle

26
src/utils/language.ts Normal file
View File

@@ -0,0 +1,26 @@
import { getGlobalConfig } from './config.js'
import { getSystemLocaleLanguage } from './intl.js'
export type PreferredLanguage = 'auto' | 'en' | 'zh'
export type ResolvedLanguage = 'en' | 'zh'
/**
* Resolve the effective display language.
* Priority: GlobalConfig.preferredLanguage → system locale → default 'en'.
*/
export function getResolvedLanguage(): ResolvedLanguage {
const pref = getGlobalConfig().preferredLanguage ?? 'auto'
if (pref === 'en' || pref === 'zh') return pref
const sysLang = getSystemLocaleLanguage()
return sysLang === 'zh' ? 'zh' : 'en'
}
const DISPLAY_NAMES: Record<string, string> = {
auto: 'Auto (follow system)',
en: 'English',
zh: '中文',
}
export function getLanguageDisplayName(lang: string): string {
return DISPLAY_NAMES[lang] ?? lang
}

View File

@@ -0,0 +1,78 @@
/**
* pipeMuteState — Master-side logical disconnect state.
*
* Tracks which slave pipes are currently "muted" (logically disconnected)
* and which have a temporary `/send` override active.
*
* This is local master state only — not part of the socket protocol.
*/
// ---------------------------------------------------------------------------
// Muted set: slaves whose business messages should be dropped by master
// ---------------------------------------------------------------------------
const _mutedPipes = new Set<string>()
export function setMasterMutedPipes(names: Iterable<string>): void {
_mutedPipes.clear()
for (const n of names) _mutedPipes.add(n)
}
export function isMasterPipeMuted(name: string): boolean {
return _mutedPipes.has(name)
}
export function removeMasterPipeMute(name: string): void {
_mutedPipes.delete(name)
}
export function clearMasterMutedPipes(): void {
_mutedPipes.clear()
}
// ---------------------------------------------------------------------------
// Send override set: slaves temporarily unmuted by explicit `/send` command.
// Override lasts until the slave emits `done` or `error`.
// ---------------------------------------------------------------------------
const _sendOverrides = new Set<string>()
let _sendOverrideVersion = 0
const _sendOverrideListeners = new Set<() => void>()
function emitSendOverrideChanged(): void {
_sendOverrideVersion += 1
for (const listener of _sendOverrideListeners) {
listener()
}
}
export function addSendOverride(name: string): void {
_sendOverrides.add(name)
emitSendOverrideChanged()
}
export function removeSendOverride(name: string): void {
if (_sendOverrides.delete(name)) {
emitSendOverrideChanged()
}
}
export function hasSendOverride(name: string): boolean {
return _sendOverrides.has(name)
}
export function clearSendOverrides(): void {
if (_sendOverrides.size > 0) {
_sendOverrides.clear()
emitSendOverrideChanged()
}
}
export function subscribeSendOverride(listener: () => void): () => void {
_sendOverrideListeners.add(listener)
return () => { _sendOverrideListeners.delete(listener) }
}
export function getSendOverrideVersion(): number {
return _sendOverrideVersion
}

View File

@@ -19,8 +19,22 @@ const pendingPipePermissions = new Map<string, PendingPipePermission>()
type PipeRelayFn = (message: PipeMessage) => void
let _pipeRelay: PipeRelayFn | null = null
// Slave-side mute flag: when true, relayPipeMessage() and permission
// relay functions will short-circuit. Set by relay_mute / relay_unmute
// control messages from master.
let _relayMuted = false
export function setRelayMuted(muted: boolean): void {
_relayMuted = muted
}
export function isRelayMuted(): boolean {
return _relayMuted
}
export function setPipeRelay(fn: PipeRelayFn | null): void {
_pipeRelay = fn
if (!fn) _relayMuted = false // reset on disconnect
}
export function getPipeRelay(): PipeRelayFn | null {
@@ -37,6 +51,7 @@ export function tryRelayPipePermissionRequest(
toolUseConfirm: ToolUseConfirm,
onResponse: (payload: PipePermissionResponsePayload) => void,
): string | null {
if (_relayMuted) return null
const send = getPipeSender()
if (!send) return null
@@ -93,6 +108,7 @@ export function notifyPipePermissionCancel(
reason?: string,
): void {
if (!requestId) return
if (_relayMuted) return
const send = getPipeSender()
if (!send) return
send({

View File

@@ -31,7 +31,8 @@ import { attachNdjsonFramer } from './ndjsonFramer.js'
* Message types exchanged over the pipe.
*
* Basic: ping, pong
* Control: attach_request, attach_accept, attach_reject, detach
* Control: attach_request, attach_accept, attach_reject, detach,
* relay_mute, relay_unmute
* Data (M→S): prompt — master sends user input to slave
* Data (S→M): stream — slave streams AI output fragments
* tool_start — slave notifies tool execution start
@@ -49,6 +50,9 @@ export type PipeMessageType =
| 'attach_accept'
| 'attach_reject'
| 'detach'
// Mute control (master → slave): logical disconnect without dropping transport
| 'relay_mute'
| 'relay_unmute'
// Data flow (master → slave)
| 'prompt'
// Data flow (slave → master)

View File

@@ -66,6 +66,11 @@ import { evictTerminalTask } from '../../utils/task/framework.js'
import { tokenCountWithEstimation } from '../../utils/tokens.js'
import { createAbortController } from '../abortController.js'
import { type AgentContext, runWithAgentContext } from '../agentContext.js'
import {
markAutonomyRunCompleted,
markAutonomyRunFailed,
markAutonomyRunRunning,
} from '../autonomyRuns.js'
import { count } from '../array.js'
import { logForDebugging } from '../debug.js'
import { cloneFileStateCache } from '../fileStateCache.js'
@@ -668,6 +673,7 @@ type WaitResult =
| {
type: 'new_message'
message: string
autonomyRunId?: string
from: string
color?: string
summary?: string
@@ -710,7 +716,7 @@ async function waitForNextPromptOrShutdown(
task.type === 'in_process_teammate' &&
task.pendingUserMessages.length > 0
) {
const message = task.pendingUserMessages[0]! // Safe: checked length > 0
const pending = task.pendingUserMessages[0]! // Safe: checked length > 0
// Pop the message from the queue
setAppState(prev => {
const prevTask = prev.tasks[taskId]
@@ -731,9 +737,13 @@ async function waitForNextPromptOrShutdown(
logForDebugging(
`[inProcessRunner] ${identity.agentName} found pending user message (poll #${pollCount})`,
)
if (pending.autonomyRunId) {
await markAutonomyRunRunning(pending.autonomyRunId)
}
return {
type: 'new_message',
message,
message: pending.message,
autonomyRunId: pending.autonomyRunId,
from: 'user',
}
}
@@ -1010,6 +1020,7 @@ export async function runInProcessTeammate(
description,
)
let currentPrompt = wrappedInitialPrompt
let currentAutonomyRunId: string | undefined
let shouldExit = false
// Try to claim an available task immediately so the UI can show activity
@@ -1306,6 +1317,13 @@ export async function runInProcessTeammate(
}),
setAppState,
)
if (currentAutonomyRunId) {
await markAutonomyRunFailed(currentAutonomyRunId, ERROR_MESSAGE_USER_ABORT)
currentAutonomyRunId = undefined
}
} else if (currentAutonomyRunId) {
await markAutonomyRunCompleted(currentAutonomyRunId)
currentAutonomyRunId = undefined
}
// Check if already idle before updating (to skip duplicate notification)
@@ -1378,6 +1396,7 @@ export async function runInProcessTeammate(
createUserMessage({ content: currentPrompt }),
setAppState,
)
currentAutonomyRunId = undefined
break
case 'new_message':
@@ -1389,6 +1408,7 @@ export async function runInProcessTeammate(
// Messages from other teammates get XML wrapper for identification
if (waitResult.from === 'user') {
currentPrompt = waitResult.message
currentAutonomyRunId = waitResult.autonomyRunId
} else {
currentPrompt = formatAsTeammateMessage(
waitResult.from,
@@ -1404,6 +1424,7 @@ export async function runInProcessTeammate(
createUserMessage({ content: currentPrompt }),
setAppState,
)
currentAutonomyRunId = undefined
}
break
@@ -1459,7 +1480,6 @@ export async function runInProcessTeammate(
summary: identity.agentId,
})
}
unregisterPerfettoAgent(identity.agentId)
return { success: true, messages: allMessages }
} catch (error) {
@@ -1511,6 +1531,9 @@ export async function runInProcessTeammate(
summary: identity.agentId,
})
}
if (currentAutonomyRunId) {
await markAutonomyRunFailed(currentAutonomyRunId, errorMessage)
}
// Send idle notification with failure via file-based mailbox
await sendIdleNotification(

View File

@@ -24,6 +24,7 @@ import type {
TeammateIdentity,
} from '../../tasks/InProcessTeammateTask/types.js'
import { createAbortController } from '../abortController.js'
import { markAutonomyRunFailed } from '../autonomyRuns.js'
import { formatAgentId } from '../agentId.js'
import { registerCleanup } from '../cleanupRegistry.js'
import { logForDebugging } from '../debug.js'
@@ -233,6 +234,7 @@ export function killInProcessTeammate(
let agentId: string | null = null
let toolUseId: string | undefined
let description: string | undefined
let pendingAutonomyRunIds: string[] = []
setAppState((prev: AppState) => {
const task = prev.tasks[taskId]
@@ -252,6 +254,11 @@ export function killInProcessTeammate(
toolUseId = teammateTask.toolUseId
description = teammateTask.description
// Capture pending autonomy run IDs before clearing them
pendingAutonomyRunIds = teammateTask.pendingUserMessages
.map(message => message.autonomyRunId)
.filter((runId): runId is string => runId !== undefined)
// Abort the controller to stop execution
teammateTask.abortController?.abort()
@@ -304,6 +311,12 @@ export function killInProcessTeammate(
}
if (killed) {
for (const runId of pendingAutonomyRunIds) {
void markAutonomyRunFailed(
runId,
`Teammate ${agentId ?? taskId} was stopped before it could consume the queued autonomy prompt.`,
)
}
void evictTaskOutput(taskId)
// notified:true was pre-set so no XML notification fires; close the SDK
// task_started bookend directly. The in-process runner's own

View File

@@ -1,3 +1,78 @@
// Auto-generated stub — replace with real implementation
export const shouldGenerateTaskSummary: () => boolean = () => false;
export const maybeGenerateTaskSummary: (options: Record<string, unknown>) => void = () => {};
import { feature } from 'bun:bundle'
import { isBgSession, updateSessionActivity } from './concurrentSessions.js'
import { logForDebugging } from './debug.js'
/**
* Minimum interval between task summary generations (ms).
* Prevents excessive updates during rapid tool-call loops.
*/
const SUMMARY_INTERVAL_MS = 30_000
let lastSummaryTime = 0
/**
* Whether a task summary should be generated this turn.
* Only generates in bg sessions, and rate-limits to avoid churn.
*/
export function shouldGenerateTaskSummary(): boolean {
if (!feature('BG_SESSIONS')) return false
if (!isBgSession()) return false
const now = Date.now()
return now - lastSummaryTime >= SUMMARY_INTERVAL_MS
}
/**
* Generate a task summary from the current turn's messages and push it
* to the session registry so `claude ps` can display live status.
*
* Fire-and-forget from query.ts — errors are logged, never thrown.
*/
export function maybeGenerateTaskSummary(
options: Record<string, unknown>,
): void {
lastSummaryTime = Date.now()
try {
const messages = options.forkContextMessages as
| Array<{
type: string
message?: { content?: unknown }
}>
| undefined
if (!messages || messages.length === 0) return
// Extract a short status from the most recent assistant message
const lastAssistant = [...messages]
.reverse()
.find(m => m.type === 'assistant')
let status: 'busy' | 'idle' = 'busy'
let waitingFor: string | undefined
if (lastAssistant?.message?.content) {
const content = lastAssistant.message.content
// Check if last block is tool_use
if (Array.isArray(content)) {
const lastBlock = content[content.length - 1] as
| Record<string, unknown>
| undefined
if (lastBlock?.type === 'tool_use') {
status = 'busy'
waitingFor = `tool: ${lastBlock.name || 'unknown'}`
}
}
}
// Fire-and-forget update to session registry
void updateSessionActivity({
status,
waitingFor,
}).catch(err => {
logForDebugging(`[taskSummary] updateSessionActivity failed: ${err}`)
})
} catch (err) {
logForDebugging(`[taskSummary] error: ${err}`)
}
}

View File

@@ -1,3 +1,4 @@
import { existsSync } from 'fs'
import memoize from 'lodash-es/memoize.js'
import * as path from 'path'
import * as pathWin32 from 'path/win32'
@@ -8,17 +9,13 @@ import { memoizeWithLRU } from './memoize.js'
import { getPlatform } from './platform.js'
/**
* Check if a file or directory exists on Windows using the dir command
* @param path - The path to check
* @returns true if the path exists, false otherwise
* Check if a file or directory exists on Windows.
* Uses fs.existsSync instead of `dir` shell command to avoid spawning
* cmd.exe — which can cause brief console window flashes in detached
* or windowsHide child processes.
*/
function checkPathExists(path: string): boolean {
try {
execSync_DEPRECATED(`dir "${path}"`, { stdio: 'pipe' })
return true
} catch {
return false
}
function checkPathExists(filePath: string): boolean {
return existsSync(filePath)
}
/**
@@ -88,6 +85,8 @@ export function setShellIfWindows(): void {
if (getPlatform() === 'windows') {
const gitBashPath = findGitBashPath()
process.env.SHELL = gitBashPath
// Propagate to child processes so they skip filesystem probing
process.env.CLAUDE_CODE_GIT_BASH_PATH = gitBashPath
logForDebugging(`Using bash path: "${gitBashPath}"`)
}
}