From c4775fff58f2e921e2158ab4cbfbd2fe70069462 Mon Sep 17 00:00:00 2001 From: unraid Date: Wed, 22 Apr 2026 22:38:09 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20autonomy=20?= =?UTF-8?q?=E8=87=AA=E4=B8=BB=E6=A8=A1=E5=BC=8F=E5=91=BD=E4=BB=A4=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 autonomy CLI handler 和交互式面板 - 新增 autonomyCommandSpec 命令规范定义 - 新增 autonomyAuthority 权限控制 - 新增 autonomyStatus 状态管理 - 注册 CLI 子命令 (claude autonomy status/runs/flows/flow) Co-Authored-By: Claude Opus 4.6 --- src/cli/handlers/__tests__/autonomy.test.ts | 132 +++++++++++ src/cli/handlers/autonomy.ts | 213 +++++++++++++++++ src/commands/__tests__/autonomy.test.ts | 223 ++++++++++++++---- src/commands/autonomy.ts | 122 +--------- src/commands/autonomyPanel.tsx | 208 ++++++++++++++++ src/main.tsx | 62 +++++ .../__tests__/autonomyCommandSpec.test.ts | 42 ++++ src/utils/autonomyAuthority.ts | 12 + src/utils/autonomyCommandSpec.ts | 79 +++++++ src/utils/autonomyStatus.ts | 222 +++++++++++++++++ 10 files changed, 1152 insertions(+), 163 deletions(-) create mode 100644 src/cli/handlers/__tests__/autonomy.test.ts create mode 100644 src/cli/handlers/autonomy.ts create mode 100644 src/commands/autonomyPanel.tsx create mode 100644 src/utils/__tests__/autonomyCommandSpec.test.ts create mode 100644 src/utils/autonomyCommandSpec.ts create mode 100644 src/utils/autonomyStatus.ts diff --git a/src/cli/handlers/__tests__/autonomy.test.ts b/src/cli/handlers/__tests__/autonomy.test.ts new file mode 100644 index 000000000..25e751bfd --- /dev/null +++ b/src/cli/handlers/__tests__/autonomy.test.ts @@ -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') + }) +}) diff --git a/src/cli/handlers/autonomy.ts b/src/cli/handlers/autonomy.ts new file mode 100644 index 000000000..c63865408 --- /dev/null +++ b/src/cli/handlers/autonomy.ts @@ -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 { + 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 { + 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 { + process.stdout.write(`${await getAutonomyStatusText(options)}\n`) +} + +export async function getAutonomyRunsText( + limit?: string | number, +): Promise { + return formatAutonomyRunsList( + await listAutonomyRuns(), + parseAutonomyLimit(limit), + ) +} + +export async function autonomyRunsHandler( + limit?: string | number, +): Promise { + process.stdout.write(`${await getAutonomyRunsText(limit)}\n`) +} + +export async function getAutonomyFlowsText( + limit?: string | number, +): Promise { + return formatAutonomyFlowsList( + await listAutonomyFlows(), + parseAutonomyLimit(limit), + ) +} + +export async function autonomyFlowsHandler( + limit?: string | number, +): Promise { + process.stdout.write(`${await getAutonomyFlowsText(limit)}\n`) +} + +export async function getAutonomyFlowText(flowId: string): Promise { + return formatAutonomyFlowDetail(await getAutonomyFlowById(flowId)) +} + +export async function autonomyFlowHandler(flowId: string): Promise { + process.stdout.write(`${await getAutonomyFlowText(flowId)}\n`) +} + +export async function cancelAutonomyFlowText( + flowId: string, + options?: { + removeQueuedInMemory?: boolean + }, +): Promise { + 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 { + process.stdout.write(`${await cancelAutonomyFlowText(flowId)}\n`) +} + +export async function resumeAutonomyFlowText( + flowId: string, + options?: { + enqueueInMemory?: boolean + }, +): Promise { + 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 { + process.stdout.write(`${await resumeAutonomyFlowText(flowId)}\n`) +} + +export async function getAutonomyCommandText( + args: string, + options?: { + enqueueInMemory?: boolean + removeQueuedInMemory?: boolean + }, +): Promise { + 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 + } +} diff --git a/src/commands/__tests__/autonomy.test.ts b/src/commands/__tests__/autonomy.test.ts index 8b36670ce..dd88a3db5 100644 --- a/src/commands/__tests__/autonomy.test.ts +++ b/src/commands/__tests__/autonomy.test.ts @@ -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 { - 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:') }) }) diff --git a/src/commands/autonomy.ts b/src/commands/autonomy.ts index c387fb850..56925cf82 100644 --- a/src/commands/autonomy.ts +++ b/src/commands/autonomy.ts @@ -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 |flow cancel |flow resume ]', - } - } - - 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 |flow cancel |flow resume ]', + load: () => import('./autonomyPanel.js'), } satisfies Command export default autonomy diff --git a/src/commands/autonomyPanel.tsx b/src/commands/autonomyPanel.tsx new file mode 100644 index 000000000..481c4f66d --- /dev/null +++ b/src/commands/autonomyPanel.tsx @@ -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; +}; + +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([]); + + useEffect(() => { + let cancelled = false; + void listAutonomyFlows().then(items => { + if (!cancelled) setFlows(items.slice(0, 5)); + }); + return () => { + cancelled = true; + }; + }, []); + + const actions = useMemo(() => { + 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(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 ( + onDone('Autonomy panel dismissed', { display: 'system' })} + color="background" + hideInputGuide + > + + {actions.map((action, index) => ( + + {`${index === selectedIndex ? '›' : ' '} ${action.label}`.padEnd(ACTION_LABEL_COLUMN_WIDTH)} + {action.description} + + ))} + + ↑/↓ select · Enter run · Esc close + + + + ); +} + +export async function call(onDone: LocalJSXCommandOnDone, _context: unknown, args?: string): Promise { + const trimmed = args?.trim() ?? ''; + if (trimmed) { + const result = await getAutonomyCommandText(trimmed, { + enqueueInMemory: true, + removeQueuedInMemory: true, + }); + onDone(result, { display: 'system' }); + return null; + } + + return ; +} diff --git a/src/main.tsx b/src/main.tsx index 06a05cabf..c1d6e9144 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -6429,6 +6429,68 @@ async function run(): Promise { } } + // 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 ") + .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 ") + .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 ") + .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. diff --git a/src/utils/__tests__/autonomyCommandSpec.test.ts b/src/utils/__tests__/autonomyCommandSpec.test.ts new file mode 100644 index 000000000..eb1b62411 --- /dev/null +++ b/src/utils/__tests__/autonomyCommandSpec.test.ts @@ -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 ') + }) + + 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' }) + }) +}) diff --git a/src/utils/autonomyAuthority.ts b/src/utils/autonomyAuthority.ts index aa790fe35..c604d3049 100644 --- a/src/utils/autonomyAuthority.ts +++ b/src/utils/autonomyAuthority.ts @@ -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 { diff --git a/src/utils/autonomyCommandSpec.ts b/src/utils/autonomyCommandSpec.ts new file mode 100644 index 000000000..dd8a9e209 --- /dev/null +++ b/src/utils/autonomyCommandSpec.ts @@ -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 |flow cancel |flow resume ]' + +export const AUTONOMY_USAGE = + 'Usage: /autonomy [status [--deep]|runs [limit]|flows [limit]|flow |flow cancel |flow resume ]' + +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 ', + cancel: { + command: 'cancel ', + description: 'Cancel a queued, waiting, or running autonomy flow', + }, + resume: { + command: 'resume ', + 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' } +} diff --git a/src/utils/autonomyStatus.ts b/src/utils/autonomyStatus.ts new file mode 100644 index 000000000..950aabbf7 --- /dev/null +++ b/src/utils/autonomyStatus.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const sections = await formatAutonomyDeepStatusSections(params) + return sections + .map((section, index) => + [ + index === 0 ? '# Autonomy Deep Status' : `## ${section.title}`, + section.content, + ].join('\n'), + ) + .join('\n\n') +}