mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-17 05:45:51 +00:00
Feat/integrate lint preview (#285)
* feat: 适配 zed acp 协议 * docs: 完善 acp 文档 * feat: integrate feature branches + daemon/job 命令层级化 + 跨平台后台引擎 Cherry-picked from origin/lint/preview (637c908), excluding lint-only changes. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: correct detectMimeFromBase64 to decode raw bytes from base64 Cherry-picked from origin/lint/preview (ee36954). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: daemon 子进程 spawn 跨平台修复 + CliLaunchSpec 集中化重构 Cherry-picked from origin/lint/preview (c5f52cd), excluding lint-only formatting changes. - 新建 src/utils/cliLaunch.ts: 集中化 CLI 子进程启动层 - 修复 --daemon-worker=kind 等号格式解析 - 修复 daemon/bg fast path 缺少 setShellIfWindows() - 修复 checkPathExists 用 existsSync 替代 execSync('dir') - 7 个 spawn 站点迁移到 CliLaunchSpec Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: merge tsconfig.base.json into tsconfig.json with full compiler options The cherry-pick from637c908dropped jsx/strict/etc settings when removing tsconfig.base.json. This commit restores them in a single tsconfig.json. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: merge tsconfig.base.json into tsconfig.json with full compiler options The cherry-pick from637c908dropped jsx/strict/etc settings when removing tsconfig.base.json. This commit restores them in a single tsconfig.json. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
241
src/utils/__tests__/autonomyAuthority.test.ts
Normal file
241
src/utils/__tests__/autonomyAuthority.test.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
AUTONOMY_AGENTS_PATH_POSIX,
|
||||
AUTONOMY_DIR,
|
||||
buildAutonomyTurnPrompt,
|
||||
loadAutonomyAuthority,
|
||||
resetAutonomyAuthorityForTests,
|
||||
} from '../autonomyAuthority'
|
||||
import {
|
||||
cleanupTempDir,
|
||||
createTempDir,
|
||||
createTempSubdir,
|
||||
writeTempFile,
|
||||
} from '../../../tests/mocks/file-system'
|
||||
|
||||
const AGENTS_REL = join(AUTONOMY_DIR, 'AGENTS.md')
|
||||
const HEARTBEAT_REL = join(AUTONOMY_DIR, 'HEARTBEAT.md')
|
||||
|
||||
let tempDir = ''
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await createTempDir('autonomy-authority-')
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
resetAutonomyAuthorityForTests()
|
||||
if (tempDir) {
|
||||
await cleanupTempDir(tempDir)
|
||||
}
|
||||
})
|
||||
|
||||
describe('autonomyAuthority', () => {
|
||||
test('loadAutonomyAuthority merges AGENTS.md files from root to current directory', async () => {
|
||||
const nestedDir = await createTempSubdir(tempDir, 'packages/app')
|
||||
await writeTempFile(tempDir, AGENTS_REL, 'root authority')
|
||||
await writeTempFile(nestedDir, AGENTS_REL, 'nested authority')
|
||||
await writeTempFile(
|
||||
tempDir,
|
||||
HEARTBEAT_REL,
|
||||
[
|
||||
'# Heartbeat',
|
||||
'tasks:',
|
||||
' - name: inbox',
|
||||
' interval: 30m',
|
||||
' prompt: "Check inbox"',
|
||||
].join('\n'),
|
||||
)
|
||||
|
||||
const snapshot = await loadAutonomyAuthority({
|
||||
rootDir: tempDir,
|
||||
currentDir: nestedDir,
|
||||
})
|
||||
|
||||
expect(snapshot.agentsFiles.map(file => file.relativePath)).toEqual([
|
||||
AUTONOMY_AGENTS_PATH_POSIX,
|
||||
`packages/app/${AUTONOMY_AGENTS_PATH_POSIX}`,
|
||||
])
|
||||
expect(snapshot.agentsContent).toContain('root authority')
|
||||
expect(snapshot.agentsContent).toContain('nested authority')
|
||||
expect(snapshot.heartbeatContent).toContain('# Heartbeat')
|
||||
expect(snapshot.heartbeatTasks).toEqual([
|
||||
{
|
||||
name: 'inbox',
|
||||
interval: '30m',
|
||||
prompt: 'Check inbox',
|
||||
steps: [],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('loadAutonomyAuthority reads HEARTBEAT.md only from the workspace root', async () => {
|
||||
const nestedDir = await createTempSubdir(tempDir, 'child')
|
||||
await writeTempFile(
|
||||
tempDir,
|
||||
HEARTBEAT_REL,
|
||||
'# Root heartbeat\nRemember the root task',
|
||||
)
|
||||
await writeTempFile(
|
||||
nestedDir,
|
||||
HEARTBEAT_REL,
|
||||
'# Nested heartbeat\nThis should not be used',
|
||||
)
|
||||
|
||||
const snapshot = await loadAutonomyAuthority({
|
||||
rootDir: tempDir,
|
||||
currentDir: nestedDir,
|
||||
})
|
||||
|
||||
expect(snapshot.heartbeatFile?.path).toBe(join(tempDir, HEARTBEAT_REL))
|
||||
expect(snapshot.heartbeatContent).toContain('Root heartbeat')
|
||||
expect(snapshot.heartbeatContent).not.toContain('Nested heartbeat')
|
||||
})
|
||||
|
||||
test('buildAutonomyTurnPrompt returns the original prompt when no authority files exist', async () => {
|
||||
const prompt = await buildAutonomyTurnPrompt({
|
||||
basePrompt: 'Run the scheduled task.',
|
||||
trigger: 'scheduled-task',
|
||||
rootDir: tempDir,
|
||||
currentDir: tempDir,
|
||||
})
|
||||
|
||||
expect(prompt).toBe('Run the scheduled task.')
|
||||
})
|
||||
|
||||
test('buildAutonomyTurnPrompt injects AGENTS.md and HEARTBEAT.md for automated turns', async () => {
|
||||
const nestedDir = await createTempSubdir(tempDir, 'nested')
|
||||
await writeTempFile(tempDir, AGENTS_REL, 'root rules')
|
||||
await writeTempFile(nestedDir, AGENTS_REL, 'nested rules')
|
||||
await writeTempFile(tempDir, HEARTBEAT_REL, 'Check heartbeat directives')
|
||||
|
||||
const scheduledPrompt = await buildAutonomyTurnPrompt({
|
||||
basePrompt: 'Review the nightly report.',
|
||||
trigger: 'scheduled-task',
|
||||
rootDir: tempDir,
|
||||
currentDir: nestedDir,
|
||||
})
|
||||
const tickPrompt = await buildAutonomyTurnPrompt({
|
||||
basePrompt: '<tick>12:00:00</tick>',
|
||||
trigger: 'proactive-tick',
|
||||
rootDir: tempDir,
|
||||
currentDir: nestedDir,
|
||||
})
|
||||
|
||||
expect(scheduledPrompt).toContain(
|
||||
'This prompt was generated automatically. Follow the workspace authority below before acting.',
|
||||
)
|
||||
expect(scheduledPrompt).toContain('<autonomy_authority>')
|
||||
expect(scheduledPrompt).toContain('root rules')
|
||||
expect(scheduledPrompt).toContain('nested rules')
|
||||
expect(scheduledPrompt).toContain('Check heartbeat directives')
|
||||
expect(scheduledPrompt).toContain('Review the nightly report.')
|
||||
|
||||
expect(tickPrompt).toContain(
|
||||
'This is an autonomous proactive turn. Follow the workspace authority below before acting.',
|
||||
)
|
||||
expect(tickPrompt).toContain('<tick>12:00:00</tick>')
|
||||
})
|
||||
|
||||
test('proactive prompts surface due HEARTBEAT.md tasks only when their interval elapses', async () => {
|
||||
await writeTempFile(
|
||||
tempDir,
|
||||
HEARTBEAT_REL,
|
||||
[
|
||||
'tasks:',
|
||||
' - name: inbox',
|
||||
' interval: 30m',
|
||||
' prompt: "Check inbox"',
|
||||
].join('\n'),
|
||||
)
|
||||
|
||||
const first = await buildAutonomyTurnPrompt({
|
||||
basePrompt: '<tick>12:00:00</tick>',
|
||||
trigger: 'proactive-tick',
|
||||
rootDir: tempDir,
|
||||
currentDir: tempDir,
|
||||
nowMs: 0,
|
||||
})
|
||||
const second = await buildAutonomyTurnPrompt({
|
||||
basePrompt: '<tick>12:10:00</tick>',
|
||||
trigger: 'proactive-tick',
|
||||
rootDir: tempDir,
|
||||
currentDir: tempDir,
|
||||
nowMs: 10 * 60_000,
|
||||
})
|
||||
const third = await buildAutonomyTurnPrompt({
|
||||
basePrompt: '<tick>12:31:00</tick>',
|
||||
trigger: 'proactive-tick',
|
||||
rootDir: tempDir,
|
||||
currentDir: tempDir,
|
||||
nowMs: 31 * 60_000,
|
||||
})
|
||||
|
||||
expect(first).toContain('Due HEARTBEAT.md tasks:')
|
||||
expect(first).toContain('- inbox (30m): Check inbox')
|
||||
expect(second).not.toContain('Due HEARTBEAT.md tasks:')
|
||||
expect(third).toContain('Due HEARTBEAT.md tasks:')
|
||||
})
|
||||
|
||||
test('managed HEARTBEAT.md tasks parse nested steps and are not duplicated into the inline due-task section', async () => {
|
||||
await writeTempFile(
|
||||
tempDir,
|
||||
HEARTBEAT_REL,
|
||||
[
|
||||
'tasks:',
|
||||
' - name: inbox',
|
||||
' interval: 30m',
|
||||
' prompt: "Check inbox"',
|
||||
' - name: weekly-report',
|
||||
' interval: 7d',
|
||||
' prompt: "Ship the weekly report"',
|
||||
' steps:',
|
||||
' - name: gather',
|
||||
' prompt: "Gather weekly inputs"',
|
||||
' - name: draft',
|
||||
' prompt: "Draft the weekly report"',
|
||||
' wait_for: manual',
|
||||
].join('\n'),
|
||||
)
|
||||
|
||||
const snapshot = await loadAutonomyAuthority({
|
||||
rootDir: tempDir,
|
||||
currentDir: tempDir,
|
||||
})
|
||||
const prompt = await buildAutonomyTurnPrompt({
|
||||
basePrompt: '<tick>12:00:00</tick>',
|
||||
trigger: 'proactive-tick',
|
||||
rootDir: tempDir,
|
||||
currentDir: tempDir,
|
||||
nowMs: 0,
|
||||
})
|
||||
|
||||
expect(snapshot.heartbeatTasks).toEqual([
|
||||
{
|
||||
name: 'inbox',
|
||||
interval: '30m',
|
||||
prompt: 'Check inbox',
|
||||
steps: [],
|
||||
},
|
||||
{
|
||||
name: 'weekly-report',
|
||||
interval: '7d',
|
||||
prompt: 'Ship the weekly report',
|
||||
steps: [
|
||||
{
|
||||
name: 'gather',
|
||||
prompt: 'Gather weekly inputs',
|
||||
},
|
||||
{
|
||||
name: 'draft',
|
||||
prompt: 'Draft the weekly report',
|
||||
waitFor: 'manual',
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
expect(prompt).toContain('- inbox (30m): Check inbox')
|
||||
expect(prompt).not.toContain('- weekly-report (7d): Ship the weekly report')
|
||||
expect(prompt).not.toContain('- gather (')
|
||||
})
|
||||
})
|
||||
1116
src/utils/__tests__/autonomyFlows.test.ts
Normal file
1116
src/utils/__tests__/autonomyFlows.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
117
src/utils/__tests__/autonomyPersistence.test.ts
Normal file
117
src/utils/__tests__/autonomyPersistence.test.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { cleanupTempDir, createTempDir } from '../../../tests/mocks/file-system'
|
||||
|
||||
// Mock the lockfile module so tests don't need real file locks
|
||||
mock.module('../lockfile.js', () => ({
|
||||
lock: async (_file: string, _options?: unknown) => {
|
||||
return async () => {}
|
||||
},
|
||||
}))
|
||||
|
||||
let tempDir = ''
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await createTempDir('autonomy-persistence-')
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
if (tempDir) {
|
||||
await cleanupTempDir(tempDir)
|
||||
}
|
||||
})
|
||||
|
||||
describe('withAutonomyPersistenceLock', () => {
|
||||
test('runs fn and returns its result', async () => {
|
||||
const { withAutonomyPersistenceLock } = await import(
|
||||
'../autonomyPersistence'
|
||||
)
|
||||
const result = await withAutonomyPersistenceLock(tempDir, async () => {
|
||||
return 42
|
||||
})
|
||||
expect(result).toBe(42)
|
||||
})
|
||||
|
||||
test('creates the autonomy directory and lock file', async () => {
|
||||
const { withAutonomyPersistenceLock } = await import(
|
||||
'../autonomyPersistence'
|
||||
)
|
||||
await withAutonomyPersistenceLock(tempDir, async () => 'ok')
|
||||
|
||||
const autonomyDir = join(tempDir, '.claude', 'autonomy')
|
||||
expect(existsSync(autonomyDir)).toBe(true)
|
||||
})
|
||||
|
||||
test('propagates errors from the inner function', async () => {
|
||||
const { withAutonomyPersistenceLock } = await import(
|
||||
'../autonomyPersistence'
|
||||
)
|
||||
await expect(
|
||||
withAutonomyPersistenceLock(tempDir, async () => {
|
||||
throw new Error('inner failure')
|
||||
}),
|
||||
).rejects.toThrow('inner failure')
|
||||
})
|
||||
|
||||
test('serializes concurrent calls on the same rootDir', async () => {
|
||||
const { withAutonomyPersistenceLock } = await import(
|
||||
'../autonomyPersistence'
|
||||
)
|
||||
const order: number[] = []
|
||||
|
||||
const first = withAutonomyPersistenceLock(tempDir, async () => {
|
||||
order.push(1)
|
||||
// Simulate async work
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
order.push(2)
|
||||
return 'first'
|
||||
})
|
||||
|
||||
const second = withAutonomyPersistenceLock(tempDir, async () => {
|
||||
order.push(3)
|
||||
return 'second'
|
||||
})
|
||||
|
||||
const [r1, r2] = await Promise.all([first, second])
|
||||
|
||||
expect(r1).toBe('first')
|
||||
expect(r2).toBe('second')
|
||||
// The second call must wait for the first to finish
|
||||
expect(order).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
test('allows parallel calls on different rootDirs', async () => {
|
||||
const { withAutonomyPersistenceLock } = await import(
|
||||
'../autonomyPersistence'
|
||||
)
|
||||
const tempDir2 = await createTempDir('autonomy-persistence-2-')
|
||||
|
||||
try {
|
||||
const order: string[] = []
|
||||
|
||||
const first = withAutonomyPersistenceLock(tempDir, async () => {
|
||||
order.push('a-start')
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
order.push('a-end')
|
||||
return 'a'
|
||||
})
|
||||
|
||||
const second = withAutonomyPersistenceLock(tempDir2, async () => {
|
||||
order.push('b-start')
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
order.push('b-end')
|
||||
return 'b'
|
||||
})
|
||||
|
||||
const [r1, r2] = await Promise.all([first, second])
|
||||
expect(r1).toBe('a')
|
||||
expect(r2).toBe('b')
|
||||
// Both should start before either ends (parallel)
|
||||
expect(order.indexOf('a-start')).toBeLessThan(order.indexOf('a-end'))
|
||||
expect(order.indexOf('b-start')).toBeLessThan(order.indexOf('b-end'))
|
||||
} finally {
|
||||
await cleanupTempDir(tempDir2)
|
||||
}
|
||||
})
|
||||
})
|
||||
421
src/utils/__tests__/autonomyRuns.test.ts
Normal file
421
src/utils/__tests__/autonomyRuns.test.ts
Normal file
@@ -0,0 +1,421 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
||||
import { mkdir, writeFile } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import {
|
||||
resetStateForTests,
|
||||
setCwdState,
|
||||
setOriginalCwd,
|
||||
setProjectRoot,
|
||||
} from '../../bootstrap/state'
|
||||
import {
|
||||
formatAutonomyRunsList,
|
||||
formatAutonomyRunsStatus,
|
||||
listAutonomyRuns,
|
||||
createAutonomyQueuedPrompt,
|
||||
createProactiveAutonomyCommands,
|
||||
finalizeAutonomyRunCompleted,
|
||||
markAutonomyRunCompleted,
|
||||
markAutonomyRunFailed,
|
||||
markAutonomyRunRunning,
|
||||
recoverManagedAutonomyFlowPrompt,
|
||||
resolveAutonomyRunsPath,
|
||||
startManagedAutonomyFlowFromHeartbeatTask,
|
||||
} from '../autonomyRuns'
|
||||
import {
|
||||
formatAutonomyFlowsList,
|
||||
getAutonomyFlowById,
|
||||
listAutonomyFlows,
|
||||
} from '../autonomyFlows'
|
||||
import {
|
||||
AUTONOMY_DIR,
|
||||
resetAutonomyAuthorityForTests,
|
||||
} from '../autonomyAuthority'
|
||||
import { resetCommandQueue } from '../messageQueueManager'
|
||||
import {
|
||||
cleanupTempDir,
|
||||
createTempDir,
|
||||
createTempSubdir,
|
||||
writeTempFile,
|
||||
} from '../../../tests/mocks/file-system'
|
||||
|
||||
const AGENTS_REL = join(AUTONOMY_DIR, 'AGENTS.md')
|
||||
const HEARTBEAT_REL = join(AUTONOMY_DIR, 'HEARTBEAT.md')
|
||||
|
||||
let tempDir = ''
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await createTempDir('autonomy-runs-')
|
||||
resetStateForTests()
|
||||
resetAutonomyAuthorityForTests()
|
||||
resetCommandQueue()
|
||||
setOriginalCwd(tempDir)
|
||||
setProjectRoot(tempDir)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
resetStateForTests()
|
||||
resetAutonomyAuthorityForTests()
|
||||
resetCommandQueue()
|
||||
if (tempDir) {
|
||||
await cleanupTempDir(tempDir)
|
||||
}
|
||||
})
|
||||
|
||||
describe('autonomyRuns', () => {
|
||||
test('createAutonomyQueuedPrompt records a queued automatic run and returns a prompt command', async () => {
|
||||
const currentDir = await createTempSubdir(tempDir, 'nested')
|
||||
await writeTempFile(tempDir, AGENTS_REL, 'root authority')
|
||||
|
||||
const command = await createAutonomyQueuedPrompt({
|
||||
basePrompt: 'Review nightly report',
|
||||
trigger: 'scheduled-task',
|
||||
rootDir: tempDir,
|
||||
currentDir,
|
||||
sourceId: 'cron-1',
|
||||
sourceLabel: 'nightly-report',
|
||||
workload: 'cron',
|
||||
})
|
||||
|
||||
const runs = await listAutonomyRuns(tempDir)
|
||||
const flows = await listAutonomyFlows(tempDir)
|
||||
|
||||
expect(command).not.toBeNull()
|
||||
expect(command!.mode).toBe('prompt')
|
||||
expect(command!.isMeta).toBe(true)
|
||||
expect(command!.autonomy?.trigger).toBe('scheduled-task')
|
||||
expect(command!.autonomy?.sourceId).toBe('cron-1')
|
||||
expect(command!.origin).toBeDefined()
|
||||
expect(command!.value).toContain('root authority')
|
||||
expect(runs).toHaveLength(1)
|
||||
expect(runs[0]).toMatchObject({
|
||||
runId: command!.autonomy?.runId,
|
||||
runtime: 'automatic',
|
||||
trigger: 'scheduled-task',
|
||||
status: 'queued',
|
||||
ownerKey: 'main-thread',
|
||||
sourceId: 'cron-1',
|
||||
sourceLabel: 'nightly-report',
|
||||
})
|
||||
expect(flows).toHaveLength(0)
|
||||
expect(resolveAutonomyRunsPath(tempDir)).toContain('.claude')
|
||||
})
|
||||
|
||||
test('createAutonomyQueuedPrompt defaults currentDir to the active cwd for nested authority', async () => {
|
||||
const nestedDir = await createTempSubdir(tempDir, 'nested')
|
||||
await writeTempFile(tempDir, AGENTS_REL, 'root authority')
|
||||
await writeTempFile(nestedDir, AGENTS_REL, 'nested authority')
|
||||
setOriginalCwd(nestedDir)
|
||||
setCwdState(nestedDir)
|
||||
|
||||
const command = await createAutonomyQueuedPrompt({
|
||||
basePrompt: '<tick>12:00:00</tick>',
|
||||
trigger: 'proactive-tick',
|
||||
rootDir: tempDir,
|
||||
})
|
||||
|
||||
expect(command).not.toBeNull()
|
||||
expect(command!.value).toContain('root authority')
|
||||
expect(command!.value).toContain('nested authority')
|
||||
})
|
||||
|
||||
test('markAutonomyRunRunning/completed/failed update persisted lifecycle state for plain runs', async () => {
|
||||
const command = await createAutonomyQueuedPrompt({
|
||||
basePrompt: '<tick>12:00:00</tick>',
|
||||
trigger: 'proactive-tick',
|
||||
rootDir: tempDir,
|
||||
currentDir: tempDir,
|
||||
})
|
||||
expect(command).not.toBeNull()
|
||||
const runId = command!.autonomy!.runId
|
||||
|
||||
await markAutonomyRunRunning(runId, tempDir, 100)
|
||||
let runs = await listAutonomyRuns(tempDir)
|
||||
expect(runs[0]).toMatchObject({
|
||||
runId,
|
||||
status: 'running',
|
||||
startedAt: 100,
|
||||
})
|
||||
|
||||
await markAutonomyRunCompleted(runId, tempDir, 200)
|
||||
runs = await listAutonomyRuns(tempDir)
|
||||
expect(runs[0]).toMatchObject({
|
||||
runId,
|
||||
status: 'completed',
|
||||
endedAt: 200,
|
||||
})
|
||||
|
||||
await markAutonomyRunFailed(runId, 'boom', tempDir, 300)
|
||||
runs = await listAutonomyRuns(tempDir)
|
||||
expect(runs[0]).toMatchObject({
|
||||
runId,
|
||||
status: 'failed',
|
||||
endedAt: 300,
|
||||
error: 'boom',
|
||||
})
|
||||
})
|
||||
|
||||
test('formatters produce readable status and run listings', async () => {
|
||||
const first = await createAutonomyQueuedPrompt({
|
||||
basePrompt: 'scheduled prompt',
|
||||
trigger: 'scheduled-task',
|
||||
rootDir: tempDir,
|
||||
currentDir: tempDir,
|
||||
sourceId: 'cron-1',
|
||||
sourceLabel: 'nightly',
|
||||
})
|
||||
const second = await createAutonomyQueuedPrompt({
|
||||
basePrompt: '<tick>12:00:00</tick>',
|
||||
trigger: 'proactive-tick',
|
||||
rootDir: tempDir,
|
||||
currentDir: tempDir,
|
||||
})
|
||||
|
||||
expect(first).not.toBeNull()
|
||||
expect(second).not.toBeNull()
|
||||
await markAutonomyRunRunning(first!.autonomy!.runId, tempDir, 100)
|
||||
await markAutonomyRunCompleted(first!.autonomy!.runId, tempDir, 200)
|
||||
await markAutonomyRunFailed(
|
||||
second!.autonomy!.runId,
|
||||
'stopped',
|
||||
tempDir,
|
||||
300,
|
||||
)
|
||||
|
||||
const runs = await listAutonomyRuns(tempDir)
|
||||
const status = formatAutonomyRunsStatus(runs)
|
||||
const list = formatAutonomyRunsList(runs, 5)
|
||||
const flows = await listAutonomyFlows(tempDir)
|
||||
const flowList = formatAutonomyFlowsList(flows, 5)
|
||||
|
||||
expect(status).toContain('Autonomy runs: 2')
|
||||
expect(status).toContain('Completed: 1')
|
||||
expect(status).toContain('Failed: 1')
|
||||
expect(list).toContain(first!.autonomy!.runId)
|
||||
expect(list).toContain(second!.autonomy!.runId)
|
||||
expect(list).toContain('nightly')
|
||||
expect(list).toContain('stopped')
|
||||
expect(flowList).toBe('No autonomy flows recorded.')
|
||||
})
|
||||
|
||||
test('same-process concurrent run creation does not lose updates', async () => {
|
||||
await Promise.all([
|
||||
createAutonomyQueuedPrompt({
|
||||
basePrompt: 'scheduled one',
|
||||
trigger: 'scheduled-task',
|
||||
rootDir: tempDir,
|
||||
currentDir: tempDir,
|
||||
sourceId: 'cron-1',
|
||||
}),
|
||||
createAutonomyQueuedPrompt({
|
||||
basePrompt: 'scheduled two',
|
||||
trigger: 'scheduled-task',
|
||||
rootDir: tempDir,
|
||||
currentDir: tempDir,
|
||||
sourceId: 'cron-2',
|
||||
}),
|
||||
])
|
||||
|
||||
const runs = await listAutonomyRuns(tempDir)
|
||||
|
||||
expect(runs).toHaveLength(2)
|
||||
expect(new Set(runs.map(run => run.sourceId))).toEqual(
|
||||
new Set(['cron-1', 'cron-2']),
|
||||
)
|
||||
})
|
||||
|
||||
test('listAutonomyRuns keeps older persisted records by normalizing missing runtime and owner metadata', async () => {
|
||||
const runsPath = resolveAutonomyRunsPath(tempDir)
|
||||
await mkdir(join(tempDir, '.claude', 'autonomy'), { recursive: true })
|
||||
await writeFile(
|
||||
runsPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
runs: [
|
||||
{
|
||||
runId: 'legacy-run',
|
||||
trigger: 'scheduled-task',
|
||||
status: 'completed',
|
||||
rootDir: tempDir,
|
||||
promptPreview: 'legacy prompt',
|
||||
createdAt: 123,
|
||||
},
|
||||
],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
'utf-8',
|
||||
)
|
||||
|
||||
const [legacy] = await listAutonomyRuns(tempDir)
|
||||
|
||||
expect(legacy).toMatchObject({
|
||||
runId: 'legacy-run',
|
||||
runtime: 'automatic',
|
||||
ownerKey: 'main-thread',
|
||||
currentDir: tempDir,
|
||||
status: 'completed',
|
||||
})
|
||||
})
|
||||
|
||||
test('createAutonomyQueuedPrompt does not consume heartbeat tasks or create runs when shouldCreate rejects commit', async () => {
|
||||
await writeTempFile(
|
||||
tempDir,
|
||||
HEARTBEAT_REL,
|
||||
[
|
||||
'tasks:',
|
||||
' - name: inbox',
|
||||
' interval: 30m',
|
||||
' prompt: "Check inbox"',
|
||||
].join('\n'),
|
||||
)
|
||||
|
||||
const skipped = await createAutonomyQueuedPrompt({
|
||||
basePrompt: '<tick>12:00:00</tick>',
|
||||
trigger: 'proactive-tick',
|
||||
rootDir: tempDir,
|
||||
currentDir: tempDir,
|
||||
shouldCreate: () => false,
|
||||
})
|
||||
const committed = await createAutonomyQueuedPrompt({
|
||||
basePrompt: '<tick>12:01:00</tick>',
|
||||
trigger: 'proactive-tick',
|
||||
rootDir: tempDir,
|
||||
currentDir: tempDir,
|
||||
})
|
||||
|
||||
const runs = await listAutonomyRuns(tempDir)
|
||||
|
||||
expect(skipped).toBeNull()
|
||||
expect(committed).not.toBeNull()
|
||||
expect(committed!.value).toContain('Due HEARTBEAT.md tasks:')
|
||||
expect(runs).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('createProactiveAutonomyCommands queues one managed flow step command per due HEARTBEAT flow', async () => {
|
||||
await writeTempFile(
|
||||
tempDir,
|
||||
HEARTBEAT_REL,
|
||||
[
|
||||
'tasks:',
|
||||
' - name: inbox',
|
||||
' interval: 30m',
|
||||
' prompt: "Check inbox"',
|
||||
' - name: weekly-report',
|
||||
' interval: 7d',
|
||||
' prompt: "Ship the weekly report"',
|
||||
' steps:',
|
||||
' - name: gather',
|
||||
' prompt: "Gather weekly inputs"',
|
||||
' - name: draft',
|
||||
' prompt: "Draft the weekly report"',
|
||||
].join('\n'),
|
||||
)
|
||||
|
||||
const commands = await createProactiveAutonomyCommands({
|
||||
basePrompt: '<tick>12:00:00</tick>',
|
||||
rootDir: tempDir,
|
||||
currentDir: tempDir,
|
||||
})
|
||||
|
||||
const runs = await listAutonomyRuns(tempDir)
|
||||
const flows = await listAutonomyFlows(tempDir)
|
||||
|
||||
expect(commands).toHaveLength(2)
|
||||
expect(commands[0]!.autonomy?.trigger).toBe('proactive-tick')
|
||||
expect(commands[0]!.value).toContain('- inbox (30m): Check inbox')
|
||||
expect(commands[1]!.autonomy?.trigger).toBe('managed-flow-step')
|
||||
expect(commands[1]!.value).toContain(
|
||||
'This is step 1/2 of the managed autonomy flow',
|
||||
)
|
||||
expect(runs).toHaveLength(2)
|
||||
expect(flows).toHaveLength(1)
|
||||
expect(flows[0]).toMatchObject({
|
||||
status: 'queued',
|
||||
currentStep: 'gather',
|
||||
goal: 'Ship the weekly report',
|
||||
})
|
||||
})
|
||||
|
||||
test('finalizeAutonomyRunCompleted advances managed flows to the next queued step', async () => {
|
||||
const command = await startManagedAutonomyFlowFromHeartbeatTask({
|
||||
task: {
|
||||
name: 'weekly-report',
|
||||
interval: '7d',
|
||||
prompt: 'Ship the weekly report',
|
||||
steps: [
|
||||
{
|
||||
name: 'gather',
|
||||
prompt: 'Gather weekly inputs',
|
||||
},
|
||||
{
|
||||
name: 'draft',
|
||||
prompt: 'Draft the weekly report',
|
||||
},
|
||||
],
|
||||
},
|
||||
rootDir: tempDir,
|
||||
currentDir: tempDir,
|
||||
})
|
||||
|
||||
expect(command).not.toBeNull()
|
||||
await markAutonomyRunRunning(command!.autonomy!.runId, tempDir, 100)
|
||||
|
||||
const nextCommands = await finalizeAutonomyRunCompleted({
|
||||
runId: command!.autonomy!.runId,
|
||||
rootDir: tempDir,
|
||||
currentDir: tempDir,
|
||||
})
|
||||
|
||||
const runs = await listAutonomyRuns(tempDir)
|
||||
const [flow] = await listAutonomyFlows(tempDir)
|
||||
const detail = await getAutonomyFlowById(flow!.flowId, tempDir)
|
||||
|
||||
expect(nextCommands).toHaveLength(1)
|
||||
expect(nextCommands[0]!.autonomy?.trigger).toBe('managed-flow-step')
|
||||
expect(nextCommands[0]!.value).toContain('Current step: draft')
|
||||
expect(runs).toHaveLength(2)
|
||||
expect(flow).toMatchObject({
|
||||
status: 'queued',
|
||||
currentStep: 'draft',
|
||||
runCount: 2,
|
||||
})
|
||||
expect(detail?.stateJson?.steps.map(step => step.status)).toEqual([
|
||||
'completed',
|
||||
'queued',
|
||||
])
|
||||
})
|
||||
|
||||
test('recoverManagedAutonomyFlowPrompt rehydrates a queued managed step with the same run id', async () => {
|
||||
const command = await startManagedAutonomyFlowFromHeartbeatTask({
|
||||
task: {
|
||||
name: 'weekly-report',
|
||||
interval: '7d',
|
||||
prompt: 'Ship the weekly report',
|
||||
steps: [
|
||||
{
|
||||
name: 'gather',
|
||||
prompt: 'Gather weekly inputs',
|
||||
},
|
||||
{
|
||||
name: 'draft',
|
||||
prompt: 'Draft the weekly report',
|
||||
},
|
||||
],
|
||||
},
|
||||
rootDir: tempDir,
|
||||
currentDir: tempDir,
|
||||
})
|
||||
|
||||
const [flow] = await listAutonomyFlows(tempDir)
|
||||
const recovered = await recoverManagedAutonomyFlowPrompt({
|
||||
flowId: flow!.flowId,
|
||||
rootDir: tempDir,
|
||||
currentDir: tempDir,
|
||||
})
|
||||
|
||||
expect(recovered).not.toBeNull()
|
||||
expect(recovered!.autonomy?.runId).toBe(command!.autonomy?.runId)
|
||||
expect(recovered!.autonomy?.flowId).toBe(flow!.flowId)
|
||||
})
|
||||
})
|
||||
79
src/utils/__tests__/cronScheduler.baseline.test.ts
Normal file
79
src/utils/__tests__/cronScheduler.baseline.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
buildMissedTaskNotification,
|
||||
isRecurringTaskAged,
|
||||
} from '../cronScheduler'
|
||||
|
||||
describe('cronScheduler baseline helpers', () => {
|
||||
test('isRecurringTaskAged returns false when maxAgeMs is zero', () => {
|
||||
expect(
|
||||
isRecurringTaskAged(
|
||||
{ id: 'a', cron: '* * * * *', prompt: 'x', createdAt: 0, recurring: true },
|
||||
10_000,
|
||||
0,
|
||||
),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
test('isRecurringTaskAged only ages recurring non-permanent tasks', () => {
|
||||
expect(
|
||||
isRecurringTaskAged(
|
||||
{ id: 'a', cron: '* * * * *', prompt: 'x', createdAt: 0 },
|
||||
10_000,
|
||||
100,
|
||||
),
|
||||
).toBe(false)
|
||||
|
||||
expect(
|
||||
isRecurringTaskAged(
|
||||
{
|
||||
id: 'b',
|
||||
cron: '* * * * *',
|
||||
prompt: 'x',
|
||||
createdAt: 0,
|
||||
recurring: true,
|
||||
permanent: true,
|
||||
},
|
||||
10_000,
|
||||
100,
|
||||
),
|
||||
).toBe(false)
|
||||
|
||||
expect(
|
||||
isRecurringTaskAged(
|
||||
{ id: 'c', cron: '* * * * *', prompt: 'x', createdAt: 0, recurring: true },
|
||||
10_000,
|
||||
100,
|
||||
),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('buildMissedTaskNotification preserves AskUserQuestion safety instruction', () => {
|
||||
const msg = buildMissedTaskNotification([
|
||||
{
|
||||
id: 'a1b2c3d4',
|
||||
cron: '* * * * *',
|
||||
prompt: 'check deployment',
|
||||
createdAt: new Date('2026-04-12T10:00:00Z').getTime(),
|
||||
},
|
||||
])
|
||||
|
||||
expect(msg).toContain('AskUserQuestion')
|
||||
expect(msg).toContain('Do NOT execute this prompt yet')
|
||||
expect(msg).toContain('check deployment')
|
||||
})
|
||||
|
||||
test('buildMissedTaskNotification widens the code fence when the prompt contains backticks', () => {
|
||||
const msg = buildMissedTaskNotification([
|
||||
{
|
||||
id: 'z9y8x7w6',
|
||||
cron: '* * * * *',
|
||||
prompt: 'run ```dangerous``` only if approved',
|
||||
createdAt: new Date('2026-04-12T10:00:00Z').getTime(),
|
||||
},
|
||||
])
|
||||
|
||||
expect(msg).toContain('````')
|
||||
expect(msg).toContain('run ```dangerous``` only if approved')
|
||||
})
|
||||
})
|
||||
203
src/utils/__tests__/cronTasks.baseline.test.ts
Normal file
203
src/utils/__tests__/cronTasks.baseline.test.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
getSessionCronTasks,
|
||||
resetStateForTests,
|
||||
setOriginalCwd,
|
||||
setProjectRoot,
|
||||
} from '../../bootstrap/state'
|
||||
import {
|
||||
addCronTask,
|
||||
findMissedTasks,
|
||||
getCronFilePath,
|
||||
hasCronTasksSync,
|
||||
listAllCronTasks,
|
||||
markCronTasksFired,
|
||||
nextCronRunMs,
|
||||
oneShotJitteredNextCronRunMs,
|
||||
readCronTasks,
|
||||
removeCronTasks,
|
||||
writeCronTasks,
|
||||
} from '../cronTasks'
|
||||
import { cleanupTempDir, createTempDir } from '../../../tests/mocks/file-system'
|
||||
|
||||
let tempDir = ''
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await createTempDir('cron-baseline-')
|
||||
resetStateForTests()
|
||||
setOriginalCwd(tempDir)
|
||||
setProjectRoot(tempDir)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
resetStateForTests()
|
||||
if (tempDir) {
|
||||
await cleanupTempDir(tempDir)
|
||||
}
|
||||
})
|
||||
|
||||
describe('cronTasks baseline', () => {
|
||||
test('session-only cron tasks remain in memory and do not create the cron file', async () => {
|
||||
const id = await addCronTask('* * * * *', 'session-only prompt', true, false)
|
||||
|
||||
const tasks = await listAllCronTasks()
|
||||
|
||||
expect(id).toHaveLength(8)
|
||||
expect(getSessionCronTasks()).toHaveLength(1)
|
||||
expect(tasks).toHaveLength(1)
|
||||
expect(tasks[0]).toMatchObject({
|
||||
id,
|
||||
prompt: 'session-only prompt',
|
||||
durable: false,
|
||||
recurring: true,
|
||||
})
|
||||
expect(existsSync(getCronFilePath())).toBe(false)
|
||||
})
|
||||
|
||||
test('durable cron tasks are written to .claude/scheduled_tasks.json', async () => {
|
||||
const id = await addCronTask('* * * * *', 'durable prompt', true, true)
|
||||
|
||||
const filePath = getCronFilePath()
|
||||
const fileTasks = await readCronTasks()
|
||||
|
||||
expect(existsSync(filePath)).toBe(true)
|
||||
expect(filePath).toBe(join(tempDir, '.claude', 'scheduled_tasks.json'))
|
||||
expect(fileTasks).toHaveLength(1)
|
||||
expect(fileTasks[0]).toMatchObject({
|
||||
id,
|
||||
prompt: 'durable prompt',
|
||||
recurring: true,
|
||||
})
|
||||
expect(fileTasks[0].durable).toBeUndefined()
|
||||
})
|
||||
|
||||
test('writeCronTasks strips runtime-only durable flags from disk', async () => {
|
||||
await writeCronTasks([
|
||||
{
|
||||
id: 'abc12345',
|
||||
cron: '* * * * *',
|
||||
prompt: 'strip durable',
|
||||
createdAt: 123,
|
||||
recurring: true,
|
||||
durable: false,
|
||||
},
|
||||
])
|
||||
|
||||
const raw = await readFile(getCronFilePath(), 'utf-8')
|
||||
expect(raw).not.toContain('"durable"')
|
||||
})
|
||||
|
||||
test('hasCronTasksSync reflects whether the durable cron file has entries', async () => {
|
||||
expect(hasCronTasksSync()).toBe(false)
|
||||
|
||||
await writeCronTasks([
|
||||
{
|
||||
id: 'sync0001',
|
||||
cron: '* * * * *',
|
||||
prompt: 'present',
|
||||
createdAt: 1,
|
||||
},
|
||||
])
|
||||
|
||||
expect(hasCronTasksSync()).toBe(true)
|
||||
})
|
||||
|
||||
test('daemon-style listAllCronTasks(dir) excludes session-only tasks', async () => {
|
||||
await addCronTask('* * * * *', 'session prompt', true, false)
|
||||
const durableId = await addCronTask('* * * * *', 'durable prompt', true, true)
|
||||
|
||||
const sessionView = await listAllCronTasks()
|
||||
const daemonView = await listAllCronTasks(tempDir)
|
||||
|
||||
expect(sessionView).toHaveLength(2)
|
||||
expect(daemonView).toHaveLength(1)
|
||||
expect(daemonView[0]).toMatchObject({
|
||||
id: durableId,
|
||||
prompt: 'durable prompt',
|
||||
})
|
||||
})
|
||||
|
||||
test('removeCronTasks without dir removes session-only tasks from memory', async () => {
|
||||
const sessionId = await addCronTask('* * * * *', 'remove me', true, false)
|
||||
|
||||
await removeCronTasks([sessionId])
|
||||
|
||||
expect(getSessionCronTasks()).toHaveLength(0)
|
||||
expect(await listAllCronTasks()).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('removeCronTasks with dir does not mutate session-only task storage', async () => {
|
||||
const sessionId = await addCronTask('* * * * *', 'keep session task', true, false)
|
||||
await addCronTask('* * * * *', 'durable prompt', true, true)
|
||||
|
||||
await removeCronTasks([sessionId], tempDir)
|
||||
|
||||
expect(getSessionCronTasks()).toHaveLength(1)
|
||||
expect(getSessionCronTasks()[0]?.id).toBe(sessionId)
|
||||
})
|
||||
|
||||
test('markCronTasksFired persists lastFiredAt for durable tasks', async () => {
|
||||
await writeCronTasks([
|
||||
{
|
||||
id: 'fire0001',
|
||||
cron: '* * * * *',
|
||||
prompt: 'persist fired',
|
||||
createdAt: 100,
|
||||
recurring: true,
|
||||
},
|
||||
])
|
||||
|
||||
await markCronTasksFired(['fire0001'], 123456789)
|
||||
|
||||
const tasks = await readCronTasks()
|
||||
expect(tasks[0]?.lastFiredAt).toBe(123456789)
|
||||
})
|
||||
|
||||
test('findMissedTasks returns tasks whose first scheduled run is in the past', () => {
|
||||
const nowMs = new Date('2026-04-12T10:10:00').getTime()
|
||||
const tasks = findMissedTasks(
|
||||
[
|
||||
{
|
||||
id: 'missed01',
|
||||
cron: '* * * * *',
|
||||
prompt: 'old task',
|
||||
createdAt: new Date('2026-04-12T10:00:00').getTime(),
|
||||
},
|
||||
{
|
||||
id: 'future01',
|
||||
cron: '59 23 31 12 *',
|
||||
prompt: 'far future',
|
||||
createdAt: nowMs,
|
||||
},
|
||||
],
|
||||
nowMs,
|
||||
)
|
||||
|
||||
expect(tasks.map(t => t.id)).toEqual(['missed01'])
|
||||
})
|
||||
|
||||
test('nextCronRunMs returns null for invalid cron expressions', () => {
|
||||
expect(nextCronRunMs('invalid cron', Date.now())).toBeNull()
|
||||
})
|
||||
|
||||
test('oneShotJitteredNextCronRunMs never returns a time earlier than fromMs', () => {
|
||||
const fromMs = new Date('2026-04-12T10:59:50').getTime()
|
||||
const next = oneShotJitteredNextCronRunMs('0 11 * * *', fromMs, '00000000')
|
||||
|
||||
expect(next).not.toBeNull()
|
||||
expect(next!).toBeGreaterThanOrEqual(fromMs)
|
||||
})
|
||||
|
||||
test('jitteredNextCronRunMs returns the exact next fire time when no second match exists in range', () => {
|
||||
const fromMs = new Date('2026-04-12T10:00:00').getTime()
|
||||
const exact = nextCronRunMs('0 0 29 2 *', fromMs)
|
||||
const jittered = oneShotJitteredNextCronRunMs('0 0 29 2 *', fromMs, '89abcdef')
|
||||
|
||||
expect(exact).not.toBeNull()
|
||||
expect(jittered).not.toBeNull()
|
||||
expect(jittered!).toBeGreaterThanOrEqual(fromMs)
|
||||
})
|
||||
})
|
||||
82
src/utils/__tests__/language.test.ts
Normal file
82
src/utils/__tests__/language.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { describe, test, expect, mock } from 'bun:test'
|
||||
|
||||
// Mock dependencies before importing the module under test
|
||||
let mockPreferredLanguage: string | undefined
|
||||
let mockSystemLocale: string | undefined
|
||||
|
||||
mock.module('src/utils/config.js', () => ({
|
||||
getGlobalConfig: () => ({
|
||||
preferredLanguage: mockPreferredLanguage,
|
||||
}),
|
||||
}))
|
||||
|
||||
mock.module('src/utils/intl.js', () => ({
|
||||
getSystemLocaleLanguage: () => mockSystemLocale,
|
||||
}))
|
||||
|
||||
const { getResolvedLanguage, getLanguageDisplayName } = await import(
|
||||
'src/utils/language.js'
|
||||
)
|
||||
|
||||
describe('getResolvedLanguage', () => {
|
||||
test('returns en when config is explicitly en', () => {
|
||||
mockPreferredLanguage = 'en'
|
||||
mockSystemLocale = 'zh'
|
||||
expect(getResolvedLanguage()).toBe('en')
|
||||
})
|
||||
|
||||
test('returns zh when config is explicitly zh', () => {
|
||||
mockPreferredLanguage = 'zh'
|
||||
mockSystemLocale = 'en'
|
||||
expect(getResolvedLanguage()).toBe('zh')
|
||||
})
|
||||
|
||||
test('falls back to system locale zh when config is auto', () => {
|
||||
mockPreferredLanguage = 'auto'
|
||||
mockSystemLocale = 'zh'
|
||||
expect(getResolvedLanguage()).toBe('zh')
|
||||
})
|
||||
|
||||
test('falls back to en when config is auto and system locale is not zh', () => {
|
||||
mockPreferredLanguage = 'auto'
|
||||
mockSystemLocale = 'en'
|
||||
expect(getResolvedLanguage()).toBe('en')
|
||||
})
|
||||
|
||||
test('falls back to en when config is auto and system locale is undefined', () => {
|
||||
mockPreferredLanguage = 'auto'
|
||||
mockSystemLocale = undefined
|
||||
expect(getResolvedLanguage()).toBe('en')
|
||||
})
|
||||
|
||||
test('falls back to auto behavior when config preferredLanguage is undefined', () => {
|
||||
mockPreferredLanguage = undefined
|
||||
mockSystemLocale = 'zh'
|
||||
expect(getResolvedLanguage()).toBe('zh')
|
||||
})
|
||||
|
||||
test('defaults to en when both config and locale are undefined', () => {
|
||||
mockPreferredLanguage = undefined
|
||||
mockSystemLocale = undefined
|
||||
expect(getResolvedLanguage()).toBe('en')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getLanguageDisplayName', () => {
|
||||
test('returns Auto (follow system) for auto', () => {
|
||||
expect(getLanguageDisplayName('auto')).toBe('Auto (follow system)')
|
||||
})
|
||||
|
||||
test('returns English for en', () => {
|
||||
expect(getLanguageDisplayName('en')).toBe('English')
|
||||
})
|
||||
|
||||
test('returns 中文 for zh', () => {
|
||||
expect(getLanguageDisplayName('zh')).toBe('中文')
|
||||
})
|
||||
|
||||
test('returns the input string for unknown language codes', () => {
|
||||
expect(getLanguageDisplayName('fr')).toBe('fr')
|
||||
expect(getLanguageDisplayName('unknown')).toBe('unknown')
|
||||
})
|
||||
})
|
||||
124
src/utils/__tests__/pipeMuteState.test.ts
Normal file
124
src/utils/__tests__/pipeMuteState.test.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { describe, test, expect, beforeEach } from 'bun:test'
|
||||
import {
|
||||
setMasterMutedPipes,
|
||||
isMasterPipeMuted,
|
||||
removeMasterPipeMute,
|
||||
clearMasterMutedPipes,
|
||||
addSendOverride,
|
||||
removeSendOverride,
|
||||
hasSendOverride,
|
||||
clearSendOverrides,
|
||||
} from '../pipeMuteState.js'
|
||||
|
||||
describe('setMasterMutedPipes', () => {
|
||||
beforeEach(() => {
|
||||
clearMasterMutedPipes()
|
||||
clearSendOverrides()
|
||||
})
|
||||
|
||||
test('sets muted pipes from iterable', () => {
|
||||
setMasterMutedPipes(['pipe-a', 'pipe-b'])
|
||||
expect(isMasterPipeMuted('pipe-a')).toBe(true)
|
||||
expect(isMasterPipeMuted('pipe-b')).toBe(true)
|
||||
expect(isMasterPipeMuted('pipe-c')).toBe(false)
|
||||
})
|
||||
|
||||
test('replaces previous muted set', () => {
|
||||
setMasterMutedPipes(['pipe-a'])
|
||||
setMasterMutedPipes(['pipe-b'])
|
||||
expect(isMasterPipeMuted('pipe-a')).toBe(false)
|
||||
expect(isMasterPipeMuted('pipe-b')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isMasterPipeMuted', () => {
|
||||
beforeEach(() => {
|
||||
clearMasterMutedPipes()
|
||||
})
|
||||
|
||||
test('returns false for unknown pipe', () => {
|
||||
expect(isMasterPipeMuted('unknown')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeMasterPipeMute', () => {
|
||||
beforeEach(() => {
|
||||
clearMasterMutedPipes()
|
||||
})
|
||||
|
||||
test('removes a single muted pipe', () => {
|
||||
setMasterMutedPipes(['pipe-a', 'pipe-b'])
|
||||
removeMasterPipeMute('pipe-a')
|
||||
expect(isMasterPipeMuted('pipe-a')).toBe(false)
|
||||
expect(isMasterPipeMuted('pipe-b')).toBe(true)
|
||||
})
|
||||
|
||||
test('no-ops for non-existent pipe', () => {
|
||||
removeMasterPipeMute('nonexistent')
|
||||
expect(isMasterPipeMuted('nonexistent')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearMasterMutedPipes', () => {
|
||||
test('clears all muted pipes', () => {
|
||||
setMasterMutedPipes(['pipe-a', 'pipe-b', 'pipe-c'])
|
||||
clearMasterMutedPipes()
|
||||
expect(isMasterPipeMuted('pipe-a')).toBe(false)
|
||||
expect(isMasterPipeMuted('pipe-b')).toBe(false)
|
||||
expect(isMasterPipeMuted('pipe-c')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('addSendOverride', () => {
|
||||
beforeEach(() => {
|
||||
clearSendOverrides()
|
||||
})
|
||||
|
||||
test('adds a send override', () => {
|
||||
addSendOverride('pipe-x')
|
||||
expect(hasSendOverride('pipe-x')).toBe(true)
|
||||
})
|
||||
|
||||
test('adding same override twice is idempotent', () => {
|
||||
addSendOverride('pipe-x')
|
||||
addSendOverride('pipe-x')
|
||||
expect(hasSendOverride('pipe-x')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeSendOverride', () => {
|
||||
beforeEach(() => {
|
||||
clearSendOverrides()
|
||||
})
|
||||
|
||||
test('removes a send override', () => {
|
||||
addSendOverride('pipe-x')
|
||||
removeSendOverride('pipe-x')
|
||||
expect(hasSendOverride('pipe-x')).toBe(false)
|
||||
})
|
||||
|
||||
test('no-ops for non-existent override', () => {
|
||||
removeSendOverride('nonexistent')
|
||||
expect(hasSendOverride('nonexistent')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasSendOverride', () => {
|
||||
beforeEach(() => {
|
||||
clearSendOverrides()
|
||||
})
|
||||
|
||||
test('returns false when no overrides set', () => {
|
||||
expect(hasSendOverride('pipe-x')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearSendOverrides', () => {
|
||||
test('clears all send overrides', () => {
|
||||
addSendOverride('pipe-a')
|
||||
addSendOverride('pipe-b')
|
||||
clearSendOverrides()
|
||||
expect(hasSendOverride('pipe-a')).toBe(false)
|
||||
expect(hasSendOverride('pipe-b')).toBe(false)
|
||||
})
|
||||
})
|
||||
93
src/utils/__tests__/taskSummary.test.ts
Normal file
93
src/utils/__tests__/taskSummary.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Tests for src/utils/taskSummary.ts
|
||||
*
|
||||
* Covers: shouldGenerateTaskSummary, maybeGenerateTaskSummary
|
||||
*
|
||||
* Note: bun:bundle's feature() is a compile-time construct and cannot be
|
||||
* trivially mocked at test time. We test maybeGenerateTaskSummary (which
|
||||
* is called unconditionally) and the rate-limit behavior indirectly.
|
||||
*/
|
||||
import { describe, expect, test, mock, beforeEach } from 'bun:test'
|
||||
|
||||
// ─── mocks ──────────────────────────────────────────────────────────────────
|
||||
|
||||
let _updateCalls: any[] = []
|
||||
|
||||
mock.module('bun:bundle', () => ({
|
||||
feature: (_name: string) => false,
|
||||
}))
|
||||
|
||||
mock.module('../concurrentSessions.js', () => ({
|
||||
isBgSession: () => false,
|
||||
updateSessionActivity: async (data: any) => {
|
||||
_updateCalls.push(data)
|
||||
},
|
||||
}))
|
||||
|
||||
mock.module('../debug.js', () => ({
|
||||
logForDebugging: () => {},
|
||||
}))
|
||||
|
||||
// ─── import after mocks ─────────────────────────────────────────────────────
|
||||
|
||||
const { shouldGenerateTaskSummary, maybeGenerateTaskSummary } = await import(
|
||||
'../taskSummary.js'
|
||||
)
|
||||
|
||||
// ─── tests ──────────────────────────────────────────────────────────────────
|
||||
|
||||
beforeEach(() => {
|
||||
_updateCalls = []
|
||||
})
|
||||
|
||||
describe('shouldGenerateTaskSummary', () => {
|
||||
test('returns false when feature is disabled', () => {
|
||||
// bun:bundle feature mock returns false
|
||||
expect(shouldGenerateTaskSummary()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('maybeGenerateTaskSummary', () => {
|
||||
test('does not throw with empty messages', () => {
|
||||
expect(() =>
|
||||
maybeGenerateTaskSummary({ forkContextMessages: [] }),
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
test('does not throw with undefined messages', () => {
|
||||
expect(() => maybeGenerateTaskSummary({})).not.toThrow()
|
||||
})
|
||||
|
||||
test('does not throw with assistant message containing tool_use', () => {
|
||||
expect(() =>
|
||||
maybeGenerateTaskSummary({
|
||||
forkContextMessages: [
|
||||
{
|
||||
type: 'assistant',
|
||||
message: {
|
||||
content: [
|
||||
{ type: 'text', text: 'Let me check' },
|
||||
{ type: 'tool_use', name: 'bash' },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
test('does not throw with non-array content', () => {
|
||||
expect(() =>
|
||||
maybeGenerateTaskSummary({
|
||||
forkContextMessages: [
|
||||
{
|
||||
type: 'assistant',
|
||||
message: {
|
||||
content: 'plain text response',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
).not.toThrow()
|
||||
})
|
||||
})
|
||||
522
src/utils/autonomyAuthority.ts
Normal file
522
src/utils/autonomyAuthority.ts
Normal file
@@ -0,0 +1,522 @@
|
||||
import {
|
||||
basename,
|
||||
dirname,
|
||||
isAbsolute,
|
||||
join,
|
||||
relative,
|
||||
resolve,
|
||||
} from 'node:path'
|
||||
import { getProjectRoot } from '../bootstrap/state.js'
|
||||
import { getCwd } from './cwd.js'
|
||||
import { getFsImplementation } from './fsOperations.js'
|
||||
import { normalizePathForConfigKey } from './path.js'
|
||||
|
||||
export const AUTONOMY_DIR = join('.claude', 'autonomy')
|
||||
export const AUTONOMY_DIR_POSIX = '.claude/autonomy'
|
||||
export const AUTONOMY_AGENTS_FILENAME = 'AGENTS.md'
|
||||
export const AUTONOMY_HEARTBEAT_FILENAME = 'HEARTBEAT.md'
|
||||
export const AUTONOMY_AGENTS_PATH_POSIX = `${AUTONOMY_DIR_POSIX}/${AUTONOMY_AGENTS_FILENAME}`
|
||||
export const AUTONOMY_HEARTBEAT_PATH_POSIX = `${AUTONOMY_DIR_POSIX}/${AUTONOMY_HEARTBEAT_FILENAME}`
|
||||
|
||||
export type HeartbeatAuthorityTask = {
|
||||
name: string
|
||||
interval: string
|
||||
prompt: string
|
||||
steps: HeartbeatAuthorityTaskStep[]
|
||||
}
|
||||
|
||||
export type HeartbeatAuthorityTaskStep = {
|
||||
name: string
|
||||
prompt: string
|
||||
waitFor?: string
|
||||
}
|
||||
|
||||
export type AutonomyAuthorityFile = {
|
||||
path: string
|
||||
relativePath: string
|
||||
content: string
|
||||
}
|
||||
|
||||
export type AutonomyAuthoritySnapshot = {
|
||||
rootDir: string
|
||||
currentDir: string
|
||||
agentsFiles: AutonomyAuthorityFile[]
|
||||
agentsContent: string | null
|
||||
heartbeatFile: AutonomyAuthorityFile | null
|
||||
heartbeatContent: string | null
|
||||
heartbeatTasks: HeartbeatAuthorityTask[]
|
||||
}
|
||||
|
||||
type AutonomyAuthorityParams = {
|
||||
rootDir?: string
|
||||
currentDir?: string
|
||||
}
|
||||
|
||||
export type AutonomyTriggerKind =
|
||||
| 'proactive-tick'
|
||||
| 'scheduled-task'
|
||||
| 'managed-flow-step'
|
||||
|
||||
export type PreparedAutonomyTurn = {
|
||||
rootDir: string
|
||||
currentDir: string
|
||||
trigger: AutonomyTriggerKind
|
||||
prompt: string
|
||||
dueHeartbeatTasks: HeartbeatAuthorityTask[]
|
||||
nowMs: number
|
||||
}
|
||||
|
||||
const heartbeatTaskLastRunByKey = new Map<string, number>()
|
||||
|
||||
function isPathWithinRoot(rootDir: string, currentDir: string): boolean {
|
||||
const delta = relative(rootDir, currentDir)
|
||||
return delta === '' || (!delta.startsWith('..') && !isAbsolute(delta))
|
||||
}
|
||||
|
||||
function listAuthorityDirectories(
|
||||
rootDir: string,
|
||||
currentDir: string,
|
||||
): string[] {
|
||||
const resolvedRoot = resolve(rootDir)
|
||||
const resolvedCurrent = resolve(currentDir)
|
||||
if (!isPathWithinRoot(resolvedRoot, resolvedCurrent)) {
|
||||
return [resolvedRoot]
|
||||
}
|
||||
|
||||
const dirs: string[] = []
|
||||
let cursor = resolvedCurrent
|
||||
for (;;) {
|
||||
dirs.push(cursor)
|
||||
if (cursor === resolvedRoot) {
|
||||
break
|
||||
}
|
||||
const parent = dirname(cursor)
|
||||
if (parent === cursor) {
|
||||
break
|
||||
}
|
||||
cursor = parent
|
||||
}
|
||||
return dirs.reverse()
|
||||
}
|
||||
|
||||
async function readAuthorityFile(
|
||||
filePath: string,
|
||||
rootDir: string,
|
||||
): Promise<AutonomyAuthorityFile | null> {
|
||||
try {
|
||||
const content = (await getFsImplementation().readFile(filePath, {
|
||||
encoding: 'utf-8',
|
||||
})) as string
|
||||
const trimmed = content.trim()
|
||||
if (!trimmed) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
path: filePath,
|
||||
relativePath:
|
||||
normalizePathForConfigKey(relative(rootDir, filePath)) ||
|
||||
basename(filePath),
|
||||
content: trimmed,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function mergeAgentsAuthority(files: AutonomyAuthorityFile[]): string | null {
|
||||
if (files.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return files
|
||||
.map(file => `## ${file.relativePath}\n${file.content}`)
|
||||
.join('\n\n')
|
||||
}
|
||||
|
||||
export function parseHeartbeatAuthorityTasks(
|
||||
content: string,
|
||||
): HeartbeatAuthorityTask[] {
|
||||
const tasks: HeartbeatAuthorityTask[] = []
|
||||
const lines = content.split('\n')
|
||||
const getIndent = (line: string): number =>
|
||||
line.length - line.trimStart().length
|
||||
const parseScalar = (line: string, key: string): string =>
|
||||
line
|
||||
.replace(key, '')
|
||||
.trim()
|
||||
.replace(/^["']|["']$/g, '')
|
||||
|
||||
function parseSteps(
|
||||
startIndex: number,
|
||||
stepsIndent: number,
|
||||
): { steps: HeartbeatAuthorityTaskStep[]; nextIndex: number } {
|
||||
const steps: HeartbeatAuthorityTaskStep[] = []
|
||||
let index = startIndex
|
||||
|
||||
while (index < lines.length) {
|
||||
const line = lines[index]!
|
||||
const trimmed = line.trim()
|
||||
const indent = getIndent(line)
|
||||
|
||||
if (!trimmed) {
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (indent <= stepsIndent) {
|
||||
break
|
||||
}
|
||||
|
||||
if (!trimmed.startsWith('- name:')) {
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
const stepIndent = indent
|
||||
const name = parseScalar(trimmed, '- name:')
|
||||
let prompt = ''
|
||||
let waitFor: string | undefined
|
||||
index += 1
|
||||
|
||||
while (index < lines.length) {
|
||||
const nextLine = lines[index]!
|
||||
const nextTrimmed = nextLine.trim()
|
||||
const nextIndent = getIndent(nextLine)
|
||||
|
||||
if (!nextTrimmed) {
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (nextIndent <= stepIndent) {
|
||||
break
|
||||
}
|
||||
|
||||
if (nextTrimmed.startsWith('prompt:')) {
|
||||
prompt = parseScalar(nextTrimmed, 'prompt:')
|
||||
} else if (nextTrimmed.startsWith('wait_for:')) {
|
||||
waitFor = parseScalar(nextTrimmed, 'wait_for:')
|
||||
}
|
||||
|
||||
index += 1
|
||||
}
|
||||
|
||||
if (name && prompt) {
|
||||
steps.push({
|
||||
name,
|
||||
prompt,
|
||||
...(waitFor ? { waitFor } : {}),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { steps, nextIndex: index }
|
||||
}
|
||||
|
||||
const tasksLineIndex = lines.findIndex(line => line.trim() === 'tasks:')
|
||||
if (tasksLineIndex === -1) {
|
||||
return tasks
|
||||
}
|
||||
|
||||
const tasksIndent = getIndent(lines[tasksLineIndex]!)
|
||||
let index = tasksLineIndex + 1
|
||||
|
||||
while (index < lines.length) {
|
||||
const line = lines[index]!
|
||||
const trimmed = line.trim()
|
||||
const indent = getIndent(line)
|
||||
|
||||
if (!trimmed) {
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (indent <= tasksIndent) {
|
||||
break
|
||||
}
|
||||
|
||||
if (!trimmed.startsWith('- name:')) {
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
const taskIndent = indent
|
||||
const name = parseScalar(trimmed, '- name:')
|
||||
let interval = ''
|
||||
let prompt = ''
|
||||
let steps: HeartbeatAuthorityTaskStep[] = []
|
||||
index += 1
|
||||
|
||||
while (index < lines.length) {
|
||||
const nextLine = lines[index]!
|
||||
const nextTrimmed = nextLine.trim()
|
||||
const nextIndent = getIndent(nextLine)
|
||||
|
||||
if (!nextTrimmed) {
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (nextIndent <= tasksIndent) {
|
||||
break
|
||||
}
|
||||
|
||||
if (nextIndent === taskIndent && nextTrimmed.startsWith('- name:')) {
|
||||
break
|
||||
}
|
||||
|
||||
if (nextIndent <= taskIndent) {
|
||||
break
|
||||
}
|
||||
|
||||
if (nextTrimmed.startsWith('interval:')) {
|
||||
interval = parseScalar(nextTrimmed, 'interval:')
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (nextTrimmed.startsWith('prompt:')) {
|
||||
prompt = parseScalar(nextTrimmed, 'prompt:')
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (nextTrimmed === 'steps:') {
|
||||
const parsed = parseSteps(index + 1, nextIndent)
|
||||
steps = parsed.steps
|
||||
index = parsed.nextIndex
|
||||
continue
|
||||
}
|
||||
|
||||
index += 1
|
||||
}
|
||||
|
||||
if (name && interval && prompt) {
|
||||
tasks.push({
|
||||
name,
|
||||
interval,
|
||||
prompt,
|
||||
steps,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return tasks
|
||||
}
|
||||
|
||||
function parseHeartbeatIntervalMs(interval: string): number | null {
|
||||
const match = interval.trim().match(/^(\d+)\s*(ms|s|m|h|d)?$/i)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
const value = Number.parseInt(match[1]!, 10)
|
||||
const unit = (match[2] ?? 'm').toLowerCase()
|
||||
switch (unit) {
|
||||
case 'ms':
|
||||
return value
|
||||
case 's':
|
||||
return value * 1_000
|
||||
case 'm':
|
||||
return value * 60_000
|
||||
case 'h':
|
||||
return value * 60 * 60_000
|
||||
case 'd':
|
||||
return value * 24 * 60 * 60_000
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function heartbeatTaskKey(
|
||||
rootDir: string,
|
||||
task: HeartbeatAuthorityTask,
|
||||
): string {
|
||||
return `${rootDir}::${task.name}::${task.interval}::${task.prompt}`
|
||||
}
|
||||
|
||||
function collectDueHeartbeatTasks(
|
||||
snapshot: AutonomyAuthoritySnapshot,
|
||||
nowMs: number,
|
||||
): HeartbeatAuthorityTask[] {
|
||||
const due: HeartbeatAuthorityTask[] = []
|
||||
for (const task of snapshot.heartbeatTasks) {
|
||||
const intervalMs = parseHeartbeatIntervalMs(task.interval)
|
||||
if (intervalMs == null) {
|
||||
continue
|
||||
}
|
||||
const key = heartbeatTaskKey(snapshot.rootDir, task)
|
||||
const lastRunMs = heartbeatTaskLastRunByKey.get(key)
|
||||
if (lastRunMs !== undefined && nowMs - lastRunMs < intervalMs) {
|
||||
continue
|
||||
}
|
||||
due.push(task)
|
||||
}
|
||||
return due
|
||||
}
|
||||
|
||||
function markHeartbeatTasksConsumed(
|
||||
snapshot: AutonomyAuthoritySnapshot,
|
||||
tasks: HeartbeatAuthorityTask[],
|
||||
nowMs: number,
|
||||
): void {
|
||||
for (const task of tasks) {
|
||||
heartbeatTaskLastRunByKey.set(
|
||||
heartbeatTaskKey(snapshot.rootDir, task),
|
||||
nowMs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function resetAutonomyAuthorityForTests(): void {
|
||||
heartbeatTaskLastRunByKey.clear()
|
||||
}
|
||||
|
||||
export async function loadAutonomyAuthority(
|
||||
params: AutonomyAuthorityParams = {},
|
||||
): Promise<AutonomyAuthoritySnapshot> {
|
||||
const rootDir = resolve(params.rootDir ?? getProjectRoot())
|
||||
const currentDir = resolve(params.currentDir ?? getCwd())
|
||||
const authorityDirs = listAuthorityDirectories(rootDir, currentDir)
|
||||
|
||||
const [agentsResults, heartbeatFile] = await Promise.all([
|
||||
Promise.all(
|
||||
authorityDirs.map(async dir =>
|
||||
readAuthorityFile(
|
||||
join(dir, AUTONOMY_DIR, AUTONOMY_AGENTS_FILENAME),
|
||||
rootDir,
|
||||
),
|
||||
),
|
||||
),
|
||||
readAuthorityFile(
|
||||
join(rootDir, AUTONOMY_DIR, AUTONOMY_HEARTBEAT_FILENAME),
|
||||
rootDir,
|
||||
),
|
||||
])
|
||||
const agentsFiles = agentsResults.filter(
|
||||
(file): file is AutonomyAuthorityFile => file !== null,
|
||||
)
|
||||
|
||||
return {
|
||||
rootDir,
|
||||
currentDir,
|
||||
agentsFiles,
|
||||
agentsContent: mergeAgentsAuthority(agentsFiles),
|
||||
heartbeatFile,
|
||||
heartbeatContent: heartbeatFile?.content ?? null,
|
||||
heartbeatTasks: heartbeatFile
|
||||
? parseHeartbeatAuthorityTasks(heartbeatFile.content)
|
||||
: [],
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildAutonomyTurnPrompt(params: {
|
||||
basePrompt: string
|
||||
trigger: AutonomyTriggerKind
|
||||
rootDir?: string
|
||||
currentDir?: string
|
||||
nowMs?: number
|
||||
}): Promise<string> {
|
||||
const prepared = await prepareAutonomyTurnPrompt(params)
|
||||
commitPreparedAutonomyTurn(prepared)
|
||||
return prepared.prompt
|
||||
}
|
||||
|
||||
export async function prepareAutonomyTurnPrompt(params: {
|
||||
basePrompt: string
|
||||
trigger: AutonomyTriggerKind
|
||||
rootDir?: string
|
||||
currentDir?: string
|
||||
nowMs?: number
|
||||
}): Promise<PreparedAutonomyTurn> {
|
||||
const snapshot = await loadAutonomyAuthority({
|
||||
rootDir: params.rootDir,
|
||||
currentDir: params.currentDir,
|
||||
})
|
||||
const nowMs = params.nowMs ?? Date.now()
|
||||
const dueHeartbeatTasks =
|
||||
params.trigger === 'proactive-tick'
|
||||
? collectDueHeartbeatTasks(snapshot, nowMs)
|
||||
: []
|
||||
const duePromptTasks = dueHeartbeatTasks.filter(
|
||||
task => task.steps.length === 0,
|
||||
)
|
||||
|
||||
const sections: string[] = []
|
||||
if (snapshot.agentsContent) {
|
||||
sections.push(
|
||||
`Workspace authority from ${AUTONOMY_AGENTS_FILENAME}:\n${snapshot.agentsContent}`,
|
||||
)
|
||||
}
|
||||
if (snapshot.heartbeatContent) {
|
||||
sections.push(
|
||||
`Workspace heartbeat guidance from ${AUTONOMY_HEARTBEAT_FILENAME}:\n${snapshot.heartbeatContent}`,
|
||||
)
|
||||
}
|
||||
if (duePromptTasks.length > 0) {
|
||||
sections.push(
|
||||
[
|
||||
`Due ${AUTONOMY_HEARTBEAT_FILENAME} tasks:`,
|
||||
...duePromptTasks.map(
|
||||
task => `- ${task.name} (${task.interval}): ${task.prompt}`,
|
||||
),
|
||||
].join('\n'),
|
||||
)
|
||||
}
|
||||
|
||||
if (sections.length === 0) {
|
||||
return {
|
||||
rootDir: snapshot.rootDir,
|
||||
currentDir: snapshot.currentDir,
|
||||
trigger: params.trigger,
|
||||
prompt: params.basePrompt,
|
||||
dueHeartbeatTasks,
|
||||
nowMs,
|
||||
}
|
||||
}
|
||||
|
||||
const prelude =
|
||||
params.trigger === 'proactive-tick'
|
||||
? 'This is an autonomous proactive turn. Follow the workspace authority below before acting.'
|
||||
: 'This prompt was generated automatically. Follow the workspace authority below before acting.'
|
||||
|
||||
return {
|
||||
rootDir: snapshot.rootDir,
|
||||
currentDir: snapshot.currentDir,
|
||||
trigger: params.trigger,
|
||||
prompt: [
|
||||
prelude,
|
||||
'<autonomy_authority>',
|
||||
...sections,
|
||||
'</autonomy_authority>',
|
||||
params.basePrompt,
|
||||
].join('\n\n'),
|
||||
dueHeartbeatTasks,
|
||||
nowMs,
|
||||
}
|
||||
}
|
||||
|
||||
export function commitPreparedAutonomyTurn(
|
||||
prepared: PreparedAutonomyTurn,
|
||||
): void {
|
||||
if (
|
||||
prepared.trigger !== 'proactive-tick' ||
|
||||
prepared.dueHeartbeatTasks.length === 0
|
||||
) {
|
||||
return
|
||||
}
|
||||
const snapshot: AutonomyAuthoritySnapshot = {
|
||||
rootDir: prepared.rootDir,
|
||||
currentDir: prepared.currentDir,
|
||||
agentsFiles: [],
|
||||
agentsContent: null,
|
||||
heartbeatFile: null,
|
||||
heartbeatContent: null,
|
||||
heartbeatTasks: prepared.dueHeartbeatTasks,
|
||||
}
|
||||
markHeartbeatTasksConsumed(
|
||||
snapshot,
|
||||
prepared.dueHeartbeatTasks,
|
||||
prepared.nowMs,
|
||||
)
|
||||
}
|
||||
1057
src/utils/autonomyFlows.ts
Normal file
1057
src/utils/autonomyFlows.ts
Normal file
File diff suppressed because it is too large
Load Diff
48
src/utils/autonomyPersistence.ts
Normal file
48
src/utils/autonomyPersistence.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { mkdir, writeFile } from 'fs/promises'
|
||||
import { join, resolve } from 'path'
|
||||
import { lock } from './lockfile.js'
|
||||
|
||||
const persistenceLocks = new Map<string, Promise<void>>()
|
||||
|
||||
export async function withAutonomyPersistenceLock<T>(
|
||||
rootDir: string,
|
||||
fn: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const key = resolve(rootDir)
|
||||
const lockPath = join(key, '.claude', 'autonomy', '.lock')
|
||||
const previous = persistenceLocks.get(key) ?? Promise.resolve()
|
||||
|
||||
let release!: () => void
|
||||
const current = new Promise<void>(resolve => {
|
||||
release = resolve
|
||||
})
|
||||
persistenceLocks.set(
|
||||
key,
|
||||
previous.then(() => current),
|
||||
)
|
||||
|
||||
await previous
|
||||
try {
|
||||
await mkdir(join(key, '.claude', 'autonomy'), { recursive: true })
|
||||
await writeFile(lockPath, '', { flag: 'a' })
|
||||
const unlock = await lock(lockPath, {
|
||||
lockfilePath: `${lockPath}.lock`,
|
||||
retries: {
|
||||
retries: 10,
|
||||
factor: 1.2,
|
||||
minTimeout: 10,
|
||||
maxTimeout: 100,
|
||||
},
|
||||
})
|
||||
try {
|
||||
return await fn()
|
||||
} finally {
|
||||
await unlock().catch(() => {})
|
||||
}
|
||||
} finally {
|
||||
release()
|
||||
if (persistenceLocks.get(key) === current) {
|
||||
persistenceLocks.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
797
src/utils/autonomyRuns.ts
Normal file
797
src/utils/autonomyRuns.ts
Normal file
@@ -0,0 +1,797 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import { mkdir, writeFile } from 'fs/promises'
|
||||
import { dirname, join, resolve } from 'path'
|
||||
import { getProjectRoot } from '../bootstrap/state.js'
|
||||
import type { MessageOrigin } from '../types/message.js'
|
||||
import type { QueuedCommand } from '../types/textInputTypes.js'
|
||||
import {
|
||||
AUTONOMY_DIR,
|
||||
buildAutonomyTurnPrompt,
|
||||
commitPreparedAutonomyTurn,
|
||||
prepareAutonomyTurnPrompt,
|
||||
type AutonomyTriggerKind,
|
||||
type HeartbeatAuthorityTask,
|
||||
} from './autonomyAuthority.js'
|
||||
import { getCwd } from './cwd.js'
|
||||
import {
|
||||
DEFAULT_AUTONOMY_OWNER_KEY,
|
||||
getAutonomyFlowById,
|
||||
markManagedAutonomyFlowStepCancelled,
|
||||
markManagedAutonomyFlowStepCompleted,
|
||||
markManagedAutonomyFlowStepFailed,
|
||||
markManagedAutonomyFlowStepRunning,
|
||||
queueManagedAutonomyFlowStepRun,
|
||||
resumeManagedAutonomyFlow,
|
||||
startManagedAutonomyFlow,
|
||||
type AutonomyFlowRecord,
|
||||
type AutonomyFlowSyncMode,
|
||||
type ManagedAutonomyFlowStepDefinition,
|
||||
} from './autonomyFlows.js'
|
||||
import { withAutonomyPersistenceLock } from './autonomyPersistence.js'
|
||||
import { getFsImplementation } from './fsOperations.js'
|
||||
|
||||
const AUTONOMY_RUNS_MAX = 200
|
||||
const AUTONOMY_RUNS_RELATIVE_PATH = join(AUTONOMY_DIR, 'runs.json')
|
||||
|
||||
export type AutonomyRunStatus =
|
||||
| 'queued'
|
||||
| 'running'
|
||||
| 'completed'
|
||||
| 'failed'
|
||||
| 'cancelled'
|
||||
|
||||
export type AutonomyRunRuntime = 'automatic' | 'flow_step'
|
||||
|
||||
export type AutonomyRunRecord = {
|
||||
runId: string
|
||||
runtime: AutonomyRunRuntime
|
||||
trigger: AutonomyTriggerKind
|
||||
status: AutonomyRunStatus
|
||||
rootDir: string
|
||||
currentDir: string
|
||||
ownerKey: string
|
||||
sourceId?: string
|
||||
sourceLabel?: string
|
||||
parentFlowId?: string
|
||||
parentFlowKey?: string
|
||||
parentFlowSyncMode?: AutonomyFlowSyncMode
|
||||
flowStepId?: string
|
||||
flowStepName?: string
|
||||
promptPreview: string
|
||||
createdAt: number
|
||||
startedAt?: number
|
||||
endedAt?: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
type AutonomyRunsFile = {
|
||||
runs: AutonomyRunRecord[]
|
||||
}
|
||||
|
||||
type AutonomyRunFlowRef = {
|
||||
flowId: string
|
||||
flowKey: string
|
||||
syncMode: AutonomyFlowSyncMode
|
||||
ownerKey: string
|
||||
stepId: string
|
||||
stepName: string
|
||||
}
|
||||
|
||||
function truncatePromptPreview(prompt: string): string {
|
||||
const singleLine = prompt.replace(/\s+/g, ' ').trim()
|
||||
return singleLine.length <= 240
|
||||
? singleLine
|
||||
: `${singleLine.slice(0, 237)}...`
|
||||
}
|
||||
|
||||
/** A persisted record may lack fields that were added after the initial schema. */
|
||||
type PersistedAutonomyRunRecord = Omit<
|
||||
AutonomyRunRecord,
|
||||
'runtime' | 'currentDir' | 'ownerKey'
|
||||
> &
|
||||
Partial<Pick<AutonomyRunRecord, 'runtime' | 'currentDir' | 'ownerKey'>>
|
||||
|
||||
function cloneRunRecord(run: AutonomyRunRecord): AutonomyRunRecord {
|
||||
return { ...run }
|
||||
}
|
||||
|
||||
function normalizePersistedRunRecord(
|
||||
run: PersistedAutonomyRunRecord,
|
||||
): AutonomyRunRecord {
|
||||
return {
|
||||
...run,
|
||||
runtime: run.runtime === 'flow_step' ? 'flow_step' : 'automatic',
|
||||
currentDir: run.currentDir ?? run.rootDir,
|
||||
ownerKey: run.ownerKey ?? DEFAULT_AUTONOMY_OWNER_KEY,
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveAutonomyRunsPath(
|
||||
rootDir: string = getProjectRoot(),
|
||||
): string {
|
||||
return join(resolve(rootDir), AUTONOMY_RUNS_RELATIVE_PATH)
|
||||
}
|
||||
|
||||
export async function listAutonomyRuns(
|
||||
rootDir: string = getProjectRoot(),
|
||||
): Promise<AutonomyRunRecord[]> {
|
||||
try {
|
||||
const raw = (await getFsImplementation().readFile(
|
||||
resolveAutonomyRunsPath(rootDir),
|
||||
{
|
||||
encoding: 'utf-8',
|
||||
},
|
||||
)) as string
|
||||
const parsed = JSON.parse(raw) as { runs?: unknown[] }
|
||||
if (!Array.isArray(parsed.runs)) {
|
||||
return []
|
||||
}
|
||||
return (parsed.runs as Record<string, unknown>[])
|
||||
.filter(
|
||||
(run): run is PersistedAutonomyRunRecord & Record<string, unknown> => {
|
||||
return Boolean(
|
||||
run &&
|
||||
typeof run.runId === 'string' &&
|
||||
typeof run.trigger === 'string' &&
|
||||
typeof run.status === 'string' &&
|
||||
typeof run.rootDir === 'string' &&
|
||||
typeof run.promptPreview === 'string' &&
|
||||
typeof run.createdAt === 'number',
|
||||
)
|
||||
},
|
||||
)
|
||||
.map(normalizePersistedRunRecord)
|
||||
.sort((left, right) => right.createdAt - left.createdAt)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async function writeAutonomyRuns(
|
||||
runs: AutonomyRunRecord[],
|
||||
rootDir: string = getProjectRoot(),
|
||||
): Promise<void> {
|
||||
const path = resolveAutonomyRunsPath(rootDir)
|
||||
await mkdir(dirname(path), { recursive: true })
|
||||
await writeFile(
|
||||
path,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
runs: runs
|
||||
.slice()
|
||||
.map(cloneRunRecord)
|
||||
.sort((left, right) => right.createdAt - left.createdAt)
|
||||
.slice(0, AUTONOMY_RUNS_MAX),
|
||||
} satisfies AutonomyRunsFile,
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
'utf-8',
|
||||
)
|
||||
}
|
||||
|
||||
async function updateAutonomyRun(
|
||||
runId: string,
|
||||
updater: (current: AutonomyRunRecord) => AutonomyRunRecord,
|
||||
rootDir: string = getProjectRoot(),
|
||||
): Promise<AutonomyRunRecord | null> {
|
||||
return withAutonomyPersistenceLock(rootDir, async () => {
|
||||
const runs = await listAutonomyRuns(rootDir)
|
||||
const index = runs.findIndex(run => run.runId === runId)
|
||||
if (index === -1) {
|
||||
return null
|
||||
}
|
||||
const updated = cloneRunRecord(updater(cloneRunRecord(runs[index]!)))
|
||||
runs[index] = updated
|
||||
await writeAutonomyRuns(runs, rootDir)
|
||||
return updated
|
||||
})
|
||||
}
|
||||
|
||||
export async function getAutonomyRunById(
|
||||
runId: string,
|
||||
rootDir: string = getProjectRoot(),
|
||||
): Promise<AutonomyRunRecord | null> {
|
||||
const runs = await listAutonomyRuns(rootDir)
|
||||
return runs.find(run => run.runId === runId) ?? null
|
||||
}
|
||||
|
||||
export async function createAutonomyRun(params: {
|
||||
trigger: AutonomyTriggerKind
|
||||
prompt: string
|
||||
rootDir?: string
|
||||
currentDir?: string
|
||||
sourceId?: string
|
||||
sourceLabel?: string
|
||||
runtime?: AutonomyRunRuntime
|
||||
ownerKey?: string
|
||||
flow?: AutonomyRunFlowRef
|
||||
nowMs?: number
|
||||
}): Promise<AutonomyRunRecord> {
|
||||
const rootDir = resolve(params.rootDir ?? getProjectRoot())
|
||||
const currentDir = resolve(params.currentDir ?? rootDir)
|
||||
const record: AutonomyRunRecord = {
|
||||
runId: randomUUID(),
|
||||
runtime: params.runtime ?? (params.flow ? 'flow_step' : 'automatic'),
|
||||
trigger: params.trigger,
|
||||
status: 'queued',
|
||||
rootDir,
|
||||
currentDir,
|
||||
ownerKey:
|
||||
params.flow?.ownerKey ?? params.ownerKey ?? DEFAULT_AUTONOMY_OWNER_KEY,
|
||||
...(params.sourceId ? { sourceId: params.sourceId } : {}),
|
||||
...(params.sourceLabel ? { sourceLabel: params.sourceLabel } : {}),
|
||||
...(params.flow
|
||||
? {
|
||||
parentFlowId: params.flow.flowId,
|
||||
parentFlowKey: params.flow.flowKey,
|
||||
parentFlowSyncMode: params.flow.syncMode,
|
||||
flowStepId: params.flow.stepId,
|
||||
flowStepName: params.flow.stepName,
|
||||
}
|
||||
: {}),
|
||||
promptPreview: truncatePromptPreview(params.prompt),
|
||||
createdAt: params.nowMs ?? Date.now(),
|
||||
}
|
||||
await withAutonomyPersistenceLock(rootDir, async () => {
|
||||
const runs = await listAutonomyRuns(rootDir)
|
||||
runs.unshift(record)
|
||||
await writeAutonomyRuns(runs, rootDir)
|
||||
})
|
||||
if (
|
||||
record.parentFlowId &&
|
||||
record.flowStepId &&
|
||||
record.parentFlowSyncMode === 'managed'
|
||||
) {
|
||||
const stepIndex =
|
||||
(
|
||||
await getAutonomyFlowById(record.parentFlowId, rootDir)
|
||||
)?.stateJson?.steps.findIndex(
|
||||
step => step.stepId === record.flowStepId,
|
||||
) ?? 0
|
||||
await queueManagedAutonomyFlowStepRun({
|
||||
flowId: record.parentFlowId,
|
||||
stepId: record.flowStepId,
|
||||
stepIndex: stepIndex >= 0 ? stepIndex : 0,
|
||||
runId: record.runId,
|
||||
rootDir,
|
||||
nowMs: record.createdAt,
|
||||
})
|
||||
}
|
||||
return record
|
||||
}
|
||||
|
||||
function buildManagedFlowStepPrompt(
|
||||
flow: AutonomyFlowRecord,
|
||||
stepIndex: number,
|
||||
): string {
|
||||
const state = flow.stateJson
|
||||
const step = state?.steps[stepIndex]
|
||||
if (!state || !step) {
|
||||
return flow.goal
|
||||
}
|
||||
const completed = state.steps
|
||||
.slice(0, stepIndex)
|
||||
.filter(candidate => candidate.status === 'completed')
|
||||
.map(candidate => `- ${candidate.name}`)
|
||||
const remaining = state.steps
|
||||
.slice(stepIndex + 1)
|
||||
.map(candidate => `- ${candidate.name}`)
|
||||
|
||||
return [
|
||||
`This is step ${stepIndex + 1}/${state.steps.length} of the managed autonomy flow "${flow.goal}".`,
|
||||
'<autonomy_flow>',
|
||||
`Flow ID: ${flow.flowId}`,
|
||||
`Flow source: ${flow.sourceLabel ?? flow.sourceId ?? 'automatic'}`,
|
||||
`Current step: ${step.name}`,
|
||||
completed.length > 0
|
||||
? ['Completed steps:', ...completed].join('\n')
|
||||
: 'Completed steps: none',
|
||||
remaining.length > 0
|
||||
? ['Remaining steps after this one:', ...remaining].join('\n')
|
||||
: 'Remaining steps after this one: none',
|
||||
'</autonomy_flow>',
|
||||
step.prompt,
|
||||
].join('\n\n')
|
||||
}
|
||||
|
||||
async function createOrRecoverManagedFlowStepCommand(params: {
|
||||
flowId: string
|
||||
rootDir?: string
|
||||
currentDir?: string
|
||||
priority?: 'now' | 'next' | 'later'
|
||||
workload?: string
|
||||
}): Promise<QueuedCommand | null> {
|
||||
const rootDir = resolve(params.rootDir ?? getProjectRoot())
|
||||
const flow = await getAutonomyFlowById(params.flowId, rootDir)
|
||||
if (!flow || flow.status !== 'queued' || !flow.stateJson) {
|
||||
return null
|
||||
}
|
||||
const stepIndex = flow.stateJson.currentStepIndex
|
||||
const step = flow.stateJson.steps[stepIndex]
|
||||
if (!step) {
|
||||
return null
|
||||
}
|
||||
if (step.status === 'queued' && step.runId) {
|
||||
const run = await getAutonomyRunById(step.runId, rootDir)
|
||||
if (run && run.status === 'queued' && !run.startedAt && !run.endedAt) {
|
||||
const value = await buildAutonomyTurnPrompt({
|
||||
basePrompt: buildManagedFlowStepPrompt(flow, stepIndex),
|
||||
trigger: 'managed-flow-step',
|
||||
rootDir,
|
||||
currentDir: params.currentDir ?? flow.currentDir,
|
||||
})
|
||||
const origin = {
|
||||
kind: 'autonomy',
|
||||
trigger: 'managed-flow-step',
|
||||
runId: run.runId,
|
||||
...(run.sourceId ? { sourceId: run.sourceId } : {}),
|
||||
} as unknown as MessageOrigin
|
||||
return {
|
||||
value,
|
||||
mode: 'prompt',
|
||||
priority: params.priority ?? 'later',
|
||||
isMeta: true,
|
||||
origin,
|
||||
workload: params.workload,
|
||||
autonomy: {
|
||||
runId: run.runId,
|
||||
trigger: 'managed-flow-step',
|
||||
sourceId: run.sourceId,
|
||||
sourceLabel: run.sourceLabel,
|
||||
...(run.parentFlowId ? { flowId: run.parentFlowId } : {}),
|
||||
...(run.flowStepId ? { flowStepId: run.flowStepId } : {}),
|
||||
...(run.flowStepName ? { flowStepName: run.flowStepName } : {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
if (step.status !== 'pending' || step.runId) {
|
||||
return null
|
||||
}
|
||||
return createAutonomyQueuedPrompt({
|
||||
basePrompt: buildManagedFlowStepPrompt(flow, stepIndex),
|
||||
trigger: 'managed-flow-step',
|
||||
rootDir,
|
||||
currentDir: params.currentDir ?? flow.currentDir,
|
||||
sourceId: flow.sourceId ?? flow.flowId,
|
||||
sourceLabel: flow.sourceLabel ?? flow.goal,
|
||||
workload: params.workload,
|
||||
priority: params.priority,
|
||||
flow: {
|
||||
flowId: flow.flowId,
|
||||
flowKey: flow.flowKey,
|
||||
syncMode: 'managed',
|
||||
ownerKey: flow.ownerKey,
|
||||
stepId: step.stepId,
|
||||
stepName: step.name,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function queueCurrentManagedFlowStepCommand(params: {
|
||||
flowId: string
|
||||
rootDir?: string
|
||||
currentDir?: string
|
||||
priority?: 'now' | 'next' | 'later'
|
||||
workload?: string
|
||||
}): Promise<QueuedCommand | null> {
|
||||
return createOrRecoverManagedFlowStepCommand(params)
|
||||
}
|
||||
|
||||
export async function startManagedAutonomyFlowFromHeartbeatTask(params: {
|
||||
task: HeartbeatAuthorityTask
|
||||
rootDir?: string
|
||||
currentDir?: string
|
||||
ownerKey?: string
|
||||
priority?: 'now' | 'next' | 'later'
|
||||
workload?: string
|
||||
}): Promise<QueuedCommand | null> {
|
||||
if (params.task.steps.length === 0) {
|
||||
return null
|
||||
}
|
||||
const rootDir = resolve(params.rootDir ?? getProjectRoot())
|
||||
const currentDir = resolve(params.currentDir ?? getCwd())
|
||||
const started = await startManagedAutonomyFlow({
|
||||
trigger: 'proactive-tick',
|
||||
goal: params.task.prompt,
|
||||
steps: params.task.steps.map<ManagedAutonomyFlowStepDefinition>(step => ({
|
||||
name: step.name,
|
||||
prompt: step.prompt,
|
||||
...(step.waitFor ? { waitFor: step.waitFor } : {}),
|
||||
})),
|
||||
rootDir,
|
||||
currentDir,
|
||||
ownerKey: params.ownerKey,
|
||||
sourceId: `heartbeat:${params.task.name}`,
|
||||
sourceLabel: params.task.name,
|
||||
})
|
||||
if (!started) {
|
||||
return null
|
||||
}
|
||||
return createOrRecoverManagedFlowStepCommand({
|
||||
flowId: started.flow.flowId,
|
||||
rootDir,
|
||||
currentDir,
|
||||
priority: params.priority,
|
||||
workload: params.workload,
|
||||
})
|
||||
}
|
||||
|
||||
export async function markAutonomyRunRunning(
|
||||
runId: string,
|
||||
rootDir?: string,
|
||||
nowMs?: number,
|
||||
): Promise<AutonomyRunRecord | null> {
|
||||
const updated = await updateAutonomyRun(
|
||||
runId,
|
||||
current => ({
|
||||
...current,
|
||||
status: 'running',
|
||||
startedAt: nowMs ?? Date.now(),
|
||||
}),
|
||||
rootDir,
|
||||
)
|
||||
if (updated?.parentFlowId && updated.parentFlowSyncMode === 'managed') {
|
||||
await markManagedAutonomyFlowStepRunning({
|
||||
flowId: updated.parentFlowId,
|
||||
runId: updated.runId,
|
||||
rootDir,
|
||||
nowMs: updated.startedAt,
|
||||
})
|
||||
}
|
||||
return updated
|
||||
}
|
||||
|
||||
export async function markAutonomyRunCompleted(
|
||||
runId: string,
|
||||
rootDir?: string,
|
||||
nowMs?: number,
|
||||
): Promise<AutonomyRunRecord | null> {
|
||||
const updated = await updateAutonomyRun(
|
||||
runId,
|
||||
current => ({
|
||||
...current,
|
||||
status: 'completed',
|
||||
endedAt: nowMs ?? Date.now(),
|
||||
error: undefined,
|
||||
}),
|
||||
rootDir,
|
||||
)
|
||||
if (updated?.parentFlowId && updated.parentFlowSyncMode === 'managed') {
|
||||
await markManagedAutonomyFlowStepCompleted({
|
||||
flowId: updated.parentFlowId,
|
||||
runId: updated.runId,
|
||||
rootDir,
|
||||
nowMs: updated.endedAt,
|
||||
})
|
||||
}
|
||||
return updated
|
||||
}
|
||||
|
||||
export async function markAutonomyRunFailed(
|
||||
runId: string,
|
||||
error: string,
|
||||
rootDir?: string,
|
||||
nowMs?: number,
|
||||
): Promise<AutonomyRunRecord | null> {
|
||||
const updated = await updateAutonomyRun(
|
||||
runId,
|
||||
current => ({
|
||||
...current,
|
||||
status: 'failed',
|
||||
endedAt: nowMs ?? Date.now(),
|
||||
error,
|
||||
}),
|
||||
rootDir,
|
||||
)
|
||||
if (updated?.parentFlowId && updated.parentFlowSyncMode === 'managed') {
|
||||
await markManagedAutonomyFlowStepFailed({
|
||||
flowId: updated.parentFlowId,
|
||||
runId: updated.runId,
|
||||
error,
|
||||
rootDir,
|
||||
nowMs: updated.endedAt,
|
||||
})
|
||||
}
|
||||
return updated
|
||||
}
|
||||
|
||||
export async function markAutonomyRunCancelled(
|
||||
runId: string,
|
||||
rootDir?: string,
|
||||
nowMs?: number,
|
||||
): Promise<AutonomyRunRecord | null> {
|
||||
const updated = await updateAutonomyRun(
|
||||
runId,
|
||||
current => ({
|
||||
...current,
|
||||
status: 'cancelled',
|
||||
endedAt: nowMs ?? Date.now(),
|
||||
error: undefined,
|
||||
}),
|
||||
rootDir,
|
||||
)
|
||||
if (updated?.parentFlowId && updated.parentFlowSyncMode === 'managed') {
|
||||
await markManagedAutonomyFlowStepCancelled({
|
||||
flowId: updated.parentFlowId,
|
||||
runId: updated.runId,
|
||||
rootDir,
|
||||
nowMs: updated.endedAt,
|
||||
})
|
||||
}
|
||||
return updated
|
||||
}
|
||||
|
||||
export async function finalizeAutonomyRunCompleted(params: {
|
||||
runId: string
|
||||
rootDir?: string
|
||||
currentDir?: string
|
||||
priority?: 'now' | 'next' | 'later'
|
||||
workload?: string
|
||||
nowMs?: number
|
||||
}): Promise<QueuedCommand[]> {
|
||||
const updated = await markAutonomyRunCompleted(
|
||||
params.runId,
|
||||
params.rootDir,
|
||||
params.nowMs,
|
||||
)
|
||||
if (!updated?.parentFlowId || updated.parentFlowSyncMode !== 'managed') {
|
||||
return []
|
||||
}
|
||||
const next = await queueCurrentManagedFlowStepCommand({
|
||||
flowId: updated.parentFlowId,
|
||||
rootDir: params.rootDir,
|
||||
currentDir: params.currentDir ?? updated.currentDir,
|
||||
priority: params.priority,
|
||||
workload: params.workload,
|
||||
})
|
||||
return next ? [next] : []
|
||||
}
|
||||
|
||||
export async function finalizeAutonomyRunFailed(params: {
|
||||
runId: string
|
||||
error: string
|
||||
rootDir?: string
|
||||
nowMs?: number
|
||||
}): Promise<void> {
|
||||
await markAutonomyRunFailed(
|
||||
params.runId,
|
||||
params.error,
|
||||
params.rootDir,
|
||||
params.nowMs,
|
||||
)
|
||||
}
|
||||
|
||||
export async function recoverManagedAutonomyFlowPrompt(params: {
|
||||
flowId: string
|
||||
rootDir?: string
|
||||
currentDir?: string
|
||||
priority?: 'now' | 'next' | 'later'
|
||||
workload?: string
|
||||
}): Promise<QueuedCommand | null> {
|
||||
return createOrRecoverManagedFlowStepCommand(params)
|
||||
}
|
||||
|
||||
export async function resumeManagedAutonomyFlowPrompt(params: {
|
||||
flowId: string
|
||||
rootDir?: string
|
||||
currentDir?: string
|
||||
priority?: 'now' | 'next' | 'later'
|
||||
workload?: string
|
||||
nowMs?: number
|
||||
}): Promise<QueuedCommand | null> {
|
||||
const resumed = await resumeManagedAutonomyFlow({
|
||||
flowId: params.flowId,
|
||||
rootDir: params.rootDir,
|
||||
nowMs: params.nowMs,
|
||||
})
|
||||
if (!resumed) {
|
||||
return recoverManagedAutonomyFlowPrompt({
|
||||
flowId: params.flowId,
|
||||
rootDir: params.rootDir,
|
||||
currentDir: params.currentDir,
|
||||
priority: params.priority,
|
||||
workload: params.workload,
|
||||
})
|
||||
}
|
||||
return createOrRecoverManagedFlowStepCommand({
|
||||
flowId: resumed.flow.flowId,
|
||||
rootDir: params.rootDir,
|
||||
currentDir: params.currentDir ?? resumed.flow.currentDir,
|
||||
priority: params.priority,
|
||||
workload: params.workload,
|
||||
})
|
||||
}
|
||||
|
||||
export async function createAutonomyQueuedPrompt(params: {
|
||||
trigger: AutonomyTriggerKind
|
||||
basePrompt: string
|
||||
rootDir?: string
|
||||
currentDir?: string
|
||||
sourceId?: string
|
||||
sourceLabel?: string
|
||||
workload?: string
|
||||
priority?: 'now' | 'next' | 'later'
|
||||
shouldCreate?: () => boolean
|
||||
flow?: AutonomyRunFlowRef
|
||||
}): Promise<QueuedCommand | null> {
|
||||
const rootDir = resolve(params.rootDir ?? getProjectRoot())
|
||||
const currentDir = resolve(params.currentDir ?? getCwd())
|
||||
const prepared = await prepareAutonomyTurnPrompt({
|
||||
basePrompt: params.basePrompt,
|
||||
trigger: params.trigger,
|
||||
rootDir,
|
||||
currentDir,
|
||||
})
|
||||
if (params.shouldCreate && !params.shouldCreate()) {
|
||||
return null
|
||||
}
|
||||
return commitAutonomyQueuedPrompt({
|
||||
prepared,
|
||||
rootDir,
|
||||
currentDir,
|
||||
sourceId: params.sourceId,
|
||||
sourceLabel: params.sourceLabel,
|
||||
workload: params.workload,
|
||||
priority: params.priority,
|
||||
flow: params.flow,
|
||||
})
|
||||
}
|
||||
|
||||
export async function commitAutonomyQueuedPrompt(params: {
|
||||
prepared: Awaited<ReturnType<typeof prepareAutonomyTurnPrompt>>
|
||||
rootDir?: string
|
||||
currentDir?: string
|
||||
sourceId?: string
|
||||
sourceLabel?: string
|
||||
workload?: string
|
||||
priority?: 'now' | 'next' | 'later'
|
||||
flow?: AutonomyRunFlowRef
|
||||
}): Promise<QueuedCommand> {
|
||||
const rootDir = resolve(
|
||||
params.rootDir ?? params.prepared.rootDir ?? getProjectRoot(),
|
||||
)
|
||||
const currentDir = resolve(
|
||||
params.currentDir ?? params.prepared.currentDir ?? getCwd(),
|
||||
)
|
||||
commitPreparedAutonomyTurn(params.prepared)
|
||||
const value = params.prepared.prompt
|
||||
const run = await createAutonomyRun({
|
||||
trigger: params.prepared.trigger,
|
||||
prompt: value,
|
||||
rootDir,
|
||||
currentDir,
|
||||
sourceId: params.sourceId,
|
||||
sourceLabel: params.sourceLabel,
|
||||
flow: params.flow,
|
||||
})
|
||||
const origin = {
|
||||
kind: 'autonomy',
|
||||
trigger: params.prepared.trigger,
|
||||
runId: run.runId,
|
||||
...(params.sourceId ? { sourceId: params.sourceId } : {}),
|
||||
} as unknown as MessageOrigin
|
||||
|
||||
return {
|
||||
value,
|
||||
mode: 'prompt',
|
||||
priority: params.priority ?? 'later',
|
||||
isMeta: true,
|
||||
origin,
|
||||
workload: params.workload,
|
||||
autonomy: {
|
||||
runId: run.runId,
|
||||
trigger: params.prepared.trigger,
|
||||
sourceId: params.sourceId,
|
||||
sourceLabel: params.sourceLabel,
|
||||
...(run.parentFlowId ? { flowId: run.parentFlowId } : {}),
|
||||
...(run.flowStepId ? { flowStepId: run.flowStepId } : {}),
|
||||
...(run.flowStepName ? { flowStepName: run.flowStepName } : {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function createProactiveAutonomyCommands(params: {
|
||||
basePrompt: string
|
||||
rootDir?: string
|
||||
currentDir?: string
|
||||
workload?: string
|
||||
priority?: 'now' | 'next' | 'later'
|
||||
shouldCreate?: () => boolean
|
||||
}): Promise<QueuedCommand[]> {
|
||||
const rootDir = resolve(params.rootDir ?? getProjectRoot())
|
||||
const currentDir = resolve(params.currentDir ?? getCwd())
|
||||
const prepared = await prepareAutonomyTurnPrompt({
|
||||
basePrompt: params.basePrompt,
|
||||
trigger: 'proactive-tick',
|
||||
rootDir,
|
||||
currentDir,
|
||||
})
|
||||
if (params.shouldCreate && !params.shouldCreate()) {
|
||||
return []
|
||||
}
|
||||
|
||||
const commands: QueuedCommand[] = [
|
||||
await commitAutonomyQueuedPrompt({
|
||||
prepared,
|
||||
rootDir,
|
||||
currentDir,
|
||||
workload: params.workload,
|
||||
priority: params.priority,
|
||||
}),
|
||||
]
|
||||
|
||||
for (const task of prepared.dueHeartbeatTasks) {
|
||||
if (task.steps.length === 0) {
|
||||
continue
|
||||
}
|
||||
if (params.shouldCreate && !params.shouldCreate()) {
|
||||
break
|
||||
}
|
||||
const flowCommand = await startManagedAutonomyFlowFromHeartbeatTask({
|
||||
task,
|
||||
rootDir,
|
||||
currentDir,
|
||||
priority: params.priority,
|
||||
workload: params.workload,
|
||||
})
|
||||
if (flowCommand) {
|
||||
commands.push(flowCommand)
|
||||
}
|
||||
}
|
||||
|
||||
return commands
|
||||
}
|
||||
|
||||
export function formatAutonomyRunsStatus(runs: AutonomyRunRecord[]): string {
|
||||
const counts = {
|
||||
queued: 0,
|
||||
running: 0,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
cancelled: 0,
|
||||
}
|
||||
for (const run of runs) {
|
||||
counts[run.status] += 1
|
||||
}
|
||||
const latest = runs[0]
|
||||
const latestLine = latest
|
||||
? `Latest: ${latest.trigger} ${latest.status} (${new Date(latest.createdAt).toLocaleString()})`
|
||||
: 'Latest: none'
|
||||
return [
|
||||
`Autonomy runs: ${runs.length}`,
|
||||
`Queued: ${counts.queued}`,
|
||||
`Running: ${counts.running}`,
|
||||
`Completed: ${counts.completed}`,
|
||||
`Failed: ${counts.failed}`,
|
||||
`Cancelled: ${counts.cancelled}`,
|
||||
latestLine,
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export function formatAutonomyRunsList(
|
||||
runs: AutonomyRunRecord[],
|
||||
limit = 10,
|
||||
): string {
|
||||
const slice = runs.slice(0, limit)
|
||||
if (slice.length === 0) {
|
||||
return 'No autonomy runs recorded.'
|
||||
}
|
||||
return slice
|
||||
.map(run => {
|
||||
const source = run.sourceLabel ?? run.sourceId ?? 'auto'
|
||||
const flow =
|
||||
run.parentFlowId && run.flowStepName
|
||||
? ` | flow=${run.parentFlowId} step=${run.flowStepName}`
|
||||
: ''
|
||||
const ended =
|
||||
run.endedAt != null
|
||||
? ` -> ${new Date(run.endedAt).toLocaleTimeString()}`
|
||||
: ''
|
||||
const error = run.error ? ` | ${run.error}` : ''
|
||||
return `${run.runId} | ${run.runtime} | ${run.trigger} | ${run.status} | ${source}${flow} | ${new Date(run.createdAt).toLocaleTimeString()}${ended}\n ${run.promptPreview}${error}`
|
||||
})
|
||||
.join('\n')
|
||||
}
|
||||
180
src/utils/cliLaunch.ts
Normal file
180
src/utils/cliLaunch.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { type ChildProcess, spawn, type SpawnOptions } from 'child_process'
|
||||
import { isInBundledMode } from './bundledMode.js'
|
||||
import { quote } from './bash/shellQuote.js'
|
||||
|
||||
/**
|
||||
* CliLaunchSpec — normalized descriptor for spawning a child CLI process.
|
||||
*
|
||||
* Every site that re-execs the CLI (daemon workers, bg sessions, bridge
|
||||
* sessions, assistant/RCS daemon launchers) should use this instead of
|
||||
* manually assembling `[...process.execArgv, process.argv[1]!, ...]`.
|
||||
*
|
||||
* Centralizing the bootstrap contract prevents the class of bugs where
|
||||
* individual spawn sites forget execArgv, windowsHide, or env propagation.
|
||||
*/
|
||||
export interface CliLaunchSpec {
|
||||
/** Runtime binary path (e.g. bun, node). */
|
||||
execPath: string
|
||||
/** Full argument list including bootstrap args and CLI args. */
|
||||
args: string[]
|
||||
/** Environment for the child process. */
|
||||
env: NodeJS.ProcessEnv
|
||||
/** Whether to hide the console window on Windows. */
|
||||
windowsHide: boolean
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Frozen bootstrap snapshot — computed once at module load time.
|
||||
//
|
||||
// Bun quirk (https://github.com/oven-sh/bun/issues/11673): in single-file
|
||||
// executables, app arguments from process.argv can leak into process.execArgv.
|
||||
// We snapshot and filter once, so every child gets a clean, stable set of
|
||||
// runtime flags regardless of when buildCliLaunch is called.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Filter out leaked application arguments from process.execArgv.
|
||||
* Only keep known runtime flags: -d (defines), --feature, --inspect variants.
|
||||
*/
|
||||
function sanitizeExecArgv(raw: readonly string[]): string[] {
|
||||
const result: string[] = []
|
||||
for (let i = 0; i < raw.length; i++) {
|
||||
const arg = raw[i]!
|
||||
// Bun define flags: -d KEY:VALUE or -dKEY:VALUE
|
||||
if (arg === '-d' || arg.startsWith('-d ') || arg.startsWith('-d\t')) {
|
||||
result.push(arg)
|
||||
if (arg === '-d' && i + 1 < raw.length) {
|
||||
result.push(raw[++i]!)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (arg.startsWith('-d') && arg.includes(':')) {
|
||||
result.push(arg)
|
||||
continue
|
||||
}
|
||||
// Bun feature flags: --feature NAME
|
||||
if (arg === '--feature') {
|
||||
result.push(arg)
|
||||
if (i + 1 < raw.length) {
|
||||
result.push(raw[++i]!)
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Node/Bun inspect flags
|
||||
if (/^--inspect(-brk)?(=|$)/.test(arg)) {
|
||||
result.push(arg)
|
||||
continue
|
||||
}
|
||||
// Keep other known runtime flags (e.g. --conditions, --experimental-*)
|
||||
if (arg.startsWith('--') && !arg.includes('=') && i + 1 < raw.length) {
|
||||
// Unknown two-part flag — skip conservatively in bundled mode only
|
||||
if (isInBundledMode()) continue
|
||||
result.push(arg)
|
||||
result.push(raw[++i]!)
|
||||
continue
|
||||
}
|
||||
if (arg.startsWith('-') && !isInBundledMode()) {
|
||||
result.push(arg)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const BOOTSTRAP_ARGS: readonly string[] = Object.freeze(
|
||||
sanitizeExecArgv(process.execArgv),
|
||||
)
|
||||
const SCRIPT_PATH: string | undefined = process.argv[1]
|
||||
const EXEC_PATH: string = process.execPath
|
||||
const IS_WINDOWS = process.platform === 'win32'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build a normalized launch spec for spawning a child CLI process.
|
||||
*
|
||||
* @param cliArgs Arguments to pass to the CLI entrypoint (e.g. ['daemon', 'start'])
|
||||
* @param opts.env Override environment (defaults to process.env)
|
||||
*/
|
||||
export function buildCliLaunch(
|
||||
cliArgs: string[],
|
||||
opts?: { env?: NodeJS.ProcessEnv },
|
||||
): CliLaunchSpec {
|
||||
const baseEnv = opts?.env ?? process.env
|
||||
|
||||
// In bundled mode the execPath IS the CLI binary — no script path needed.
|
||||
// In script mode (dev / npm) we need the script path between runtime flags
|
||||
// and CLI args so the runtime knows which file to execute.
|
||||
const args: string[] =
|
||||
isInBundledMode() || !SCRIPT_PATH
|
||||
? [...BOOTSTRAP_ARGS, ...cliArgs]
|
||||
: [...BOOTSTRAP_ARGS, SCRIPT_PATH, ...cliArgs]
|
||||
|
||||
// Ensure Windows children can discover git-bash without shelling out
|
||||
const env: NodeJS.ProcessEnv = { ...baseEnv }
|
||||
if (IS_WINDOWS) {
|
||||
if (
|
||||
process.env.CLAUDE_CODE_GIT_BASH_PATH &&
|
||||
!env.CLAUDE_CODE_GIT_BASH_PATH
|
||||
) {
|
||||
env.CLAUDE_CODE_GIT_BASH_PATH = process.env.CLAUDE_CODE_GIT_BASH_PATH
|
||||
}
|
||||
if (process.env.SHELL && !env.SHELL) {
|
||||
env.SHELL = process.env.SHELL
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
execPath: EXEC_PATH,
|
||||
args,
|
||||
env,
|
||||
windowsHide: IS_WINDOWS,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn a child CLI process from a launch spec.
|
||||
*
|
||||
* Callers provide transport-level options (stdio, detached, cwd) while the
|
||||
* spec handles bootstrap concerns (execPath, args, env, windowsHide).
|
||||
*
|
||||
* Windows note: `detached: true` on Windows creates a new console window
|
||||
* (unlike Unix where it only creates a new process group). Node.js uses
|
||||
* `windowsHide` to pass CREATE_NO_WINDOW, but Bun may not implement it.
|
||||
* As a fallback, we always set both `windowsHide: true` and keep
|
||||
* `detached` as-is — the child needs `detached` to outlive the parent.
|
||||
*/
|
||||
export function spawnCli(
|
||||
spec: CliLaunchSpec,
|
||||
spawnOpts: Omit<SpawnOptions, 'windowsHide'>,
|
||||
): ChildProcess {
|
||||
return spawn(spec.execPath, spec.args, {
|
||||
...spawnOpts,
|
||||
env: { ...spec.env, ...(spawnOpts.env as NodeJS.ProcessEnv) },
|
||||
windowsHide: spec.windowsHide,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Quote a launch spec into a single shell command string (for tmux).
|
||||
*/
|
||||
export function quoteCliLaunch(spec: CliLaunchSpec): string {
|
||||
return quote([spec.execPath, ...spec.args])
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the frozen bootstrap args snapshot.
|
||||
* Useful for call sites that need the raw args (e.g. bridgeMain deps).
|
||||
*/
|
||||
export function getBootstrapArgs(): readonly string[] {
|
||||
return BOOTSTRAP_ARGS
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the script path (process.argv[1] at startup).
|
||||
* Returns undefined in bundled mode.
|
||||
*/
|
||||
export function getScriptPath(): string | undefined {
|
||||
return SCRIPT_PATH
|
||||
}
|
||||
@@ -334,6 +334,9 @@ export type GlobalConfig = {
|
||||
overageCreditUpsellSeenCount?: number // Number of times the overage credit upsell has been shown
|
||||
hasVisitedExtraUsage?: boolean // Whether the user has visited /extra-usage — hides credit upsells
|
||||
|
||||
// Display language preference
|
||||
preferredLanguage?: 'auto' | 'en' | 'zh' // auto = follow system locale, en = English, zh = 中文
|
||||
|
||||
// Voice mode notice tracking
|
||||
voiceNoticeSeenCount?: number // Number of times the voice-mode-available notice has been shown
|
||||
voiceLangHintShownCount?: number // Number of times the /voice dictation-language hint has been shown
|
||||
@@ -556,7 +559,6 @@ export type GlobalConfig = {
|
||||
// Speculation configuration (ant-only)
|
||||
speculationEnabled?: boolean // Whether speculation is enabled (default: true)
|
||||
|
||||
|
||||
// Client data for server-side experiments (fetched during bootstrap).
|
||||
clientDataCache?: Record<string, unknown> | null
|
||||
|
||||
|
||||
@@ -26,6 +26,12 @@ import { fileHistoryEnabled, fileHistoryMakeSnapshot } from './fileHistory.js'
|
||||
import { gracefulShutdownSync } from './gracefulShutdown.js'
|
||||
import { enqueue } from './messageQueueManager.js'
|
||||
import { resolveSkillModelOverride } from './model/model.js'
|
||||
import {
|
||||
finalizeAutonomyRunCompleted,
|
||||
finalizeAutonomyRunFailed,
|
||||
markAutonomyRunFailed,
|
||||
markAutonomyRunRunning,
|
||||
} from './autonomyRuns.js'
|
||||
import type { ProcessUserInputContext } from './processUserInput/processUserInput.js'
|
||||
import { processUserInput } from './processUserInput/processUserInput.js'
|
||||
import type { QueryGuard } from './QueryGuard.js'
|
||||
@@ -460,6 +466,7 @@ async function executeUserInput(params: ExecuteUserInputParams): Promise<void> {
|
||||
commands.every(c => c.workload === firstWorkload)
|
||||
? firstWorkload
|
||||
: undefined
|
||||
let autonomyRunIds: string[] | undefined
|
||||
|
||||
// Wrap the entire turn (processUserInput loop + onQuery) in an
|
||||
// AsyncLocalStorage context. This is the ONLY way to correctly
|
||||
@@ -469,131 +476,159 @@ async function executeUserInput(params: ExecuteUserInputParams): Promise<void> {
|
||||
// context — isolated from the parent's continuation. A process-global
|
||||
// mutable slot would be clobbered at the detached closure's first
|
||||
// await by this function's synchronous return path. See state.ts.
|
||||
await runWithWorkload(turnWorkload, async () => {
|
||||
for (let i = 0; i < commands.length; i++) {
|
||||
const cmd = commands[i]!
|
||||
const isFirst = i === 0
|
||||
const result = await processUserInput({
|
||||
input: cmd.value,
|
||||
preExpansionInput: cmd.preExpansionValue,
|
||||
mode: cmd.mode,
|
||||
setToolJSX,
|
||||
context: makeContext(),
|
||||
pastedContents: isFirst ? cmd.pastedContents : undefined,
|
||||
messages,
|
||||
setUserInputOnProcessing: isFirst
|
||||
? setUserInputOnProcessing
|
||||
: undefined,
|
||||
isAlreadyProcessing: !isFirst,
|
||||
querySource,
|
||||
canUseTool,
|
||||
uuid: cmd.uuid,
|
||||
ideSelection: isFirst ? ideSelection : undefined,
|
||||
skipSlashCommands: cmd.skipSlashCommands,
|
||||
bridgeOrigin: cmd.bridgeOrigin,
|
||||
isMeta: cmd.isMeta,
|
||||
skipAttachments: !isFirst,
|
||||
})
|
||||
// Stamp origin here rather than threading another arg through
|
||||
// processUserInput → processUserInputBase → processTextPrompt → createUserMessage.
|
||||
// Derive origin from mode for task-notifications — mirrors the origin
|
||||
// derivation at messages.ts (case 'queued_command'); intentionally
|
||||
// does NOT mirror its isMeta:true so idle-dequeued notifications stay
|
||||
// visible in the transcript via UserAgentNotificationMessage.
|
||||
const origin =
|
||||
cmd.origin ??
|
||||
(cmd.mode === 'task-notification'
|
||||
? ({ kind: 'task-notification' } as const)
|
||||
: undefined)
|
||||
if (origin) {
|
||||
for (const m of result.messages) {
|
||||
if (m.type === 'user') m.origin = origin
|
||||
try {
|
||||
await runWithWorkload(turnWorkload, async () => {
|
||||
for (let i = 0; i < commands.length; i++) {
|
||||
const cmd = commands[i]!
|
||||
const isFirst = i === 0
|
||||
if (cmd.autonomy?.runId) {
|
||||
;(autonomyRunIds ??= []).push(cmd.autonomy.runId)
|
||||
await markAutonomyRunRunning(cmd.autonomy.runId)
|
||||
}
|
||||
const result = await processUserInput({
|
||||
input: cmd.value,
|
||||
preExpansionInput: cmd.preExpansionValue,
|
||||
mode: cmd.mode,
|
||||
setToolJSX,
|
||||
context: makeContext(),
|
||||
pastedContents: isFirst ? cmd.pastedContents : undefined,
|
||||
messages,
|
||||
setUserInputOnProcessing: isFirst
|
||||
? setUserInputOnProcessing
|
||||
: undefined,
|
||||
isAlreadyProcessing: !isFirst,
|
||||
querySource,
|
||||
canUseTool,
|
||||
uuid: cmd.uuid,
|
||||
ideSelection: isFirst ? ideSelection : undefined,
|
||||
skipSlashCommands: cmd.skipSlashCommands,
|
||||
bridgeOrigin: cmd.bridgeOrigin,
|
||||
isMeta: cmd.isMeta,
|
||||
skipAttachments: !isFirst,
|
||||
})
|
||||
// Stamp origin here rather than threading another arg through
|
||||
// processUserInput → processUserInputBase → processTextPrompt → createUserMessage.
|
||||
// Derive origin from mode for task-notifications — mirrors the origin
|
||||
// derivation at messages.ts (case 'queued_command'); intentionally
|
||||
// does NOT mirror its isMeta:true so idle-dequeued notifications stay
|
||||
// visible in the transcript via UserAgentNotificationMessage.
|
||||
const origin =
|
||||
cmd.origin ??
|
||||
(cmd.mode === 'task-notification'
|
||||
? ({ kind: 'task-notification' } as const)
|
||||
: undefined)
|
||||
if (origin) {
|
||||
for (const m of result.messages) {
|
||||
if (m.type === 'user') m.origin = origin
|
||||
}
|
||||
}
|
||||
newMessages.push(...result.messages)
|
||||
if (isFirst) {
|
||||
shouldQuery = result.shouldQuery
|
||||
allowedTools = result.allowedTools
|
||||
model = result.model
|
||||
effort = result.effort
|
||||
nextInput = result.nextInput
|
||||
submitNextInput = result.submitNextInput
|
||||
}
|
||||
}
|
||||
newMessages.push(...result.messages)
|
||||
if (isFirst) {
|
||||
shouldQuery = result.shouldQuery
|
||||
allowedTools = result.allowedTools
|
||||
model = result.model
|
||||
effort = result.effort
|
||||
nextInput = result.nextInput
|
||||
submitNextInput = result.submitNextInput
|
||||
}
|
||||
}
|
||||
|
||||
queryCheckpoint('query_process_user_input_end')
|
||||
if (fileHistoryEnabled()) {
|
||||
queryCheckpoint('query_file_history_snapshot_start')
|
||||
newMessages.filter(selectableUserMessagesFilter).forEach(message => {
|
||||
void fileHistoryMakeSnapshot(
|
||||
(updater: (prev: FileHistoryState) => FileHistoryState) => {
|
||||
setAppState(prev => ({
|
||||
...prev,
|
||||
fileHistory: updater(prev.fileHistory),
|
||||
}))
|
||||
},
|
||||
message.uuid,
|
||||
queryCheckpoint('query_process_user_input_end')
|
||||
if (fileHistoryEnabled()) {
|
||||
queryCheckpoint('query_file_history_snapshot_start')
|
||||
newMessages.filter(selectableUserMessagesFilter).forEach(message => {
|
||||
void fileHistoryMakeSnapshot(
|
||||
(updater: (prev: FileHistoryState) => FileHistoryState) => {
|
||||
setAppState(prev => ({
|
||||
...prev,
|
||||
fileHistory: updater(prev.fileHistory),
|
||||
}))
|
||||
},
|
||||
message.uuid,
|
||||
)
|
||||
})
|
||||
queryCheckpoint('query_file_history_snapshot_end')
|
||||
}
|
||||
|
||||
if (newMessages.length) {
|
||||
// History is now added in the caller (onSubmit) for direct user submissions.
|
||||
// This ensures queued command processing (notifications, already-queued user input)
|
||||
// doesn't add to history, since those either shouldn't be in history or were
|
||||
// already added when originally queued.
|
||||
resetHistory()
|
||||
setToolJSX({
|
||||
jsx: null,
|
||||
shouldHidePromptInput: false,
|
||||
clearLocalJSX: true,
|
||||
})
|
||||
|
||||
const primaryCmd = commands[0]
|
||||
const primaryMode = primaryCmd?.mode ?? 'prompt'
|
||||
const primaryInput =
|
||||
primaryCmd && typeof primaryCmd.value === 'string'
|
||||
? primaryCmd.value
|
||||
: undefined
|
||||
const shouldCallBeforeQuery = primaryMode === 'prompt'
|
||||
await onQuery(
|
||||
newMessages,
|
||||
abortController,
|
||||
shouldQuery,
|
||||
allowedTools ?? [],
|
||||
model
|
||||
? resolveSkillModelOverride(model, mainLoopModel)
|
||||
: mainLoopModel,
|
||||
shouldCallBeforeQuery ? onBeforeQuery : undefined,
|
||||
primaryInput,
|
||||
effort,
|
||||
)
|
||||
})
|
||||
queryCheckpoint('query_file_history_snapshot_end')
|
||||
}
|
||||
|
||||
if (newMessages.length) {
|
||||
// History is now added in the caller (onSubmit) for direct user submissions.
|
||||
// This ensures queued command processing (notifications, already-queued user input)
|
||||
// doesn't add to history, since those either shouldn't be in history or were
|
||||
// already added when originally queued.
|
||||
resetHistory()
|
||||
setToolJSX({
|
||||
jsx: null,
|
||||
shouldHidePromptInput: false,
|
||||
clearLocalJSX: true,
|
||||
})
|
||||
|
||||
const primaryCmd = commands[0]
|
||||
const primaryMode = primaryCmd?.mode ?? 'prompt'
|
||||
const primaryInput =
|
||||
primaryCmd && typeof primaryCmd.value === 'string'
|
||||
? primaryCmd.value
|
||||
: undefined
|
||||
const shouldCallBeforeQuery = primaryMode === 'prompt'
|
||||
await onQuery(
|
||||
newMessages,
|
||||
abortController,
|
||||
shouldQuery,
|
||||
allowedTools ?? [],
|
||||
model
|
||||
? resolveSkillModelOverride(model, mainLoopModel)
|
||||
: mainLoopModel,
|
||||
shouldCallBeforeQuery ? onBeforeQuery : undefined,
|
||||
primaryInput,
|
||||
effort,
|
||||
)
|
||||
} else {
|
||||
// Local slash commands that skip messages (e.g., /model, /theme).
|
||||
// Release the guard BEFORE clearing toolJSX to prevent spinner flash —
|
||||
// the spinner formula checks: (!toolJSX || showSpinner) && isLoading.
|
||||
// If we clear toolJSX while the guard is still reserved, spinner briefly
|
||||
// shows. The finally below also calls cancelReservation (no-op if idle).
|
||||
queryGuard.cancelReservation()
|
||||
setToolJSX({
|
||||
jsx: null,
|
||||
shouldHidePromptInput: false,
|
||||
clearLocalJSX: true,
|
||||
})
|
||||
resetHistory()
|
||||
setAbortController(null)
|
||||
}
|
||||
|
||||
// Handle nextInput from commands that want to chain (e.g., /discover activation)
|
||||
if (nextInput) {
|
||||
if (submitNextInput) {
|
||||
enqueue({ value: nextInput, mode: 'prompt' })
|
||||
} else {
|
||||
params.onInputChange(nextInput)
|
||||
// Local slash commands that skip messages (e.g., /model, /theme).
|
||||
// Release the guard BEFORE clearing toolJSX to prevent spinner flash —
|
||||
// the spinner formula checks: (!toolJSX || showSpinner) && isLoading.
|
||||
// If we clear toolJSX while the guard is still reserved, spinner briefly
|
||||
// shows. The finally below also calls cancelReservation (no-op if idle).
|
||||
queryGuard.cancelReservation()
|
||||
setToolJSX({
|
||||
jsx: null,
|
||||
shouldHidePromptInput: false,
|
||||
clearLocalJSX: true,
|
||||
})
|
||||
resetHistory()
|
||||
setAbortController(null)
|
||||
}
|
||||
|
||||
// Handle nextInput from commands that want to chain (e.g., /discover activation)
|
||||
if (nextInput) {
|
||||
if (submitNextInput) {
|
||||
enqueue({ value: nextInput, mode: 'prompt' })
|
||||
} else {
|
||||
params.onInputChange(nextInput)
|
||||
}
|
||||
}
|
||||
}) // end runWithWorkload — ALS context naturally scoped, no finally needed
|
||||
if (autonomyRunIds?.length) {
|
||||
for (const runId of autonomyRunIds) {
|
||||
const nextCommands = await finalizeAutonomyRunCompleted({
|
||||
runId,
|
||||
priority: 'later',
|
||||
workload: turnWorkload,
|
||||
})
|
||||
for (const nextCommand of nextCommands) {
|
||||
enqueue(nextCommand)
|
||||
}
|
||||
}
|
||||
}
|
||||
}) // end runWithWorkload — ALS context naturally scoped, no finally needed
|
||||
} catch (error) {
|
||||
if (autonomyRunIds?.length) {
|
||||
for (const runId of autonomyRunIds) {
|
||||
await finalizeAutonomyRunFailed({
|
||||
runId,
|
||||
error: String(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
} finally {
|
||||
// Safety net: release the guard reservation if processUserInput threw
|
||||
// or onQuery was skipped. No-op if onQuery already ran (guard is idle
|
||||
|
||||
26
src/utils/language.ts
Normal file
26
src/utils/language.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { getGlobalConfig } from './config.js'
|
||||
import { getSystemLocaleLanguage } from './intl.js'
|
||||
|
||||
export type PreferredLanguage = 'auto' | 'en' | 'zh'
|
||||
export type ResolvedLanguage = 'en' | 'zh'
|
||||
|
||||
/**
|
||||
* Resolve the effective display language.
|
||||
* Priority: GlobalConfig.preferredLanguage → system locale → default 'en'.
|
||||
*/
|
||||
export function getResolvedLanguage(): ResolvedLanguage {
|
||||
const pref = getGlobalConfig().preferredLanguage ?? 'auto'
|
||||
if (pref === 'en' || pref === 'zh') return pref
|
||||
const sysLang = getSystemLocaleLanguage()
|
||||
return sysLang === 'zh' ? 'zh' : 'en'
|
||||
}
|
||||
|
||||
const DISPLAY_NAMES: Record<string, string> = {
|
||||
auto: 'Auto (follow system)',
|
||||
en: 'English',
|
||||
zh: '中文',
|
||||
}
|
||||
|
||||
export function getLanguageDisplayName(lang: string): string {
|
||||
return DISPLAY_NAMES[lang] ?? lang
|
||||
}
|
||||
78
src/utils/pipeMuteState.ts
Normal file
78
src/utils/pipeMuteState.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* pipeMuteState — Master-side logical disconnect state.
|
||||
*
|
||||
* Tracks which slave pipes are currently "muted" (logically disconnected)
|
||||
* and which have a temporary `/send` override active.
|
||||
*
|
||||
* This is local master state only — not part of the socket protocol.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Muted set: slaves whose business messages should be dropped by master
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const _mutedPipes = new Set<string>()
|
||||
|
||||
export function setMasterMutedPipes(names: Iterable<string>): void {
|
||||
_mutedPipes.clear()
|
||||
for (const n of names) _mutedPipes.add(n)
|
||||
}
|
||||
|
||||
export function isMasterPipeMuted(name: string): boolean {
|
||||
return _mutedPipes.has(name)
|
||||
}
|
||||
|
||||
export function removeMasterPipeMute(name: string): void {
|
||||
_mutedPipes.delete(name)
|
||||
}
|
||||
|
||||
export function clearMasterMutedPipes(): void {
|
||||
_mutedPipes.clear()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Send override set: slaves temporarily unmuted by explicit `/send` command.
|
||||
// Override lasts until the slave emits `done` or `error`.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const _sendOverrides = new Set<string>()
|
||||
let _sendOverrideVersion = 0
|
||||
const _sendOverrideListeners = new Set<() => void>()
|
||||
|
||||
function emitSendOverrideChanged(): void {
|
||||
_sendOverrideVersion += 1
|
||||
for (const listener of _sendOverrideListeners) {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
|
||||
export function addSendOverride(name: string): void {
|
||||
_sendOverrides.add(name)
|
||||
emitSendOverrideChanged()
|
||||
}
|
||||
|
||||
export function removeSendOverride(name: string): void {
|
||||
if (_sendOverrides.delete(name)) {
|
||||
emitSendOverrideChanged()
|
||||
}
|
||||
}
|
||||
|
||||
export function hasSendOverride(name: string): boolean {
|
||||
return _sendOverrides.has(name)
|
||||
}
|
||||
|
||||
export function clearSendOverrides(): void {
|
||||
if (_sendOverrides.size > 0) {
|
||||
_sendOverrides.clear()
|
||||
emitSendOverrideChanged()
|
||||
}
|
||||
}
|
||||
|
||||
export function subscribeSendOverride(listener: () => void): () => void {
|
||||
_sendOverrideListeners.add(listener)
|
||||
return () => { _sendOverrideListeners.delete(listener) }
|
||||
}
|
||||
|
||||
export function getSendOverrideVersion(): number {
|
||||
return _sendOverrideVersion
|
||||
}
|
||||
@@ -19,8 +19,22 @@ const pendingPipePermissions = new Map<string, PendingPipePermission>()
|
||||
type PipeRelayFn = (message: PipeMessage) => void
|
||||
let _pipeRelay: PipeRelayFn | null = null
|
||||
|
||||
// Slave-side mute flag: when true, relayPipeMessage() and permission
|
||||
// relay functions will short-circuit. Set by relay_mute / relay_unmute
|
||||
// control messages from master.
|
||||
let _relayMuted = false
|
||||
|
||||
export function setRelayMuted(muted: boolean): void {
|
||||
_relayMuted = muted
|
||||
}
|
||||
|
||||
export function isRelayMuted(): boolean {
|
||||
return _relayMuted
|
||||
}
|
||||
|
||||
export function setPipeRelay(fn: PipeRelayFn | null): void {
|
||||
_pipeRelay = fn
|
||||
if (!fn) _relayMuted = false // reset on disconnect
|
||||
}
|
||||
|
||||
export function getPipeRelay(): PipeRelayFn | null {
|
||||
@@ -37,6 +51,7 @@ export function tryRelayPipePermissionRequest(
|
||||
toolUseConfirm: ToolUseConfirm,
|
||||
onResponse: (payload: PipePermissionResponsePayload) => void,
|
||||
): string | null {
|
||||
if (_relayMuted) return null
|
||||
const send = getPipeSender()
|
||||
if (!send) return null
|
||||
|
||||
@@ -93,6 +108,7 @@ export function notifyPipePermissionCancel(
|
||||
reason?: string,
|
||||
): void {
|
||||
if (!requestId) return
|
||||
if (_relayMuted) return
|
||||
const send = getPipeSender()
|
||||
if (!send) return
|
||||
send({
|
||||
|
||||
@@ -31,7 +31,8 @@ import { attachNdjsonFramer } from './ndjsonFramer.js'
|
||||
* Message types exchanged over the pipe.
|
||||
*
|
||||
* Basic: ping, pong
|
||||
* Control: attach_request, attach_accept, attach_reject, detach
|
||||
* Control: attach_request, attach_accept, attach_reject, detach,
|
||||
* relay_mute, relay_unmute
|
||||
* Data (M→S): prompt — master sends user input to slave
|
||||
* Data (S→M): stream — slave streams AI output fragments
|
||||
* tool_start — slave notifies tool execution start
|
||||
@@ -49,6 +50,9 @@ export type PipeMessageType =
|
||||
| 'attach_accept'
|
||||
| 'attach_reject'
|
||||
| 'detach'
|
||||
// Mute control (master → slave): logical disconnect without dropping transport
|
||||
| 'relay_mute'
|
||||
| 'relay_unmute'
|
||||
// Data flow (master → slave)
|
||||
| 'prompt'
|
||||
// Data flow (slave → master)
|
||||
|
||||
@@ -66,6 +66,11 @@ import { evictTerminalTask } from '../../utils/task/framework.js'
|
||||
import { tokenCountWithEstimation } from '../../utils/tokens.js'
|
||||
import { createAbortController } from '../abortController.js'
|
||||
import { type AgentContext, runWithAgentContext } from '../agentContext.js'
|
||||
import {
|
||||
markAutonomyRunCompleted,
|
||||
markAutonomyRunFailed,
|
||||
markAutonomyRunRunning,
|
||||
} from '../autonomyRuns.js'
|
||||
import { count } from '../array.js'
|
||||
import { logForDebugging } from '../debug.js'
|
||||
import { cloneFileStateCache } from '../fileStateCache.js'
|
||||
@@ -668,6 +673,7 @@ type WaitResult =
|
||||
| {
|
||||
type: 'new_message'
|
||||
message: string
|
||||
autonomyRunId?: string
|
||||
from: string
|
||||
color?: string
|
||||
summary?: string
|
||||
@@ -710,7 +716,7 @@ async function waitForNextPromptOrShutdown(
|
||||
task.type === 'in_process_teammate' &&
|
||||
task.pendingUserMessages.length > 0
|
||||
) {
|
||||
const message = task.pendingUserMessages[0]! // Safe: checked length > 0
|
||||
const pending = task.pendingUserMessages[0]! // Safe: checked length > 0
|
||||
// Pop the message from the queue
|
||||
setAppState(prev => {
|
||||
const prevTask = prev.tasks[taskId]
|
||||
@@ -731,9 +737,13 @@ async function waitForNextPromptOrShutdown(
|
||||
logForDebugging(
|
||||
`[inProcessRunner] ${identity.agentName} found pending user message (poll #${pollCount})`,
|
||||
)
|
||||
if (pending.autonomyRunId) {
|
||||
await markAutonomyRunRunning(pending.autonomyRunId)
|
||||
}
|
||||
return {
|
||||
type: 'new_message',
|
||||
message,
|
||||
message: pending.message,
|
||||
autonomyRunId: pending.autonomyRunId,
|
||||
from: 'user',
|
||||
}
|
||||
}
|
||||
@@ -1010,6 +1020,7 @@ export async function runInProcessTeammate(
|
||||
description,
|
||||
)
|
||||
let currentPrompt = wrappedInitialPrompt
|
||||
let currentAutonomyRunId: string | undefined
|
||||
let shouldExit = false
|
||||
|
||||
// Try to claim an available task immediately so the UI can show activity
|
||||
@@ -1306,6 +1317,13 @@ export async function runInProcessTeammate(
|
||||
}),
|
||||
setAppState,
|
||||
)
|
||||
if (currentAutonomyRunId) {
|
||||
await markAutonomyRunFailed(currentAutonomyRunId, ERROR_MESSAGE_USER_ABORT)
|
||||
currentAutonomyRunId = undefined
|
||||
}
|
||||
} else if (currentAutonomyRunId) {
|
||||
await markAutonomyRunCompleted(currentAutonomyRunId)
|
||||
currentAutonomyRunId = undefined
|
||||
}
|
||||
|
||||
// Check if already idle before updating (to skip duplicate notification)
|
||||
@@ -1378,6 +1396,7 @@ export async function runInProcessTeammate(
|
||||
createUserMessage({ content: currentPrompt }),
|
||||
setAppState,
|
||||
)
|
||||
currentAutonomyRunId = undefined
|
||||
break
|
||||
|
||||
case 'new_message':
|
||||
@@ -1389,6 +1408,7 @@ export async function runInProcessTeammate(
|
||||
// Messages from other teammates get XML wrapper for identification
|
||||
if (waitResult.from === 'user') {
|
||||
currentPrompt = waitResult.message
|
||||
currentAutonomyRunId = waitResult.autonomyRunId
|
||||
} else {
|
||||
currentPrompt = formatAsTeammateMessage(
|
||||
waitResult.from,
|
||||
@@ -1404,6 +1424,7 @@ export async function runInProcessTeammate(
|
||||
createUserMessage({ content: currentPrompt }),
|
||||
setAppState,
|
||||
)
|
||||
currentAutonomyRunId = undefined
|
||||
}
|
||||
break
|
||||
|
||||
@@ -1459,7 +1480,6 @@ export async function runInProcessTeammate(
|
||||
summary: identity.agentId,
|
||||
})
|
||||
}
|
||||
|
||||
unregisterPerfettoAgent(identity.agentId)
|
||||
return { success: true, messages: allMessages }
|
||||
} catch (error) {
|
||||
@@ -1511,6 +1531,9 @@ export async function runInProcessTeammate(
|
||||
summary: identity.agentId,
|
||||
})
|
||||
}
|
||||
if (currentAutonomyRunId) {
|
||||
await markAutonomyRunFailed(currentAutonomyRunId, errorMessage)
|
||||
}
|
||||
|
||||
// Send idle notification with failure via file-based mailbox
|
||||
await sendIdleNotification(
|
||||
|
||||
@@ -24,6 +24,7 @@ import type {
|
||||
TeammateIdentity,
|
||||
} from '../../tasks/InProcessTeammateTask/types.js'
|
||||
import { createAbortController } from '../abortController.js'
|
||||
import { markAutonomyRunFailed } from '../autonomyRuns.js'
|
||||
import { formatAgentId } from '../agentId.js'
|
||||
import { registerCleanup } from '../cleanupRegistry.js'
|
||||
import { logForDebugging } from '../debug.js'
|
||||
@@ -233,6 +234,7 @@ export function killInProcessTeammate(
|
||||
let agentId: string | null = null
|
||||
let toolUseId: string | undefined
|
||||
let description: string | undefined
|
||||
let pendingAutonomyRunIds: string[] = []
|
||||
|
||||
setAppState((prev: AppState) => {
|
||||
const task = prev.tasks[taskId]
|
||||
@@ -252,6 +254,11 @@ export function killInProcessTeammate(
|
||||
toolUseId = teammateTask.toolUseId
|
||||
description = teammateTask.description
|
||||
|
||||
// Capture pending autonomy run IDs before clearing them
|
||||
pendingAutonomyRunIds = teammateTask.pendingUserMessages
|
||||
.map(message => message.autonomyRunId)
|
||||
.filter((runId): runId is string => runId !== undefined)
|
||||
|
||||
// Abort the controller to stop execution
|
||||
teammateTask.abortController?.abort()
|
||||
|
||||
@@ -304,6 +311,12 @@ export function killInProcessTeammate(
|
||||
}
|
||||
|
||||
if (killed) {
|
||||
for (const runId of pendingAutonomyRunIds) {
|
||||
void markAutonomyRunFailed(
|
||||
runId,
|
||||
`Teammate ${agentId ?? taskId} was stopped before it could consume the queued autonomy prompt.`,
|
||||
)
|
||||
}
|
||||
void evictTaskOutput(taskId)
|
||||
// notified:true was pre-set so no XML notification fires; close the SDK
|
||||
// task_started bookend directly. The in-process runner's own
|
||||
|
||||
@@ -1,3 +1,78 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export const shouldGenerateTaskSummary: () => boolean = () => false;
|
||||
export const maybeGenerateTaskSummary: (options: Record<string, unknown>) => void = () => {};
|
||||
import { feature } from 'bun:bundle'
|
||||
import { isBgSession, updateSessionActivity } from './concurrentSessions.js'
|
||||
import { logForDebugging } from './debug.js'
|
||||
|
||||
/**
|
||||
* Minimum interval between task summary generations (ms).
|
||||
* Prevents excessive updates during rapid tool-call loops.
|
||||
*/
|
||||
const SUMMARY_INTERVAL_MS = 30_000
|
||||
|
||||
let lastSummaryTime = 0
|
||||
|
||||
/**
|
||||
* Whether a task summary should be generated this turn.
|
||||
* Only generates in bg sessions, and rate-limits to avoid churn.
|
||||
*/
|
||||
export function shouldGenerateTaskSummary(): boolean {
|
||||
if (!feature('BG_SESSIONS')) return false
|
||||
if (!isBgSession()) return false
|
||||
|
||||
const now = Date.now()
|
||||
return now - lastSummaryTime >= SUMMARY_INTERVAL_MS
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a task summary from the current turn's messages and push it
|
||||
* to the session registry so `claude ps` can display live status.
|
||||
*
|
||||
* Fire-and-forget from query.ts — errors are logged, never thrown.
|
||||
*/
|
||||
export function maybeGenerateTaskSummary(
|
||||
options: Record<string, unknown>,
|
||||
): void {
|
||||
lastSummaryTime = Date.now()
|
||||
|
||||
try {
|
||||
const messages = options.forkContextMessages as
|
||||
| Array<{
|
||||
type: string
|
||||
message?: { content?: unknown }
|
||||
}>
|
||||
| undefined
|
||||
|
||||
if (!messages || messages.length === 0) return
|
||||
|
||||
// Extract a short status from the most recent assistant message
|
||||
const lastAssistant = [...messages]
|
||||
.reverse()
|
||||
.find(m => m.type === 'assistant')
|
||||
|
||||
let status: 'busy' | 'idle' = 'busy'
|
||||
let waitingFor: string | undefined
|
||||
|
||||
if (lastAssistant?.message?.content) {
|
||||
const content = lastAssistant.message.content
|
||||
// Check if last block is tool_use
|
||||
if (Array.isArray(content)) {
|
||||
const lastBlock = content[content.length - 1] as
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
if (lastBlock?.type === 'tool_use') {
|
||||
status = 'busy'
|
||||
waitingFor = `tool: ${lastBlock.name || 'unknown'}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fire-and-forget update to session registry
|
||||
void updateSessionActivity({
|
||||
status,
|
||||
waitingFor,
|
||||
}).catch(err => {
|
||||
logForDebugging(`[taskSummary] updateSessionActivity failed: ${err}`)
|
||||
})
|
||||
} catch (err) {
|
||||
logForDebugging(`[taskSummary] error: ${err}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { existsSync } from 'fs'
|
||||
import memoize from 'lodash-es/memoize.js'
|
||||
import * as path from 'path'
|
||||
import * as pathWin32 from 'path/win32'
|
||||
@@ -8,17 +9,13 @@ import { memoizeWithLRU } from './memoize.js'
|
||||
import { getPlatform } from './platform.js'
|
||||
|
||||
/**
|
||||
* Check if a file or directory exists on Windows using the dir command
|
||||
* @param path - The path to check
|
||||
* @returns true if the path exists, false otherwise
|
||||
* Check if a file or directory exists on Windows.
|
||||
* Uses fs.existsSync instead of `dir` shell command to avoid spawning
|
||||
* cmd.exe — which can cause brief console window flashes in detached
|
||||
* or windowsHide child processes.
|
||||
*/
|
||||
function checkPathExists(path: string): boolean {
|
||||
try {
|
||||
execSync_DEPRECATED(`dir "${path}"`, { stdio: 'pipe' })
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
function checkPathExists(filePath: string): boolean {
|
||||
return existsSync(filePath)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -88,6 +85,8 @@ export function setShellIfWindows(): void {
|
||||
if (getPlatform() === 'windows') {
|
||||
const gitBashPath = findGitBashPath()
|
||||
process.env.SHELL = gitBashPath
|
||||
// Propagate to child processes so they skip filesystem probing
|
||||
process.env.CLAUDE_CODE_GIT_BASH_PATH = gitBashPath
|
||||
logForDebugging(`Using bash path: "${gitBashPath}"`)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user