Feat/integrate lint preview (#285)

* feat: 适配 zed acp 协议

* docs: 完善 acp 文档

* feat: integrate feature branches + daemon/job 命令层级化 + 跨平台后台引擎

Cherry-picked from origin/lint/preview (637c908), excluding lint-only changes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: correct detectMimeFromBase64 to decode raw bytes from base64

Cherry-picked from origin/lint/preview (ee36954).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: daemon 子进程 spawn 跨平台修复 + CliLaunchSpec 集中化重构

Cherry-picked from origin/lint/preview (c5f52cd), excluding lint-only formatting changes.

- 新建 src/utils/cliLaunch.ts: 集中化 CLI 子进程启动层
- 修复 --daemon-worker=kind 等号格式解析
- 修复 daemon/bg fast path 缺少 setShellIfWindows()
- 修复 checkPathExists 用 existsSync 替代 execSync('dir')
- 7 个 spawn 站点迁移到 CliLaunchSpec

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: merge tsconfig.base.json into tsconfig.json with full compiler options

The cherry-pick from 637c908 dropped jsx/strict/etc settings when removing
tsconfig.base.json. This commit restores them in a single tsconfig.json.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: merge tsconfig.base.json into tsconfig.json with full compiler options

The cherry-pick from 637c908 dropped jsx/strict/etc settings when removing
tsconfig.base.json. This commit restores them in a single tsconfig.json.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
claude-code-best
2026-04-16 20:59:29 +08:00
committed by GitHub
parent a02dc0bded
commit c8d08d235b
137 changed files with 13267 additions and 837 deletions

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

1057
src/utils/autonomyFlows.ts Normal file

File diff suppressed because it is too large Load Diff

View File

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

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

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

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

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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