feat: add --events flag for JSONL stream filtering (#1000)

Allow orchestrators to filter the JSONL event stream to specific event
types, reducing stdout noise. The filter applies only to output —
internal processing (completion detection, supervised mode, answer
injection) is unaffected.

- New `--events <types>` flag (comma-separated, implies `--json`)
- Filter applied at stdout write point, all events still processed internally
- Updated help-text and SKILL.md with examples
- Tests for argument parsing and filter matching logic
This commit is contained in:
Juan Francisco Lebrero 2026-03-17 20:35:44 -03:00 committed by GitHub
parent fc44ea3fbe
commit fe0f4f35e6
4 changed files with 182 additions and 2 deletions

View file

@ -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<string> // 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`)
}

View file

@ -44,6 +44,7 @@ const SUBCOMMAND_HELP: Record<string, string> = {
' --supervised Forward interactive UI requests to orchestrator via stdout/stdin',
' --response-timeout N Timeout (ms) for orchestrator response (default: 30000)',
' --answers <path> Pre-supply answers and secrets (JSON file)',
' --events <types> 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<string, string> = {
' 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',

View file

@ -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 <path>` — pre-supply answers and secrets from JSON file
- `--events <types>` — 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`:

View file

@ -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<string>
}
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<string> | 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<string>()
const shouldEmit = (type: string) => !filter || filter.has(type)
assert.ok(!shouldEmit('agent_end'))
assert.ok(!shouldEmit('message_update'))
})