mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-17 05:45: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:
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