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
33 changes: 32 additions & 1 deletion packages/opencode/src/cli/cmd/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(" ")
Expand Down Expand Up @@ -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")
Expand Down
9 changes: 9 additions & 0 deletions packages/opencode/src/tool/bash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string | undefined> = { ...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))
Expand Down
85 changes: 79 additions & 6 deletions packages/opencode/src/tool/question.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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="<label>" — pick the option whose label matches
//
// Mode overrides:
// ALTIMATE_FORCE_INTERACTIVE=1 — keep Question.ask() (e.g. tests)
// ALTIMATE_NON_INTERACTIVE=1 — set by `run`; opt-in elsewhere

function isNonInteractive(): boolean {
if (process.env["ALTIMATE_FORCE_INTERACTIVE"] === "1") return false
return process.env["ALTIMATE_NON_INTERACTIVE"] === "1"
}

function autoAnswer(questions: Question.Info[]): Question.Answer[] {
const mode = process.env["ALTIMATE_AUTO_ANSWER"]?.toLowerCase()
return questions.map((q) => {
if (!mode) return [] // default — Unanswered, agent decides
if (mode === "first") return q.options[0] ? [q.options[0].label] : []
if (mode === "last") {
const last = q.options[q.options.length - 1]
return last ? [last.label] : []
}
const match = q.options.find((o) => o.label.toLowerCase() === mode)
return match ? [match.label] : []
})
}
// altimate_change end

export const QuestionTool = Tool.define("question", {
description: DESCRIPTION,
parameters: z.object({
questions: z.array(Question.Info.omit({ custom: true })).describe("Questions to ask"),
}),
async execute(params, ctx) {
const answers = await Question.ask({
sessionID: ctx.sessionID,
questions: params.questions,
tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined,
})
// altimate_change start — short-circuit when no human is listening.
// Cache the mode once: env vars can change across the `await` below, and
// we want the result prefix to describe the path the answer actually
// came from, not whatever state we observe later.
const nonInteractive = isNonInteractive()
let answers: Question.Answer[]
if (nonInteractive) {
answers = autoAnswer(params.questions)
} else {
answers = await Question.ask({
sessionID: ctx.sessionID,
questions: params.questions,
tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined,
})
}
// altimate_change end

function format(answer: Question.Answer | undefined) {
if (!answer?.length) return "Unanswered"
Expand All @@ -22,9 +87,17 @@ export const QuestionTool = Tool.define("question", {

const formatted = params.questions.map((q, i) => `"${q.question}"="${format(answers[i])}"`).join(", ")

// altimate_change start — split the whole message per mode. The original
// trailer "continue with the user's answers in mind" contradicts the
// non-interactive branch which tells the agent no user was available.
const output = nonInteractive
? `Running in non-interactive mode (no answer channel available). No user was available to answer. Either pick a safe path from the context of the action you were about to take, or report that user input is required to proceed — the user can set ALTIMATE_AUTO_ANSWER=first|last|<exact option label> to pre-answer questions in this mode. Result: ${formatted}.`
: `User has answered your questions: ${formatted}. You can now continue with the user's answers in mind.`
// altimate_change end

return {
title: `Asked ${params.questions.length} question${params.questions.length > 1 ? "s" : ""}`,
output: `User has answered your questions: ${formatted}. You can now continue with the user's answers in mind.`,
output,
metadata: {
answers,
},
Expand Down
Loading
Loading