mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 12:55:51 +00:00
feat: 添加 autonomy 自主模式命令系统
- 新增 autonomy CLI handler 和交互式面板 - 新增 autonomyCommandSpec 命令规范定义 - 新增 autonomyAuthority 权限控制 - 新增 autonomyStatus 状态管理 - 注册 CLI 子命令 (claude autonomy status/runs/flows/flow) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
132
src/cli/handlers/__tests__/autonomy.test.ts
Normal file
132
src/cli/handlers/__tests__/autonomy.test.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
||||
import { mkdir, rm, writeFile } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import {
|
||||
resetStateForTests,
|
||||
setOriginalCwd,
|
||||
setProjectRoot,
|
||||
} from '../../../bootstrap/state'
|
||||
import { createAutonomyQueuedPrompt } from '../../../utils/autonomyRuns'
|
||||
import {
|
||||
cancelAutonomyFlowText,
|
||||
getAutonomyDeepSectionText,
|
||||
getAutonomyFlowText,
|
||||
getAutonomyFlowsText,
|
||||
getAutonomyStatusText,
|
||||
resumeAutonomyFlowText,
|
||||
} from '../autonomy'
|
||||
import {
|
||||
listAutonomyFlows,
|
||||
startManagedAutonomyFlow,
|
||||
} from '../../../utils/autonomyFlows'
|
||||
|
||||
let tempDir: string
|
||||
let previousConfigDir: string | undefined
|
||||
|
||||
beforeEach(async () => {
|
||||
previousConfigDir = process.env.CLAUDE_CONFIG_DIR
|
||||
tempDir = join(
|
||||
tmpdir(),
|
||||
`autonomy-cli-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
)
|
||||
await mkdir(tempDir, { recursive: true })
|
||||
process.env.CLAUDE_CONFIG_DIR = join(tempDir, 'config')
|
||||
resetStateForTests()
|
||||
setOriginalCwd(tempDir)
|
||||
setProjectRoot(tempDir)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
resetStateForTests()
|
||||
if (previousConfigDir === undefined) {
|
||||
delete process.env.CLAUDE_CONFIG_DIR
|
||||
} else {
|
||||
process.env.CLAUDE_CONFIG_DIR = previousConfigDir
|
||||
}
|
||||
await rm(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('autonomy CLI handler', () => {
|
||||
test('prints the same basic status surfaces as the slash command', async () => {
|
||||
await createAutonomyQueuedPrompt({
|
||||
basePrompt: 'scheduled prompt',
|
||||
trigger: 'scheduled-task',
|
||||
rootDir: tempDir,
|
||||
currentDir: tempDir,
|
||||
sourceLabel: 'nightly',
|
||||
})
|
||||
|
||||
const output = await getAutonomyStatusText()
|
||||
|
||||
expect(output).toContain('Autonomy runs: 1')
|
||||
expect(output).toContain('Queued: 1')
|
||||
expect(output).toContain('Autonomy flows: 0')
|
||||
})
|
||||
|
||||
test('prints deep status for CLI status --deep', async () => {
|
||||
await mkdir(join(tempDir, '.claude'), { recursive: true })
|
||||
await writeFile(
|
||||
join(tempDir, '.claude', 'remote-trigger-audit.jsonl'),
|
||||
`${JSON.stringify({
|
||||
auditId: 'audit-1',
|
||||
createdAt: 1,
|
||||
action: 'list',
|
||||
ok: true,
|
||||
status: 200,
|
||||
})}\n`,
|
||||
)
|
||||
|
||||
const output = await getAutonomyStatusText({ deep: true })
|
||||
|
||||
expect(output).toContain('# Autonomy Deep Status')
|
||||
expect(output).toContain('## Workflow Runs')
|
||||
expect(output).toContain('## Pipes')
|
||||
expect(output).toContain('## Remote Control')
|
||||
expect(output).toContain('## RemoteTrigger')
|
||||
})
|
||||
|
||||
test('prints individual deep status sections for panel actions', async () => {
|
||||
const pipes = await getAutonomyDeepSectionText('pipes')
|
||||
const remoteControl = await getAutonomyDeepSectionText('remote-control')
|
||||
|
||||
expect(pipes).toContain('# Pipes')
|
||||
expect(pipes).toContain('Pipe registry:')
|
||||
expect(remoteControl).toContain('# Remote Control')
|
||||
expect(remoteControl).toContain('Remote Control:')
|
||||
})
|
||||
|
||||
test('lists, inspects, cancels, and resumes flows from CLI handlers', async () => {
|
||||
await startManagedAutonomyFlow({
|
||||
trigger: 'proactive-tick',
|
||||
goal: 'ship managed flow',
|
||||
rootDir: tempDir,
|
||||
currentDir: tempDir,
|
||||
steps: [
|
||||
{
|
||||
name: 'wait',
|
||||
prompt: 'Wait for manual signal',
|
||||
waitFor: 'manual',
|
||||
},
|
||||
{
|
||||
name: 'run',
|
||||
prompt: 'Run the next step',
|
||||
},
|
||||
],
|
||||
})
|
||||
const [waitingFlow] = await listAutonomyFlows(tempDir)
|
||||
|
||||
expect(await getAutonomyFlowsText()).toContain(waitingFlow!.flowId)
|
||||
expect(await getAutonomyFlowText(waitingFlow!.flowId)).toContain(
|
||||
'Current step: wait',
|
||||
)
|
||||
|
||||
const resumed = await resumeAutonomyFlowText(waitingFlow!.flowId)
|
||||
expect(resumed).toContain('Prepared the next managed step')
|
||||
expect(resumed).toContain('Prompt:')
|
||||
expect(resumed).toContain('Wait for manual signal')
|
||||
|
||||
const cancelled = await cancelAutonomyFlowText(waitingFlow!.flowId)
|
||||
expect(cancelled).toContain('Cancelled flow')
|
||||
})
|
||||
})
|
||||
213
src/cli/handlers/autonomy.ts
Normal file
213
src/cli/handlers/autonomy.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import {
|
||||
formatAutonomyFlowDetail,
|
||||
formatAutonomyFlowsList,
|
||||
formatAutonomyFlowsStatus,
|
||||
getAutonomyFlowById,
|
||||
listAutonomyFlows,
|
||||
requestManagedAutonomyFlowCancel,
|
||||
} from '../../utils/autonomyFlows.js'
|
||||
import {
|
||||
formatAutonomyRunsList,
|
||||
formatAutonomyRunsStatus,
|
||||
listAutonomyRuns,
|
||||
markAutonomyRunCancelled,
|
||||
resumeManagedAutonomyFlowPrompt,
|
||||
} from '../../utils/autonomyRuns.js'
|
||||
import {
|
||||
formatAutonomyDeepStatus,
|
||||
formatAutonomyDeepStatusSections,
|
||||
type AutonomyDeepStatusSectionId,
|
||||
} from '../../utils/autonomyStatus.js'
|
||||
import {
|
||||
AUTONOMY_USAGE,
|
||||
parseAutonomyArgs,
|
||||
} from '../../utils/autonomyCommandSpec.js'
|
||||
import {
|
||||
enqueuePendingNotification,
|
||||
removeByFilter,
|
||||
} from '../../utils/messageQueueManager.js'
|
||||
|
||||
export function parseAutonomyLimit(raw?: string | number): number {
|
||||
const parsed = typeof raw === 'number' ? raw : Number.parseInt(raw ?? '', 10)
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return 10
|
||||
}
|
||||
return Math.min(parsed, 50)
|
||||
}
|
||||
|
||||
export async function getAutonomyStatusText(options?: {
|
||||
deep?: boolean
|
||||
}): Promise<string> {
|
||||
const [runs, flows] = await Promise.all([
|
||||
listAutonomyRuns(),
|
||||
listAutonomyFlows(),
|
||||
])
|
||||
|
||||
if (options?.deep) {
|
||||
return formatAutonomyDeepStatus({ runs, flows })
|
||||
}
|
||||
|
||||
return [
|
||||
formatAutonomyRunsStatus(runs),
|
||||
formatAutonomyFlowsStatus(flows),
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export async function getAutonomyDeepSectionText(
|
||||
sectionId: AutonomyDeepStatusSectionId,
|
||||
): Promise<string> {
|
||||
const [runs, flows] = await Promise.all([
|
||||
listAutonomyRuns(),
|
||||
listAutonomyFlows(),
|
||||
])
|
||||
const sections = await formatAutonomyDeepStatusSections({ runs, flows })
|
||||
const section = sections.find(item => item.id === sectionId)
|
||||
if (!section) {
|
||||
return `Autonomy deep status section not found: ${sectionId}`
|
||||
}
|
||||
return [`# ${section.title}`, section.content].join('\n')
|
||||
}
|
||||
|
||||
export async function autonomyStatusHandler(options?: {
|
||||
deep?: boolean
|
||||
}): Promise<void> {
|
||||
process.stdout.write(`${await getAutonomyStatusText(options)}\n`)
|
||||
}
|
||||
|
||||
export async function getAutonomyRunsText(
|
||||
limit?: string | number,
|
||||
): Promise<string> {
|
||||
return formatAutonomyRunsList(
|
||||
await listAutonomyRuns(),
|
||||
parseAutonomyLimit(limit),
|
||||
)
|
||||
}
|
||||
|
||||
export async function autonomyRunsHandler(
|
||||
limit?: string | number,
|
||||
): Promise<void> {
|
||||
process.stdout.write(`${await getAutonomyRunsText(limit)}\n`)
|
||||
}
|
||||
|
||||
export async function getAutonomyFlowsText(
|
||||
limit?: string | number,
|
||||
): Promise<string> {
|
||||
return formatAutonomyFlowsList(
|
||||
await listAutonomyFlows(),
|
||||
parseAutonomyLimit(limit),
|
||||
)
|
||||
}
|
||||
|
||||
export async function autonomyFlowsHandler(
|
||||
limit?: string | number,
|
||||
): Promise<void> {
|
||||
process.stdout.write(`${await getAutonomyFlowsText(limit)}\n`)
|
||||
}
|
||||
|
||||
export async function getAutonomyFlowText(flowId: string): Promise<string> {
|
||||
return formatAutonomyFlowDetail(await getAutonomyFlowById(flowId))
|
||||
}
|
||||
|
||||
export async function autonomyFlowHandler(flowId: string): Promise<void> {
|
||||
process.stdout.write(`${await getAutonomyFlowText(flowId)}\n`)
|
||||
}
|
||||
|
||||
export async function cancelAutonomyFlowText(
|
||||
flowId: string,
|
||||
options?: {
|
||||
removeQueuedInMemory?: boolean
|
||||
},
|
||||
): Promise<string> {
|
||||
const cancelled = await requestManagedAutonomyFlowCancel({ flowId })
|
||||
if (!cancelled) {
|
||||
return 'Autonomy flow not found.'
|
||||
}
|
||||
if (!cancelled.accepted) {
|
||||
return `Autonomy flow ${flowId} is already terminal (${cancelled.flow.status}).`
|
||||
}
|
||||
|
||||
let removedCount = 0
|
||||
if (options?.removeQueuedInMemory) {
|
||||
const removed = removeByFilter(cmd => cmd.autonomy?.flowId === flowId)
|
||||
removedCount = removed.length
|
||||
for (const command of removed) {
|
||||
if (command.autonomy?.runId) {
|
||||
await markAutonomyRunCancelled(command.autonomy.runId)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const runId of cancelled.queuedRunIds) {
|
||||
await markAutonomyRunCancelled(runId)
|
||||
}
|
||||
removedCount = cancelled.queuedRunIds.length
|
||||
}
|
||||
|
||||
return cancelled.flow.status === 'running'
|
||||
? `Cancellation requested for flow ${flowId}. The current step is still running, and no new steps will be started.`
|
||||
: `Cancelled flow ${flowId}. Removed ${removedCount} queued step(s).`
|
||||
}
|
||||
|
||||
export async function autonomyFlowCancelHandler(flowId: string): Promise<void> {
|
||||
process.stdout.write(`${await cancelAutonomyFlowText(flowId)}\n`)
|
||||
}
|
||||
|
||||
export async function resumeAutonomyFlowText(
|
||||
flowId: string,
|
||||
options?: {
|
||||
enqueueInMemory?: boolean
|
||||
},
|
||||
): Promise<string> {
|
||||
const command = await resumeManagedAutonomyFlowPrompt({ flowId })
|
||||
if (!command) {
|
||||
return 'Autonomy flow is not waiting or was not found.'
|
||||
}
|
||||
|
||||
if (options?.enqueueInMemory) {
|
||||
enqueuePendingNotification(command)
|
||||
return `Queued the next managed step for flow ${flowId}.`
|
||||
}
|
||||
|
||||
const runId = command.autonomy?.runId ?? 'unknown'
|
||||
return [
|
||||
`Prepared the next managed step for flow ${flowId}.`,
|
||||
`Run ID: ${runId}`,
|
||||
'',
|
||||
'Prompt:',
|
||||
typeof command.value === 'string' ? command.value : String(command.value),
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export async function autonomyFlowResumeHandler(flowId: string): Promise<void> {
|
||||
process.stdout.write(`${await resumeAutonomyFlowText(flowId)}\n`)
|
||||
}
|
||||
|
||||
export async function getAutonomyCommandText(
|
||||
args: string,
|
||||
options?: {
|
||||
enqueueInMemory?: boolean
|
||||
removeQueuedInMemory?: boolean
|
||||
},
|
||||
): Promise<string> {
|
||||
const parsed = parseAutonomyArgs(args)
|
||||
|
||||
switch (parsed.type) {
|
||||
case 'status':
|
||||
return getAutonomyStatusText({ deep: parsed.deep })
|
||||
case 'runs':
|
||||
return getAutonomyRunsText(parsed.limit)
|
||||
case 'flows':
|
||||
return getAutonomyFlowsText(parsed.limit)
|
||||
case 'flow-detail':
|
||||
return getAutonomyFlowText(parsed.flowId)
|
||||
case 'flow-cancel':
|
||||
return cancelAutonomyFlowText(parsed.flowId, {
|
||||
removeQueuedInMemory: options?.removeQueuedInMemory,
|
||||
})
|
||||
case 'flow-resume':
|
||||
return resumeAutonomyFlowText(parsed.flowId, {
|
||||
enqueueInMemory: options?.enqueueInMemory,
|
||||
})
|
||||
case 'usage':
|
||||
return AUTONOMY_USAGE
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,12 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
||||
import type React from 'react'
|
||||
import autonomyCommand from '../autonomy'
|
||||
import type { LocalCommandResult } from '../../types/command'
|
||||
import {
|
||||
resetStateForTests,
|
||||
setOriginalCwd,
|
||||
setProjectRoot,
|
||||
} from '../../bootstrap/state'
|
||||
|
||||
function expectTextResult(
|
||||
result: LocalCommandResult,
|
||||
): asserts result is Extract<LocalCommandResult, { type: 'text' }> {
|
||||
if (result.type !== 'text')
|
||||
throw new Error(`Expected text result, got ${result.type}`)
|
||||
}
|
||||
import { listAutonomyFlows } from '../../utils/autonomyFlows'
|
||||
import {
|
||||
createAutonomyQueuedPrompt,
|
||||
@@ -25,11 +19,30 @@ import {
|
||||
resetCommandQueue,
|
||||
} from '../../utils/messageQueueManager'
|
||||
import { cleanupTempDir, createTempDir } from '../../../tests/mocks/file-system'
|
||||
import { mkdir, writeFile } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { writeRegistry } from '../../utils/pipeRegistry'
|
||||
import { getAutonomyPanelBaseActionCountForTests } from '../autonomyPanel'
|
||||
|
||||
let tempDir = ''
|
||||
let previousConfigDir: string | undefined
|
||||
|
||||
async function callAutonomy(args = ''): Promise<{
|
||||
result?: string
|
||||
}> {
|
||||
const mod = await autonomyCommand.load()
|
||||
let result: string | undefined
|
||||
const onDone = (text: string) => {
|
||||
result = text
|
||||
}
|
||||
await mod.call(onDone as any, {} as any, args)
|
||||
return { result }
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await createTempDir('autonomy-command-')
|
||||
previousConfigDir = process.env.CLAUDE_CONFIG_DIR
|
||||
process.env.CLAUDE_CONFIG_DIR = join(tempDir, 'config')
|
||||
resetStateForTests()
|
||||
resetCommandQueue()
|
||||
setOriginalCwd(tempDir)
|
||||
@@ -39,12 +52,30 @@ beforeEach(async () => {
|
||||
afterEach(async () => {
|
||||
resetStateForTests()
|
||||
resetCommandQueue()
|
||||
if (previousConfigDir === undefined) {
|
||||
delete process.env.CLAUDE_CONFIG_DIR
|
||||
} else {
|
||||
process.env.CLAUDE_CONFIG_DIR = previousConfigDir
|
||||
}
|
||||
if (tempDir) {
|
||||
await cleanupTempDir(tempDir)
|
||||
}
|
||||
})
|
||||
|
||||
describe('/autonomy', () => {
|
||||
test('without args renders the autonomy panel', async () => {
|
||||
const mod = await autonomyCommand.load()
|
||||
let onDoneCalled = false
|
||||
const onDone = () => {
|
||||
onDoneCalled = true
|
||||
}
|
||||
const jsx = await mod.call(onDone as any, {} as any, '')
|
||||
// Without args, the panel JSX is returned (onDone is NOT called)
|
||||
expect(jsx).not.toBeNull()
|
||||
expect(onDoneCalled).toBe(false)
|
||||
expect(getAutonomyPanelBaseActionCountForTests()).toBeGreaterThan(10)
|
||||
})
|
||||
|
||||
test('status reports autonomy runs and managed flows separately', async () => {
|
||||
const plainRun = await createAutonomyQueuedPrompt({
|
||||
basePrompt: 'scheduled prompt',
|
||||
@@ -76,14 +107,12 @@ describe('/autonomy', () => {
|
||||
currentDir: tempDir,
|
||||
})
|
||||
|
||||
const mod = await autonomyCommand.load()
|
||||
const result = await mod.call('', {} as any)
|
||||
const { result } = await callAutonomy('status')
|
||||
|
||||
expectTextResult(result)
|
||||
expect(result.value).toContain('Autonomy runs: 2')
|
||||
expect(result.value).toContain('Autonomy flows: 1')
|
||||
expect(result.value).toContain('Completed: 1')
|
||||
expect(result.value).toContain('Queued: 1')
|
||||
expect(result).toContain('Autonomy runs: 2')
|
||||
expect(result).toContain('Autonomy flows: 1')
|
||||
expect(result).toContain('Completed: 1')
|
||||
expect(result).toContain('Queued: 1')
|
||||
})
|
||||
|
||||
test('runs subcommand lists recent autonomy runs', async () => {
|
||||
@@ -94,12 +123,10 @@ describe('/autonomy', () => {
|
||||
currentDir: tempDir,
|
||||
})
|
||||
|
||||
const mod = await autonomyCommand.load()
|
||||
const result = await mod.call('runs 5', {} as any)
|
||||
const { result } = await callAutonomy('runs 5')
|
||||
|
||||
expectTextResult(result)
|
||||
expect(result.value).toContain(queued!.autonomy!.runId)
|
||||
expect(result.value).toContain('proactive-tick')
|
||||
expect(result).toContain(queued!.autonomy!.runId)
|
||||
expect(result).toContain('proactive-tick')
|
||||
})
|
||||
|
||||
test('flows subcommand lists managed flows and flow subcommand shows detail', async () => {
|
||||
@@ -124,18 +151,14 @@ describe('/autonomy', () => {
|
||||
})
|
||||
|
||||
const [flow] = await listAutonomyFlows(tempDir)
|
||||
const mod = await autonomyCommand.load()
|
||||
const flowsResult = await callAutonomy('flows 5')
|
||||
expect(flowsResult.result).toContain(flow!.flowId)
|
||||
expect(flowsResult.result).toContain('managed')
|
||||
|
||||
const flowsResult = await mod.call('flows 5', {} as any)
|
||||
expectTextResult(flowsResult)
|
||||
expect(flowsResult.value).toContain(flow!.flowId)
|
||||
expect(flowsResult.value).toContain('managed')
|
||||
|
||||
const flowResult = await mod.call(`flow ${flow!.flowId}`, {} as any)
|
||||
expectTextResult(flowResult)
|
||||
expect(flowResult.value).toContain(`Flow: ${flow!.flowId}`)
|
||||
expect(flowResult.value).toContain('Mode: managed')
|
||||
expect(flowResult.value).toContain('Current step: gather')
|
||||
const flowResult = await callAutonomy(`flow ${flow!.flowId}`)
|
||||
expect(flowResult.result).toContain(`Flow: ${flow!.flowId}`)
|
||||
expect(flowResult.result).toContain('Mode: managed')
|
||||
expect(flowResult.result).toContain('Current step: gather')
|
||||
})
|
||||
|
||||
test('flow resume queues the next waiting step', async () => {
|
||||
@@ -163,11 +186,9 @@ describe('/autonomy', () => {
|
||||
expect(waitingStart).toBeNull()
|
||||
const [flow] = await listAutonomyFlows(tempDir)
|
||||
|
||||
const mod = await autonomyCommand.load()
|
||||
const result = await mod.call(`flow resume ${flow!.flowId}`, {} as any)
|
||||
const { result } = await callAutonomy(`flow resume ${flow!.flowId}`)
|
||||
|
||||
expectTextResult(result)
|
||||
expect(result.value).toContain('Queued the next managed step')
|
||||
expect(result).toContain('Queued the next managed step')
|
||||
expect(getCommandQueueSnapshot()).toHaveLength(1)
|
||||
expect(getCommandQueueSnapshot()[0]!.autonomy?.flowId).toBe(flow!.flowId)
|
||||
})
|
||||
@@ -197,12 +218,10 @@ describe('/autonomy', () => {
|
||||
enqueuePendingNotification(queued!)
|
||||
expect(getCommandQueueSnapshot()).toHaveLength(1)
|
||||
const [flow] = await listAutonomyFlows(tempDir)
|
||||
const mod = await autonomyCommand.load()
|
||||
const result = await mod.call(`flow cancel ${flow!.flowId}`, {} as any)
|
||||
const { result } = await callAutonomy(`flow cancel ${flow!.flowId}`)
|
||||
const [cancelledFlow] = await listAutonomyFlows(tempDir)
|
||||
|
||||
expectTextResult(result)
|
||||
expect(result.value).toContain('Cancelled flow')
|
||||
expect(result).toContain('Cancelled flow')
|
||||
expect(cancelledFlow!.status).toBe('cancelled')
|
||||
expect(getCommandQueueSnapshot()).toHaveLength(0)
|
||||
})
|
||||
@@ -227,20 +246,132 @@ describe('/autonomy', () => {
|
||||
await markAutonomyRunCompleted(queued!.autonomy!.runId, tempDir)
|
||||
|
||||
const [flow] = await listAutonomyFlows(tempDir)
|
||||
const mod = await autonomyCommand.load()
|
||||
const result = await mod.call(`flow cancel ${flow!.flowId}`, {} as any)
|
||||
const { result } = await callAutonomy(`flow cancel ${flow!.flowId}`)
|
||||
const [terminalFlow] = await listAutonomyFlows(tempDir)
|
||||
|
||||
expectTextResult(result)
|
||||
expect(result.value).toContain('already terminal')
|
||||
expect(result).toContain('already terminal')
|
||||
expect(terminalFlow!.status).toBe('succeeded')
|
||||
})
|
||||
|
||||
test('invalid subcommands return usage text', async () => {
|
||||
const mod = await autonomyCommand.load()
|
||||
const result = await mod.call('unknown', {} as any)
|
||||
const { result } = await callAutonomy('unknown')
|
||||
|
||||
expectTextResult(result)
|
||||
expect(result.value).toContain('Usage: /autonomy')
|
||||
expect(result).toContain('Usage: /autonomy')
|
||||
})
|
||||
|
||||
test('status --deep reports local autonomy health surfaces', async () => {
|
||||
const run = await createAutonomyQueuedPrompt({
|
||||
basePrompt: 'scheduled prompt',
|
||||
trigger: 'scheduled-task',
|
||||
rootDir: tempDir,
|
||||
currentDir: tempDir,
|
||||
sourceLabel: 'nightly',
|
||||
})
|
||||
expect(run).not.toBeNull()
|
||||
|
||||
await mkdir(join(tempDir, '.claude'), { recursive: true })
|
||||
await writeFile(
|
||||
join(tempDir, '.claude', 'scheduled_tasks.json'),
|
||||
JSON.stringify({
|
||||
tasks: [
|
||||
{
|
||||
id: 'cron1',
|
||||
cron: '0 9 * * *',
|
||||
prompt: 'Daily check',
|
||||
createdAt: Date.now(),
|
||||
recurring: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
await mkdir(join(tempDir, '.claude', 'workflow-runs'), {
|
||||
recursive: true,
|
||||
})
|
||||
await writeFile(
|
||||
join(tempDir, '.claude', 'workflow-runs', 'workflow-1.json'),
|
||||
JSON.stringify({
|
||||
runId: 'workflow-1',
|
||||
workflow: 'release',
|
||||
status: 'running',
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
currentStepIndex: 0,
|
||||
steps: [
|
||||
{
|
||||
name: 'Run tests',
|
||||
prompt: 'Run focused tests',
|
||||
status: 'running',
|
||||
startedAt: 2,
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
const teamDir = join(process.env.CLAUDE_CONFIG_DIR ?? '', 'teams', 'alpha')
|
||||
await mkdir(teamDir, { recursive: true })
|
||||
await writeFile(
|
||||
join(teamDir, 'config.json'),
|
||||
JSON.stringify({
|
||||
name: 'alpha',
|
||||
createdAt: Date.now(),
|
||||
leadAgentId: 'team-lead@alpha',
|
||||
members: [
|
||||
{
|
||||
agentId: 'team-lead@alpha',
|
||||
name: 'team-lead',
|
||||
joinedAt: Date.now(),
|
||||
tmuxPaneId: '',
|
||||
cwd: tempDir,
|
||||
subscriptions: [],
|
||||
},
|
||||
{
|
||||
agentId: 'worker@alpha',
|
||||
name: 'worker',
|
||||
joinedAt: Date.now(),
|
||||
tmuxPaneId: 'in-process',
|
||||
cwd: tempDir,
|
||||
subscriptions: [],
|
||||
backendType: 'in-process',
|
||||
isActive: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
await writeRegistry({
|
||||
version: 1,
|
||||
mainMachineId: 'machine-main-123456',
|
||||
main: {
|
||||
id: 'main-id',
|
||||
pid: 123,
|
||||
machineId: 'machine-main-123456',
|
||||
startedAt: 1,
|
||||
ip: '127.0.0.1',
|
||||
mac: '00:11:22:33:44:55',
|
||||
hostname: 'main-host',
|
||||
pipeName: 'main-pipe',
|
||||
},
|
||||
subs: [],
|
||||
})
|
||||
|
||||
const { result } = await callAutonomy('status --deep')
|
||||
|
||||
expect(result).toContain('# Autonomy Deep Status')
|
||||
expect(result).toContain('Auto mode:')
|
||||
expect(result).toContain('## Runs')
|
||||
expect(result).toContain('Autonomy runs: 1')
|
||||
expect(result).toContain('## Cron')
|
||||
expect(result).toContain('Cron jobs: 1')
|
||||
expect(result).toContain('## Workflow Runs')
|
||||
expect(result).toContain('Workflow runs: 1')
|
||||
expect(result).toContain('workflow-1: release: running')
|
||||
expect(result).toContain('## Teams')
|
||||
expect(result).toContain('alpha: teammates=1')
|
||||
expect(result).toContain('@worker: idle backend=in-process')
|
||||
expect(result).toContain('## Pipes')
|
||||
expect(result).toContain('Pipe registry: 1 main, 0 sub(s)')
|
||||
expect(result).toContain('## Runtime')
|
||||
expect(result).toContain('Daemon:')
|
||||
expect(result).toContain('## Remote Control')
|
||||
expect(result).toContain('Remote Control:')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,125 +1,13 @@
|
||||
import type { Command, LocalCommandCall } from '../types/command.js'
|
||||
import {
|
||||
formatAutonomyFlowDetail,
|
||||
formatAutonomyFlowsList,
|
||||
formatAutonomyFlowsStatus,
|
||||
getAutonomyFlowById,
|
||||
listAutonomyFlows,
|
||||
requestManagedAutonomyFlowCancel,
|
||||
} from '../utils/autonomyFlows.js'
|
||||
import {
|
||||
formatAutonomyRunsList,
|
||||
formatAutonomyRunsStatus,
|
||||
listAutonomyRuns,
|
||||
markAutonomyRunCancelled,
|
||||
resumeManagedAutonomyFlowPrompt,
|
||||
} from '../utils/autonomyRuns.js'
|
||||
import {
|
||||
enqueuePendingNotification,
|
||||
removeByFilter,
|
||||
} from '../utils/messageQueueManager.js'
|
||||
|
||||
function parseRunsLimit(raw?: string): number {
|
||||
const parsed = Number.parseInt(raw ?? '', 10)
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return 10
|
||||
}
|
||||
return Math.min(parsed, 50)
|
||||
}
|
||||
|
||||
const call: LocalCommandCall = async (args: string) => {
|
||||
const [subcommand = 'status', arg1, arg2] = args.trim().split(/\s+/, 3)
|
||||
const runs = await listAutonomyRuns()
|
||||
const flows = await listAutonomyFlows()
|
||||
|
||||
if (subcommand === 'runs') {
|
||||
return {
|
||||
type: 'text',
|
||||
value: formatAutonomyRunsList(runs, parseRunsLimit(arg1)),
|
||||
}
|
||||
}
|
||||
|
||||
if (subcommand === 'flows') {
|
||||
return {
|
||||
type: 'text',
|
||||
value: formatAutonomyFlowsList(flows, parseRunsLimit(arg1)),
|
||||
}
|
||||
}
|
||||
|
||||
if (subcommand === 'flow') {
|
||||
if (arg1 === 'cancel') {
|
||||
const flowId = arg2 ?? ''
|
||||
const cancelled = await requestManagedAutonomyFlowCancel({ flowId })
|
||||
if (!cancelled) {
|
||||
return {
|
||||
type: 'text',
|
||||
value: 'Autonomy flow not found.',
|
||||
}
|
||||
}
|
||||
if (!cancelled.accepted) {
|
||||
return {
|
||||
type: 'text',
|
||||
value: `Autonomy flow ${flowId} is already terminal (${cancelled.flow.status}).`,
|
||||
}
|
||||
}
|
||||
const removed = removeByFilter(cmd => cmd.autonomy?.flowId === flowId)
|
||||
for (const command of removed) {
|
||||
if (command.autonomy?.runId) {
|
||||
await markAutonomyRunCancelled(command.autonomy.runId)
|
||||
}
|
||||
}
|
||||
return {
|
||||
type: 'text',
|
||||
value:
|
||||
cancelled.flow.status === 'running'
|
||||
? `Cancellation requested for flow ${flowId}. The current step is still running, and no new steps will be started.`
|
||||
: `Cancelled flow ${flowId}. Removed ${removed.length} queued step(s).`,
|
||||
}
|
||||
}
|
||||
|
||||
if (arg1 === 'resume') {
|
||||
const flowId = arg2 ?? ''
|
||||
const command = await resumeManagedAutonomyFlowPrompt({ flowId })
|
||||
if (!command) {
|
||||
return {
|
||||
type: 'text',
|
||||
value: 'Autonomy flow is not waiting or was not found.',
|
||||
}
|
||||
}
|
||||
enqueuePendingNotification(command)
|
||||
return {
|
||||
type: 'text',
|
||||
value: `Queued the next managed step for flow ${flowId}.`,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'text',
|
||||
value: formatAutonomyFlowDetail(await getAutonomyFlowById(arg1 ?? '')),
|
||||
}
|
||||
}
|
||||
|
||||
if (subcommand !== 'status' && subcommand !== '') {
|
||||
return {
|
||||
type: 'text',
|
||||
value:
|
||||
'Usage: /autonomy [status|runs [limit]|flows [limit]|flow <id>|flow cancel <id>|flow resume <id>]',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'text',
|
||||
value: [formatAutonomyRunsStatus(runs), formatAutonomyFlowsStatus(flows)].join('\n'),
|
||||
}
|
||||
}
|
||||
import type { Command } from '../types/command.js'
|
||||
|
||||
const autonomy = {
|
||||
type: 'local',
|
||||
type: 'local-jsx',
|
||||
name: 'autonomy',
|
||||
description:
|
||||
'Inspect automatic autonomy runs recorded for proactive ticks and scheduled tasks',
|
||||
supportsNonInteractive: true,
|
||||
load: () => Promise.resolve({ call }),
|
||||
argumentHint:
|
||||
'[status [--deep]|runs [limit]|flows [limit]|flow <id>|flow cancel <id>|flow resume <id>]',
|
||||
load: () => import('./autonomyPanel.js'),
|
||||
} satisfies Command
|
||||
|
||||
export default autonomy
|
||||
|
||||
208
src/commands/autonomyPanel.tsx
Normal file
208
src/commands/autonomyPanel.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Box, Text, useInput } from '@anthropic/ink';
|
||||
import { Dialog } from '@anthropic/ink';
|
||||
import { useRegisterOverlay } from '../context/overlayContext.js';
|
||||
import type { LocalJSXCommandOnDone } from '../types/command.js';
|
||||
import { getAutonomyCommandText, getAutonomyDeepSectionText, getAutonomyStatusText } from '../cli/handlers/autonomy.js';
|
||||
import { listAutonomyFlows, type AutonomyFlowRecord } from '../utils/autonomyFlows.js';
|
||||
|
||||
type AutonomyAction = {
|
||||
label: string;
|
||||
description: string;
|
||||
run: () => Promise<string>;
|
||||
};
|
||||
|
||||
const BASE_AUTONOMY_PANEL_ACTION_COUNT = 14;
|
||||
const ACTION_LABEL_COLUMN_WIDTH = 24;
|
||||
|
||||
export function getAutonomyPanelBaseActionCountForTests(): number {
|
||||
return BASE_AUTONOMY_PANEL_ACTION_COUNT;
|
||||
}
|
||||
|
||||
function AutonomyPanel({ onDone }: { onDone: LocalJSXCommandOnDone }): React.ReactNode {
|
||||
useRegisterOverlay('autonomy-panel');
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [flows, setFlows] = useState<AutonomyFlowRecord[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void listAutonomyFlows().then(items => {
|
||||
if (!cancelled) setFlows(items.slice(0, 5));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const actions = useMemo<AutonomyAction[]>(() => {
|
||||
const base: AutonomyAction[] = [
|
||||
{
|
||||
label: 'Overview',
|
||||
description: 'Show run and flow counts plus the latest automatic activity',
|
||||
run: () => getAutonomyStatusText(),
|
||||
},
|
||||
{
|
||||
label: 'Full deep status',
|
||||
description: 'Print every local autonomy surface in one diagnostic report',
|
||||
run: () => getAutonomyStatusText({ deep: true }),
|
||||
},
|
||||
{
|
||||
label: 'Auto mode',
|
||||
description: 'Check whether auto permission mode is available and why',
|
||||
run: () => getAutonomyDeepSectionText('auto-mode'),
|
||||
},
|
||||
{
|
||||
label: 'Runs summary',
|
||||
description: 'Show queued/running/completed/failed run totals and latest run',
|
||||
run: () => getAutonomyDeepSectionText('runs'),
|
||||
},
|
||||
{
|
||||
label: 'Recent runs',
|
||||
description: 'List recent autonomy run IDs, triggers, statuses, and prompts',
|
||||
run: () => getAutonomyCommandText('runs 10'),
|
||||
},
|
||||
{
|
||||
label: 'Flows summary',
|
||||
description: 'Show managed flow totals across queued/running/waiting states',
|
||||
run: () => getAutonomyDeepSectionText('flows'),
|
||||
},
|
||||
{
|
||||
label: 'Recent flows',
|
||||
description: 'List recent managed flow IDs, status, current step, and goal',
|
||||
run: () => getAutonomyCommandText('flows 10'),
|
||||
},
|
||||
{
|
||||
label: 'Cron',
|
||||
description: 'Show scheduled autonomy jobs, durability, recurrence, and next run',
|
||||
run: () => getAutonomyDeepSectionText('cron'),
|
||||
},
|
||||
{
|
||||
label: 'Workflow runs',
|
||||
description: 'Show persisted WorkflowTool runs and their current workflow step',
|
||||
run: () => getAutonomyDeepSectionText('workflow-runs'),
|
||||
},
|
||||
{
|
||||
label: 'Teams',
|
||||
description: 'Show Agent Teams, teammate backends, activity, and open tasks',
|
||||
run: () => getAutonomyDeepSectionText('teams'),
|
||||
},
|
||||
{
|
||||
label: 'Pipes',
|
||||
description: 'Show UDS/named-pipe and LAN registry for terminal messaging',
|
||||
run: () => getAutonomyDeepSectionText('pipes'),
|
||||
},
|
||||
{
|
||||
label: 'Runtime',
|
||||
description: 'Show daemon state and live background or interactive sessions',
|
||||
run: () => getAutonomyDeepSectionText('runtime'),
|
||||
},
|
||||
{
|
||||
label: 'Remote Control',
|
||||
description: 'Show bridge mode, base URL, token presence, and entitlement note',
|
||||
run: () => getAutonomyDeepSectionText('remote-control'),
|
||||
},
|
||||
{
|
||||
label: 'RemoteTrigger',
|
||||
description: 'Show recent remote trigger audit records, failures, and latest call',
|
||||
run: () => getAutonomyDeepSectionText('remote-trigger'),
|
||||
},
|
||||
];
|
||||
|
||||
const flowActions = flows.flatMap<AutonomyAction>(flow => {
|
||||
const shortId = flow.flowId.slice(0, 8);
|
||||
const items: AutonomyAction[] = [
|
||||
{
|
||||
label: `Flow ${shortId}`,
|
||||
description: `${flow.status}: ${flow.goal}`,
|
||||
run: () => getAutonomyCommandText(`flow ${flow.flowId}`),
|
||||
},
|
||||
];
|
||||
if (flow.status === 'waiting') {
|
||||
items.push({
|
||||
label: `Resume ${shortId}`,
|
||||
description: flow.currentStep ? `Resume waiting step: ${flow.currentStep}` : 'Resume waiting flow',
|
||||
run: () =>
|
||||
getAutonomyCommandText(`flow resume ${flow.flowId}`, {
|
||||
enqueueInMemory: true,
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (
|
||||
flow.status === 'queued' ||
|
||||
flow.status === 'running' ||
|
||||
flow.status === 'waiting' ||
|
||||
flow.status === 'blocked'
|
||||
) {
|
||||
items.push({
|
||||
label: `Cancel ${shortId}`,
|
||||
description: `Cancel ${flow.status} flow`,
|
||||
run: () =>
|
||||
getAutonomyCommandText(`flow cancel ${flow.flowId}`, {
|
||||
removeQueuedInMemory: true,
|
||||
}),
|
||||
});
|
||||
}
|
||||
return items;
|
||||
});
|
||||
|
||||
return [...base, ...flowActions];
|
||||
}, [flows]);
|
||||
|
||||
const selectCurrent = () => {
|
||||
const action = actions[selectedIndex];
|
||||
if (!action) return;
|
||||
void action.run().then(result => {
|
||||
onDone(result, { display: 'system' });
|
||||
});
|
||||
};
|
||||
|
||||
useInput((_input, key) => {
|
||||
if (key.upArrow) {
|
||||
setSelectedIndex(index => Math.max(0, index - 1));
|
||||
return;
|
||||
}
|
||||
if (key.downArrow) {
|
||||
setSelectedIndex(index => Math.min(actions.length - 1, index + 1));
|
||||
return;
|
||||
}
|
||||
if (key.return) {
|
||||
selectCurrent();
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
title="Autonomy"
|
||||
subtitle={`${actions.length} actions`}
|
||||
onCancel={() => onDone('Autonomy panel dismissed', { display: 'system' })}
|
||||
color="background"
|
||||
hideInputGuide
|
||||
>
|
||||
<Box flexDirection="column">
|
||||
{actions.map((action, index) => (
|
||||
<Box key={`${action.label}-${index}`} flexDirection="row">
|
||||
<Text>{`${index === selectedIndex ? '›' : ' '} ${action.label}`.padEnd(ACTION_LABEL_COLUMN_WIDTH)}</Text>
|
||||
<Text dimColor>{action.description}</Text>
|
||||
</Box>
|
||||
))}
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>↑/↓ select · Enter run · Esc close</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export async function call(onDone: LocalJSXCommandOnDone, _context: unknown, args?: string): Promise<React.ReactNode> {
|
||||
const trimmed = args?.trim() ?? '';
|
||||
if (trimmed) {
|
||||
const result = await getAutonomyCommandText(trimmed, {
|
||||
enqueueInMemory: true,
|
||||
removeQueuedInMemory: true,
|
||||
});
|
||||
onDone(result, { display: 'system' });
|
||||
return null;
|
||||
}
|
||||
|
||||
return <AutonomyPanel onDone={onDone} />;
|
||||
}
|
||||
62
src/main.tsx
62
src/main.tsx
@@ -6429,6 +6429,68 @@ async function run(): Promise<CommanderCommand> {
|
||||
}
|
||||
}
|
||||
|
||||
// claude autonomy — CLI subcommands mirroring /autonomy slash command
|
||||
{
|
||||
const autonomyCmd = program
|
||||
.command("autonomy")
|
||||
.description("Inspect and manage automatic autonomy runs and flows");
|
||||
|
||||
autonomyCmd
|
||||
.command("status")
|
||||
.description("Print autonomy run, flow, team, pipe, and remote-control status")
|
||||
.option("--deep", "Include teams, pipes, daemon, and remote-control sections")
|
||||
.action(async (options: { deep?: boolean }) => {
|
||||
const { autonomyStatusHandler } = await import("./cli/handlers/autonomy.js");
|
||||
await autonomyStatusHandler(options);
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
autonomyCmd
|
||||
.command("runs [limit]")
|
||||
.description("List recent autonomy runs")
|
||||
.action(async (limit?: string) => {
|
||||
const { autonomyRunsHandler } = await import("./cli/handlers/autonomy.js");
|
||||
await autonomyRunsHandler(limit);
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
autonomyCmd
|
||||
.command("flows [limit]")
|
||||
.description("List recent autonomy flows")
|
||||
.action(async (limit?: string) => {
|
||||
const { autonomyFlowsHandler } = await import("./cli/handlers/autonomy.js");
|
||||
await autonomyFlowsHandler(limit);
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
const flowCmd = autonomyCmd
|
||||
.command("flow <flowId>")
|
||||
.description("Inspect a single autonomy flow")
|
||||
.action(async (flowId: string) => {
|
||||
const { autonomyFlowHandler } = await import("./cli/handlers/autonomy.js");
|
||||
await autonomyFlowHandler(flowId);
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
flowCmd
|
||||
.command("cancel <flowId>")
|
||||
.description("Cancel a queued, waiting, or running autonomy flow")
|
||||
.action(async (flowId: string) => {
|
||||
const { autonomyFlowCancelHandler } = await import("./cli/handlers/autonomy.js");
|
||||
await autonomyFlowCancelHandler(flowId);
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
flowCmd
|
||||
.command("resume <flowId>")
|
||||
.description("Resume a waiting autonomy flow")
|
||||
.action(async (flowId: string) => {
|
||||
const { autonomyFlowResumeHandler } = await import("./cli/handlers/autonomy.js");
|
||||
await autonomyFlowResumeHandler(flowId);
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
// Remote Control command — connect local environment to claude.ai/code.
|
||||
// The actual command is intercepted by the fast-path in cli.tsx before
|
||||
// Commander.js runs, so this registration exists only for help output.
|
||||
|
||||
42
src/utils/__tests__/autonomyCommandSpec.test.ts
Normal file
42
src/utils/__tests__/autonomyCommandSpec.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
AUTONOMY_ARGUMENT_HINT,
|
||||
parseAutonomyArgs,
|
||||
} from '../autonomyCommandSpec'
|
||||
|
||||
describe('autonomy command spec', () => {
|
||||
test('provides a command-panel argument hint', () => {
|
||||
expect(AUTONOMY_ARGUMENT_HINT).toContain('status [--deep]')
|
||||
expect(AUTONOMY_ARGUMENT_HINT).toContain('flow resume <id>')
|
||||
})
|
||||
|
||||
test('parses shared slash/CLI autonomy routes', () => {
|
||||
expect(parseAutonomyArgs('')).toEqual({ type: 'status', deep: false })
|
||||
expect(parseAutonomyArgs('status --deep')).toEqual({
|
||||
type: 'status',
|
||||
deep: true,
|
||||
})
|
||||
expect(parseAutonomyArgs('runs 5')).toEqual({
|
||||
type: 'runs',
|
||||
limit: '5',
|
||||
})
|
||||
expect(parseAutonomyArgs('flows 7')).toEqual({
|
||||
type: 'flows',
|
||||
limit: '7',
|
||||
})
|
||||
expect(parseAutonomyArgs('flow flow-1')).toEqual({
|
||||
type: 'flow-detail',
|
||||
flowId: 'flow-1',
|
||||
})
|
||||
expect(parseAutonomyArgs('flow cancel flow-1')).toEqual({
|
||||
type: 'flow-cancel',
|
||||
flowId: 'flow-1',
|
||||
})
|
||||
expect(parseAutonomyArgs('flow resume flow-1')).toEqual({
|
||||
type: 'flow-resume',
|
||||
flowId: 'flow-1',
|
||||
})
|
||||
expect(parseAutonomyArgs('flow cancel')).toEqual({ type: 'usage' })
|
||||
expect(parseAutonomyArgs('unknown')).toEqual({ type: 'usage' })
|
||||
})
|
||||
})
|
||||
@@ -372,6 +372,18 @@ export function resetAutonomyAuthorityForTests(): void {
|
||||
heartbeatTaskLastRunByKey.clear()
|
||||
}
|
||||
|
||||
export function hasAutonomyConfig(rootDir?: string): boolean {
|
||||
const root = resolve(rootDir ?? getProjectRoot())
|
||||
const fs = getFsImplementation()
|
||||
try {
|
||||
const agentsPath = join(root, AUTONOMY_DIR, AUTONOMY_AGENTS_FILENAME)
|
||||
const heartbeatPath = join(root, AUTONOMY_DIR, AUTONOMY_HEARTBEAT_FILENAME)
|
||||
return fs.existsSync(agentsPath) || fs.existsSync(heartbeatPath)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadAutonomyAuthority(
|
||||
params: AutonomyAuthorityParams = {},
|
||||
): Promise<AutonomyAuthoritySnapshot> {
|
||||
|
||||
79
src/utils/autonomyCommandSpec.ts
Normal file
79
src/utils/autonomyCommandSpec.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
export const AUTONOMY_COMMAND_NAME = 'autonomy'
|
||||
|
||||
export const AUTONOMY_COMMAND_DESCRIPTION =
|
||||
'Inspect and manage automatic autonomy runs and flows'
|
||||
|
||||
export const AUTONOMY_ARGUMENT_HINT =
|
||||
'[status [--deep]|runs [limit]|flows [limit]|flow <id>|flow cancel <id>|flow resume <id>]'
|
||||
|
||||
export const AUTONOMY_USAGE =
|
||||
'Usage: /autonomy [status [--deep]|runs [limit]|flows [limit]|flow <id>|flow cancel <id>|flow resume <id>]'
|
||||
|
||||
export const AUTONOMY_CLI = {
|
||||
status: {
|
||||
command: 'status',
|
||||
description:
|
||||
'Print autonomy run, flow, team, pipe, and remote-control status',
|
||||
},
|
||||
runs: {
|
||||
command: 'runs [limit]',
|
||||
description: 'List recent autonomy runs',
|
||||
},
|
||||
flows: {
|
||||
command: 'flows [limit]',
|
||||
description: 'List recent autonomy flows',
|
||||
},
|
||||
flow: {
|
||||
command: 'flow',
|
||||
description: 'Inspect or manage a single autonomy flow',
|
||||
argument: '[flowId]',
|
||||
argumentDescription: 'Flow ID to inspect',
|
||||
usage: 'Usage: claude autonomy flow <flow-id>',
|
||||
cancel: {
|
||||
command: 'cancel <flowId>',
|
||||
description: 'Cancel a queued, waiting, or running autonomy flow',
|
||||
},
|
||||
resume: {
|
||||
command: 'resume <flowId>',
|
||||
description:
|
||||
'Resume a waiting autonomy flow and print the prepared prompt',
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
export type ParsedAutonomyCommand =
|
||||
| { type: 'status'; deep: boolean }
|
||||
| { type: 'runs'; limit?: string }
|
||||
| { type: 'flows'; limit?: string }
|
||||
| { type: 'flow-detail'; flowId: string }
|
||||
| { type: 'flow-cancel'; flowId: string }
|
||||
| { type: 'flow-resume'; flowId: string }
|
||||
| { type: 'usage' }
|
||||
|
||||
export function parseAutonomyArgs(args: string): ParsedAutonomyCommand {
|
||||
const [subcommand = 'status', arg1, arg2] = args.trim().split(/\s+/, 3)
|
||||
|
||||
if (subcommand === '' || subcommand === 'status') {
|
||||
return { type: 'status', deep: arg1 === '--deep' }
|
||||
}
|
||||
|
||||
if (subcommand === 'runs') {
|
||||
return { type: 'runs', limit: arg1 }
|
||||
}
|
||||
|
||||
if (subcommand === 'flows') {
|
||||
return { type: 'flows', limit: arg1 }
|
||||
}
|
||||
|
||||
if (subcommand === 'flow') {
|
||||
if (arg1 === 'cancel') {
|
||||
return arg2 ? { type: 'flow-cancel', flowId: arg2 } : { type: 'usage' }
|
||||
}
|
||||
if (arg1 === 'resume') {
|
||||
return arg2 ? { type: 'flow-resume', flowId: arg2 } : { type: 'usage' }
|
||||
}
|
||||
return arg1 ? { type: 'flow-detail', flowId: arg1 } : { type: 'usage' }
|
||||
}
|
||||
|
||||
return { type: 'usage' }
|
||||
}
|
||||
222
src/utils/autonomyStatus.ts
Normal file
222
src/utils/autonomyStatus.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
import { readdir } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { queryDaemonStatus } from '../daemon/state.js'
|
||||
import { listLiveSessions } from '../cli/bg.js'
|
||||
import {
|
||||
type AutonomyFlowRecord,
|
||||
formatAutonomyFlowsStatus,
|
||||
} from './autonomyFlows.js'
|
||||
import {
|
||||
type AutonomyRunRecord,
|
||||
formatAutonomyRunsStatus,
|
||||
} from './autonomyRuns.js'
|
||||
import { getTeamsDir } from './envUtils.js'
|
||||
import {
|
||||
isAutoModeGateEnabled,
|
||||
getAutoModeUnavailableReason,
|
||||
} from './permissions/permissionSetup.js'
|
||||
import { cronToHuman } from './cron.js'
|
||||
import { listAllCronTasks, nextCronRunMs } from './cronTasks.js'
|
||||
import { getTeammateStatuses } from './teamDiscovery.js'
|
||||
import { listTasks } from './tasks.js'
|
||||
import {
|
||||
formatRemoteTriggerAuditStatus,
|
||||
listRemoteTriggerAuditRecords,
|
||||
} from './remoteTriggerAudit.js'
|
||||
import { formatWorkflowRunsStatus, listWorkflowRuns } from './workflowRuns.js'
|
||||
import { formatPipeRegistryStatus } from './pipeStatus.js'
|
||||
import { formatRemoteControlLocalStatus } from './remoteControlStatus.js'
|
||||
|
||||
type DeepStatusParams = {
|
||||
runs: AutonomyRunRecord[]
|
||||
flows: AutonomyFlowRecord[]
|
||||
nowMs?: number
|
||||
}
|
||||
|
||||
export type AutonomyDeepStatusSectionId =
|
||||
| 'auto-mode'
|
||||
| 'runs'
|
||||
| 'flows'
|
||||
| 'cron'
|
||||
| 'workflow-runs'
|
||||
| 'teams'
|
||||
| 'pipes'
|
||||
| 'runtime'
|
||||
| 'remote-control'
|
||||
| 'remote-trigger'
|
||||
|
||||
export type AutonomyDeepStatusSection = {
|
||||
id: AutonomyDeepStatusSectionId
|
||||
title: string
|
||||
content: string
|
||||
}
|
||||
|
||||
async function listTeamNames(): Promise<string[]> {
|
||||
try {
|
||||
const entries = await readdir(getTeamsDir(), { withFileTypes: true })
|
||||
return entries
|
||||
.filter(e => e.isDirectory())
|
||||
.map(e => e.name)
|
||||
.sort()
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async function formatTeamsSection(): Promise<string> {
|
||||
const teamNames = await listTeamNames()
|
||||
if (teamNames.length === 0) {
|
||||
return ['Teams: 0', ' none'].join('\n')
|
||||
}
|
||||
|
||||
const lines = [`Teams: ${teamNames.length}`]
|
||||
for (const teamName of teamNames) {
|
||||
const teammates = getTeammateStatuses(teamName)
|
||||
const tasks = await listTasks(teamName)
|
||||
const openTasks = tasks.filter(t => t.status !== 'completed')
|
||||
const running = teammates.filter(t => t.status === 'running').length
|
||||
const idle = teammates.filter(t => t.status === 'idle').length
|
||||
lines.push(
|
||||
` ${teamName}: teammates=${teammates.length} running=${running} idle=${idle} open_tasks=${openTasks.length}`,
|
||||
)
|
||||
for (const teammate of teammates.slice(0, 5)) {
|
||||
const ownerTasks = openTasks.filter(
|
||||
t => t.owner === teammate.name || t.owner === teammate.agentId,
|
||||
)
|
||||
lines.push(
|
||||
` @${teammate.name}: ${teammate.status} backend=${teammate.backendType ?? 'unknown'} mode=${teammate.mode ?? 'default'} tasks=${ownerTasks.length}`,
|
||||
)
|
||||
}
|
||||
if (teammates.length > 5) {
|
||||
lines.push(` ... ${teammates.length - 5} more teammate(s)`)
|
||||
}
|
||||
}
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
async function formatCronSection(nowMs: number): Promise<string> {
|
||||
const jobs = await listAllCronTasks()
|
||||
if (jobs.length === 0) {
|
||||
return ['Cron jobs: 0', ' none'].join('\n')
|
||||
}
|
||||
const lines = [`Cron jobs: ${jobs.length}`]
|
||||
for (const job of jobs.slice(0, 10)) {
|
||||
const next = nextCronRunMs(job.cron, nowMs)
|
||||
lines.push(
|
||||
` ${job.id}: ${cronToHuman(job.cron)} ${job.recurring ? 'recurring' : 'one-shot'} ${job.durable === false ? 'session-only' : 'durable'} next=${next ? new Date(next).toLocaleString() : 'none'}`,
|
||||
)
|
||||
}
|
||||
if (jobs.length > 10) {
|
||||
lines.push(` ... ${jobs.length - 10} more job(s)`)
|
||||
}
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
async function formatRuntimeSection(): Promise<string> {
|
||||
const daemon = queryDaemonStatus()
|
||||
const sessions = await listLiveSessions()
|
||||
const lines = [
|
||||
`Daemon: ${daemon.status}${daemon.state ? ` pid=${daemon.state.pid} workers=${daemon.state.workerKinds.join(',')}` : ''}`,
|
||||
`Background sessions: ${sessions.length}`,
|
||||
]
|
||||
for (const session of sessions.slice(0, 8)) {
|
||||
lines.push(
|
||||
` pid=${session.pid} kind=${session.kind} status=${session.status ?? 'unknown'} cwd=${session.cwd}`,
|
||||
)
|
||||
}
|
||||
if (sessions.length > 8) {
|
||||
lines.push(` ... ${sessions.length - 8} more session(s)`)
|
||||
}
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
function formatAutoModeSection(): string {
|
||||
let available = false
|
||||
let reason: string | null = null
|
||||
try {
|
||||
available = isAutoModeGateEnabled()
|
||||
reason = getAutoModeUnavailableReason()
|
||||
} catch (error) {
|
||||
return [
|
||||
'Auto mode: unknown',
|
||||
` reason=${error instanceof Error ? error.message : String(error)}`,
|
||||
].join('\n')
|
||||
}
|
||||
return [
|
||||
`Auto mode: ${available ? 'available' : 'unavailable'}`,
|
||||
` reason=${reason ?? 'none'}`,
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export async function formatAutonomyDeepStatusSections({
|
||||
runs,
|
||||
flows,
|
||||
nowMs = Date.now(),
|
||||
}: DeepStatusParams): Promise<AutonomyDeepStatusSection[]> {
|
||||
return Promise.all([
|
||||
Promise.resolve({
|
||||
id: 'auto-mode' as const,
|
||||
title: 'Auto Mode',
|
||||
content: formatAutoModeSection(),
|
||||
}),
|
||||
Promise.resolve({
|
||||
id: 'runs' as const,
|
||||
title: 'Runs',
|
||||
content: formatAutonomyRunsStatus(runs),
|
||||
}),
|
||||
Promise.resolve({
|
||||
id: 'flows' as const,
|
||||
title: 'Flows',
|
||||
content: formatAutonomyFlowsStatus(flows),
|
||||
}),
|
||||
formatCronSection(nowMs).then(content => ({
|
||||
id: 'cron' as const,
|
||||
title: 'Cron',
|
||||
content,
|
||||
})),
|
||||
listWorkflowRuns().then(runs => ({
|
||||
id: 'workflow-runs' as const,
|
||||
title: 'Workflow Runs',
|
||||
content: formatWorkflowRunsStatus(runs),
|
||||
})),
|
||||
formatTeamsSection().then(content => ({
|
||||
id: 'teams' as const,
|
||||
title: 'Teams',
|
||||
content,
|
||||
})),
|
||||
formatPipeRegistryStatus().then(content => ({
|
||||
id: 'pipes' as const,
|
||||
title: 'Pipes',
|
||||
content,
|
||||
})),
|
||||
formatRuntimeSection().then(content => ({
|
||||
id: 'runtime' as const,
|
||||
title: 'Runtime',
|
||||
content,
|
||||
})),
|
||||
Promise.resolve({
|
||||
id: 'remote-control' as const,
|
||||
title: 'Remote Control',
|
||||
content: formatRemoteControlLocalStatus(),
|
||||
}),
|
||||
listRemoteTriggerAuditRecords().then(records => ({
|
||||
id: 'remote-trigger' as const,
|
||||
title: 'RemoteTrigger',
|
||||
content: formatRemoteTriggerAuditStatus(records),
|
||||
})),
|
||||
])
|
||||
}
|
||||
|
||||
export async function formatAutonomyDeepStatus(
|
||||
params: DeepStatusParams,
|
||||
): Promise<string> {
|
||||
const sections = await formatAutonomyDeepStatusSections(params)
|
||||
return sections
|
||||
.map((section, index) =>
|
||||
[
|
||||
index === 0 ? '# Autonomy Deep Status' : `## ${section.title}`,
|
||||
section.content,
|
||||
].join('\n'),
|
||||
)
|
||||
.join('\n\n')
|
||||
}
|
||||
Reference in New Issue
Block a user