Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/code/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
16 changes: 6 additions & 10 deletions packages/code/src/lib/address-review.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand All @@ -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`);
}

Expand All @@ -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),
});

Expand Down Expand Up @@ -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,
Expand Down
31 changes: 31 additions & 0 deletions packages/code/src/lib/agent-spawn.ts
Original file line number Diff line number Diff line change
@@ -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)];
}
11 changes: 4 additions & 7 deletions packages/code/src/lib/auto-review-loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -335,15 +336,15 @@ 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,
});
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),
});

Expand Down Expand Up @@ -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);
});
}
Expand Down
19 changes: 10 additions & 9 deletions packages/code/src/lib/git-hook-fixer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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),
});

Expand Down Expand Up @@ -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)}`,
Expand Down
20 changes: 8 additions & 12 deletions packages/code/src/webhook-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -1233,7 +1234,7 @@ async function prepareRepository(branch: string, verbose = false): Promise<strin
/**
* Spawn the agent harness to address review feedback from a prompt file.
*
* @param promptFile - Path to markdown prompt (read and sent via stdin)
* @param promptFile - Path to markdown prompt (read and passed via argv)
* @param workDir - Git working directory
*/
async function runAgentHarnessForReview(
Expand All @@ -1260,9 +1261,11 @@ async function runAgentHarnessForReview(
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 promptContent = readFileSync(promptFile, "utf8");
const runOptions = { maxTurns, skipPermissions: true, workingDir: workDir };
const agentArgs = buildHeadlessAgentArgs(harness, promptContent, runOptions);

console.log(` Command: ${resolvedPath} ${agentArgs.join(" ")}`);
console.log(` Command: ${resolvedPath} ${harness.buildArgs(runOptions).join(" ")}`);
console.log(` Timeout: ${timeoutMinutes} minutes`);

let stdoutOutput = "";
Expand All @@ -1272,7 +1275,7 @@ async function runAgentHarnessForReview(
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),
});

Expand Down Expand Up @@ -1364,13 +1367,6 @@ async function runAgentHarnessForReview(
});
}
});

// Send prompt content to Agent
if (agent.stdin) {
const promptContent = require("fs").readFileSync(promptFile, "utf8");
agent.stdin.write(promptContent);
agent.stdin.end();
}
})().catch((error) => {
resolve({
success: false,
Expand Down
45 changes: 45 additions & 0 deletions packages/code/tests/agent-spawn.test.ts
Original file line number Diff line number Diff line change
@@ -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"]);
});
});
Loading