diff --git a/src/headless.ts b/src/headless.ts index 660bcdced..f9dcb0eef 100644 --- a/src/headless.ts +++ b/src/headless.ts @@ -36,6 +36,7 @@ export interface HeadlessOptions { supervised?: boolean // supervised mode: forward interactive requests to orchestrator responseTimeout?: number // timeout for orchestrator response (default 30000ms) answers?: string // path to answers JSON file + eventFilter?: Set // filter JSONL output to specific event types } interface ExtensionUIRequest { @@ -103,6 +104,9 @@ export function parseHeadlessArgs(argv: string[]): HeadlessOptions { } } else if (arg === '--answers' && i + 1 < args.length) { options.answers = args[++i] + } else if (arg === '--events' && i + 1 < args.length) { + options.eventFilter = new Set(args[++i].split(',')) + options.json = true // --events implies --json } else if (arg === '--supervised') { options.supervised = true options.json = true // supervised implies json @@ -540,9 +544,12 @@ async function runHeadlessOnce(options: HeadlessOptions, restartCount: number): // Answer injector: observe events for question metadata injector?.observeEvent(eventObj) - // --json mode: forward all events as JSONL to stdout + // --json mode: forward events as JSONL to stdout (filtered if --events) if (options.json) { - process.stdout.write(JSON.stringify(eventObj) + '\n') + const eventType = String(eventObj.type ?? '') + if (!options.eventFilter || options.eventFilter.has(eventType)) { + process.stdout.write(JSON.stringify(eventObj) + '\n') + } } else { // Progress output to stderr const line = formatProgress(eventObj, !!options.verbose) @@ -734,6 +741,9 @@ async function runHeadlessOnce(options: HeadlessOptions, restartCount: number): 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`) + if (options.eventFilter) { + process.stderr.write(`[headless] Event filter: ${[...options.eventFilter].join(', ')}\n`) + } if (restartCount > 0) { process.stderr.write(`[headless] Restarts: ${restartCount}\n`) } diff --git a/src/help-text.ts b/src/help-text.ts index 2310ba742..31c50b51a 100644 --- a/src/help-text.ts +++ b/src/help-text.ts @@ -44,6 +44,7 @@ const SUBCOMMAND_HELP: Record = { ' --supervised Forward interactive UI requests to orchestrator via stdout/stdin', ' --response-timeout N Timeout (ms) for orchestrator response (default: 30000)', ' --answers Pre-supply answers and secrets (JSON file)', + ' --events Filter JSONL output to specific event types (comma-separated)', '', 'Commands:', ' auto Run all queued units continuously (default)', @@ -68,6 +69,7 @@ const SUBCOMMAND_HELP: Record = { ' gsd headless new-milestone --context spec.md --auto Create + auto-execute', ' gsd headless --supervised auto Supervised orchestrator mode', ' gsd headless --answers answers.json auto With pre-supplied answers', + ' gsd headless --events agent_end,extension_ui_request auto Filtered event stream', ' gsd headless query Instant JSON state snapshot', '', 'Exit codes: 0 = complete, 1 = error/timeout, 2 = blocked', diff --git a/src/resources/extensions/gsd/skills/gsd-headless/SKILL.md b/src/resources/extensions/gsd/skills/gsd-headless/SKILL.md index 0a470f5c8..eab981d4b 100644 --- a/src/resources/extensions/gsd/skills/gsd-headless/SKILL.md +++ b/src/resources/extensions/gsd/skills/gsd-headless/SKILL.md @@ -22,6 +22,7 @@ gsd headless [flags] [command] [args...] - `--response-timeout N` — timeout for orchestrator response in supervised mode (default 30000) - `--max-restarts N` — auto-restart on crash with backoff (default 3, 0 to disable) - `--answers ` — pre-supply answers and secrets from JSON file +- `--events ` — filter JSONL output to specific event types (comma-separated, implies `--json`) **Exit codes:** 0=complete, 1=error/timeout, 2=blocked @@ -165,6 +166,22 @@ done Event types: `agent_start`, `agent_end`, `tool_execution_start`, `tool_execution_end`, `extension_ui_request`, `message_update`, `error`. +### Filtered Event Stream + +Use `--events` to receive only specific event types — reduces noise for orchestrators: + +```bash +# Only phase-relevant events +gsd headless --events agent_end,extension_ui_request auto 2>/dev/null + +# Only tool execution events +gsd headless --events tool_execution_start,tool_execution_end auto +``` + +The filter applies only to stdout output. Internal processing (completion detection, supervised mode, answer injection) is unaffected — all events are still processed internally. + +Available event types: `agent_start`, `agent_end`, `tool_execution_start`, `tool_execution_end`, `tool_execution_update`, `extension_ui_request`, `message_start`, `message_end`, `message_update`, `turn_start`, `turn_end`. + ## Answer Injection Pre-supply answers and secrets for headless runs via `--answers`: diff --git a/src/tests/headless-events.test.ts b/src/tests/headless-events.test.ts new file mode 100644 index 000000000..12a6e8ca0 --- /dev/null +++ b/src/tests/headless-events.test.ts @@ -0,0 +1,151 @@ +/** + * Tests for `--events` flag — JSONL event stream filtering. + * + * Validates argument parsing and the event filter logic used by + * the headless orchestrator to reduce stdout noise for orchestrators. + * + * Uses extracted parsing logic (mirrors headless.ts) to avoid + * transitive @gsd/native import that breaks in test environment. + */ + +import test from 'node:test' +import assert from 'node:assert/strict' + +// ─── Extracted parsing logic (mirrors headless.ts) ───────────────────────── + +interface HeadlessOptions { + timeout: number + json: boolean + model?: string + command: string + commandArgs: string[] + context?: string + contextText?: string + auto?: boolean + verbose?: boolean + maxRestarts?: number + supervised?: boolean + responseTimeout?: number + answers?: string + eventFilter?: Set +} + +function parseHeadlessArgs(argv: string[]): HeadlessOptions { + const options: HeadlessOptions = { + timeout: 300_000, + json: false, + command: 'auto', + commandArgs: [], + } + + const args = argv.slice(2) + let positionalStarted = false + + for (let i = 0; i < args.length; i++) { + const arg = args[i] + if (arg === 'headless') continue + + if (!positionalStarted && arg.startsWith('--')) { + if (arg === '--timeout' && i + 1 < args.length) { + options.timeout = parseInt(args[++i], 10) + } else if (arg === '--json') { + options.json = true + } else if (arg === '--model' && i + 1 < args.length) { + options.model = args[++i] + } else if (arg === '--context' && i + 1 < args.length) { + options.context = args[++i] + } else if (arg === '--context-text' && i + 1 < args.length) { + options.contextText = args[++i] + } else if (arg === '--auto') { + options.auto = true + } else if (arg === '--verbose') { + options.verbose = true + } else if (arg === '--max-restarts' && i + 1 < args.length) { + options.maxRestarts = parseInt(args[++i], 10) + } else if (arg === '--answers' && i + 1 < args.length) { + options.answers = args[++i] + } else if (arg === '--events' && i + 1 < args.length) { + options.eventFilter = new Set(args[++i].split(',')) + options.json = true + } else if (arg === '--supervised') { + options.supervised = true + options.json = true + } else if (arg === '--response-timeout' && i + 1 < args.length) { + options.responseTimeout = parseInt(args[++i], 10) + } + } else if (!positionalStarted) { + positionalStarted = true + options.command = arg + } else { + options.commandArgs.push(arg) + } + } + + return options +} + +// ─── parseHeadlessArgs: --events flag ────────────────────────────────────── + +test('--events parses comma-separated event types into a Set', () => { + const opts = parseHeadlessArgs(['node', 'gsd', 'headless', '--events', 'agent_end,extension_ui_request', 'auto']) + assert.ok(opts.eventFilter instanceof Set) + assert.equal(opts.eventFilter!.size, 2) + assert.ok(opts.eventFilter!.has('agent_end')) + assert.ok(opts.eventFilter!.has('extension_ui_request')) +}) + +test('--events implies --json', () => { + const opts = parseHeadlessArgs(['node', 'gsd', 'headless', '--events', 'agent_end', 'auto']) + assert.equal(opts.json, true) +}) + +test('--events with single type', () => { + const opts = parseHeadlessArgs(['node', 'gsd', 'headless', '--events', 'agent_end', 'auto']) + assert.equal(opts.eventFilter!.size, 1) + assert.ok(opts.eventFilter!.has('agent_end')) +}) + +test('no --events flag means no filter', () => { + const opts = parseHeadlessArgs(['node', 'gsd', 'headless', '--json', 'auto']) + assert.equal(opts.eventFilter, undefined) +}) + +test('--events with all common types', () => { + const types = 'agent_start,agent_end,tool_execution_start,tool_execution_end,extension_ui_request' + const opts = parseHeadlessArgs(['node', 'gsd', 'headless', '--events', types, 'auto']) + assert.equal(opts.eventFilter!.size, 5) +}) + +test('--events combined with other flags', () => { + const opts = parseHeadlessArgs(['node', 'gsd', 'headless', '--timeout', '60000', '--events', 'agent_end', '--verbose', 'next']) + assert.equal(opts.timeout, 60000) + assert.equal(opts.verbose, true) + assert.equal(opts.command, 'next') + assert.ok(opts.eventFilter!.has('agent_end')) + assert.equal(opts.json, true) +}) + +// ─── Event filter matching logic ─────────────────────────────────────────── + +test('filter allows matching event types', () => { + const filter = new Set(['agent_end', 'extension_ui_request']) + assert.ok(filter.has('agent_end')) + assert.ok(filter.has('extension_ui_request')) + assert.ok(!filter.has('message_update')) + assert.ok(!filter.has('tool_execution_start')) +}) + +test('no filter allows all event types (undefined check)', () => { + const filter: Set | undefined = undefined + const shouldEmit = (type: string) => !filter || filter.has(type) + assert.ok(shouldEmit('agent_end')) + assert.ok(shouldEmit('message_update')) + assert.ok(shouldEmit('tool_execution_start')) +}) + +test('empty filter blocks all events', () => { + const filter = new Set() + const shouldEmit = (type: string) => !filter || filter.has(type) + assert.ok(!shouldEmit('agent_end')) + assert.ok(!shouldEmit('message_update')) +})