feat: add gsd headless CLI subcommand for non-interactive auto-mode

Adds a first-class `gsd headless` command that runs auto-mode without a
TUI by spawning a child process in RPC mode via RpcClient. Useful for
CI/CD pipelines, scripts, and unattended execution.

CLI interface:
  gsd headless                  - Run auto-mode until complete
  gsd headless --step           - Run one unit only (sends /gsd next)
  gsd headless --timeout 300000 - Custom timeout (default 5 min)
  gsd headless --json           - Forward RPC events as JSONL to stdout
  gsd headless --verbose        - Show full agent text and tool results
  gsd headless --model <id>     - Override model

Exit codes: 0 = complete, 1 = error/timeout, 2 = blocked

Features:
- Extension UI auto-responder (handles select, confirm, input, editor,
  notify, setStatus, setWidget, setTitle, set_editor_text)
- Completion detection via terminal notification keywords + idle timeout
- Human-readable progress output to stderr
- SIGINT/SIGTERM forwarding for clean shutdown
- Child process crash detection
- Completion summary with diagnostics on failure
This commit is contained in:
frizynn 2026-03-16 16:18:25 -03:00
parent 1a85853fd8
commit b09e2a549c
3 changed files with 464 additions and 0 deletions

View file

@ -174,6 +174,13 @@ if (cliFlags.messages[0] === 'sessions') {
cliFlags._selectedSessionPath = selected.path
}
// `gsd headless` — run auto-mode without TUI
if (cliFlags.messages[0] === 'headless') {
const { runHeadless, parseHeadlessArgs } = await import('./headless.js')
await runHeadless(parseHeadlessArgs(process.argv))
process.exit(0)
}
// Pi's tool bootstrap can mis-detect already-installed fd/rg on some systems
// because spawnSync(..., ["--version"]) returns EPERM despite a zero exit code.
// Provision local managed binaries first so Pi sees them without probing PATH.

427
src/headless.ts Normal file
View file

@ -0,0 +1,427 @@
/**
* Headless Orchestrator `gsd headless`
*
* Runs GSD's auto-mode (or a single unit via --step) without a TUI by
* spawning a child process in RPC mode, auto-responding to extension UI
* requests, and streaming progress to stderr.
*
* Exit codes:
* 0 complete (auto-mode finished successfully)
* 1 error or timeout
* 2 blocked (auto-mode reported a blocker)
*/
import { existsSync } from 'node:fs'
import { join } from 'node:path'
import { ChildProcess } from 'node:child_process'
// RpcClient is not in @gsd/pi-coding-agent's public exports — import from dist directly.
// This relative path resolves correctly from both src/ (via tsx) and dist/ (compiled).
import { RpcClient } from '../packages/pi-coding-agent/dist/modes/rpc/rpc-client.js'
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface HeadlessOptions {
timeout: number
step: boolean
json: boolean
verbose: boolean
model?: string
}
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
detail?: string
}
// ---------------------------------------------------------------------------
// CLI Argument Parser
// ---------------------------------------------------------------------------
export function parseHeadlessArgs(argv: string[]): HeadlessOptions {
const options: HeadlessOptions = {
timeout: 300_000,
step: false,
json: false,
verbose: false,
}
const args = argv.slice(2)
for (let i = 0; i < args.length; i++) {
const arg = args[i]
if (arg === 'headless') continue
if (arg === '--timeout' && i + 1 < args.length) {
options.timeout = parseInt(args[++i], 10)
if (Number.isNaN(options.timeout) || options.timeout <= 0) {
process.stderr.write('[headless] Error: --timeout must be a positive integer (milliseconds)\n')
process.exit(1)
}
} else if (arg === '--step') {
options.step = true
} else if (arg === '--json') {
options.json = true
} else if (arg === '--verbose') {
options.verbose = true
} else if (arg === '--model' && i + 1 < args.length) {
options.model = args[++i]
}
}
return options
}
// ---------------------------------------------------------------------------
// JSONL Helper
// ---------------------------------------------------------------------------
function serializeJsonLine(obj: Record<string, unknown>): string {
return JSON.stringify(obj) + '\n'
}
// ---------------------------------------------------------------------------
// Extension UI Auto-Responder
// ---------------------------------------------------------------------------
function handleExtensionUIRequest(
event: ExtensionUIRequest,
writeToStdin: (data: string) => void,
): void {
const { id, method } = event
let response: Record<string, unknown>
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<string, unknown>,
verbose: boolean,
): string | null {
const type = String(event.type ?? '')
switch (type) {
case 'tool_execution_start':
return `[tool] ${event.toolName ?? 'unknown'}`
case 'tool_execution_end':
if (verbose) {
const result = String(event.result ?? '').slice(0, 200)
return `[tool:result] ${event.toolName ?? 'unknown'}: ${result}`
}
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 ?? ''}`
}
return null
case 'message_update':
if (verbose) {
const msgEvent = event.assistantMessageEvent as Record<string, unknown> | undefined
const text = String(msgEvent?.text ?? '').slice(0, 200)
if (text) return `[assistant] ${text}`
}
return null
default:
return null
}
}
// ---------------------------------------------------------------------------
// Completion Detection
// ---------------------------------------------------------------------------
const TERMINAL_KEYWORDS = ['complete', 'stopped', 'blocked']
const IDLE_TIMEOUT_MS = 15_000
function isTerminalNotification(event: Record<string, unknown>): boolean {
if (event.type !== 'extension_ui_request' || event.method !== 'notify') return false
const message = String(event.message ?? '').toLowerCase()
return TERMINAL_KEYWORDS.some((kw) => message.includes(kw))
}
function isBlockedNotification(event: Record<string, unknown>): boolean {
if (event.type !== 'extension_ui_request' || event.method !== 'notify') return false
return String(event.message ?? '').toLowerCase().includes('blocked')
}
// ---------------------------------------------------------------------------
// Main Orchestrator
// ---------------------------------------------------------------------------
export async function runHeadless(options: HeadlessOptions): Promise<void> {
const startTime = Date.now()
// Validate .gsd/ directory
const gsdDir = join(process.cwd(), '.gsd')
if (!existsSync(gsdDir)) {
process.stderr.write('[headless] Error: No .gsd/ directory found in current directory.\n')
process.stderr.write("[headless] Run 'gsd' interactively first to initialize a project.\n")
process.exit(1)
}
// Resolve CLI path for the child process
const cliPath = process.env.GSD_BIN_PATH || process.argv[1]
if (!cliPath) {
process.stderr.write('[headless] Error: Cannot determine CLI path. Set GSD_BIN_PATH or run via gsd.\n')
process.exit(1)
}
// Create RPC client
const clientOptions: Record<string, unknown> = {
cliPath,
cwd: process.cwd(),
}
if (options.model) {
clientOptions.model = options.model
}
const client = new RpcClient(clientOptions)
// Event tracking
let totalEvents = 0
let toolCallCount = 0
let sawToolExecution = false
let blocked = false
let completed = false
let exitCode = 0
const recentEvents: TrackedEvent[] = []
function trackEvent(event: Record<string, unknown>): void {
totalEvents++
const type = String(event.type ?? 'unknown')
if (type === 'tool_execution_start') {
toolCallCount++
sawToolExecution = true
}
// Keep last 20 events for diagnostics
const detail =
type === 'tool_execution_start'
? String(event.toolName ?? '')
: type === 'extension_ui_request'
? `${event.method}: ${event.title ?? event.message ?? ''}`
: undefined
recentEvents.push({ type, timestamp: Date.now(), detail })
if (recentEvents.length > 20) recentEvents.shift()
}
// Stdin writer for sending extension_ui_response to child
let stdinWriter: ((data: string) => void) | null = null
// Completion promise
let resolveCompletion: () => void
let rejectCompletion: (err: Error) => void
const completionPromise = new Promise<void>((resolve, reject) => {
resolveCompletion = resolve
rejectCompletion = reject
})
// Idle timeout — fallback completion detection
let idleTimer: ReturnType<typeof setTimeout> | null = null
function resetIdleTimer(): void {
if (idleTimer) clearTimeout(idleTimer)
if (sawToolExecution) {
idleTimer = setTimeout(() => {
completed = true
resolveCompletion()
}, IDLE_TIMEOUT_MS)
}
}
// Overall timeout
const timeoutTimer = setTimeout(() => {
process.stderr.write(`[headless] Timeout after ${options.timeout / 1000}s\n`)
exitCode = 1
resolveCompletion()
}, options.timeout)
// Event handler
client.onEvent((event) => {
const eventObj = event as unknown as Record<string, unknown>
trackEvent(eventObj)
resetIdleTimer()
// --json mode: forward all events as JSONL to stdout
if (options.json) {
process.stdout.write(JSON.stringify(eventObj) + '\n')
} else {
// Progress output to stderr
const line = formatProgress(eventObj, options.verbose)
if (line) process.stderr.write(line + '\n')
}
// Handle extension_ui_request
if (eventObj.type === 'extension_ui_request' && stdinWriter) {
// Check for terminal notification before auto-responding
if (isBlockedNotification(eventObj)) {
blocked = true
}
if (isTerminalNotification(eventObj)) {
completed = true
}
handleExtensionUIRequest(eventObj as unknown as ExtensionUIRequest, stdinWriter)
// If we detected a terminal notification, resolve after responding
if (completed) {
exitCode = blocked ? 2 : 0
resolveCompletion()
return
}
}
// agent_end after tool execution — possible completion
if (eventObj.type === 'agent_end' && sawToolExecution && !completed) {
// Don't immediately resolve — wait for potential terminal notify or idle timeout.
// The idle timer handles this case.
}
})
// Signal handling
const signalHandler = () => {
process.stderr.write('\n[headless] Interrupted, stopping child process...\n')
exitCode = 1
client.stop().finally(() => {
clearTimeout(timeoutTimer)
if (idleTimer) clearTimeout(idleTimer)
process.exit(exitCode)
})
}
process.on('SIGINT', signalHandler)
process.on('SIGTERM', signalHandler)
// Start the RPC session
try {
await client.start()
} catch (err) {
process.stderr.write(`[headless] Error: Failed to start RPC session: ${err instanceof Error ? err.message : String(err)}\n`)
clearTimeout(timeoutTimer)
process.exit(1)
}
// Access stdin writer from the internal process
const internalProcess = (client as any).process as ChildProcess
if (!internalProcess?.stdin) {
process.stderr.write('[headless] Error: Cannot access child process stdin\n')
await client.stop()
clearTimeout(timeoutTimer)
process.exit(1)
}
stdinWriter = (data: string) => {
internalProcess.stdin!.write(data)
}
// Detect child process crash
internalProcess.on('exit', (code) => {
if (!completed) {
const msg = `[headless] Child process exited unexpectedly with code ${code ?? 'null'}\n`
process.stderr.write(msg)
exitCode = 1
resolveCompletion()
}
})
if (!options.json) {
process.stderr.write('[headless] Starting auto-mode...\n')
}
// Send the command
const command = options.step ? '/gsd next' : '/gsd auto'
try {
await client.prompt(command)
} catch (err) {
process.stderr.write(`[headless] Error: Failed to send prompt: ${err instanceof Error ? err.message : String(err)}\n`)
exitCode = 1
}
// Wait for completion
if (exitCode === 0 || exitCode === 2) {
await completionPromise
}
// Cleanup
clearTimeout(timeoutTimer)
if (idleTimer) clearTimeout(idleTimer)
process.removeListener('SIGINT', signalHandler)
process.removeListener('SIGTERM', signalHandler)
await client.stop()
// Summary
const duration = ((Date.now() - startTime) / 1000).toFixed(1)
const status = blocked ? 'blocked' : exitCode === 1 ? (totalEvents === 0 ? 'error' : 'timeout') : 'complete'
process.stderr.write(`[headless] Status: ${status}\n`)
process.stderr.write(`[headless] Duration: ${duration}s\n`)
process.stderr.write(`[headless] Events: ${totalEvents} total, ${toolCallCount} tool calls\n`)
// On failure, print last 5 events for diagnostics
if (exitCode !== 0) {
const lastFive = recentEvents.slice(-5)
if (lastFive.length > 0) {
process.stderr.write('[headless] Last events:\n')
for (const e of lastFive) {
process.stderr.write(` ${e.type}${e.detail ? `: ${e.detail}` : ''}\n`)
}
}
}
process.exit(exitCode)
}

View file

@ -31,6 +31,35 @@ const SUBCOMMAND_HELP: Record<string, string> = {
'',
'Compare with --continue (-c) which always resumes the most recent session.',
].join('\n'),
headless: [
'Usage: gsd headless [flags] [command] [args...]',
'',
'Run /gsd commands without the TUI. Default command: auto',
'',
'Flags (before command):',
' --timeout N Overall timeout in ms (default: 300000)',
' --json JSONL event stream to stdout',
' --verbose Detailed progress output',
' --model ID Override model',
'',
'Commands:',
' auto /gsd auto (default)',
' next /gsd next — one unit',
' status /gsd status',
' queue /gsd queue',
' discuss /gsd discuss',
' doctor [mode] /gsd doctor [fix|heal|audit]',
' steer "desc" /gsd steer',
' dispatch <phase> Direct unit-type dispatch',
' ... Any /gsd subcommand',
'',
'Dispatch phases:',
' research, plan, execute, complete, reassess, uat, replan',
' Also: research-milestone, plan-slice, execute-task, etc.',
'',
'Exit codes: 0 = complete, 1 = error/timeout, 2 = blocked',
].join('\n'),
}
export function printHelp(version: string): void {
@ -51,6 +80,7 @@ export function printHelp(version: string): void {
process.stdout.write(' config Re-run the setup wizard\n')
process.stdout.write(' update Update GSD to the latest version\n')
process.stdout.write(' sessions List and resume a past session\n')
process.stdout.write(' headless [cmd] [args] Run /gsd commands without TUI (default: auto)\n')
process.stdout.write('\nRun gsd <subcommand> --help for subcommand-specific help.\n')
}