mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-17 22:05:50 +00:00
Feat/integrate lint preview (#285)
* feat: 适配 zed acp 协议 * docs: 完善 acp 文档 * feat: integrate feature branches + daemon/job 命令层级化 + 跨平台后台引擎 Cherry-picked from origin/lint/preview (637c908), excluding lint-only changes. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: correct detectMimeFromBase64 to decode raw bytes from base64 Cherry-picked from origin/lint/preview (ee36954). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: daemon 子进程 spawn 跨平台修复 + CliLaunchSpec 集中化重构 Cherry-picked from origin/lint/preview (c5f52cd), excluding lint-only formatting changes. - 新建 src/utils/cliLaunch.ts: 集中化 CLI 子进程启动层 - 修复 --daemon-worker=kind 等号格式解析 - 修复 daemon/bg fast path 缺少 setShellIfWindows() - 修复 checkPathExists 用 existsSync 替代 execSync('dir') - 7 个 spawn 站点迁移到 CliLaunchSpec Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: merge tsconfig.base.json into tsconfig.json with full compiler options The cherry-pick from637c908dropped jsx/strict/etc settings when removing tsconfig.base.json. This commit restores them in a single tsconfig.json. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: merge tsconfig.base.json into tsconfig.json with full compiler options The cherry-pick from637c908dropped jsx/strict/etc settings when removing tsconfig.base.json. This commit restores them in a single tsconfig.json. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
246
src/commands/__tests__/autonomy.test.ts
Normal file
246
src/commands/__tests__/autonomy.test.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
||||
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,
|
||||
markAutonomyRunCompleted,
|
||||
startManagedAutonomyFlowFromHeartbeatTask,
|
||||
} from '../../utils/autonomyRuns'
|
||||
import {
|
||||
enqueuePendingNotification,
|
||||
getCommandQueueSnapshot,
|
||||
resetCommandQueue,
|
||||
} from '../../utils/messageQueueManager'
|
||||
import { cleanupTempDir, createTempDir } from '../../../tests/mocks/file-system'
|
||||
|
||||
let tempDir = ''
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await createTempDir('autonomy-command-')
|
||||
resetStateForTests()
|
||||
resetCommandQueue()
|
||||
setOriginalCwd(tempDir)
|
||||
setProjectRoot(tempDir)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
resetStateForTests()
|
||||
resetCommandQueue()
|
||||
if (tempDir) {
|
||||
await cleanupTempDir(tempDir)
|
||||
}
|
||||
})
|
||||
|
||||
describe('/autonomy', () => {
|
||||
test('status reports autonomy runs and managed flows separately', async () => {
|
||||
const plainRun = await createAutonomyQueuedPrompt({
|
||||
basePrompt: 'scheduled prompt',
|
||||
trigger: 'scheduled-task',
|
||||
rootDir: tempDir,
|
||||
currentDir: tempDir,
|
||||
sourceLabel: 'nightly',
|
||||
})
|
||||
expect(plainRun).not.toBeNull()
|
||||
await markAutonomyRunCompleted(plainRun!.autonomy!.runId, tempDir)
|
||||
|
||||
await startManagedAutonomyFlowFromHeartbeatTask({
|
||||
task: {
|
||||
name: 'weekly-report',
|
||||
interval: '7d',
|
||||
prompt: 'Ship the weekly report',
|
||||
steps: [
|
||||
{
|
||||
name: 'gather',
|
||||
prompt: 'Gather weekly inputs',
|
||||
},
|
||||
{
|
||||
name: 'draft',
|
||||
prompt: 'Draft the weekly report',
|
||||
},
|
||||
],
|
||||
},
|
||||
rootDir: tempDir,
|
||||
currentDir: tempDir,
|
||||
})
|
||||
|
||||
const mod = await autonomyCommand.load()
|
||||
const result = await mod.call('', {} as any)
|
||||
|
||||
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')
|
||||
})
|
||||
|
||||
test('runs subcommand lists recent autonomy runs', async () => {
|
||||
const queued = await createAutonomyQueuedPrompt({
|
||||
basePrompt: '<tick>12:00:00</tick>',
|
||||
trigger: 'proactive-tick',
|
||||
rootDir: tempDir,
|
||||
currentDir: tempDir,
|
||||
})
|
||||
|
||||
const mod = await autonomyCommand.load()
|
||||
const result = await mod.call('runs 5', {} as any)
|
||||
|
||||
expectTextResult(result)
|
||||
expect(result.value).toContain(queued!.autonomy!.runId)
|
||||
expect(result.value).toContain('proactive-tick')
|
||||
})
|
||||
|
||||
test('flows subcommand lists managed flows and flow subcommand shows detail', async () => {
|
||||
await startManagedAutonomyFlowFromHeartbeatTask({
|
||||
task: {
|
||||
name: 'weekly-report',
|
||||
interval: '7d',
|
||||
prompt: 'Ship the weekly report',
|
||||
steps: [
|
||||
{
|
||||
name: 'gather',
|
||||
prompt: 'Gather weekly inputs',
|
||||
},
|
||||
{
|
||||
name: 'draft',
|
||||
prompt: 'Draft the weekly report',
|
||||
},
|
||||
],
|
||||
},
|
||||
rootDir: tempDir,
|
||||
currentDir: tempDir,
|
||||
})
|
||||
|
||||
const [flow] = await listAutonomyFlows(tempDir)
|
||||
const mod = await autonomyCommand.load()
|
||||
|
||||
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')
|
||||
})
|
||||
|
||||
test('flow resume queues the next waiting step', async () => {
|
||||
const waitingStart = await startManagedAutonomyFlowFromHeartbeatTask({
|
||||
task: {
|
||||
name: 'weekly-report',
|
||||
interval: '7d',
|
||||
prompt: 'Ship the weekly report',
|
||||
steps: [
|
||||
{
|
||||
name: 'gather',
|
||||
prompt: 'Gather weekly inputs',
|
||||
waitFor: 'manual',
|
||||
},
|
||||
{
|
||||
name: 'draft',
|
||||
prompt: 'Draft the weekly report',
|
||||
},
|
||||
],
|
||||
},
|
||||
rootDir: tempDir,
|
||||
currentDir: tempDir,
|
||||
})
|
||||
|
||||
expect(waitingStart).toBeNull()
|
||||
const [flow] = await listAutonomyFlows(tempDir)
|
||||
|
||||
const mod = await autonomyCommand.load()
|
||||
const result = await mod.call(`flow resume ${flow!.flowId}`, {} as any)
|
||||
|
||||
expectTextResult(result)
|
||||
expect(result.value).toContain('Queued the next managed step')
|
||||
expect(getCommandQueueSnapshot()).toHaveLength(1)
|
||||
expect(getCommandQueueSnapshot()[0]!.autonomy?.flowId).toBe(flow!.flowId)
|
||||
})
|
||||
|
||||
test('flow cancel removes queued managed steps and marks the flow cancelled', async () => {
|
||||
const queued = await startManagedAutonomyFlowFromHeartbeatTask({
|
||||
task: {
|
||||
name: 'weekly-report',
|
||||
interval: '7d',
|
||||
prompt: 'Ship the weekly report',
|
||||
steps: [
|
||||
{
|
||||
name: 'gather',
|
||||
prompt: 'Gather weekly inputs',
|
||||
},
|
||||
{
|
||||
name: 'draft',
|
||||
prompt: 'Draft the weekly report',
|
||||
},
|
||||
],
|
||||
},
|
||||
rootDir: tempDir,
|
||||
currentDir: tempDir,
|
||||
})
|
||||
|
||||
expect(queued).not.toBeNull()
|
||||
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 [cancelledFlow] = await listAutonomyFlows(tempDir)
|
||||
|
||||
expectTextResult(result)
|
||||
expect(result.value).toContain('Cancelled flow')
|
||||
expect(cancelledFlow!.status).toBe('cancelled')
|
||||
expect(getCommandQueueSnapshot()).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('flow cancel refuses to rewrite a terminal managed flow', async () => {
|
||||
const queued = await startManagedAutonomyFlowFromHeartbeatTask({
|
||||
task: {
|
||||
name: 'weekly-report',
|
||||
interval: '7d',
|
||||
prompt: 'Ship the weekly report',
|
||||
steps: [
|
||||
{
|
||||
name: 'gather',
|
||||
prompt: 'Gather weekly inputs',
|
||||
},
|
||||
],
|
||||
},
|
||||
rootDir: tempDir,
|
||||
currentDir: tempDir,
|
||||
})
|
||||
|
||||
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 [terminalFlow] = await listAutonomyFlows(tempDir)
|
||||
|
||||
expectTextResult(result)
|
||||
expect(result.value).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)
|
||||
|
||||
expectTextResult(result)
|
||||
expect(result.value).toContain('Usage: /autonomy')
|
||||
})
|
||||
})
|
||||
48
src/commands/__tests__/proactive.baseline.test.ts
Normal file
48
src/commands/__tests__/proactive.baseline.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { beforeEach, describe, expect, test } from 'bun:test'
|
||||
import proactiveCommand from '../proactive'
|
||||
import {
|
||||
activateProactive,
|
||||
deactivateProactive,
|
||||
isProactiveActive,
|
||||
} from '../../proactive/index'
|
||||
|
||||
beforeEach(() => {
|
||||
deactivateProactive()
|
||||
})
|
||||
|
||||
describe('/proactive baseline', () => {
|
||||
test('invoking the command enables proactive mode and emits a system reminder', async () => {
|
||||
const mod = await proactiveCommand.load()
|
||||
let resultText: string | undefined
|
||||
let options: Parameters<Parameters<typeof mod.call>[0]>[1] | undefined
|
||||
|
||||
await mod.call((result, opts) => {
|
||||
resultText = result
|
||||
options = opts
|
||||
}, {} as any)
|
||||
|
||||
expect(isProactiveActive()).toBe(true)
|
||||
expect(resultText).toContain('Proactive mode enabled')
|
||||
expect(options?.display).toBe('system')
|
||||
expect(options?.metaMessages?.[0]).toContain(
|
||||
'Proactive mode is now enabled',
|
||||
)
|
||||
})
|
||||
|
||||
test('invoking the command again disables proactive mode', async () => {
|
||||
const mod = await proactiveCommand.load()
|
||||
activateProactive('test')
|
||||
|
||||
let resultText: string | undefined
|
||||
let options: Parameters<Parameters<typeof mod.call>[0]>[1] | undefined
|
||||
|
||||
await mod.call((result, opts) => {
|
||||
resultText = result
|
||||
options = opts
|
||||
}, {} as any)
|
||||
|
||||
expect(isProactiveActive()).toBe(false)
|
||||
expect(resultText).toBe('Proactive mode disabled')
|
||||
expect(options?.display).toBe('system')
|
||||
})
|
||||
})
|
||||
@@ -1,53 +0,0 @@
|
||||
import * as React from 'react'
|
||||
import type { LocalJSXCommandContext } from '../../commands.js'
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js'
|
||||
import type { AppState } from '../../state/AppState.js'
|
||||
|
||||
/** Stub — install wizard is not yet restored. */
|
||||
export async function computeDefaultInstallDir(): Promise<string> {
|
||||
return ''
|
||||
}
|
||||
|
||||
/** Stub — install wizard is not yet restored. */
|
||||
export function NewInstallWizard(_props: {
|
||||
defaultDir: string
|
||||
onInstalled: (dir: string) => void
|
||||
onCancel: () => void
|
||||
onError: (message: string) => void
|
||||
}): React.ReactNode {
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* /assistant command implementation.
|
||||
*
|
||||
* Opens the Kairos assistant panel. In the current build the panel is
|
||||
* rendered by the REPL layer when kairosActive is true; the slash command
|
||||
* simply toggles visibility and prints a confirmation line.
|
||||
*/
|
||||
export async function call(
|
||||
onDone: LocalJSXCommandOnDone,
|
||||
context: LocalJSXCommandContext,
|
||||
_args: string,
|
||||
): Promise<React.ReactNode> {
|
||||
const { setAppState, getAppState } = context
|
||||
|
||||
const current = getAppState()
|
||||
const isVisible = (current as Record<string, unknown>).assistantPanelVisible
|
||||
|
||||
if (isVisible) {
|
||||
setAppState((prev: AppState) => ({
|
||||
...prev,
|
||||
assistantPanelVisible: false,
|
||||
} as AppState))
|
||||
onDone('Assistant panel hidden.', { display: 'system' })
|
||||
} else {
|
||||
setAppState((prev: AppState) => ({
|
||||
...prev,
|
||||
assistantPanelVisible: true,
|
||||
} as AppState))
|
||||
onDone('Assistant panel opened.', { display: 'system' })
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
175
src/commands/assistant/assistant.tsx
Normal file
175
src/commands/assistant/assistant.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import * as React from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { resolve } from 'path';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import { Dialog } from '../../components/design-system/Dialog.js';
|
||||
import { ListItem } from '../../components/design-system/ListItem.js';
|
||||
import { useRegisterOverlay } from '../../context/overlayContext.js';
|
||||
import { useKeybindings } from '../../keybindings/useKeybinding.js';
|
||||
import { findGitRoot } from '../../utils/git.js';
|
||||
import { buildCliLaunch, spawnCli } from '../../utils/cliLaunch.js';
|
||||
import { getKairosActive, setKairosActive } from '../../bootstrap/state.js';
|
||||
import type { LocalJSXCommandContext } from '../../commands.js';
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js';
|
||||
import type { AppState } from '../../state/AppState.js';
|
||||
|
||||
/**
|
||||
* Compute the default directory for assistant daemon installation.
|
||||
* Prefers git root of cwd; falls back to cwd itself.
|
||||
*/
|
||||
export async function computeDefaultInstallDir(): Promise<string> {
|
||||
const cwd = process.cwd();
|
||||
const gitRoot = findGitRoot(cwd);
|
||||
return gitRoot || resolve(cwd);
|
||||
}
|
||||
|
||||
interface WizardProps {
|
||||
defaultDir: string;
|
||||
onInstalled: (dir: string) => void;
|
||||
onCancel: () => void;
|
||||
onError: (message: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Install wizard for assistant mode. Shown when `claude assistant` finds
|
||||
* zero CCR sessions. Guides the user to start a daemon that registers
|
||||
* a bridge → CCR cloud session.
|
||||
*
|
||||
* After installation, main.tsx tells the user to run `claude assistant`
|
||||
* again in a few seconds (daemon needs time to register the bridge session).
|
||||
*/
|
||||
export function NewInstallWizard({ defaultDir, onInstalled, onCancel, onError }: WizardProps): React.ReactNode {
|
||||
useRegisterOverlay('assistant-install-wizard');
|
||||
const [focusIndex, setFocusIndex] = useState(0);
|
||||
const [starting, setStarting] = useState(false);
|
||||
|
||||
useKeybindings(
|
||||
{
|
||||
'select:next': () => setFocusIndex(i => (i + 1) % 2),
|
||||
'select:previous': () => setFocusIndex(i => (i - 1 + 2) % 2),
|
||||
'select:accept': () => {
|
||||
if (focusIndex === 0) {
|
||||
startDaemon();
|
||||
} else {
|
||||
onCancel();
|
||||
}
|
||||
},
|
||||
},
|
||||
{ context: 'Select' },
|
||||
);
|
||||
|
||||
function startDaemon(): void {
|
||||
if (starting) return;
|
||||
setStarting(true);
|
||||
|
||||
const dir = defaultDir || resolve('.');
|
||||
|
||||
try {
|
||||
const launch = buildCliLaunch(['daemon', 'start', `--dir=${dir}`]);
|
||||
|
||||
const child = spawnCli(launch, {
|
||||
cwd: dir,
|
||||
stdio: 'ignore',
|
||||
detached: true,
|
||||
});
|
||||
|
||||
child.unref();
|
||||
|
||||
child.on('error', err => {
|
||||
onError(`Failed to start daemon: ${err.message}`);
|
||||
});
|
||||
|
||||
// Give the daemon a moment to initialize, then report success.
|
||||
// The daemon still needs several more seconds to register the bridge
|
||||
// and create a CCR session — main.tsx will tell the user to reconnect.
|
||||
setTimeout(() => {
|
||||
onInstalled(dir);
|
||||
}, 1500);
|
||||
} catch (err) {
|
||||
onError(`Failed to start daemon: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (starting) {
|
||||
return (
|
||||
<Dialog title="Assistant Setup" onCancel={onCancel} hideInputGuide>
|
||||
<Text>Starting daemon in {defaultDir}...</Text>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog title="Assistant Setup" onCancel={onCancel} hideInputGuide>
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text>No active assistant sessions found.</Text>
|
||||
<Text>
|
||||
Start a daemon in <Text bold>{defaultDir || '.'}</Text> to create a cloud session?
|
||||
</Text>
|
||||
<Box flexDirection="column">
|
||||
<ListItem isFocused={focusIndex === 0}>
|
||||
<Text>Start assistant daemon</Text>
|
||||
</ListItem>
|
||||
<ListItem isFocused={focusIndex === 1}>
|
||||
<Text>Cancel</Text>
|
||||
</ListItem>
|
||||
</Box>
|
||||
<Text dimColor>Enter to select · Esc to cancel</Text>
|
||||
</Box>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* /assistant command implementation.
|
||||
*
|
||||
* First invocation activates KAIROS (sets kairosActive, enables brief
|
||||
* and proactive tools). Subsequent invocations toggle the assistant panel.
|
||||
*/
|
||||
export async function call(
|
||||
onDone: LocalJSXCommandOnDone,
|
||||
context: LocalJSXCommandContext,
|
||||
_args: string,
|
||||
): Promise<React.ReactNode> {
|
||||
const { setAppState, getAppState } = context;
|
||||
|
||||
// First invocation: activate KAIROS
|
||||
if (!getKairosActive()) {
|
||||
setKairosActive(true);
|
||||
setAppState(
|
||||
(prev: AppState) =>
|
||||
({
|
||||
...prev,
|
||||
kairosEnabled: true,
|
||||
assistantPanelVisible: true,
|
||||
}) as AppState,
|
||||
);
|
||||
onDone('KAIROS assistant mode activated.', { display: 'system' });
|
||||
return null;
|
||||
}
|
||||
|
||||
// Subsequent invocations: toggle panel visibility
|
||||
const current = getAppState();
|
||||
const isVisible = (current as Record<string, unknown>).assistantPanelVisible;
|
||||
|
||||
if (isVisible) {
|
||||
setAppState(
|
||||
(prev: AppState) =>
|
||||
({
|
||||
...prev,
|
||||
assistantPanelVisible: false,
|
||||
}) as AppState,
|
||||
);
|
||||
onDone('Assistant panel hidden.', { display: 'system' });
|
||||
} else {
|
||||
setAppState(
|
||||
(prev: AppState) =>
|
||||
({
|
||||
...prev,
|
||||
assistantPanelVisible: true,
|
||||
}) as AppState,
|
||||
);
|
||||
onDone('Assistant panel opened.', { display: 'system' });
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,25 +1,21 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import { getKairosActive } from '../../bootstrap/state.js'
|
||||
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'
|
||||
|
||||
/**
|
||||
* Runtime gate for the /assistant command.
|
||||
* Runtime gate for the /assistant command visibility.
|
||||
*
|
||||
* Build-time: feature('KAIROS') must be on (checked in commands.ts before
|
||||
* the module is even required).
|
||||
* Build-time: feature('KAIROS') must be on.
|
||||
* Runtime: tengu_kairos_assistant GrowthBook flag (remote kill switch).
|
||||
*
|
||||
* Runtime: tengu_kairos_assistant GrowthBook flag acts as a remote kill
|
||||
* switch, and kairosActive state must be true (set during bootstrap when
|
||||
* the session qualifies for KAIROS features).
|
||||
* Does NOT require kairosActive — the /assistant command is visible
|
||||
* before activation so users can invoke it to activate KAIROS.
|
||||
*/
|
||||
export function isAssistantEnabled(): boolean {
|
||||
if (!feature('KAIROS')) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
!getFeatureValue_CACHED_MAY_BE_STALE('tengu_kairos_assistant', false)
|
||||
) {
|
||||
if (!getFeatureValue_CACHED_MAY_BE_STALE('tengu_kairos_assistant', false)) {
|
||||
return false
|
||||
}
|
||||
return getKairosActive()
|
||||
return true
|
||||
}
|
||||
|
||||
125
src/commands/autonomy.ts
Normal file
125
src/commands/autonomy.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
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'),
|
||||
}
|
||||
}
|
||||
|
||||
const autonomy = {
|
||||
type: 'local',
|
||||
name: 'autonomy',
|
||||
description:
|
||||
'Inspect automatic autonomy runs recorded for proactive ticks and scheduled tasks',
|
||||
supportsNonInteractive: true,
|
||||
load: () => Promise.resolve({ call }),
|
||||
} satisfies Command
|
||||
|
||||
export default autonomy
|
||||
24
src/commands/daemon/__tests__/daemon.test.ts
Normal file
24
src/commands/daemon/__tests__/daemon.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
|
||||
describe('/daemon command', () => {
|
||||
test('index exports a valid Command', async () => {
|
||||
const mod = await import('../index.js')
|
||||
const cmd = mod.default
|
||||
expect(cmd.name).toBe('daemon')
|
||||
expect(cmd.type).toBe('local-jsx')
|
||||
expect(typeof cmd.load).toBe('function')
|
||||
expect(cmd.description).toContain('daemon')
|
||||
})
|
||||
|
||||
test('daemon module exports call function', async () => {
|
||||
const mod = await import('../daemon.js')
|
||||
expect(typeof mod.call).toBe('function')
|
||||
})
|
||||
|
||||
test('argumentHint lists subcommands', async () => {
|
||||
const mod = await import('../index.js')
|
||||
const cmd = mod.default
|
||||
expect(cmd.argumentHint).toContain('status')
|
||||
expect(cmd.argumentHint).toContain('bg')
|
||||
})
|
||||
})
|
||||
57
src/commands/daemon/daemon.tsx
Normal file
57
src/commands/daemon/daemon.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import type {
|
||||
LocalJSXCommandOnDone,
|
||||
LocalJSXCommandContext,
|
||||
} from '../../types/command.js'
|
||||
|
||||
/**
|
||||
* /daemon slash command — manages daemon and background sessions from the REPL.
|
||||
*
|
||||
* Subcommands: status | start | stop | bg | attach | logs | kill
|
||||
* Default (no args): status
|
||||
*/
|
||||
export async function call(
|
||||
onDone: LocalJSXCommandOnDone,
|
||||
_context: LocalJSXCommandContext,
|
||||
args: string,
|
||||
): Promise<React.ReactNode> {
|
||||
const parts = args ? args.trim().split(/\s+/) : []
|
||||
const sub = parts[0] || 'status'
|
||||
|
||||
// attach is interactive/blocking — not available inside the REPL
|
||||
if (sub === 'attach') {
|
||||
onDone(
|
||||
'Use `claude daemon attach` from the CLI. Attach is not available inside the REPL.',
|
||||
{ display: 'system' },
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
// For all other subcommands, capture console output and return via onDone
|
||||
const lines = await captureConsole(async () => {
|
||||
if (sub === 'bg') {
|
||||
const bg = await import('../../cli/bg.js')
|
||||
await bg.handleBgStart(parts.slice(1))
|
||||
} else {
|
||||
const { daemonMain } = await import('../../daemon/main.js')
|
||||
await daemonMain([sub, ...parts.slice(1)])
|
||||
}
|
||||
})
|
||||
|
||||
onDone(lines.join('\n') || 'Done.', { display: 'system' })
|
||||
return null
|
||||
}
|
||||
|
||||
async function captureConsole(fn: () => Promise<void>): Promise<string[]> {
|
||||
const lines: string[] = []
|
||||
const origLog = console.log
|
||||
const origError = console.error
|
||||
console.log = (...a: unknown[]) => lines.push(a.map(String).join(' '))
|
||||
console.error = (...a: unknown[]) => lines.push(a.map(String).join(' '))
|
||||
try {
|
||||
await fn()
|
||||
} finally {
|
||||
console.log = origLog
|
||||
console.error = origError
|
||||
}
|
||||
return lines
|
||||
}
|
||||
17
src/commands/daemon/index.ts
Normal file
17
src/commands/daemon/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { Command } from '../../commands.js'
|
||||
import { feature } from 'bun:bundle'
|
||||
|
||||
const daemon = {
|
||||
type: 'local-jsx',
|
||||
name: 'daemon',
|
||||
description: 'Manage background sessions and daemon',
|
||||
argumentHint: '[status|start|stop|bg|attach|logs|kill]',
|
||||
isEnabled: () => {
|
||||
if (feature('DAEMON')) return true
|
||||
if (feature('BG_SESSIONS')) return true
|
||||
return false
|
||||
},
|
||||
load: () => import('./daemon.js'),
|
||||
} satisfies Command
|
||||
|
||||
export default daemon
|
||||
@@ -1,6 +1,7 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import type { Command } from '../commands.js'
|
||||
import { maybeMarkProjectOnboardingComplete } from '../projectOnboardingState.js'
|
||||
import { AUTONOMY_AGENTS_PATH_POSIX } from '../utils/autonomyAuthority.js'
|
||||
import { isEnvTruthy } from '../utils/envUtils.js'
|
||||
|
||||
const OLD_INIT_PROMPT = `Please analyze this codebase and create a CLAUDE.md file, which will be given to future instances of Claude Code to operate in this repository.
|
||||
@@ -43,7 +44,7 @@ Use AskUserQuestion to find out what the user wants:
|
||||
|
||||
## Phase 2: Explore the codebase
|
||||
|
||||
Launch a subagent to survey the codebase, and ask it to read key files to understand the project: manifest files (package.json, Cargo.toml, pyproject.toml, go.mod, pom.xml, etc.), README, Makefile/build configs, CI config, existing CLAUDE.md, .claude/rules/, AGENTS.md, .cursor/rules or .cursorrules, .github/copilot-instructions.md, .windsurfrules, .clinerules, .mcp.json.
|
||||
Launch a subagent to survey the codebase, and ask it to read key files to understand the project: manifest files (package.json, Cargo.toml, pyproject.toml, go.mod, pom.xml, etc.), README, Makefile/build configs, CI config, existing CLAUDE.md, .claude/rules/, ${AUTONOMY_AGENTS_PATH_POSIX}, .cursor/rules or .cursorrules, .github/copilot-instructions.md, .windsurfrules, .clinerules, .mcp.json.
|
||||
|
||||
Detect:
|
||||
- Build, test, and lint commands (especially non-standard ones)
|
||||
@@ -105,7 +106,7 @@ Include:
|
||||
- Repo etiquette (branch naming, PR conventions, commit style)
|
||||
- Required env vars or setup steps
|
||||
- Non-obvious gotchas or architectural decisions
|
||||
- Important parts from existing AI coding tool configs if they exist (AGENTS.md, .cursor/rules, .cursorrules, .github/copilot-instructions.md, .windsurfrules, .clinerules)
|
||||
- Important parts from existing AI coding tool configs if they exist (${AUTONOMY_AGENTS_PATH_POSIX}, .cursor/rules, .cursorrules, .github/copilot-instructions.md, .windsurfrules, .clinerules)
|
||||
|
||||
Exclude:
|
||||
- File-by-file structure or component lists (Claude can discover these by reading the codebase)
|
||||
|
||||
25
src/commands/job/__tests__/job.test.ts
Normal file
25
src/commands/job/__tests__/job.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
|
||||
describe('/job command', () => {
|
||||
test('index exports a valid Command', async () => {
|
||||
const mod = await import('../index.js')
|
||||
const cmd = mod.default
|
||||
expect(cmd.name).toBe('job')
|
||||
expect(cmd.type).toBe('local-jsx')
|
||||
expect(typeof cmd.load).toBe('function')
|
||||
expect(cmd.description).toContain('job')
|
||||
})
|
||||
|
||||
test('job module exports call function', async () => {
|
||||
const mod = await import('../job.js')
|
||||
expect(typeof mod.call).toBe('function')
|
||||
})
|
||||
|
||||
test('argumentHint lists subcommands', async () => {
|
||||
const mod = await import('../index.js')
|
||||
const cmd = mod.default
|
||||
expect(cmd.argumentHint).toContain('list')
|
||||
expect(cmd.argumentHint).toContain('new')
|
||||
expect(cmd.argumentHint).toContain('status')
|
||||
})
|
||||
})
|
||||
16
src/commands/job/index.ts
Normal file
16
src/commands/job/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { Command } from '../../commands.js'
|
||||
import { feature } from 'bun:bundle'
|
||||
|
||||
const job = {
|
||||
type: 'local-jsx',
|
||||
name: 'job',
|
||||
description: 'Manage template jobs',
|
||||
argumentHint: '[list|new|reply|status]',
|
||||
isEnabled: () => {
|
||||
if (feature('TEMPLATES')) return true
|
||||
return false
|
||||
},
|
||||
load: () => import('./job.js'),
|
||||
} satisfies Command
|
||||
|
||||
export default job
|
||||
34
src/commands/job/job.tsx
Normal file
34
src/commands/job/job.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { LocalJSXCommandOnDone, LocalJSXCommandContext } from '../../types/command.js'
|
||||
|
||||
/**
|
||||
* /job slash command — manages template jobs from inside the REPL.
|
||||
*
|
||||
* Subcommands: list | new <template> [args] | reply <id> <text> | status <id>
|
||||
* Default (no args): list
|
||||
*/
|
||||
export async function call(
|
||||
onDone: LocalJSXCommandOnDone,
|
||||
_context: LocalJSXCommandContext,
|
||||
args: string,
|
||||
): Promise<React.ReactNode> {
|
||||
const parts = args ? args.trim().split(/\s+/) : []
|
||||
const sub = parts[0] || 'list'
|
||||
|
||||
// Capture console output so we can return it as onDone text
|
||||
const lines: string[] = []
|
||||
const origLog = console.log
|
||||
const origError = console.error
|
||||
console.log = (...a: unknown[]) => lines.push(a.map(String).join(' '))
|
||||
console.error = (...a: unknown[]) => lines.push(a.map(String).join(' '))
|
||||
|
||||
try {
|
||||
const { templatesMain } = await import('../../cli/handlers/templateJobs.js')
|
||||
await templatesMain([sub, ...parts.slice(1)])
|
||||
} finally {
|
||||
console.log = origLog
|
||||
console.error = origError
|
||||
}
|
||||
|
||||
onDone(lines.join('\n') || 'Done.', { display: 'system' })
|
||||
return null
|
||||
}
|
||||
12
src/commands/lang/index.ts
Normal file
12
src/commands/lang/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { Command } from '../../commands.js'
|
||||
|
||||
const lang = {
|
||||
type: 'local-jsx',
|
||||
name: 'lang',
|
||||
description: 'Set display language (en/zh/auto)',
|
||||
immediate: true,
|
||||
argumentHint: '<en|zh|auto>',
|
||||
load: () => import('./lang.js'),
|
||||
} satisfies Command
|
||||
|
||||
export default lang
|
||||
49
src/commands/lang/lang.ts
Normal file
49
src/commands/lang/lang.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { ToolUseContext } from '../../Tool.js'
|
||||
import type {
|
||||
LocalJSXCommandContext,
|
||||
LocalJSXCommandOnDone,
|
||||
} from '../../types/command.js'
|
||||
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'
|
||||
import {
|
||||
type PreferredLanguage,
|
||||
getLanguageDisplayName,
|
||||
getResolvedLanguage,
|
||||
} from '../../utils/language.js'
|
||||
|
||||
const VALID_LANGS: readonly PreferredLanguage[] = ['en', 'zh', 'auto']
|
||||
|
||||
export async function call(
|
||||
onDone: LocalJSXCommandOnDone,
|
||||
_context: ToolUseContext & LocalJSXCommandContext,
|
||||
args: string,
|
||||
): Promise<null> {
|
||||
const arg = args.trim().toLowerCase()
|
||||
|
||||
if (!arg) {
|
||||
const pref = getGlobalConfig().preferredLanguage ?? 'auto'
|
||||
const resolved = getResolvedLanguage()
|
||||
const suffix =
|
||||
pref === 'auto' ? ` → ${getLanguageDisplayName(resolved)}` : ''
|
||||
onDone(`Language: ${getLanguageDisplayName(pref)}${suffix}`, {
|
||||
display: 'system',
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
if (!VALID_LANGS.includes(arg as PreferredLanguage)) {
|
||||
onDone(`Invalid language "${arg}". Use: en, zh, or auto`, {
|
||||
display: 'system',
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
const lang = arg as PreferredLanguage
|
||||
saveGlobalConfig(current => ({ ...current, preferredLanguage: lang }))
|
||||
|
||||
const resolved = getResolvedLanguage()
|
||||
const suffix = lang === 'auto' ? ` → ${getLanguageDisplayName(resolved)}` : ''
|
||||
onDone(`Language set to ${getLanguageDisplayName(lang)}${suffix}`, {
|
||||
display: 'system',
|
||||
})
|
||||
return null
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { spawn, type ChildProcess } from 'child_process';
|
||||
import { type ChildProcess } from 'child_process';
|
||||
import { resolve } from 'path';
|
||||
import * as React from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
@@ -10,6 +10,7 @@ import { ListItem } from '../../components/design-system/ListItem.js';
|
||||
import { useRegisterOverlay } from '../../context/overlayContext.js';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import { useKeybindings } from '../../keybindings/useKeybinding.js';
|
||||
import { buildCliLaunch, spawnCli } from '../../utils/cliLaunch.js';
|
||||
import type { ToolUseContext } from '../../Tool.js';
|
||||
import type { LocalJSXCommandContext, LocalJSXCommandOnDone } from '../../types/command.js';
|
||||
import { errorMessage } from '../../utils/errors.js';
|
||||
@@ -202,9 +203,9 @@ async function checkPrerequisites(): Promise<string | null> {
|
||||
function startDaemon(): void {
|
||||
const dir = resolve('.');
|
||||
|
||||
const execArgs = [...process.execArgv, process.argv[1]!, 'daemon', 'start', `--dir=${dir}`];
|
||||
const launch = buildCliLaunch(['daemon', 'start', `--dir=${dir}`]);
|
||||
|
||||
const child = spawn(process.execPath, execArgs, {
|
||||
const child = spawnCli(launch, {
|
||||
cwd: dir,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: false,
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { LocalCommandCall } from '../../types/command.js'
|
||||
import { getSlaveClient } from '../../hooks/useMasterMonitor.js'
|
||||
import { getPipeIpc } from '../../utils/pipeTransport.js'
|
||||
import {
|
||||
addSendOverride,
|
||||
removeSendOverride,
|
||||
removeMasterPipeMute,
|
||||
} from '../../utils/pipeMuteState.js'
|
||||
|
||||
export const call: LocalCommandCall = async (args, context) => {
|
||||
const currentState = context.getAppState()
|
||||
@@ -48,6 +53,12 @@ export const call: LocalCommandCall = async (args, context) => {
|
||||
}
|
||||
|
||||
try {
|
||||
// Temporarily override mute for this slave so its response is visible.
|
||||
// Override lasts until the slave emits 'done' or 'error' (cleared by
|
||||
// useMasterMonitor's attachPipeEntryEmitter handler).
|
||||
addSendOverride(targetName)
|
||||
removeMasterPipeMute(targetName)
|
||||
client.send({ type: 'relay_unmute' })
|
||||
client.send({
|
||||
type: 'prompt',
|
||||
data: message,
|
||||
@@ -89,6 +100,8 @@ export const call: LocalCommandCall = async (args, context) => {
|
||||
value: `Sent to "${targetName}": ${message.slice(0, 100)}${message.length > 100 ? '...' : ''}`,
|
||||
}
|
||||
} catch (err) {
|
||||
// Roll back override on send failure to prevent permanent unmute
|
||||
removeSendOverride(targetName)
|
||||
return {
|
||||
type: 'text',
|
||||
value: `Failed to send to "${targetName}": ${err instanceof Error ? err.message : String(err)}`,
|
||||
|
||||
@@ -1 +1,19 @@
|
||||
export default null
|
||||
import type { Command, LocalJSXCommandOnDone } from '../types/command.js'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
const call = async (onDone: LocalJSXCommandOnDone): Promise<ReactNode> => {
|
||||
onDone(
|
||||
'torch: Reserved internal debug command. No implementation is available in this build.',
|
||||
{ display: 'system' },
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
export default {
|
||||
type: 'local-jsx',
|
||||
name: 'torch',
|
||||
description: '[INTERNAL] Development debug command (reserved)',
|
||||
isEnabled: () => true,
|
||||
isHidden: true,
|
||||
load: () => Promise.resolve({ call }),
|
||||
} satisfies Command
|
||||
|
||||
Reference in New Issue
Block a user