diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index dc59c4c86..1af6784fd 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -368,6 +368,30 @@ export const RunCommand = cmd({ // altimate_change end }, handler: async (args) => { + // altimate_change start — `run` is the only entrypoint without an answer + // channel for the question tool: no TUI is mounted and the in-process + // Server.Default() shim below does not bind a port, so a connected IDE + // or web client cannot POST /question/:requestID/reply. Without this + // flag, Question.ask() awaits a Deferred forever and the parent + // supervisor TaskStops the subprocess — looking exactly like a hang. + // Server commands (serve/web/acp/workspace-serve) intentionally leave + // this unset so their HTTP reply path stays live. + // + // Skipped when --attach is set: the agent runs on the remote server, so + // the local env var would be a no-op and would only pollute the local + // process env for other tools that may consult it. + // + // Child processes spawned by the bash tool would inherit this flag and + // misbehave if they themselves are server-mode entrypoints; bash.ts + // strips ALTIMATE_NON_INTERACTIVE from mergedEnv to prevent that leak. + // + // Users can opt out by exporting ALTIMATE_NON_INTERACTIVE=0 before + // launching `run`. + if (!args.attach && process.env["ALTIMATE_NON_INTERACTIVE"] === undefined) { + process.env["ALTIMATE_NON_INTERACTIVE"] = "1" + } + // altimate_change end + let message = [...args.message, ...(args["--"] || [])] .map((arg) => (arg.includes(" ") ? `"${arg.replace(/"/g, '\\"')}"` : arg)) .join(" ") @@ -430,7 +454,14 @@ export const RunCommand = cmd({ message = [extractedParts.join("\n\n"), message].filter(Boolean).join("\n\n") } - if (!process.stdin.isTTY) message += "\n" + (await Bun.stdin.text()) + // altimate_change start — null-safe stdin read. process.stdin can be + // undefined in embedded/child runtimes (dev-punia review, PR #937). + // Earlier revision used `!process.stdin?.isTTY`, which turned the crash + // into a stall: undefined stdin satisfied the guard and we then awaited + // Bun.stdin.text() on a stream that would never EOF. Skip the read + // entirely when there is no stdin to read from. + if (process.stdin && !process.stdin.isTTY) message += "\n" + (await Bun.stdin.text()) + // altimate_change end if (message.trim().length === 0 && !args.command) { UI.error("You must provide a message or a command") diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 5f3405202..c6dda6130 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -168,6 +168,15 @@ export const BashTool = Tool.define("bash", async () => { // altimate_change start — prepend bundled tools dir (ALTIMATE_BIN_DIR) and user tools dirs to PATH const mergedEnv: Record = { ...process.env, ...shellEnv.env } + // altimate_change start — strip ALTIMATE_NON_INTERACTIVE from child env. + // `run` sets this flag on its own process so the question tool short- + // circuits, but child processes spawned by the bash tool may themselves + // be server-mode entrypoints (e.g. `altimate-code serve`) that need + // their HTTP question-reply path live. Without this delete, the parent + // process.env spread above would silently disable that path in every + // nested server invocation. See PR #937 review (Issue #3). + delete mergedEnv["ALTIMATE_NON_INTERACTIVE"] + // altimate_change end const sep = process.platform === "win32" ? ";" : ":" const basePath = mergedEnv.PATH ?? mergedEnv.Path ?? "" const pathEntries = new Set(basePath.split(sep).filter(Boolean)) diff --git a/packages/opencode/src/tool/question.ts b/packages/opencode/src/tool/question.ts index a2887546d..8e689e949 100644 --- a/packages/opencode/src/tool/question.ts +++ b/packages/opencode/src/tool/question.ts @@ -3,17 +3,82 @@ import { Tool } from "./tool" import { Question } from "../question" import DESCRIPTION from "./question.txt" +// altimate_change start — non-interactive handling for the question tool. +// +// Question.ask() resolves via either a TUI click or an HTTP reply at +// POST /question/:requestID/reply. Server commands (serve / web / acp / +// workspace-serve) expose the HTTP path, so an IDE or web client CAN answer +// even though their stdin is not a TTY. Only `altimate-code run` is +// genuinely headless: it uses an in-process Server.Default() shim with no +// bound port, so no client can reach the reply route and Question.ask() +// awaits forever. +// +// Detection is therefore opt-in via env var rather than TTY-based: +// `run` sets ALTIMATE_NON_INTERACTIVE=1 on startup; every other entrypoint +// defaults to interactive. Earlier revisions used !process.stdin.isTTY, +// which misclassified server mode and silently disabled the HTTP reply path +// for IDE users (see PR #937 review). +// +// Policy when non-interactive: return Unanswered for every question and let +// the calling agent decide. The agent knows what it was about to do and +// why it asked; it can pick a safe path from context or report that input +// is required. We deliberately do NOT guess based on label text — every +// heuristic we tried (safe-keyword scan, last-option fallback) either +// invented decisions the user didn't make or false-positive'd on labels +// like "Snowflake" that happened to contain "no". +// +// Explicit overrides (for users who genuinely want a default and accept +// the responsibility): +// ALTIMATE_AUTO_ANSWER=first — always pick the first option +// ALTIMATE_AUTO_ANSWER=last — always pick the last option +// ALTIMATE_AUTO_ANSWER="