fix: redirect auto-mode to headless when stdout is piped (#2732) (#3269)

When `gsd auto` is run with piped stdout (e.g. `gsd auto | cat` or
`gsd auto > file`), the TUI cannot render on a non-terminal output
stream, causing the process to hang indefinitely.

This fix:
- Detects piped stdout before entering interactive mode and redirects
  `gsd auto` to headless mode automatically
- Extends the interactive mode TTY gate to also check process.stdout.isTTY
  (previously only checked stdin), with a descriptive error message
- Adds `gsd headless` to the non-interactive alternatives hint

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher 2026-03-30 15:48:07 -04:00 committed by GitHub
parent 81e303a483
commit 3ec96fd992
2 changed files with 194 additions and 2 deletions

View file

@ -565,6 +565,20 @@ if (!cliFlags.worktree && !isPrintMode) {
} catch { /* non-fatal */ }
}
// ---------------------------------------------------------------------------
// Auto-redirect: `gsd auto` with piped stdout → headless mode (#2732)
// When stdout is not a TTY (e.g. `gsd auto | cat`, `gsd auto > file`),
// the TUI cannot render and the process hangs. Redirect to headless mode
// which handles non-interactive output gracefully.
// ---------------------------------------------------------------------------
if (cliFlags.messages[0] === 'auto' && !process.stdout.isTTY) {
await ensureRtkBootstrap()
const { runHeadless, parseHeadlessArgs } = await import('./headless.js')
process.stderr.write('[gsd] stdout is not a terminal — running auto-mode in headless mode.\n')
await runHeadless(parseHeadlessArgs(['node', 'gsd', 'headless', ...cliFlags.messages.slice(1)]))
process.exit(0)
}
// ---------------------------------------------------------------------------
// Interactive mode — normal TTY session
// ---------------------------------------------------------------------------
@ -662,14 +676,20 @@ if (enabledModelPatterns && enabledModelPatterns.length > 0) {
}
}
if (!process.stdin.isTTY) {
process.stderr.write('[gsd] Error: Interactive mode requires a terminal (TTY).\n')
if (!process.stdin.isTTY || !process.stdout.isTTY) {
const missing = !process.stdin.isTTY && !process.stdout.isTTY
? 'stdin and stdout are'
: !process.stdin.isTTY
? 'stdin is'
: '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 --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')
process.stderr.write('[gsd] gsd --mode mcp MCP server over stdin/stdout\n')
process.stderr.write('[gsd] gsd --mode text "message" Text output mode\n')
process.stderr.write('[gsd] gsd headless Auto-mode without TUI\n')
process.exit(1)
}

View file

@ -0,0 +1,172 @@
/**
* Tests for auto-mode piped I/O detection (#2732).
*
* When `gsd auto` is run with piped stdout (e.g. `gsd auto | cat`),
* the CLI should detect the non-TTY stdout and redirect to headless
* mode instead of hanging in interactive mode trying to set up a TUI
* on a non-terminal output stream.
*
* Also verifies the stdout TTY gate at the interactive mode entry point:
* when stdout is piped, interactive mode must not be entered regardless
* of the subcommand.
*/
import test from "node:test";
import assert from "node:assert/strict";
// ─── Extracted detection logic (mirrors cli.ts) ───────────────────────────
/**
* Subcommands that are explicitly handled before the interactive mode
* section in cli.ts and therefore never fall through to the TUI.
*/
const EXPLICIT_SUBCOMMANDS = new Set([
"headless",
"update",
"config",
"worktree",
"wt",
"sessions",
"web",
]);
/**
* Detect whether the current subcommand should be auto-redirected
* to headless mode when stdout is not a TTY.
*
* Returns true when: the subcommand is "auto" AND stdout is piped.
*/
function shouldRedirectAutoToHeadless(
subcommand: string | undefined,
stdoutIsTTY: boolean,
): boolean {
if (stdoutIsTTY) return false;
return subcommand === "auto";
}
/**
* Check whether interactive mode can be entered.
* Both stdin AND stdout must be TTY for the TUI to work.
*/
function canEnterInteractiveMode(
stdinIsTTY: boolean,
stdoutIsTTY: boolean,
): boolean {
return stdinIsTTY && stdoutIsTTY;
}
/**
* Returns true if the subcommand is handled by an explicit branch
* in cli.ts and will never reach the interactive mode section.
*/
function isExplicitSubcommand(subcommand: string | undefined): boolean {
return subcommand !== undefined && EXPLICIT_SUBCOMMANDS.has(subcommand);
}
// ─── shouldRedirectAutoToHeadless ─────────────────────────────────────────
test("redirects 'auto' to headless when stdout is piped", () => {
assert.ok(shouldRedirectAutoToHeadless("auto", false));
});
test("does NOT redirect 'auto' when stdout is a TTY", () => {
assert.ok(!shouldRedirectAutoToHeadless("auto", true));
});
test("does NOT redirect non-auto subcommands when stdout is piped", () => {
assert.ok(!shouldRedirectAutoToHeadless("headless", false));
assert.ok(!shouldRedirectAutoToHeadless("config", false));
assert.ok(!shouldRedirectAutoToHeadless("update", false));
assert.ok(!shouldRedirectAutoToHeadless(undefined, false));
});
// ─── canEnterInteractiveMode ──────────────────────────────────────────────
test("allows interactive mode when both stdin and stdout are TTY", () => {
assert.ok(canEnterInteractiveMode(true, true));
});
test("blocks interactive mode when stdin is piped", () => {
assert.ok(!canEnterInteractiveMode(false, true));
});
test("blocks interactive mode when stdout is piped", () => {
assert.ok(!canEnterInteractiveMode(true, false));
});
test("blocks interactive mode when both stdin and stdout are piped", () => {
assert.ok(!canEnterInteractiveMode(false, false));
});
// ─── isExplicitSubcommand ─────────────────────────────────────────────────
test("identifies explicitly handled subcommands", () => {
assert.ok(isExplicitSubcommand("headless"));
assert.ok(isExplicitSubcommand("update"));
assert.ok(isExplicitSubcommand("config"));
assert.ok(isExplicitSubcommand("worktree"));
assert.ok(isExplicitSubcommand("wt"));
assert.ok(isExplicitSubcommand("sessions"));
assert.ok(isExplicitSubcommand("web"));
});
test("does NOT identify 'auto' as explicit subcommand", () => {
assert.ok(!isExplicitSubcommand("auto"));
});
test("does NOT identify undefined as explicit subcommand", () => {
assert.ok(!isExplicitSubcommand(undefined));
});
// ─── End-to-end scenario: gsd auto | cat ──────────────────────────────────
test("scenario: 'gsd auto 2>&1 | cat' — should redirect to headless", () => {
// Simulates: subcommand = "auto", stdin is TTY, stdout is piped
const subcommand = "auto";
const stdinIsTTY = true;
const stdoutIsTTY = false;
// Interactive mode should be blocked
assert.ok(!canEnterInteractiveMode(stdinIsTTY, stdoutIsTTY));
// Auto should be redirected to headless
assert.ok(shouldRedirectAutoToHeadless(subcommand, stdoutIsTTY));
});
test("scenario: 'gsd auto > /tmp/output.txt' — should redirect to headless", () => {
const subcommand = "auto";
const stdinIsTTY = true;
const stdoutIsTTY = false;
assert.ok(!canEnterInteractiveMode(stdinIsTTY, stdoutIsTTY));
assert.ok(shouldRedirectAutoToHeadless(subcommand, stdoutIsTTY));
});
test("scenario: 'gsd auto' in terminal — normal interactive mode", () => {
const subcommand = "auto";
const stdinIsTTY = true;
const stdoutIsTTY = true;
assert.ok(canEnterInteractiveMode(stdinIsTTY, stdoutIsTTY));
assert.ok(!shouldRedirectAutoToHeadless(subcommand, stdoutIsTTY));
});
test("scenario: 'echo msg | gsd auto' — stdin piped, should redirect", () => {
const subcommand = "auto";
const stdinIsTTY = false;
const stdoutIsTTY = true; // stdout is TTY even though stdin is piped
// stdout is TTY, so auto redirect doesn't trigger...
assert.ok(!shouldRedirectAutoToHeadless(subcommand, stdoutIsTTY));
// ...but interactive mode is blocked because stdin is piped
assert.ok(!canEnterInteractiveMode(stdinIsTTY, stdoutIsTTY));
});
test("scenario: 'echo msg | gsd auto | cat' — both piped", () => {
const subcommand = "auto";
const stdinIsTTY = false;
const stdoutIsTTY = false;
assert.ok(!canEnterInteractiveMode(stdinIsTTY, stdoutIsTTY));
assert.ok(shouldRedirectAutoToHeadless(subcommand, stdoutIsTTY));
});