mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 12:55:51 +00:00
Feat/integrate lint preview (#285)
* feat: 适配 zed acp 协议 * docs: 完善 acp 文档 * feat: integrate feature branches + daemon/job 命令层级化 + 跨平台后台引擎 Cherry-picked from origin/lint/preview (637c908), excluding lint-only changes. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: correct detectMimeFromBase64 to decode raw bytes from base64 Cherry-picked from origin/lint/preview (ee36954). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: daemon 子进程 spawn 跨平台修复 + CliLaunchSpec 集中化重构 Cherry-picked from origin/lint/preview (c5f52cd), excluding lint-only formatting changes. - 新建 src/utils/cliLaunch.ts: 集中化 CLI 子进程启动层 - 修复 --daemon-worker=kind 等号格式解析 - 修复 daemon/bg fast path 缺少 setShellIfWindows() - 修复 checkPathExists 用 existsSync 替代 execSync('dir') - 7 个 spawn 站点迁移到 CliLaunchSpec Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: merge tsconfig.base.json into tsconfig.json with full compiler options The cherry-pick from637c908dropped jsx/strict/etc settings when removing tsconfig.base.json. This commit restores them in a single tsconfig.json. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: merge tsconfig.base.json into tsconfig.json with full compiler options The cherry-pick from637c908dropped jsx/strict/etc settings when removing tsconfig.base.json. This commit restores them in a single tsconfig.json. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
91
src/__tests__/context.baseline.test.ts
Normal file
91
src/__tests__/context.baseline.test.ts
Normal 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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,3 +0,0 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export {};
|
||||
export const AssistantSessionChooser: (props: Record<string, unknown>) => null = () => null;
|
||||
54
src/assistant/AssistantSessionChooser.tsx
Normal file
54
src/assistant/AssistantSessionChooser.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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 ?? '',
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
344
src/cli/bg.ts
344
src/cli/bg.ts
@@ -1,7 +1,337 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export {};
|
||||
export const psHandler: (args: string[]) => Promise<void> = (async () => {}) as (args: string[]) => Promise<void>;
|
||||
export const logsHandler: (sessionId: string | undefined) => Promise<void> = (async () => {}) as (sessionId: string | undefined) => Promise<void>;
|
||||
export const attachHandler: (sessionId: string | undefined) => Promise<void> = (async () => {}) as (sessionId: string | undefined) => Promise<void>;
|
||||
export const killHandler: (sessionId: string | undefined) => Promise<void> = (async () => {}) as (sessionId: string | undefined) => Promise<void>;
|
||||
export const handleBgFlag: (args: string[]) => Promise<void> = (async () => {}) as (args: string[]) => Promise<void>;
|
||||
import { readdir, readFile, unlink } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { getClaudeConfigHomeDir } from '../utils/envUtils.js'
|
||||
import { isProcessRunning } from '../utils/genericProcessUtils.js'
|
||||
import { jsonParse } from '../utils/slowOperations.js'
|
||||
import { selectEngine } from './bg/engines/index.js'
|
||||
import type { SessionEntry } from './bg/engine.js'
|
||||
|
||||
export type { SessionEntry } from './bg/engine.js'
|
||||
|
||||
function getSessionsDir(): string {
|
||||
return join(getClaudeConfigHomeDir(), 'sessions')
|
||||
}
|
||||
|
||||
export async function listLiveSessions(): Promise<SessionEntry[]> {
|
||||
const dir = getSessionsDir()
|
||||
let files: string[]
|
||||
try {
|
||||
files = await readdir(dir)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
const sessions: SessionEntry[] = []
|
||||
for (const file of files) {
|
||||
if (!/^\d+\.json$/.test(file)) continue
|
||||
const pid = parseInt(file.slice(0, -5), 10)
|
||||
|
||||
if (!isProcessRunning(pid)) {
|
||||
void unlink(join(dir, file)).catch(() => {})
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = await readFile(join(dir, file), 'utf-8')
|
||||
const entry = jsonParse(raw) as SessionEntry
|
||||
sessions.push(entry)
|
||||
} catch {
|
||||
// Corrupt file — skip
|
||||
}
|
||||
}
|
||||
|
||||
return sessions
|
||||
}
|
||||
|
||||
export function findSession(
|
||||
sessions: SessionEntry[],
|
||||
target: string,
|
||||
): SessionEntry | undefined {
|
||||
const asNum = parseInt(target, 10)
|
||||
return sessions.find(
|
||||
s =>
|
||||
s.sessionId === target ||
|
||||
s.pid === asNum ||
|
||||
(s.name && s.name === target),
|
||||
)
|
||||
}
|
||||
|
||||
function formatTime(ts: number): string {
|
||||
return new Date(ts).toLocaleString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the engine type for an existing session.
|
||||
* Backward-compatible: sessions without an `engine` field are inferred
|
||||
* from the presence of `tmuxSessionName`.
|
||||
*/
|
||||
function resolveSessionEngine(session: SessionEntry): 'tmux' | 'detached' {
|
||||
if (session.engine) return session.engine
|
||||
return session.tmuxSessionName ? 'tmux' : 'detached'
|
||||
}
|
||||
|
||||
/**
|
||||
* `claude daemon status` / `claude ps` — list live sessions.
|
||||
*/
|
||||
export async function psHandler(_args: string[]): Promise<void> {
|
||||
const sessions = await listLiveSessions()
|
||||
|
||||
if (sessions.length === 0) {
|
||||
console.log('No active sessions.')
|
||||
return
|
||||
}
|
||||
|
||||
console.log(
|
||||
`${sessions.length} active session${sessions.length > 1 ? 's' : ''}:\n`,
|
||||
)
|
||||
|
||||
for (const s of sessions) {
|
||||
const engineType = resolveSessionEngine(s)
|
||||
const parts: string[] = [
|
||||
` PID: ${s.pid}`,
|
||||
` Kind: ${s.kind}`,
|
||||
` Engine: ${engineType}`,
|
||||
` Session: ${s.sessionId}`,
|
||||
` CWD: ${s.cwd}`,
|
||||
]
|
||||
|
||||
if (s.name) parts.push(` Name: ${s.name}`)
|
||||
if (s.startedAt) parts.push(` Started: ${formatTime(s.startedAt)}`)
|
||||
if (s.status) parts.push(` Status: ${s.status}`)
|
||||
if (s.waitingFor) parts.push(` Waiting for: ${s.waitingFor}`)
|
||||
if (s.bridgeSessionId) parts.push(` Bridge: ${s.bridgeSessionId}`)
|
||||
if (s.tmuxSessionName) parts.push(` Tmux: ${s.tmuxSessionName}`)
|
||||
if (s.logPath) parts.push(` Log: ${s.logPath}`)
|
||||
|
||||
console.log(parts.join('\n'))
|
||||
console.log()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `claude daemon logs <target>` — show logs for a session.
|
||||
*/
|
||||
export async function logsHandler(target: string | undefined): Promise<void> {
|
||||
const sessions = await listLiveSessions()
|
||||
|
||||
if (!target) {
|
||||
if (sessions.length === 0) {
|
||||
console.log('No active sessions.')
|
||||
return
|
||||
}
|
||||
if (sessions.length === 1) {
|
||||
target = sessions[0]!.sessionId
|
||||
} else {
|
||||
console.log('Multiple sessions active. Specify one:')
|
||||
for (const s of sessions) {
|
||||
const label = s.name ? `${s.name} (${s.sessionId})` : s.sessionId
|
||||
console.log(` ${label} PID=${s.pid}`)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const session = findSession(sessions, target)
|
||||
if (!session) {
|
||||
console.error(`Session not found: ${target}`)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
if (!session.logPath) {
|
||||
console.log(`No log path recorded for session ${session.sessionId}`)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await readFile(session.logPath, 'utf-8')
|
||||
process.stdout.write(content)
|
||||
} catch (e) {
|
||||
console.error(`Failed to read log file: ${session.logPath}`)
|
||||
console.error(e instanceof Error ? e.message : String(e))
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `claude daemon attach <target>` — attach to a background session.
|
||||
*
|
||||
* Engine-aware: tmux sessions use tmux attach, detached sessions use log tail.
|
||||
*/
|
||||
export async function attachHandler(target: string | undefined): Promise<void> {
|
||||
const sessions = await listLiveSessions()
|
||||
|
||||
if (!target) {
|
||||
// Find bg sessions (tmux or detached)
|
||||
const bgSessions = sessions.filter(
|
||||
s => s.tmuxSessionName || s.engine === 'detached',
|
||||
)
|
||||
if (bgSessions.length === 0) {
|
||||
console.log(
|
||||
'No background sessions to attach to. Start one with `claude daemon bg`.',
|
||||
)
|
||||
return
|
||||
}
|
||||
if (bgSessions.length === 1) {
|
||||
target = bgSessions[0]!.sessionId
|
||||
} else {
|
||||
console.log('Multiple background sessions. Specify one:')
|
||||
for (const s of bgSessions) {
|
||||
const label = s.name ? `${s.name} (${s.sessionId})` : s.sessionId
|
||||
const engineType = resolveSessionEngine(s)
|
||||
console.log(` ${label} PID=${s.pid} engine=${engineType}`)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const session = findSession(sessions, target)
|
||||
if (!session) {
|
||||
console.error(`Session not found: ${target}`)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
const engineType = resolveSessionEngine(session)
|
||||
|
||||
try {
|
||||
if (engineType === 'tmux') {
|
||||
const { TmuxEngine } = await import('./bg/engines/tmux.js')
|
||||
const tmux = new TmuxEngine()
|
||||
if (!(await tmux.available())) {
|
||||
console.error('tmux is no longer available. Cannot attach to tmux session.')
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
await tmux.attach(session)
|
||||
} else {
|
||||
const { DetachedEngine } = await import('./bg/engines/detached.js')
|
||||
const detached = new DetachedEngine()
|
||||
await detached.attach(session)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e instanceof Error ? e.message : String(e))
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `claude daemon kill <target>` — kill a session.
|
||||
*/
|
||||
export async function killHandler(target: string | undefined): Promise<void> {
|
||||
const sessions = await listLiveSessions()
|
||||
|
||||
if (!target) {
|
||||
if (sessions.length === 0) {
|
||||
console.log('No active sessions to kill.')
|
||||
return
|
||||
}
|
||||
console.log('Specify a session to kill:')
|
||||
for (const s of sessions) {
|
||||
const label = s.name ? `${s.name} (${s.sessionId})` : s.sessionId
|
||||
console.log(` ${label} PID=${s.pid}`)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const session = findSession(sessions, target)
|
||||
if (!session) {
|
||||
console.error(`Session not found: ${target}`)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`Killing session ${session.sessionId} (PID: ${session.pid})...`)
|
||||
|
||||
try {
|
||||
process.kill(session.pid, 'SIGTERM')
|
||||
} catch {
|
||||
console.log('Session already exited.')
|
||||
return
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
|
||||
if (isProcessRunning(session.pid)) {
|
||||
try {
|
||||
process.kill(session.pid, 'SIGKILL')
|
||||
console.log('Session force-killed.')
|
||||
} catch {
|
||||
console.log('Session exited during grace period.')
|
||||
}
|
||||
} else {
|
||||
console.log('Session stopped.')
|
||||
}
|
||||
|
||||
const pidFile = join(getSessionsDir(), `${session.pid}.json`)
|
||||
void unlink(pidFile).catch(() => {})
|
||||
}
|
||||
|
||||
/**
|
||||
* `claude daemon bg [args]` — start a background session.
|
||||
*
|
||||
* Cross-platform: uses TmuxEngine on macOS/Linux when tmux is available,
|
||||
* falls back to DetachedEngine on Windows or when tmux is absent.
|
||||
*/
|
||||
export async function handleBgStart(args: string[]): Promise<void> {
|
||||
const engine = await selectEngine()
|
||||
|
||||
// Strip --bg/--background from args (for backward-compat shortcut)
|
||||
const filteredArgs = args.filter(a => a !== '--bg' && a !== '--background')
|
||||
|
||||
// Engines without interactive TTY input (e.g. detached) require -p/--print
|
||||
// or piped input. Tmux provides a virtual terminal so it works without -p.
|
||||
if (
|
||||
!engine.supportsInteractiveInput &&
|
||||
!filteredArgs.some(a => a === '-p' || a === '--print' || a === '--pipe')
|
||||
) {
|
||||
console.error(
|
||||
'Error: Background sessions with detached engine require -p/--print flag.\n' +
|
||||
'The detached engine has no terminal for interactive input.\n\n' +
|
||||
'Usage:\n' +
|
||||
' claude daemon bg -p "your prompt here"\n' +
|
||||
' echo "prompt" | claude daemon bg --pipe',
|
||||
)
|
||||
if (process.platform !== 'win32') {
|
||||
console.error(
|
||||
'\nAlternatively, install tmux for interactive background sessions:\n' +
|
||||
` ${process.platform === 'darwin' ? 'brew install tmux' : 'sudo apt install tmux'}`,
|
||||
)
|
||||
}
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
const sessionName = `claude-bg-${randomUUID().slice(0, 8)}`
|
||||
const logPath = join(
|
||||
getClaudeConfigHomeDir(),
|
||||
'sessions',
|
||||
'logs',
|
||||
`${sessionName}.log`,
|
||||
)
|
||||
|
||||
try {
|
||||
const result = await engine.start({
|
||||
sessionName,
|
||||
args: filteredArgs,
|
||||
env: { ...process.env },
|
||||
logPath,
|
||||
cwd: process.cwd(),
|
||||
})
|
||||
|
||||
console.log(`Background session started: ${result.sessionName}`)
|
||||
console.log(` Engine: ${result.engineUsed}`)
|
||||
console.log(` Log: ${result.logPath}`)
|
||||
console.log()
|
||||
console.log(`Use \`claude daemon attach ${result.sessionName}\` to reconnect.`)
|
||||
console.log(`Use \`claude daemon status\` to check status.`)
|
||||
console.log(`Use \`claude daemon kill ${result.sessionName}\` to stop.`)
|
||||
} catch (e) {
|
||||
console.error(e instanceof Error ? e.message : String(e))
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy export alias — kept for backward compatibility with cli.tsx
|
||||
export const handleBgFlag = handleBgStart
|
||||
|
||||
15
src/cli/bg/__tests__/detached.test.ts
Normal file
15
src/cli/bg/__tests__/detached.test.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { DetachedEngine } from '../engines/detached.js'
|
||||
|
||||
describe('DetachedEngine', () => {
|
||||
test('name is "detached"', () => {
|
||||
const engine = new DetachedEngine()
|
||||
expect(engine.name).toBe('detached')
|
||||
})
|
||||
|
||||
test('available always returns true', async () => {
|
||||
const engine = new DetachedEngine()
|
||||
const result = await engine.available()
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
})
|
||||
37
src/cli/bg/__tests__/engine.test.ts
Normal file
37
src/cli/bg/__tests__/engine.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
|
||||
describe('selectEngine', () => {
|
||||
test('returns engine with valid BgEngine interface', async () => {
|
||||
const { selectEngine } = await import('../engines/index.js')
|
||||
const engine = await selectEngine()
|
||||
expect(engine.name).toBeDefined()
|
||||
expect(['tmux', 'detached']).toContain(engine.name)
|
||||
expect(typeof engine.available).toBe('function')
|
||||
expect(typeof engine.start).toBe('function')
|
||||
expect(typeof engine.attach).toBe('function')
|
||||
})
|
||||
|
||||
test('engine.available() returns a boolean', async () => {
|
||||
const { selectEngine } = await import('../engines/index.js')
|
||||
const engine = await selectEngine()
|
||||
const result = await engine.available()
|
||||
expect(typeof result).toBe('boolean')
|
||||
})
|
||||
})
|
||||
|
||||
describe('SessionEntry type', () => {
|
||||
test('engine field accepts tmux or detached', async () => {
|
||||
// Verify the module loads and exports the expected interface shape
|
||||
const mod = await import('../engine.js')
|
||||
expect(mod).toBeDefined()
|
||||
const entry = {
|
||||
pid: 123,
|
||||
sessionId: 'test',
|
||||
cwd: '/tmp',
|
||||
startedAt: Date.now(),
|
||||
kind: 'bg',
|
||||
engine: 'detached' as const,
|
||||
}
|
||||
expect(entry.engine).toBe('detached')
|
||||
})
|
||||
})
|
||||
8
src/cli/bg/__tests__/tail.test.ts
Normal file
8
src/cli/bg/__tests__/tail.test.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
|
||||
describe('tailLog', () => {
|
||||
test('module exports tailLog function', async () => {
|
||||
const mod = await import('../tail.js')
|
||||
expect(typeof mod.tailLog).toBe('function')
|
||||
})
|
||||
})
|
||||
49
src/cli/bg/engine.ts
Normal file
49
src/cli/bg/engine.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* BgEngine — cross-platform background session engine abstraction.
|
||||
*
|
||||
* Implementations:
|
||||
* TmuxEngine — macOS/Linux with tmux installed
|
||||
* DetachedEngine — Windows, or macOS/Linux without tmux (fallback)
|
||||
*/
|
||||
|
||||
export interface SessionEntry {
|
||||
pid: number
|
||||
sessionId: string
|
||||
cwd: string
|
||||
startedAt: number
|
||||
kind: string
|
||||
name?: string
|
||||
logPath?: string
|
||||
entrypoint?: string
|
||||
status?: string
|
||||
waitingFor?: string
|
||||
updatedAt?: number
|
||||
bridgeSessionId?: string
|
||||
agent?: string
|
||||
tmuxSessionName?: string
|
||||
engine?: 'tmux' | 'detached'
|
||||
}
|
||||
|
||||
export interface BgStartOptions {
|
||||
sessionName: string
|
||||
args: string[]
|
||||
env: Record<string, string | undefined>
|
||||
logPath: string
|
||||
cwd: string
|
||||
}
|
||||
|
||||
export interface BgStartResult {
|
||||
pid: number
|
||||
sessionName: string
|
||||
logPath: string
|
||||
engineUsed: 'tmux' | 'detached'
|
||||
}
|
||||
|
||||
export interface BgEngine {
|
||||
readonly name: 'tmux' | 'detached'
|
||||
/** Whether the engine provides a TTY for interactive REPL input. */
|
||||
readonly supportsInteractiveInput: boolean
|
||||
available(): Promise<boolean>
|
||||
start(opts: BgStartOptions): Promise<BgStartResult>
|
||||
attach(session: SessionEntry): Promise<void>
|
||||
}
|
||||
54
src/cli/bg/engines/detached.ts
Normal file
54
src/cli/bg/engines/detached.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { closeSync, mkdirSync, openSync } from 'fs'
|
||||
import { dirname } from 'path'
|
||||
import { buildCliLaunch, spawnCli } from '../../../utils/cliLaunch.js'
|
||||
import type { BgEngine, BgStartOptions, BgStartResult, SessionEntry } from '../engine.js'
|
||||
import { tailLog } from '../tail.js'
|
||||
|
||||
export class DetachedEngine implements BgEngine {
|
||||
readonly name = 'detached' as const
|
||||
readonly supportsInteractiveInput = false
|
||||
|
||||
async available(): Promise<boolean> {
|
||||
return true
|
||||
}
|
||||
|
||||
async start(opts: BgStartOptions): Promise<BgStartResult> {
|
||||
mkdirSync(dirname(opts.logPath), { recursive: true })
|
||||
|
||||
const logFd = openSync(opts.logPath, 'a')
|
||||
|
||||
const launch = buildCliLaunch(opts.args, {
|
||||
env: {
|
||||
...opts.env,
|
||||
CLAUDE_CODE_SESSION_KIND: 'bg',
|
||||
CLAUDE_CODE_SESSION_NAME: opts.sessionName,
|
||||
CLAUDE_CODE_SESSION_LOG: opts.logPath,
|
||||
} as NodeJS.ProcessEnv,
|
||||
})
|
||||
|
||||
const child = spawnCli(launch, {
|
||||
detached: true,
|
||||
stdio: ['ignore', logFd, logFd],
|
||||
cwd: opts.cwd,
|
||||
})
|
||||
|
||||
child.unref()
|
||||
closeSync(logFd)
|
||||
|
||||
const pid = child.pid ?? 0
|
||||
|
||||
return {
|
||||
pid,
|
||||
sessionName: opts.sessionName,
|
||||
logPath: opts.logPath,
|
||||
engineUsed: 'detached',
|
||||
}
|
||||
}
|
||||
|
||||
async attach(session: SessionEntry): Promise<void> {
|
||||
if (!session.logPath) {
|
||||
throw new Error(`Session ${session.sessionId} has no log path.`)
|
||||
}
|
||||
await tailLog(session.logPath)
|
||||
}
|
||||
}
|
||||
17
src/cli/bg/engines/index.ts
Normal file
17
src/cli/bg/engines/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export type { BgEngine, BgStartOptions, BgStartResult, SessionEntry } from '../engine.js'
|
||||
|
||||
export async function selectEngine(): Promise<import('../engine.js').BgEngine> {
|
||||
if (process.platform === 'win32') {
|
||||
const { DetachedEngine } = await import('./detached.js')
|
||||
return new DetachedEngine()
|
||||
}
|
||||
|
||||
const { TmuxEngine } = await import('./tmux.js')
|
||||
const tmux = new TmuxEngine()
|
||||
if (await tmux.available()) {
|
||||
return tmux
|
||||
}
|
||||
|
||||
const { DetachedEngine } = await import('./detached.js')
|
||||
return new DetachedEngine()
|
||||
}
|
||||
75
src/cli/bg/engines/tmux.ts
Normal file
75
src/cli/bg/engines/tmux.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { spawnSync } from 'child_process'
|
||||
import { execFileNoThrow } from '../../../utils/execFileNoThrow.js'
|
||||
import { buildCliLaunch, quoteCliLaunch } from '../../../utils/cliLaunch.js'
|
||||
import type { BgEngine, BgStartOptions, BgStartResult, SessionEntry } from '../engine.js'
|
||||
|
||||
export class TmuxEngine implements BgEngine {
|
||||
readonly name = 'tmux' as const
|
||||
readonly supportsInteractiveInput = true
|
||||
|
||||
async available(): Promise<boolean> {
|
||||
const { code } = await execFileNoThrow('tmux', ['-V'], { useCwd: false })
|
||||
return code === 0
|
||||
}
|
||||
|
||||
async start(opts: BgStartOptions): Promise<BgStartResult> {
|
||||
const launch = buildCliLaunch(opts.args, {
|
||||
env: {
|
||||
...opts.env,
|
||||
CLAUDE_CODE_SESSION_KIND: 'bg',
|
||||
CLAUDE_CODE_SESSION_NAME: opts.sessionName,
|
||||
CLAUDE_CODE_SESSION_LOG: opts.logPath,
|
||||
CLAUDE_CODE_TMUX_SESSION: opts.sessionName,
|
||||
} as NodeJS.ProcessEnv,
|
||||
})
|
||||
|
||||
const cmd = quoteCliLaunch(launch)
|
||||
|
||||
const result = spawnSync(
|
||||
'tmux',
|
||||
['new-session', '-d', '-s', opts.sessionName, cmd],
|
||||
{ stdio: 'inherit', env: launch.env },
|
||||
)
|
||||
|
||||
if (result.status !== 0) {
|
||||
throw new Error('Failed to create tmux session.')
|
||||
}
|
||||
|
||||
// tmux doesn't directly report the child PID; we return 0.
|
||||
// The actual session process writes its own PID file.
|
||||
return {
|
||||
pid: 0,
|
||||
sessionName: opts.sessionName,
|
||||
logPath: opts.logPath,
|
||||
engineUsed: 'tmux',
|
||||
}
|
||||
}
|
||||
|
||||
async attach(session: SessionEntry): Promise<void> {
|
||||
if (!session.tmuxSessionName) {
|
||||
throw new Error(`Session ${session.sessionId} has no tmux session name.`)
|
||||
}
|
||||
|
||||
const result = spawnSync(
|
||||
'tmux',
|
||||
['attach-session', '-t', session.tmuxSessionName],
|
||||
{ stdio: 'inherit' },
|
||||
)
|
||||
|
||||
if (result.status !== 0) {
|
||||
throw new Error(
|
||||
`Failed to attach to tmux session '${session.tmuxSessionName}'.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getTmuxInstallHint(): string {
|
||||
if (process.platform === 'darwin') {
|
||||
return 'Install with: brew install tmux'
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
return 'tmux is not natively available on Windows. Consider using WSL.'
|
||||
}
|
||||
return 'Install with: sudo apt install tmux (or your package manager)'
|
||||
}
|
||||
70
src/cli/bg/tail.ts
Normal file
70
src/cli/bg/tail.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import {
|
||||
openSync,
|
||||
readSync,
|
||||
closeSync,
|
||||
statSync,
|
||||
watchFile,
|
||||
unwatchFile,
|
||||
createReadStream,
|
||||
} from 'fs'
|
||||
import { createInterface } from 'readline'
|
||||
|
||||
/**
|
||||
* Cross-platform real-time log output. Ctrl+C exits tail without killing
|
||||
* the background process.
|
||||
*
|
||||
* Strategy:
|
||||
* 1. Read existing content and output to stdout
|
||||
* 2. Use fs.watchFile() (polling-based — works everywhere including Windows)
|
||||
* 3. On change, read new bytes from the last known position
|
||||
* 4. SIGINT exits cleanly
|
||||
*/
|
||||
export async function tailLog(logPath: string): Promise<void> {
|
||||
let position = 0
|
||||
|
||||
// Output existing content
|
||||
try {
|
||||
const stat = statSync(logPath)
|
||||
position = stat.size
|
||||
if (position > 0) {
|
||||
const stream = createReadStream(logPath, { start: 0, end: position - 1 })
|
||||
const rl = createInterface({ input: stream })
|
||||
for await (const line of rl) {
|
||||
process.stdout.write(line + '\n')
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// File may not exist yet — that's fine
|
||||
}
|
||||
|
||||
console.log('\n[tail] Watching for new output... (Ctrl+C to detach)\n')
|
||||
|
||||
return new Promise<void>(resolve => {
|
||||
const onSignal = (): void => {
|
||||
unwatchFile(logPath)
|
||||
process.removeListener('SIGINT', onSignal)
|
||||
console.log('\n[tail] Detached from session.')
|
||||
resolve()
|
||||
}
|
||||
process.on('SIGINT', onSignal)
|
||||
|
||||
watchFile(logPath, { interval: 300 }, () => {
|
||||
try {
|
||||
const stat = statSync(logPath)
|
||||
if (stat.size <= position) return
|
||||
|
||||
const fd = openSync(logPath, 'r')
|
||||
try {
|
||||
const buf = Buffer.alloc(stat.size - position)
|
||||
readSync(fd, buf, 0, buf.length, position)
|
||||
process.stdout.write(buf)
|
||||
position = stat.size
|
||||
} finally {
|
||||
closeSync(fd)
|
||||
}
|
||||
} catch {
|
||||
// File may have been deleted or truncated
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1,13 +1,216 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
import type { Command } from '@commander-js/extra-typings';
|
||||
import type { Command } from '@commander-js/extra-typings'
|
||||
import {
|
||||
createTask,
|
||||
getTask,
|
||||
updateTask,
|
||||
listTasks,
|
||||
getTasksDir,
|
||||
} from '../../utils/tasks.js'
|
||||
import { getRecentActivity } from '../../utils/logoV2Utils.js'
|
||||
import type { LogOption } from '../../types/logs.js'
|
||||
|
||||
export {};
|
||||
export const logHandler: (logId: string | number | undefined) => Promise<void> = (async () => {}) as (logId: string | number | undefined) => Promise<void>;
|
||||
export const errorHandler: (num: number | undefined) => Promise<void> = (async () => {}) as (num: number | undefined) => Promise<void>;
|
||||
export const exportHandler: (source: string, outputFile: string) => Promise<void> = (async () => {}) as (source: string, outputFile: string) => Promise<void>;
|
||||
export const taskCreateHandler: (subject: string, opts: { description?: string; list?: string }) => Promise<void> = (async () => {}) as (subject: string, opts: { description?: string; list?: string }) => Promise<void>;
|
||||
export const taskListHandler: (opts: { list?: string; pending?: boolean; json?: boolean }) => Promise<void> = (async () => {}) as (opts: { list?: string; pending?: boolean; json?: boolean }) => Promise<void>;
|
||||
export const taskGetHandler: (id: string, opts: { list?: string }) => Promise<void> = (async () => {}) as (id: string, opts: { list?: string }) => Promise<void>;
|
||||
export const taskUpdateHandler: (id: string, opts: { list?: string; status?: string; subject?: string; description?: string; owner?: string; clearOwner?: boolean }) => Promise<void> = (async () => {}) as (id: string, opts: { list?: string; status?: string; subject?: string; description?: string; owner?: string; clearOwner?: boolean }) => Promise<void>;
|
||||
export const taskDirHandler: (opts: { list?: string }) => Promise<void> = (async () => {}) as (opts: { list?: string }) => Promise<void>;
|
||||
export const completionHandler: (shell: string, opts: { output?: string }, program: Command) => Promise<void> = (async () => {}) as (shell: string, opts: { output?: string }, program: Command) => Promise<void>;
|
||||
const DEFAULT_LIST = 'default'
|
||||
|
||||
// ─── Group C: Task CRUD ──────────────────────────────────────────────────────
|
||||
|
||||
export async function taskCreateHandler(
|
||||
subject: string,
|
||||
opts: { description?: string; list?: string },
|
||||
): Promise<void> {
|
||||
const listId = opts.list || DEFAULT_LIST
|
||||
const id = await createTask(listId, {
|
||||
subject,
|
||||
description: opts.description || '',
|
||||
status: 'pending',
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
})
|
||||
console.log(`Created task ${id}: ${subject}`)
|
||||
}
|
||||
|
||||
export async function taskListHandler(opts: {
|
||||
list?: string
|
||||
pending?: boolean
|
||||
json?: boolean
|
||||
}): Promise<void> {
|
||||
const listId = opts.list || DEFAULT_LIST
|
||||
let tasks = await listTasks(listId)
|
||||
|
||||
if (opts.pending) {
|
||||
tasks = tasks.filter(t => t.status === 'pending')
|
||||
}
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(tasks, null, 2))
|
||||
return
|
||||
}
|
||||
|
||||
if (tasks.length === 0) {
|
||||
console.log('No tasks found.')
|
||||
return
|
||||
}
|
||||
|
||||
for (const t of tasks) {
|
||||
console.log(` [${t.status}] ${t.id}: ${t.subject}`)
|
||||
if (t.description) console.log(` ${t.description}`)
|
||||
if (t.owner) console.log(` owner: ${t.owner}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function taskGetHandler(
|
||||
id: string,
|
||||
opts: { list?: string },
|
||||
): Promise<void> {
|
||||
const listId = opts.list || DEFAULT_LIST
|
||||
const task = await getTask(listId, id)
|
||||
if (!task) {
|
||||
console.error(`Task not found: ${id}`)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
console.log(JSON.stringify(task, null, 2))
|
||||
}
|
||||
|
||||
export async function taskUpdateHandler(
|
||||
id: string,
|
||||
opts: {
|
||||
list?: string
|
||||
status?: string
|
||||
subject?: string
|
||||
description?: string
|
||||
owner?: string
|
||||
clearOwner?: boolean
|
||||
},
|
||||
): Promise<void> {
|
||||
const listId = opts.list || DEFAULT_LIST
|
||||
const updates: Record<string, unknown> = {}
|
||||
|
||||
if (opts.status) updates.status = opts.status
|
||||
if (opts.subject) updates.subject = opts.subject
|
||||
if (opts.description) updates.description = opts.description
|
||||
if (opts.owner) updates.owner = opts.owner
|
||||
if (opts.clearOwner) updates.owner = undefined
|
||||
|
||||
const task = await updateTask(listId, id, updates)
|
||||
if (!task) {
|
||||
console.error(`Task not found: ${id}`)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
console.log(`Updated task ${id}: [${task.status}] ${task.subject}`)
|
||||
}
|
||||
|
||||
export async function taskDirHandler(opts: { list?: string }): Promise<void> {
|
||||
const listId = opts.list || DEFAULT_LIST
|
||||
console.log(getTasksDir(listId))
|
||||
}
|
||||
|
||||
// ─── Group B: Log / Error / Export ───────────────────────────────────────────
|
||||
|
||||
export async function logHandler(
|
||||
logId: string | number | undefined,
|
||||
): Promise<void> {
|
||||
const logs = await getRecentActivity()
|
||||
|
||||
if (logId === undefined) {
|
||||
if (logs.length === 0) {
|
||||
console.log('No recent sessions.')
|
||||
return
|
||||
}
|
||||
for (let i = 0; i < Math.min(logs.length, 20); i++) {
|
||||
const log = logs[i]!
|
||||
const date = log.modified
|
||||
? new Date(log.modified).toLocaleString()
|
||||
: 'unknown'
|
||||
const title =
|
||||
(log as Record<string, unknown>).title || log.sessionId || 'untitled'
|
||||
console.log(` ${i}: ${title} (${date})`)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const idx = typeof logId === 'string' ? parseInt(logId, 10) : logId
|
||||
const log =
|
||||
Number.isFinite(idx) && idx >= 0 && idx < logs.length
|
||||
? logs[idx]
|
||||
: logs.find(l => l.sessionId === String(logId))
|
||||
|
||||
if (!log) {
|
||||
console.error(`Session not found: ${logId}`)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(log, null, 2))
|
||||
}
|
||||
|
||||
export async function errorHandler(num: number | undefined): Promise<void> {
|
||||
// Error log viewing — shows recent session errors
|
||||
const logs = await getRecentActivity()
|
||||
const count = num ?? 5
|
||||
|
||||
console.log(`Last ${count} sessions:`)
|
||||
for (let i = 0; i < Math.min(count, logs.length); i++) {
|
||||
const log = logs[i]!
|
||||
const date = log.modified
|
||||
? new Date(log.modified).toLocaleString()
|
||||
: 'unknown'
|
||||
console.log(` ${i}: ${log.sessionId} (${date})`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function exportHandler(
|
||||
source: string,
|
||||
outputFile: string,
|
||||
): Promise<void> {
|
||||
const { writeFile, readFile } = await import('fs/promises')
|
||||
const logs = await getRecentActivity()
|
||||
|
||||
// Try as index first
|
||||
const idx = parseInt(source, 10)
|
||||
let log: LogOption | undefined
|
||||
if (Number.isFinite(idx) && idx >= 0 && idx < logs.length) {
|
||||
log = logs[idx]
|
||||
} else {
|
||||
log = logs.find(l => l.sessionId === source)
|
||||
}
|
||||
|
||||
if (!log) {
|
||||
// Try as file path
|
||||
try {
|
||||
const content = await readFile(source, 'utf-8')
|
||||
await writeFile(outputFile, content, 'utf-8')
|
||||
console.log(`Exported ${source} → ${outputFile}`)
|
||||
return
|
||||
} catch {
|
||||
console.error(`Source not found: ${source}`)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
await writeFile(outputFile, JSON.stringify(log, null, 2), 'utf-8')
|
||||
console.log(`Exported session ${log.sessionId} → ${outputFile}`)
|
||||
}
|
||||
|
||||
// ─── Group D: Completion ─────────────────────────────────────────────────────
|
||||
|
||||
export async function completionHandler(
|
||||
shell: string,
|
||||
opts: { output?: string },
|
||||
_program: Command,
|
||||
): Promise<void> {
|
||||
const { regenerateCompletionCache } = await import(
|
||||
'../../utils/completionCache.js'
|
||||
)
|
||||
|
||||
if (opts.output) {
|
||||
// Generate and write to file
|
||||
await regenerateCompletionCache()
|
||||
console.log(`Completion cache regenerated for ${shell}.`)
|
||||
} else {
|
||||
// Regenerate and output to stdout
|
||||
await regenerateCompletionCache()
|
||||
console.log(`Completion cache regenerated for ${shell}.`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,158 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export {};
|
||||
export const templatesMain: (args: string[]) => Promise<void> = () => Promise.resolve();
|
||||
import { randomUUID } from 'crypto'
|
||||
import { listTemplates, loadTemplate } from '../../jobs/templates.js'
|
||||
import {
|
||||
createJob,
|
||||
readJobState,
|
||||
appendJobReply,
|
||||
getJobDir,
|
||||
} from '../../jobs/state.js'
|
||||
|
||||
/**
|
||||
* Entry point for template job commands: `new`, `list`, `reply`.
|
||||
* Called from cli.tsx fast-path.
|
||||
*/
|
||||
export async function templatesMain(args: string[]): Promise<void> {
|
||||
const subcommand = args[0]
|
||||
|
||||
switch (subcommand) {
|
||||
case 'list':
|
||||
handleList()
|
||||
break
|
||||
case 'new':
|
||||
handleNew(args.slice(1))
|
||||
break
|
||||
case 'reply':
|
||||
handleReply(args.slice(1))
|
||||
break
|
||||
case 'status':
|
||||
handleStatus(args.slice(1))
|
||||
break
|
||||
default:
|
||||
console.error(`Unknown template command: ${subcommand}`)
|
||||
printUsage()
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
|
||||
function printUsage(): void {
|
||||
console.log(`
|
||||
Template Job Commands:
|
||||
|
||||
claude job list List available templates
|
||||
claude job new <template> [args] Create a new job from a template
|
||||
claude job reply <job-id> <text> Reply to an existing job
|
||||
claude job status <job-id> Show job status
|
||||
`)
|
||||
}
|
||||
|
||||
function handleStatus(args: string[]): void {
|
||||
const jobId = args[0]
|
||||
if (!jobId) {
|
||||
console.error('Usage: claude job status <job-id>')
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
const state = readJobState(jobId)
|
||||
if (!state) {
|
||||
console.error(`Job not found: ${jobId}`)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`Job: ${state.jobId}`)
|
||||
console.log(` Template: ${state.templateName}`)
|
||||
console.log(` Status: ${state.status}`)
|
||||
console.log(` Created: ${state.createdAt}`)
|
||||
console.log(` Updated: ${state.updatedAt}`)
|
||||
console.log(` Args: ${state.args.join(' ') || '(none)'}`)
|
||||
}
|
||||
|
||||
function handleList(): void {
|
||||
const templates = listTemplates()
|
||||
|
||||
if (templates.length === 0) {
|
||||
console.log('No templates found.')
|
||||
console.log('Place .md files in .claude/templates/ or ~/.claude/templates/')
|
||||
return
|
||||
}
|
||||
|
||||
console.log(
|
||||
`${templates.length} template${templates.length > 1 ? 's' : ''} found:\n`,
|
||||
)
|
||||
|
||||
for (const t of templates) {
|
||||
console.log(` ${t.name}`)
|
||||
console.log(` ${t.description}`)
|
||||
console.log(` Path: ${t.filePath}`)
|
||||
console.log()
|
||||
}
|
||||
}
|
||||
|
||||
function handleNew(args: string[]): void {
|
||||
const templateName = args[0]
|
||||
if (!templateName) {
|
||||
console.error('Usage: claude job new <template> [args...]')
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
const template = loadTemplate(templateName)
|
||||
if (!template) {
|
||||
console.error(`Template not found: ${templateName}`)
|
||||
console.log('\nAvailable templates:')
|
||||
for (const t of listTemplates()) {
|
||||
console.log(` ${t.name}`)
|
||||
}
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
const jobId = randomUUID().slice(0, 8)
|
||||
const inputText = args.slice(1).join(' ')
|
||||
const rawContent = `---\n${Object.entries(template.frontmatter)
|
||||
.map(([k, v]) => `${k}: ${v}`)
|
||||
.join('\n')}\n---\n${template.content}`
|
||||
|
||||
const dir = createJob(
|
||||
jobId,
|
||||
templateName,
|
||||
rawContent,
|
||||
inputText,
|
||||
args.slice(1),
|
||||
)
|
||||
|
||||
console.log(`Job created: ${jobId}`)
|
||||
console.log(` Template: ${templateName}`)
|
||||
console.log(` Directory: ${dir}`)
|
||||
if (inputText) {
|
||||
console.log(` Input: ${inputText}`)
|
||||
}
|
||||
}
|
||||
|
||||
function handleReply(args: string[]): void {
|
||||
const jobId = args[0]
|
||||
const text = args.slice(1).join(' ')
|
||||
|
||||
if (!jobId || !text) {
|
||||
console.error('Usage: claude job reply <job-id> <text>')
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
const state = readJobState(jobId)
|
||||
if (!state) {
|
||||
console.error(`Job not found: ${jobId}`)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
const ok = appendJobReply(jobId, text)
|
||||
if (ok) {
|
||||
console.log(`Reply added to job ${jobId}`)
|
||||
console.log(` Directory: ${getJobDir(jobId)}`)
|
||||
} else {
|
||||
console.error(`Failed to append reply to job ${jobId}`)
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
|
||||
456
src/cli/print.ts
456
src/cli/print.ts
@@ -320,6 +320,17 @@ import {
|
||||
logQueryProfileReport,
|
||||
} from 'src/utils/queryProfiler.js'
|
||||
import { asSessionId } from 'src/types/ids.js'
|
||||
import {
|
||||
commitAutonomyQueuedPrompt,
|
||||
createAutonomyQueuedPrompt,
|
||||
createProactiveAutonomyCommands,
|
||||
finalizeAutonomyRunCompleted,
|
||||
finalizeAutonomyRunFailed,
|
||||
markAutonomyRunCompleted,
|
||||
markAutonomyRunFailed,
|
||||
markAutonomyRunRunning,
|
||||
} from 'src/utils/autonomyRuns.js'
|
||||
import { prepareAutonomyTurnPrompt } from 'src/utils/autonomyAuthority.js'
|
||||
import { jsonStringify } from '../utils/slowOperations.js'
|
||||
import { skillChangeDetector } from '../utils/skills/skillChangeDetector.js'
|
||||
import { getCommands, clearCommandsCache } from '../commands.js'
|
||||
@@ -362,9 +373,12 @@ const proactiveModule =
|
||||
feature('PROACTIVE') || feature('KAIROS')
|
||||
? (require('../proactive/index.js') as typeof import('../proactive/index.js'))
|
||||
: null
|
||||
const cronSchedulerModule = require('../utils/cronScheduler.js') as typeof import('../utils/cronScheduler.js')
|
||||
const cronJitterConfigModule = require('../utils/cronJitterConfig.js') as typeof import('../utils/cronJitterConfig.js')
|
||||
const cronGate = require('@claude-code-best/builtin-tools/tools/ScheduleCronTool/prompt.js') as typeof import('@claude-code-best/builtin-tools/tools/ScheduleCronTool/prompt.js')
|
||||
const cronSchedulerModule =
|
||||
require('../utils/cronScheduler.js') as typeof import('../utils/cronScheduler.js')
|
||||
const cronJitterConfigModule =
|
||||
require('../utils/cronJitterConfig.js') as typeof import('../utils/cronJitterConfig.js')
|
||||
const cronGate =
|
||||
require('@claude-code-best/builtin-tools/tools/ScheduleCronTool/prompt.js') as typeof import('@claude-code-best/builtin-tools/tools/ScheduleCronTool/prompt.js')
|
||||
const extractMemoriesModule = feature('EXTRACT_MEMORIES')
|
||||
? (require('../services/extractMemories/extractMemories.js') as typeof import('../services/extractMemories/extractMemories.js'))
|
||||
: null
|
||||
@@ -1180,7 +1194,9 @@ function runHeadlessStreaming(
|
||||
removeInterruptedMessage(mutableMessages, turnInterruptionState.message)
|
||||
enqueue({
|
||||
mode: 'prompt',
|
||||
value: turnInterruptionState.message.message!.content as string | ContentBlockParam[],
|
||||
value: turnInterruptionState.message.message!.content as
|
||||
| string
|
||||
| ContentBlockParam[],
|
||||
uuid: randomUUID(),
|
||||
})
|
||||
}
|
||||
@@ -1642,7 +1658,10 @@ function runHeadlessStreaming(
|
||||
connection.config.type === 'stdio' ||
|
||||
connection.config.type === undefined
|
||||
) {
|
||||
const stdioConfig = connection.config as { command: string; args: string[] }
|
||||
const stdioConfig = connection.config as {
|
||||
command: string
|
||||
args: string[]
|
||||
}
|
||||
config = {
|
||||
type: 'stdio' as const,
|
||||
command: stdioConfig.command,
|
||||
@@ -1804,7 +1823,8 @@ function runHeadlessStreaming(
|
||||
}
|
||||
for (const [name, config] of Object.entries(sdkMcpConfigs)) {
|
||||
if (config.type === 'sdk' && !(name in supportedConfigs)) {
|
||||
supportedConfigs[name] = config as unknown as McpServerConfigForProcessTransport
|
||||
supportedConfigs[name] =
|
||||
config as unknown as McpServerConfigForProcessTransport
|
||||
}
|
||||
}
|
||||
const { response, sdkServersChanged } =
|
||||
@@ -1839,15 +1859,23 @@ function runHeadlessStreaming(
|
||||
) {
|
||||
return
|
||||
}
|
||||
const tickContent = `<${TICK_TAG}>${new Date().toLocaleTimeString()}</${TICK_TAG}>`
|
||||
enqueue({
|
||||
mode: 'prompt' as const,
|
||||
value: tickContent,
|
||||
uuid: randomUUID(),
|
||||
priority: 'later',
|
||||
isMeta: true,
|
||||
})
|
||||
void run()
|
||||
void (async () => {
|
||||
const commands = await createProactiveAutonomyCommands({
|
||||
basePrompt: `<${TICK_TAG}>${new Date().toLocaleTimeString()}</${TICK_TAG}>`,
|
||||
currentDir: cwd(),
|
||||
shouldCreate: () => !inputClosed,
|
||||
})
|
||||
for (const command of commands) {
|
||||
if (inputClosed) {
|
||||
return
|
||||
}
|
||||
enqueue({
|
||||
...command,
|
||||
uuid: randomUUID(),
|
||||
})
|
||||
}
|
||||
void run()
|
||||
})()
|
||||
}, 0)
|
||||
}
|
||||
: undefined
|
||||
@@ -2092,6 +2120,9 @@ function runHeadlessStreaming(
|
||||
}
|
||||
|
||||
const input = command.value
|
||||
const autonomyRunIds = batch
|
||||
.map(item => item.autonomy?.runId)
|
||||
.filter((runId): runId is string => Boolean(runId))
|
||||
|
||||
if (structuredIO instanceof RemoteIO && command.mode === 'prompt') {
|
||||
logEvent('tengu_bridge_message_received', {
|
||||
@@ -2141,107 +2172,151 @@ function runHeadlessStreaming(
|
||||
// const-capture: TS loses `while ((command = dequeue()))` narrowing
|
||||
// inside the closure.
|
||||
const cmd = command
|
||||
await runWithWorkload(cmd.workload ?? options.workload, async () => {
|
||||
for await (const message of ask({
|
||||
commands: uniqBy(
|
||||
[...currentCommands, ...appState.mcp.commands],
|
||||
'name',
|
||||
),
|
||||
prompt: input,
|
||||
promptUuid: cmd.uuid,
|
||||
isMeta: cmd.isMeta,
|
||||
cwd: cwd(),
|
||||
tools: allTools,
|
||||
verbose: options.verbose,
|
||||
mcpClients: allMcpClients,
|
||||
thinkingConfig: options.thinkingConfig,
|
||||
maxTurns: options.maxTurns,
|
||||
maxBudgetUsd: options.maxBudgetUsd,
|
||||
taskBudget: options.taskBudget,
|
||||
canUseTool,
|
||||
userSpecifiedModel: activeUserSpecifiedModel,
|
||||
fallbackModel: options.fallbackModel,
|
||||
jsonSchema: getInitJsonSchema() ?? options.jsonSchema,
|
||||
mutableMessages,
|
||||
getReadFileCache: () =>
|
||||
pendingSeeds.size === 0
|
||||
? readFileState
|
||||
: mergeFileStateCaches(readFileState, pendingSeeds),
|
||||
setReadFileCache: cache => {
|
||||
readFileState = cache
|
||||
for (const [path, seed] of pendingSeeds.entries()) {
|
||||
const existing = readFileState.get(path)
|
||||
if (!existing || seed.timestamp > existing.timestamp) {
|
||||
readFileState.set(path, seed)
|
||||
for (const runId of autonomyRunIds) {
|
||||
await markAutonomyRunRunning(runId)
|
||||
}
|
||||
let lastResultIsError = false
|
||||
try {
|
||||
await runWithWorkload(
|
||||
cmd.workload ?? options.workload,
|
||||
async () => {
|
||||
for await (const message of ask({
|
||||
commands: uniqBy(
|
||||
[...currentCommands, ...appState.mcp.commands],
|
||||
'name',
|
||||
),
|
||||
prompt: input,
|
||||
promptUuid: cmd.uuid,
|
||||
isMeta: cmd.isMeta,
|
||||
cwd: cwd(),
|
||||
tools: allTools,
|
||||
verbose: options.verbose,
|
||||
mcpClients: allMcpClients,
|
||||
thinkingConfig: options.thinkingConfig,
|
||||
maxTurns: options.maxTurns,
|
||||
maxBudgetUsd: options.maxBudgetUsd,
|
||||
taskBudget: options.taskBudget,
|
||||
canUseTool,
|
||||
userSpecifiedModel: activeUserSpecifiedModel,
|
||||
fallbackModel: options.fallbackModel,
|
||||
jsonSchema: getInitJsonSchema() ?? options.jsonSchema,
|
||||
mutableMessages,
|
||||
getReadFileCache: () =>
|
||||
pendingSeeds.size === 0
|
||||
? readFileState
|
||||
: mergeFileStateCaches(readFileState, pendingSeeds),
|
||||
setReadFileCache: cache => {
|
||||
readFileState = cache
|
||||
for (const [path, seed] of pendingSeeds.entries()) {
|
||||
const existing = readFileState.get(path)
|
||||
if (!existing || seed.timestamp > existing.timestamp) {
|
||||
readFileState.set(path, seed)
|
||||
}
|
||||
}
|
||||
pendingSeeds.clear()
|
||||
},
|
||||
customSystemPrompt: options.systemPrompt,
|
||||
appendSystemPrompt: options.appendSystemPrompt,
|
||||
getAppState,
|
||||
setAppState,
|
||||
abortController,
|
||||
replayUserMessages: options.replayUserMessages,
|
||||
includePartialMessages: options.includePartialMessages,
|
||||
handleElicitation: (serverName, params, elicitSignal) =>
|
||||
structuredIO.handleElicitation(
|
||||
serverName,
|
||||
params.message,
|
||||
undefined,
|
||||
elicitSignal,
|
||||
params.mode,
|
||||
params.url,
|
||||
'elicitationId' in params
|
||||
? params.elicitationId
|
||||
: undefined,
|
||||
),
|
||||
agents: currentAgents,
|
||||
orphanedPermission: cmd.orphanedPermission,
|
||||
setSDKStatus: status => {
|
||||
output.enqueue({
|
||||
type: 'system',
|
||||
subtype: 'status',
|
||||
status: status as 'compacting' | null,
|
||||
session_id: getSessionId(),
|
||||
uuid: randomUUID(),
|
||||
})
|
||||
},
|
||||
})) {
|
||||
// Forward messages to bridge incrementally (mid-turn) so
|
||||
// claude.ai sees progress and the connection stays alive
|
||||
// while blocked on permission requests.
|
||||
forwardMessagesToBridge()
|
||||
|
||||
if (message.type === 'result') {
|
||||
lastResultIsError = !!(message as Record<string, unknown>)
|
||||
.is_error
|
||||
// Flush pending SDK events so they appear before result on the stream.
|
||||
for (const event of drainSdkEvents()) {
|
||||
output.enqueue(event)
|
||||
}
|
||||
|
||||
// Hold-back: don't emit result while background agents are running
|
||||
const currentState = getAppState()
|
||||
if (
|
||||
getRunningTasks(currentState).some(
|
||||
t =>
|
||||
(t.type === 'local_agent' ||
|
||||
t.type === 'local_workflow') &&
|
||||
isBackgroundTask(t),
|
||||
)
|
||||
) {
|
||||
heldBackResult = message as StdoutMessage
|
||||
} else {
|
||||
heldBackResult = null
|
||||
output.enqueue(message as StdoutMessage)
|
||||
}
|
||||
} else {
|
||||
// Flush SDK events (task_started, task_progress) so background
|
||||
// agent progress is streamed in real-time, not batched until result.
|
||||
for (const event of drainSdkEvents()) {
|
||||
output.enqueue(event)
|
||||
}
|
||||
output.enqueue(message as StdoutMessage)
|
||||
}
|
||||
}
|
||||
pendingSeeds.clear()
|
||||
},
|
||||
customSystemPrompt: options.systemPrompt,
|
||||
appendSystemPrompt: options.appendSystemPrompt,
|
||||
getAppState,
|
||||
setAppState,
|
||||
abortController,
|
||||
replayUserMessages: options.replayUserMessages,
|
||||
includePartialMessages: options.includePartialMessages,
|
||||
handleElicitation: (serverName, params, elicitSignal) =>
|
||||
structuredIO.handleElicitation(
|
||||
serverName,
|
||||
params.message,
|
||||
undefined,
|
||||
elicitSignal,
|
||||
params.mode,
|
||||
params.url,
|
||||
'elicitationId' in params ? params.elicitationId : undefined,
|
||||
),
|
||||
agents: currentAgents,
|
||||
orphanedPermission: cmd.orphanedPermission,
|
||||
setSDKStatus: status => {
|
||||
output.enqueue({
|
||||
type: 'system',
|
||||
subtype: 'status',
|
||||
status: status as 'compacting' | null,
|
||||
session_id: getSessionId(),
|
||||
uuid: randomUUID(),
|
||||
) // end runWithWorkload
|
||||
if (lastResultIsError) {
|
||||
for (const runId of autonomyRunIds) {
|
||||
await finalizeAutonomyRunFailed({
|
||||
runId,
|
||||
error: 'ask() returned an error result',
|
||||
})
|
||||
},
|
||||
})) {
|
||||
// Forward messages to bridge incrementally (mid-turn) so
|
||||
// claude.ai sees progress and the connection stays alive
|
||||
// while blocked on permission requests.
|
||||
forwardMessagesToBridge()
|
||||
|
||||
if (message.type === 'result') {
|
||||
// Flush pending SDK events so they appear before result on the stream.
|
||||
for (const event of drainSdkEvents()) {
|
||||
output.enqueue(event)
|
||||
}
|
||||
} else {
|
||||
for (const runId of autonomyRunIds) {
|
||||
const nextCommands = await finalizeAutonomyRunCompleted({
|
||||
runId,
|
||||
currentDir: cwd(),
|
||||
priority: 'later',
|
||||
workload: cmd.workload ?? options.workload,
|
||||
})
|
||||
for (const nextCommand of nextCommands) {
|
||||
enqueue({
|
||||
...nextCommand,
|
||||
uuid: randomUUID(),
|
||||
})
|
||||
}
|
||||
|
||||
// Hold-back: don't emit result while background agents are running
|
||||
const currentState = getAppState()
|
||||
if (
|
||||
getRunningTasks(currentState).some(
|
||||
t =>
|
||||
(t.type === 'local_agent' ||
|
||||
t.type === 'local_workflow') &&
|
||||
isBackgroundTask(t),
|
||||
)
|
||||
) {
|
||||
heldBackResult = message as StdoutMessage
|
||||
} else {
|
||||
heldBackResult = null
|
||||
output.enqueue(message as StdoutMessage)
|
||||
}
|
||||
} else {
|
||||
// Flush SDK events (task_started, task_progress) so background
|
||||
// agent progress is streamed in real-time, not batched until result.
|
||||
for (const event of drainSdkEvents()) {
|
||||
output.enqueue(event)
|
||||
}
|
||||
output.enqueue(message as StdoutMessage)
|
||||
}
|
||||
}
|
||||
}) // end runWithWorkload
|
||||
} catch (error) {
|
||||
for (const runId of autonomyRunIds) {
|
||||
await finalizeAutonomyRunFailed({
|
||||
runId,
|
||||
error: String(error),
|
||||
})
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
for (const uuid of batchUuids) {
|
||||
notifyCommandLifecycle(uuid, 'completed')
|
||||
@@ -2253,10 +2328,15 @@ function runHeadlessStreaming(
|
||||
|
||||
if (feature('FILE_PERSISTENCE') && turnStartTime !== undefined) {
|
||||
void executeFilePersistence(
|
||||
{ turnStartTime } as import('src/utils/filePersistence/types.js').TurnStartTime,
|
||||
{
|
||||
turnStartTime,
|
||||
} as import('src/utils/filePersistence/types.js').TurnStartTime,
|
||||
abortController.signal,
|
||||
result => {
|
||||
const filesResult = result as unknown as { persistedFiles: { filename: string; file_id: string }[]; failedFiles: { filename: string; error: string }[] }
|
||||
const filesResult = result as unknown as {
|
||||
persistedFiles: { filename: string; file_id: string }[]
|
||||
failedFiles: { filename: string; error: string }[]
|
||||
}
|
||||
output.enqueue({
|
||||
type: 'system' as const,
|
||||
subtype: 'files_persisted' as const,
|
||||
@@ -2700,28 +2780,73 @@ function runHeadlessStreaming(
|
||||
// the end of run() picks up the queued command.
|
||||
let cronScheduler: import('../utils/cronScheduler.js').CronScheduler | null =
|
||||
null
|
||||
if (
|
||||
cronGate.isKairosCronEnabled()
|
||||
) {
|
||||
if (cronGate.isKairosCronEnabled()) {
|
||||
cronScheduler = cronSchedulerModule.createCronScheduler({
|
||||
onFire: prompt => {
|
||||
if (inputClosed) return
|
||||
enqueue({
|
||||
mode: 'prompt',
|
||||
value: prompt,
|
||||
uuid: randomUUID(),
|
||||
priority: 'later',
|
||||
// System-generated — matches useScheduledTasks.ts REPL equivalent.
|
||||
// Without this, messages.ts metaProp eval is {} → prompt leaks
|
||||
// into visible transcript when cron fires mid-turn in -p mode.
|
||||
isMeta: true,
|
||||
// Threaded to cc_workload= in the billing-header attribution block
|
||||
// so the API can serve cron requests at lower QoS. drainCommandQueue
|
||||
// reads this per-iteration and hoists it into bootstrap state for
|
||||
// the ask() call.
|
||||
workload: WORKLOAD_CRON,
|
||||
})
|
||||
void run()
|
||||
void (async () => {
|
||||
const prepared = await prepareAutonomyTurnPrompt({
|
||||
basePrompt: prompt,
|
||||
trigger: 'scheduled-task',
|
||||
currentDir: cwd(),
|
||||
})
|
||||
if (inputClosed) return
|
||||
const command = await commitAutonomyQueuedPrompt({
|
||||
prepared,
|
||||
currentDir: cwd(),
|
||||
workload: WORKLOAD_CRON,
|
||||
})
|
||||
if (inputClosed) return
|
||||
enqueue({
|
||||
...command,
|
||||
uuid: randomUUID(),
|
||||
})
|
||||
void run()
|
||||
})()
|
||||
},
|
||||
onFireTask: task => {
|
||||
if (inputClosed) return
|
||||
void (async () => {
|
||||
if (task.agentId) {
|
||||
const prepared = await prepareAutonomyTurnPrompt({
|
||||
basePrompt: task.prompt,
|
||||
trigger: 'scheduled-task',
|
||||
currentDir: cwd(),
|
||||
})
|
||||
if (inputClosed) return
|
||||
const command = await commitAutonomyQueuedPrompt({
|
||||
prepared,
|
||||
currentDir: cwd(),
|
||||
sourceId: task.id,
|
||||
sourceLabel: task.prompt,
|
||||
workload: WORKLOAD_CRON,
|
||||
})
|
||||
await markAutonomyRunFailed(
|
||||
command.autonomy!.runId,
|
||||
`No teammate runtime available for scheduled task owner ${task.agentId} in headless mode.`,
|
||||
)
|
||||
return
|
||||
}
|
||||
const prepared = await prepareAutonomyTurnPrompt({
|
||||
basePrompt: task.prompt,
|
||||
trigger: 'scheduled-task',
|
||||
currentDir: cwd(),
|
||||
})
|
||||
if (inputClosed) return
|
||||
const command = await commitAutonomyQueuedPrompt({
|
||||
prepared,
|
||||
currentDir: cwd(),
|
||||
sourceId: task.id,
|
||||
sourceLabel: task.prompt,
|
||||
workload: WORKLOAD_CRON,
|
||||
})
|
||||
if (inputClosed) return
|
||||
enqueue({
|
||||
...command,
|
||||
uuid: randomUUID(),
|
||||
})
|
||||
void run()
|
||||
})()
|
||||
},
|
||||
isLoading: () => running || inputClosed,
|
||||
getJitterConfig: cronJitterConfigModule?.getCronJitterConfig,
|
||||
@@ -2996,7 +3121,9 @@ function runHeadlessStreaming(
|
||||
sdkClient.type === 'connected' &&
|
||||
sdkClient.client?.transport?.onmessage
|
||||
) {
|
||||
sdkClient.client.transport.onmessage(mcpRequest.message as import('@modelcontextprotocol/sdk/types.js').JSONRPCMessage)
|
||||
sdkClient.client.transport.onmessage(
|
||||
mcpRequest.message as import('@modelcontextprotocol/sdk/types.js').JSONRPCMessage,
|
||||
)
|
||||
}
|
||||
sendControlResponseSuccess(msg)
|
||||
} else if (msg.request.subtype === 'rewind_files') {
|
||||
@@ -3061,7 +3188,10 @@ function runHeadlessStreaming(
|
||||
sendControlResponseSuccess(msg)
|
||||
} else if (msg.request.subtype === 'mcp_set_servers') {
|
||||
const { response, sdkServersChanged } = await applyMcpServerChanges(
|
||||
msg.request.servers as Record<string, McpServerConfigForProcessTransport>,
|
||||
msg.request.servers as Record<
|
||||
string,
|
||||
McpServerConfigForProcessTransport
|
||||
>,
|
||||
)
|
||||
sendControlResponseSuccess(msg, response)
|
||||
|
||||
@@ -3131,7 +3261,8 @@ function runHeadlessStreaming(
|
||||
model: a.model === 'inherit' ? undefined : a.model,
|
||||
})),
|
||||
plugins,
|
||||
mcpServers: buildMcpServerStatuses() as SDKControlReloadPluginsResponse['mcpServers'],
|
||||
mcpServers:
|
||||
buildMcpServerStatuses() as SDKControlReloadPluginsResponse['mcpServers'],
|
||||
error_count: r.error_count,
|
||||
} satisfies SDKControlReloadPluginsResponse)
|
||||
} catch (error) {
|
||||
@@ -3406,7 +3537,7 @@ function runHeadlessStreaming(
|
||||
mcp: {
|
||||
...prev.mcp,
|
||||
clients: prev.mcp.clients.map(c =>
|
||||
c.name === serverName as string ? result.client : c,
|
||||
c.name === (serverName as string) ? result.client : c,
|
||||
),
|
||||
tools: [
|
||||
...reject(prev.mcp.tools, t =>
|
||||
@@ -3455,7 +3586,9 @@ function runHeadlessStreaming(
|
||||
})
|
||||
.finally(() => {
|
||||
// Clean up only if this is still the active flow
|
||||
if (activeOAuthFlows.get(serverName as string) === controller) {
|
||||
if (
|
||||
activeOAuthFlows.get(serverName as string) === controller
|
||||
) {
|
||||
activeOAuthFlows.delete(serverName as string)
|
||||
oauthCallbackSubmitters.delete(serverName as string)
|
||||
oauthManualCallbackUsed.delete(serverName as string)
|
||||
@@ -3570,7 +3703,9 @@ function runHeadlessStreaming(
|
||||
// next API call re-reads keychain/file and works. No respawn.
|
||||
await installOAuthTokens(tokens)
|
||||
logEvent('tengu_oauth_success', {
|
||||
loginWithClaudeAi: (loginWithClaudeAi ?? true) as boolean | number,
|
||||
loginWithClaudeAi: (loginWithClaudeAi ?? true) as
|
||||
| boolean
|
||||
| number,
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -3618,10 +3753,7 @@ function runHeadlessStreaming(
|
||||
req.subtype === 'claude_oauth_wait_for_completion'
|
||||
) {
|
||||
if (!claudeOAuth) {
|
||||
sendControlResponseError(
|
||||
msg,
|
||||
'No active claude_authenticate flow',
|
||||
)
|
||||
sendControlResponseError(msg, 'No active claude_authenticate flow')
|
||||
} else {
|
||||
// Inject the manual code synchronously — must happen in stdin
|
||||
// message order so a subsequent claude_authenticate doesn't
|
||||
@@ -3681,7 +3813,7 @@ function runHeadlessStreaming(
|
||||
mcp: {
|
||||
...prev.mcp,
|
||||
clients: prev.mcp.clients.map(c =>
|
||||
c.name === serverName as string ? result.client : c,
|
||||
c.name === (serverName as string) ? result.client : c,
|
||||
),
|
||||
tools: [
|
||||
...reject(prev.mcp.tools, t => t.name?.startsWith(prefix)),
|
||||
@@ -4116,9 +4248,13 @@ function runHeadlessStreaming(
|
||||
mode: 'prompt' as const,
|
||||
// file_attachments rides the protobuf catchall from the web composer.
|
||||
// Same-ref no-op when absent (no 'file_attachments' key).
|
||||
value: await resolveAndPrepend(userMsg, (userMsg.message as { content: ContentBlockParam[] }).content),
|
||||
value: await resolveAndPrepend(
|
||||
userMsg,
|
||||
(userMsg.message as { content: ContentBlockParam[] }).content,
|
||||
),
|
||||
uuid: userMsg.uuid as `${string}-${string}-${string}-${string}-${string}`,
|
||||
priority: (userMsg as { priority?: string }).priority as import('src/types/textInputTypes.js').QueuePriority,
|
||||
priority: (userMsg as { priority?: string })
|
||||
.priority as import('src/types/textInputTypes.js').QueuePriority,
|
||||
})
|
||||
// Increment prompt count for attribution tracking and save snapshot
|
||||
// The snapshot persists promptCount so it survives compaction
|
||||
@@ -4447,7 +4583,10 @@ async function handleInitializeRequest(
|
||||
const accountInfo = getAccountInformation()
|
||||
if (request.hooks) {
|
||||
const hooks: Partial<Record<HookEvent, HookCallbackMatcher[]>> = {}
|
||||
for (const [event, matchers] of Object.entries(request.hooks) as [string, Array<{ hookCallbackIds: string[]; timeout?: number; matcher?: string }>][]) {
|
||||
for (const [event, matchers] of Object.entries(request.hooks) as [
|
||||
string,
|
||||
Array<{ hookCallbackIds: string[]; timeout?: number; matcher?: string }>,
|
||||
][]) {
|
||||
hooks[event as HookEvent] = matchers.map(matcher => {
|
||||
const callbacks = matcher.hookCallbackIds.map(callbackId => {
|
||||
return structuredIO.createHookCallback(callbackId, matcher.timeout)
|
||||
@@ -4489,7 +4628,11 @@ async function handleInitializeRequest(
|
||||
// getAccountInformation() returns undefined under 3P providers, so the
|
||||
// other fields are all absent. apiProvider disambiguates "not logged
|
||||
// in" (firstParty + tokenSource:none) from "3P, login not applicable".
|
||||
apiProvider: getAPIProvider() as 'firstParty' | 'bedrock' | 'vertex' | 'foundry',
|
||||
apiProvider: getAPIProvider() as
|
||||
| 'firstParty'
|
||||
| 'bedrock'
|
||||
| 'vertex'
|
||||
| 'foundry',
|
||||
},
|
||||
pid: process.pid,
|
||||
}
|
||||
@@ -4537,7 +4680,11 @@ async function handleRewindFiles(
|
||||
dryRun: boolean,
|
||||
): Promise<RewindFilesResult> {
|
||||
if (!fileHistoryEnabled()) {
|
||||
return { canRewind: false, error: 'File rewinding is not enabled.', filesChanged: [] }
|
||||
return {
|
||||
canRewind: false,
|
||||
error: 'File rewinding is not enabled.',
|
||||
filesChanged: [],
|
||||
}
|
||||
}
|
||||
if (!fileHistoryCanRestore(appState.fileHistory, userMessageId)) {
|
||||
return {
|
||||
@@ -4842,7 +4989,10 @@ function reregisterChannelHandlerAfterReconnect(
|
||||
value: wrapChannelMessage(connection.name, content, meta),
|
||||
priority: 'next',
|
||||
isMeta: true,
|
||||
origin: { kind: 'channel', server: connection.name } as unknown as string,
|
||||
origin: {
|
||||
kind: 'channel',
|
||||
server: connection.name,
|
||||
} as unknown as string,
|
||||
skipSlashCommands: true,
|
||||
})
|
||||
},
|
||||
@@ -5266,13 +5416,21 @@ export async function handleOrphanedPermissionResponse({
|
||||
onEnqueued?: () => void
|
||||
handledToolUseIds: Set<string>
|
||||
}): Promise<boolean> {
|
||||
const responseInner = message.response as { subtype?: string; response?: Record<string, unknown>; request_id?: string } | undefined
|
||||
const responseInner = message.response as
|
||||
| {
|
||||
subtype?: string
|
||||
response?: Record<string, unknown>
|
||||
request_id?: string
|
||||
}
|
||||
| undefined
|
||||
if (
|
||||
responseInner?.subtype === 'success' &&
|
||||
responseInner.response?.toolUseID &&
|
||||
typeof responseInner.response.toolUseID === 'string'
|
||||
) {
|
||||
const permissionResult = responseInner.response as PermissionResult & { toolUseID?: string }
|
||||
const permissionResult = responseInner.response as PermissionResult & {
|
||||
toolUseID?: string
|
||||
}
|
||||
const toolUseID = permissionResult.toolUseID
|
||||
if (!toolUseID) {
|
||||
return false
|
||||
|
||||
@@ -1,2 +1,70 @@
|
||||
// Auto-generated stub
|
||||
export async function rollback(target?: string, options?: { list?: boolean; dryRun?: boolean; safe?: boolean }): Promise<void> {}
|
||||
/**
|
||||
* `claude rollback [target]` — roll back to a previous Claude Code version.
|
||||
*
|
||||
* ANT-only command (USER_TYPE === "ant").
|
||||
*
|
||||
* Options:
|
||||
* --list List recent published versions
|
||||
* --dry-run Show what would be installed without installing
|
||||
* --safe Roll back to the server-pinned safe version
|
||||
*/
|
||||
export async function rollback(
|
||||
target?: string,
|
||||
options?: { list?: boolean; dryRun?: boolean; safe?: boolean },
|
||||
): Promise<void> {
|
||||
if (options?.list) {
|
||||
console.log('Recent versions:')
|
||||
console.log(' (version listing requires access to the release registry)')
|
||||
console.log(' Use `claude update --list` for available versions.')
|
||||
return
|
||||
}
|
||||
|
||||
if (options?.safe) {
|
||||
console.log('Safe rollback: would install the server-pinned safe version.')
|
||||
if (options.dryRun) {
|
||||
console.log(' (dry run — no changes made)')
|
||||
return
|
||||
}
|
||||
console.log(' Safe version pinning requires access to the release API.')
|
||||
console.log(' Contact oncall for the current safe version.')
|
||||
return
|
||||
}
|
||||
|
||||
if (!target) {
|
||||
console.error(
|
||||
'Usage: claude rollback [target]\n\n' +
|
||||
'Options:\n' +
|
||||
' -l, --list List recent published versions\n' +
|
||||
' --dry-run Show what would be installed\n' +
|
||||
' --safe Roll back to server-pinned safe version\n\n' +
|
||||
'Examples:\n' +
|
||||
' claude rollback 2.1.880\n' +
|
||||
' claude rollback --list\n' +
|
||||
' claude rollback --safe',
|
||||
)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`Rolling back to version ${target}...`)
|
||||
|
||||
if (options?.dryRun) {
|
||||
console.log(` (dry run — would install ${target})`)
|
||||
return
|
||||
}
|
||||
|
||||
// Version rollback via npm/bun
|
||||
const { spawnSync } = await import('child_process')
|
||||
const result = spawnSync(
|
||||
'npm',
|
||||
['install', '-g', `@anthropic-ai/claude-code@${target}`],
|
||||
{ stdio: 'inherit' },
|
||||
)
|
||||
|
||||
if (result.status !== 0) {
|
||||
console.error(`Rollback failed with exit code ${result.status}`)
|
||||
process.exitCode = result.status ?? 1
|
||||
} else {
|
||||
console.log(`Rolled back to ${target} successfully.`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +1,95 @@
|
||||
// Auto-generated stub
|
||||
export async function up(): Promise<void> {}
|
||||
import { readFileSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { spawnSync } from 'child_process'
|
||||
import { findGitRoot } from '../utils/git.js'
|
||||
|
||||
/**
|
||||
* `claude up` — run the "# claude up" section from the nearest CLAUDE.md.
|
||||
*
|
||||
* Walks up from CWD looking for CLAUDE.md files, extracts the section
|
||||
* under the `# claude up` heading, and executes it as a shell script.
|
||||
*
|
||||
* ANT-only command (USER_TYPE === "ant").
|
||||
*/
|
||||
export async function up(): Promise<void> {
|
||||
const cwd = process.cwd()
|
||||
const gitRoot = findGitRoot(cwd)
|
||||
const searchDirs = gitRoot ? [gitRoot, cwd] : [cwd]
|
||||
|
||||
let upSection: string | null = null
|
||||
|
||||
for (const dir of searchDirs) {
|
||||
const claudeMdPath = join(dir, 'CLAUDE.md')
|
||||
try {
|
||||
const content = readFileSync(claudeMdPath, 'utf-8')
|
||||
upSection = extractUpSection(content)
|
||||
if (upSection) {
|
||||
console.log(`Found "# claude up" in ${claudeMdPath}`)
|
||||
break
|
||||
}
|
||||
} catch {
|
||||
// File not found — continue searching
|
||||
}
|
||||
}
|
||||
|
||||
if (!upSection) {
|
||||
console.log(
|
||||
'No "# claude up" section found in CLAUDE.md.\n' +
|
||||
'Add a section like:\n\n' +
|
||||
' # claude up\n' +
|
||||
' ```bash\n' +
|
||||
' npm install\n' +
|
||||
' npm run build\n' +
|
||||
' ```',
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
console.log('Running:\n')
|
||||
console.log(upSection)
|
||||
console.log()
|
||||
|
||||
const result = spawnSync('bash', ['-c', upSection], {
|
||||
cwd,
|
||||
stdio: 'inherit',
|
||||
})
|
||||
|
||||
if (result.status !== 0) {
|
||||
console.error(`\nclaude up failed with exit code ${result.status}`)
|
||||
process.exitCode = result.status ?? 1
|
||||
} else {
|
||||
console.log('\nclaude up completed successfully.')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the content under "# claude up" heading from markdown.
|
||||
* Returns the text between `# claude up` and the next `#` heading (or EOF).
|
||||
* Strips fenced code block markers if present.
|
||||
*/
|
||||
function extractUpSection(markdown: string): string | null {
|
||||
const lines = markdown.split('\n')
|
||||
let inSection = false
|
||||
const sectionLines: string[] = []
|
||||
|
||||
for (const line of lines) {
|
||||
if (/^#\s+claude\s+up\b/i.test(line)) {
|
||||
inSection = true
|
||||
continue
|
||||
}
|
||||
if (inSection && /^#\s/.test(line)) {
|
||||
break
|
||||
}
|
||||
if (inSection) {
|
||||
sectionLines.push(line)
|
||||
}
|
||||
}
|
||||
|
||||
if (sectionLines.length === 0) return null
|
||||
|
||||
// Strip fenced code block markers
|
||||
let text = sectionLines.join('\n').trim()
|
||||
text = text.replace(/^```\w*\n?/, '').replace(/\n?```\s*$/, '')
|
||||
|
||||
return text.trim() || null
|
||||
}
|
||||
|
||||
@@ -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
|
||||
: []),
|
||||
|
||||
246
src/commands/__tests__/autonomy.test.ts
Normal file
246
src/commands/__tests__/autonomy.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
48
src/commands/__tests__/proactive.baseline.test.ts
Normal file
48
src/commands/__tests__/proactive.baseline.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
175
src/commands/assistant/assistant.tsx
Normal file
175
src/commands/assistant/assistant.tsx
Normal 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;
|
||||
}
|
||||
@@ -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
125
src/commands/autonomy.ts
Normal 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
|
||||
24
src/commands/daemon/__tests__/daemon.test.ts
Normal file
24
src/commands/daemon/__tests__/daemon.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
57
src/commands/daemon/daemon.tsx
Normal file
57
src/commands/daemon/daemon.tsx
Normal 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
|
||||
}
|
||||
17
src/commands/daemon/index.ts
Normal file
17
src/commands/daemon/index.ts
Normal 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
|
||||
@@ -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)
|
||||
|
||||
25
src/commands/job/__tests__/job.test.ts
Normal file
25
src/commands/job/__tests__/job.test.ts
Normal 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
16
src/commands/job/index.ts
Normal 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
34
src/commands/job/job.tsx
Normal 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
|
||||
}
|
||||
12
src/commands/lang/index.ts
Normal file
12
src/commands/lang/index.ts
Normal 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
49
src/commands/lang/lang.ts
Normal 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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)}`,
|
||||
|
||||
@@ -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
|
||||
|
||||
61
src/daemon/__tests__/daemonMain.test.ts
Normal file
61
src/daemon/__tests__/daemonMain.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
185
src/daemon/__tests__/state.test.ts
Normal file
185
src/daemon/__tests__/state.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
* status — unified 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
157
src/daemon/state.ts
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -48,7 +48,6 @@ export function useAwaySummary(
|
||||
'tengu_sedge_lantern',
|
||||
false,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!feature('AWAY_SUMMARY')) return
|
||||
if (!gbEnabled) return
|
||||
|
||||
@@ -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 } =
|
||||
|
||||
@@ -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
|
||||
|
||||
141
src/hooks/usePipeMuteSync.ts
Normal file
141
src/hooks/usePipeMuteSync.ts
Normal 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()
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
@@ -90,6 +90,7 @@ export function usePipePermissionForward({
|
||||
input: payload.input,
|
||||
toolUseContext,
|
||||
toolUseID: `pipe:${payload.requestId}`,
|
||||
pipeName,
|
||||
permissionResult: payload.permissionResult,
|
||||
permissionPromptStartTimeMs:
|
||||
payload.permissionPromptStartTimeMs,
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
140
src/jobs/__tests__/classifier.test.ts
Normal file
140
src/jobs/__tests__/classifier.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
91
src/jobs/__tests__/state.test.ts
Normal file
91
src/jobs/__tests__/state.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
87
src/jobs/__tests__/templates.test.ts
Normal file
87
src/jobs/__tests__/templates.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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
102
src/jobs/state.ts
Normal 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
86
src/jobs/templates.ts
Normal 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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
80
src/proactive/__tests__/state.baseline.test.ts
Normal file
80
src/proactive/__tests__/state.baseline.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
487
src/services/api/openai/__tests__/queryModelOpenAI.isolated.ts
Normal file
487
src/services/api/openai/__tests__/queryModelOpenAI.isolated.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
702
src/services/langfuse/__tests__/langfuse.isolated.ts
Normal file
702
src/services/langfuse/__tests__/langfuse.isolated.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
241
src/utils/__tests__/autonomyAuthority.test.ts
Normal file
241
src/utils/__tests__/autonomyAuthority.test.ts
Normal 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 (')
|
||||
})
|
||||
})
|
||||
1116
src/utils/__tests__/autonomyFlows.test.ts
Normal file
1116
src/utils/__tests__/autonomyFlows.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
117
src/utils/__tests__/autonomyPersistence.test.ts
Normal file
117
src/utils/__tests__/autonomyPersistence.test.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
})
|
||||
421
src/utils/__tests__/autonomyRuns.test.ts
Normal file
421
src/utils/__tests__/autonomyRuns.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
79
src/utils/__tests__/cronScheduler.baseline.test.ts
Normal file
79
src/utils/__tests__/cronScheduler.baseline.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
203
src/utils/__tests__/cronTasks.baseline.test.ts
Normal file
203
src/utils/__tests__/cronTasks.baseline.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
82
src/utils/__tests__/language.test.ts
Normal file
82
src/utils/__tests__/language.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
124
src/utils/__tests__/pipeMuteState.test.ts
Normal file
124
src/utils/__tests__/pipeMuteState.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
93
src/utils/__tests__/taskSummary.test.ts
Normal file
93
src/utils/__tests__/taskSummary.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
522
src/utils/autonomyAuthority.ts
Normal file
522
src/utils/autonomyAuthority.ts
Normal 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
1057
src/utils/autonomyFlows.ts
Normal file
File diff suppressed because it is too large
Load Diff
48
src/utils/autonomyPersistence.ts
Normal file
48
src/utils/autonomyPersistence.ts
Normal 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
797
src/utils/autonomyRuns.ts
Normal 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
180
src/utils/cliLaunch.ts
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
26
src/utils/language.ts
Normal 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
|
||||
}
|
||||
78
src/utils/pipeMuteState.ts
Normal file
78
src/utils/pipeMuteState.ts
Normal 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
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}"`)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user