diff --git a/.changeset/headless-scripted-runs.md b/.changeset/headless-scripted-runs.md new file mode 100644 index 000000000..a9d409d8a --- /dev/null +++ b/.changeset/headless-scripted-runs.md @@ -0,0 +1,7 @@ +--- +"@moonshot-ai/kimi-code": minor +"@moonshot-ai/kimi-code-sdk": minor +"@moonshot-ai/agent-core": minor +--- + +Add headless CLI runs with status polling, file output, goal control, and session run locking. diff --git a/apps/kimi-code/src/cli/commands.ts b/apps/kimi-code/src/cli/commands.ts index faf1e1da8..ca4f92d0c 100644 --- a/apps/kimi-code/src/cli/commands.ts +++ b/apps/kimi-code/src/cli/commands.ts @@ -3,6 +3,7 @@ import { registerMigrateCommand } from '#/migration/index'; import { Command, Option } from 'commander'; import type { CLIOptions } from './options'; +import { registerHeadlessCommand, type HeadlessCommandHandler } from './headless/commands'; import { registerAcpCommand } from './sub/acp'; import { registerDoctorCommand } from './sub/doctor'; import { registerExportCommand } from './sub/export'; @@ -20,11 +21,13 @@ export function createProgram( onMigrate: MigrateCommandHandler, onPluginNodeRunner: PluginNodeRunnerHandler = () => {}, onUpgrade: UpgradeCommandHandler = () => {}, + onHeadless: HeadlessCommandHandler = () => {}, ): Command { const program = new Command(CLI_COMMAND_NAME) .description('The Starting Point for Next-Gen Agents') .version(version, '-V, --version') .allowUnknownOption(false) + .enablePositionalOptions() .configureHelp({ helpWidth: 100 }) .helpOption('-h, --help', 'Show help.') .usage('[options] [command]') @@ -81,6 +84,7 @@ export function createProgram( registerLoginCommand(program); registerDoctorCommand(program); registerMigrateCommand(program, onMigrate); + registerHeadlessCommand(program, onHeadless); program .command('upgrade') .description('Upgrade Kimi Code to the latest version.') diff --git a/apps/kimi-code/src/cli/headless/approval.ts b/apps/kimi-code/src/cli/headless/approval.ts new file mode 100644 index 000000000..c81c0a2ab --- /dev/null +++ b/apps/kimi-code/src/cli/headless/approval.ts @@ -0,0 +1,78 @@ +import type { ApprovalHandler, ApprovalRequest, ApprovalResponse } from '@moonshot-ai/kimi-code-sdk'; + +import type { HeadlessApprovalStatus } from './status-file'; +import type { HeadlessWarning } from './status-file'; + +export interface HeadlessApprovalOptions { + readonly approvePlan: boolean; + readonly rejectPlan: boolean; + readonly onPlanApprovalRequired: (approval: HeadlessApprovalStatus) => void; +} + +export function createHeadlessApprovalHandler(options: HeadlessApprovalOptions): ApprovalHandler { + return (request) => { + if (!isPlanApprovalRequest(request)) return { decision: 'approved' }; + + const approval: HeadlessApprovalStatus = { + kind: 'plan', + toolCallId: request.toolCallId, + decision: options.approvePlan ? 'approved' : options.rejectPlan ? 'rejected' : 'required', + decidedByFlag: options.approvePlan ? 'approve-plan' : options.rejectPlan ? 'reject-plan' : null, + message: options.approvePlan + ? 'Plan approved by --approve-plan.' + : options.rejectPlan + ? 'Plan rejected by --reject-plan.' + : 'rerun with --approve-plan or --reject-plan', + }; + options.onPlanApprovalRequired(approval); + + if (options.approvePlan) { + return approvePlanRequest(request); + } + if (options.rejectPlan) { + return { + decision: 'rejected', + selectedLabel: 'Reject and Exit', + feedback: 'Rejected by --reject-plan.', + }; + } + return { + decision: 'cancelled', + feedback: 'Plan approval requires --approve-plan or --reject-plan in headless mode.', + }; + }; +} + +export function getUnusedPlanFlagWarning(options: { + readonly approvePlan: boolean; + readonly rejectPlan: boolean; + readonly planApprovalSeen: boolean; +}): HeadlessWarning | null { + if (options.planApprovalSeen) return null; + if (options.approvePlan) { + return { + code: 'PLAN_FLAG_UNUSED', + message: '--approve-plan was set, but no plan approval was requested.', + }; + } + if (options.rejectPlan) { + return { + code: 'PLAN_FLAG_UNUSED', + message: '--reject-plan was set, but no plan approval was requested.', + }; + } + return null; +} + +function isPlanApprovalRequest(request: ApprovalRequest): boolean { + return request.toolName === 'ExitPlanMode' || request.display.kind === 'plan_review'; +} + +function approvePlanRequest(request: ApprovalRequest): ApprovalResponse { + const firstOption = + request.display.kind === 'plan_review' ? request.display.options?.[0]?.label : undefined; + return { + decision: 'approved', + selectedLabel: firstOption, + }; +} diff --git a/apps/kimi-code/src/cli/headless/atomic-file.ts b/apps/kimi-code/src/cli/headless/atomic-file.ts new file mode 100644 index 000000000..0816343d2 --- /dev/null +++ b/apps/kimi-code/src/cli/headless/atomic-file.ts @@ -0,0 +1,19 @@ +import { mkdir, open, rename } from 'node:fs/promises'; +import path from 'node:path'; + +export async function writeAtomicTextFile(filePath: string, text: string): Promise { + await mkdir(path.dirname(filePath), { recursive: true }); + const tempPath = `${filePath}.tmp`; + const handle = await open(tempPath, 'w'); + try { + await handle.writeFile(text, 'utf8'); + await handle.sync(); + } finally { + await handle.close(); + } + await rename(tempPath, filePath); +} + +export async function writeAtomicJsonFile(filePath: string, value: unknown): Promise { + await writeAtomicTextFile(filePath, `${JSON.stringify(value, null, 2)}\n`); +} diff --git a/apps/kimi-code/src/cli/headless/commands.ts b/apps/kimi-code/src/cli/headless/commands.ts new file mode 100644 index 000000000..c5b984ad0 --- /dev/null +++ b/apps/kimi-code/src/cli/headless/commands.ts @@ -0,0 +1,393 @@ +import { Command, Option } from 'commander'; + +export type HeadlessControlAction = 'pause_goal' | 'cancel_goal' | 'interrupt'; + +export interface HeadlessRunOptions { + readonly prompt?: string; + readonly goal?: string; + readonly replaceGoal?: string; + readonly cwd?: string; + readonly session?: string; + readonly continue: boolean; + readonly model?: string; + readonly statusFile?: string; + readonly outputDir?: string; + readonly metadataOnly: boolean; + readonly approvePlan: boolean; + readonly rejectPlan: boolean; + readonly skillsDirs: readonly string[]; +} + +export interface HeadlessStatusOptions { + readonly file: string; + readonly json: boolean; +} + +export interface HeadlessGoalControlOptions { + readonly action: HeadlessControlAction; + readonly file: string; + readonly wait: boolean; +} + +export type HeadlessCommand = + | { readonly kind: 'run'; readonly options: HeadlessRunOptions } + | { readonly kind: 'status'; readonly options: HeadlessStatusOptions } + | { readonly kind: 'goal-control'; readonly options: HeadlessGoalControlOptions }; + +export type HeadlessCommandHandler = (command: HeadlessCommand) => void; + +const HEADLESS_INPUT_SOURCE_ERROR = + 'Specify exactly one of --prompt, --goal, or --replace-goal.'; +const HEADLESS_PLAN_FLAGS_ERROR = 'Cannot combine --approve-plan with --reject-plan.'; + +interface RawHeadlessRunOptions { + readonly prompt?: string; + readonly goal?: string; + readonly replaceGoal?: string; + readonly cwd?: string; + readonly session?: string; + readonly continue?: boolean; + readonly model?: string; + readonly statusFile?: string; + readonly outputDir?: string; + readonly metadataOnly?: boolean; + readonly approvePlan?: boolean; + readonly rejectPlan?: boolean; + readonly skillsDir?: string[]; +} + +interface RawHeadlessStatusOptions { + readonly file?: string; + readonly json?: boolean; +} + +interface RawHeadlessGoalControlOptions { + readonly file?: string; + readonly wait?: boolean; +} + +export function registerHeadlessCommand( + program: Command, + onHeadless: HeadlessCommandHandler, +): void { + const headless = program + .command('headless') + .description('Run and inspect non-interactive Kimi Code turns.') + .showHelpAfterError() + .addHelpText( + 'after', + [ + '', + 'Headless mode runs without the TUI. The process exits when the run ends.', + '', + 'Usage heads-up:', + ' Give Kimi exactly one input: --prompt, --goal, or --replace-goal.', + ' Use --prompt for one turn. Use --goal for autonomous multi-turn work.', + ' For automation, prefer --status-file and read the JSON state from that file.', + ' Do not poll the status file in a tight loop. Set a reasonable time limit.', + ' Stop waiting when the time limit expires or when the Kimi process exits.', + ' Use --approve-plan or --reject-plan if a plan review may appear.', + ' Stop a running goal with: kimi headless goal pause|cancel|interrupt --file ', + '', + 'Commands guide:', + ' run: start a headless prompt or goal run.', + ' Use it when you want Kimi to do work without opening the TUI.', + ' status: read the status file written by a run.', + ' Use it to inspect progress, active tools, output files, and errors.', + ' goal: send pause, cancel, or interrupt to a running goal.', + ' Use it only with the status file from a goal run.', + '', + 'Options guide:', + ' --prompt: run one turn with this instruction.', + ' Use this for bounded work where one assistant answer is enough.', + ' --goal: create a multi-turn goal and keep running until the goal stops.', + ' Use this for autonomous work that may need several turns.', + ' --replace-goal: replace the active goal in the session, then run the new goal.', + ' Use this when the old goal should no longer continue.', + ' --cwd: run from this working directory.', + ' Use this when the caller is not already in the target repository.', + ' --session: resume a specific session id.', + ' Use this when the caller already knows which session to continue.', + ' --continue: resume the latest session for the working directory.', + ' Use this when the caller wants to continue recent work in the same repo.', + ' --model: override the configured model for this run.', + ' Use this for one-off model selection without editing config.toml.', + ' --status-file: write live JSON status for polling.', + ' Use this when another process needs state, active tool, goal progress, errors, or output paths.', + ' --output-dir: write Markdown responses to files.', + ' Use this for long output, multi-turn goals, or when stdout must stay machine-readable.', + ' --metadata-only: print only the final JSON metadata line.', + ' Use this when another program reads stdout and response text is available from files or status.', + ' --approve-plan: approve a plan review if one appears.', + ' Use this when Kimi should continue from planning into implementation.', + ' --reject-plan: reject a plan review if one appears.', + ' Use this when Kimi should stop after producing a plan.', + ' --skills-dir: load skills from this directory. Can be repeated.', + ' Use this to give Kimi a specific skill set for the run.', + '', + 'Examples with outcomes:', + ' Run one prompt and print the answer:', + ' kimi headless run --prompt "inspect"', + ' What happens: Kimi starts a non-interactive session, runs one turn, then exits.', + ' Output: stdout starts with one JSON metadata line; Markdown follows after a blank line.', + ' Possible outcomes: completed or failed.', + '', + ' Run one prompt with live progress polling:', + ' kimi headless run --prompt "fix tests" --status-file /tmp/kimi-run/status.json', + ' What happens: Kimi updates the status file while the turn runs.', + ' Polling: read the file at a reasonable interval until Kimi exits or your time limit expires.', + ' Possible outcomes: running, completed, failed, or cancelled.', + '', + ' Run a multi-turn goal and write each turn to files:', + ' kimi headless run --goal "raise coverage" --status-file /tmp/kimi-run/status.json --output-dir /tmp/kimi-run', + ' What happens: Kimi creates a goal and continues across turns until the goal stops.', + ' Output: each completed turn is written under /tmp/kimi-run/turns, and goal status is written as JSON.', + ' Possible outcomes: completed, paused, failed, cancelled, or interrupted.', + '', + ' Check progress:', + ' kimi headless status --file /tmp/kimi-run/status.json', + ' What happens: Kimi prints a compact summary of a running or finished run.', + ' Use --json when another program needs the full status object.', + '', + ' Pause a running goal after the current turn:', + ' kimi headless goal pause --file /tmp/kimi-run/status.json --wait', + ' What happens: Kimi sends the request through the run control file.', + ' With --wait, this command waits until the running process applies the request or exits.', + ' Possible outcomes: control applied, control pending, or the run already ended.', + ].join('\n'), + ); + + addRootGoalOptions(headless); + + const run = headless + .command('run') + .description('Run a prompt or goal without the TUI.') + .addHelpText( + 'after', + [ + '', + 'What happens: Kimi starts a non-interactive run, then exits.', + 'Use --prompt for one turn. Use --goal or --replace-goal for multi-turn goal work.', + 'Default stdout starts with one JSON metadata line. Markdown follows unless you use --metadata-only or --output-dir.', + '', + 'Possible outcomes: completed, paused, failed, cancelled, or interrupted.', + ' completed: the prompt turn finished, or the goal was marked complete.', + ' paused: the goal stopped and can be resumed later.', + ' failed: the turn failed, or the goal became blocked.', + ' cancelled: the process received SIGINT or SIGTERM.', + ' interrupted: a control command stopped the active turn.', + '', + 'Examples with outcomes:', + ' kimi headless run --prompt "inspect"', + ' Runs one turn and prints metadata plus Markdown. Outcomes: completed or failed.', + ' kimi headless run --prompt "inspect" --metadata-only', + ' Runs one turn and prints only metadata. Use this when another program reads stdout.', + ' kimi headless run --goal "raise coverage to 99.5%" --status-file /tmp/kimi-run/status.json', + ' Runs a goal across turns and writes live status. Outcomes: completed, paused, failed, cancelled, or interrupted.', + ].join('\n'), + ); + addRunOptions(run, { includePrompt: true }); + run.action((options: RawHeadlessRunOptions) => { + onHeadless({ + kind: 'run', + options: buildRunOptions(options, run), + }); + }); + + const status = headless + .command('status') + .description('Read a status file written by headless run.') + .requiredOption('--file ', 'Read this status file.') + .option('--json', 'Print the complete status JSON.', false) + .addHelpText( + 'after', + [ + '', + 'What happens: Kimi reads the status JSON and prints the current state.', + 'Use this while a run is active or after it exits.', + 'The compact output includes state, session, turn, active tool, goal, files, and control details when available.', + 'Use --json when another program needs the complete status object.', + '', + 'Possible states include: starting, running, approval_required, paused, completed, failed, cancelled, interrupted.', + '', + 'Example:', + ' kimi headless status --file /tmp/kimi-run/status.json', + ' Prints a human-readable summary.', + ' kimi headless status --file /tmp/kimi-run/status.json --json', + ' Prints the full JSON object for automation.', + ].join('\n'), + ); + status.action((options: RawHeadlessStatusOptions) => { + onHeadless({ + kind: 'status', + options: buildStatusOptions(options, status), + }); + }); + + const goal = headless + .command('goal') + .description('Send a control request to a goal run.') + .addHelpText( + 'after', + [ + '', + 'What happens: Kimi writes a control request for the running goal.', + 'The running headless process reads that request from its control file.', + 'Use --wait to wait until the running process applies the request or exits.', + '', + 'Actions:', + ' pause: let the current turn finish, then pause before the next turn.', + ' cancel: let the current turn finish, then cancel the goal.', + ' interrupt: stop the active turn now and leave the goal paused when possible.', + '', + 'Example:', + ' kimi headless goal pause --file /tmp/kimi-run/status.json --wait', + ' Sends a pause request and waits for it to be applied.', + ].join('\n'), + ); + registerGoalControlCommand(goal, 'pause', 'pause_goal', onHeadless); + registerGoalControlCommand(goal, 'cancel', 'cancel_goal', onHeadless); + registerGoalControlCommand(goal, 'interrupt', 'interrupt', onHeadless); + + headless.action((options: RawHeadlessRunOptions) => { + onHeadless({ + kind: 'run', + options: buildRunOptions(options, headless), + }); + }); +} + +function addRootGoalOptions(command: Command): void { + command.addOption(new Option('--goal ', 'Create and run a multi-turn goal.')); + addSharedRunOptions(command); +} + +function addRunOptions(command: Command, options: { readonly includePrompt: boolean }): void { + command.showHelpAfterError(); + if (options.includePrompt) { + command.addOption(new Option('--prompt ', 'Run one turn with this instruction.')); + } + command.addOption(new Option('--goal ', 'Create and run a multi-turn goal.')); + command.addOption( + new Option('--replace-goal ', 'Replace the active goal, then run the new goal.'), + ); + addSharedRunOptions(command); +} + +function addSharedRunOptions(command: Command): void { + command + .addOption(new Option('--cwd ', 'Run from this working directory.')) + .addOption(new Option('--session ', 'Resume a specific session.')) + .option('--continue', 'Continue the latest session for the working directory.', false) + .addOption(new Option('--model ', 'Use this model for this run only.')) + .addOption(new Option('--status-file ', 'Write live JSON status for polling.')) + .addOption(new Option('--output-dir ', 'Write Markdown responses to files.')) + .option('--metadata-only', 'Print only the final JSON metadata line.', false) + .option('--approve-plan', 'Approve a plan review if one appears.', false) + .option('--reject-plan', 'Reject a plan review if one appears.', false) + .addOption( + new Option( + '--skills-dir ', + 'Load skills from this directory. Can be repeated.', + ) + .argParser((value: string, previous: string[] | undefined) => [ + ...(previous ?? []), + value, + ]) + .default([]), + ); +} + +function registerGoalControlCommand( + goal: Command, + name: string, + action: HeadlessControlAction, + onHeadless: HeadlessCommandHandler, +): void { + const command = goal + .command(name) + .description(getGoalControlDescription(action)) + .requiredOption('--file ', 'Status file for the running headless goal.') + .option('--wait', 'Wait until the running process applies the request.', false); + + command.action((options: RawHeadlessGoalControlOptions) => { + onHeadless({ + kind: 'goal-control', + options: buildGoalControlOptions(options, action, command), + }); + }); +} + +function getGoalControlDescription(action: HeadlessControlAction): string { + switch (action) { + case 'pause_goal': + return 'Let the current turn finish, then pause the goal.'; + case 'cancel_goal': + return 'Let the current turn finish, then cancel the goal.'; + case 'interrupt': + return 'Stop the active turn now and leave the goal paused when possible.'; + } +} + +function buildRunOptions(raw: RawHeadlessRunOptions, command: Command): HeadlessRunOptions { + const prompt = normalizeOptionalString(raw.prompt); + const goal = normalizeOptionalString(raw.goal); + const replaceGoal = normalizeOptionalString(raw.replaceGoal); + const inputCount = [prompt, goal, replaceGoal].filter((value) => value !== undefined).length; + if (inputCount !== 1) { + command.error(HEADLESS_INPUT_SOURCE_ERROR); + } + if (raw.approvePlan === true && raw.rejectPlan === true) { + command.error(HEADLESS_PLAN_FLAGS_ERROR); + } + + return { + prompt, + goal, + replaceGoal, + cwd: normalizeOptionalString(raw.cwd), + session: normalizeOptionalString(raw.session), + continue: raw.continue === true, + model: normalizeOptionalString(raw.model), + statusFile: normalizeOptionalString(raw.statusFile), + outputDir: normalizeOptionalString(raw.outputDir), + metadataOnly: raw.metadataOnly === true, + approvePlan: raw.approvePlan === true, + rejectPlan: raw.rejectPlan === true, + skillsDirs: raw.skillsDir ?? [], + }; +} + +function buildStatusOptions(raw: RawHeadlessStatusOptions, command: Command): HeadlessStatusOptions { + const file = normalizeOptionalString(raw.file); + if (file === undefined) { + command.error('Missing required option --file .'); + } + return { + file, + json: raw.json === true, + }; +} + +function buildGoalControlOptions( + raw: RawHeadlessGoalControlOptions, + action: HeadlessControlAction, + command: Command, +): HeadlessGoalControlOptions { + const file = normalizeOptionalString(raw.file); + if (file === undefined) { + command.error('Missing required option --file .'); + } + return { + action, + file, + wait: raw.wait === true, + }; +} + +function normalizeOptionalString(value: string | undefined): string | undefined { + if (value === undefined) return undefined; + const trimmed = value.trim(); + if (trimmed.length === 0) return undefined; + return value; +} diff --git a/apps/kimi-code/src/cli/headless/control.ts b/apps/kimi-code/src/cli/headless/control.ts new file mode 100644 index 000000000..acf5e43d9 --- /dev/null +++ b/apps/kimi-code/src/cli/headless/control.ts @@ -0,0 +1,50 @@ +import { readFile } from 'node:fs/promises'; + +import { writeAtomicJsonFile } from './atomic-file'; +import { + readHeadlessRunStatus, + type HeadlessAppliedControlRequest, + type HeadlessControlRequest, +} from './status-file'; + +export async function writeHeadlessControlRequest( + controlPath: string, + request: HeadlessControlRequest, +): Promise { + await writeAtomicJsonFile(controlPath, request); +} + +export async function readHeadlessControlRequest( + controlPath: string, +): Promise { + try { + return JSON.parse(await readFile(controlPath, 'utf8')) as HeadlessControlRequest; + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') return null; + throw error; + } +} + +export async function waitForHeadlessControlApplied(input: { + readonly statusFile: string; + readonly commandId: string; + readonly timeoutMs: number; +}): Promise { + const deadline = Date.now() + input.timeoutMs; + while (Date.now() <= deadline) { + const status = await readHeadlessRunStatus(input.statusFile); + const applied = status.control?.lastApplied; + if (applied?.commandId === input.commandId) return applied; + if (isTerminalState(status.state)) return null; + await delay(100); + } + return null; +} + +function isTerminalState(state: string): boolean { + return ['paused', 'completed', 'failed', 'cancelled', 'interrupted'].includes(state); +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/apps/kimi-code/src/cli/headless/output-files.ts b/apps/kimi-code/src/cli/headless/output-files.ts new file mode 100644 index 000000000..b696fe67f --- /dev/null +++ b/apps/kimi-code/src/cli/headless/output-files.ts @@ -0,0 +1,80 @@ +import { access, mkdir, stat } from 'node:fs/promises'; +import { constants } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import { writeAtomicJsonFile, writeAtomicTextFile } from './atomic-file'; +import type { HeadlessGoalStatus, HeadlessResponseFile, HeadlessSidecarFile } from './status-file'; + +export interface ResolveHeadlessOutputDirInput { + readonly explicitOutputDir?: string; + readonly statusFile?: string; + readonly runId: string; +} + +export function resolveHeadlessOutputDir(input: ResolveHeadlessOutputDirInput): string { + if (input.explicitOutputDir !== undefined) return path.resolve(input.explicitOutputDir); + if (input.statusFile !== undefined) return path.resolve(`${input.statusFile}.d`); + return path.join(tmpdir(), `kimi-headless-${input.runId}`); +} + +export async function preflightHeadlessOutputDir(outputDir: string): Promise { + try { + const existing = await stat(outputDir); + if (!existing.isDirectory()) { + throw new Error('Output path exists and is not a directory.'); + } + } catch (error) { + if (error instanceof Error && error.message === 'Output path exists and is not a directory.') { + throw error; + } + await mkdir(outputDir, { recursive: true }); + } + + try { + await access(outputDir, constants.W_OK); + } catch { + throw new Error('Output directory is not writable.'); + } + await mkdir(path.join(outputDir, 'turns'), { recursive: true }); +} + +export async function writeHeadlessResponseFile(input: { + readonly outputDir: string; + readonly turnIndex: number; + readonly turnId: number | null; + readonly markdown: string; + readonly updatedAt: string; +}): Promise { + const filePath = path.join(input.outputDir, 'turns', `turn-${padTurnIndex(input.turnIndex)}.md`); + await mkdir(path.dirname(filePath), { recursive: true }); + await writeAtomicTextFile(filePath, input.markdown); + return { + turnIndex: input.turnIndex, + turnId: input.turnId, + path: filePath, + state: 'completed', + bytes: Buffer.byteLength(input.markdown, 'utf8'), + updatedAt: input.updatedAt, + }; +} + +export async function writeHeadlessGoalStatusFile(input: { + readonly outputDir: string; + readonly goal: HeadlessGoalStatus; + readonly updatedAt: string; +}): Promise { + const filePath = path.join(input.outputDir, 'goal-status.json'); + await writeAtomicJsonFile(filePath, input.goal); + const json = `${JSON.stringify(input.goal, null, 2)}\n`; + return { + path: filePath, + state: 'completed', + bytes: Buffer.byteLength(json, 'utf8'), + updatedAt: input.updatedAt, + }; +} + +function padTurnIndex(turnIndex: number): string { + return String(turnIndex).padStart(4, '0'); +} diff --git a/apps/kimi-code/src/cli/headless/output.ts b/apps/kimi-code/src/cli/headless/output.ts new file mode 100644 index 000000000..07273c1f9 --- /dev/null +++ b/apps/kimi-code/src/cli/headless/output.ts @@ -0,0 +1,31 @@ +import type { + HeadlessApprovalStatus, + HeadlessGoalStatus, + HeadlessRunFiles, + HeadlessRunState, + HeadlessRunSummary, + HeadlessWarning, +} from './status-file'; + +export interface HeadlessMetadataHeader { + readonly type: 'headless.result'; + readonly schemaVersion: 1; + readonly runId: string; + readonly sessionId: string | null; + readonly turnId: number | null; + readonly state: HeadlessRunState; + readonly responseFormat: 'markdown' | 'files' | 'omitted'; + readonly responseOmitted: boolean; + readonly resumeCommand: string | null; + readonly summary: HeadlessRunSummary; + readonly approval: HeadlessApprovalStatus | null; + readonly goal: HeadlessGoalStatus | null; + readonly warnings: readonly HeadlessWarning[]; + readonly files: HeadlessRunFiles; + readonly error?: { readonly message: string }; +} + +export function formatHeadlessMetadataHeader(header: HeadlessMetadataHeader): string { + const suffix = header.responseOmitted ? '\n' : '\n\n'; + return `${JSON.stringify(header)}${suffix}`; +} diff --git a/apps/kimi-code/src/cli/headless/run.ts b/apps/kimi-code/src/cli/headless/run.ts new file mode 100644 index 000000000..483941c32 --- /dev/null +++ b/apps/kimi-code/src/cli/headless/run.ts @@ -0,0 +1,980 @@ +import { randomUUID } from 'node:crypto'; +import path from 'node:path'; + +import { + acquireSessionRunLock as acquireSdkSessionRunLock, + createKimiHarness, + type Event, +} from '@moonshot-ai/kimi-code-sdk'; + +import type { HeadlessCommand, HeadlessRunOptions } from './commands'; +import { goalExitCode } from '../goal-prompt'; +import { + readHeadlessControlRequest, + waitForHeadlessControlApplied, + writeHeadlessControlRequest, +} from './control'; +import { formatHeadlessMetadataHeader } from './output'; +import { + preflightHeadlessOutputDir, + resolveHeadlessOutputDir, + writeHeadlessGoalStatusFile, + writeHeadlessResponseFile, +} from './output-files'; +import { + preflightHeadlessStatusFile, + readHeadlessRunStatus, + type HeadlessApprovalStatus, + type HeadlessGoalStatus, + type HeadlessRunFiles, + type HeadlessRunState, + type HeadlessRunStatus, + type HeadlessRunSummary, + writeHeadlessRunStatus, +} from './status-file'; +import { createKimiCodeHostIdentity } from '../version'; +import { createHeadlessApprovalHandler, getUnusedPlanFlagWarning } from './approval'; + +interface HeadlessOutput { + write(chunk: string): boolean; +} + +interface HeadlessRunIO { + readonly stdout?: HeadlessOutput; + readonly createHarness?: (options: { + readonly identity: ReturnType; + readonly uiMode: string; + readonly skillDirs: readonly string[]; + }) => HeadlessHarness; + readonly acquireSessionRunLock?: (input: { + readonly sessionDir: string; + readonly runId: string; + readonly pid: number; + readonly command: string; + }) => Promise; + readonly processSignals?: HeadlessProcessSignals; +} + +type HeadlessSignalName = 'SIGINT' | 'SIGTERM'; + +interface HeadlessProcessSignals { + once(signal: HeadlessSignalName, listener: () => void): void; + off(signal: HeadlessSignalName, listener: () => void): void; + exit(code: number): never; +} + +interface HeadlessHarness { + ensureConfigFile(): Promise; + getConfig(): Promise<{ readonly defaultModel?: string }>; + createSession(input: { + readonly workDir: string; + readonly model: string; + readonly permission: 'manual'; + }): Promise; + resumeSession(input: { readonly id: string }): Promise; + listSessions(input?: { + readonly workDir?: string; + readonly sessionId?: string; + }): Promise; + close(): Promise; +} + +interface HeadlessSessionSummary { + readonly id: string; + readonly workDir: string; + readonly sessionDir: string; +} + +interface HeadlessSession { + readonly id: string; + readonly workDir: string; + readonly summary?: HeadlessSessionSummary; + onEvent(listener: (event: Event) => void): () => void; + prompt(input: string): Promise; + cancel(): Promise; + createGoal(input: { readonly objective: string; readonly replace: boolean }): Promise; + getGoal(): Promise<{ readonly goal: GoalSnapshotLike | null }>; + pauseGoal(input?: { readonly reason?: string }): Promise; + cancelGoal(input?: { readonly reason?: string }): Promise; + setApprovalHandler(handler: unknown): void; + setQuestionHandler(handler: unknown): void; + getStatus(): Promise<{ readonly permission: 'yolo' | 'manual' | 'auto'; readonly model?: string }>; + setPermission(mode: 'auto' | 'manual' | 'yolo'): Promise; + setModel(model: string): Promise; +} + +interface HeadlessSessionRunLock { + readonly sessionDir: string; + readonly runId: string; + release(): Promise; +} + +interface RunContext { + readonly runId: string; + readonly startedAtMs: number; + readonly startedAt: string; + readonly pid: number; + readonly workDir: string; + readonly model: string | null; + readonly statusFile?: string; + readonly outputDir?: string; + readonly metadataOnly: boolean; + readonly goalMode: boolean; + readonly summary: MutableHeadlessRunSummary; + status: HeadlessRunStatus; + files: HeadlessRunFiles; + assistantMarkdown: string; + currentTurnMarkdown: string; + turnResponses: Array<{ readonly turnId: number | null; readonly markdown: string }>; + goalTerminal: boolean; + planApprovalSeen: boolean; + statusWriteQueue: Promise; + statusWriteError: Error | null; +} + +interface GoalSnapshotLike { + readonly goalId?: string; + readonly status?: string; + readonly terminalReason?: string; + readonly turnsUsed?: number; + readonly tokensUsed?: number; + readonly wallClockMs?: number; +} + +type MutableHeadlessRunSummary = { + -readonly [Key in keyof HeadlessRunSummary]: HeadlessRunSummary[Key]; +}; + +export async function runHeadless( + command: HeadlessCommand, + version: string, + io: HeadlessRunIO = {}, +): Promise { + const stdout = io.stdout ?? process.stdout; + + switch (command.kind) { + case 'status': + await runHeadlessStatus(command.options, stdout); + return; + case 'goal-control': + await runHeadlessGoalControl(command.options, stdout); + return; + case 'run': + await runHeadlessRun(command.options, version, io, stdout); + return; + } +} + +async function runHeadlessRun( + options: HeadlessRunOptions, + version: string, + io: HeadlessRunIO, + stdout: HeadlessOutput, +): Promise { + const runId = `run_${randomUUID()}`; + const startedAtMs = Date.now(); + const startedAt = new Date(startedAtMs).toISOString(); + let workDir = path.resolve(options.cwd ?? process.cwd()); + const prompt = options.prompt ?? options.goal ?? options.replaceGoal; + if (prompt === undefined) throw new Error('Specify a prompt or goal for headless run.'); + const goalMode = options.goal !== undefined || options.replaceGoal !== undefined; + + if (options.statusFile !== undefined) { + await preflightHeadlessStatusFile(options.statusFile); + } + const outputDir = + options.outputDir === undefined && !goalMode + ? undefined + : resolveHeadlessOutputDir({ + explicitOutputDir: options.outputDir, + statusFile: options.statusFile, + runId, + }); + if (outputDir !== undefined) { + await preflightHeadlessOutputDir(outputDir); + } + + const createHarness = io.createHarness ?? ((input) => createKimiHarness(input)); + const acquireSessionRunLock = io.acquireSessionRunLock ?? acquireSdkSessionRunLock; + const harness = createHarness({ + identity: createKimiCodeHostIdentity(version), + uiMode: 'headless', + skillDirs: options.skillsDirs, + }); + let lock: HeadlessSessionRunLock | undefined; + let session: HeadlessSession | undefined; + let context: RunContext | undefined; + let lockReleased = false; + let harnessClosed = false; + const releaseLock = async (): Promise => { + if (lockReleased) return; + lockReleased = true; + await lock?.release(); + }; + const closeHarness = async (): Promise => { + if (harnessClosed) return; + harnessClosed = true; + await harness.close(); + }; + const removeSignalHandlers = installHeadlessSignalHandlers({ + signals: io.processSignals ?? defaultProcessSignals, + getContext: () => context, + getSession: () => session, + releaseLock, + closeHarness, + }); + + try { + await harness.ensureConfigFile(); + const config = await harness.getConfig(); + const resolved = await resolveHeadlessSession(harness, options, workDir, config.defaultModel); + workDir = resolved.workDir; + lock = await acquireSessionRunLock({ + sessionDir: resolved.sessionDir, + runId, + pid: process.pid, + command: 'headless run', + }); + session = resolved.session; + context = createRunContext({ + runId, + startedAtMs, + startedAt, + workDir, + model: resolved.model, + statusFile: options.statusFile, + outputDir, + metadataOnly: options.metadataOnly, + goalMode, + sessionId: session.id, + }); + installHeadlessRunHandlers(session, options, context); + if (goalMode) { + await session.createGoal({ + objective: prompt, + replace: options.replaceGoal !== undefined, + }); + context.status = { + ...context.status, + control: { + path: path.join(context.outputDir!, 'control.json'), + supportedActions: ['pause_goal', 'cancel_goal', 'interrupt'], + lastRequest: null, + lastApplied: null, + }, + }; + } + await writeRunStatus(context, 'starting'); + await runHeadlessPromptTurn(session, prompt, context); + recordUnusedPlanFlagWarning(context, options); + await writeCurrentRunStatus(context); + await finalizeHeadlessRun(context, stdout); + } finally { + removeSignalHandlers(); + await releaseLock(); + await closeHarness(); + } +} + +const defaultProcessSignals: HeadlessProcessSignals = { + once: (signal, listener) => { + process.once(signal, listener); + }, + off: (signal, listener) => { + process.off(signal, listener); + }, + exit: (code) => process.exit(code), +}; + +function installHeadlessSignalHandlers(input: { + readonly signals: HeadlessProcessSignals; + readonly getContext: () => RunContext | undefined; + readonly getSession: () => HeadlessSession | undefined; + readonly releaseLock: () => Promise; + readonly closeHarness: () => Promise; +}): () => void { + const sigintListener = () => { + void handleHeadlessSignal('SIGINT', input); + }; + const sigtermListener = () => { + void handleHeadlessSignal('SIGTERM', input); + }; + input.signals.once('SIGINT', sigintListener); + input.signals.once('SIGTERM', sigtermListener); + return () => { + input.signals.off('SIGINT', sigintListener); + input.signals.off('SIGTERM', sigtermListener); + }; +} + +async function handleHeadlessSignal( + signal: HeadlessSignalName, + input: { + readonly signals: HeadlessProcessSignals; + readonly getContext: () => RunContext | undefined; + readonly getSession: () => HeadlessSession | undefined; + readonly releaseLock: () => Promise; + readonly closeHarness: () => Promise; + }, +): Promise { + const context = input.getContext(); + const session = input.getSession(); + await session?.cancel().catch(() => {}); + if (context !== undefined) { + updateRunStatus(context, 'cancelled', `signal.${signal.toLowerCase()}`, { + error: new Error(`${signal} received`), + }); + await writeCurrentRunStatus(context).catch(() => {}); + } + await input.releaseLock().catch(() => {}); + await input.closeHarness().catch(() => {}); + input.signals.exit(signal === 'SIGINT' ? 130 : 143); +} + +function recordUnusedPlanFlagWarning(context: RunContext, options: HeadlessRunOptions): void { + const warning = getUnusedPlanFlagWarning({ + approvePlan: options.approvePlan, + rejectPlan: options.rejectPlan, + planApprovalSeen: context.planApprovalSeen, + }); + if (warning === null) return; + context.status = { + ...context.status, + warnings: [...context.status.warnings, warning], + }; +} + +async function resolveHeadlessSession( + harness: HeadlessHarness, + options: HeadlessRunOptions, + initialWorkDir: string, + defaultModel: string | undefined, +): Promise<{ + readonly session: HeadlessSession; + readonly sessionDir: string; + readonly workDir: string; + readonly model: string; +}> { + if (options.session !== undefined) { + const sessions = await harness.listSessions({ sessionId: options.session }); + const target = sessions[0]; + if (target === undefined) throw new Error(`Session "${options.session}" not found.`); + if (options.cwd !== undefined && target.workDir !== initialWorkDir) { + throw new Error(`Session "${options.session}" was created under a different directory.`); + } + const session = await harness.resumeSession({ id: options.session }); + const status = await session.getStatus(); + if (options.model !== undefined) await session.setModel(options.model); + return { + session, + sessionDir: target.sessionDir, + workDir: options.cwd === undefined ? target.workDir : initialWorkDir, + model: requireConfiguredModel(options.model, status.model, defaultModel), + }; + } + + if (options.continue) { + const sessions = await harness.listSessions({ workDir: initialWorkDir }); + const previous = sessions[0]; + if (previous !== undefined) { + const session = await harness.resumeSession({ id: previous.id }); + const status = await session.getStatus(); + if (options.model !== undefined) await session.setModel(options.model); + return { + session, + sessionDir: previous.sessionDir, + workDir: initialWorkDir, + model: requireConfiguredModel(options.model, status.model, defaultModel), + }; + } + } + + const model = requireConfiguredModel(options.model, defaultModel); + const session = await harness.createSession({ + workDir: initialWorkDir, + model, + permission: 'manual', + }); + const sessionDir = session.summary?.sessionDir; + if (sessionDir === undefined) { + throw new Error(`Session "${session.id}" did not report a session directory.`); + } + return { session, sessionDir, workDir: initialWorkDir, model }; +} + +function installHeadlessRunHandlers( + session: HeadlessSession, + options: HeadlessRunOptions, + context: RunContext, +): void { + session.setApprovalHandler( + createHeadlessApprovalHandler({ + approvePlan: options.approvePlan, + rejectPlan: options.rejectPlan, + onPlanApprovalRequired: (approval) => { + context.planApprovalSeen = true; + context.status = { ...context.status, approval }; + updateRunStatus(context, 'approval_required', 'approval.required'); + }, + }), + ); + session.setQuestionHandler(() => null); +} + +function createRunContext(input: { + readonly runId: string; + readonly startedAtMs: number; + readonly startedAt: string; + readonly workDir: string; + readonly model: string; + readonly statusFile?: string; + readonly outputDir?: string; + readonly metadataOnly: boolean; + readonly goalMode: boolean; + readonly sessionId: string; +}): RunContext { + const summary = emptySummary(); + const files: HeadlessRunFiles = { + outputDir: input.outputDir ?? null, + responses: [], + finalResponse: null, + goalStatus: null, + }; + return { + runId: input.runId, + startedAtMs: input.startedAtMs, + startedAt: input.startedAt, + pid: process.pid, + workDir: input.workDir, + model: input.model, + statusFile: input.statusFile, + outputDir: input.outputDir, + metadataOnly: input.metadataOnly, + goalMode: input.goalMode, + summary, + files, + assistantMarkdown: '', + currentTurnMarkdown: '', + turnResponses: [], + goalTerminal: false, + planApprovalSeen: false, + statusWriteQueue: Promise.resolve(), + statusWriteError: null, + status: { + schemaVersion: 1, + runId: input.runId, + pid: process.pid, + sessionId: input.sessionId, + turnId: null, + state: 'starting', + workDir: input.workDir, + model: input.model, + startedAt: input.startedAt, + updatedAt: input.startedAt, + elapsedMs: 0, + lastEvent: null, + activeTool: null, + summary, + approval: null, + goal: null, + warnings: [], + files, + control: null, + error: null, + resumeCommand: `kimi -r ${input.sessionId}`, + }, + }; +} + +function emptySummary(): MutableHeadlessRunSummary { + return { + turnStepCount: 0, + toolCallCount: 0, + completedToolCallCount: 0, + failedToolCallCount: 0, + assistantCharCount: 0, + thinkingCharCount: 0, + }; +} + +async function runHeadlessPromptTurn( + session: HeadlessSession, + prompt: string, + context: RunContext, +): Promise { + let activeTurnId: number | null = null; + let settled = false; + let unsubscribe: (() => void) | undefined; + let controlTimer: NodeJS.Timeout | undefined; + let pendingControl = Promise.resolve(); + const appliedControls = new Set(); + + await new Promise((resolve, reject) => { + const finish = (error?: Error): void => { + if (settled) return; + settled = true; + if (controlTimer !== undefined) clearInterval(controlTimer); + unsubscribe?.(); + if (error !== undefined) { + reject(error); + return; + } + resolve(); + }; + + if (context.status.control !== null) { + controlTimer = setInterval(() => { + pendingControl = pendingControl + .then(() => applyControlRequest(session, context, appliedControls, finish)) + .catch((error: unknown) => { + const normalized = error instanceof Error ? error : new Error(String(error)); + updateRunStatus(context, 'failed', 'control.failed', { error: normalized }); + finish(normalized); + }); + }, 25); + } + + unsubscribe = session.onEvent((event) => { + if (event.type === 'error' && event.agentId === 'main') { + const error = new Error(`${event.code}: ${event.message}`); + updateRunStatus(context, 'failed', event.type, { error }); + finish(error); + return; + } + if (event.agentId !== 'main') return; + if (event.type === 'goal.updated') { + handleGoalUpdated(context, event.snapshot as GoalSnapshotLike | null | undefined); + if (context.goalTerminal && activeTurnId === null) finish(); + return; + } + if (event.type === 'turn.started' && activeTurnId === null) { + activeTurnId = event.turnId; + } + if (!hasTurnId(event) || (activeTurnId !== null && event.turnId !== activeTurnId)) return; + handleRunEvent(context, event); + if (event.type === 'turn.ended') { + if (event.reason === 'completed') { + activeTurnId = null; + updateRunStatus(context, finalRunStateForContext(context), event.type); + if (!context.goalMode || context.goalTerminal) finish(); + return; + } + if (event.reason === 'cancelled' && context.status.state === 'interrupted') { + activeTurnId = null; + updateRunStatus(context, 'interrupted', event.type); + finish(); + return; + } + const error = new Error(formatTurnEndedFailure(event)); + updateRunStatus(context, 'failed', event.type, { error }); + finish(error); + } + }); + + session.prompt(prompt).catch((error: unknown) => { + const normalized = error instanceof Error ? error : new Error(String(error)); + updateRunStatus(context, 'failed', 'prompt.failed', { error: normalized }); + finish(normalized); + }); + }); + await pendingControl; +} + +async function applyControlRequest( + session: HeadlessSession, + context: RunContext, + appliedControls: Set, + finish: () => void, +): Promise { + const control = context.status.control; + if (control === null) return; + const request = await readHeadlessControlRequest(control.path); + if (request === null || request.runId !== context.runId || appliedControls.has(request.commandId)) { + return; + } + appliedControls.add(request.commandId); + context.status = { + ...context.status, + control: { ...control, lastRequest: request }, + }; + + try { + switch (request.action) { + case 'pause_goal': + await session.pauseGoal({ reason: 'headless control request' }); + break; + case 'cancel_goal': + await session.cancelGoal({ reason: 'headless control request' }); + break; + case 'interrupt': + context.status = { ...context.status, state: 'interrupted' }; + await session.pauseGoal({ reason: 'headless control request' }).catch(() => {}); + await session.cancel(); + break; + } + const nextControl = context.status.control; + context.status = { + ...context.status, + state: request.action === 'interrupt' ? 'interrupted' : context.status.state, + control: + nextControl === null + ? null + : { + ...nextControl, + lastApplied: { + commandId: request.commandId, + action: request.action, + appliedAt: new Date().toISOString(), + result: 'applied', + }, + }, + }; + updateRunStatus(context, context.status.state, 'control.applied'); + await writeCurrentRunStatus(context); + if (request.action === 'interrupt') finish(); + } catch (error) { + const nextControl = context.status.control; + context.status = { + ...context.status, + control: + nextControl === null + ? null + : { + ...nextControl, + lastApplied: { + commandId: request.commandId, + action: request.action, + appliedAt: new Date().toISOString(), + result: 'failed', + error: { message: error instanceof Error ? error.message : String(error) }, + }, + }, + }; + updateRunStatus(context, context.status.state, 'control.failed'); + await writeCurrentRunStatus(context); + } +} + +function handleGoalUpdated(context: RunContext, snapshot: GoalSnapshotLike | null | undefined): void { + if (snapshot === null || snapshot === undefined) return; + const goal = goalStatusFromSnapshot(snapshot); + context.status = { ...context.status, goal }; + context.goalTerminal = isTerminalGoalStatus(goal.status); + updateRunStatus(context, context.status.state, 'goal.updated'); +} + +function handleRunEvent(context: RunContext, event: Event & { readonly turnId: number }): void { + switch (event.type) { + case 'turn.started': + context.currentTurnMarkdown = ''; + updateRunStatus(context, 'running', event.type, { turnId: event.turnId }); + return; + case 'turn.step.started': + context.summary.turnStepCount += 1; + updateRunStatus(context, 'running', event.type); + return; + case 'assistant.delta': + context.assistantMarkdown += event.delta; + context.currentTurnMarkdown += event.delta; + context.summary.assistantCharCount += event.delta.length; + updateRunStatus(context, 'running', event.type, { write: false }); + return; + case 'thinking.delta': + context.summary.thinkingCharCount += event.delta.length; + updateRunStatus(context, 'running', event.type, { write: false }); + return; + case 'tool.call.started': + context.summary.toolCallCount += 1; + context.status = { + ...context.status, + activeTool: { + toolCallId: event.toolCallId, + name: event.name, + description: event.description, + }, + }; + updateRunStatus(context, 'running', event.type); + return; + case 'tool.result': + if (event.isError === true) { + context.summary.failedToolCallCount += 1; + } else { + context.summary.completedToolCallCount += 1; + } + if (context.status.activeTool?.toolCallId === event.toolCallId) { + context.status = { ...context.status, activeTool: null }; + } + updateRunStatus(context, 'running', event.type); + return; + case 'turn.ended': + if (event.reason === 'completed') { + context.turnResponses.push({ + turnId: event.turnId, + markdown: context.currentTurnMarkdown, + }); + } + return; + default: + updateRunStatus(context, context.status.state, event.type); + } +} + +function updateRunStatus( + context: RunContext, + state: HeadlessRunState, + lastEvent: string, + options: { readonly turnId?: number; readonly error?: Error; readonly write?: boolean } = {}, +): void { + const updatedAt = new Date().toISOString(); + context.status = { + ...context.status, + turnId: options.turnId ?? context.status.turnId, + state, + updatedAt, + elapsedMs: Date.now() - context.startedAtMs, + lastEvent, + summary: { ...context.summary }, + files: context.files, + error: options.error === undefined ? context.status.error : { message: options.error.message }, + }; + if (options.write !== false) scheduleCurrentRunStatus(context); +} + +async function writeRunStatus(context: RunContext, state: HeadlessRunState): Promise { + updateRunStatus(context, state, context.status.lastEvent ?? 'run.status'); + await writeCurrentRunStatus(context); +} + +async function writeCurrentRunStatus(context: RunContext): Promise { + if (context.statusFile === undefined) return; + scheduleCurrentRunStatus(context); + await flushScheduledRunStatusWrites(context); +} + +function scheduleCurrentRunStatus(context: RunContext): void { + if (context.statusFile === undefined) return; + const statusFile = context.statusFile; + const status = context.status; + context.statusWriteQueue = context.statusWriteQueue + .then(() => writeHeadlessRunStatus(statusFile, status)) + .catch((error: unknown) => { + context.statusWriteError = error instanceof Error ? error : new Error(String(error)); + }); +} + +async function flushScheduledRunStatusWrites(context: RunContext): Promise { + await context.statusWriteQueue; + if (context.statusWriteError !== null) throw context.statusWriteError; +} + +async function finalizeHeadlessRun( + context: RunContext, + stdout: HeadlessOutput, +): Promise { + if (context.outputDir !== undefined) { + const responses = context.turnResponses.length > 0 + ? context.turnResponses + : [{ turnId: context.status.turnId, markdown: context.assistantMarkdown }]; + const responseFiles = []; + for (const [index, response] of responses.entries()) { + responseFiles.push( + await writeHeadlessResponseFile({ + outputDir: context.outputDir, + turnIndex: index + 1, + turnId: response.turnId, + markdown: response.markdown, + updatedAt: context.status.updatedAt, + }), + ); + } + const goalStatus = + context.goalMode && context.status.goal !== null + ? await writeHeadlessGoalStatusFile({ + outputDir: context.outputDir, + goal: context.status.goal, + updatedAt: context.status.updatedAt, + }) + : null; + context.files = { + ...context.files, + responses: responseFiles, + finalResponse: responseFiles.at(-1) ?? null, + goalStatus, + }; + context.status = { ...context.status, files: context.files }; + await writeCurrentRunStatus(context); + } + + const responseFormat = context.outputDir !== undefined + ? 'files' + : context.metadataOnly + ? 'omitted' + : 'markdown'; + const metadata = { + type: 'headless.result', + schemaVersion: 1, + runId: context.runId, + sessionId: context.status.sessionId, + turnId: context.status.turnId, + state: context.status.state, + responseFormat, + responseOmitted: responseFormat !== 'markdown', + resumeCommand: context.status.resumeCommand, + summary: context.status.summary, + approval: context.status.approval, + goal: context.status.goal, + warnings: context.status.warnings, + files: context.files, + } satisfies Parameters[0]; + stdout.write(formatHeadlessMetadataHeader(metadata)); + if (responseFormat === 'markdown') stdout.write(context.assistantMarkdown); + applyHeadlessGoalExitCode(context); +} + +function applyHeadlessGoalExitCode(context: RunContext): void { + if (!context.goalMode) return; + const code = goalExitCode(context.status.goal?.status ?? undefined); + if (code !== 0) process.exitCode = code; +} + +function requireConfiguredModel(...models: readonly (string | undefined)[]): string { + const model = models.find((item) => item !== undefined && item.trim().length > 0); + if (model === undefined) { + throw new Error( + 'No model configured. Run `kimi` and use /login to sign in, then retry; or set default_model in config.toml.', + ); + } + return model; +} + +function goalStatusFromSnapshot(snapshot: GoalSnapshotLike): HeadlessGoalStatus { + return { + goalId: snapshot.goalId ?? null, + status: snapshot.status ?? null, + reason: snapshot.terminalReason ?? null, + turnsUsed: snapshot.turnsUsed ?? null, + tokensUsed: snapshot.tokensUsed ?? null, + wallClockMs: snapshot.wallClockMs ?? null, + }; +} + +function isTerminalGoalStatus(status: string | null): boolean { + return status === 'complete' || status === 'blocked' || status === 'paused' || status === 'cancelled'; +} + +function finalRunStateForContext(context: RunContext): HeadlessRunState { + if (!context.goalMode) return 'completed'; + switch (context.status.goal?.status) { + case 'complete': + return 'completed'; + case 'paused': + return 'paused'; + case 'cancelled': + return 'cancelled'; + case 'blocked': + return 'failed'; + default: + return 'running'; + } +} + +function hasTurnId(event: Event): event is Event & { readonly turnId: number } { + return 'turnId' in event; +} + +function formatTurnEndedFailure(event: Extract): string { + if (event.error !== undefined) return `${event.error.code}: ${event.error.message}`; + return `Headless turn ended with reason: ${event.reason}`; +} + +async function runHeadlessStatus( + options: Extract['options'], + stdout: HeadlessOutput, +): Promise { + const status = await readHeadlessRunStatus(options.file); + if (options.json) { + stdout.write(`${JSON.stringify(status)}\n`); + return; + } + stdout.write(formatHeadlessStatus(status)); +} + +async function runHeadlessGoalControl( + options: Extract['options'], + stdout: HeadlessOutput, +): Promise { + const status = await readHeadlessRunStatus(options.file); + const control = status.control; + if (control === null) { + throw new Error('Status file does not contain a control path.'); + } + if (!control.supportedActions.includes(options.action)) { + throw new Error(`Headless run does not support control action "${options.action}".`); + } + + const commandId = `cmd_${randomUUID()}`; + await writeHeadlessControlRequest(control.path, { + schemaVersion: 1, + runId: status.runId, + commandId, + action: options.action, + requestedAt: new Date().toISOString(), + }); + + if (options.wait) { + const applied = await waitForHeadlessControlApplied({ + statusFile: options.file, + commandId, + timeoutMs: 30_000, + }); + if (applied !== null) { + stdout.write(`control ${applied.result} - ${applied.action} - command ${commandId}\n`); + return; + } + } + + const written = await readHeadlessControlRequest(control.path); + stdout.write(`control pending - ${written?.action ?? options.action} - command ${commandId}\n`); +} + +function formatHeadlessStatus(status: HeadlessRunStatus): string { + const parts: string[] = [status.state]; + if (status.sessionId !== null) parts.push(`session ${status.sessionId}`); + if (status.turnId !== null) parts.push(`turn ${status.turnId}`); + if (status.summary.toolCallCount > 0) { + parts.push(`tools ${status.summary.completedToolCallCount}/${status.summary.toolCallCount}`); + } + if (status.activeTool !== null) parts.push(`tool ${status.activeTool.name}`); + if (status.approval !== null) { + parts.push( + `${approvalLabel(status.approval.decision)} - ${status.approval.kind} - ${status.approval.message}`, + ); + } + if (status.goal !== null) { + parts.push( + `goal ${status.goal.status ?? 'unknown'} - turns ${status.goal.turnsUsed ?? 0} - tokens ${status.goal.tokensUsed ?? 0}`, + ); + } + if (status.files.outputDir !== null) { + const completedResponses = status.files.responses.filter( + (response) => response.state === 'completed', + ).length; + parts.push(`files ${completedResponses} - output ${status.files.outputDir}`); + } + if (status.control?.lastRequest !== null && status.control?.lastRequest !== undefined) { + const request = status.control.lastRequest; + if (status.control.lastApplied?.commandId === request.commandId) { + parts.push( + `control ${status.control.lastApplied.result} - ${request.action} - command ${request.commandId}`, + ); + } else { + parts.push(`control pending - ${request.action} - command ${request.commandId}`); + } + } + parts.push(`updated ${status.updatedAt}`); + return `${parts.join(' - ')}\n`; +} + +function approvalLabel(decision: HeadlessApprovalStatus['decision']): string { + return decision === 'required' ? 'approval required' : `approval ${decision}`; +} diff --git a/apps/kimi-code/src/cli/headless/status-file.ts b/apps/kimi-code/src/cli/headless/status-file.ts new file mode 100644 index 000000000..d9fdfc83f --- /dev/null +++ b/apps/kimi-code/src/cli/headless/status-file.ts @@ -0,0 +1,155 @@ +import { access, readFile, stat } from 'node:fs/promises'; +import { constants } from 'node:fs'; +import path from 'node:path'; + +import { writeAtomicJsonFile } from './atomic-file'; + +export type HeadlessRunState = + | 'starting' + | 'running' + | 'approval_required' + | 'paused' + | 'completed' + | 'failed' + | 'cancelled' + | 'interrupted'; + +export interface HeadlessRunSummary { + readonly turnStepCount: number; + readonly toolCallCount: number; + readonly completedToolCallCount: number; + readonly failedToolCallCount: number; + readonly assistantCharCount: number; + readonly thinkingCharCount: number; +} + +export interface HeadlessActiveToolStatus { + readonly toolCallId: string; + readonly name: string; + readonly description?: string; +} + +export interface HeadlessApprovalStatus { + readonly kind: 'plan'; + readonly toolCallId?: string; + readonly decision: 'required' | 'approved' | 'rejected'; + readonly decidedByFlag: 'approve-plan' | 'reject-plan' | null; + readonly message: string; +} + +export interface HeadlessGoalStatus { + readonly goalId: string | null; + readonly status: string | null; + readonly reason: string | null; + readonly turnsUsed: number | null; + readonly tokensUsed: number | null; + readonly wallClockMs: number | null; +} + +export interface HeadlessWarning { + readonly code: string; + readonly message: string; +} + +export type HeadlessOutputFileState = 'writing' | 'completed' | 'failed'; + +export interface HeadlessResponseFile { + readonly turnIndex: number; + readonly turnId: number | null; + readonly path: string; + readonly state: HeadlessOutputFileState; + readonly bytes: number | null; + readonly updatedAt: string; +} + +export interface HeadlessSidecarFile { + readonly path: string; + readonly state: HeadlessOutputFileState; + readonly bytes: number | null; + readonly updatedAt: string; +} + +export interface HeadlessRunFiles { + readonly outputDir: string | null; + readonly responses: readonly HeadlessResponseFile[]; + readonly finalResponse: HeadlessResponseFile | null; + readonly goalStatus: HeadlessSidecarFile | null; +} + +export type HeadlessControlAction = 'pause_goal' | 'cancel_goal' | 'interrupt'; + +export interface HeadlessControlRequest { + readonly schemaVersion: 1; + readonly runId: string; + readonly commandId: string; + readonly action: HeadlessControlAction; + readonly requestedAt: string; +} + +export interface HeadlessAppliedControlRequest { + readonly commandId: string; + readonly action: HeadlessControlAction; + readonly appliedAt: string; + readonly result: 'applied' | 'failed'; + readonly error?: { readonly message: string }; +} + +export interface HeadlessRunControl { + readonly path: string; + readonly supportedActions: readonly HeadlessControlAction[]; + readonly lastRequest: HeadlessControlRequest | null; + readonly lastApplied: HeadlessAppliedControlRequest | null; +} + +export interface HeadlessRunStatus { + readonly schemaVersion: 1; + readonly runId: string; + readonly pid: number; + readonly sessionId: string | null; + readonly turnId: number | null; + readonly state: HeadlessRunState; + readonly workDir: string; + readonly model: string | null; + readonly startedAt: string; + readonly updatedAt: string; + readonly elapsedMs: number; + readonly lastEvent: string | null; + readonly activeTool: HeadlessActiveToolStatus | null; + readonly summary: HeadlessRunSummary; + readonly approval: HeadlessApprovalStatus | null; + readonly goal: HeadlessGoalStatus | null; + readonly warnings: readonly HeadlessWarning[]; + readonly files: HeadlessRunFiles; + readonly control: HeadlessRunControl | null; + readonly error: { readonly message: string } | null; + readonly resumeCommand: string | null; +} + +export async function preflightHeadlessStatusFile(filePath: string): Promise { + const parent = path.dirname(filePath); + let parentStat; + try { + parentStat = await stat(parent); + } catch { + throw new Error('Status file parent directory does not exist.'); + } + if (!parentStat.isDirectory()) { + throw new Error('Status file parent path is not a directory.'); + } + try { + await access(parent, constants.W_OK); + } catch { + throw new Error('Status file parent directory is not writable.'); + } +} + +export async function writeHeadlessRunStatus( + filePath: string, + status: HeadlessRunStatus, +): Promise { + await writeAtomicJsonFile(filePath, status); +} + +export async function readHeadlessRunStatus(filePath: string): Promise { + return JSON.parse(await readFile(filePath, 'utf8')) as HeadlessRunStatus; +} diff --git a/apps/kimi-code/src/main.ts b/apps/kimi-code/src/main.ts index e94472590..df1782c29 100644 --- a/apps/kimi-code/src/main.ts +++ b/apps/kimi-code/src/main.ts @@ -23,6 +23,7 @@ import { } from '@moonshot-ai/kimi-telemetry'; import { createProgram } from './cli/commands'; +import { runHeadless } from './cli/headless/run'; import type { CLIOptions } from './cli/options'; import { OptionConflictError, validateOptions } from './cli/options'; import { runPrompt } from './cli/run-prompt'; @@ -174,6 +175,14 @@ export function main(): void { process.exit(1); }); }, + (command) => { + void Promise.resolve(runHeadless(command, version)).catch(async (error: unknown) => { + await logStartupFailure('run headless command', error); + process.stderr.write(formatStartupError(error, { operation: 'run headless command' })); + process.stderr.write(`See log: ${resolveGlobalLogPath(resolveKimiHome())}\n`); + process.exit(1); + }); + }, ); program.parse(process.argv); diff --git a/apps/kimi-code/test/cli/headless.test.ts b/apps/kimi-code/test/cli/headless.test.ts new file mode 100644 index 000000000..1f7ca793f --- /dev/null +++ b/apps/kimi-code/test/cli/headless.test.ts @@ -0,0 +1,1894 @@ +import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createProgram } from '#/cli/commands'; +import { + createHeadlessApprovalHandler, + getUnusedPlanFlagWarning, +} from '#/cli/headless/approval'; +import type { HeadlessCommand } from '#/cli/headless/commands'; +import { + readHeadlessControlRequest, + writeHeadlessControlRequest, +} from '#/cli/headless/control'; +import { formatHeadlessMetadataHeader } from '#/cli/headless/output'; +import { + preflightHeadlessOutputDir, + resolveHeadlessOutputDir, + writeHeadlessGoalStatusFile, + writeHeadlessResponseFile, +} from '#/cli/headless/output-files'; +import { + preflightHeadlessStatusFile, + readHeadlessRunStatus, + type HeadlessRunStatus, + writeHeadlessRunStatus, +} from '#/cli/headless/status-file'; +import { runHeadless } from '#/cli/headless/run'; +import { GOAL_EXIT_CODES } from '#/cli/goal-prompt'; + +const tempDirs: string[] = []; + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +async function createTempDir(): Promise { + const dir = await mkdtemp(path.join(tmpdir(), 'kimi-headless-test-')); + tempDirs.push(dir); + return dir; +} + +function createStatus(overrides: Partial = {}): HeadlessRunStatus { + return { + schemaVersion: 1, + runId: 'run_test', + pid: 123, + sessionId: 'ses_test', + turnId: 1, + state: 'running', + workDir: '/repo', + model: 'kimi-code/k2.5', + startedAt: '2026-06-05T00:00:00.000Z', + updatedAt: '2026-06-05T00:00:01.000Z', + elapsedMs: 1000, + lastEvent: 'turn.started', + activeTool: null, + summary: { + turnStepCount: 1, + toolCallCount: 0, + completedToolCallCount: 0, + failedToolCallCount: 0, + assistantCharCount: 0, + thinkingCharCount: 0, + }, + approval: null, + goal: null, + warnings: [], + files: { + outputDir: null, + responses: [], + finalResponse: null, + goalStatus: null, + }, + control: null, + error: null, + resumeCommand: 'kimi -r ses_test', + ...overrides, + }; +} + +function outputWriter() { + let text = ''; + return { + write: vi.fn((chunk: string) => { + text += chunk; + return true; + }), + text: () => text, + }; +} + +async function waitForAssertion(assertion: () => void | Promise): Promise { + let lastError: unknown; + for (let attempt = 0; attempt < 40; attempt += 1) { + try { + await assertion(); + return; + } catch (error) { + lastError = error; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + } + throw lastError; +} + +function createFakeHeadlessRuntime() { + const eventHandlers = new Set<(event: Record) => void>(); + const session = { + id: 'ses_headless', + workDir: '/repo', + summary: { + id: 'ses_headless', + workDir: '/repo', + sessionDir: '/tmp/ses_headless', + }, + setModel: vi.fn(async () => {}), + setPermission: vi.fn(async () => {}), + setApprovalHandler: vi.fn(), + setQuestionHandler: vi.fn(), + getStatus: vi.fn(async () => ({ permission: 'manual' as const, model: 'saved-model' })), + createGoal: vi.fn(), + getGoal: vi.fn(), + pauseGoal: vi.fn(), + cancelGoal: vi.fn(), + cancel: vi.fn(async () => {}), + onEvent: vi.fn((handler: (event: any) => void) => { + eventHandlers.add(handler); + return () => { + eventHandlers.delete(handler); + }; + }), + prompt: vi.fn(async () => { + for (const handler of eventHandlers) { + handler({ + type: 'turn.started', + sessionId: 'ses_headless', + agentId: 'main', + turnId: 7, + origin: { kind: 'user' }, + }); + handler({ + type: 'turn.step.started', + sessionId: 'ses_headless', + agentId: 'main', + turnId: 7, + stepIndex: 1, + }); + handler({ + type: 'thinking.delta', + sessionId: 'ses_headless', + agentId: 'main', + turnId: 7, + delta: 'thinking', + }); + handler({ + type: 'assistant.delta', + sessionId: 'ses_headless', + agentId: 'main', + turnId: 7, + delta: '## Done\n', + }); + handler({ + type: 'tool.call.started', + sessionId: 'ses_headless', + agentId: 'main', + turnId: 7, + toolCallId: 'call_1', + name: 'functions.exec_command', + args: { cmd: 'pnpm test' }, + }); + handler({ + type: 'tool.result', + sessionId: 'ses_headless', + agentId: 'main', + turnId: 7, + toolCallId: 'call_1', + output: 'ok', + }); + handler({ + type: 'assistant.delta', + sessionId: 'ses_headless', + agentId: 'main', + turnId: 7, + delta: 'All set.\n', + }); + handler({ + type: 'turn.ended', + sessionId: 'ses_headless', + agentId: 'main', + turnId: 7, + reason: 'completed', + }); + } + }), + }; + const harness = { + ensureConfigFile: vi.fn(), + getConfig: vi.fn(async () => ({ providers: {}, defaultModel: 'k2', telemetry: true })), + createSession: vi.fn(async () => session), + resumeSession: vi.fn(async () => session), + listSessions: vi.fn(async () => [ + { + id: 'ses_headless', + workDir: '/repo', + sessionDir: '/tmp/ses_headless', + }, + ]), + close: vi.fn(), + }; + const releaseLock = vi.fn(async () => {}); + const acquireLock = vi.fn(async () => ({ + sessionDir: '/tmp/ses_headless', + runId: 'run_test', + release: releaseLock, + })); + + const emit = (event: Record) => { + for (const handler of eventHandlers) { + handler(event); + } + }; + + return { session, harness, acquireLock, releaseLock, emit }; +} + +function parseHeadless(argv: string[]): HeadlessCommand { + let captured: HeadlessCommand | undefined; + + const program = createProgram( + '0.1.0-test', + () => { + throw new Error('main action should not run'); + }, + () => {}, + () => {}, + () => {}, + (command) => { + captured = command; + }, + ); + + program.exitOverride(); + program.configureOutput({ + writeOut: () => {}, + writeErr: () => {}, + }); + + program.parse(['node', 'kimi', ...argv]); + + if (captured === undefined) { + throw new Error('Headless action handler was not called'); + } + return captured; +} + +function expectParseError(argv: string[], message: string): void { + let stderr = ''; + const program = createProgram( + '0.1.0-test', + () => { + throw new Error('main action should not run'); + }, + () => {}, + () => {}, + () => {}, + () => { + throw new Error('headless action should not run'); + }, + ); + + program.exitOverride(); + program.configureOutput({ + writeOut: () => {}, + writeErr: (value) => { + stderr += value; + }, + }); + + expect(() => program.parse(['node', 'kimi', ...argv])).toThrow(); + expect(stderr).toContain(message); +} + +function expectCommanderError(argv: string[], message: string): string { + let stderr = ''; + const program = createProgram( + '0.1.0-test', + () => { + throw new Error('main action should not run'); + }, + () => {}, + () => {}, + () => {}, + () => { + throw new Error('headless action should not run'); + }, + ); + + program.exitOverride(); + program.configureOutput({ + writeOut: () => {}, + writeErr: (value) => { + stderr += value; + }, + }); + + expect(() => program.parse(['node', 'kimi', ...argv])).toThrow(); + expect(stderr).toContain(message); + return stderr; +} + +function helpFor(argv: string[]): string { + let stdout = ''; + const program = createProgram( + '0.1.0-test', + () => { + throw new Error('main action should not run'); + }, + () => {}, + () => {}, + () => {}, + () => { + throw new Error('headless action should not run'); + }, + ); + + program.exitOverride(); + program.configureOutput({ + writeOut: (value) => { + stdout += value; + }, + writeErr: () => {}, + }); + + try { + program.parse(['node', 'kimi', ...argv, '--help']); + } catch (error) { + const code = (error as { code?: string }).code; + const message = error instanceof Error ? error.message : ''; + if (code !== 'commander.helpDisplayed' && !message.includes('process.exit unexpectedly called')) { + throw error; + } + } + + return stdout; +} + +describe('headless command parsing', () => { + it('parses headless run with a prompt', () => { + expect(parseHeadless(['headless', 'run', '--prompt', 'inspect'])).toEqual({ + kind: 'run', + options: { + prompt: 'inspect', + continue: false, + metadataOnly: false, + approvePlan: false, + rejectPlan: false, + skillsDirs: [], + }, + }); + }); + + it('parses headless run options', () => { + expect( + parseHeadless([ + 'headless', + 'run', + '--cwd', + '/repo', + '--session', + 'ses_123', + '--prompt', + 'inspect', + '--model', + 'kimi-code/k2.5', + '--status-file', + '/tmp/kimi-run/status.json', + '--output-dir', + '/tmp/kimi-run', + '--metadata-only', + '--approve-plan', + '--skills-dir', + '/skills/one', + '--skills-dir', + '/skills/two', + ]), + ).toEqual({ + kind: 'run', + options: { + prompt: 'inspect', + cwd: '/repo', + session: 'ses_123', + continue: false, + model: 'kimi-code/k2.5', + statusFile: '/tmp/kimi-run/status.json', + outputDir: '/tmp/kimi-run', + metadataOnly: true, + approvePlan: true, + rejectPlan: false, + skillsDirs: ['/skills/one', '/skills/two'], + }, + }); + }); + + it('parses the top-level goal shortcut', () => { + expect(parseHeadless(['headless', '--goal', 'raise coverage to 99.5%'])).toEqual({ + kind: 'run', + options: { + goal: 'raise coverage to 99.5%', + continue: false, + metadataOnly: false, + approvePlan: false, + rejectPlan: false, + skillsDirs: [], + }, + }); + }); + + it('parses goal and replace-goal run inputs', () => { + expect(parseHeadless(['headless', 'run', '--goal', 'raise coverage'])).toMatchObject({ + kind: 'run', + options: { goal: 'raise coverage' }, + }); + expect(parseHeadless(['headless', 'run', '--replace-goal', 'raise coverage'])).toMatchObject({ + kind: 'run', + options: { replaceGoal: 'raise coverage' }, + }); + }); + + it('rejects run without exactly one input source', () => { + expectParseError(['headless', 'run'], 'Specify exactly one of --prompt, --goal, or --replace-goal.'); + expectParseError( + ['headless', 'run', '--prompt', 'inspect', '--goal', 'raise coverage'], + 'Specify exactly one of --prompt, --goal, or --replace-goal.', + ); + expectParseError( + ['headless', 'run', '--goal', 'raise coverage', '--replace-goal', 'raise coverage'], + 'Specify exactly one of --prompt, --goal, or --replace-goal.', + ); + }); + + it('shows headless help when no input source is provided', () => { + const stderr = expectCommanderError( + ['headless'], + 'Specify exactly one of --prompt, --goal, or --replace-goal.', + ); + + expect(stderr).toContain('Usage: kimi headless'); + expect(stderr).toContain('Headless mode runs without the TUI.'); + expect(stderr).toContain('Examples with outcomes:'); + }); + + it('warns users not to poll status too aggressively', () => { + const help = helpFor(['headless']); + + expect(help).toContain('Usage heads-up:'); + expect(help).toContain('Do not poll the status file in a tight loop.'); + expect(help).toContain('Set a reasonable time limit.'); + expect(help).toContain('Stop waiting when the time limit expires or when the Kimi process exits.'); + }); + + it('explains headless commands, options, examples, and outcomes', () => { + const help = helpFor(['headless']); + + expect(help).toContain('Commands guide:'); + expect(help).toContain('run: start a headless prompt or goal run.'); + expect(help).toContain('status: read the status file written by a run.'); + expect(help).toContain('goal: send pause, cancel, or interrupt to a running goal.'); + expect(help).toContain('Options guide:'); + expect(help).toContain('--status-file: write live JSON status for polling.'); + expect(help).toContain('--output-dir: write Markdown responses to files.'); + expect(help).toContain('--metadata-only: print only the final JSON metadata line.'); + expect(help).toContain('--approve-plan: approve a plan review if one appears.'); + expect(help).toContain('--reject-plan: reject a plan review if one appears.'); + expect(help).toContain('What happens: Kimi starts a non-interactive session, runs one turn, then exits.'); + expect(help).toContain('Possible outcomes: completed or failed.'); + expect(help).toContain('What happens: Kimi creates a goal and continues across turns until the goal stops.'); + expect(help).toContain('Possible outcomes: completed, paused, failed, cancelled, or interrupted.'); + expect(help).toContain('What happens: Kimi prints a compact summary of a running or finished run.'); + expect(help).toContain('What happens: Kimi sends the request through the run control file.'); + }); + + it('rejects conflicting plan flags', () => { + expectParseError( + ['headless', 'run', '--prompt', 'inspect', '--approve-plan', '--reject-plan'], + 'Cannot combine --approve-plan with --reject-plan.', + ); + }); + + it('keeps prompt-mode output format unavailable in headless run', () => { + expectCommanderError( + ['headless', 'run', '--prompt', 'inspect', '--output-format=stream-json'], + "unknown option '--output-format=stream-json'", + ); + }); + + it('parses headless status', () => { + expect(parseHeadless(['headless', 'status', '--file', '/tmp/kimi-run/status.json'])).toEqual({ + kind: 'status', + options: { + file: '/tmp/kimi-run/status.json', + json: false, + }, + }); + + expect( + parseHeadless(['headless', 'status', '--file', '/tmp/kimi-run/status.json', '--json']), + ).toEqual({ + kind: 'status', + options: { + file: '/tmp/kimi-run/status.json', + json: true, + }, + }); + }); + + it('parses goal control commands', () => { + expect(parseHeadless(['headless', 'goal', 'pause', '--file', '/tmp/kimi-run/status.json'])).toEqual({ + kind: 'goal-control', + options: { + action: 'pause_goal', + file: '/tmp/kimi-run/status.json', + wait: false, + }, + }); + + expect( + parseHeadless([ + 'headless', + 'goal', + 'cancel', + '--file', + '/tmp/kimi-run/status.json', + '--wait', + ]), + ).toEqual({ + kind: 'goal-control', + options: { + action: 'cancel_goal', + file: '/tmp/kimi-run/status.json', + wait: true, + }, + }); + + expect( + parseHeadless(['headless', 'goal', 'interrupt', '--file', '/tmp/kimi-run/status.json']), + ).toEqual({ + kind: 'goal-control', + options: { + action: 'interrupt', + file: '/tmp/kimi-run/status.json', + wait: false, + }, + }); + }); + + it('explains graceful goal pause and immediate interrupt in help', () => { + expect(helpFor(['headless', 'goal', 'pause'])).toContain( + 'Let the current turn finish, then pause the goal.', + ); + expect(helpFor(['headless', 'goal', 'cancel'])).toContain( + 'Let the current turn finish, then cancel the goal.', + ); + expect(helpFor(['headless', 'goal', 'interrupt'])).toContain( + 'Stop the active turn now and leave the goal paused when possible.', + ); + }); + + it('explains subcommand help with usage details', () => { + const runHelp = helpFor(['headless', 'run']); + const statusHelp = helpFor(['headless', 'status']); + const goalHelp = helpFor(['headless', 'goal']); + + expect(runHelp).toContain('What happens: Kimi starts a non-interactive run, then exits.'); + expect(runHelp).toContain('Possible outcomes: completed, paused, failed, cancelled, or interrupted.'); + expect(statusHelp).toContain('What happens: Kimi reads the status JSON and prints the current state.'); + expect(statusHelp).toContain('Use --json when another program needs the complete status object.'); + expect(goalHelp).toContain('What happens: Kimi writes a control request for the running goal.'); + expect(goalHelp).toContain('Use --wait to wait until the running process applies the request or exits.'); + }); +}); + +describe('headless status files', () => { + it('writes and reads status files atomically', async () => { + const dir = await createTempDir(); + const file = path.join(dir, 'status.json'); + const status = createStatus({ + goal: { + goalId: 'goal_123', + status: 'active', + reason: null, + turnsUsed: 1, + tokensUsed: 100, + wallClockMs: 5000, + }, + warnings: [{ code: 'PLAN_FLAG_UNUSED', message: '--approve-plan was unused.' }], + }); + + await writeHeadlessRunStatus(file, status); + + await expect(readHeadlessRunStatus(file)).resolves.toEqual(status); + await expect(stat(`${file}.tmp`)).rejects.toThrow(); + }); + + it('recreates the status file parent if it is deleted during a run', async () => { + const dir = await createTempDir(); + const file = path.join(dir, 'status.json'); + const status = createStatus(); + + await preflightHeadlessStatusFile(file); + await rm(dir, { recursive: true, force: true }); + await writeHeadlessRunStatus(file, status); + + await expect(readHeadlessRunStatus(file)).resolves.toEqual(status); + }); + + it('preflights status files before a run starts', async () => { + const dir = await createTempDir(); + await expect(preflightHeadlessStatusFile(path.join(dir, 'status.json'))).resolves.toBeUndefined(); + await expect( + preflightHeadlessStatusFile(path.join(dir, 'missing', 'status.json')), + ).rejects.toThrow('Status file parent directory does not exist.'); + }); +}); + +describe('headless output formatting', () => { + it('formats metadata headers without embedding Markdown', () => { + const formatted = formatHeadlessMetadataHeader({ + type: 'headless.result', + schemaVersion: 1, + runId: 'run_test', + sessionId: 'ses_test', + turnId: 1, + state: 'completed', + responseFormat: 'markdown', + responseOmitted: false, + resumeCommand: 'kimi -r ses_test', + summary: createStatus().summary, + approval: null, + goal: null, + warnings: [], + files: createStatus().files, + }); + + expect(formatted).toBe(`${formatted.trim()}\n\n`); + const parsed = JSON.parse(formatted.split('\n')[0]!); + expect(parsed).toMatchObject({ + type: 'headless.result', + responseFormat: 'markdown', + responseOmitted: false, + }); + expect(formatted).not.toContain('assistant Markdown'); + }); + + it('formats metadata-only headers as a single line', () => { + const formatted = formatHeadlessMetadataHeader({ + type: 'headless.result', + schemaVersion: 1, + runId: 'run_test', + sessionId: null, + turnId: null, + state: 'completed', + responseFormat: 'files', + responseOmitted: true, + resumeCommand: null, + summary: createStatus().summary, + approval: null, + goal: null, + warnings: [], + files: { + outputDir: '/tmp/kimi-run', + responses: [], + finalResponse: null, + goalStatus: null, + }, + }); + + expect(formatted).toBe(`${formatted.trim()}\n`); + expect(JSON.parse(formatted)).toMatchObject({ + responseFormat: 'files', + responseOmitted: true, + }); + }); +}); + +describe('headless output files', () => { + it('resolves output directories from explicit, status, and temp inputs', () => { + expect( + resolveHeadlessOutputDir({ explicitOutputDir: '/tmp/kimi-run', runId: 'run_test' }), + ).toBe('/tmp/kimi-run'); + expect( + resolveHeadlessOutputDir({ + statusFile: '/tmp/kimi-run/status.json', + runId: 'run_test', + }), + ).toBe('/tmp/kimi-run/status.json.d'); + expect(resolveHeadlessOutputDir({ runId: 'run_test' })).toContain('run_test'); + }); + + it('writes response and goal status files atomically', async () => { + const outputDir = await createTempDir(); + + await preflightHeadlessOutputDir(outputDir); + const responseFile = await writeHeadlessResponseFile({ + outputDir, + turnIndex: 1, + turnId: 7, + markdown: 'model markdown\n', + updatedAt: '2026-06-05T00:00:01.000Z', + }); + const goalFile = await writeHeadlessGoalStatusFile({ + outputDir, + goal: { + goalId: 'goal_123', + status: 'complete', + reason: 'done', + turnsUsed: 1, + tokensUsed: 100, + wallClockMs: 5000, + }, + updatedAt: '2026-06-05T00:00:02.000Z', + }); + + expect(responseFile).toMatchObject({ + turnIndex: 1, + turnId: 7, + path: path.join(outputDir, 'turns', 'turn-0001.md'), + state: 'completed', + bytes: 15, + }); + await expect(readFile(responseFile.path, 'utf8')).resolves.toBe('model markdown\n'); + await expect(stat(`${responseFile.path}.tmp`)).rejects.toThrow(); + expect(goalFile).toMatchObject({ + path: path.join(outputDir, 'goal-status.json'), + state: 'completed', + }); + await expect(readFile(goalFile.path, 'utf8')).resolves.toContain('"goalId": "goal_123"'); + }); + + it('recreates the output directory if it is deleted during a run', async () => { + const outputDir = await createTempDir(); + + await preflightHeadlessOutputDir(outputDir); + await rm(outputDir, { recursive: true, force: true }); + const goalFile = await writeHeadlessGoalStatusFile({ + outputDir, + goal: { + goalId: 'goal_123', + status: 'complete', + reason: 'done', + turnsUsed: 1, + tokensUsed: 100, + wallClockMs: 5000, + }, + updatedAt: '2026-06-05T00:00:02.000Z', + }); + + expect(goalFile).toMatchObject({ + path: path.join(outputDir, 'goal-status.json'), + state: 'completed', + }); + await expect(readFile(goalFile.path, 'utf8')).resolves.toContain('"goalId": "goal_123"'); + }); + + it('rejects output paths that are not directories', async () => { + const dir = await createTempDir(); + const file = path.join(dir, 'not-a-directory'); + await writeFile(file, 'x'); + + await expect(preflightHeadlessOutputDir(file)).rejects.toThrow( + 'Output path exists and is not a directory.', + ); + }); +}); + +describe('headless control files', () => { + it('writes and reads control requests atomically', async () => { + const dir = await createTempDir(); + const file = path.join(dir, 'control.json'); + const request = { + schemaVersion: 1 as const, + runId: 'run_test', + commandId: 'cmd_001', + action: 'pause_goal' as const, + requestedAt: '2026-06-05T00:00:01.000Z', + }; + + await writeHeadlessControlRequest(file, request); + + await expect(readHeadlessControlRequest(file)).resolves.toEqual(request); + await expect(stat(`${file}.tmp`)).rejects.toThrow(); + }); + + it('returns null for a missing control file', async () => { + const dir = await createTempDir(); + + await expect(readHeadlessControlRequest(path.join(dir, 'control.json'))).resolves.toBeNull(); + }); +}); + +describe('headless approval warnings', () => { + const planApprovalRequest = { + toolCallId: 'call_plan', + toolName: 'ExitPlanMode', + action: 'ExitPlanMode', + display: { + kind: 'plan_review' as const, + plan: 'Do the work.', + options: [{ label: 'Option A', description: 'Use option A.' }], + }, + }; + + it('approves or rejects plan approval requests from explicit flags', async () => { + const approvedSeen: unknown[] = []; + const approveHandler = createHeadlessApprovalHandler({ + approvePlan: true, + rejectPlan: false, + onPlanApprovalRequired: (approval) => approvedSeen.push(approval), + }); + expect(await approveHandler(planApprovalRequest)).toEqual({ + decision: 'approved', + selectedLabel: 'Option A', + }); + expect(approvedSeen).toEqual([ + expect.objectContaining({ + decision: 'approved', + decidedByFlag: 'approve-plan', + }), + ]); + + const rejectHandler = createHeadlessApprovalHandler({ + approvePlan: false, + rejectPlan: true, + onPlanApprovalRequired: () => {}, + }); + expect(await rejectHandler(planApprovalRequest)).toMatchObject({ + decision: 'rejected', + selectedLabel: 'Reject and Exit', + }); + }); + + it('cancels plan approval requests without an explicit flag', async () => { + const handler = createHeadlessApprovalHandler({ + approvePlan: false, + rejectPlan: false, + onPlanApprovalRequired: () => {}, + }); + + expect(handler(planApprovalRequest)).toMatchObject({ + decision: 'cancelled', + }); + }); + + it('records unused plan flags as non-fatal warnings', () => { + expect( + getUnusedPlanFlagWarning({ + approvePlan: true, + rejectPlan: false, + planApprovalSeen: false, + }), + ).toEqual({ + code: 'PLAN_FLAG_UNUSED', + message: '--approve-plan was set, but no plan approval was requested.', + }); + expect( + getUnusedPlanFlagWarning({ + approvePlan: false, + rejectPlan: true, + planApprovalSeen: false, + }), + ).toEqual({ + code: 'PLAN_FLAG_UNUSED', + message: '--reject-plan was set, but no plan approval was requested.', + }); + expect( + getUnusedPlanFlagWarning({ + approvePlan: true, + rejectPlan: false, + planApprovalSeen: true, + }), + ).toBeNull(); + }); +}); + +describe('runHeadless status command', () => { + it('prints a compact human status summary', async () => { + const dir = await createTempDir(); + const file = path.join(dir, 'status.json'); + await writeHeadlessRunStatus( + file, + createStatus({ + sessionId: 'ses_123', + turnId: 7, + updatedAt: '2026-06-05T00:00:05.000Z', + activeTool: { + toolCallId: 'call_123', + name: 'functions.exec_command', + description: 'Run tests', + }, + summary: { + turnStepCount: 2, + toolCallCount: 3, + completedToolCallCount: 2, + failedToolCallCount: 0, + assistantCharCount: 25, + thinkingCharCount: 10, + }, + }), + ); + const stdout = outputWriter(); + + await runHeadless({ kind: 'status', options: { file, json: false } }, '1.2.3-test', { + stdout, + }); + + expect(stdout.text()).toBe( + 'running - session ses_123 - turn 7 - tools 2/3 - tool functions.exec_command - updated 2026-06-05T00:00:05.000Z\n', + ); + }); + + it('prints raw status JSON', async () => { + const dir = await createTempDir(); + const file = path.join(dir, 'status.json'); + const status = createStatus({ state: 'completed' }); + await writeHeadlessRunStatus(file, status); + const stdout = outputWriter(); + + await runHeadless({ kind: 'status', options: { file, json: true } }, '1.2.3-test', { + stdout, + }); + + expect(JSON.parse(stdout.text())).toEqual(status); + }); + + it('includes approval, goal, file, and control details in human status', async () => { + const dir = await createTempDir(); + const file = path.join(dir, 'status.json'); + await writeHeadlessRunStatus( + file, + createStatus({ + state: 'approval_required', + approval: { + kind: 'plan', + decision: 'required', + decidedByFlag: null, + message: 'rerun with --approve-plan or --reject-plan', + }, + goal: { + goalId: 'goal_123', + status: 'complete', + reason: null, + turnsUsed: 3, + tokensUsed: 12000, + wallClockMs: 60000, + }, + files: { + outputDir: '/tmp/kimi-run', + responses: [ + { + turnIndex: 1, + turnId: 7, + path: '/tmp/kimi-run/turns/turn-0001.md', + state: 'completed', + bytes: 12, + updatedAt: '2026-06-05T00:00:05.000Z', + }, + ], + finalResponse: null, + goalStatus: null, + }, + control: { + path: path.join(dir, 'control.json'), + supportedActions: ['pause_goal', 'cancel_goal', 'interrupt'], + lastRequest: { + schemaVersion: 1, + runId: 'run_test', + commandId: 'cmd_001', + action: 'pause_goal', + requestedAt: '2026-06-05T00:00:02.000Z', + }, + lastApplied: null, + }, + }), + ); + const stdout = outputWriter(); + + await runHeadless({ kind: 'status', options: { file, json: false } }, '1.2.3-test', { + stdout, + }); + + expect(stdout.text()).toContain( + 'approval required - plan - rerun with --approve-plan or --reject-plan', + ); + expect(stdout.text()).toContain('goal complete - turns 3 - tokens 12000'); + expect(stdout.text()).toContain('files 1 - output /tmp/kimi-run'); + expect(stdout.text()).toContain('control pending - pause_goal - command cmd_001'); + }); +}); + +describe('runHeadless goal control command', () => { + it('writes a goal control request to the status control path', async () => { + const dir = await createTempDir(); + const statusFile = path.join(dir, 'status.json'); + const controlFile = path.join(dir, 'control.json'); + await writeHeadlessRunStatus( + statusFile, + createStatus({ + control: { + path: controlFile, + supportedActions: ['pause_goal', 'cancel_goal', 'interrupt'], + lastRequest: null, + lastApplied: null, + }, + }), + ); + const stdout = outputWriter(); + + await runHeadless( + { kind: 'goal-control', options: { action: 'pause_goal', file: statusFile, wait: false } }, + '1.2.3-test', + { stdout }, + ); + + const request = await readHeadlessControlRequest(controlFile); + expect(request).toMatchObject({ + schemaVersion: 1, + runId: 'run_test', + action: 'pause_goal', + commandId: expect.any(String), + requestedAt: expect.any(String), + }); + expect(stdout.text()).toMatch(/^control pending - pause_goal - command .+\n$/); + }); + + it('rejects goal control when the status file has no control path', async () => { + const dir = await createTempDir(); + const statusFile = path.join(dir, 'status.json'); + await writeHeadlessRunStatus(statusFile, createStatus()); + + await expect( + runHeadless( + { kind: 'goal-control', options: { action: 'pause_goal', file: statusFile, wait: false } }, + '1.2.3-test', + ), + ).rejects.toThrow('Status file does not contain a control path.'); + }); +}); + +describe('runHeadless prompt run command', () => { + it('runs one prompt turn and prints metadata plus Markdown by default', async () => { + const dir = await createTempDir(); + const statusFile = path.join(dir, 'status.json'); + const runtime = createFakeHeadlessRuntime(); + const stdout = outputWriter(); + + await runHeadless( + { + kind: 'run', + options: { + prompt: 'inspect', + cwd: '/repo', + continue: false, + statusFile, + metadataOnly: false, + approvePlan: false, + rejectPlan: false, + skillsDirs: [], + }, + }, + '1.2.3-test', + { + stdout, + createHarness: () => runtime.harness, + acquireSessionRunLock: runtime.acquireLock, + }, + ); + + expect(runtime.harness.createSession).toHaveBeenCalledWith({ + workDir: '/repo', + model: 'k2', + permission: 'manual', + }); + expect(runtime.acquireLock).toHaveBeenCalledWith({ + sessionDir: '/tmp/ses_headless', + runId: expect.any(String), + pid: process.pid, + command: 'headless run', + }); + expect(runtime.session.prompt).toHaveBeenCalledWith('inspect'); + expect(runtime.releaseLock).toHaveBeenCalledOnce(); + expect(runtime.harness.close).toHaveBeenCalledOnce(); + + const [metadataLine, markdown] = stdout.text().split('\n\n'); + expect(JSON.parse(metadataLine!)).toMatchObject({ + type: 'headless.result', + schemaVersion: 1, + sessionId: 'ses_headless', + turnId: 7, + state: 'completed', + responseFormat: 'markdown', + responseOmitted: false, + summary: { + turnStepCount: 1, + toolCallCount: 1, + completedToolCallCount: 1, + failedToolCallCount: 0, + assistantCharCount: 17, + thinkingCharCount: 8, + }, + }); + expect(markdown).toBe('## Done\nAll set.\n'); + await expect(readHeadlessRunStatus(statusFile)).resolves.toMatchObject({ + state: 'completed', + sessionId: 'ses_headless', + turnId: 7, + summary: { + toolCallCount: 1, + completedToolCallCount: 1, + }, + }); + }); + + it('updates the status file while a turn is running', async () => { + const dir = await createTempDir(); + const statusFile = path.join(dir, 'status.json'); + const runtime = createFakeHeadlessRuntime(); + runtime.session.prompt.mockImplementationOnce(async () => { + runtime.emit({ + type: 'turn.started', + sessionId: 'ses_headless', + agentId: 'main', + turnId: 7, + origin: { kind: 'user' }, + }); + runtime.emit({ + type: 'tool.call.started', + sessionId: 'ses_headless', + agentId: 'main', + turnId: 7, + toolCallId: 'call_1', + name: 'functions.exec_command', + description: 'Run tests', + args: { cmd: 'pnpm test' }, + }); + await waitForAssertion(async () => { + await expect(readHeadlessRunStatus(statusFile)).resolves.toMatchObject({ + state: 'running', + lastEvent: 'tool.call.started', + activeTool: { + toolCallId: 'call_1', + name: 'functions.exec_command', + description: 'Run tests', + }, + summary: { + toolCallCount: 1, + }, + }); + }); + runtime.emit({ + type: 'tool.result', + sessionId: 'ses_headless', + agentId: 'main', + turnId: 7, + toolCallId: 'call_1', + output: 'ok', + }); + runtime.emit({ + type: 'turn.ended', + sessionId: 'ses_headless', + agentId: 'main', + turnId: 7, + reason: 'completed', + }); + }); + + await runHeadless( + { + kind: 'run', + options: { + prompt: 'inspect', + cwd: '/repo', + continue: false, + statusFile, + metadataOnly: true, + approvePlan: false, + rejectPlan: false, + skillsDirs: [], + }, + }, + '1.2.3-test', + { + stdout: outputWriter(), + createHarness: () => runtime.harness, + acquireSessionRunLock: runtime.acquireLock, + }, + ); + }); + + it('omits Markdown when metadataOnly is set', async () => { + const runtime = createFakeHeadlessRuntime(); + const stdout = outputWriter(); + + await runHeadless( + { + kind: 'run', + options: { + prompt: 'inspect', + cwd: '/repo', + continue: false, + metadataOnly: true, + approvePlan: false, + rejectPlan: false, + skillsDirs: [], + }, + }, + '1.2.3-test', + { + stdout, + createHarness: () => runtime.harness, + acquireSessionRunLock: runtime.acquireLock, + }, + ); + + const output = JSON.parse(stdout.text()); + expect(output).toMatchObject({ + responseFormat: 'omitted', + responseOmitted: true, + }); + }); + + it('continues and records a warning when --approve-plan is unused', async () => { + const dir = await createTempDir(); + const statusFile = path.join(dir, 'status.json'); + const runtime = createFakeHeadlessRuntime(); + const stdout = outputWriter(); + + await runHeadless( + { + kind: 'run', + options: { + prompt: 'inspect', + cwd: '/repo', + continue: false, + statusFile, + metadataOnly: true, + approvePlan: true, + rejectPlan: false, + skillsDirs: [], + }, + }, + '1.2.3-test', + { + stdout, + createHarness: () => runtime.harness, + acquireSessionRunLock: runtime.acquireLock, + }, + ); + + const metadata = JSON.parse(stdout.text()); + expect(metadata.warnings).toEqual([ + { + code: 'PLAN_FLAG_UNUSED', + message: '--approve-plan was set, but no plan approval was requested.', + }, + ]); + await expect(readHeadlessRunStatus(statusFile)).resolves.toMatchObject({ + state: 'completed', + warnings: [ + { + code: 'PLAN_FLAG_UNUSED', + message: '--approve-plan was set, but no plan approval was requested.', + }, + ], + }); + }); + + it('uses manual permission for new sessions so plan flags can handle plan approval', async () => { + const runtime = createFakeHeadlessRuntime(); + + await runHeadless( + { + kind: 'run', + options: { + prompt: 'inspect', + cwd: '/repo', + continue: false, + metadataOnly: true, + approvePlan: false, + rejectPlan: true, + skillsDirs: [], + }, + }, + '1.2.3-test', + { + stdout: outputWriter(), + createHarness: () => runtime.harness, + acquireSessionRunLock: runtime.acquireLock, + }, + ); + + expect(runtime.harness.createSession).toHaveBeenCalledWith({ + workDir: '/repo', + model: 'k2', + permission: 'manual', + }); + expect(runtime.session.setApprovalHandler).toHaveBeenCalledOnce(); + }); + + it('writes Markdown to output files when outputDir is set', async () => { + const dir = await createTempDir(); + const runtime = createFakeHeadlessRuntime(); + const stdout = outputWriter(); + + await runHeadless( + { + kind: 'run', + options: { + prompt: 'inspect', + cwd: '/repo', + continue: false, + outputDir: dir, + metadataOnly: false, + approvePlan: false, + rejectPlan: false, + skillsDirs: [], + }, + }, + '1.2.3-test', + { + stdout, + createHarness: () => runtime.harness, + acquireSessionRunLock: runtime.acquireLock, + }, + ); + + const metadata = JSON.parse(stdout.text()); + expect(metadata).toMatchObject({ + responseFormat: 'files', + responseOmitted: true, + files: { + outputDir: dir, + responses: [ + { + turnIndex: 1, + turnId: 7, + state: 'completed', + bytes: 17, + }, + ], + }, + }); + const responsePath = metadata.files.responses[0].path as string; + await expect(readFile(responsePath, 'utf-8')).resolves.toBe('## Done\nAll set.\n'); + }); + + it('fails before resume when --session and --cwd mismatch the session workdir', async () => { + const runtime = createFakeHeadlessRuntime(); + runtime.harness.listSessions.mockResolvedValueOnce([ + { + id: 'ses_headless', + workDir: '/other', + sessionDir: '/tmp/ses_headless', + }, + ]); + + await expect( + runHeadless( + { + kind: 'run', + options: { + prompt: 'inspect', + cwd: '/repo', + session: 'ses_headless', + continue: false, + metadataOnly: false, + approvePlan: false, + rejectPlan: false, + skillsDirs: [], + }, + }, + '1.2.3-test', + { + createHarness: () => runtime.harness, + acquireSessionRunLock: runtime.acquireLock, + }, + ), + ).rejects.toThrow('Session "ses_headless" was created under a different directory.'); + + expect(runtime.harness.resumeSession).not.toHaveBeenCalled(); + expect(runtime.acquireLock).not.toHaveBeenCalled(); + }); + + it('runs goal mode with metadata-only stdout and one Markdown file per turn', async () => { + const dir = await createTempDir(); + const statusFile = path.join(dir, 'status.json'); + const outputDir = path.join(dir, 'out'); + const runtime = createFakeHeadlessRuntime(); + runtime.session.createGoal = vi.fn(async () => ({ + goalId: 'goal_123', + status: 'active', + objective: 'raise coverage', + terminalReason: undefined, + turnsUsed: 0, + tokensUsed: 0, + wallClockMs: 0, + budget: { + tokenBudget: null, + turnBudget: null, + wallClockBudgetMs: null, + tokenBudgetReached: false, + turnBudgetReached: false, + wallClockBudgetReached: false, + }, + })); + runtime.session.prompt.mockImplementationOnce(async () => { + runtime.emit({ + type: 'turn.started', + sessionId: 'ses_headless', + agentId: 'main', + turnId: 7, + origin: { kind: 'user' }, + }); + runtime.emit({ + type: 'assistant.delta', + sessionId: 'ses_headless', + agentId: 'main', + turnId: 7, + delta: 'Turn one.\n', + }); + runtime.emit({ + type: 'turn.ended', + sessionId: 'ses_headless', + agentId: 'main', + turnId: 7, + reason: 'completed', + }); + await waitForAssertion(async () => { + await expect(readHeadlessRunStatus(statusFile)).resolves.toMatchObject({ + state: 'running', + lastEvent: 'turn.ended', + turnId: 7, + }); + }); + runtime.emit({ + type: 'turn.started', + sessionId: 'ses_headless', + agentId: 'main', + turnId: 8, + origin: { kind: 'user' }, + }); + runtime.emit({ + type: 'assistant.delta', + sessionId: 'ses_headless', + agentId: 'main', + turnId: 8, + delta: 'Turn two.\n', + }); + runtime.emit({ + type: 'goal.updated', + sessionId: 'ses_headless', + agentId: 'main', + change: { kind: 'completion', status: 'complete' }, + snapshot: { + goalId: 'goal_123', + status: 'complete', + objective: 'raise coverage', + terminalReason: 'Objective achieved.', + turnsUsed: 2, + tokensUsed: 12000, + wallClockMs: 45000, + }, + }); + runtime.emit({ + type: 'turn.ended', + sessionId: 'ses_headless', + agentId: 'main', + turnId: 8, + reason: 'completed', + }); + }); + const stdout = outputWriter(); + + await runHeadless( + { + kind: 'run', + options: { + goal: 'raise coverage', + cwd: '/repo', + continue: false, + statusFile, + outputDir, + metadataOnly: false, + approvePlan: false, + rejectPlan: false, + skillsDirs: [], + }, + }, + '1.2.3-test', + { + stdout, + createHarness: () => runtime.harness, + acquireSessionRunLock: runtime.acquireLock, + }, + ); + + expect(runtime.session.createGoal).toHaveBeenCalledWith({ + objective: 'raise coverage', + replace: false, + }); + expect(runtime.session.prompt).toHaveBeenCalledWith('raise coverage'); + const metadata = JSON.parse(stdout.text()); + expect(metadata).toMatchObject({ + responseFormat: 'files', + responseOmitted: true, + goal: { + goalId: 'goal_123', + status: 'complete', + reason: 'Objective achieved.', + turnsUsed: 2, + tokensUsed: 12000, + wallClockMs: 45000, + }, + files: { + responses: [ + { turnIndex: 1, turnId: 7, state: 'completed', bytes: 10 }, + { turnIndex: 2, turnId: 8, state: 'completed', bytes: 10 }, + ], + }, + }); + const firstResponsePath = metadata.files.responses[0].path as string; + const secondResponsePath = metadata.files.responses[1].path as string; + await expect(readFile(firstResponsePath, 'utf-8')).resolves.toBe('Turn one.\n'); + await expect(readFile(secondResponsePath, 'utf-8')).resolves.toBe('Turn two.\n'); + await expect(readHeadlessRunStatus(statusFile)).resolves.toMatchObject({ + goal: { status: 'complete' }, + control: { + path: path.join(outputDir, 'control.json'), + supportedActions: ['pause_goal', 'cancel_goal', 'interrupt'], + }, + files: { + responses: [{ turnId: 7 }, { turnId: 8 }], + goalStatus: { + path: path.join(outputDir, 'goal-status.json'), + state: 'completed', + }, + }, + }); + }); + + it('sets a non-zero process exit code when a goal blocks', async () => { + const savedExitCode = process.exitCode; + process.exitCode = undefined; + const dir = await createTempDir(); + const outputDir = path.join(dir, 'out'); + const runtime = createFakeHeadlessRuntime(); + runtime.session.createGoal.mockResolvedValueOnce({}); + runtime.session.prompt.mockImplementationOnce(async () => { + runtime.emit({ + type: 'turn.started', + sessionId: 'ses_headless', + agentId: 'main', + turnId: 7, + origin: { kind: 'user' }, + }); + runtime.emit({ + type: 'assistant.delta', + sessionId: 'ses_headless', + agentId: 'main', + turnId: 7, + delta: 'Blocked.\n', + }); + runtime.emit({ + type: 'goal.updated', + sessionId: 'ses_headless', + agentId: 'main', + change: { kind: 'completion', status: 'blocked' }, + snapshot: { + goalId: 'goal_123', + status: 'blocked', + objective: 'raise coverage', + terminalReason: 'Need user input.', + turnsUsed: 1, + tokensUsed: 1200, + wallClockMs: 5000, + }, + }); + runtime.emit({ + type: 'turn.ended', + sessionId: 'ses_headless', + agentId: 'main', + turnId: 7, + reason: 'completed', + }); + }); + const stdout = outputWriter(); + + try { + await runHeadless( + { + kind: 'run', + options: { + goal: 'raise coverage', + cwd: '/repo', + continue: false, + outputDir, + metadataOnly: true, + approvePlan: false, + rejectPlan: false, + skillsDirs: [], + }, + }, + '1.2.3-test', + { + stdout, + createHarness: () => runtime.harness, + acquireSessionRunLock: runtime.acquireLock, + }, + ); + + expect(JSON.parse(stdout.text())).toMatchObject({ + state: 'failed', + goal: { + status: 'blocked', + reason: 'Need user input.', + }, + }); + expect(process.exitCode).toBe(GOAL_EXIT_CODES.blocked); + } finally { + process.exitCode = savedExitCode; + } + }); + + it('applies pause_goal without interrupting the active turn', async () => { + const dir = await createTempDir(); + const statusFile = path.join(dir, 'status.json'); + const outputDir = path.join(dir, 'out'); + const controlFile = path.join(outputDir, 'control.json'); + const runtime = createFakeHeadlessRuntime(); + runtime.session.createGoal.mockResolvedValueOnce({}); + runtime.session.pauseGoal.mockResolvedValueOnce({}); + runtime.session.prompt.mockImplementationOnce(async () => { + runtime.emit({ + type: 'turn.started', + sessionId: 'ses_headless', + agentId: 'main', + turnId: 7, + origin: { kind: 'user' }, + }); + const status = await readHeadlessRunStatus(statusFile); + await writeHeadlessControlRequest(controlFile, { + schemaVersion: 1, + runId: status.runId, + commandId: 'cmd_pause', + action: 'pause_goal', + requestedAt: '2026-06-05T00:00:05.000Z', + }); + await waitForAssertion(() => { + expect(runtime.session.pauseGoal).toHaveBeenCalledWith({ + reason: 'headless control request', + }); + }); + expect(runtime.session.cancel).not.toHaveBeenCalled(); + runtime.emit({ + type: 'assistant.delta', + sessionId: 'ses_headless', + agentId: 'main', + turnId: 7, + delta: 'Still finished.\n', + }); + runtime.emit({ + type: 'goal.updated', + sessionId: 'ses_headless', + agentId: 'main', + change: { kind: 'lifecycle', status: 'paused' }, + snapshot: { + goalId: 'goal_123', + status: 'paused', + objective: 'raise coverage', + terminalReason: 'headless control request', + turnsUsed: 1, + tokensUsed: 100, + wallClockMs: 1000, + }, + }); + runtime.emit({ + type: 'turn.ended', + sessionId: 'ses_headless', + agentId: 'main', + turnId: 7, + reason: 'completed', + }); + }); + + await runHeadless( + { + kind: 'run', + options: { + goal: 'raise coverage', + cwd: '/repo', + continue: false, + statusFile, + outputDir, + metadataOnly: false, + approvePlan: false, + rejectPlan: false, + skillsDirs: [], + }, + }, + '1.2.3-test', + { + stdout: outputWriter(), + createHarness: () => runtime.harness, + acquireSessionRunLock: runtime.acquireLock, + }, + ); + + await expect(readHeadlessRunStatus(statusFile)).resolves.toMatchObject({ + state: 'paused', + control: { + lastRequest: { commandId: 'cmd_pause', action: 'pause_goal' }, + lastApplied: { + commandId: 'cmd_pause', + action: 'pause_goal', + result: 'applied', + }, + }, + files: { + responses: [{ turnId: 7 }], + }, + }); + }); + + it('ignores stale control requests from previous runs', async () => { + const dir = await createTempDir(); + const statusFile = path.join(dir, 'status.json'); + const outputDir = path.join(dir, 'out'); + const controlFile = path.join(outputDir, 'control.json'); + await writeHeadlessControlRequest(controlFile, { + schemaVersion: 1, + runId: 'run_previous', + commandId: 'cmd_old_cancel', + action: 'cancel_goal', + requestedAt: '2026-06-05T00:00:05.000Z', + }); + const runtime = createFakeHeadlessRuntime(); + runtime.session.createGoal.mockResolvedValueOnce({}); + runtime.session.prompt.mockImplementationOnce(async () => { + runtime.emit({ + type: 'turn.started', + sessionId: 'ses_headless', + agentId: 'main', + turnId: 7, + origin: { kind: 'user' }, + }); + await new Promise((resolve) => setTimeout(resolve, 75)); + runtime.emit({ + type: 'goal.updated', + sessionId: 'ses_headless', + agentId: 'main', + change: { kind: 'completion', status: 'complete' }, + snapshot: { + goalId: 'goal_123', + status: 'complete', + objective: 'raise coverage', + terminalReason: 'Objective achieved.', + turnsUsed: 1, + tokensUsed: 100, + wallClockMs: 1000, + }, + }); + runtime.emit({ + type: 'turn.ended', + sessionId: 'ses_headless', + agentId: 'main', + turnId: 7, + reason: 'completed', + }); + }); + + await runHeadless( + { + kind: 'run', + options: { + goal: 'raise coverage', + cwd: '/repo', + continue: false, + statusFile, + outputDir, + metadataOnly: false, + approvePlan: false, + rejectPlan: false, + skillsDirs: [], + }, + }, + '1.2.3-test', + { + stdout: outputWriter(), + createHarness: () => runtime.harness, + acquireSessionRunLock: runtime.acquireLock, + }, + ); + + expect(runtime.session.cancelGoal).not.toHaveBeenCalled(); + await expect(readHeadlessRunStatus(statusFile)).resolves.toMatchObject({ + state: 'completed', + control: { + lastRequest: null, + lastApplied: null, + }, + }); + }); + + it('reports interrupt control as interrupted instead of failed', async () => { + const dir = await createTempDir(); + const statusFile = path.join(dir, 'status.json'); + const outputDir = path.join(dir, 'out'); + const controlFile = path.join(outputDir, 'control.json'); + const runtime = createFakeHeadlessRuntime(); + runtime.session.createGoal.mockResolvedValueOnce({}); + runtime.session.pauseGoal.mockResolvedValueOnce({}); + runtime.session.cancel.mockImplementationOnce(async () => { + runtime.emit({ + type: 'turn.ended', + sessionId: 'ses_headless', + agentId: 'main', + turnId: 7, + reason: 'cancelled', + }); + }); + runtime.session.prompt.mockImplementationOnce(async () => { + runtime.emit({ + type: 'turn.started', + sessionId: 'ses_headless', + agentId: 'main', + turnId: 7, + origin: { kind: 'user' }, + }); + const status = await readHeadlessRunStatus(statusFile); + await writeHeadlessControlRequest(controlFile, { + schemaVersion: 1, + runId: status.runId, + commandId: 'cmd_interrupt', + action: 'interrupt', + requestedAt: '2026-06-05T00:00:05.000Z', + }); + await waitForAssertion(() => { + expect(runtime.session.cancel).toHaveBeenCalledOnce(); + }); + }); + const stdout = outputWriter(); + + await runHeadless( + { + kind: 'run', + options: { + goal: 'raise coverage', + cwd: '/repo', + continue: false, + statusFile, + outputDir, + metadataOnly: false, + approvePlan: false, + rejectPlan: false, + skillsDirs: [], + }, + }, + '1.2.3-test', + { + stdout, + createHarness: () => runtime.harness, + acquireSessionRunLock: runtime.acquireLock, + }, + ); + + expect(JSON.parse(stdout.text())).toMatchObject({ + state: 'interrupted', + responseFormat: 'files', + }); + await expect(readHeadlessRunStatus(statusFile)).resolves.toMatchObject({ + state: 'interrupted', + error: null, + control: { + lastRequest: { commandId: 'cmd_interrupt', action: 'interrupt' }, + lastApplied: { + commandId: 'cmd_interrupt', + action: 'interrupt', + result: 'applied', + }, + }, + }); + }); +}); diff --git a/apps/kimi-code/test/cli/main.test.ts b/apps/kimi-code/test/cli/main.test.ts index 52aba94b1..71e401021 100644 --- a/apps/kimi-code/test/cli/main.test.ts +++ b/apps/kimi-code/test/cli/main.test.ts @@ -4,6 +4,7 @@ import { ErrorCodes, KimiError } from '@moonshot-ai/kimi-code-sdk'; import { validateOptions } from '#/cli/options'; import type { CLIOptions } from '#/cli/options'; import type * as OptionsModule from '#/cli/options'; +import { runHeadless } from '#/cli/headless/run'; import { runPrompt } from '#/cli/run-prompt'; import { runShell } from '#/cli/run-shell'; import { formatStartupError } from '#/cli/startup-error'; @@ -20,6 +21,7 @@ const mocks = vi.hoisted(() => { runUpdatePreflight: vi.fn(), runShell: vi.fn(), runPrompt: vi.fn(), + runHeadless: vi.fn(), installCrashHandlers: vi.fn(), track: vi.fn(), setTelemetryContext: vi.fn(), @@ -127,6 +129,10 @@ vi.mock('../../src/cli/run-prompt', () => ({ runPrompt: mocks.runPrompt, })); +vi.mock('../../src/cli/headless/run', () => ({ + runHeadless: mocks.runHeadless, +})); + class ExitCalled extends Error { constructor(readonly code: number) { super(`exit(${code})`); @@ -235,6 +241,29 @@ describe('main entry command handling', () => { expect(runShell).not.toHaveBeenCalled(); }); + it('routes parsed headless commands to the headless runner', () => { + main(); + + const createProgramCall = mocks.createProgram.mock.calls[0] as unknown[] | undefined; + const onHeadless = createProgramCall?.[5] as ((command: unknown) => void) | undefined; + expect(onHeadless).toBeTypeOf('function'); + + const command = { + kind: 'run', + options: { + prompt: 'inspect', + continue: false, + metadataOnly: false, + approvePlan: false, + rejectPlan: false, + skillsDirs: [], + }, + }; + onHeadless?.(command); + + expect(runHeadless).toHaveBeenCalledWith(command, '0.0.1-alpha.2'); + }); + it('keeps shell mode update preflight interactive by default', async () => { const opts = defaultOpts(); mocks.validateOptions.mockReturnValue({ options: opts, uiMode: 'shell' }); diff --git a/apps/kimi-code/test/cli/options.test.ts b/apps/kimi-code/test/cli/options.test.ts index 90fb53ecf..81f1f2efb 100644 --- a/apps/kimi-code/test/cli/options.test.ts +++ b/apps/kimi-code/test/cli/options.test.ts @@ -292,6 +292,7 @@ describe('CLI options parsing', () => { 'login', 'doctor', 'migrate', + 'headless', 'upgrade', ]); }); diff --git a/docs/en/reference/kimi-command.md b/docs/en/reference/kimi-command.md index b8be01096..64c4856d8 100644 --- a/docs/en/reference/kimi-command.md +++ b/docs/en/reference/kimi-command.md @@ -119,9 +119,61 @@ kimi -p "List changed files" --output-format stream-json In `stream-json` mode, regular replies produce an Assistant message; when the model calls a tool, an Assistant message with `tool_calls` is emitted first, followed by the corresponding Tool message, then subsequent Assistant messages. Thinking content is not written to JSONL; tool progress and "resuming session" notices are still written to stderr. +### Headless mode for agents and scripts + +Use `kimi headless` when another program or coding agent needs to start, inspect, or control a non-TUI run: + +```sh +kimi headless run --prompt "Inspect this repository" +kimi headless run --prompt "Fix the failing test" --status-file /tmp/kimi-run/status.json +kimi headless status --file /tmp/kimi-run/status.json +``` + +`headless run` is still turn-based. The process exits when the run ends. It does not use `--output-format`; stdout starts with one JSON metadata line. By default, the Assistant response follows after a blank line as verbatim Markdown. Add `--metadata-only` to print only metadata, or add `--output-dir ` to write response Markdown files and list them in the metadata. + +Useful options: + +| Option | Description | +| --- | --- | +| `--prompt ` | Run one prompt turn | +| `--cwd ` | Select or validate the session workspace | +| `--session ` | Resume a specific session | +| `--continue` | Continue the latest session for the working directory | +| `--model ` | Override the model for this run | +| `--status-file ` | Write JSON status updates that another process can poll | +| `--output-dir ` | Write response Markdown and sidecar files to this directory | +| `--metadata-only` | Omit Markdown from stdout | +| `--approve-plan` | Approve a plan review if one appears | +| `--reject-plan` | Reject a plan review if one appears | +| `--skills-dir ` | Load Skills from this directory. Can be repeated | + +Goal-backed headless runs use files for each completed turn: + +```sh +KIMI_CODE_EXPERIMENTAL_GOAL_COMMAND=1 kimi headless run \ + --goal "Raise coverage to 99.5%" \ + --status-file /tmp/kimi-run/status.json \ + --output-dir /tmp/kimi-run +``` + +While a goal is running, read the status file or run: + +```sh +kimi headless status --file /tmp/kimi-run/status.json +kimi headless status --file /tmp/kimi-run/status.json --json +``` + +Use goal control commands with the same status file. `pause` matches TUI `/goal pause`: the current turn continues, then the goal stops before the next turn. `interrupt` stops the active turn immediately. + +```sh +kimi headless goal pause --file /tmp/kimi-run/status.json +kimi headless goal cancel --file /tmp/kimi-run/status.json +kimi headless goal interrupt --file /tmp/kimi-run/status.json +``` + ## Subcommands -`kimi` provides the following subcommands: `login` (non-interactive login), `acp` (ACP IDE mode), `doctor` (validate configuration files), `export` (export a session), `migrate` (migrate legacy data), `upgrade` (check for updates), and `provider` (manage providers). +`kimi` provides the following subcommands: `login` (non-interactive login), `acp` (ACP IDE mode), `doctor` (validate configuration files), `export` (export a session), `headless` (run without the TUI), `migrate` (migrate legacy data), `upgrade` (check for updates), and `provider` (manage providers). ### `kimi login` diff --git a/docs/zh/reference/kimi-command.md b/docs/zh/reference/kimi-command.md index 36fb20d4c..68e8d9d32 100644 --- a/docs/zh/reference/kimi-command.md +++ b/docs/zh/reference/kimi-command.md @@ -119,9 +119,61 @@ kimi -p "List changed files" --output-format stream-json `stream-json` 模式下,普通回复输出 Assistant 消息;模型调用工具时,先输出带 `tool_calls` 的 Assistant 消息,再输出对应的 Tool 消息,最后继续输出后续 Assistant 消息。thinking 内容不会写入 JSONL;工具进度和恢复会话提示仍写到 stderr。 +### 面向 Agent 和脚本的 headless 模式 + +当另一个程序或编码 Agent 需要启动、查看或控制一次非 TUI 运行时,使用 `kimi headless`: + +```sh +kimi headless run --prompt "Inspect this repository" +kimi headless run --prompt "Fix the failing test" --status-file /tmp/kimi-run/status.json +kimi headless status --file /tmp/kimi-run/status.json +``` + +`headless run` 仍然按轮次运行。运行结束后,进程退出。它不使用 `--output-format`;stdout 的开头是一行 JSON 元数据。默认情况下,Assistant 回复会在一个空行后以原样 Markdown 形式输出。加 `--metadata-only` 只打印元数据,或加 `--output-dir ` 写入回复 Markdown 文件,并在元数据中列出这些文件。 + +常用选项: + +| 选项 | 说明 | +| --- | --- | +| `--prompt ` | 运行一个 prompt 轮次 | +| `--cwd ` | 选择或校验会话工作区 | +| `--session ` | 恢复指定会话 | +| `--continue` | 继续当前工作目录下最近一次的会话 | +| `--model ` | 为本次运行覆盖模型 | +| `--status-file ` | 写入可供另一个进程轮询的 JSON 状态更新 | +| `--output-dir ` | 把回复 Markdown 和附带文件写入该目录 | +| `--metadata-only` | 不把 Markdown 输出到 stdout | +| `--approve-plan` | 如果出现计划审阅,则批准计划 | +| `--reject-plan` | 如果出现计划审阅,则拒绝计划 | +| `--skills-dir ` | 从该目录加载 Skills。可重复传入 | + +目标驱动的 headless 运行会为每个完成的轮次写入文件: + +```sh +KIMI_CODE_EXPERIMENTAL_GOAL_COMMAND=1 kimi headless run \ + --goal "Raise coverage to 99.5%" \ + --status-file /tmp/kimi-run/status.json \ + --output-dir /tmp/kimi-run +``` + +目标运行期间,可以读取状态文件,也可以运行: + +```sh +kimi headless status --file /tmp/kimi-run/status.json +kimi headless status --file /tmp/kimi-run/status.json --json +``` + +目标控制命令使用同一个状态文件。`pause` 与 TUI 的 `/goal pause` 体验一致:当前轮次继续运行,然后目标会在下一个轮次前停止。`interrupt` 会立刻停止当前轮次。 + +```sh +kimi headless goal pause --file /tmp/kimi-run/status.json +kimi headless goal cancel --file /tmp/kimi-run/status.json +kimi headless goal interrupt --file /tmp/kimi-run/status.json +``` + ## 子命令 -`kimi` 提供以下子命令:`login`(非交互式登录)、`acp`(ACP IDE 模式)、`doctor`(校验配置文件)、`export`(导出会话)、`migrate`(迁移旧版数据)、`upgrade`(检查更新)、`provider`(管理供应商)。 +`kimi` 提供以下子命令:`login`(非交互式登录)、`acp`(ACP IDE 模式)、`doctor`(校验配置文件)、`export`(导出会话)、`headless`(不打开 TUI 运行)、`migrate`(迁移旧版数据)、`upgrade`(检查更新)、`provider`(管理供应商)。 ### `kimi login` diff --git a/packages/agent-core/src/agent/records/index.ts b/packages/agent-core/src/agent/records/index.ts index 8bf050398..d82c1d797 100644 --- a/packages/agent-core/src/agent/records/index.ts +++ b/packages/agent-core/src/agent/records/index.ts @@ -123,6 +123,54 @@ function restoreAgentRecord(agent: Agent, input: AgentRecord): void { } } +function inferRestoredTurnCount(records: readonly AgentRecord[]): number { + return Math.max( + countAcceptedTopLevelTurnInputs(records), + countFirstStepLoopEvents(records), + ); +} + +function countAcceptedTopLevelTurnInputs(records: readonly AgentRecord[]): number { + let count = 0; + for (let index = 0; index < records.length; index += 1) { + const record = records[index]; + if (record?.type !== 'turn.prompt' && record?.type !== 'turn.steer') continue; + const next = nextLaunchSignalRecord(records, index + 1); + if ( + next?.type === 'context.append_message' && + next.message.role === 'user' && + JSON.stringify(next.message.content) === JSON.stringify(record.input) && + JSON.stringify(next.message.origin) === JSON.stringify(record.origin) + ) { + count += 1; + } + } + return count; +} + +function nextLaunchSignalRecord( + records: readonly AgentRecord[], + startIndex: number, +): AgentRecord | undefined { + for (let index = startIndex; index < records.length; index += 1) { + const record = records[index]; + if (record === undefined) return undefined; + if (record.type === 'metadata') continue; + if (record.type.startsWith('goal.')) continue; + return record; + } + return undefined; +} + +function countFirstStepLoopEvents(records: readonly AgentRecord[]): number { + return records.filter( + (record) => + record.type === 'context.append_loop_event' && + record.event.type === 'step.begin' && + record.event.step === 1, + ).length; +} + export interface RestoringContext { time?: number; } @@ -217,6 +265,7 @@ export class AgentRecords { await this.agent.blobStore.rehydrateParts(msg.content); } } + this.agent.turn.restoreTurnCount(inferRestoredTurnCount(replayedRecords)); const firstRecord = replayedRecords[0]; if ( firstRecord?.type === 'metadata' && diff --git a/packages/agent-core/src/agent/turn/index.ts b/packages/agent-core/src/agent/turn/index.ts index e583a83d8..203386230 100644 --- a/packages/agent-core/src/agent/turn/index.ts +++ b/packages/agent-core/src/agent/turn/index.ts @@ -279,6 +279,11 @@ export class TurnFlow { this.steerBuffer.length = 0; } + restoreTurnCount(count: number): void { + if (count <= 0) return; + this.turnId = Math.max(this.turnId, count - 1); + } + /** * The body of the single in-flight `activeTurn`. Routes to the goal driver * (sequential continuation turns) when a goal is active, otherwise runs exactly diff --git a/packages/agent-core/src/errors/codes.ts b/packages/agent-core/src/errors/codes.ts index 80dd108f9..8a4b67584 100644 --- a/packages/agent-core/src/errors/codes.ts +++ b/packages/agent-core/src/errors/codes.ts @@ -20,6 +20,7 @@ export const ErrorCodes = { SESSION_STATE_NOT_FOUND: 'session.state_not_found', SESSION_STATE_INVALID: 'session.state_invalid', SESSION_FORK_ACTIVE_TURN: 'session.fork_active_turn', + SESSION_LOCKED: 'session.locked', SESSION_EXPORT_NOT_FOUND: 'session.export_not_found', SESSION_EXPORT_MISSING_VERSION: 'session.export_missing_version', SESSION_CLOSED: 'session.closed', @@ -155,6 +156,12 @@ export const KIMI_ERROR_INFO = { public: true, action: 'Wait for the active turn to complete before forking.', }, + 'session.locked': { + title: 'Session is locked', + retryable: true, + public: true, + action: 'Wait for the active run to finish or remove a stale lock.', + }, 'session.export_not_found': { title: 'Session export directory missing', retryable: false, diff --git a/packages/agent-core/test/agent/turn.test.ts b/packages/agent-core/test/agent/turn.test.ts index 8afa05ecc..de11cadf8 100644 --- a/packages/agent-core/test/agent/turn.test.ts +++ b/packages/agent-core/test/agent/turn.test.ts @@ -25,6 +25,7 @@ import type { } from '../../src/session/subagent-host'; import { recordingTelemetry, type TelemetryRecord } from '../fixtures/telemetry'; import { createFakeKaos } from '../tools/fixtures/fake-kaos'; +import { InMemoryAgentRecordPersistence } from '../../src/agent/records'; import { createCommandKaos, testAgent, type TestAgentOptions } from './harness/agent'; import { executeTool } from '../tools/fixtures/execute-tool'; @@ -148,6 +149,62 @@ describe('Agent turn flow', () => { }); }); + it('continues turn ids after multiple completed prompts are replayed', async () => { + const persistence = new InMemoryAgentRecordPersistence(); + const ctx = testAgent({ persistence }); + ctx.configure(); + + ctx.mockNextResponse({ type: 'text', text: 'first done' }); + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'First prompt' }] }); + await ctx.untilTurnEnd(); + + ctx.mockNextResponse({ type: 'text', text: 'second done' }); + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Second prompt' }] }); + await ctx.untilTurnEnd(); + + const resumed = testAgent({ + persistence: new InMemoryAgentRecordPersistence(persistence.records), + }); + await resumed.agent.resume(); + + resumed.mockNextResponse({ type: 'text', text: 'third done' }); + await resumed.rpc.prompt({ input: [{ type: 'text', text: 'Third prompt' }] }); + + expect(await resumed.untilTurnEnd()).toContainEqual({ + type: '[rpc]', + event: 'turn.started', + args: { turnId: 2, origin: { kind: 'user' } }, + }); + }); + + it('does not count rejected busy prompts when restoring turn ids', async () => { + const persistence = new InMemoryAgentRecordPersistence(); + const ctx = testAgent({ kaos: createCommandKaos('should-not-run'), persistence }); + ctx.configure({ tools: ['Bash'] }); + + ctx.mockNextResponse({ type: 'text', text: 'I will wait for approval.' }, bashCall()); + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Start the active turn' }] }); + await ctx.untilApprovalRequest(); + + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Rejected busy prompt' }] }); + await ctx.rpc.cancel({ turnId: 0 }); + await ctx.untilTurnEnd(); + + const resumed = testAgent({ + persistence: new InMemoryAgentRecordPersistence(persistence.records), + }); + await resumed.agent.resume(); + + resumed.mockNextResponse({ type: 'text', text: 'next done' }); + await resumed.rpc.prompt({ input: [{ type: 'text', text: 'Next real prompt' }] }); + + expect(await resumed.untilTurnEnd()).toContainEqual({ + type: '[rpc]', + event: 'turn.started', + args: { turnId: 1, origin: { kind: 'user' } }, + }); + }); + it('fires PostToolUse for same-step dups with the original real output, not the dedup placeholder', async () => { // Hook command asserts the dup's PostToolUse payload carries the real // stdout ('dup'), not the placeholder (''). diff --git a/packages/node-sdk/src/index.ts b/packages/node-sdk/src/index.ts index 8e0bfd446..4e1e7f8ef 100644 --- a/packages/node-sdk/src/index.ts +++ b/packages/node-sdk/src/index.ts @@ -1,6 +1,8 @@ export { KimiHarness } from '#/kimi-harness'; export type { KimiHarnessRuntimeOptions } from '#/kimi-harness'; export { Session } from '#/session'; +export { acquireSessionRunLock } from '#/session-lock'; +export type { AcquireSessionRunLockInput, SessionRunLock } from '#/session-lock'; export { KimiAuthFacade } from '#/auth'; export { createKimiHarness, diff --git a/packages/node-sdk/src/session-lock.ts b/packages/node-sdk/src/session-lock.ts new file mode 100644 index 000000000..91c5e8076 --- /dev/null +++ b/packages/node-sdk/src/session-lock.ts @@ -0,0 +1,168 @@ +import { open, readFile, stat, unlink } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { ErrorCodes, KimiError } from '@moonshot-ai/agent-core'; + +const SESSION_RUN_LOCK_FILE = 'run.lock'; +const CORRUPT_LOCK_STALE_MS = 30_000; + +export interface SessionRunLock { + readonly sessionDir: string; + readonly runId: string; + release(): Promise; +} + +export interface AcquireSessionRunLockInput { + readonly sessionDir: string; + readonly runId: string; + readonly pid: number; + readonly command: string; +} + +interface SessionRunLockFile { + readonly schemaVersion: 1; + readonly runId: string; + readonly pid: number; + readonly createdAt: string; + readonly command: string; +} + +export async function acquireSessionRunLock( + input: AcquireSessionRunLockInput, +): Promise { + const lockPath = getSessionRunLockPath(input.sessionDir); + + try { + return await createSessionRunLock(input, lockPath); + } catch (error) { + if (!isAlreadyExists(error)) throw error; + } + + if (await isExistingLockLive(lockPath)) { + throw createSessionLockedError(input.sessionDir, lockPath); + } + + await unlink(lockPath).catch((error: unknown) => { + if (!isNotFound(error)) throw error; + }); + + try { + return await createSessionRunLock(input, lockPath); + } catch (error) { + if (isAlreadyExists(error)) { + throw createSessionLockedError(input.sessionDir, lockPath); + } + throw error; + } +} + +function getSessionRunLockPath(sessionDir: string): string { + return join(sessionDir, SESSION_RUN_LOCK_FILE); +} + +async function createSessionRunLock( + input: AcquireSessionRunLockInput, + lockPath: string, +): Promise { + const file = await open(lockPath, 'wx', 0o600); + try { + await file.writeFile(`${JSON.stringify({ + schemaVersion: 1, + runId: input.runId, + pid: input.pid, + createdAt: new Date().toISOString(), + command: input.command, + } satisfies SessionRunLockFile, null, 2)}\n`, 'utf-8'); + } finally { + await file.close(); + } + + return { + sessionDir: input.sessionDir, + runId: input.runId, + release: async (): Promise => { + const existing = await readLockFile(lockPath); + if (existing === null || existing === 'corrupt' || existing.runId !== input.runId) return; + await unlink(lockPath).catch((error: unknown) => { + if (!isNotFound(error)) throw error; + }); + }, + }; +} + +async function isExistingLockLive(lockPath: string): Promise { + const existing = await readLockFile(lockPath); + if (existing === null) return false; + if (existing === 'corrupt') return isCorruptLockFresh(lockPath); + return isPidAlive(existing.pid); +} + +async function readLockFile(lockPath: string): Promise { + let raw: string; + try { + raw = await readFile(lockPath, 'utf-8'); + } catch (error) { + if (isNotFound(error)) return null; + throw error; + } + + try { + const parsed = JSON.parse(raw) as unknown; + if (!isLockFile(parsed)) return 'corrupt'; + return parsed; + } catch { + return 'corrupt'; + } +} + +async function isCorruptLockFresh(lockPath: string): Promise { + try { + const info = await stat(lockPath); + return Date.now() - info.mtimeMs < CORRUPT_LOCK_STALE_MS; + } catch (error) { + if (isNotFound(error)) return false; + throw error; + } +} + +function isLockFile(value: unknown): value is SessionRunLockFile { + if (typeof value !== 'object' || value === null) return false; + const lock = value as Partial; + return ( + lock.schemaVersion === 1 && + typeof lock.runId === 'string' && + Number.isInteger(lock.pid) && + typeof lock.createdAt === 'string' && + typeof lock.command === 'string' + ); +} + +function isPidAlive(pid: number): boolean { + if (!Number.isInteger(pid) || pid <= 0) return true; + if (typeof process.kill !== 'function') return true; + + try { + process.kill(pid, 0); + return true; + } catch (error) { + const code = typeof error === 'object' && error !== null ? (error as { code?: string }).code : undefined; + if (code === 'ESRCH') return false; + return true; + } +} + +function createSessionLockedError(sessionDir: string, lockPath: string): KimiError { + return new KimiError( + ErrorCodes.SESSION_LOCKED, + `Session at "${sessionDir}" is already locked by another run.`, + { details: { sessionDir, lockPath } }, + ); +} + +function isAlreadyExists(error: unknown): boolean { + return typeof error === 'object' && error !== null && (error as { code?: string }).code === 'EEXIST'; +} + +function isNotFound(error: unknown): boolean { + return typeof error === 'object' && error !== null && (error as { code?: string }).code === 'ENOENT'; +} diff --git a/packages/node-sdk/test/session-lock.test.ts b/packages/node-sdk/test/session-lock.test.ts new file mode 100644 index 000000000..73825646c --- /dev/null +++ b/packages/node-sdk/test/session-lock.test.ts @@ -0,0 +1,153 @@ +import { access, mkdtemp, readFile, rm, utimes, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { acquireSessionRunLock, type KimiError } from '#/index'; + +const tempDirs: string[] = []; + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +async function makeSessionDir(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'kimi-session-lock-test-')); + tempDirs.push(dir); + return dir; +} + +describe('acquireSessionRunLock', () => { + it('creates run.lock in the session directory', async () => { + const sessionDir = await makeSessionDir(); + + const lock = await acquireSessionRunLock({ + sessionDir, + runId: 'run_001', + pid: 123, + command: 'headless run', + }); + + expect(lock).toMatchObject({ sessionDir, runId: 'run_001' }); + const raw = await readFile(join(sessionDir, 'run.lock'), 'utf-8'); + expect(JSON.parse(raw)).toEqual({ + schemaVersion: 1, + runId: 'run_001', + pid: 123, + createdAt: expect.any(String), + command: 'headless run', + }); + }); + + it('rejects a second live lock with session.locked', async () => { + const sessionDir = await makeSessionDir(); + const first = await acquireSessionRunLock({ + sessionDir, + runId: 'run_001', + pid: process.pid, + command: 'headless run', + }); + + try { + await expect( + acquireSessionRunLock({ + sessionDir, + runId: 'run_002', + pid: process.pid, + command: 'headless run', + }), + ).rejects.toMatchObject({ + name: 'KimiError', + code: 'session.locked', + } satisfies Partial); + } finally { + await first.release(); + } + }); + + it('removes the lock on release', async () => { + const sessionDir = await makeSessionDir(); + const lock = await acquireSessionRunLock({ + sessionDir, + runId: 'run_001', + pid: process.pid, + command: 'headless run', + }); + + await lock.release(); + + await expect(access(join(sessionDir, 'run.lock'))).rejects.toThrow(); + }); + + it('removes a stale lock with a dead pid', async () => { + const sessionDir = await makeSessionDir(); + await writeFile( + join(sessionDir, 'run.lock'), + `${JSON.stringify({ + schemaVersion: 1, + runId: 'old_run', + pid: 999_999_999, + createdAt: '2026-06-05T00:00:00.000Z', + command: 'headless run', + })}\n`, + 'utf-8', + ); + + const lock = await acquireSessionRunLock({ + sessionDir, + runId: 'run_001', + pid: process.pid, + command: 'headless run', + }); + + const raw = await readFile(join(sessionDir, 'run.lock'), 'utf-8'); + expect(JSON.parse(raw)).toMatchObject({ runId: 'run_001' }); + await lock.release(); + }); + + it('removes a stale corrupt lock file', async () => { + const sessionDir = await makeSessionDir(); + const lockPath = join(sessionDir, 'run.lock'); + await writeFile(lockPath, '{"schemaVersion":', 'utf-8'); + const old = new Date(Date.now() - 60_000); + await utimes(lockPath, old, old); + + const lock = await acquireSessionRunLock({ + sessionDir, + runId: 'run_001', + pid: process.pid, + command: 'headless run', + }); + + const raw = await readFile(lockPath, 'utf-8'); + expect(JSON.parse(raw)).toMatchObject({ runId: 'run_001' }); + await lock.release(); + }); + + it('does not release another run lock', async () => { + const sessionDir = await makeSessionDir(); + const lock = await acquireSessionRunLock({ + sessionDir, + runId: 'run_001', + pid: process.pid, + command: 'headless run', + }); + await writeFile( + join(sessionDir, 'run.lock'), + `${JSON.stringify({ + schemaVersion: 1, + runId: 'run_002', + pid: process.pid, + createdAt: '2026-06-05T00:00:00.000Z', + command: 'headless run', + })}\n`, + 'utf-8', + ); + + await lock.release(); + + const raw = await readFile(join(sessionDir, 'run.lock'), 'utf-8'); + expect(JSON.parse(raw)).toMatchObject({ runId: 'run_002' }); + }); +}); diff --git a/plans/2026-06-05-headless-implementation-tracker.md b/plans/2026-06-05-headless-implementation-tracker.md new file mode 100644 index 000000000..2241ee039 --- /dev/null +++ b/plans/2026-06-05-headless-implementation-tracker.md @@ -0,0 +1,233 @@ +# Headless Mode Implementation Tracker + +## Current Milestone + +Implement the headless command surface first. + +This milestone shall cover: + +- `kimi headless run` option parsing. +- `kimi headless --goal` shortcut parsing. +- `kimi headless status` parsing. +- `kimi headless goal pause|cancel|interrupt` parsing. +- main entry routing to the headless handler. +- prompt-mode regression coverage for `kimi -p`. + +## Progress + +- [x] Command parsing tests written. +- [x] Command parsing tests fail for the missing feature. +- [x] Command parsing and routing implemented. +- [x] Focused tests pass. +- [x] CLI help checked. +- [x] Self-contained commit created: `4d26627 feat: add headless command surface`. + +## Current Milestone 2 + +Implement headless status, output-file, control, and approval helpers. + +This milestone shall cover: + +- Atomic status file writes and reads. +- Status file preflight. +- Metadata header formatting. +- Output directory resolution. +- Atomic response and goal-status file writes. +- Control request writes and reads. +- Non-fatal unused plan flag warnings. + +Goal control semantics: + +- `pause_goal` shall match TUI `/goal pause`: finish the current turn, then stop before the next goal turn. +- `cancel_goal` shall finish the current turn, then cancel the goal before the next goal turn. +- `interrupt` shall stop the active turn immediately and leave the goal paused when possible. + +## Milestone 2 Progress + +- [x] Helper tests written. +- [x] Helper tests fail for missing modules. +- [x] Helper modules implemented. +- [x] Focused tests pass. +- [x] Typecheck passes. +- [x] Self-contained commit created. + +## Current Milestone 3 + +Add SDK session run locking. + +This milestone shall cover: + +- `packages/node-sdk/src/session-lock.ts`. +- `acquireSessionRunLock` exported from the SDK. +- `session.locked` public error code. +- Live lock rejection. +- Dead-pid stale lock replacement. +- Guarded release that does not remove another run's lock. + +## Milestone 3 Progress + +- [x] Lock tests written. +- [x] Lock tests fail for missing helper. +- [x] Lock helper implemented. +- [x] Focused tests pass. +- [x] Typecheck passes. +- [x] Build passes. +- [x] Self-contained commit created. + +## Current Milestone 4 + +Wire headless status and goal-control commands. + +This milestone shall cover: + +- `runHeadless` status dispatch. +- Human status summary output. +- Raw status JSON output. +- Goal control request writes through `status.control.path`. +- Fail-safe rejection when a status file has no control path. + +## Milestone 4 Progress + +- [x] Command behavior tests written. +- [x] Command behavior tests fail for the stub. +- [x] Status and goal-control commands implemented. +- [x] Focused tests pass. +- [x] Typecheck passes. +- [x] Build passes. +- [x] Self-contained commit created. + +## Current Milestone 5 + +Implement one-turn prompt-backed `headless run`. + +This milestone shall cover: + +- New-session prompt runs. +- `--cwd` for new sessions. +- `--session` cwd validation. +- Session run lock acquisition and release. +- Status file updates. +- Default JSON metadata header plus Markdown. +- `--metadata-only`. +- `--output-dir` response files. + +## Milestone 5 Progress + +- [x] Prompt-run tests written. +- [x] Prompt-run tests fail for the missing run branch. +- [x] Prompt run branch implemented. +- [x] Focused tests pass. +- [x] Typecheck passes. +- [x] Build passes. +- [x] Real CLI smoke run passes. +- [x] Self-contained commit created. + +## Current Milestone 6 + +Implement goal-backed headless runs and graceful pause control. + +This milestone shall cover: + +- `--goal` creates a goal and prompts with the objective. +- Goal-backed stdout stays metadata-only. +- Goal-backed runs write one Markdown file per completed turn. +- Goal-backed runs write `goal-status.json`. +- Goal-backed status includes `control.path`. +- `pause_goal` calls `pauseGoal()` and does not call `cancel()`. +- Paused goals finish the active turn and end with `state: "paused"`. + +## Milestone 6 Progress + +- [x] Goal-mode tests written. +- [x] Goal-mode tests fail for missing goal branch. +- [x] Goal-mode run branch implemented. +- [x] Graceful pause control test written. +- [x] Graceful pause control test fails before polling. +- [x] Control polling implemented. +- [x] Focused tests pass. +- [x] Typecheck passes. +- [x] Build passes. +- [x] Real goal CLI smoke run passes with `KIMI_CODE_EXPERIMENTAL_GOAL_COMMAND=1`. +- [x] Self-contained commit created. + +## Current Milestone 7 + +Wire fail-safe plan approval flags. + +This milestone shall cover: + +- Plan approval handler for `ExitPlanMode` and `plan_review`. +- `--approve-plan` approves plan review. +- `--reject-plan` rejects and exits plan mode. +- Missing plan flag cancels plan review in headless mode. +- Unused plan flags become non-fatal warnings. +- Warnings appear in metadata and status files. + +## Milestone 7 Progress + +- [x] Approval helper tests written. +- [x] Run-level unused warning test written. +- [x] Approval handler implemented. +- [x] Run-level warnings implemented. +- [x] Focused tests pass. +- [x] Typecheck passes. +- [x] Build passes. +- [x] Real `--approve-plan` unused-warning smoke run passes. +- [x] Self-contained commit created. + +## Current Milestone 8 + +Document headless mode and add the changeset. + +This milestone shall cover: + +- English command reference. +- Chinese command reference. +- Changeset for the CLI, SDK, and agent-core package changes. +- Plan correction for graceful goal pause semantics. + +## Milestone 8 Progress + +- [x] English command reference updated. +- [x] Chinese command reference updated. +- [x] Graceful pause plan wording amended. +- [x] Changeset written. +- [x] Focused tests pass. +- [x] Typecheck passes. +- [x] Build passes. +- [x] Docs build passes with the repo pnpm environment. +- [ ] Self-contained commit created. + +## Current Milestone 9 + +Run manual headless trials and example projects. + +This milestone shall cover: + +- Built CLI smoke checks after the final code commit. +- Three side projects under `/tmp/kimi-headless-examples/`. +- At least 10 headless turns per side project. +- Status file polling and metadata/file output during the trials. +- Reports with DOs and DONTs from the trial runs. + +## Milestone 9 Progress + +- [x] Built CLI smoke checks pass. +- [x] Example project 1 reaches at least 10 turns: `headless-js-checklist`, 11 completed turns. +- [x] Example project 2 reaches at least 10 turns: `headless-python-textstats`, 10 completed turns and one pre-fix interrupted turn. +- [x] Example project 3 reaches at least 10 turns: `headless-web-timer`, 11 completed turns. +- [x] Reports written. +- [x] Self-contained commit created if repository files change. + +## Later Milestones + +- [x] Status, output, output-file, control, and approval helpers. +- [x] SDK session lock helper. +- [x] Headless status and goal-control commands. +- [x] One-turn prompt-backed headless run execution. +- [x] Goal-backed multi-turn execution and file output. +- [x] Fail-safe plan approval flags. +- [x] Docs and changeset. +- [x] Build CLI and run manual headless trials. +- [x] Three example projects under `/tmp/kimi-headless-examples/`. +- [x] Reports with DOs and DONTs. diff --git a/plans/2026-06-05-headless-mode.md b/plans/2026-06-05-headless-mode.md new file mode 100644 index 000000000..18e200f6e --- /dev/null +++ b/plans/2026-06-05-headless-mode.md @@ -0,0 +1,1778 @@ +# Headless Mode Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a dedicated non-TUI command surface for programmatic users and coding agents. + +**Architecture:** Keep headless mode turn-based. Implement `kimi headless run` as a separate command surface from `kimi -p`. Use the current SDK, session, and event APIs. Do not change `-p` behavior, output, validation, or option names. + +**Tech Stack:** TypeScript, Commander.js, Vitest, Kimi Code SDK session events. Keep the current tech stack as much as possible. Do not add runtime dependencies unless the existing stack cannot solve the problem. + +--- + +## Reader Outcome + +After this plan, a program can run one Kimi Code turn without a TUI. + +The command shall be clear from `--help` alone. + +The command shall expose enough state for another process to monitor a long turn. + +The CLI shall stop when the turn ends. + +The existing `kimi -p` prompt mode shall remain unchanged. + +## Current Code Facts + +- `apps/kimi-code/src/cli/run-prompt.ts` already runs one prompt without the TUI. +- `kimi -p "..."` selects `uiMode: 'print'` through `validateOptions`. +- `runPromptTurn` listens to session events and stops on `turn.ended`. +- `--output-format` currently belongs to prompt mode and supports `text` and `stream-json`. +- Headless prompt mode currently installs approval and question handlers. +- `KimiHarness` and `Session` already expose `listSessions`, `createSession`, `resumeSession`, `getStatus`, `getUsage`, `cancel`, `prompt`, `steer`, and events. +- `apps/kimi-code` may only use core capabilities through `@moonshot-ai/kimi-code-sdk`. + +## Non-Goals + +- Do not modify `kimi -p`. +- Do not extend `--output-format`. +- Do not make daemon mode part of this slice. +- Do not add a new runtime dependency for locking, file writes, or JSON framing. +- Do not auto-approve plan exit by default. + +## CLI Contract + +Keep existing prompt mode unchanged: + +```sh +kimi -p "summarize this repository" +``` + +Add a dedicated command group: + +```sh +kimi headless run --prompt "summarize this repository" +kimi headless run --cwd /repo --prompt "fix the failing test" +kimi headless run --prompt "fix the failing test" --status-file /tmp/kimi-run.json +kimi headless run --prompt "fix the failing test" --output-dir /tmp/kimi-run +kimi headless run --prompt "inspect" --metadata-only +kimi headless run --prompt "apply this plan" --approve-plan +kimi headless run --prompt "review this plan" --reject-plan +kimi headless --goal "ship the refactor" --status-file /tmp/kimi-run/status.json +kimi headless run --goal "ship the refactor" --output-dir /tmp/kimi-run +kimi headless run --replace-goal "ship the refactor" +kimi headless goal pause --file /tmp/kimi-run/status.json +kimi headless goal cancel --file /tmp/kimi-run/status.json +kimi headless goal interrupt --file /tmp/kimi-run/status.json +kimi headless status --file /tmp/kimi-run.json --json +``` + +`kimi headless --help` shall explain that headless mode runs one turn and exits. + +`kimi headless run --help` shall show examples for: + +- a default JSON metadata header plus Markdown response +- metadata-only output +- a status file +- file output with `--output-dir` +- a custom working directory +- plan approval +- goal-backed execution +- goal pause, cancel, and interrupt control + +`kimi headless status --help` shall explain that it reads a status file written by `headless run`. + +`kimi headless run` shall support these options: + +- `--prompt `: prompt text. +- `--goal `: create a goal and run until it reaches a terminal state. +- `--replace-goal `: replace the active goal and run until it reaches a terminal state. +- `--cwd `: working directory for a new session, `--continue`, or session workdir validation. +- `--session `: resume a specific session. +- `--continue`: continue the latest session for the working directory. +- `--model `: override the model for this run. +- `--status-file `: write atomic run status updates to a JSON file. +- `--output-dir `: write response Markdown and goal metadata files to a directory. +- `--metadata-only`: print only the JSON metadata line and omit the Markdown response body. +- `--approve-plan`: approve plan-exit requests only. +- `--reject-plan`: reject plan-exit requests by selecting the existing `Reject and Exit` plan-review choice. +- `--skills-dir `: reuse the existing repeatable skill directory option. + +`kimi headless run` shall not support `--output-format`. + +`kimi headless run` shall always print a JSON metadata header by default. + +`kimi headless --goal ` shall be a shortcut for `kimi headless run --goal `. + +The shortcut shall accept the same run options that make sense for a goal-backed run, including `--cwd`, `--session`, `--continue`, `--model`, `--status-file`, `--output-dir`, `--metadata-only`, `--approve-plan`, `--reject-plan`, and `--skills-dir`. + +Every run shall include exactly one of `--prompt`, `--goal`, or `--replace-goal`. + +`--prompt`, `--goal`, and `--replace-goal` shall be mutually exclusive. + +`kimi headless goal` shall support these subcommands: + +- `pause --file `: request a graceful pause after the current turn finishes. +- `cancel --file `: request graceful goal cancellation after the current turn finishes. +- `interrupt --file `: request immediate interruption of the active turn and leave the goal paused when possible. + +Each subcommand shall support `--wait`. + +With `--wait`, the command shall wait until the running process records the command id in `control.lastApplied`. + +## Existing `-p` Contract + +`kimi -p` is an existing shortcut with existing output contracts. + +This plan shall not change: + +- accepted options for `-p` +- `--output-format=text` +- `--output-format=stream-json` +- prompt-mode validation errors +- prompt-mode telemetry labels +- prompt-mode stdout or stderr layout + +Implementation may copy small logic from `run-prompt.ts`. + +Implementation may extract shared code only after regression tests prove `kimi -p` output and validation do not change. + +## Working Directory Contract + +`--cwd ` shall resolve to an absolute path before session lookup or session creation. + +If `--cwd` is omitted, use `process.cwd()`. + +For a new session: + +- use the resolved cwd as `workDir` +- include the resolved cwd in the status file and JSON header + +For `--continue`: + +- list sessions for the resolved cwd +- resume the newest session for that cwd +- fail if no session exists for that cwd + +For `--session `: + +- list the target session +- if `--cwd` is present and differs from the session workdir, fail before resuming +- if `--cwd` is omitted, use the session workdir from the session summary + +The help text shall state that `--cwd` selects or validates the session workspace. + +## Session Lock Contract + +Headless mode should prevent two local `kimi` processes from running the same session at the same time. + +Use a lock file in the session directory: + +```text +/run.lock +``` + +Acquire the lock with atomic file create: + +```ts +await open(lockPath, 'wx'); +``` + +The lock file shall contain: + +```json +{ + "schemaVersion": 1, + "runId": "run_123", + "pid": 12345, + "createdAt": "2026-06-05T00:00:00.000Z", + "command": "headless run" +} +``` + +Lock behavior: + +- For `--session` and `--continue`, acquire the lock before `resumeSession`. +- For a new session, acquire the lock after `createSession` returns and before sending the prompt. +- Release the lock after the turn ends or after startup failure. +- If the lock exists and its pid is alive, fail with `SESSION_LOCKED`. +- If the lock exists and its pid is not alive, remove it and acquire a new lock. +- If the lock cannot be created or removed, fail before sending the prompt. + +The helper shall live in the SDK. + +The helper shall stay independent of CLI concerns so the later TUI and daemon locking work can reuse the same primitive. + +## Status File Contract + +`headless run --status-file ` shall preflight the status file before creating or resuming a session. + +Preflight rules: + +- If the parent directory does not exist, fail before creating or resuming a session. +- If the parent path is not writable, fail before creating or resuming a session. +- If the status file already exists, overwrite it through atomic replace. +- If `.tmp` exists from an old run, overwrite it. + +`headless run --status-file ` shall write JSON with this shape: + +```json +{ + "schemaVersion": 1, + "runId": "run_123", + "pid": 12345, + "sessionId": "ses_123", + "turnId": 7, + "state": "running", + "workDir": "/repo", + "model": "kimi-code/k2.5", + "startedAt": "2026-06-05T00:00:00.000Z", + "updatedAt": "2026-06-05T00:00:05.000Z", + "elapsedMs": 5000, + "lastEvent": "tool.call.started", + "activeTool": { + "toolCallId": "call_123", + "name": "functions.exec_command", + "description": "Run tests" + }, + "summary": { + "turnStepCount": 2, + "toolCallCount": 3, + "completedToolCallCount": 2, + "failedToolCallCount": 0, + "assistantCharCount": 1520, + "thinkingCharCount": 430 + }, + "approval": null, + "goal": null, + "warnings": [], + "files": { + "outputDir": null, + "responses": [], + "finalResponse": null, + "goalStatus": null + }, + "control": null, + "error": null, + "resumeCommand": "kimi -r ses_123" +} +``` + +For goal-backed runs, `goal` shall be non-null: + +```json +{ + "goal": { + "goalId": "goal_123", + "status": "complete", + "reason": "Objective achieved.", + "turnsUsed": 3, + "tokensUsed": 12000, + "wallClockMs": 45000 + } +} +``` + +For runs that write response files, `files` shall list every caller-visible artifact: + +```json +{ + "files": { + "outputDir": "/tmp/kimi-run", + "responses": [ + { + "turnIndex": 1, + "turnId": 7, + "path": "/tmp/kimi-run/turns/turn-0001.md", + "state": "completed", + "bytes": 4210, + "updatedAt": "2026-06-05T00:00:10.000Z" + }, + { + "turnIndex": 2, + "turnId": 8, + "path": "/tmp/kimi-run/turns/turn-0002.md", + "state": "completed", + "bytes": 2832, + "updatedAt": "2026-06-05T00:00:25.000Z" + } + ], + "finalResponse": { + "turnIndex": 2, + "turnId": 8, + "path": "/tmp/kimi-run/turns/turn-0002.md", + "state": "completed", + "bytes": 2832, + "updatedAt": "2026-06-05T00:00:25.000Z" + }, + "goalStatus": { + "path": "/tmp/kimi-run/goal-status.json", + "state": "completed", + "bytes": 168, + "updatedAt": "2026-06-05T00:00:25.000Z" + } + } +} +``` + +For goal-backed runs, `control` shall be non-null while the process is running: + +```json +{ + "control": { + "path": "/tmp/kimi-run/control.json", + "supportedActions": ["pause_goal", "cancel_goal", "interrupt"], + "lastRequest": null, + "lastApplied": null + } +} +``` + +Callers shall only read response files with `state: "completed"`. + +The status writer shall update `files` only after the target file has been atomically renamed into place. + +Allowed `state` values: + +- `starting` +- `running` +- `approval_required` +- `paused` +- `completed` +- `failed` +- `cancelled` +- `interrupted` + +The writer shall use an atomic replace: + +1. Write `.tmp`. +2. Flush the full JSON text. +3. Rename `.tmp` to ``. + +If the CLI exits through `SIGINT` or `SIGTERM`, it shall update the status file to `cancelled` before process exit when possible. + +## Output File Contract + +`--output-dir ` shall write caller-readable artifacts to a directory. + +If `--output-dir` is omitted and `--status-file ` is present, derive the output directory as `.d`. + +If both `--output-dir` and `--status-file` are omitted, create a run-specific directory under the OS temp directory. + +The final stdout metadata shall always include the resolved output directory when files are written. + +For goal-backed runs, headless mode shall always write response files. + +For non-goal runs, headless mode shall write response files only when `--output-dir` is set. + +Preflight rules: + +- If the output directory does not exist, create it before creating or resuming a session. +- If the output directory path exists and is not a directory, fail before creating or resuming a session. +- If the output directory is not writable, fail before creating or resuming a session. +- Create `turns/` before sending the prompt. +- If a target response file already exists, overwrite it through atomic replace. +- If a target temp file exists from an old run, overwrite it. + +Goal-backed directory layout: + +```text +/ + turns/ + turn-0001.md + turn-0002.md + control.json + goal-status.json +``` + +File rules: + +- Write assistant Markdown for each completed turn to `turns/turn-XXXX.md`. +- Do not add generated headings, separators, or status blocks to response Markdown files. +- Treat Markdown files as opaque model output. +- Write goal status to `goal-status.json` for goal-backed runs. +- Write every file atomically with a temp file and rename. +- Update the status file after the final path exists. +- Never list temp files in status JSON. + +For goal-backed runs, stdout shall not include Markdown. + +For goal-backed runs, callers shall read turn response files from `files.responses` and goal state from `goal` or `files.goalStatus`. + +Active monitoring contract: + +- Callers shall poll `headless status --file --json` or read the status file directly. +- Callers shall discover output files only through `files`. +- Callers shall not scan the output directory to infer run state. +- Callers shall read a response file only after its status entry has `state: "completed"`. +- Callers shall treat response Markdown as opaque content. + +## Goal Control Contract + +Headless goal control shall use a control file owned by the running process. + +The running process remains the only process that owns the session lock. + +Callers shall send control requests by writing the control file path listed in `status.control.path`. + +Control request shape: + +```json +{ + "schemaVersion": 1, + "runId": "run_123", + "commandId": "cmd_001", + "action": "pause_goal", + "requestedAt": "2026-06-05T00:00:20.000Z" +} +``` + +Allowed `action` values: + +- `pause_goal` +- `cancel_goal` +- `interrupt` + +Control request writes shall be atomic: + +1. Write `.tmp`. +2. Flush the full JSON text. +3. Rename `.tmp` to ``. + +`pause_goal` behavior: + +- Record the request in `control.lastRequest`. +- Apply the pause request when the control file is read. +- Let the current turn keep running. +- Do not call `session.cancel()` for this action. +- Do not schedule another goal turn after the current turn ends. +- Write final status with `state: "paused"`. +- Leave the goal resumable. +- Exit with the existing paused goal exit code. +- Match the TUI `/goal pause` user experience. +- Do not use the word "pause" for immediate turn interruption. + +`cancel_goal` behavior: + +- Record the request in `control.lastRequest`. +- Let the current turn finish. +- Do not call `session.cancel()` for this action. +- Before scheduling the next goal turn, call `session.cancelGoal()`. +- Write final status with `state: "cancelled"`. +- Do not schedule more goal turns. + +`interrupt` behavior: + +- Record the request in `control.lastRequest`. +- Stop the active turn as soon as possible. +- Call `session.pauseGoal()` when a goal is active. +- Call `session.cancel()` to interrupt the active turn. +- Write final status with `state: "interrupted"`. +- Leave the goal resumable when `pauseGoal()` succeeds. +- Exit with the existing paused goal exit code. +- Use this action for the behavior that stops the active turn immediately. + +After applying any control request, write `control.lastApplied` with: + +```json +{ + "commandId": "cmd_001", + "action": "pause_goal", + "appliedAt": "2026-06-05T00:00:30.000Z", + "result": "applied" +} +``` + +If a control request cannot be applied, write `control.lastApplied.result: "failed"` with an error message. + +The helper command `--wait` mode shall poll the status file until `control.lastApplied.commandId` matches its command id or the run reaches a terminal state. + +## Output Contract + +Default stdout shall start with one JSON metadata line. + +For non-goal runs without `--output-dir`, the metadata line shall be followed by one blank line and the final assistant response as verbatim Markdown: + +```text +{"type":"headless.result","schemaVersion":1,"runId":"run_123","sessionId":"ses_123","turnId":7,"state":"completed","responseFormat":"markdown","responseOmitted":false,"resumeCommand":"kimi -r ses_123","summary":{"toolCallCount":3,"completedToolCallCount":3,"failedToolCallCount":0,"turnStepCount":2,"assistantCharCount":1520,"thinkingCharCount":430,"elapsedMs":5000},"approval":null,"goal":null,"warnings":[],"files":{"outputDir":null,"responses":[],"finalResponse":null,"goalStatus":null}} + +The assistant response starts here as Markdown. +``` + +Do not put the full assistant response inside a JSON string. + +For non-goal runs with `--output-dir`, stdout shall contain only the metadata line and list the response file: + +```text +{"type":"headless.result","schemaVersion":1,"runId":"run_123","sessionId":"ses_123","turnId":7,"state":"completed","responseFormat":"files","responseOmitted":true,"resumeCommand":"kimi -r ses_123","summary":{"toolCallCount":3,"completedToolCallCount":3,"failedToolCallCount":0,"turnStepCount":2,"assistantCharCount":1520,"thinkingCharCount":430,"elapsedMs":5000},"approval":null,"goal":null,"warnings":[],"files":{"outputDir":"/tmp/kimi-run","responses":[{"turnIndex":1,"turnId":7,"path":"/tmp/kimi-run/turns/turn-0001.md","state":"completed","bytes":4210,"updatedAt":"2026-06-05T00:00:10.000Z"}],"finalResponse":{"turnIndex":1,"turnId":7,"path":"/tmp/kimi-run/turns/turn-0001.md","state":"completed","bytes":4210,"updatedAt":"2026-06-05T00:00:10.000Z"},"goalStatus":null}} +``` + +For goal-backed runs, stdout shall contain only the metadata line. + +For goal-backed runs, response files shall be listed in `files.responses`: + +```text +{"type":"headless.result","schemaVersion":1,"runId":"run_123","sessionId":"ses_123","turnId":8,"state":"completed","responseFormat":"files","responseOmitted":true,"resumeCommand":"kimi -r ses_123","summary":{"toolCallCount":3,"completedToolCallCount":3,"failedToolCallCount":0,"turnStepCount":2,"assistantCharCount":1520,"thinkingCharCount":430,"elapsedMs":5000},"approval":null,"goal":{"goalId":"goal_123","status":"complete","reason":"Objective achieved.","turnsUsed":2,"tokensUsed":12000,"wallClockMs":45000},"warnings":[],"files":{"outputDir":"/tmp/kimi-run","responses":[{"turnIndex":1,"turnId":7,"path":"/tmp/kimi-run/turns/turn-0001.md","state":"completed","bytes":4210,"updatedAt":"2026-06-05T00:00:10.000Z"},{"turnIndex":2,"turnId":8,"path":"/tmp/kimi-run/turns/turn-0002.md","state":"completed","bytes":2832,"updatedAt":"2026-06-05T00:00:25.000Z"}],"finalResponse":{"turnIndex":2,"turnId":8,"path":"/tmp/kimi-run/turns/turn-0002.md","state":"completed","bytes":2832,"updatedAt":"2026-06-05T00:00:25.000Z"},"goalStatus":{"path":"/tmp/kimi-run/goal-status.json","state":"completed","bytes":168,"updatedAt":"2026-06-05T00:00:25.000Z"}}} +``` + +`--metadata-only` shall omit the Markdown response body: + +```text +{"type":"headless.result","schemaVersion":1,"runId":"run_123","sessionId":"ses_123","turnId":7,"state":"completed","responseFormat":"omitted","responseOmitted":true,"resumeCommand":"kimi -r ses_123","summary":{"toolCallCount":3,"completedToolCallCount":3,"failedToolCallCount":0,"turnStepCount":2,"assistantCharCount":1520,"thinkingCharCount":430,"elapsedMs":5000},"approval":null,"goal":null,"warnings":[],"files":{"outputDir":null,"responses":[],"finalResponse":null,"goalStatus":null}} +``` + +When the run fails before a Markdown response exists, stdout shall contain one metadata line: + +```text +{"type":"headless.result","schemaVersion":1,"runId":"run_123","sessionId":"ses_123","turnId":7,"state":"failed","responseFormat":"omitted","responseOmitted":true,"error":{"message":"PROVIDER_ERROR: request failed"}} +``` + +Default stderr may contain progress, warnings, and errors. + +This plan shall not add event JSONL output. + +Event stream output belongs to a later daemon or session-inspection design. + +## Goal Mode Contract + +`kimi headless` shall support a dedicated goal option. + +Supported first-slice commands: + +```sh +kimi headless --goal "ship the refactor" +kimi headless run --goal "ship the refactor" +kimi headless run --replace-goal "ship the refactor" +``` + +Use the existing goal creation, goal replacement, and goal exit-code behavior from `apps/kimi-code/src/cli/goal-prompt.ts`. + +Goal behavior: + +- `--goal ` creates a goal and sends the objective as the turn prompt. +- `--replace-goal ` replaces the active goal and sends the objective as the turn prompt. +- The run remains turn-based at the session layer. +- The CLI process may run multiple turns when goal mode drives the session. +- The process exits when the goal reaches a terminal state or the headless runner receives a terminal turn result. +- Metadata and status JSON shall include a `goal` object when the run is goal-backed. +- Metadata and status JSON shall include `files.responses` for each completed turn. +- Each turn response shall be written as an opaque Markdown file. +- Headless mode shall not add generated headings or goal-status text to turn response Markdown. +- Goal status shall be available in `goal`, `files.goalStatus`, and the final metadata line. + +Goal metadata shape: + +```json +{ + "goalId": "goal_123", + "status": "complete", + "reason": "Objective achieved.", + "turnsUsed": 3, + "tokensUsed": 12000, + "wallClockMs": 45000 +} +``` + +First-slice goal support shall not add the TUI-only `/goal status`, `/goal pause`, `/goal resume`, `/goal cancel`, or `/goal next` command forms. + +If a program needs those forms, add them as explicit headless subcommands in a later plan. + +## Plan Approval Contract + +Headless mode shall not auto-approve plan exit by default. + +The approval handler shall treat plan-exit approval separately from tool approvals. + +Default behavior: + +- If the agent requests plan-exit approval, set state to `approval_required`. +- Write the approval details to the status file. +- Emit a metadata result with `state: "approval_required"`. +- Return a cancelled or rejected approval response with feedback that the caller must rerun with `--approve-plan` or `--reject-plan`. + +`--approve-plan` behavior: + +- Approve plan-exit requests only. +- Do not approve arbitrary tool calls. +- Keep normal headless approval behavior for other approvals. +- Record `approval.decision: "approved"` and `approval.decidedByFlag: "approve-plan"` in the status file and metadata header. +- If no plan-exit approval is requested during the run, continue normally and record a non-fatal `PLAN_FLAG_UNUSED` warning. + +`--reject-plan` behavior: + +- Reject plan-exit requests only. +- Select the existing `Reject and Exit` plan-review choice. +- Do not reject arbitrary tool calls. +- Record `approval.decision: "rejected"` and `approval.decidedByFlag: "reject-plan"` in the status file and metadata header. +- If no plan-exit approval is requested during the run, continue normally and record a non-fatal `PLAN_FLAG_UNUSED` warning. + +`--approve-plan` and `--reject-plan` shall conflict. + +If both flags are present, fail during CLI validation before creating or resuming a session. + +Approval status shape: + +```json +{ + "kind": "plan", + "toolCallId": "call_123", + "decision": "required", + "decidedByFlag": null, + "message": "Plan approval is required. Rerun with --approve-plan to approve plan exit or --reject-plan to reject and exit." +} +``` + +The first slice does not need to persist a pending approval across processes. + +Unused plan flag warning shape: + +```json +{ + "code": "PLAN_FLAG_UNUSED", + "message": "--approve-plan was set, but no plan approval was requested." +} +``` + +Warnings shall be written to stderr, the status file, and the final metadata line. + +## File Structure + +Create: + +- `apps/kimi-code/src/cli/headless/commands.ts` + - Registers the `headless` command group. + - Parses only headless options. + - Does not reuse prompt-mode `--output-format`. +- `apps/kimi-code/src/cli/headless/run.ts` + - Owns the `headless run` flow. + - Creates or resumes sessions through `KimiHarness`. + - Runs prompt-backed or goal-backed headless execution. + - Writes Markdown to stdout only for default non-goal runs. + - Handles `--goal` and `--replace-goal` through the existing goal helpers. +- `apps/kimi-code/src/cli/headless/status-file.ts` + - Owns status file types. + - Owns status file preflight. + - Owns atomic status writes. + - Owns status file reads for `headless status`. +- `apps/kimi-code/src/cli/headless/output.ts` + - Owns metadata header formatting. + - Ensures assistant Markdown is never embedded in the metadata JSON. +- `apps/kimi-code/src/cli/headless/output-files.ts` + - Resolves the output directory. + - Owns atomic response file writes. + - Owns goal status file writes. +- `apps/kimi-code/src/cli/headless/control.ts` + - Owns control file request and applied-result types. + - Owns atomic control request writes for helper commands. + - Owns control request polling for the running process. +- `apps/kimi-code/src/cli/headless/approval.ts` + - Owns headless approval behavior. + - Separates plan-exit approval from other approvals. +- `apps/kimi-code/test/cli/headless.test.ts` + - Covers command parsing, help text, status command behavior, cwd handling, approval, and output. + +Create: + +- `packages/node-sdk/src/session-lock.ts` + - Owns session lock acquire and release. +- `packages/node-sdk/test/session-lock.test.ts` + - Covers lock acquisition, busy lock, stale lock, and release. + +Modify: + +- `apps/kimi-code/src/cli/commands.ts` + - Register the `headless` command group. +- `apps/kimi-code/src/main.ts` + - Route the headless command. +- `apps/kimi-code/test/cli/main.test.ts` + - Cover routing to headless mode. +- `apps/kimi-code/test/cli/options.test.ts` + - Keep existing `-p` assertions unchanged. + - Add regression assertions that `--output-format` remains prompt-mode only. +- `apps/kimi-code/test/cli/run-prompt.test.ts` + - Run existing prompt-mode tests as regression coverage. +- `packages/node-sdk/src/index.ts` + - Export the session lock helper. + +Do not modify: + +- `apps/kimi-code/src/cli/run-prompt.ts` +- `PromptOutputFormat` +- prompt-mode `CLIOptions` + +If implementation later needs shared code from `run-prompt.ts`, extract it in a separate reviewed patch with prompt-mode regression tests. + +## Task 1: Register the Headless Command + +**Files:** + +- Create: `apps/kimi-code/src/cli/headless/commands.ts` +- Modify: `apps/kimi-code/src/cli/commands.ts` +- Modify: `apps/kimi-code/src/main.ts` +- Test: `apps/kimi-code/test/cli/headless.test.ts` +- Test: `apps/kimi-code/test/cli/main.test.ts` +- Test: `apps/kimi-code/test/cli/options.test.ts` + +- [ ] **Step 1: Write command parsing tests** + +Add tests that parse: + +```ts +expect(parseHeadless(['headless', 'run', '--prompt', 'inspect'])).toMatchObject({ + prompt: 'inspect', + cwd: undefined, + metadataOnly: false, +}); + +expect(parseHeadless(['headless', 'run', '--cwd', '/repo', '--prompt', 'inspect'])).toMatchObject({ + prompt: 'inspect', + cwd: '/repo', +}); + +expect(parseHeadless(['headless', 'run', '--prompt', 'inspect', '--status-file', '/tmp/kimi.json'])).toMatchObject({ + prompt: 'inspect', + statusFile: '/tmp/kimi.json', +}); + +expect(parseHeadless(['headless', 'run', '--prompt', 'inspect', '--output-dir', '/tmp/kimi-run'])).toMatchObject({ + prompt: 'inspect', + outputDir: '/tmp/kimi-run', +}); + +expect(parseHeadless(['headless', '--goal', 'raise coverage to 99.5%'])).toMatchObject({ + goal: 'raise coverage to 99.5%', +}); + +expect(parseHeadless(['headless', 'run', '--goal', 'raise coverage to 99.5%'])).toMatchObject({ + goal: 'raise coverage to 99.5%', +}); + +expect(parseHeadless(['headless', 'run', '--replace-goal', 'raise coverage to 99.5%'])).toMatchObject({ + replaceGoal: 'raise coverage to 99.5%', +}); + +expect(parseHeadless(['headless', 'run', '--prompt', 'inspect', '--metadata-only'])).toMatchObject({ + metadataOnly: true, +}); + +expect(parseHeadless(['headless', 'run', '--prompt', 'inspect', '--approve-plan'])).toMatchObject({ + approvePlan: true, +}); + +expect(parseHeadless(['headless', 'run', '--prompt', 'inspect', '--reject-plan'])).toMatchObject({ + rejectPlan: true, +}); + +expect(parseHeadless(['headless', 'goal', 'pause', '--file', '/tmp/kimi-run/status.json'])).toMatchObject({ + action: 'pause_goal', + statusFile: '/tmp/kimi-run/status.json', + wait: false, +}); + +expect(parseHeadless(['headless', 'goal', 'cancel', '--file', '/tmp/kimi-run/status.json', '--wait'])).toMatchObject({ + action: 'cancel_goal', + statusFile: '/tmp/kimi-run/status.json', + wait: true, +}); + +expect(parseHeadless(['headless', 'goal', 'interrupt', '--file', '/tmp/kimi-run/status.json'])).toMatchObject({ + action: 'interrupt', + statusFile: '/tmp/kimi-run/status.json', +}); +``` + +Add a regression test that `kimi -p "inspect" --output-format=stream-json` still parses through the existing prompt-mode path. + +Add a rejection test that `kimi headless run --prompt inspect --output-format=stream-json` fails with a headless-specific message. + +Add a rejection test that `kimi headless run --prompt inspect --approve-plan --reject-plan` fails before the run starts. + +Add a rejection test that `kimi headless run --prompt inspect --goal "raise coverage"` fails before the run starts. + +Add a rejection test that `kimi headless run --goal "raise coverage" --replace-goal "raise coverage"` fails before the run starts. + +Add a rejection test that `kimi headless run` fails because it has no input source. + +- [ ] **Step 2: Run the focused tests** + +Run: + +```sh +pnpm vitest run apps/kimi-code/test/cli/headless.test.ts apps/kimi-code/test/cli/options.test.ts apps/kimi-code/test/cli/main.test.ts +``` + +Expected: fail because the headless command group does not exist. + +- [ ] **Step 3: Add headless option types** + +Create a headless-only type: + +```ts +export interface HeadlessRunOptions { + readonly prompt?: string; + readonly goal?: string; + readonly replaceGoal?: string; + readonly cwd?: string; + readonly session?: string; + readonly continue: boolean; + readonly model?: string; + readonly statusFile?: string; + readonly outputDir?: string; + readonly metadataOnly: boolean; + readonly approvePlan: boolean; + readonly rejectPlan: boolean; + readonly skillsDirs: readonly string[]; +} +``` + +Do not add these fields to prompt-mode `CLIOptions`. + +- [ ] **Step 4: Register commands** + +Register: + +```ts +program + .command('headless') + .description('Run and inspect non-interactive Kimi Code turns.'); +``` + +Register subcommands: + +- `headless run` +- `headless status` +- `headless goal pause` +- `headless goal cancel` +- `headless goal interrupt` + +Register `headless --goal ` as a shortcut that calls the `headless run` handler with `goal` set. + +Wire `headless run` to a new handler type. + +Wire `headless status` to a new handler type. + +- [ ] **Step 5: Run the focused tests** + +Run: + +```sh +pnpm vitest run apps/kimi-code/test/cli/headless.test.ts apps/kimi-code/test/cli/options.test.ts apps/kimi-code/test/cli/main.test.ts +``` + +Expected: pass. + +## Task 2: Add Status File Helpers + +**Files:** + +- Create: `apps/kimi-code/src/cli/headless/status-file.ts` +- Test: `apps/kimi-code/test/cli/headless.test.ts` + +- [ ] **Step 1: Write status file tests** + +Cover: + +- atomic write and read +- overwrite an existing status file +- overwrite an existing temp file +- fail when the parent directory does not exist +- fail when the path parent is not writable +- include `runId` +- include summary counters +- include goal status when the run is goal-backed +- include non-fatal warnings +- include response and goal-status file lists + +Use this test shape: + +```ts +const status: HeadlessRunStatus = { + schemaVersion: 1, + runId: 'run_test', + pid: 123, + sessionId: 'ses_test', + turnId: 1, + state: 'running', + workDir: '/repo', + model: 'kimi-code/k2.5', + startedAt: '2026-06-05T00:00:00.000Z', + updatedAt: '2026-06-05T00:00:01.000Z', + elapsedMs: 1000, + lastEvent: 'turn.started', + activeTool: null, + summary: { + turnStepCount: 1, + toolCallCount: 0, + completedToolCallCount: 0, + failedToolCallCount: 0, + assistantCharCount: 0, + thinkingCharCount: 0, + }, + approval: null, + goal: null, + warnings: [], + files: { + outputDir: null, + responses: [], + finalResponse: null, + goalStatus: null, + }, + control: null, + error: null, + resumeCommand: 'kimi -r ses_test', +}; + +await writeHeadlessRunStatus(filePath, status); +await expect(readHeadlessRunStatus(filePath)).resolves.toEqual(status); +``` + +- [ ] **Step 2: Run the focused tests** + +Run: + +```sh +pnpm vitest run apps/kimi-code/test/cli/headless.test.ts +``` + +Expected: fail because the module does not exist. + +- [ ] **Step 3: Implement the helper** + +Create these exports: + +```ts +export type HeadlessRunState = + | 'starting' + | 'running' + | 'approval_required' + | 'paused' + | 'completed' + | 'failed' + | 'cancelled' + | 'interrupted'; + +export interface HeadlessRunSummary { + readonly turnStepCount: number; + readonly toolCallCount: number; + readonly completedToolCallCount: number; + readonly failedToolCallCount: number; + readonly assistantCharCount: number; + readonly thinkingCharCount: number; +} + +export interface HeadlessActiveToolStatus { + readonly toolCallId: string; + readonly name: string; + readonly description?: string; +} + +export interface HeadlessApprovalStatus { + readonly kind: 'plan'; + readonly toolCallId?: string; + readonly decision: 'required' | 'approved' | 'rejected'; + readonly decidedByFlag: 'approve-plan' | 'reject-plan' | null; + readonly message: string; +} + +export interface HeadlessGoalStatus { + readonly goalId: string | null; + readonly status: string | null; + readonly reason: string | null; + readonly turnsUsed: number | null; + readonly tokensUsed: number | null; + readonly wallClockMs: number | null; +} + +export interface HeadlessWarning { + readonly code: string; + readonly message: string; +} + +export type HeadlessOutputFileState = 'writing' | 'completed' | 'failed'; + +export interface HeadlessResponseFile { + readonly turnIndex: number; + readonly turnId: number | null; + readonly path: string; + readonly state: HeadlessOutputFileState; + readonly bytes: number | null; + readonly updatedAt: string; +} + +export interface HeadlessSidecarFile { + readonly path: string; + readonly state: HeadlessOutputFileState; + readonly bytes: number | null; + readonly updatedAt: string; +} + +export interface HeadlessRunFiles { + readonly outputDir: string | null; + readonly responses: readonly HeadlessResponseFile[]; + readonly finalResponse: HeadlessResponseFile | null; + readonly goalStatus: HeadlessSidecarFile | null; +} + +export type HeadlessControlAction = 'pause_goal' | 'cancel_goal' | 'interrupt'; + +export interface HeadlessControlRequest { + readonly schemaVersion: 1; + readonly runId: string; + readonly commandId: string; + readonly action: HeadlessControlAction; + readonly requestedAt: string; +} + +export interface HeadlessAppliedControlRequest { + readonly commandId: string; + readonly action: HeadlessControlAction; + readonly appliedAt: string; + readonly result: 'applied' | 'failed'; + readonly error?: { readonly message: string }; +} + +export interface HeadlessRunControl { + readonly path: string; + readonly supportedActions: readonly HeadlessControlAction[]; + readonly lastRequest: HeadlessControlRequest | null; + readonly lastApplied: HeadlessAppliedControlRequest | null; +} + +export interface HeadlessRunStatus { + readonly schemaVersion: 1; + readonly runId: string; + readonly pid: number; + readonly sessionId: string | null; + readonly turnId: number | null; + readonly state: HeadlessRunState; + readonly workDir: string; + readonly model: string | null; + readonly startedAt: string; + readonly updatedAt: string; + readonly elapsedMs: number; + readonly lastEvent: string | null; + readonly activeTool: HeadlessActiveToolStatus | null; + readonly summary: HeadlessRunSummary; + readonly approval: HeadlessApprovalStatus | null; + readonly goal: HeadlessGoalStatus | null; + readonly warnings: readonly HeadlessWarning[]; + readonly files: HeadlessRunFiles; + readonly control: HeadlessRunControl | null; + readonly error: { readonly message: string } | null; + readonly resumeCommand: string | null; +} +``` + +Implement: + +```ts +export async function preflightHeadlessStatusFile(filePath: string): Promise; + +export async function writeHeadlessRunStatus( + filePath: string, + status: HeadlessRunStatus, +): Promise; + +export async function readHeadlessRunStatus(filePath: string): Promise; +``` + +- [ ] **Step 4: Run the focused tests** + +Run: + +```sh +pnpm vitest run apps/kimi-code/test/cli/headless.test.ts +``` + +Expected: pass. + +## Task 3: Add Session Locking + +**Files:** + +- Create: `packages/node-sdk/src/session-lock.ts` +- Modify: `packages/node-sdk/src/index.ts` +- Test: `packages/node-sdk/test/session-lock.test.ts` + +- [ ] **Step 1: Write lock tests** + +Cover: + +- acquire creates `/run.lock` +- second acquire fails with `SESSION_LOCKED` +- release removes the lock +- stale lock with dead pid is removed +- release does not remove another run's lock + +- [ ] **Step 2: Run the focused tests** + +Run: + +```sh +pnpm vitest run packages/node-sdk/test/session-lock.test.ts +``` + +Expected: fail because the lock helper does not exist. + +- [ ] **Step 3: Implement the lock helper** + +Expose: + +```ts +export interface SessionRunLock { + readonly sessionDir: string; + readonly runId: string; + release(): Promise; +} + +export interface AcquireSessionRunLockInput { + readonly sessionDir: string; + readonly runId: string; + readonly pid: number; + readonly command: string; +} + +export async function acquireSessionRunLock( + input: AcquireSessionRunLockInput, +): Promise; +``` + +Use `fs.open(lockPath, 'wx')` for acquisition. + +Use `process.kill(pid, 0)` to detect a live pid where supported. + +If live-pid detection is unavailable, treat the lock as live. + +- [ ] **Step 4: Run the focused tests** + +Run: + +```sh +pnpm vitest run packages/node-sdk/test/session-lock.test.ts +``` + +Expected: pass. + +## Task 4: Add Headless Output and File Helpers + +**Files:** + +- Create: `apps/kimi-code/src/cli/headless/output.ts` +- Create: `apps/kimi-code/src/cli/headless/output-files.ts` +- Test: `apps/kimi-code/test/cli/headless.test.ts` + +- [ ] **Step 1: Write output tests** + +Assert `formatHeadlessMetadataHeader` returns one JSON line and a blank line when `responseOmitted` is false. + +Assert the JSON line does not contain the assistant Markdown response. + +Assert `formatHeadlessMetadataHeader` returns one JSON line and no trailing blank line when `responseOmitted` is true. + +Assert `formatHeadlessMetadataHeader` supports `responseFormat: 'files'`. + +Assert `resolveHeadlessOutputDir` uses: + +- explicit `--output-dir` +- `.d` when only `--status-file` is present +- a run-specific OS temp directory when both options are absent + +Assert `writeHeadlessResponseFile` writes Markdown atomically and returns a completed file record. + +Assert `writeHeadlessGoalStatusFile` writes JSON atomically and returns a completed file record. + +- [ ] **Step 2: Run the focused tests** + +Run: + +```sh +pnpm vitest run apps/kimi-code/test/cli/headless.test.ts +``` + +Expected: fail because output helpers do not exist. + +- [ ] **Step 3: Implement output helpers** + +Create: + +```ts +export interface HeadlessMetadataHeader { + readonly type: 'headless.result'; + readonly schemaVersion: 1; + readonly runId: string; + readonly sessionId: string | null; + readonly turnId: number | null; + readonly state: HeadlessRunState; + readonly responseFormat: 'markdown' | 'files' | 'omitted'; + readonly responseOmitted: boolean; + readonly resumeCommand: string | null; + readonly summary: HeadlessRunSummary; + readonly approval: HeadlessApprovalStatus | null; + readonly goal: HeadlessGoalStatus | null; + readonly warnings: readonly HeadlessWarning[]; + readonly files: HeadlessRunFiles; + readonly error?: { readonly message: string }; +} + +export function formatHeadlessMetadataHeader(header: HeadlessMetadataHeader): string { + return header.responseOmitted + ? `${JSON.stringify(header)}\n` + : `${JSON.stringify(header)}\n\n`; +} +``` + +Create output file helpers: + +```ts +export interface ResolveHeadlessOutputDirInput { + readonly explicitOutputDir?: string; + readonly statusFile?: string; + readonly runId: string; +} + +export function resolveHeadlessOutputDir(input: ResolveHeadlessOutputDirInput): string; + +export async function preflightHeadlessOutputDir(outputDir: string): Promise; + +export async function writeHeadlessResponseFile(input: { + readonly outputDir: string; + readonly turnIndex: number; + readonly turnId: number | null; + readonly markdown: string; + readonly updatedAt: string; +}): Promise; + +export async function writeHeadlessGoalStatusFile(input: { + readonly outputDir: string; + readonly goal: HeadlessGoalStatus; + readonly updatedAt: string; +}): Promise; +``` + +- [ ] **Step 4: Run the focused tests** + +Run: + +```sh +pnpm vitest run apps/kimi-code/test/cli/headless.test.ts +``` + +Expected: pass. + +## Task 5: Add Headless Goal Control Helpers + +**Files:** + +- Create: `apps/kimi-code/src/cli/headless/control.ts` +- Modify: `apps/kimi-code/src/cli/headless/commands.ts` +- Test: `apps/kimi-code/test/cli/headless.test.ts` + +- [ ] **Step 1: Write control helper tests** + +Cover: + +- `writeHeadlessControlRequest` writes a control file atomically. +- `readHeadlessControlRequest` reads the latest request. +- `pause` helper command writes `action: "pause_goal"`. +- `cancel` helper command writes `action: "cancel_goal"`. +- `interrupt` helper command writes `action: "interrupt"`. +- helper commands read `control.path` from the status file. +- helper commands fail when the status file has no control path. +- `--wait` polls until `control.lastApplied.commandId` matches. + +- [ ] **Step 2: Run the focused tests** + +Run: + +```sh +pnpm vitest run apps/kimi-code/test/cli/headless.test.ts +``` + +Expected: fail because control helpers do not exist. + +- [ ] **Step 3: Implement control helpers** + +Create: + +```ts +export async function writeHeadlessControlRequest( + controlPath: string, + request: HeadlessControlRequest, +): Promise; + +export async function readHeadlessControlRequest( + controlPath: string, +): Promise; + +export async function waitForHeadlessControlApplied(input: { + readonly statusFile: string; + readonly commandId: string; + readonly timeoutMs: number; +}): Promise; +``` + +Use atomic replace for control request writes. + +Generate a new `commandId` in each helper command. + +- [ ] **Step 4: Run the focused tests** + +Run: + +```sh +pnpm vitest run apps/kimi-code/test/cli/headless.test.ts +``` + +Expected: pass. + +## Task 6: Add Headless Approval Handling + +**Files:** + +- Create: `apps/kimi-code/src/cli/headless/approval.ts` +- Test: `apps/kimi-code/test/cli/headless.test.ts` + +- [ ] **Step 1: Write approval tests** + +Cover: + +- plan-exit approval without `--approve-plan` returns rejected +- plan-exit approval sets `approval_required` +- plan-exit approval with `--approve-plan` returns approved +- plan-exit approval with `--reject-plan` selects `Reject and Exit` +- `--approve-plan` and `--reject-plan` conflict during option validation +- `--approve-plan` does not approve unrelated tools +- `--reject-plan` does not reject unrelated tools +- unused `--approve-plan` records `PLAN_FLAG_UNUSED` after a run with no plan approval +- unused `--reject-plan` records `PLAN_FLAG_UNUSED` after a run with no plan approval + +- [ ] **Step 2: Run the focused tests** + +Run: + +```sh +pnpm vitest run apps/kimi-code/test/cli/headless.test.ts +``` + +Expected: fail because approval helpers do not exist. + +- [ ] **Step 3: Implement approval helpers** + +Create: + +```ts +export interface HeadlessApprovalOptions { + readonly approvePlan: boolean; + readonly rejectPlan: boolean; + readonly onPlanApprovalRequired: (approval: HeadlessApprovalStatus) => void; +} + +export function getUnusedPlanFlagWarning(options: { + readonly approvePlan: boolean; + readonly rejectPlan: boolean; + readonly planApprovalSeen: boolean; +}): HeadlessWarning | null; + +export function createHeadlessApprovalHandler( + options: HeadlessApprovalOptions, +): ApprovalHandler; +``` + +Detect plan-exit approval from the approval request action or display data. + +Return approved only when `approvePlan` is true and the approval is for plan exit. + +Return rejected with the existing `Reject and Exit` choice only when `rejectPlan` is true and the approval is for plan exit. + +Return rejected or cancelled for plan exit when both flags are false. + +Keep the existing headless approval behavior for non-plan approvals. + +After the run completes, call `getUnusedPlanFlagWarning`. + +If it returns a warning, append it to stderr, status JSON, and final metadata. + +- [ ] **Step 4: Run the focused tests** + +Run: + +```sh +pnpm vitest run apps/kimi-code/test/cli/headless.test.ts +``` + +Expected: pass. + +## Task 7: Implement `headless run` + +**Files:** + +- Create: `apps/kimi-code/src/cli/headless/run.ts` +- Modify: `apps/kimi-code/src/cli/headless/commands.ts` +- Test: `apps/kimi-code/test/cli/headless.test.ts` +- Test: `packages/node-sdk/test/session-lock.test.ts` + +- [ ] **Step 1: Write run-flow tests** + +Use a fake harness or the existing CLI fake pattern. + +Cover: + +- new session uses `--cwd` +- new session defaults to `process.cwd()` +- `--continue` filters sessions by resolved cwd +- `--session` with mismatched `--cwd` fails before resume +- status preflight runs before session creation +- output directory preflight runs before prompt dispatch when files are needed +- existing session lock fails before prompt +- lock releases after completed turn +- default output prints JSON metadata followed by Markdown +- `--metadata-only` prints JSON metadata without Markdown +- `--output-dir` writes non-goal response Markdown to a file and lists it in metadata +- `--goal ` creates a goal and sends the objective as the prompt +- `--replace-goal ` replaces the goal and sends the objective as the prompt +- goal-backed metadata includes `goal.status` +- goal-backed stdout contains metadata only +- goal-backed output writes one Markdown file per completed turn +- goal-backed status JSON lists every completed turn file +- goal-backed output writes `goal-status.json` +- goal-backed status JSON includes `control.path` +- `pause_goal` lets the current turn finish and pauses before the next goal turn +- `pause_goal` does not call `session.cancel()` +- `cancel_goal` lets the current turn finish and cancels before the next goal turn +- `cancel_goal` does not call `session.cancel()` +- `interrupt` calls `session.cancel()` and exits without waiting for the turn to finish +- `interrupt` leaves the goal paused when possible +- response files contain only assistant Markdown, with no generated status wrapper +- unused `--approve-plan` and `--reject-plan` flags produce non-fatal warnings +- status summary counters update after turn, tool, assistant, and thinking events +- prompt-mode tests still pass without changes + +- [ ] **Step 2: Run focused tests** + +Run: + +```sh +pnpm vitest run apps/kimi-code/test/cli/headless.test.ts packages/node-sdk/test/session-lock.test.ts apps/kimi-code/test/cli/run-prompt.test.ts +``` + +Expected: fail because `headless run` is not implemented. + +- [ ] **Step 3: Implement session resolution** + +Implement this order: + +1. Resolve cwd. +2. Preflight status file if present. +3. Create a run id. +4. Resolve and preflight output directory when `--output-dir` is present or goal mode is active. +5. Create the harness. +6. Resolve the session: + - `--session`: list and validate target. + - `--continue`: list latest for cwd. + - neither: create a new session. +7. Acquire session lock. +8. Resolve `--goal` or `--replace-goal` input when present. +9. Create the control file path when goal mode is active. +10. Install approval and question handlers. +11. Start the prompt or goal-backed prompt. +12. Listen to events until `turn.ended`, goal terminal state, or `interrupt`. +13. Poll the control file while the run is active. +14. For `pause_goal`, let the active turn finish, then pause before the next goal turn. +15. For `cancel_goal`, let the active turn finish, then cancel before the next goal turn. +16. For `interrupt`, pause the goal when possible and cancel the active turn immediately. +17. Write each completed turn response file when file output is active. +18. Write `goal-status.json` when goal mode is active. +19. Record unused plan flag warnings if needed. +20. Release lock and close harness. + +- [ ] **Step 4: Implement event summary updates** + +Increment: + +- `turnStepCount` on `turn.step.started` +- `toolCallCount` on `tool.call.started` +- `completedToolCallCount` on non-error `tool.result` +- `failedToolCallCount` on error `tool.result` +- `assistantCharCount` by assistant delta length +- `thinkingCharCount` by thinking delta length + +Update `activeTool` on `tool.call.started`. + +Clear `activeTool` when the matching tool result arrives. + +- [ ] **Step 5: Implement stdout and stderr behavior** + +Default stdout: + +```text +{"type":"headless.result",...} + + +``` + +`--metadata-only` stdout: + +```text +{"type":"headless.result",...} +``` + +Goal-backed stdout: + +```text +{"type":"headless.result","responseFormat":"files",...} +``` + +Do not print goal-backed Markdown to stdout. + +- [ ] **Step 6: Run focused tests** + +Run: + +```sh +pnpm vitest run apps/kimi-code/test/cli/headless.test.ts packages/node-sdk/test/session-lock.test.ts apps/kimi-code/test/cli/run-prompt.test.ts +``` + +Expected: pass. + +## Task 8: Add `headless status` + +**Files:** + +- Modify: `apps/kimi-code/src/cli/headless/commands.ts` +- Test: `apps/kimi-code/test/cli/headless.test.ts` + +- [ ] **Step 1: Write status command tests** + +Assert: + +```sh +kimi headless status --file /tmp/kimi-run.json +``` + +prints a compact human summary: + +```text +running - session ses_123 - turn 7 - tools 2/3 - updated 2026-06-05T00:00:05.000Z +``` + +Assert: + +```sh +kimi headless status --file /tmp/kimi-run.json --json +``` + +prints the raw JSON. + +Assert `approval_required` output includes: + +```text +approval required - plan - rerun with --approve-plan or --reject-plan +``` + +Assert goal-backed output includes the goal status when present: + +```text +goal complete - turns 3 - tokens 12000 +``` + +Assert file-backed output includes the output directory and completed response count: + +```text +files 2 - output /tmp/kimi-run +``` + +Assert pending control output includes: + +```text +control pending - pause_goal - command cmd_001 +``` + +- [ ] **Step 2: Run focused tests** + +Run: + +```sh +pnpm vitest run apps/kimi-code/test/cli/headless.test.ts +``` + +Expected: fail because `headless status` is not implemented. + +- [ ] **Step 3: Implement status command output** + +Human output shall include: + +- state +- session id when present +- turn id when present +- completed and total tool calls +- active tool when present +- approval state when present +- goal state when present +- control pending or applied state when present +- completed response file count when present +- output directory when present +- updated timestamp + +- [ ] **Step 4: Run focused tests** + +Run: + +```sh +pnpm vitest run apps/kimi-code/test/cli/headless.test.ts +``` + +Expected: pass. + +## Task 9: Improve Help Text for Agents + +**Files:** + +- Modify: `apps/kimi-code/src/cli/headless/commands.ts` +- Test: `apps/kimi-code/test/cli/headless.test.ts` + +- [ ] **Step 1: Write help tests** + +Assert the help includes: + +- "Run one turn without the TUI." +- "The process exits when the turn ends." +- "Default output starts with one JSON metadata line, then Markdown." +- `--cwd ` +- `--status-file ` +- `--output-dir ` +- `--metadata-only` +- `--approve-plan` +- `--reject-plan` +- `--goal ` +- `--replace-goal ` +- `headless goal pause --file ` +- `headless goal cancel --file ` +- `headless goal interrupt --file ` +- A `headless status` example +- No `--output-format` + +- [ ] **Step 2: Run focused tests** + +Run: + +```sh +pnpm vitest run apps/kimi-code/test/cli/headless.test.ts +``` + +Expected: fail until help text is added. + +- [ ] **Step 3: Add help examples** + +Use Commander `.addHelpText('after', ...)`. + +Keep examples short and copy-pasteable. + +- [ ] **Step 4: Run focused tests** + +Run: + +```sh +pnpm vitest run apps/kimi-code/test/cli/headless.test.ts +``` + +Expected: pass. + +## Task 10: Verify and Prepare Release Notes + +**Files:** + +- Modify: `docs/en/reference/kimi-command.md` +- Modify: `docs/zh/reference/kimi-command.md` +- Create: `.changeset/headless-mode.md` + +- [ ] **Step 1: Run focused tests** + +Run: + +```sh +pnpm vitest run apps/kimi-code/test/cli/headless.test.ts apps/kimi-code/test/cli/main.test.ts apps/kimi-code/test/cli/options.test.ts apps/kimi-code/test/cli/run-prompt.test.ts packages/node-sdk/test/session-lock.test.ts +``` + +Expected: pass. + +- [ ] **Step 2: Run app and SDK typechecks** + +Run: + +```sh +pnpm --filter @moonshot-ai/kimi-code-sdk run typecheck +pnpm --filter @moonshot-ai/kimi-code run typecheck +``` + +Expected: pass. + +- [ ] **Step 3: Update command reference docs** + +Document: + +- `kimi headless run` +- `kimi headless status` +- `--cwd` +- `--status-file` +- `--output-dir` +- `--metadata-only` +- `--approve-plan` +- `--reject-plan` +- `kimi headless --goal ` +- `kimi headless run --goal ` +- `kimi headless run --replace-goal ` +- `kimi headless goal pause --file ` +- `kimi headless goal cancel --file ` +- `kimi headless goal interrupt --file ` + +State that `kimi -p` remains the existing prompt shortcut. + +Keep English and Chinese docs in sync. + +- [ ] **Step 4: Generate a changeset** + +Use the `gen-changesets` skill. + +This feature changes user-facing CLI behavior, so the expected package is `@moonshot-ai/kimi-code`. + +If the session lock helper is exported from the SDK, include `@moonshot-ai/kimi-code-sdk`. + +Use a `minor` bump unless reviewers identify a breaking change. + +## Risks + +- Accidental prompt-mode changes would break scripts. Keep `-p` untouched and run prompt-mode regression tests. +- Lock files can survive crashes. Detect stale pids and fail clearly when stale detection is not possible. +- Status file writes can become noisy. Write on state changes and important tool transitions only. +- Metadata can become hard to read if it embeds responses. Keep responses out of JSON and store goal-mode turn output in files. +- Plan approval can be ambiguous. Only `--approve-plan` approves plan exit, and only `--reject-plan` rejects and exits plan mode. +- Plan flags can be stale. Treat unused plan flags as warnings, not fatal errors. +- Goal handling can drift from TUI behavior. Reuse the existing headless goal helpers and exit-code mapping for create and replace. +- Response Markdown can collide with generated Markdown structure. Do not inject headings or status blocks into response files. +- Goal pause semantics can drift from the TUI. `pause_goal` shall be graceful and shall not interrupt the active turn. + +## Definition of Done + +- `kimi headless --help` teaches an agent how to run and inspect one turn. +- `kimi headless run --prompt "..."` runs one turn without the TUI. +- `kimi -p` behavior and output stay unchanged. +- `--cwd` controls the session workspace. +- session locks prevent concurrent local runs for the same session. +- `--status-file` writes valid JSON during the turn and at exit. +- status JSON includes run id, active tool, counters, elapsed time, approval state, goal state, warnings, file lists, and errors. +- `headless status --file ` reads the status file. +- default stdout starts with JSON metadata and then prints the verbatim assistant Markdown response. +- `--output-dir` writes response files and lists them in metadata and status JSON. +- `--metadata-only` omits the Markdown response body. +- `--approve-plan` approves only plan-exit requests. +- `--reject-plan` rejects only plan-exit requests by selecting `Reject and Exit`. +- unused `--approve-plan` and `--reject-plan` record non-fatal warnings and do not stop the run. +- `kimi headless --goal ` works as a shortcut for a goal-backed run. +- `--goal ` and `--replace-goal ` work in `headless run`. +- goal-backed runs write each turn response to a separate Markdown file. +- goal-backed runs list response files and `goal-status.json` in status JSON. +- goal-backed stdout contains metadata only and does not print response Markdown. +- `headless goal pause --file ` requests a graceful pause after the current turn. +- `headless goal cancel --file ` requests graceful cancellation after the current turn. +- `headless goal interrupt --file ` interrupts the active turn immediately and leaves the goal paused when possible. +- Focused tests and typechecks pass. diff --git a/plans/2026-06-05-headless-trial-report.md b/plans/2026-06-05-headless-trial-report.md new file mode 100644 index 000000000..b7fef0f36 --- /dev/null +++ b/plans/2026-06-05-headless-trial-report.md @@ -0,0 +1,43 @@ +# Headless Mode Trial Report + +## Summary + +Three side projects were created under `/tmp/kimi-headless-examples/` with headless CLI runs. + +Each project used at least 10 completed headless invocations. + +| Project | Completed turns | Verification | +| --- | ---: | --- | +| `headless-js-checklist` | 11 | `node --test test/*.test.js` | +| `headless-python-textstats` | 10 | `python3 -m unittest discover -s tests` | +| `headless-web-timer` | 11 | `node test.js` | + +Project-level DOs and DONTs are in each project's `trial-report.md`. + +## DOs + +- Use `--status-file` on every long or scripted run. +- Use `headless status --file ` while a turn is running. +- Keep each prompt narrow. +- Use `--output-dir` so each turn leaves Markdown and metadata files. +- Keep response Markdown out of JSON for readability. +- Use one output directory per invocation. +- Run the project test command after the final turn. +- Treat stale or misleading status as a product bug, not trial noise. +- Document runtime assumptions in the generated project. + +## DONTs + +- Do not batch too many feature, fixture, doc, and test changes into one turn. +- Do not rely on system `node` outside the repo. It may be older than the repo-required Node version. +- Do not assume a resumed session reports a new numeric `turnId`; count completed CLI invocations for trial accounting. +- Do not ignore slow turns when the status file shows no output files yet. +- Do not treat `pause` as an immediate stop. Immediate active-turn stop belongs to `interrupt`. +- Do not leave reports to the final project check only; reports should include failures and interruptions. + +## Product Notes + +- Live status writes are useful. They showed active tool counts during long turns. +- The Python project exposed a signal-cleanup gap. A pre-fix interrupted process left its status file at `state: "running"`. +- After the signal cleanup fix, a SIGINT smoke exited with code `130` and wrote `state: "cancelled"` with `lastEvent: "signal.sigint"`. +- Long turns tend to happen when a prompt combines feature work, docs, and tests. Smaller follow-up prompts completed more predictably. diff --git a/plans/2026-06-07-headless-complex-app-trials.md b/plans/2026-06-07-headless-complex-app-trials.md new file mode 100644 index 000000000..8262c9a1d --- /dev/null +++ b/plans/2026-06-07-headless-complex-app-trials.md @@ -0,0 +1,1391 @@ +# Headless Complex App Trials Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Use `kimi headless` to build eight substantial, usable applications and reveal headless-mode bugs through long, supervised multi-turn development. + +**Architecture:** Treat headless mode as the worker interface, not the product being built. A human operator drives each project with short prompts, inspects artifacts after every turn, uses Playwright for browser checks, records failures, and sends terse correction prompts. Each app lives in its own directory under `/tmp/kimi-headless-examples/`. + +**Tech Stack:** `kimi headless`, TypeScript, Vite, React, SQLite where useful, local file persistence, Playwright CLI for browser inspection, Vitest or app-appropriate tests. + +--- + +## Reader Outcome + +After this plan, you can run a serious headless-mode trial across eight real applications. + +Each project shall receive 50 to 100 completed headless turns. + +Each project shall become usable by a real person, not just a mocked demo. + +Each project shall have browser inspection, tests, screenshots, status files, run logs, and a written report. + +Execution shall not start until the user approves this plan. + +## Trial Definition + +This plan tests whether `kimi headless` can drive realistic software development. + +The trial does not build applications about headless mode. + +The applications are test payloads. They should be hard enough to expose headless bugs, agent drift, missing status updates, weak recovery behavior, and poor operator ergonomics. + +## Global Trial Contract + +Each app shall use this budget: + +- Minimum completed turns: `50` +- Target completed turns: `60` +- Maximum completed turns: `100` + +Use more than 60 turns when: + +- Playwright inspection finds broken interaction. +- Tests fail after a claimed fix. +- The UI works but feels unfinished. +- Data persistence is incomplete. +- Import, export, search, or settings flows are missing. +- The app cannot be used without reading source code. + +Stop before 100 turns only when all app-specific acceptance gates pass. + +If an invocation is cancelled, interrupted, failed, or stuck, do not count it as a completed turn. + +Record it in the project report and continue with a new completed turn. + +## Headless Bug Triage And Restart Contract + +If the operator finds a headless-mode bug during the experiment, pause the current app trial long enough to classify the bug. + +Record: + +- exact `kimi headless` command +- app directory +- run directory +- turn number +- `status.json` +- `stdout.json` +- `stderr.txt` +- output files +- screenshots if browser state matters +- expected behavior +- actual behavior +- whether the current app folder is still resumable + +Severity rules: + +- **Blocking:** the current session, status, lock, output, or app folder cannot be trusted. +- **High:** the trial can continue, but status, output, lock, signal handling, or resume behavior is wrong. +- **Medium:** the trial can continue, but metadata, help, or reports are confusing. +- **Low:** docs or cosmetic issue. + +Fix headless mode before continuing when the bug affects: + +- session continuation +- status file truth +- output file integrity +- run locks +- signal handling +- prompt or goal control +- metadata needed by the operator + +If the current app project is not safely resumable, do not keep trying to repair that folder. + +Use this restart protocol: + +1. Mark the current folder as abandoned in its `trial-report.md`. +2. Add an entry to `shared/failure-ledger.md`. +3. Preserve the broken folder as evidence. +4. Fix the headless-mode bug if needed. +5. Start the same app idea in a new sibling folder. +6. Start a new Git repo in the new folder. +7. Start a new headless session in the new folder. +8. Continue completed-turn counting from the new folder only. +9. Link the abandoned folder from the new folder's `prompt-log.md` and `trial-report.md`. + +Restart folder names shall use this pattern: + +```text +apps/workflow-automation-builder/ +apps/workflow-automation-builder-restart-01/ +apps/workflow-automation-builder-restart-02/ +``` + +The abandoned folder shall keep: + +- `prompt-log.md` +- `trial-report.md` +- app-local Git history + +The root trial folder shall keep the abandoned folder's run evidence under `runs//`. + +The aggregate report shall count abandoned folders separately from successful final folders. + +## Lazy Human Operator Contract + +The operator shall write prompts like a busy user, not like a detailed spec author. + +Prompt style: + +```text +make the import flow real. csv in, errors visible, sample file too. +``` + +```text +ui is confusing. inspect it in browser and make the main path obvious. +``` + +```text +tests are failing. fix the cause, don't delete coverage. +``` + +```text +ship the saved views feature. make it usable. +``` + +The operator shall still supervise: + +- read status files while turns run +- inspect generated files +- run tests +- open the app in a browser +- use Playwright snapshots and screenshots +- reject shallow work +- send concise correction prompts + +## Per-Turn Prompt Log Contract + +Each app shall have a Markdown prompt log: + +```text +app-name/ + prompt-log.md +``` + +The operator shall update `prompt-log.md` before or immediately after every headless invocation. + +Each completed, failed, cancelled, or stuck invocation shall have one entry. + +Use this format: + +````markdown +## Turn 017 + +**Prompt used:** + +```text +the board reload is broken. fix persistence and commit. +``` + +**Why this prompt now:** Playwright reload check showed the shape layer disappeared after refresh, so the next turn should focus only on persistence. + +**Expected artifact change:** Board state should persist across reload and tests should cover saved shape layers. + +**Result:** Completed. Commit `abc1234` added persistence tests and fixed layer serialization. +```` + +The prompt log shall explain why the operator chose that prompt at that moment. + +The explanation should reference concrete evidence: + +- status file state +- failing test output +- Playwright snapshot or screenshot +- broken browser workflow +- missing acceptance gate +- shallow implementation from the prior turn + +Do not write generic reasons such as "continue development". + +## App-Local Commit Contract + +Each app shall be its own Git repository. + +The headless worker shall make small, frequent commits inside the app directory. + +Commit expectations: + +- Initialize Git in the app during the first turn or first setup turn. +- Commit after each self-contained feature, bug fix, test pass, or UI polish slice. +- Prefer one commit every 1 to 3 completed turns. +- Use conventional commit-style messages where practical. +- Do not make one large final commit. +- Do not commit `runs/`, screenshots, status files, or temporary logs unless the project report explicitly needs them. +- Do not add co-author trailers. + +The operator should often include commit pressure in prompts: + +```text +make saved views work. add tests. commit the focused change. +``` + +```text +ui is messy. polish the card editor only and commit. +``` + +The operator shall verify commit history during supervision: + +```sh +git -C "$APP_DIR" log --oneline --decorate -12 +git -C "$APP_DIR" status --short +``` + +Expected: + +- meaningful recent commits exist +- no accidental `runs/` files are staged +- no unrelated generated files are committed + +## Global Directory Layout + +Create this layout under `/tmp/kimi-headless-examples/`: + +```text +headless-trials-2026-06/ + README.md + tracker.md + operator-log.md + runs/ + collaborative-whiteboard/ + turn-001/ + status.json + stdout.json + stderr.txt + output/ + playwright/ + snapshot.md + screenshot.png + shared/ + prompt-bank.md + playwright-notes.md + failure-ledger.md + apps/ + collaborative-whiteboard/ + kanban-planning-system/ + log-analytics-workbench/ + sql-explorer/ + workflow-automation-builder/ + workflow-automation-builder-restart-01/ + issue-tracker-triage/ + music-library-manager/ + personal-crm/ +``` + +Each app shall contain: + +```text +app-name/ + operator-log.md + prompt-log.md + trial-report.md + README.md + package.json + src/ + tests/ +``` + +Do not place run artifacts under the app directory. + +Use root-level run folders instead: + +```text +headless-trials-2026-06/ + runs/ + app-name/ + turn-001/ + status.json + stdout.json + stderr.txt + output/ + playwright/ +``` + +The worker can delete files under its cwd during scaffold cleanup. + +Status files, stdout, stderr, response files, screenshots, and snapshots shall live outside the worker cwd. + +## Progress Tracker Contract + +Maintain this file throughout execution: + +```text +/tmp/kimi-headless-examples/headless-trials-2026-06/tracker.md +``` + +The tracker is the user's quick progress view when they are not present. + +Update it: + +- before starting an app +- after every completed, failed, cancelled, interrupted, or stuck turn +- after each Playwright inspection +- after each test run +- after each app-local commit check +- after each headless-mode bug triage +- after each restart decision +- before ending any long work session + +Use these status values: + +- `not-started` +- `running` +- `verifying` +- `blocked-headless-bug` +- `blocked-app-bug` +- `abandoned` +- `restarted` +- `done` + +The table shall use this shape: + +```markdown +| Project | Status | Folder | Completed Turns | Failed/Cancelled/Stuck | Target | Max | Restarts | Last Turn | Last Prompt | Last Status | Tests | Browser Check | Commits | Open Issue | Next Action | Updated | +| --- | --- | --- | ---: | ---: | ---: | ---: | ---: | --- | --- | --- | --- | --- | ---: | --- | --- | --- | +| collaborative-whiteboard | not-started | apps/collaborative-whiteboard | 0 | 0 | 60 | 100 | 0 | - | - | - | - | - | 0 | - | bootstrap app | 2026-06-07T00:00:00+08:00 | +``` + +Column rules: + +- `Project`: stable app slug. +- `Status`: one of the status values above. +- `Folder`: current active folder, or final folder after restart. +- `Completed Turns`: only turns with `state: "completed"`. +- `Failed/Cancelled/Stuck`: all non-completed invocations. +- `Target`: default `60`. +- `Max`: default `100`. +- `Restarts`: number of abandoned folders for the app. +- `Last Turn`: latest run directory name. +- `Last Prompt`: short summary, not the full prompt. The full prompt belongs in `prompt-log.md`. +- `Last Status`: latest status-file state and useful counters. +- `Tests`: latest test command result. +- `Browser Check`: latest Playwright snapshot or screenshot result. +- `Commits`: app-local commit count. +- `Open Issue`: most important unresolved app or headless issue. +- `Next Action`: what the operator should do next. +- `Updated`: ISO timestamp with timezone. + +If a project is abandoned and restarted, keep one row for the app. + +Set `Folder` to the active restart folder. + +Mention abandoned folders in `Open Issue` or `Next Action`, and detail them in the aggregate report. + +## Common Headless Command Pattern + +Before each run, set: + +```sh +APP_DIR=/tmp/kimi-headless-examples/headless-trials-2026-06/apps/ +RUN_DIR=/tmp/kimi-headless-examples/headless-trials-2026-06/runs//turn-001 +mkdir -p "$RUN_DIR/output" "$RUN_DIR/playwright" +``` + +`RUN_DIR` shall not be inside `APP_DIR`. + +First turn: + +```sh +node /path/to/kimi-code/apps/kimi-code/dist/main.mjs \ + headless run \ + --cwd "$APP_DIR" \ + --prompt "$PROMPT" \ + --metadata-only \ + --status-file "$RUN_DIR/status.json" \ + --output-dir "$RUN_DIR/output" \ + > "$RUN_DIR/stdout.json" \ + 2> "$RUN_DIR/stderr.txt" +``` + +Follow-up turns: + +```sh +node /path/to/kimi-code/apps/kimi-code/dist/main.mjs \ + headless run \ + --cwd "$APP_DIR" \ + --continue \ + --prompt "$PROMPT" \ + --metadata-only \ + --status-file "$RUN_DIR/status.json" \ + --output-dir "$RUN_DIR/output" \ + > "$RUN_DIR/stdout.json" \ + 2> "$RUN_DIR/stderr.txt" +``` + +Status check: + +```sh +node /path/to/kimi-code/apps/kimi-code/dist/main.mjs \ + headless status \ + --file "$RUN_DIR/status.json" +``` + +## Turn Wait Protocol + +The operator shall supervise each headless turn without joining Kimi's creation loop. + +After starting `kimi headless`, wait for one of two events: + +- the process exits +- one minute passes + +During the one-minute wait: + +- do not read code +- do not read generated files +- do not read diffs +- do not inspect artifacts +- do not poll status repeatedly +- do not update tracker files +- do not send extra prompts + +If the operator is using an interactive process session, the wait may be a blocking read with no input: + +```text +write_stdin({ + session_id: , + chars: "", + yield_time_ms: 60000 +}) +``` + +This call only waits for the process to produce output or exit. + +It shall not be treated as active supervision work. + +If the process exits before 60 seconds, handle the completed run immediately. + +If one minute passes and the process is still running, read only compact status fields: + +```sh +jq '{state,lastEvent,turnId,summary,error,updatedAt}' "$RUN_DIR/status.json" +``` + +If status is healthy, wait another minute. + +A turn taking more than 5 minutes is not suspicious by itself. + +Do not classify a run as stuck only because it is slow. + +Treat it as stuck only when status stops changing for a long period, the process is gone without a terminal status, or a clear headless-mode bug appears. + +The operator shall not read Kimi's code while the turn is running. + +The operator should inspect outcomes after the turn finishes: + +- final status +- final response file +- app-local Git status and latest commit +- tests and build +- browser behavior with Playwright when the app can run + +The operator should read code only when the outcome is unclear, tests fail in a way that needs triage, or a headless-mode bug needs evidence. + +## Playwright Supervision Contract + +Before using Playwright, verify `npx`: + +```sh +command -v npx >/dev/null 2>&1 +``` + +Set the wrapper path: + +```sh +export CODEX_HOME="${CODEX_HOME:-$HOME/.codex}" +export PWCLI="$CODEX_HOME/skills/playwright/scripts/playwright_cli.sh" +``` + +For each browser inspection: + +```sh +"$PWCLI" open "$APP_URL" --headed +"$PWCLI" snapshot > "$RUN_DIR/playwright/snapshot.md" +"$PWCLI" screenshot "$RUN_DIR/playwright/screenshot.png" +``` + +Use snapshots before clicking element refs. + +Re-snapshot after navigation, modal opens, tab changes, drag/drop attempts, or major UI updates. + +Playwright belongs to the supervising operator. + +The headless worker should not use Playwright during app-building turns. + +The worker should use normal project tests, typechecks, and implementation tools. + +The operator uses Playwright outside the worker turn to inspect the artifact and decide the next prompt. + +## Quality Bar For Every App + +Each app shall have: + +- real create, read, update, and delete workflows +- persistence across page reloads +- import and export where the domain naturally needs it +- empty states +- loading states +- validation errors +- useful sample data +- keyboard-accessible controls +- screen-reader labels for primary workflows +- responsive layout for desktop and narrow widths +- tests for domain logic +- tests for persistence or import/export logic +- at least one browser-supervised end-to-end workflow +- project README with setup, usage, test, and limitations +- project `prompt-log.md` with every prompt and rationale +- project `trial-report.md` with DOs and DONTs +- small, frequent app-local commits + +Basic styling is not enough. + +Each app shall have polished details: + +- clear information hierarchy +- usable spacing +- stable toolbar and navigation +- meaningful icons only when available from the chosen stack +- consistent button states +- disabled states for unavailable actions +- inline validation copy +- no overlapping text +- no placeholder-only features + +## Task 1: Prepare Trial Harness + +**Files:** + +- Create: `/tmp/kimi-headless-examples/headless-trials-2026-06/README.md` +- Create: `/tmp/kimi-headless-examples/headless-trials-2026-06/tracker.md` +- Create: `/tmp/kimi-headless-examples/headless-trials-2026-06/operator-log.md` +- Create: `/tmp/kimi-headless-examples/headless-trials-2026-06/shared/prompt-bank.md` +- Create: `/tmp/kimi-headless-examples/headless-trials-2026-06/shared/playwright-notes.md` +- Create: `/tmp/kimi-headless-examples/headless-trials-2026-06/shared/failure-ledger.md` + +- [x] **Step 1: Create the root trial folder** + +Run: + +```sh +mkdir -p /tmp/kimi-headless-examples/headless-trials-2026-06/shared +mkdir -p /tmp/kimi-headless-examples/headless-trials-2026-06/apps +``` + +Expected: folders exist. + +- [x] **Step 2: Verify the headless CLI build** + +Run: + +```sh +pnpm --filter @moonshot-ai/kimi-code run build +node apps/kimi-code/dist/main.mjs headless --help +``` + +Expected: build passes and help includes `run`, `status`, and `goal`. + +- [x] **Step 3: Verify Playwright wrapper** + +Run: + +```sh +command -v npx >/dev/null 2>&1 +export CODEX_HOME="${CODEX_HOME:-$HOME/.codex}" +export PWCLI="$CODEX_HOME/skills/playwright/scripts/playwright_cli.sh" +"$PWCLI" --help +``` + +Expected: wrapper prints help. + +- [x] **Step 4: Write the root README** + +The README shall explain: + +- eight apps +- 50 to 100 completed turns per app +- `tracker.md` as the quick progress view +- how completed turns are counted +- how prompt rationale logs are maintained +- how cancelled and failed turns are logged +- how app-local commits are expected +- where screenshots and reports live + +- [x] **Step 5: Write the shared prompt bank** + +Include prompt categories: + +- bootstrap +- fix failing tests +- browser polish +- persistence +- import/export +- accessibility +- performance +- small commits +- report writing + +- [x] **Step 6: Write the initial tracker** + +Create `tracker.md` with one row per app. + +Initial values: + +- `Status`: `not-started` +- `Completed Turns`: `0` +- `Failed/Cancelled/Stuck`: `0` +- `Target`: `60` +- `Max`: `100` +- `Restarts`: `0` +- `Last Turn`: `-` +- `Last Prompt`: `-` +- `Last Status`: `-` +- `Tests`: `-` +- `Browser Check`: `-` +- `Commits`: `0` +- `Open Issue`: `-` +- `Next Action`: `bootstrap app` + +- [x] **Step 7: Commit harness files only if execution starts** + +Do not commit during plan approval. + +Commit during execution after files are created: + +```sh +git add /tmp/kimi-headless-examples/headless-trials-2026-06 +git commit -m "chore: add headless trial harness" +``` + +## Task 2: Collaborative Whiteboard + +**App Directory:** `/tmp/kimi-headless-examples/headless-trials-2026-06/apps/collaborative-whiteboard` + +**Goal:** Build a polished local whiteboard for drawing shapes, sticky notes, connectors, and annotations. + +**Core Workflows:** + +- create boards +- draw rectangles, ellipses, arrows, and freehand strokes +- add sticky notes +- select, move, resize, and delete items +- zoom and pan +- undo and redo +- save boards locally +- export to PNG and JSON +- import JSON board snapshots + +**Expected Tech:** + +- Vite +- React +- TypeScript +- canvas or SVG +- localStorage or IndexedDB +- Vitest for geometry and state tests + +**Turn Plan:** + +- Turns 1-10: scaffold app, drawing model, board persistence, tests +- Turns 11-20: selection, drag, resize, delete, undo/redo +- Turns 21-30: zoom, pan, sticky notes, connectors +- Turns 31-40: import/export, sample boards, error handling +- Turns 41-50: Playwright browser inspection and interaction repair +- Turns 51-70: UI polish, keyboard controls, accessibility, responsive layout +- Turns 71-100: bug hunts, edge cases, performance, final report + +**Supervision Gates:** + +- [ ] board can be created and reopened after reload +- [ ] shapes can be selected, moved, resized, and deleted +- [ ] undo and redo work for at least five action types +- [ ] exported JSON imports into an equivalent board +- [ ] Playwright screenshot shows a usable toolbar and canvas +- [ ] no visible overlap at 1280px and 390px widths + +**Playwright Checks:** + +```sh +"$PWCLI" open "$APP_URL" --headed +"$PWCLI" snapshot > "$RUN_DIR/playwright/snapshot.md" +"$PWCLI" screenshot "$RUN_DIR/playwright/screenshot.png" +``` + +Manual actions to perform through Playwright: + +- create board +- add sticky note +- draw rectangle +- select and move item +- reload page +- verify board persists + +## Task 3: Kanban Planning System + +**App Directory:** `/tmp/kimi-headless-examples/headless-trials-2026-06/apps/kanban-planning-system` + +**Goal:** Build a usable planning board for tasks, lanes, dependencies, filters, and saved views. + +**Core Workflows:** + +- create projects and boards +- create columns and swimlanes +- create, edit, archive, and restore cards +- drag cards across columns +- assign priority, labels, due dates, and owners +- define card dependencies +- filter and search cards +- save views +- import and export board JSON + +**Expected Tech:** + +- Vite +- React +- TypeScript +- drag/drop library if available or native pointer events +- localStorage or IndexedDB +- Vitest for board reducers and dependency rules + +**Turn Plan:** + +- Turns 1-10: scaffold, domain model, reducer tests, basic board UI +- Turns 11-20: card CRUD, columns, swimlanes, persistence +- Turns 21-30: drag/drop, dependency rules, archive/restore +- Turns 31-40: filters, search, saved views +- Turns 41-50: import/export and sample data +- Turns 51-70: Playwright repair, keyboard navigation, responsive board layout +- Turns 71-100: polish, edge cases, report + +**Supervision Gates:** + +- [ ] card movement persists after reload +- [ ] dependency cycles are rejected with clear copy +- [ ] saved filters can be selected later +- [ ] import/export round-trips realistic sample data +- [ ] drag/drop is usable in browser +- [ ] dense board remains readable + +**Playwright Actions:** + +- create card +- edit title and labels +- drag card to another column +- filter by label +- save view +- reload and verify persistence + +## Task 4: Mini Log Analytics Workbench + +**App Directory:** `/tmp/kimi-headless-examples/headless-trials-2026-06/apps/log-analytics-workbench` + +**Goal:** Build a local log-analysis app that imports logs, parses events, filters timelines, and highlights anomalies. + +**Core Workflows:** + +- import plain text log files +- parse timestamps, levels, sources, and messages +- display event table and timeline +- filter by time, level, source, and text +- save queries +- mark anomalies +- chart event volume +- export filtered results + +**Expected Tech:** + +- Vite +- React +- TypeScript +- Web Worker for parsing if needed +- localStorage or IndexedDB +- Vitest for parser and query engine + +**Turn Plan:** + +- Turns 1-10: scaffold, parser, fixtures, parser tests +- Turns 11-20: import UI, event table, empty/error states +- Turns 21-30: filters, saved queries, query tests +- Turns 31-40: timeline and charts +- Turns 41-50: anomaly markers, export, sample datasets +- Turns 51-70: Playwright inspections, performance on large fixture +- Turns 71-100: visual polish, accessibility, report + +**Supervision Gates:** + +- [ ] realistic log fixture imports successfully +- [ ] invalid lines are shown without crashing import +- [ ] filters update table and chart consistently +- [ ] saved query survives reload +- [ ] large fixture remains responsive enough for local use +- [ ] anomaly marker is visible in table and timeline + +**Playwright Actions:** + +- import sample log +- apply level filter +- save query +- mark anomaly +- export filtered data + +## Task 5: Interactive SQL Explorer + +**App Directory:** `/tmp/kimi-headless-examples/headless-trials-2026-06/apps/sql-explorer` + +**Goal:** Build a local SQLite-style query explorer with schema browsing, saved queries, result tables, CSV import, and charting. + +**Core Workflows:** + +- create or load local database +- import CSV into a table +- inspect schema +- write SQL query +- run query and show result table +- save query +- view query history +- chart numeric result columns +- export query results + +**Expected Tech:** + +- Vite +- React +- TypeScript +- SQLite in browser through a local package if practical +- fallback in-memory relational model if SQLite package setup blocks progress +- Vitest for CSV parsing, query history, and chart data mapping + +**Turn Plan:** + +- Turns 1-10: scaffold, table model, CSV parser, tests +- Turns 11-20: schema browser, query editor, result table +- Turns 21-30: SQL execution or fallback query engine +- Turns 31-40: saved queries, history, errors +- Turns 41-50: charting and export +- Turns 51-70: Playwright inspection, keyboard shortcuts, empty states +- Turns 71-100: polish, robustness, report + +**Supervision Gates:** + +- [ ] CSV import creates a browsable table +- [ ] query errors are shown inline +- [ ] saved query can be rerun +- [ ] query history persists +- [ ] chart view handles at least one numeric column +- [ ] export writes visible result data + +**Playwright Actions:** + +- import CSV +- inspect schema +- run query +- save query +- switch to chart +- export results + +## Task 6: Workflow Automation Builder + +**App Directory:** `/tmp/kimi-headless-examples/headless-trials-2026-06/apps/workflow-automation-builder` + +**Goal:** Build a visual node-based workflow builder with mock execution and trace inspection. + +**Core Workflows:** + +- create workflow +- add trigger, transform, condition, and action nodes +- connect nodes +- edit node configuration +- validate workflow +- run workflow against sample input +- show step-by-step trace +- save and reload workflow +- export and import workflow JSON + +**Expected Tech:** + +- Vite +- React +- TypeScript +- SVG or canvas node graph +- localStorage or IndexedDB +- Vitest for graph validation and execution engine + +**Turn Plan:** + +- Turns 1-10: scaffold, graph model, validation tests +- Turns 11-20: node palette, node editor, persistence +- Turns 21-30: connectors, layout, graph interactions +- Turns 31-40: mock execution engine and trace viewer +- Turns 41-50: import/export and sample workflows +- Turns 51-80: Playwright graph interaction repair, usability polish +- Turns 81-100: edge cases, accessibility, report + +**Supervision Gates:** + +- [ ] invalid graph shows clear validation errors +- [ ] valid workflow runs and produces trace +- [ ] node config edits persist +- [ ] import/export round-trips workflow +- [ ] graph is usable in browser without reading docs +- [ ] trace viewer explains each step + +**Playwright Actions:** + +- create workflow +- add nodes +- connect nodes +- run workflow +- inspect trace +- reload and verify persistence + +## Task 7: Issue Tracker With Triage Hooks + +**App Directory:** `/tmp/kimi-headless-examples/headless-trials-2026-06/apps/issue-tracker-triage` + +**Goal:** Build a local issue tracker with triage workflows, duplicate detection, saved views, comments, and activity history. + +**Core Workflows:** + +- create issue +- edit title, description, status, priority, labels, and milestone +- add comments +- record activity log +- search and filter issues +- detect likely duplicates from title and labels +- save views +- import/export issue JSON +- Markdown preview for descriptions and comments + +**Expected Tech:** + +- Vite +- React +- TypeScript +- localStorage or IndexedDB +- small Markdown renderer if dependency is justified +- Vitest for triage rules and duplicate scoring + +**Turn Plan:** + +- Turns 1-10: scaffold, issue model, reducer tests +- Turns 11-20: issue list, detail editor, persistence +- Turns 21-30: comments, activity log, labels, milestones +- Turns 31-40: search, filters, saved views +- Turns 41-50: duplicate detection, Markdown preview +- Turns 51-70: Playwright inspection and workflow repair +- Turns 71-100: import/export, polish, report + +**Supervision Gates:** + +- [ ] issue workflow works from list to detail and back +- [ ] comments append to activity log +- [ ] duplicate detection is visible but not destructive +- [ ] saved view persists +- [ ] Markdown preview is readable +- [ ] imported issues preserve comments and activity + +**Playwright Actions:** + +- create issue +- add comment +- apply label filter +- save view +- inspect duplicate suggestions +- export issues + +## Task 8: Music Library Manager + +**App Directory:** `/tmp/kimi-headless-examples/headless-trials-2026-06/apps/music-library-manager` + +**Goal:** Build a local music library manager with folder import simulation, metadata editing, playlists, duplicate detection, and smart filters. + +**Core Workflows:** + +- import a folder manifest or sample JSON library +- list tracks, albums, artists, and genres +- edit metadata +- create playlists +- detect duplicates +- define smart filters +- export library and playlists +- show scan summary and warnings + +**Expected Tech:** + +- Vite +- React +- TypeScript +- localStorage or IndexedDB +- fixture-based import instead of direct filesystem scan in browser +- Vitest for duplicate detection, filters, and playlist rules + +**Turn Plan:** + +- Turns 1-10: scaffold, music domain model, sample library fixtures +- Turns 11-20: library import, track table, metadata editor +- Turns 21-30: album/artist views, playlists +- Turns 31-40: duplicate detection and smart filters +- Turns 41-50: export and scan warnings +- Turns 51-70: Playwright inspection, dense UI polish +- Turns 71-100: edge cases, accessibility, report + +**Supervision Gates:** + +- [ ] sample library imports and persists +- [ ] metadata edit updates all relevant views +- [ ] playlist creation and ordering work +- [ ] duplicate suggestions are explainable +- [ ] smart filters can be saved +- [ ] export includes edited metadata and playlists + +**Playwright Actions:** + +- import sample library +- edit track metadata +- create playlist +- run duplicate detection +- save smart filter +- reload and verify persistence + +## Task 9: Personal CRM + +**App Directory:** `/tmp/kimi-headless-examples/headless-trials-2026-06/apps/personal-crm` + +**Goal:** Build a local CRM for contacts, companies, interactions, reminders, pipeline stages, notes, and search. + +**Core Workflows:** + +- create contacts and companies +- link contacts to companies +- record interactions +- add notes +- create reminders +- move opportunities through pipeline stages +- search contacts and companies +- import/export contacts +- show timeline per contact + +**Expected Tech:** + +- Vite +- React +- TypeScript +- localStorage or IndexedDB +- Vitest for relationship logic, reminders, and search + +**Turn Plan:** + +- Turns 1-10: scaffold, CRM domain model, tests +- Turns 11-20: contact and company CRUD, persistence +- Turns 21-30: interactions, notes, timeline +- Turns 31-40: reminders and pipeline stages +- Turns 41-50: search, import/export, sample data +- Turns 51-70: Playwright workflow repair and UI polish +- Turns 71-100: responsive layout, accessibility, report + +**Supervision Gates:** + +- [ ] contact can be linked to company +- [ ] timeline shows notes and interactions in order +- [ ] reminder state can be changed +- [ ] pipeline stage changes persist +- [ ] search finds contacts, companies, and notes +- [ ] import/export round-trips sample data + +**Playwright Actions:** + +- create company +- create contact +- add interaction +- create reminder +- move opportunity +- search and reload + +## Task 10: Cross-Project Supervision + +**Files:** + +- Modify: `/tmp/kimi-headless-examples/headless-trials-2026-06/tracker.md` +- Create: `/tmp/kimi-headless-examples/headless-trials-2026-06/operator-log.md` +- Modify: `/tmp/kimi-headless-examples/headless-trials-2026-06/shared/failure-ledger.md` +- Modify: each app's `prompt-log.md` +- Modify: each app's `trial-report.md` + +- [ ] **Step 1: Count completed turns after each app** + +Run: + +```sh +python3 - <<'PY' +import json, pathlib +root = pathlib.Path('/tmp/kimi-headless-examples/headless-trials-2026-06/apps') +for app in sorted(root.iterdir()): + if not app.is_dir(): + continue + completed = 0 + states = {} + for status_path in sorted((app / 'runs').glob('turn-*/status.json')): + status = json.load(open(status_path)) + states[status['state']] = states.get(status['state'], 0) + 1 + if status['state'] == 'completed': + completed += 1 + print(f'{app.name}: completed={completed} states={states}') +PY +``` + +Expected: every app has `completed >= 50`. + +- [ ] **Step 2: Update the tracker table** + +After every app turn or verification checkpoint, update `tracker.md`. + +For each app row, verify: + +- `Status` reflects current reality. +- `Folder` points to the active app folder. +- `Completed Turns` matches status files. +- `Failed/Cancelled/Stuck` matches status files. +- `Last Turn` is the newest run directory. +- `Last Prompt` matches the latest prompt-log entry summary. +- `Last Status` includes latest status state and tool count when useful. +- `Tests` names the latest test command and result. +- `Browser Check` names latest snapshot or screenshot result. +- `Commits` matches app-local Git history. +- `Open Issue` is concise. +- `Next Action` is actionable. +- `Updated` uses the current timestamp. + +If the user asks for progress, read `tracker.md` first. + +- [ ] **Step 3: Verify per-turn prompt logs** + +For each app, verify: + +- `prompt-log.md` exists +- each run directory has a matching prompt-log entry +- each prompt-log entry includes the exact prompt text +- each prompt-log entry explains why that prompt was used at that moment +- each prompt-log entry links to evidence when available +- failed, cancelled, interrupted, and stuck runs are included + +Run: + +```sh +python3 - <<'PY' +import pathlib, re +root = pathlib.Path('/tmp/kimi-headless-examples/headless-trials-2026-06/apps') +for app in sorted(root.iterdir()): + if not app.is_dir(): + continue + log = app / 'prompt-log.md' + runs = sorted((app / 'runs').glob('turn-*')) + text = log.read_text() if log.exists() else '' + entries = len(re.findall(r'^## Turn ', text, flags=re.M)) + print(f'{app.name}: runs={len(runs)} prompt_log_entries={entries}') +PY +``` + +Expected: `prompt_log_entries >= runs` for each app. + +- [ ] **Step 4: Verify app-local commits** + +For each app, run: + +```sh +git -C "$APP_DIR" status --short +git -C "$APP_DIR" log --oneline --decorate -20 +``` + +Expected: + +- Git repo exists. +- Recent history has small commits. +- Commit messages describe focused changes. +- `runs/`, status files, screenshots, and temporary logs are not committed. +- No co-author trailers appear in commit messages. + +- [ ] **Step 5: Record headless bugs** + +For each failure, record: + +- command +- status file state +- stdout and stderr paths +- what the operator expected +- what happened +- whether a product fix was made +- whether the app folder was still resumable +- abandoned folder path when restart was required +- replacement folder path when restart was required + +- [ ] **Step 6: Record abandoned and restarted projects** + +For each abandoned app folder, verify: + +- original folder still exists +- original `runs/` data is preserved +- original `prompt-log.md` includes the abandonment entry +- original `trial-report.md` explains why it was abandoned +- `shared/failure-ledger.md` links to the original folder +- replacement folder uses the `-restart-NN` naming pattern +- replacement folder has a new Git repo +- replacement folder has a new headless session + +Run: + +```sh +find /tmp/kimi-headless-examples/headless-trials-2026-06/apps \ + -maxdepth 1 \ + -type d \ + -name '*-restart-*' \ + -print +``` + +Expected: any restart folder corresponds to a documented abandoned folder. + +- [ ] **Step 7: Record app bugs** + +For each app bug, record: + +- browser screenshot path +- failing test command +- correction prompt +- turn number that fixed it + +- [ ] **Step 8: Write per-project report** + +Each `trial-report.md` shall include: + +- turn count +- commit count +- screenshots +- prompt-log summary +- strongest lazy prompts +- weak prompts +- headless-mode issues found +- app-quality issues found +- abandonment and restart history when applicable +- DOs +- DONTs + +- [ ] **Step 9: Write aggregate report** + +Create: + +```text +/tmp/kimi-headless-examples/headless-trials-2026-06/report.md +``` + +Include: + +- table of all eight apps +- completed turns per app +- abandoned folder count per app +- final folder path per app +- app-local commit count per app +- final verification command per app +- screenshots per app +- top 10 headless-mode findings +- top 10 operator lessons +- product issues fixed during the trial +- remaining headless-mode concerns + +## Task 11: Final Verification + +**Files:** + +- Modify: `/tmp/kimi-headless-examples/headless-trials-2026-06/report.md` +- Modify: `plans/2026-06-07-headless-complex-app-trials.md` + +- [ ] **Step 1: Verify all app tests** + +Run each app's documented test command. + +Expected: every app passes its own test suite. + +- [ ] **Step 2: Verify all apps run** + +Start each app's dev server one at a time. + +Open it with Playwright. + +Capture a screenshot. + +Expected: each app renders the primary workflow without blank screens or console-fatal errors. + +- [ ] **Step 3: Verify turn counts** + +Run the count script from Task 10. + +Expected: all apps have at least 50 completed turns. + +- [ ] **Step 4: Verify reports** + +Check every app has: + +- `README.md` +- root `tracker.md` row with current state +- `prompt-log.md` +- `trial-report.md` +- at least one Playwright screenshot +- at least one status file from a long-running turn +- app-local Git history with small commits +- restart history when the app was abandoned and restarted +- final verification command result + +- [ ] **Step 5: Update this plan** + +Mark completed tasks in this file as execution progresses. + +Do not mark a project done until its supervision gates pass. + +## Execution Policy + +Do not start execution from this plan until the user explicitly approves it. + +Once approved, use small commits after self-contained additions in the headless repository when repository files change. + +Inside each side-project app, ask the headless worker to commit its own changes frequently. + +The app-local commits shall stay inside the app repository. + +Do not commit generated side-project code into the headless repository unless the user asks. + +Do not overwrite the existing trial projects unless the user approves cleanup. + +If a project becomes non-resumable, preserve it and restart in a new sibling folder. + +Do not delete the abandoned folder. + +Do not reuse the abandoned folder's Git repo or headless session for the restarted app. + +## Risk Register + +- Eight apps at 50 to 100 turns each is a long-running trial. Expect interruptions and retries. +- Browser-based apps may drift into superficial UI unless Playwright checks happen often. +- Agents may claim tests passed without running them. Verify command output directly. +- Long turns may expose status-file or signal-handling bugs. Record these as trial findings. +- Too much prompt detail makes the operator unrealistic. Keep prompts short and corrective. +- Too little supervision lets bad app quality accumulate. Inspect after every turn or every small group of turns. +- Missing app-local commits make it harder to audit agent work. Verify commit history repeatedly. +- Missing prompt rationale makes the trial hard to learn from. Update `prompt-log.md` every turn. +- A headless bug may corrupt the current app's session or status. Preserve the broken folder and restart in a new sibling folder. +- Restarted apps can confuse reporting. Track abandoned and final folders separately. + +## Definition of Done + +- All eight apps exist under `/tmp/kimi-headless-examples/headless-trials-2026-06/apps/`. +- Each app has at least 50 completed headless turns. +- If an app was restarted, the final restart folder has at least 50 completed turns. +- Abandoned app folders are preserved and linked from the aggregate report. +- Each app is usable through a browser. +- Each app has persistence. +- Each app has tests. +- Each app has a README. +- Root `tracker.md` accurately shows current status, turn counts, tests, browser checks, commits, open issue, and next action for every app. +- Each app has a `prompt-log.md` with every prompt and the reason for using it. +- Each app has a `trial-report.md`. +- Each app has small, frequent local Git commits made by the headless worker. +- Each app has Playwright snapshots and screenshots. +- The aggregate report exists. +- All final verification commands pass. +- Any headless-mode bugs found during the trial are recorded with reproduction data.