fix: route gsd auto to headless runner to prevent hang on piped stdin/stdout (#3057)

`gsd auto` was not handled as a subcommand — it fell through to the
interactive TUI, which hangs indefinitely when stdin/stdout are piped
(non-TTY). Add `auto` as a recognized subcommand that rewrites argv
and delegates to `runHeadless(parseHeadlessArgs(...))`, matching the
existing `gsd headless auto` behavior.

Also adds `gsd auto` to TTY error hints and help text.

Closes #2732

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher 2026-03-30 16:44:04 -04:00 committed by GitHub
parent df9e06cfa5
commit 05b7cb95cb
3 changed files with 126 additions and 0 deletions

View file

@ -171,6 +171,7 @@ const hasSubcommand = cliFlags.messages.length > 0
if (!process.stdin.isTTY && !isPrintMode && !hasSubcommand && !cliFlags.listModels && !cliFlags.web) {
process.stderr.write('[gsd] Error: Interactive mode requires a terminal (TTY).\n')
process.stderr.write('[gsd] Non-interactive alternatives:\n')
process.stderr.write('[gsd] gsd auto Auto-mode (pipeable, no TUI)\n')
process.stderr.write('[gsd] gsd --print "your message" Single-shot prompt\n')
process.stderr.write('[gsd] gsd --mode rpc JSON-RPC over stdin/stdout\n')
process.stderr.write('[gsd] gsd --mode mcp MCP server over stdin/stdout\n')
@ -301,6 +302,23 @@ if (cliFlags.messages[0] === 'headless') {
process.exit(0)
}
// `gsd auto [args...]` — shorthand for `gsd headless auto [args...]` (#2732)
// Without this, `gsd auto` falls through to the interactive TUI which hangs
// when stdin/stdout are piped (non-TTY environments).
if (cliFlags.messages[0] === 'auto') {
await ensureRtkBootstrap()
const { runHeadless, parseHeadlessArgs } = await import('./headless.js')
// Rewrite argv so parseHeadlessArgs sees: [node, gsd, headless, auto, ...rest]
const rewrittenArgv = [
process.argv[0],
process.argv[1],
'headless',
...cliFlags.messages, // ['auto', ...extra args]
]
await runHeadless(parseHeadlessArgs(rewrittenArgv))
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.
@ -659,6 +677,7 @@ if (!process.stdin.isTTY || !process.stdout.isTTY) {
: 'stdout is'
process.stderr.write(`[gsd] Error: Interactive mode requires a terminal (TTY) but ${missing} not a TTY.\n`)
process.stderr.write('[gsd] Non-interactive alternatives:\n')
process.stderr.write('[gsd] gsd auto Auto-mode (pipeable, no TUI)\n')
process.stderr.write('[gsd] gsd --print "your message" Single-shot prompt\n')
process.stderr.write('[gsd] gsd --web [path] Browser-only web mode\n')
process.stderr.write('[gsd] gsd --mode rpc JSON-RPC over stdin/stdout\n')

View file

@ -169,6 +169,7 @@ export function printHelp(version: string): void {
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(' worktree <cmd> Manage worktrees (list, merge, clean, remove)\n')
process.stdout.write(' auto [args] Run auto-mode without TUI (pipeable)\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')
}

View file

@ -0,0 +1,106 @@
/**
* Tests for `gsd auto` routing verifies that `auto` is recognized as a
* subcommand alias for `headless auto` so it doesn't fall through to the
* interactive TUI, which hangs when stdin/stdout are piped.
*
* Regression test for #2732.
*/
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
const projectRoot = join(fileURLToPath(import.meta.url), '..', '..', '..')
// ---------------------------------------------------------------------------
// Source-level verification: cli.ts must handle 'auto' before TUI
// ---------------------------------------------------------------------------
/**
* Read cli.ts and verify the 'auto' subcommand is routed before the
* interactive TUI code path. This is the definitive test if cli.ts doesn't
* handle 'auto', piped invocations will hang (#2732).
*/
function cliSourceHandlesAutoBeforeTUI(): boolean {
const cliSource = readFileSync(join(projectRoot, 'src', 'cli.ts'), 'utf-8')
// Find the position of the 'auto' subcommand handler
// It should appear as: messages[0] === 'auto'
const autoHandlerMatch = cliSource.match(
/messages\[0\]\s*===\s*['"]auto['"]/,
)
if (!autoHandlerMatch) return false
// Find the position of the InteractiveMode TUI entry
const tuiMatch = cliSource.match(/new\s+InteractiveMode\s*\(/)
if (!tuiMatch) return false
// The auto handler must appear BEFORE the TUI in the source
const autoPos = cliSource.indexOf(autoHandlerMatch[0])
const tuiPos = cliSource.indexOf(tuiMatch[0])
return autoPos < tuiPos
}
// ═══════════════════════════════════════════════════════════════════════════
// Core regression test: `gsd auto` must be handled before TUI (#2732)
// ═══════════════════════════════════════════════════════════════════════════
test('cli.ts handles `auto` subcommand before interactive TUI (#2732)', () => {
assert.ok(
cliSourceHandlesAutoBeforeTUI(),
'cli.ts must route messages[0] === "auto" to a handler BEFORE ' +
'reaching `new InteractiveMode()`. Without this, `gsd auto` with ' +
'piped stdin/stdout falls through to the TUI and hangs.',
)
})
// ═══════════════════════════════════════════════════════════════════════════
// Verify the auto handler routes to headless (not a stub/no-op)
// ═══════════════════════════════════════════════════════════════════════════
test('cli.ts routes `auto` to headless runner', () => {
const cliSource = readFileSync(join(projectRoot, 'src', 'cli.ts'), 'utf-8')
// The auto handler block should import or reference headless
// Look for the auto block and check it contains runHeadless or headless
const autoBlockRegex = /messages\[0\]\s*===\s*['"]auto['"][\s\S]*?runHeadless/
assert.ok(
autoBlockRegex.test(cliSource),
'`auto` subcommand handler must invoke runHeadless to delegate to headless mode',
)
})
// ═══════════════════════════════════════════════════════════════════════════
// Verify piped-mode hint in error message when auto mode is not available
// ═══════════════════════════════════════════════════════════════════════════
test('TTY error message mentions `gsd auto` as a non-interactive alternative', () => {
const cliSource = readFileSync(join(projectRoot, 'src', 'cli.ts'), 'utf-8')
// The TTY error message should mention auto as an alternative
assert.ok(
cliSource.includes('gsd auto') || cliSource.includes('gsd headless'),
'TTY error hints should mention headless/auto mode as alternatives',
)
})
// ═══════════════════════════════════════════════════════════════════════════
// `gsd headless` still works (no regression)
// ═══════════════════════════════════════════════════════════════════════════
test('cli.ts handles `headless` subcommand before interactive TUI', () => {
const cliSource = readFileSync(join(projectRoot, 'src', 'cli.ts'), 'utf-8')
const headlessMatch = cliSource.match(/messages\[0\]\s*===\s*['"]headless['"]/)
const tuiMatch = cliSource.match(/new\s+InteractiveMode\s*\(/)
assert.ok(headlessMatch, 'headless subcommand handler exists')
assert.ok(tuiMatch, 'InteractiveMode TUI exists')
const headlessPos = cliSource.indexOf(headlessMatch![0])
const tuiPos = cliSource.indexOf(tuiMatch![0])
assert.ok(headlessPos < tuiPos, 'headless handler is before TUI')
})