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:
unraid
2026-04-22 22:38:09 +08:00
parent 31b2fdd97a
commit c4775fff58
10 changed files with 1152 additions and 163 deletions

View File

@@ -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:')
})
})

View File

@@ -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

View 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} />;
}