diff --git a/src/headless-context.ts b/src/headless-context.ts new file mode 100644 index 000000000..8f9512c30 --- /dev/null +++ b/src/headless-context.ts @@ -0,0 +1,59 @@ +/** + * Headless Context Loading — stdin reading, file context, and project bootstrapping + * + * Handles loading context from files or stdin for headless new-milestone, + * and bootstraps the .gsd/ directory structure when needed. + */ + +import { readFileSync, mkdirSync } from 'node:fs' +import { join, resolve } from 'node:path' + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface ContextOptions { + context?: string // file path or '-' for stdin + contextText?: string // inline text +} + +// --------------------------------------------------------------------------- +// Stdin Reader +// --------------------------------------------------------------------------- + +export async function readStdin(): Promise { + const chunks: Buffer[] = [] + for await (const chunk of process.stdin) { + chunks.push(chunk as Buffer) + } + return Buffer.concat(chunks).toString('utf-8') +} + +// --------------------------------------------------------------------------- +// Context Loading +// --------------------------------------------------------------------------- + +export async function loadContext(options: ContextOptions): Promise { + if (options.contextText) return options.contextText + if (options.context === '-') { + return readStdin() + } + if (options.context) { + return readFileSync(resolve(options.context), 'utf-8') + } + throw new Error('No context provided. Use --context or --context-text ') +} + +// --------------------------------------------------------------------------- +// Project Bootstrap +// --------------------------------------------------------------------------- + +/** + * Bootstrap .gsd/ directory structure for headless new-milestone. + * Mirrors the bootstrap logic from guided-flow.ts showSmartEntry(). + */ +export function bootstrapGsdProject(basePath: string): void { + const gsdDir = join(basePath, '.gsd') + mkdirSync(join(gsdDir, 'milestones'), { recursive: true }) + mkdirSync(join(gsdDir, 'runtime'), { recursive: true }) +} diff --git a/src/headless-events.ts b/src/headless-events.ts new file mode 100644 index 000000000..c0ecd3ca8 --- /dev/null +++ b/src/headless-events.ts @@ -0,0 +1,65 @@ +/** + * Headless Event Detection — notification classification and command detection + * + * Detects terminal notifications, blocked notifications, milestone-ready signals, + * and classifies commands as quick (single-turn) vs long-running. + */ + +// --------------------------------------------------------------------------- +// Completion Detection +// --------------------------------------------------------------------------- + +/** + * Detect genuine auto-mode termination notifications. + * + * Only matches the actual stop signals emitted by stopAuto(): + * "Auto-mode stopped..." + * "Step-mode stopped..." + * + * Does NOT match progress notifications that happen to contain words like + * "complete" or "stopped" (e.g., "Override resolved — rewrite-docs completed", + * "All slices are complete — nothing to discuss", "Skipped 5+ completed units"). + * + * Blocked detection is separate — checked via isBlockedNotification. + */ +export const TERMINAL_PREFIXES = ['auto-mode stopped', 'step-mode stopped'] +export const IDLE_TIMEOUT_MS = 15_000 +// new-milestone is a long-running creative task where the LLM may pause +// between tool calls (e.g. after mkdir, before writing files). Use a +// longer idle timeout to avoid killing the session prematurely (#808). +export const NEW_MILESTONE_IDLE_TIMEOUT_MS = 120_000 + +export function isTerminalNotification(event: Record): boolean { + if (event.type !== 'extension_ui_request' || event.method !== 'notify') return false + const message = String(event.message ?? '').toLowerCase() + return TERMINAL_PREFIXES.some((prefix) => message.startsWith(prefix)) +} + +export function isBlockedNotification(event: Record): boolean { + if (event.type !== 'extension_ui_request' || event.method !== 'notify') return false + const message = String(event.message ?? '').toLowerCase() + // Blocked notifications come through stopAuto as "Auto-mode stopped (Blocked: ...)" + return message.includes('blocked:') +} + +export function isMilestoneReadyNotification(event: Record): boolean { + if (event.type !== 'extension_ui_request' || event.method !== 'notify') return false + return /milestone\s+m\d+.*ready/i.test(String(event.message ?? '')) +} + +// --------------------------------------------------------------------------- +// Quick Command Detection +// --------------------------------------------------------------------------- + +export const FIRE_AND_FORGET_METHODS = new Set(['notify', 'setStatus', 'setWidget', 'setTitle', 'set_editor_text']) + +export const QUICK_COMMANDS = new Set([ + 'status', 'queue', 'history', 'hooks', 'export', 'stop', 'pause', + 'capture', 'skip', 'undo', 'knowledge', 'config', 'prefs', + 'cleanup', 'migrate', 'doctor', 'remote', 'help', 'steer', + 'triage', 'visualize', +]) + +export function isQuickCommand(command: string): boolean { + return QUICK_COMMANDS.has(command) +} diff --git a/src/headless-ui.ts b/src/headless-ui.ts new file mode 100644 index 000000000..5b7453aac --- /dev/null +++ b/src/headless-ui.ts @@ -0,0 +1,144 @@ +/** + * Headless UI Handling — auto-response, progress formatting, and supervised stdin + * + * Handles extension UI requests (auto-responding in headless mode), + * formats progress events for stderr output, and reads orchestrator + * commands from stdin in supervised mode. + */ + +import type { Readable } from 'node:stream' + +import { RpcClient, attachJsonlLineReader, serializeJsonLine } from '@gsd/pi-coding-agent' + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface ExtensionUIRequest { + type: 'extension_ui_request' + id: string + method: string + title?: string + options?: string[] + message?: string + prefill?: string + timeout?: number + [key: string]: unknown +} + +export type { ExtensionUIRequest } + +// --------------------------------------------------------------------------- +// Extension UI Auto-Responder +// --------------------------------------------------------------------------- + +export function handleExtensionUIRequest( + event: ExtensionUIRequest, + writeToStdin: (data: string) => void, +): void { + const { id, method } = event + let response: Record + + switch (method) { + case 'select': + response = { type: 'extension_ui_response', id, value: event.options?.[0] ?? '' } + break + case 'confirm': + response = { type: 'extension_ui_response', id, confirmed: true } + break + case 'input': + response = { type: 'extension_ui_response', id, value: '' } + break + case 'editor': + response = { type: 'extension_ui_response', id, value: event.prefill ?? '' } + break + case 'notify': + case 'setStatus': + case 'setWidget': + case 'setTitle': + case 'set_editor_text': + response = { type: 'extension_ui_response', id, value: '' } + break + default: + process.stderr.write(`[headless] Warning: unknown extension_ui_request method "${method}", cancelling\n`) + response = { type: 'extension_ui_response', id, cancelled: true } + break + } + + writeToStdin(serializeJsonLine(response)) +} + +// --------------------------------------------------------------------------- +// Progress Formatter +// --------------------------------------------------------------------------- + +export function formatProgress(event: Record, verbose: boolean): string | null { + const type = String(event.type ?? '') + + switch (type) { + case 'tool_execution_start': + if (verbose) return ` [tool] ${event.toolName ?? 'unknown'}` + return null + + case 'agent_start': + return '[agent] Session started' + + case 'agent_end': + return '[agent] Session ended' + + case 'extension_ui_request': + if (event.method === 'notify') { + return `[gsd] ${event.message ?? ''}` + } + if (event.method === 'setStatus') { + return `[status] ${event.message ?? ''}` + } + return null + + default: + return null + } +} + +// --------------------------------------------------------------------------- +// Supervised Stdin Reader +// --------------------------------------------------------------------------- + +export function startSupervisedStdinReader( + stdinWriter: (data: string) => void, + client: RpcClient, + onResponse: (id: string) => void, +): () => void { + return attachJsonlLineReader(process.stdin as Readable, (line) => { + let msg: Record + try { + msg = JSON.parse(line) + } catch { + process.stderr.write(`[headless] Warning: invalid JSON from orchestrator stdin, skipping\n`) + return + } + + const type = String(msg.type ?? '') + + switch (type) { + case 'extension_ui_response': + stdinWriter(line + '\n') + if (typeof msg.id === 'string') { + onResponse(msg.id) + } + break + case 'prompt': + client.prompt(String(msg.message ?? '')) + break + case 'steer': + client.steer(String(msg.message ?? '')) + break + case 'follow_up': + client.followUp(String(msg.message ?? '')) + break + default: + process.stderr.write(`[headless] Warning: unknown message type "${type}" from orchestrator stdin\n`) + break + } + }) +} diff --git a/src/headless.ts b/src/headless.ts index f9dcb0eef..15fd1bbab 100644 --- a/src/headless.ts +++ b/src/headless.ts @@ -11,13 +11,36 @@ * 2 — blocked (command reported a blocker) */ -import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs' -import { join, resolve } from 'node:path' +import { existsSync, mkdirSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { resolve } from 'node:path' import { ChildProcess } from 'node:child_process' -import { RpcClient, attachJsonlLineReader, serializeJsonLine } from '@gsd/pi-coding-agent' +import { RpcClient } from '@gsd/pi-coding-agent' import { loadAndValidateAnswerFile, AnswerInjector } from './headless-answers.js' +import { + isTerminalNotification, + isBlockedNotification, + isMilestoneReadyNotification, + isQuickCommand, + FIRE_AND_FORGET_METHODS, + IDLE_TIMEOUT_MS, + NEW_MILESTONE_IDLE_TIMEOUT_MS, +} from './headless-events.js' + +import { + handleExtensionUIRequest, + formatProgress, + startSupervisedStdinReader, +} from './headless-ui.js' +import type { ExtensionUIRequest } from './headless-ui.js' + +import { + loadContext, + bootstrapGsdProject, +} from './headless-context.js' + // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- @@ -39,18 +62,6 @@ export interface HeadlessOptions { eventFilter?: Set // filter JSONL output to specific event types } -interface ExtensionUIRequest { - type: 'extension_ui_request' - id: string - method: string - title?: string - options?: string[] - message?: string - prefill?: string - timeout?: number - [key: string]: unknown -} - interface TrackedEvent { type: string timestamp: number @@ -128,217 +139,10 @@ export function parseHeadlessArgs(argv: string[]): HeadlessOptions { return options } -// --------------------------------------------------------------------------- -// Extension UI Auto-Responder -// --------------------------------------------------------------------------- - -function handleExtensionUIRequest( - event: ExtensionUIRequest, - writeToStdin: (data: string) => void, -): void { - const { id, method } = event - let response: Record - - switch (method) { - case 'select': - response = { type: 'extension_ui_response', id, value: event.options?.[0] ?? '' } - break - case 'confirm': - response = { type: 'extension_ui_response', id, confirmed: true } - break - case 'input': - response = { type: 'extension_ui_response', id, value: '' } - break - case 'editor': - response = { type: 'extension_ui_response', id, value: event.prefill ?? '' } - break - case 'notify': - case 'setStatus': - case 'setWidget': - case 'setTitle': - case 'set_editor_text': - response = { type: 'extension_ui_response', id, value: '' } - break - default: - process.stderr.write(`[headless] Warning: unknown extension_ui_request method "${method}", cancelling\n`) - response = { type: 'extension_ui_response', id, cancelled: true } - break - } - - writeToStdin(serializeJsonLine(response)) -} - -// --------------------------------------------------------------------------- -// Progress Formatter -// --------------------------------------------------------------------------- - -function formatProgress(event: Record, verbose: boolean): string | null { - const type = String(event.type ?? '') - - switch (type) { - case 'tool_execution_start': - if (verbose) return ` [tool] ${event.toolName ?? 'unknown'}` - return null - - case 'agent_start': - return '[agent] Session started' - - case 'agent_end': - return '[agent] Session ended' - - case 'extension_ui_request': - if (event.method === 'notify') { - return `[gsd] ${event.message ?? ''}` - } - if (event.method === 'setStatus') { - return `[status] ${event.message ?? ''}` - } - return null - - default: - return null - } -} - -// --------------------------------------------------------------------------- -// Completion Detection -// --------------------------------------------------------------------------- - -/** - * Detect genuine auto-mode termination notifications. - * - * Only matches the actual stop signals emitted by stopAuto(): - * "Auto-mode stopped..." - * "Step-mode stopped..." - * - * Does NOT match progress notifications that happen to contain words like - * "complete" or "stopped" (e.g., "Override resolved — rewrite-docs completed", - * "All slices are complete — nothing to discuss", "Skipped 5+ completed units"). - * - * Blocked detection is separate — checked via isBlockedNotification. - */ -const TERMINAL_PREFIXES = ['auto-mode stopped', 'step-mode stopped'] -const IDLE_TIMEOUT_MS = 15_000 -// new-milestone is a long-running creative task where the LLM may pause -// between tool calls (e.g. after mkdir, before writing files). Use a -// longer idle timeout to avoid killing the session prematurely (#808). -const NEW_MILESTONE_IDLE_TIMEOUT_MS = 120_000 - -function isTerminalNotification(event: Record): boolean { - if (event.type !== 'extension_ui_request' || event.method !== 'notify') return false - const message = String(event.message ?? '').toLowerCase() - return TERMINAL_PREFIXES.some((prefix) => message.startsWith(prefix)) -} - -function isBlockedNotification(event: Record): boolean { - if (event.type !== 'extension_ui_request' || event.method !== 'notify') return false - const message = String(event.message ?? '').toLowerCase() - // Blocked notifications come through stopAuto as "Auto-mode stopped (Blocked: ...)" - return message.includes('blocked:') -} - -function isMilestoneReadyNotification(event: Record): boolean { - if (event.type !== 'extension_ui_request' || event.method !== 'notify') return false - return /milestone\s+m\d+.*ready/i.test(String(event.message ?? '')) -} - -// --------------------------------------------------------------------------- -// Quick Command Detection -// --------------------------------------------------------------------------- - -const FIRE_AND_FORGET_METHODS = new Set(['notify', 'setStatus', 'setWidget', 'setTitle', 'set_editor_text']) - -const QUICK_COMMANDS = new Set([ - 'status', 'queue', 'history', 'hooks', 'export', 'stop', 'pause', - 'capture', 'skip', 'undo', 'knowledge', 'config', 'prefs', - 'cleanup', 'migrate', 'doctor', 'remote', 'help', 'steer', - 'triage', 'visualize', -]) - -function isQuickCommand(command: string): boolean { - return QUICK_COMMANDS.has(command) -} - -// --------------------------------------------------------------------------- -// Supervised Stdin Reader -// --------------------------------------------------------------------------- - -function startSupervisedStdinReader( - stdinWriter: (data: string) => void, - client: RpcClient, - onResponse: (id: string) => void, -): () => void { - return attachJsonlLineReader(process.stdin as import('node:stream').Readable, (line) => { - let msg: Record - try { - msg = JSON.parse(line) - } catch { - process.stderr.write(`[headless] Warning: invalid JSON from orchestrator stdin, skipping\n`) - return - } - - const type = String(msg.type ?? '') - - switch (type) { - case 'extension_ui_response': - stdinWriter(line + '\n') - if (typeof msg.id === 'string') { - onResponse(msg.id) - } - break - case 'prompt': - client.prompt(String(msg.message ?? '')) - break - case 'steer': - client.steer(String(msg.message ?? '')) - break - case 'follow_up': - client.followUp(String(msg.message ?? '')) - break - default: - process.stderr.write(`[headless] Warning: unknown message type "${type}" from orchestrator stdin\n`) - break - } - }) -} - // --------------------------------------------------------------------------- // Main Orchestrator // --------------------------------------------------------------------------- -// --------------------------------------------------------------------------- -// Context Loading (new-milestone) -// --------------------------------------------------------------------------- - -async function readStdin(): Promise { - const chunks: Buffer[] = [] - for await (const chunk of process.stdin) { - chunks.push(chunk as Buffer) - } - return Buffer.concat(chunks).toString('utf-8') -} - -async function loadContext(options: HeadlessOptions): Promise { - if (options.contextText) return options.contextText - if (options.context === '-') { - return readStdin() - } - if (options.context) { - return readFileSync(resolve(options.context), 'utf-8') - } - throw new Error('No context provided. Use --context or --context-text ') -} - -/** - * Bootstrap .gsd/ directory structure for headless new-milestone. - * Mirrors the bootstrap logic from guided-flow.ts showSmartEntry(). - */ -function bootstrapGsdProject(basePath: string): void { - const gsdDir = join(basePath, '.gsd') - mkdirSync(join(gsdDir, 'milestones'), { recursive: true }) - mkdirSync(join(gsdDir, 'runtime'), { recursive: true }) -} - export async function runHeadless(options: HeadlessOptions): Promise { const maxRestarts = options.maxRestarts ?? 3 let restartCount = 0