Skip to content

Commit c4775ff

Browse files
unraidclaude
andcommitted
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>
1 parent 31b2fdd commit c4775ff

10 files changed

Lines changed: 1152 additions & 163 deletions

File tree

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2+
import { mkdir, rm, writeFile } from 'fs/promises'
3+
import { tmpdir } from 'os'
4+
import { join } from 'path'
5+
import {
6+
resetStateForTests,
7+
setOriginalCwd,
8+
setProjectRoot,
9+
} from '../../../bootstrap/state'
10+
import { createAutonomyQueuedPrompt } from '../../../utils/autonomyRuns'
11+
import {
12+
cancelAutonomyFlowText,
13+
getAutonomyDeepSectionText,
14+
getAutonomyFlowText,
15+
getAutonomyFlowsText,
16+
getAutonomyStatusText,
17+
resumeAutonomyFlowText,
18+
} from '../autonomy'
19+
import {
20+
listAutonomyFlows,
21+
startManagedAutonomyFlow,
22+
} from '../../../utils/autonomyFlows'
23+
24+
let tempDir: string
25+
let previousConfigDir: string | undefined
26+
27+
beforeEach(async () => {
28+
previousConfigDir = process.env.CLAUDE_CONFIG_DIR
29+
tempDir = join(
30+
tmpdir(),
31+
`autonomy-cli-${Date.now()}-${Math.random().toString(16).slice(2)}`,
32+
)
33+
await mkdir(tempDir, { recursive: true })
34+
process.env.CLAUDE_CONFIG_DIR = join(tempDir, 'config')
35+
resetStateForTests()
36+
setOriginalCwd(tempDir)
37+
setProjectRoot(tempDir)
38+
})
39+
40+
afterEach(async () => {
41+
resetStateForTests()
42+
if (previousConfigDir === undefined) {
43+
delete process.env.CLAUDE_CONFIG_DIR
44+
} else {
45+
process.env.CLAUDE_CONFIG_DIR = previousConfigDir
46+
}
47+
await rm(tempDir, { recursive: true, force: true })
48+
})
49+
50+
describe('autonomy CLI handler', () => {
51+
test('prints the same basic status surfaces as the slash command', async () => {
52+
await createAutonomyQueuedPrompt({
53+
basePrompt: 'scheduled prompt',
54+
trigger: 'scheduled-task',
55+
rootDir: tempDir,
56+
currentDir: tempDir,
57+
sourceLabel: 'nightly',
58+
})
59+
60+
const output = await getAutonomyStatusText()
61+
62+
expect(output).toContain('Autonomy runs: 1')
63+
expect(output).toContain('Queued: 1')
64+
expect(output).toContain('Autonomy flows: 0')
65+
})
66+
67+
test('prints deep status for CLI status --deep', async () => {
68+
await mkdir(join(tempDir, '.claude'), { recursive: true })
69+
await writeFile(
70+
join(tempDir, '.claude', 'remote-trigger-audit.jsonl'),
71+
`${JSON.stringify({
72+
auditId: 'audit-1',
73+
createdAt: 1,
74+
action: 'list',
75+
ok: true,
76+
status: 200,
77+
})}\n`,
78+
)
79+
80+
const output = await getAutonomyStatusText({ deep: true })
81+
82+
expect(output).toContain('# Autonomy Deep Status')
83+
expect(output).toContain('## Workflow Runs')
84+
expect(output).toContain('## Pipes')
85+
expect(output).toContain('## Remote Control')
86+
expect(output).toContain('## RemoteTrigger')
87+
})
88+
89+
test('prints individual deep status sections for panel actions', async () => {
90+
const pipes = await getAutonomyDeepSectionText('pipes')
91+
const remoteControl = await getAutonomyDeepSectionText('remote-control')
92+
93+
expect(pipes).toContain('# Pipes')
94+
expect(pipes).toContain('Pipe registry:')
95+
expect(remoteControl).toContain('# Remote Control')
96+
expect(remoteControl).toContain('Remote Control:')
97+
})
98+
99+
test('lists, inspects, cancels, and resumes flows from CLI handlers', async () => {
100+
await startManagedAutonomyFlow({
101+
trigger: 'proactive-tick',
102+
goal: 'ship managed flow',
103+
rootDir: tempDir,
104+
currentDir: tempDir,
105+
steps: [
106+
{
107+
name: 'wait',
108+
prompt: 'Wait for manual signal',
109+
waitFor: 'manual',
110+
},
111+
{
112+
name: 'run',
113+
prompt: 'Run the next step',
114+
},
115+
],
116+
})
117+
const [waitingFlow] = await listAutonomyFlows(tempDir)
118+
119+
expect(await getAutonomyFlowsText()).toContain(waitingFlow!.flowId)
120+
expect(await getAutonomyFlowText(waitingFlow!.flowId)).toContain(
121+
'Current step: wait',
122+
)
123+
124+
const resumed = await resumeAutonomyFlowText(waitingFlow!.flowId)
125+
expect(resumed).toContain('Prepared the next managed step')
126+
expect(resumed).toContain('Prompt:')
127+
expect(resumed).toContain('Wait for manual signal')
128+
129+
const cancelled = await cancelAutonomyFlowText(waitingFlow!.flowId)
130+
expect(cancelled).toContain('Cancelled flow')
131+
})
132+
})

src/cli/handlers/autonomy.ts

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
import {
2+
formatAutonomyFlowDetail,
3+
formatAutonomyFlowsList,
4+
formatAutonomyFlowsStatus,
5+
getAutonomyFlowById,
6+
listAutonomyFlows,
7+
requestManagedAutonomyFlowCancel,
8+
} from '../../utils/autonomyFlows.js'
9+
import {
10+
formatAutonomyRunsList,
11+
formatAutonomyRunsStatus,
12+
listAutonomyRuns,
13+
markAutonomyRunCancelled,
14+
resumeManagedAutonomyFlowPrompt,
15+
} from '../../utils/autonomyRuns.js'
16+
import {
17+
formatAutonomyDeepStatus,
18+
formatAutonomyDeepStatusSections,
19+
type AutonomyDeepStatusSectionId,
20+
} from '../../utils/autonomyStatus.js'
21+
import {
22+
AUTONOMY_USAGE,
23+
parseAutonomyArgs,
24+
} from '../../utils/autonomyCommandSpec.js'
25+
import {
26+
enqueuePendingNotification,
27+
removeByFilter,
28+
} from '../../utils/messageQueueManager.js'
29+
30+
export function parseAutonomyLimit(raw?: string | number): number {
31+
const parsed = typeof raw === 'number' ? raw : Number.parseInt(raw ?? '', 10)
32+
if (!Number.isFinite(parsed) || parsed <= 0) {
33+
return 10
34+
}
35+
return Math.min(parsed, 50)
36+
}
37+
38+
export async function getAutonomyStatusText(options?: {
39+
deep?: boolean
40+
}): Promise<string> {
41+
const [runs, flows] = await Promise.all([
42+
listAutonomyRuns(),
43+
listAutonomyFlows(),
44+
])
45+
46+
if (options?.deep) {
47+
return formatAutonomyDeepStatus({ runs, flows })
48+
}
49+
50+
return [
51+
formatAutonomyRunsStatus(runs),
52+
formatAutonomyFlowsStatus(flows),
53+
].join('\n')
54+
}
55+
56+
export async function getAutonomyDeepSectionText(
57+
sectionId: AutonomyDeepStatusSectionId,
58+
): Promise<string> {
59+
const [runs, flows] = await Promise.all([
60+
listAutonomyRuns(),
61+
listAutonomyFlows(),
62+
])
63+
const sections = await formatAutonomyDeepStatusSections({ runs, flows })
64+
const section = sections.find(item => item.id === sectionId)
65+
if (!section) {
66+
return `Autonomy deep status section not found: ${sectionId}`
67+
}
68+
return [`# ${section.title}`, section.content].join('\n')
69+
}
70+
71+
export async function autonomyStatusHandler(options?: {
72+
deep?: boolean
73+
}): Promise<void> {
74+
process.stdout.write(`${await getAutonomyStatusText(options)}\n`)
75+
}
76+
77+
export async function getAutonomyRunsText(
78+
limit?: string | number,
79+
): Promise<string> {
80+
return formatAutonomyRunsList(
81+
await listAutonomyRuns(),
82+
parseAutonomyLimit(limit),
83+
)
84+
}
85+
86+
export async function autonomyRunsHandler(
87+
limit?: string | number,
88+
): Promise<void> {
89+
process.stdout.write(`${await getAutonomyRunsText(limit)}\n`)
90+
}
91+
92+
export async function getAutonomyFlowsText(
93+
limit?: string | number,
94+
): Promise<string> {
95+
return formatAutonomyFlowsList(
96+
await listAutonomyFlows(),
97+
parseAutonomyLimit(limit),
98+
)
99+
}
100+
101+
export async function autonomyFlowsHandler(
102+
limit?: string | number,
103+
): Promise<void> {
104+
process.stdout.write(`${await getAutonomyFlowsText(limit)}\n`)
105+
}
106+
107+
export async function getAutonomyFlowText(flowId: string): Promise<string> {
108+
return formatAutonomyFlowDetail(await getAutonomyFlowById(flowId))
109+
}
110+
111+
export async function autonomyFlowHandler(flowId: string): Promise<void> {
112+
process.stdout.write(`${await getAutonomyFlowText(flowId)}\n`)
113+
}
114+
115+
export async function cancelAutonomyFlowText(
116+
flowId: string,
117+
options?: {
118+
removeQueuedInMemory?: boolean
119+
},
120+
): Promise<string> {
121+
const cancelled = await requestManagedAutonomyFlowCancel({ flowId })
122+
if (!cancelled) {
123+
return 'Autonomy flow not found.'
124+
}
125+
if (!cancelled.accepted) {
126+
return `Autonomy flow ${flowId} is already terminal (${cancelled.flow.status}).`
127+
}
128+
129+
let removedCount = 0
130+
if (options?.removeQueuedInMemory) {
131+
const removed = removeByFilter(cmd => cmd.autonomy?.flowId === flowId)
132+
removedCount = removed.length
133+
for (const command of removed) {
134+
if (command.autonomy?.runId) {
135+
await markAutonomyRunCancelled(command.autonomy.runId)
136+
}
137+
}
138+
} else {
139+
for (const runId of cancelled.queuedRunIds) {
140+
await markAutonomyRunCancelled(runId)
141+
}
142+
removedCount = cancelled.queuedRunIds.length
143+
}
144+
145+
return cancelled.flow.status === 'running'
146+
? `Cancellation requested for flow ${flowId}. The current step is still running, and no new steps will be started.`
147+
: `Cancelled flow ${flowId}. Removed ${removedCount} queued step(s).`
148+
}
149+
150+
export async function autonomyFlowCancelHandler(flowId: string): Promise<void> {
151+
process.stdout.write(`${await cancelAutonomyFlowText(flowId)}\n`)
152+
}
153+
154+
export async function resumeAutonomyFlowText(
155+
flowId: string,
156+
options?: {
157+
enqueueInMemory?: boolean
158+
},
159+
): Promise<string> {
160+
const command = await resumeManagedAutonomyFlowPrompt({ flowId })
161+
if (!command) {
162+
return 'Autonomy flow is not waiting or was not found.'
163+
}
164+
165+
if (options?.enqueueInMemory) {
166+
enqueuePendingNotification(command)
167+
return `Queued the next managed step for flow ${flowId}.`
168+
}
169+
170+
const runId = command.autonomy?.runId ?? 'unknown'
171+
return [
172+
`Prepared the next managed step for flow ${flowId}.`,
173+
`Run ID: ${runId}`,
174+
'',
175+
'Prompt:',
176+
typeof command.value === 'string' ? command.value : String(command.value),
177+
].join('\n')
178+
}
179+
180+
export async function autonomyFlowResumeHandler(flowId: string): Promise<void> {
181+
process.stdout.write(`${await resumeAutonomyFlowText(flowId)}\n`)
182+
}
183+
184+
export async function getAutonomyCommandText(
185+
args: string,
186+
options?: {
187+
enqueueInMemory?: boolean
188+
removeQueuedInMemory?: boolean
189+
},
190+
): Promise<string> {
191+
const parsed = parseAutonomyArgs(args)
192+
193+
switch (parsed.type) {
194+
case 'status':
195+
return getAutonomyStatusText({ deep: parsed.deep })
196+
case 'runs':
197+
return getAutonomyRunsText(parsed.limit)
198+
case 'flows':
199+
return getAutonomyFlowsText(parsed.limit)
200+
case 'flow-detail':
201+
return getAutonomyFlowText(parsed.flowId)
202+
case 'flow-cancel':
203+
return cancelAutonomyFlowText(parsed.flowId, {
204+
removeQueuedInMemory: options?.removeQueuedInMemory,
205+
})
206+
case 'flow-resume':
207+
return resumeAutonomyFlowText(parsed.flowId, {
208+
enqueueInMemory: options?.enqueueInMemory,
209+
})
210+
case 'usage':
211+
return AUTONOMY_USAGE
212+
}
213+
}

0 commit comments

Comments
 (0)