From 213b76edcda240d1825870ecf62a7694aab20434 Mon Sep 17 00:00:00 2001 From: Daniil Pokrovsky Date: Mon, 17 Aug 2026 19:51:32 +0700 Subject: [PATCH] fix(code): pass hook-fix and review prompts via argv, not stdin TUI-first harnesses (Grok Build, Kimi, Goose, Qwen, Antigravity, Pi) open an interactive session when no prompt flag is set, then exit with ENXIO / "Device not configured" when stdin is a pipe. Positional CLIs (Codex, Cursor, Opencode) never saw the piped prompt either. The main implementation path already used buildPromptArgs. Hook fixer, address-review, auto-review, and the webhook review runner now share the same headless argv + ignored-stdin spawn. Signed-off-by: Daniil Pokrovsky --- packages/code/CHANGELOG.md | 6 +++ packages/code/src/lib/address-review.ts | 16 +++----- packages/code/src/lib/agent-spawn.ts | 31 ++++++++++++++++ packages/code/src/lib/auto-review-loop.ts | 11 ++---- packages/code/src/lib/git-hook-fixer.ts | 19 +++++----- packages/code/src/webhook-server.ts | 20 ++++------ packages/code/tests/agent-spawn.test.ts | 45 +++++++++++++++++++++++ 7 files changed, 110 insertions(+), 38 deletions(-) create mode 100644 packages/code/src/lib/agent-spawn.ts create mode 100644 packages/code/tests/agent-spawn.test.ts diff --git a/packages/code/CHANGELOG.md b/packages/code/CHANGELOG.md index 3a1192b..a4435e2 100644 --- a/packages/code/CHANGELOG.md +++ b/packages/code/CHANGELOG.md @@ -1,5 +1,11 @@ # @devintern/code Changelog +## [Unreleased] + +### Fixed + +- **Headless Grok / TUI harnesses during hook-fix and review**: hook fixer, `address-review`, auto-review, and the webhook review runner now pass the prompt on the command line (`grok -p`, `kimi --prompt`, positional for Codex/Opencode/Cursor) and ignore stdin. Those paths previously piped the prompt, so TUI-first CLIs opened an interactive session and died with `Device not configured (os error 6)` / ENXIO when no TTY was attached + ## [2.3.1] - 2026-08-12 ### Fixed diff --git a/packages/code/src/lib/address-review.ts b/packages/code/src/lib/address-review.ts index 00030d6..6c9d511 100644 --- a/packages/code/src/lib/address-review.ts +++ b/packages/code/src/lib/address-review.ts @@ -11,6 +11,7 @@ import { reapTree, resolveExecutablePathWithRetry, } from "@devintern/agent-harness"; +import { buildHeadlessAgentArgs, HEADLESS_AGENT_STDIO } from "./agent-spawn"; import { getSandbox } from "./sandbox"; import { GitHubReviewsClient } from "./github-reviews"; import { GitHubAppAuth } from "./github-app-auth"; @@ -107,7 +108,7 @@ async function getLatestChangesRequestedReview( /** * Run the configured agent harness to address review feedback. * - * @param prompt - Full review prompt sent to the agent via stdin + * @param prompt - Full review prompt sent to the agent via argv (`-p` / positional) * @param workDir - Git working directory for the agent process * @param verbose - When true, log command and timeout details * @returns Whether the agent succeeded, its combined output, and max-turns flag @@ -131,10 +132,11 @@ export async function runAgent( const maxTurns = parseInt(process.env.CLAUDE_MAX_TURNS || "500", 10); const timeoutMinutes = parseInt(process.env.AGENT_HARNESS_TIMEOUT_MINUTES || "60", 10); - const agentArgs = harness.buildArgs({ maxTurns, skipPermissions: true, workingDir: workDir }); + const runOptions = { maxTurns, skipPermissions: true, workingDir: workDir }; + const agentArgs = buildHeadlessAgentArgs(harness, prompt, runOptions); if (verbose) { - console.log(` Command: ${executablePath} ${agentArgs.join(" ")}`); + console.log(` Command: ${executablePath} ${harness.buildArgs(runOptions).join(" ")}`); console.log(` Timeout: ${timeoutMinutes} minutes`); } @@ -145,7 +147,7 @@ export async function runAgent( const { child: agent, cleanup: sandboxCleanup } = await spawnAgent({ resolvedPath, args: agentArgs, - spawnOptions: { cwd: workDir, stdio: ["pipe", "pipe", "pipe"] }, + spawnOptions: { cwd: workDir, stdio: HEADLESS_AGENT_STDIO }, sandbox: await getSandbox(harness.name), }); @@ -202,12 +204,6 @@ export async function runAgent( maxTurnsReached, }); }); - - // Send prompt to Agent via stdin - if (agent.stdin) { - agent.stdin.write(prompt); - agent.stdin.end(); - } })().catch((error) => { resolve({ success: false, diff --git a/packages/code/src/lib/agent-spawn.ts b/packages/code/src/lib/agent-spawn.ts new file mode 100644 index 0000000..458a6f5 --- /dev/null +++ b/packages/code/src/lib/agent-spawn.ts @@ -0,0 +1,31 @@ +/** + * Shared argv + stdio for headless agent spawns in this package. + * + * Implementation, hook-fix, review, and webhook paths must all put the prompt + * on the command line. Piping it via stdin makes TUI-first CLIs (Grok Build, + * Kimi, Goose, Qwen, Antigravity, Pi) open an interactive session and then + * fail with ENXIO / "Device not configured" when there is no TTY. Positional + * CLIs (Codex, Cursor, Opencode, Cline) never see a stdin prompt either. + */ + +import { buildPromptArgs } from "@devintern/agent-harness"; +import type { AgentHarness, AgentRunOptions } from "@devintern/agent-harness"; + +/** stdio used for every headless spawn: ignore stdin, pipe stdout/stderr. */ +export const HEADLESS_AGENT_STDIO: ["ignore", "pipe", "pipe"] = ["ignore", "pipe", "pipe"]; + +/** + * Build argv for a non-interactive agent run. + * + * @param harness - Resolved harness (supplies flags and optional `promptFlag`). + * @param prompt - Full prompt text. + * @param options - Per-run flags forwarded to {@link AgentHarness.buildArgs}. + * @returns Args ready to pass to {@link spawnAgent}. + */ +export function buildHeadlessAgentArgs( + harness: AgentHarness, + prompt: string, + options: AgentRunOptions, +): string[] { + return [...harness.buildArgs(options), ...buildPromptArgs(harness, prompt)]; +} diff --git a/packages/code/src/lib/auto-review-loop.ts b/packages/code/src/lib/auto-review-loop.ts index a716ec2..b9cb3f8 100644 --- a/packages/code/src/lib/auto-review-loop.ts +++ b/packages/code/src/lib/auto-review-loop.ts @@ -13,6 +13,7 @@ import { writeFileSync, readFileSync, existsSync, mkdirSync } from "fs"; import { join } from "path"; import { spawnAgent, reapTree, resolveExecutablePathWithRetry } from "@devintern/agent-harness"; import type { AgentHarness } from "@devintern/agent-harness"; +import { buildHeadlessAgentArgs, HEADLESS_AGENT_STDIO } from "./agent-spawn"; import { getSandbox } from "./sandbox"; import type { AutoReviewLoopOptions, @@ -311,7 +312,7 @@ function parseReviewFeedback(agentOutput: string): ReviewFeedback { /** * Run the agent harness with a prompt and capture stdout. * - * @param prompt - Prompt sent to the agent via stdin + * @param prompt - Prompt sent to the agent via argv (`-p` / positional) * @param workingDir - Git working directory * @param harness - Resolved agent harness configuration * @param executablePath - Path to the agent CLI executable @@ -335,7 +336,7 @@ async function runAgentPrompt( (async () => { const timeoutMinutes = parseInt(process.env.AGENT_HARNESS_TIMEOUT_MINUTES || "60", 10); - const agentArgs = harness.buildArgs({ + const agentArgs = buildHeadlessAgentArgs(harness, prompt, { maxTurns: 500, skipPermissions: true, workingDir, @@ -343,7 +344,7 @@ async function runAgentPrompt( const { child: agentProcess, cleanup: sandboxCleanup } = await spawnAgent({ resolvedPath, args: agentArgs, - spawnOptions: { cwd: workingDir, stdio: ["pipe", "pipe", "pipe"] }, + spawnOptions: { cwd: workingDir, stdio: HEADLESS_AGENT_STDIO }, sandbox: await getSandbox(harness.name), }); @@ -392,10 +393,6 @@ async function runAgentPrompt( clearTimeout(timeout); reject(new Error(`Failed to spawn ${harness.displayName}: ${error}`)); }); - - // Send prompt to stdin - agentProcess.stdin?.write(prompt); - agentProcess.stdin?.end(); })().catch(reject); }); } diff --git a/packages/code/src/lib/git-hook-fixer.ts b/packages/code/src/lib/git-hook-fixer.ts index 612d78e..fddc21d 100644 --- a/packages/code/src/lib/git-hook-fixer.ts +++ b/packages/code/src/lib/git-hook-fixer.ts @@ -7,6 +7,7 @@ import { existsSync } from "fs"; import { spawnAgent, reapTree, resolveExecutablePathWithRetry } from "@devintern/agent-harness"; import type { AgentHarness } from "@devintern/agent-harness"; +import { buildHeadlessAgentArgs, HEADLESS_AGENT_STDIO } from "./agent-spawn"; import { getSandbox } from "./sandbox"; import { Utils } from "./utils"; import { resolveOutputDir } from "./output-dir"; @@ -159,14 +160,20 @@ ${hookType === "push" ? "- Make sure to amend the commit (git commit --amend --n let stderrOutput = ""; let timedOut = false; - const agentArgs = harness.buildArgs({ maxTurns, skipPermissions: true, workingDir }); + const agentArgs = buildHeadlessAgentArgs(harness, fixPrompt, { + maxTurns, + skipPermissions: true, + workingDir, + }); // Spawn agent process to fix the issues. The executable path was already - // resolved (and waited on through any auto-update swap) above. + // resolved (and waited on through any auto-update swap) above. Prompt + // goes on argv (`-p` / positional); stdin is ignored so TUI-first CLIs + // do not try to attach a terminal. const { child: agent, cleanup: sandboxCleanup } = await spawnAgent({ resolvedPath, args: agentArgs, - spawnOptions: { stdio: ["pipe", "pipe", "pipe"], cwd: workingDir }, + spawnOptions: { stdio: HEADLESS_AGENT_STDIO, cwd: workingDir }, sandbox: await getSandbox(harness.name), }); @@ -343,12 +350,6 @@ ${hookType === "push" ? "- Make sure to amend the commit (git commit --amend --n resolve(false); } }); - - // Send the fix prompt to the agent - if (agent.stdin) { - agent.stdin.write(fixPrompt); - agent.stdin.end(); - } })().catch((error) => { console.error( `❌ Failed to run ${harness.displayName} for git hook fix: ${error instanceof Error ? error.message : String(error)}`, diff --git a/packages/code/src/webhook-server.ts b/packages/code/src/webhook-server.ts index a72feaf..37891a9 100644 --- a/packages/code/src/webhook-server.ts +++ b/packages/code/src/webhook-server.ts @@ -7,7 +7,7 @@ * review feedback using an AI agent. */ -import { existsSync, mkdirSync, unlinkSync, writeFileSync } from "fs"; +import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "fs"; import { createServer } from "http"; import type { IncomingMessage, ServerResponse } from "http"; import { join } from "path"; @@ -21,6 +21,7 @@ import { reapTree, resolveExecutablePathWithRetry, } from "@devintern/agent-harness"; +import { buildHeadlessAgentArgs, HEADLESS_AGENT_STDIO } from "./lib/agent-spawn"; import { getSandbox } from "./lib/sandbox"; import { GitHubAppAuth } from "./lib/github-app-auth"; import { GitHubReviewsClient } from "./lib/github-reviews"; @@ -1233,7 +1234,7 @@ async function prepareRepository(branch: string, verbose = false): Promise { resolve({ success: false, diff --git a/packages/code/tests/agent-spawn.test.ts b/packages/code/tests/agent-spawn.test.ts new file mode 100644 index 0000000..142cb36 --- /dev/null +++ b/packages/code/tests/agent-spawn.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from "bun:test"; +import { + ClaudeCodeHarness, + CodexHarness, + GrokHarness, + KimiHarness, + OpencodeHarness, +} from "@devintern/agent-harness"; +import { buildHeadlessAgentArgs, HEADLESS_AGENT_STDIO } from "../src/lib/agent-spawn"; + +const runOptions = { skipPermissions: true, workingDir: "/tmp/repo" }; + +describe("buildHeadlessAgentArgs", () => { + test("grok receives -p so it does not open the TUI", () => { + const args = buildHeadlessAgentArgs(new GrokHarness(), "fix the hook", runOptions); + expect(args).toContain("-p"); + expect(args).toContain("fix the hook"); + expect(args.indexOf("-p")).toBeLessThan(args.indexOf("fix the hook")); + }); + + test("kimi receives --prompt so it does not open the TUI", () => { + const args = buildHeadlessAgentArgs(new KimiHarness(), "review this", runOptions); + expect(args).toContain("--prompt"); + expect(args).toContain("review this"); + }); + + test("claude-code receives -p", () => { + const args = buildHeadlessAgentArgs(new ClaudeCodeHarness(), "implement it", runOptions); + expect(args).toContain("-p"); + expect(args).toContain("implement it"); + }); + + test("opencode and codex receive the prompt as a positional argument", () => { + expect(buildHeadlessAgentArgs(new OpencodeHarness(), "do the task", runOptions)).toContain( + "do the task", + ); + expect(buildHeadlessAgentArgs(new CodexHarness(), "do the task", runOptions)).toContain( + "do the task", + ); + }); + + test("headless stdio ignores stdin so TUI CLIs cannot attach a TTY", () => { + expect(HEADLESS_AGENT_STDIO).toEqual(["ignore", "pipe", "pipe"]); + }); +});