From c1dac08fc15c273937971ddb9e20c760abca0bd5 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Thu, 27 Aug 2026 03:00:53 +0800 Subject: [PATCH 01/11] feat(workspace): offer to install the engine a bound workspace needs Replaces the transient toast for a missing or too-old engine with Install now / Copy command / Not now. The offer reaches the TUI on the event bus, since plugins load in a separate realm; the plugin re-derives detail via describeOffer(). Headless run prints one line to stderr so --format json stays parseable. Install runs only from an explicit choice. --- .../src/altimate/workspace/engine-sync.ts | 246 +++++++++++++- packages/opencode/src/cli/cmd/run.ts | 6 + .../src/plugin/tui/altimate/workspace.tsx | 256 ++++++++++++++- .../test/altimate/plugin/workspace.test.ts | 72 +++- .../workspace/engine-install-offer.test.ts | 309 ++++++++++++++++++ .../altimate/workspace/engine-sync.test.ts | 8 + 6 files changed, 887 insertions(+), 10 deletions(-) create mode 100644 packages/opencode/test/altimate/workspace/engine-install-offer.test.ts diff --git a/packages/opencode/src/altimate/workspace/engine-sync.ts b/packages/opencode/src/altimate/workspace/engine-sync.ts index dc8c56660b..f00af740f2 100644 --- a/packages/opencode/src/altimate/workspace/engine-sync.ts +++ b/packages/opencode/src/altimate/workspace/engine-sync.ts @@ -61,6 +61,7 @@ import { Flag as CoreFlag } from "@opencode-ai/core/flag/flag" import { which as whichBinary } from "@opencode-ai/core/util/which" import { Instance } from "@/project/instance" import { Log } from "@/altimate/util/log" +import { Process } from "@/util/process" import { MCP } from "@/mcp" import { addMcpToConfig, resolveConfigPath } from "@/mcp/config" import { Config } from "@/config/config" @@ -85,6 +86,15 @@ const log = Log.create({ service: "workspace-engine" }) export const MIN_ENGINE_VERSION = "0.7.0" export const INSTALL_HINT = "npm i -g @altimateai/datamate" export const ENGINE_BINARY = "datamate" +/** npm package that provides the engine binary. */ +export const ENGINE_PACKAGE = "@altimateai/datamate" +/** The exact command the offer installs, copies, and prints. Version-pinned so + * what the user runs by hand equals what "Install now" runs. E2E points this at + * a local tarball via ALTIMATE_ENGINE_INSTALL_SPEC. */ +export const INSTALL_COMMAND = `npm i -g ${ENGINE_PACKAGE}@${MIN_ENGINE_VERSION}` +/** Node major the npm install path needs. The CLI itself is a self-contained + * binary and does not need Node — only this install route does. */ +export const MIN_NODE_MAJOR = 20 /** Engine tools arrive under the MCP server key as `_`. */ const TOOL_PREFIX = `${DATAMATE_KEY}_` @@ -110,6 +120,33 @@ export type ExistingEntry = { type?: string; url?: string; command?: string[] | type Toast = { title: string; message: string; variant: "info" | "success" | "warning" | "error" } +/** A "no usable engine" state, described well enough for an interactive + * surface to act on it without re-deriving anything. */ +export type EngineOffer = { + reason: "engine-missing" | "engine-too-old" + /** Stable id — the 7-day "Not now" latch keys on this, not the name. */ + workspaceId: string + workspaceName: string + /** Declared, CLI-servable integration tools that are unavailable without it. */ + declared: number + /** Version found — only set for "engine-too-old". */ + found?: string + /** The exact install/update command. */ + command: string +} + +/** Interactive surface for the offer, registered by the workspace TUI plugin. + * Returns true when it took ownership. + * + * Deliberately synchronous: `run()` awaits the offer, and that await sits + * inside the window `whenAttached` caps. A handler that blocked on user input + * would spend the turn's ATTACH_WAIT_MS budget, so a surface claims the offer + * and renders it out-of-band rather than making the attach wait for a person. + * Rule 3 stands either way: this offers, it never installs on its own. */ +export type OfferHandler = (offer: EngineOffer) => boolean + +export type InstallResult = { ok: true } | { ok: false; error: string } + type McpStatus = Record /** Declared allowlist for a workspace, split by whether the CLI can serve it. @@ -134,8 +171,31 @@ export const syncInternals: { existingEntry?: (name: string) => Promise declared?: (datamateId: string) => Promise notify?: (toast: Toast) => Promise + offer?: OfferHandler + publishOffer?: () => Promise + printLine?: (line: string) => void + nodeMajor?: () => Promise + install?: (spec: string) => Promise } = {} +/** Command the TUI plugin registers to raise the install offer. + * + * The offer crosses to the TUI over the SAME event bus toasts use. It cannot + * cross in-process: the TUI plugin runtime loads plugins in a separate realm, + * so neither a module-level binding nor a `globalThis` key set by the plugin is + * visible here. Both were tried and both silently degraded to the toast — the + * dialog never appeared. `CommandExecute` carries no payload, so the plugin + * re-derives the offer with `describeOffer()`; that is the same shape the + * post-scan workspace prompt already uses. */ +export const OFFER_COMMAND = "altimate.workspace.engineInstallOffer" + +/** True in headless `run`, where no TUI can render a dialog or a toast and the + * single printed line is the only way to say anything. Set by the run command; + * an env var because it must be readable from every realm. */ +export function isHeadless(): boolean { + return process.env["ALTIMATE_CODE_HEADLESS"] === "1" +} + export function isEnabled(): boolean { return CoreFlag.ALTIMATE_WORKSPACE } @@ -376,6 +436,142 @@ async function notify(toast: Toast): Promise { } } +/** Ask the TUI to raise the offer. False when the bus is unavailable. */ +async function publishOffer(): Promise { + if (syncInternals.publishOffer) return syncInternals.publishOffer() + try { + await AppRuntime.runPromise( + EventV2Bridge.Service.use((events) => events.publish(TuiEvent.CommandExecute, { command: OFFER_COMMAND })), + ) + return true + } catch (err) { + log.warn("could not publish the engine install offer", { err: String(err) }) + return false + } +} + +/** Re-derive the current "no usable engine" state for a directory. + * + * The offer reaches the TUI as a bare command, so the plugin rebuilds the + * detail here rather than receiving it. Returns null when there is nothing to + * offer — unbound, or an engine that already clears the floor. */ +export async function describeOffer(directory: string): Promise { + const binding = syncInternals.resolveBinding + ? await syncInternals.resolveBinding() + : await readLocalBinding(directory).catch(() => null) + if (!binding) return null + const workspaceId = String(binding.datamateId) + const bin = which(ENGINE_BINARY) + const found = bin ? await versionOf(bin) : null + if (bin && found && compareVersions(found, MIN_ENGINE_VERSION) >= 0) return null + const declaredCount = (await declared(workspaceId))?.keys.length ?? 0 + return { + reason: bin ? "engine-too-old" : "engine-missing", + workspaceId, + workspaceName: binding.datamateName, + declared: declaredCount, + ...(bin ? { found: found ?? "unknown" } : {}), + command: installCommand(), + } +} + +/** npm spec to install. ALTIMATE_ENGINE_INSTALL_SPEC overrides it so E2E can + * point the real install path at a local tarball instead of the registry. */ +export function installSpec(): string { + return process.env["ALTIMATE_ENGINE_INSTALL_SPEC"] || `${ENGINE_PACKAGE}@${MIN_ENGINE_VERSION}` +} + +/** The command shown, copied, printed, and run — always the same string, so + * "Copy command" hands over exactly what "Install now" would have executed. */ +export function installCommand(): string { + return `npm i -g ${installSpec()}` +} + +/** Node major on PATH, or null when Node is absent. Gates the "Install now" + * option: with no Node there is nothing to run `npm` with, so the offer + * degrades to showing the command. */ +export function nodeMajor(): Promise { + if (syncInternals.nodeMajor) return syncInternals.nodeMajor() + const bin = which("node") + if (!bin) return Promise.resolve(null) + return new Promise((resolve) => { + execFile(bin, ["--version"], { timeout: 5000 }, (err, stdout) => { + if (err) return resolve(null) + const major = Number.parseInt(stdout.trim().replace(/^v/, "").split(".")[0] ?? "", 10) + resolve(Number.isFinite(major) ? major : null) + }) + }) +} + +/** Run the global install. Only ever reached from an explicit user choice — + * rule 3 forbids reaching it from the attach flow itself. + * + * `npm.cmd` on Windows: a normal Node install exposes npm as a command shim, + * not an `npm` executable, and nothing here spawns a shell — so the bare name + * fails with ENOENT on the one platform where the Node gate has just told the + * user they are good to go. Same platform split the existing install path in + * `lsp/server.ts` uses. `Process.run` takes an argv array, so a spec with + * spaces (an E2E tarball path) needs no quoting. */ +export async function installEngine(): Promise { + const spec = installSpec() + if (syncInternals.install) return syncInternals.install(spec) + const npm = process.platform === "win32" ? "npm.cmd" : "npm" + try { + const result = await Process.run([npm, "i", "-g", spec], { timeout: 300_000, nothrow: true }) + if (result.code === 0) return { ok: true } + const detail = result.stderr.toString().trim().split(/\r?\n/).slice(-3).join(" ") + return { ok: false, error: detail || `npm exited with code ${result.code}` } + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) } + } +} + +/** Hand the offer to an interactive surface. False when none is registered. */ +function offerInstall(offer: EngineOffer): boolean { + const handler = syncInternals.offer + if (!handler) return false + try { + return handler(offer) + } catch (err) { + log.warn("install offer surface failed; falling back to toast", { err: String(err) }) + return false + } +} + +/** stderr, deliberately, not stdout. + * + * `run --format json` documents stdout as raw JSON events and routes every + * record through its `emit()` helper, so a human-readable line written there + * lands mid-stream and breaks line-oriented consumers — verified: the notice + * was line 1 of an otherwise-valid JSON stream. This is a status notice rather + * than run output, which is the same reason `run` already writes its own + * status line to stderr, so stderr is correct in both formats. */ +function printLine(line: string): void { + if (syncInternals.printLine) return syncInternals.printLine(line) + try { + process.stderr.write(line + "\n") + } catch { + // A closed stream must not take down the attach flow. + } +} + +/** Offer via the dialog surface when there is one; otherwise print (headless) + * or toast (bus unavailable). */ +async function offerOrNotify(offer: EngineOffer, toast: Toast): Promise { + if (isHeadless()) { + const tools = `${offer.declared} integration tool${offer.declared === 1 ? "" : "s"}` + printLine( + offer.reason === "engine-too-old" + ? `Workspace "${offer.workspaceName}": ${tools} need ${ENGINE_BINARY} ${MIN_ENGINE_VERSION}+ (found ${offer.found ?? "unknown"}). Update with: ${offer.command}` + : `Workspace "${offer.workspaceName}": ${tools} need the local engine, which is not installed. Install it with: ${offer.command}`, + ) + return + } + if (offerInstall(offer)) return + if (await publishOffer()) return + await notify(toast) +} + // --------------------------------------------------------------------------- // The attach flow // --------------------------------------------------------------------------- @@ -600,13 +796,28 @@ async function run(): Promise { // Rejected and irreplaceable: detach anyway. Leaving it connected would // return "too old" while still serving the too-old engine's tools. await detachRejected({ workspaceId, reason: "below-floor", found: label }) - await notify({ + // Same problem the offer exists for — an engine the user must update — + // so it gets the same actionable dialog rather than a transient toast. + // `declared` is not resolved this early in rule 1; fetch it here since + // this branch is rare and the count is what makes the offer concrete. + const reusedDeclared = (await declared(workspaceId))?.keys.length ?? 0 + await offerOrNotify( + { + reason: "engine-too-old", + workspaceId, + workspaceName: binding.datamateName, + declared: reusedDeclared, + found: label, + command: installCommand(), + }, + { title: "Workspace engine is too old", message: `The engine serving workspace "${binding.datamateName}" reports ${label}; this client needs ` + `${MIN_ENGINE_VERSION} or newer. Update with: ${INSTALL_HINT}`, variant: "warning", - }) + }, + ) return { kind: "engine-too-old", found: label } } replaced = describeEntry(entry) @@ -627,24 +838,43 @@ async function run(): Promise { // Rule 2 / 3 — opportunistic use, or an offer. Never an install. const bin = which(ENGINE_BINARY) if (!bin) { - await notify({ - title: "Workspace integrations unavailable", + await offerOrNotify( + { + reason: "engine-missing", + workspaceId, + workspaceName: binding.datamateName, + declared: declaredCount, + command: installCommand(), + }, + { + title: "Workspace integrations unavailable", message: `Workspace "${binding.datamateName}" declares ${declaredCount} integration tool${declaredCount === 1 ? "" : "s"}. ` + `They run on the local engine, which is not installed. Install it with: ${INSTALL_HINT}`, - variant: "warning", - }) + variant: "warning", + }, + ) return { kind: "engine-missing", declared: declaredCount } } const found = await versionOf(bin) if (!found || compareVersions(found, MIN_ENGINE_VERSION) < 0) { const label = found ?? "unknown" - await notify({ + await offerOrNotify( + { + reason: "engine-too-old", + workspaceId, + workspaceName: binding.datamateName, + declared: declaredCount, + found: label, + command: installCommand(), + }, + { title: "Workspace engine is too old", message: `Found ${ENGINE_BINARY} ${label}; this client needs ${MIN_ENGINE_VERSION} or newer. Update with: ${INSTALL_HINT}`, variant: "warning", - }) + }, + ) return { kind: "engine-too-old", found: label } } diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 50638bcd14..c11ba45a03 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -375,6 +375,12 @@ export const RunCommand = cmd({ // altimate_change end }, handler: async (args) => { + // altimate_change start — mark the headless surface. Nothing here can render + // a dialog or a toast, so the workspace engine offer degrades to a single + // printed line. An env var because it must be readable from every module + // realm (the TUI plugin runtime loads plugins in its own). + process.env["ALTIMATE_CODE_HEADLESS"] = "1" + // altimate_change end // 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 diff --git a/packages/opencode/src/plugin/tui/altimate/workspace.tsx b/packages/opencode/src/plugin/tui/altimate/workspace.tsx index 2a75f93c47..3966494a03 100644 --- a/packages/opencode/src/plugin/tui/altimate/workspace.tsx +++ b/packages/opencode/src/plugin/tui/altimate/workspace.tsx @@ -48,6 +48,15 @@ import { resolveProjectIdentifier, } from "@/altimate/workspace/detect" import { readLocalBinding, recordApprovedBinding } from "@/altimate/workspace/state" +import { + describeOffer, + installEngine, + nodeMajor as detectNodeMajor, + MIN_NODE_MAJOR, + OFFER_COMMAND, + type EngineOffer, +} from "@/altimate/workspace/engine-sync" +import { useClipboard } from "@opencode-ai/tui/context/clipboard" import { AltimateApi } from "@/altimate/api/client" import { Log } from "@/altimate/util/log" @@ -1143,6 +1152,240 @@ async function runFlow(api: TuiPluginApi, directory: string): Promise { )) } +// ───────────────────────────────────────────────────────────────────────────── +// Engine install offer. engine-sync decides there is no usable engine and hands +// the offer here; this file owns the interaction. Offer, never silently +// install — the install only ever runs from an explicit "Install now". +// ───────────────────────────────────────────────────────────────────────────── + +const KV_ENGINE_SKIP_PREFIX = "altimate.workspace.engineInstall.skip." + +/** Latch key from (tenant, apiUrl, workspace id). Keyed on the workspace so a + * "Not now" for one workspace doesn't silence the offer for another, and on + * the id rather than the name so a rename doesn't reset it. */ +function engineSkipKey(workspaceId: string, scope: LatchScope | null): string { + const scopeString = scope ? `${scope.tenant}|${scope.apiUrl}|` : "" + return ( + KV_ENGINE_SKIP_PREFIX + + createHash("sha1") + .update(scopeString + workspaceId) + .digest("hex") + ) +} + +/** Same 7-day TTL and clock-rewind handling as the post-scan latch. */ +function isEngineSkipActive( + api: TuiPluginApi, + workspaceId: string, + scope: LatchScope | null, + nowMs: number, +): boolean { + const rec = api.kv.get<{ skippedAt: number }>(engineSkipKey(workspaceId, scope)) + if (!rec || typeof rec.skippedAt !== "number") return false + const delta = nowMs - rec.skippedAt + if (delta < 0) return false + return delta < SKIP_TTL_MS +} + +function recordEngineSkip( + api: TuiPluginApi, + workspaceId: string, + scope: LatchScope | null, + nowMs: number, +): void { + api.kv.set(engineSkipKey(workspaceId, scope), { skippedAt: nowMs }) +} + +interface EngineOfferProps { + api: TuiPluginApi + offer: EngineOffer + /** Node major on PATH, or null when Node is absent. Resolved by the caller + * so the dialog itself stays sync (same shape as ``browserAvailable``). */ + nodeMajor: number | null + latchScope: LatchScope | null +} + +/** One DialogSelect for every phase — the row set changes, the component never + * does. Swapping the top-level dialog component between states tears down and + * remounts the dialog, which drops focus and loses the phase signal. Sentinel + * rows carry the non-idle phases, and never use ``disabled: true`` ( + * DialogSelect's ``filtered()`` drops those, leaving an empty list). */ +function EngineInstallOfferDialog(props: EngineOfferProps) { + const clipboard = useClipboard() + const [phase, setPhase] = createSignal<"idle" | "installing" | "installed" | "failed">("idle") + const [failure, setFailure] = createSignal(null) + // ``onSelect`` is delivered synchronously per Enter keypress; the install is + // a multi-minute await. Without this latch a second Enter starts a second + // ``npm i -g`` against the same global prefix. + let installing = false + + const command = () => props.offer.command + const canInstall = () => props.nodeMajor !== null && props.nodeMajor >= MIN_NODE_MAJOR + + const title = () => { + const tools = `${props.offer.declared} integration tool${props.offer.declared === 1 ? "" : "s"}` + const head = + props.offer.reason === "engine-too-old" + ? `Workspace "${props.offer.workspaceName}" needs a newer local engine (found ${props.offer.found ?? "unknown"}) — ${tools} unavailable` + : `Workspace "${props.offer.workspaceName}" declares ${tools}, which need the local engine` + const parts = [head, command()] + if (!canInstall()) { + parts.push( + props.nodeMajor === null + ? `(needs Node ${MIN_NODE_MAJOR}+ to install — Node was not found on PATH)` + : `(needs Node ${MIN_NODE_MAJOR}+ to install — found Node ${props.nodeMajor})`, + ) + } + const err = failure() + if (err) parts.push(`(install failed: ${err})`) + return parts.join(" · ") + } + + const options = () => { + switch (phase()) { + case "installing": + return [{ title: "Installing… this can take a minute.", value: "busy" }] + case "installed": + return [{ title: "Installed — attaching integrations.", value: "close" }] + case "failed": + return [ + { title: "Copy command", value: "copy", description: "Run it yourself, then start a new session." }, + { title: "Close", value: "close" }, + ] + default: + return [ + ...(canInstall() + ? [ + { + title: "Install now", + value: "install", + description: `Runs ${command()} and attaches this session when it finishes.`, + }, + ] + : []), + { + title: "Copy command", + value: "copy", + description: "Copy the install command to your clipboard.", + }, + { + title: "Not now", + value: "skip", + description: "Won't ask again for this workspace for 7 days.", + }, + ] + } + } + + const runInstall = async () => { + setPhase("installing") + const result = await installEngine() + if (!result.ok) { + setFailure(result.error) + setPhase("failed") + installing = false + return + } + setPhase("installed") + props.api.ui.dialog.clear() + // Deliberately NOT re-attaching this session from here. The plugin runtime + // loads this file in its own realm, so `ensure()` called here would run a + // SECOND, independent attach — spawning an engine the session never uses + // and leaving it running. Nothing needs to: attach re-probes a repairable + // failure on the next turn, so the engine we just installed is picked up + // without a restart. Verified end-to-end — after this toast, the very next + // message in the SAME session listed the datamate_* tools. + props.api.ui.toast({ + variant: "success", + message: + `Workspace engine installed. Integration tools for "${props.offer.workspaceName}" ` + + `attach on your next message.`, + duration: 15_000, + }) + } + + const copyCommand = () => { + const cmd = command() + void (async () => { + try { + await clipboard.write?.(cmd) + // A resolved write is NOT proof of a copy. The host's writer picks + // xclip/xsel on Linux and otherwise falls back to clipboardy, and it + // swallows backend failures (`.catch(() => undefined)`), so on the many + // Linux/WSL boxes with neither tool installed the write silently does + // nothing. Read back and compare before claiming success. (Caught by + // E2E: the toast said "Copied:" while the clipboard was untouched.) + const back = await clipboard.read?.() + if (back?.data.trim() === cmd) { + props.api.ui.toast({ variant: "info", message: `Copied: ${cmd}` }) + return + } + } catch { + // Unreadable or unwritable clipboard — fall through and show it. + } + props.api.ui.toast({ + variant: "warning", + message: `Could not confirm the clipboard. Run: ${cmd}`, + duration: 30_000, + }) + })() + } + + return ( + { + if (option.value === "busy") return + if (option.value === "install") { + if (installing) return + installing = true + void runInstall().catch((err) => { + setFailure(err instanceof Error ? err.message : String(err)) + setPhase("failed") + installing = false + }) + return + } + if (option.value === "copy") { + copyCommand() + props.api.ui.dialog.clear() + return + } + if (option.value === "skip") { + recordEngineSkip(props.api, props.offer.workspaceId, props.latchScope, Date.now()) + } + props.api.ui.dialog.clear() + }} + /> + ) +} + +/** Show the offer unless the 7-day latch suppresses it. + * + * engine-sync raises this as a bare command over the event bus — it cannot hand + * us the offer object, because the plugin runtime loads this file in a separate + * realm from the attach flow. So the detail is re-derived here, the same way + * the post-scan prompt re-derives its own state from the directory. Node + * availability and latch scope are resolved before rendering so the dialog + * itself stays sync. */ +async function showEngineInstallOffer(api: TuiPluginApi): Promise { + const offer = await describeOffer(api.state.path.directory) + // Null means the situation resolved between the attach and this dialog — an + // engine appeared, or the project is no longer bound. Say nothing. + if (!offer) return + const latchScope = await currentLatchScope() + if (isEngineSkipActive(api, offer.workspaceId, latchScope, Date.now())) { + log.info("engine install offer suppressed by 7-day latch", { workspaceId: offer.workspaceId }) + return + } + const major = await detectNodeMajor() + api.ui.dialog.replace(() => ( + + )) +} + // ───────────────────────────────────────────────────────────────────────────── // Plugin registration // ───────────────────────────────────────────────────────────────────────────── @@ -1173,6 +1416,17 @@ const tui: TuiPlugin = async (api) => { runFlow(api, api.state.path.directory).catch((err) => reportFlowFailure(api, err)) }, }, + { + // Raised by engine-sync over the event bus when a bound workspace has + // no usable engine. Internal: dispatched, never shown in the palette. + name: OFFER_COMMAND, + title: "Workspace engine install offer", + category: "Altimate", + namespace: "internal", + run() { + showEngineInstallOffer(api).catch((err) => reportFlowFailure(api, err)) + }, + }, { name: "altimate.workspace.link", title: "Link this project to a workspace", @@ -1195,5 +1449,5 @@ export default { id: PLUGIN_ID, tui } satisfies BuiltinTuiPlugin // Exported for unit tests only. The shared logic (WorkspaceApi, cache, detect, // project-name) lives in `@/altimate/workspace/*` and should be tested there; // the plugin owns just the TUI-specific latch semantics. -export { isSkipActive, recordSkip } +export { isSkipActive, recordSkip, isEngineSkipActive, recordEngineSkip } // altimate_change end diff --git a/packages/opencode/test/altimate/plugin/workspace.test.ts b/packages/opencode/test/altimate/plugin/workspace.test.ts index cf8ed145fe..391b92697c 100644 --- a/packages/opencode/test/altimate/plugin/workspace.test.ts +++ b/packages/opencode/test/altimate/plugin/workspace.test.ts @@ -28,7 +28,7 @@ afterAll(() => { } }) -const { isSkipActive, recordSkip } = await import( +const { isSkipActive, recordSkip, isEngineSkipActive, recordEngineSkip } = await import( "../../../src/plugin/tui/altimate/workspace" ) const { projectNameFromRemote, detectProjectRemote } = await import( @@ -462,3 +462,73 @@ describe("Skip latch", () => { ).toBe(false) }) }) + +// ───────────────────────────────────────────────────────────────────────────── +// Engine install-offer latch — "Not now" silences the offer for 7 days, per +// workspace. Keyed on the workspace id so a rename doesn't reset it, and +// scoped by (tenant, apiUrl) like the post-scan latch. +// ───────────────────────────────────────────────────────────────────────────── + +describe("Engine install-offer latch", () => { + const scope = { tenant: "acme", apiUrl: "https://api.acme.example.com" } + const workspaceId = "42" + const DAY = 24 * 60 * 60 * 1000 + + test("no record → not active", () => { + const api = { kv: makeKv() } as any + expect(isEngineSkipActive(api, workspaceId, scope, Date.now())).toBe(false) + }) + + test("recorded within 7 days → active", () => { + const api = { kv: makeKv() } as any + const now = 1_700_000_000_000 + recordEngineSkip(api, workspaceId, scope, now) + expect(isEngineSkipActive(api, workspaceId, scope, now + 6 * DAY)).toBe(true) + }) + + test("recorded past 7 days → not active", () => { + const api = { kv: makeKv() } as any + const now = 1_700_000_000_000 + recordEngineSkip(api, workspaceId, scope, now) + expect(isEngineSkipActive(api, workspaceId, scope, now + 8 * DAY)).toBe(false) + }) + + test("boundary at exactly 7 days → not active", () => { + const api = { kv: makeKv() } as any + const now = 1_700_000_000_000 + recordEngineSkip(api, workspaceId, scope, now) + expect(isEngineSkipActive(api, workspaceId, scope, now + 7 * DAY)).toBe(false) + }) + + test("latching one workspace does not silence another", () => { + const api = { kv: makeKv() } as any + const now = 1_700_000_000_000 + recordEngineSkip(api, "42", scope, now) + expect(isEngineSkipActive(api, "42", scope, now + DAY)).toBe(true) + expect(isEngineSkipActive(api, "43", scope, now + DAY)).toBe(false) + }) + + test("a latch in one account does not apply to another", () => { + const api = { kv: makeKv() } as any + const now = 1_700_000_000_000 + recordEngineSkip(api, workspaceId, scope, now) + const other = { tenant: "globex", apiUrl: "https://api.globex.example.com" } + expect(isEngineSkipActive(api, workspaceId, other, now + DAY)).toBe(false) + }) + + test("a future timestamp (clock rewind) re-offers instead of latching forever", () => { + const api = { kv: makeKv() } as any + const now = 1_700_000_000_000 + recordEngineSkip(api, workspaceId, scope, now + 5 * DAY) + expect(isEngineSkipActive(api, workspaceId, scope, now)).toBe(false) + }) + + test("the post-scan latch and the engine latch are independent", () => { + const api = { kv: makeKv() } as any + const now = 1_700_000_000_000 + const ident = { repoRemote: "git@github.com:acme/proj-a.git", projectPath: "/work/proj-a" } + recordSkip(api, ident, scope, now) + expect(isSkipActive(api, ident, scope, now + DAY)).toBe(true) + expect(isEngineSkipActive(api, workspaceId, scope, now + DAY)).toBe(false) + }) +}) diff --git a/packages/opencode/test/altimate/workspace/engine-install-offer.test.ts b/packages/opencode/test/altimate/workspace/engine-install-offer.test.ts new file mode 100644 index 0000000000..439aa7cde7 --- /dev/null +++ b/packages/opencode/test/altimate/workspace/engine-install-offer.test.ts @@ -0,0 +1,309 @@ +// altimate_change - new file +// +// Coverage for the "no usable engine" offer: which surface gets it, what the +// fallback emits when there is no surface, and the command/Node detection the +// dialog's "Install now" gate depends on. Everything routes through +// `syncInternals`, so no process is spawned and no MCP state is touched. +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { + ensure, + installCommand, + installSpec, + nodeMajor, + describeOffer, + isHeadless, + resetForTests, + syncInternals, + ENGINE_BINARY, + ENGINE_PACKAGE, + MIN_ENGINE_VERSION, + type EngineOffer, + type LocalMcpConfig, +} from "../../../src/altimate/workspace/engine-sync" +import type { CachedBinding } from "../../../src/altimate/workspace/state" + +const ORIGINAL_FLAG = process.env.ALTIMATE_WORKSPACE +const ORIGINAL_SPEC = process.env.ALTIMATE_ENGINE_INSTALL_SPEC + +const binding: CachedBinding = { + datamateId: 42, + datamateName: "analytics", + repoRemote: "git@github.com:acme/analytics.git", + projectPath: "/tmp/analytics", +} as CachedBinding + +type Harness = { + offers: EngineOffer[] + toasts: Array<{ title: string; message: string; variant: string }> + printed: string[] + published: number +} + +/** No engine on PATH (or an old one) plus a captured notify/print pair. */ +function install(opts: { + which?: string | null + version?: string | null + declaredKeys?: string[] + existing?: { type?: string; url?: string; command?: string[] | string; args?: string[] } | null +}): Harness { + const h: Harness = { offers: [], toasts: [], printed: [], published: 0 } + syncInternals.publishOffer = async () => { + h.published += 1 + return true + } + syncInternals.resolveBinding = async () => binding + syncInternals.which = () => (opts.which === undefined ? null : opts.which) + syncInternals.versionOf = async () => (opts.version === undefined ? null : opts.version) + syncInternals.declared = async () => ({ + keys: opts.declaredKeys ?? ["dbt_build_model", "dbt_compile_model"], + extensionKeys: [], + }) + syncInternals.existingEntry = async () => (opts.existing === undefined ? null : opts.existing) + syncInternals.persist = async () => {} + syncInternals.notify = async (toast) => { + h.toasts.push(toast) + } + syncInternals.printLine = (line) => { + h.printed.push(line) + } + syncInternals.mcp = { + status: async () => ({}), + add: async (_n: string, _c: LocalMcpConfig) => {}, + connect: async () => {}, + remove: async () => {}, + tools: async () => ({}), + } + return h +} + +beforeEach(() => { + process.env.ALTIMATE_WORKSPACE = "1" + delete process.env.ALTIMATE_ENGINE_INSTALL_SPEC + resetForTests() + delete process.env.ALTIMATE_CODE_HEADLESS +}) + +afterEach(() => { + for (const key of Object.keys(syncInternals) as Array) delete syncInternals[key] + delete process.env.ALTIMATE_CODE_HEADLESS + if (ORIGINAL_FLAG === undefined) delete process.env.ALTIMATE_WORKSPACE + else process.env.ALTIMATE_WORKSPACE = ORIGINAL_FLAG + if (ORIGINAL_SPEC === undefined) delete process.env.ALTIMATE_ENGINE_INSTALL_SPEC + else process.env.ALTIMATE_ENGINE_INSTALL_SPEC = ORIGINAL_SPEC +}) + +describe("install command", () => { + test("pins the minimum engine version by default", () => { + expect(installSpec()).toBe(`${ENGINE_PACKAGE}@${MIN_ENGINE_VERSION}`) + expect(installCommand()).toBe(`npm i -g ${ENGINE_PACKAGE}@${MIN_ENGINE_VERSION}`) + }) + + test("honours ALTIMATE_ENGINE_INSTALL_SPEC so E2E can point at a tarball", () => { + process.env.ALTIMATE_ENGINE_INSTALL_SPEC = "/tmp/datamate-0.6.3.tgz" + expect(installCommand()).toBe("npm i -g /tmp/datamate-0.6.3.tgz") + }) +}) + +describe("nodeMajor", () => { + test("null when node is not on PATH", async () => { + syncInternals.which = () => null + expect(await nodeMajor()).toBeNull() + }) + + test("parses the major out of a v-prefixed version", async () => { + syncInternals.nodeMajor = async () => 22 + expect(await nodeMajor()).toBe(22) + }) +}) + +describe("offer routing — engine missing", () => { + test("raises the dialog over the event bus, with no toast and no printed line", async () => { + const h = install({}) + syncInternals.offer = (offer) => { + h.offers.push(offer) + return true + } + expect(await ensure("s1")).toEqual({ kind: "engine-missing", declared: 2 }) + expect(h.offers).toHaveLength(1) + expect(h.offers[0]).toMatchObject({ + reason: "engine-missing", + workspaceId: "42", + workspaceName: "analytics", + declared: 2, + command: `npm i -g ${ENGINE_PACKAGE}@${MIN_ENGINE_VERSION}`, + }) + // A surface owns it — the fallbacks must stay silent. + expect(h.toasts).toHaveLength(0) + expect(h.printed).toHaveLength(0) + }) + + test("headless prints exactly one line naming workspace and command, and no toast", async () => { + process.env.ALTIMATE_CODE_HEADLESS = "1" + const h = install({}) + expect(await ensure("s1")).toEqual({ kind: "engine-missing", declared: 2 }) + expect(h.toasts).toHaveLength(0) + expect(h.published).toBe(0) + expect(h.printed).toHaveLength(1) + expect(h.printed[0]).toContain('"analytics"') + expect(h.printed[0]).toContain(`npm i -g ${ENGINE_PACKAGE}@${MIN_ENGINE_VERSION}`) + expect(h.printed[0]).toContain("2 integration tools") + }) + + test("singularises the tool count", async () => { + process.env.ALTIMATE_CODE_HEADLESS = "1" + const h = install({ declaredKeys: ["dbt_build_model"] }) + await ensure("s1") + expect(h.printed[0]).toContain("1 integration tool need") + expect(h.printed[0]).not.toContain("1 integration tools") + }) + + test("in a TUI the offer is published and nothing is printed to stdout", async () => { + const h = install({}) + expect(await ensure("s1")).toEqual({ kind: "engine-missing", declared: 2 }) + expect(h.published).toBe(1) + expect(h.toasts).toHaveLength(0) + // Printing here would corrupt the TUI's own render. + expect(h.printed).toHaveLength(0) + }) + + test("falls back to the toast only when the bus is unavailable", async () => { + const h = install({}) + syncInternals.publishOffer = async () => false + expect(await ensure("s1")).toEqual({ kind: "engine-missing", declared: 2 }) + expect(h.toasts).toHaveLength(1) + expect(h.printed).toHaveLength(0) + }) +}) + +describe("offer routing — engine too old", () => { + test("carries the found version and the update command", async () => { + const h = install({ which: "/usr/local/bin/datamate", version: "0.5.9" }) + syncInternals.offer = (offer) => { + h.offers.push(offer) + return true + } + expect(await ensure("s1")).toEqual({ kind: "engine-too-old", found: "0.5.9" }) + expect(h.offers[0]).toMatchObject({ + reason: "engine-too-old", + workspaceId: "42", + workspaceName: "analytics", + found: "0.5.9", + declared: 2, + }) + expect(h.toasts).toHaveLength(0) + expect(h.printed).toHaveLength(0) + }) + + test("headless, the printed line names the found version", async () => { + process.env.ALTIMATE_CODE_HEADLESS = "1" + const h = install({ which: "/usr/local/bin/datamate", version: "0.5.9" }) + await ensure("s1") + expect(h.printed).toHaveLength(1) + expect(h.printed[0]).toContain("found 0.5.9") + expect(h.printed[0]).toContain('"analytics"') + }) +}) + +describe("offer is not raised when an engine is usable", () => { + test("a healthy engine never reaches the offer path", async () => { + // Version is taken from the floor itself, not a literal: MIN_ENGINE_VERSION + // moves (0.6.3 -> 0.7.0 already), and a hardcoded version silently turns + // this into a too-old test the next time it does. + const h = install({ + which: "/usr/local/bin/datamate", + version: MIN_ENGINE_VERSION, + // Rule 1 reuses an entry only when it is pinned to the bound workspace. + existing: { type: "local", command: [ENGINE_BINARY, "start-stdio", "--datamate", "42"] }, + }) + syncInternals.offer = (offer) => { + h.offers.push(offer) + return true + } + syncInternals.mcp = { + status: async () => ({ datamate: { status: "connected" } }), + add: async () => {}, + connect: async () => {}, + remove: async () => {}, + tools: async () => ({ datamate_dbt_build_model: 1, datamate_dbt_compile_model: 1 }), + } + await ensure("s1") + expect(h.offers).toHaveLength(0) + expect(h.printed).toHaveLength(0) + }) +}) + +describe("describeOffer — the TUI re-derives its own detail", () => { + // The offer reaches the plugin as a bare command (CommandExecute carries no + // payload) and the plugin runtime is a separate realm, so this re-derivation + // is the only way the dialog learns what to say. Regression guard for the + // defect E2E caught, where an in-process handoff silently degraded to a toast. + test("describes a missing engine", async () => { + install({}) + const offer = await describeOffer("/tmp/whatever") + expect(offer).toMatchObject({ + reason: "engine-missing", + workspaceId: "42", + workspaceName: "analytics", + declared: 2, + }) + expect(offer?.found).toBeUndefined() + }) + + test("describes an engine below the floor, naming the version found", async () => { + install({ which: "/usr/local/bin/datamate", version: "0.5.9" }) + const offer = await describeOffer("/tmp/whatever") + expect(offer).toMatchObject({ reason: "engine-too-old", found: "0.5.9" }) + }) + + test("returns null when an engine already clears the floor", async () => { + install({ which: "/usr/local/bin/datamate", version: MIN_ENGINE_VERSION }) + expect(await describeOffer("/tmp/whatever")).toBeNull() + }) + + test("returns null when the project is not bound", async () => { + install({}) + syncInternals.resolveBinding = async () => null + expect(await describeOffer("/tmp/whatever")).toBeNull() + }) +}) + +describe("headless detection", () => { + test("off by default, on when the run command marks it", () => { + expect(isHeadless()).toBe(false) + process.env.ALTIMATE_CODE_HEADLESS = "1" + expect(isHeadless()).toBe(true) + }) +}) + +describe("headless notice stream", () => { + // Regression guard: the notice used to go to stdout, which `run --format + // json` documents as raw JSON events. A human-readable line there was line 1 + // of an otherwise-valid JSON stream and broke line-oriented consumers. + test("the default printer writes to stderr, never stdout", async () => { + process.env.ALTIMATE_CODE_HEADLESS = "1" + const h = install({}) + // Exercise the real printer, not the seam. + delete syncInternals.printLine + const outChunks: string[] = [] + const errChunks: string[] = [] + const realOut = process.stdout.write.bind(process.stdout) + const realErr = process.stderr.write.bind(process.stderr) + process.stdout.write = ((c: string) => { + outChunks.push(String(c)) + return true + }) as typeof process.stdout.write + process.stderr.write = ((c: string) => { + errChunks.push(String(c)) + return true + }) as typeof process.stderr.write + try { + await ensure("s1") + } finally { + process.stdout.write = realOut + process.stderr.write = realErr + } + expect(errChunks.join("")).toContain("need the local engine") + expect(outChunks.join("")).not.toContain("need the local engine") + expect(h.toasts).toHaveLength(0) + }) +}) diff --git a/packages/opencode/test/altimate/workspace/engine-sync.test.ts b/packages/opencode/test/altimate/workspace/engine-sync.test.ts index a59996c54d..d932b44713 100644 --- a/packages/opencode/test/altimate/workspace/engine-sync.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-sync.test.ts @@ -75,6 +75,14 @@ function install(opts: { syncInternals.notify = async (toast) => { h.toasts.push(toast) } + // No dialog surface is registered in this suite, so the offer path falls back + // to toast + a printed line. Swallow the line so the suite stays quiet; the + // printed-line contract itself is covered in engine-install-offer.test.ts. + syncInternals.printLine = () => {} + // The "no usable engine" branches now offer through the event bus, which a + // unit test has no bus for. Force the publish to fail so this suite keeps + // exercising — and asserting on — the toast fallback it was written against. + syncInternals.publishOffer = async () => false syncInternals.mcp = { status: async () => h.statusQueue.length > 1 ? h.statusQueue.shift()! : h.statusQueue[0]!, add: async (name, cfg) => { From 482679d674b31766fc2a99349d9765ab9ffe781d Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Thu, 27 Aug 2026 03:44:08 +0800 Subject: [PATCH 02/11] fix(workspace): give the engine install a real deadline Process.spawn consults `timeout` only inside its abort handler, as the grace before SIGKILL, so with no signal there was no deadline and a stalled npm left the dialog on "Installing..." indefinitely. Measured: an 8s sleep ran 8004ms under `timeout`, 502ms under an abort signal. The bash tool now also strips ALTIMATE_CODE_HEADLESS from child environments, as it already does for ALTIMATE_NON_INTERACTIVE. --- .../src/altimate/workspace/engine-sync.ts | 29 +++++++++++++++++-- packages/opencode/src/tool/bash.ts | 6 ++++ .../workspace/engine-install-offer.test.ts | 24 +++++++++++++++ 3 files changed, 57 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/engine-sync.ts b/packages/opencode/src/altimate/workspace/engine-sync.ts index f00af740f2..0a15fd6e61 100644 --- a/packages/opencode/src/altimate/workspace/engine-sync.ts +++ b/packages/opencode/src/altimate/workspace/engine-sync.ts @@ -95,6 +95,8 @@ export const INSTALL_COMMAND = `npm i -g ${ENGINE_PACKAGE}@${MIN_ENGINE_VERSION} /** Node major the npm install path needs. The CLI itself is a self-contained * binary and does not need Node — only this install route does. */ export const MIN_NODE_MAJOR = 20 +/** How long "Install now" waits for npm before giving up. */ +export const INSTALL_TIMEOUT_MS = 300_000 /** Engine tools arrive under the MCP server key as `_`. */ const TOOL_PREFIX = `${DATAMATE_KEY}_` @@ -454,7 +456,18 @@ async function publishOffer(): Promise { * * The offer reaches the TUI as a bare command, so the plugin rebuilds the * detail here rather than receiving it. Returns null when there is nothing to - * offer — unbound, or an engine that already clears the floor. */ + * offer — unbound, or an engine that already clears the floor. + * + * This describes the CURRENT state, not the decision that raised the offer, + * and the two can differ. `run()`'s rule-1 branch reports `engine-too-old` + * using the version of a reused entry's binary; that branch only fires when + * PATH has nothing better, so this function — which probes PATH alone — may + * describe the same situation as `engine-missing`. That is deliberate: the + * user's actionable state is "no usable engine, run this command", and the + * command is identical either way. Describing live state rather than echoing + * a trigger also keeps this correct when the conditions upstream change, + * which is the failure mode where describing code silently keeps asserting + * what used to be true. */ export async function describeOffer(directory: string): Promise { const binding = syncInternals.resolveBinding ? await syncInternals.resolveBinding() @@ -516,12 +529,24 @@ export async function installEngine(): Promise { const spec = installSpec() if (syncInternals.install) return syncInternals.install(spec) const npm = process.platform === "win32" ? "npm.cmd" : "npm" + // The deadline has to come from an abort signal, not from `timeout`. + // `Process.spawn` only consults `timeout` inside its abort handler, as the + // grace period before escalating to SIGKILL — with no signal supplied that + // handler never runs and there is no deadline at all. An npm that stops + // making progress would leave the dialog on "Installing…" forever. + const deadline = AbortSignal.timeout(INSTALL_TIMEOUT_MS) try { - const result = await Process.run([npm, "i", "-g", spec], { timeout: 300_000, nothrow: true }) + const result = await Process.run([npm, "i", "-g", spec], { abort: deadline, nothrow: true }) if (result.code === 0) return { ok: true } + if (deadline.aborted) { + return { ok: false, error: `npm did not finish within ${Math.round(INSTALL_TIMEOUT_MS / 60_000)} minutes` } + } const detail = result.stderr.toString().trim().split(/\r?\n/).slice(-3).join(" ") return { ok: false, error: detail || `npm exited with code ${result.code}` } } catch (err) { + if (deadline.aborted) { + return { ok: false, error: `npm did not finish within ${Math.round(INSTALL_TIMEOUT_MS / 60_000)} minutes` } + } return { ok: false, error: err instanceof Error ? err.message : String(err) } } } diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 4773f11ec6..42207b4268 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -176,6 +176,12 @@ export const BashTool = Tool.define("bash", async () => { // 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"] + // Same reasoning for the headless marker: `run` sets it so the workspace + // engine offer degrades to a printed line, but a nested entrypoint + // launched from here may well have a TUI. Left in place, the child would + // inherit "headless" and print the notice to stderr instead of showing + // the install dialog. + delete mergedEnv["ALTIMATE_CODE_HEADLESS"] // altimate_change end const sep = process.platform === "win32" ? ";" : ":" const basePath = mergedEnv.PATH ?? mergedEnv.Path ?? "" diff --git a/packages/opencode/test/altimate/workspace/engine-install-offer.test.ts b/packages/opencode/test/altimate/workspace/engine-install-offer.test.ts index 439aa7cde7..a2397eb025 100644 --- a/packages/opencode/test/altimate/workspace/engine-install-offer.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-install-offer.test.ts @@ -8,10 +8,12 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test" import { ensure, installCommand, + installEngine, installSpec, nodeMajor, describeOffer, isHeadless, + INSTALL_TIMEOUT_MS, resetForTests, syncInternals, ENGINE_BINARY, @@ -307,3 +309,25 @@ describe("headless notice stream", () => { expect(h.toasts).toHaveLength(0) }) }) + +describe("install deadline", () => { + // Regression guard. The install originally used execFile, whose `timeout` + // kills the child. Moving to Process.run for the Windows shim quietly lost + // that: Process.spawn consults `timeout` only inside its abort handler, as + // the grace before SIGKILL, so with no abort signal there is no deadline and + // a stalled npm leaves the dialog on "Installing…" forever. Measured: with + // `timeout` alone an 8s sleep ran 8004ms; with an abort signal, 502ms. + test("a stalled install is reported rather than hanging", async () => { + install({}) + // Stand in for npm stalling past the deadline. + syncInternals.install = async () => ({ ok: false, error: "npm did not finish within 5 minutes" }) + const result = await installEngine() + expect(result.ok).toBe(false) + if (!result.ok) expect(result.error).toContain("did not finish") + }) + + test("the deadline is a real duration, not zero or unset", () => { + expect(INSTALL_TIMEOUT_MS).toBeGreaterThan(0) + expect(Number.isFinite(INSTALL_TIMEOUT_MS)).toBe(true) + }) +}) From 371833805809ee47320e7b8408c4138793b07a5e Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Thu, 27 Aug 2026 04:05:17 +0800 Subject: [PATCH 03/11] fix(workspace): report the install after the dialog is dismissed Escape or a click outside dismisses the offer while npm keeps running. The failure path only set signals on the unmounted component, so a failed install or the five-minute timeout was completely silent; success also cleared the dialog stack unconditionally, which would close whatever had opened in its place. Completion now reports through a toast when the dialog is gone, and only clears a dialog this offer still owns. --- .../src/plugin/tui/altimate/workspace.tsx | 28 +++++++++++++++++-- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/plugin/tui/altimate/workspace.tsx b/packages/opencode/src/plugin/tui/altimate/workspace.tsx index 3966494a03..7c1d04af9b 100644 --- a/packages/opencode/src/plugin/tui/altimate/workspace.tsx +++ b/packages/opencode/src/plugin/tui/altimate/workspace.tsx @@ -25,7 +25,7 @@ import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" import type { BuiltinTuiPlugin } from "@opencode-ai/tui/builtins" import { createHash } from "node:crypto" import open from "open" -import { createSignal, onMount } from "solid-js" +import { createSignal, onCleanup, onMount } from "solid-js" import { ConflictError, ForbiddenError, @@ -1214,6 +1214,16 @@ function EngineInstallOfferDialog(props: EngineOfferProps) { const clipboard = useClipboard() const [phase, setPhase] = createSignal<"idle" | "installing" | "installed" | "failed">("idle") const [failure, setFailure] = createSignal(null) + // The install outlives this component: Escape or a click outside dismisses + // the dialog while npm keeps running. Signals set after that update nothing + // anyone can see — a failed install or the five-minute timeout would be + // completely silent — and clearing the dialog stack would close whatever + // opened in our place. So completion reports through a toast when we are + // gone, and only touches the dialog while we still own it. + let mounted = true + onCleanup(() => { + mounted = false + }) // ``onSelect`` is delivered synchronously per Enter keypress; the install is // a multi-minute await. Without this latch a second Enter starts a second // ``npm i -g`` against the same global prefix. @@ -1281,13 +1291,25 @@ function EngineInstallOfferDialog(props: EngineOfferProps) { setPhase("installing") const result = await installEngine() if (!result.ok) { + installing = false + if (!mounted) { + // Dismissed mid-install: the failed-phase rows have nowhere to render, + // so the error reaches the user as a toast or not at all. + props.api.ui.toast({ + variant: "error", + message: `Workspace engine install failed: ${result.error}. Run: ${command()}`, + duration: 30_000, + }) + return + } setFailure(result.error) setPhase("failed") - installing = false return } setPhase("installed") - props.api.ui.dialog.clear() + // Only clear a dialog we still own — by now the user may have opened + // another, and clearing the stack would take theirs down instead. + if (mounted) props.api.ui.dialog.clear() // Deliberately NOT re-attaching this session from here. The plugin runtime // loads this file in its own realm, so `ensure()` called here would run a // SECOND, independent attach — spawning an engine the session never uses From bb5ef1d77a645646f742eab9157ba40790d0476b Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Thu, 27 Aug 2026 04:30:00 +0800 Subject: [PATCH 04/11] fix(workspace): raise one engine offer at a time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Attach re-probes a repairable failure every turn, so the offer could be raised again while one was still up. Mid-install that replaced the "Installing..." dialog with a fresh idle one, which swallowed keystrokes into its own filter — observed end to end: after a successful install, typing never reached the prompt. The offer is now latched while one is on screen. --- .../src/plugin/tui/altimate/workspace.tsx | 13 ++++++ .../workspace/engine-install-offer.test.ts | 46 +++++++++++++------ 2 files changed, 45 insertions(+), 14 deletions(-) diff --git a/packages/opencode/src/plugin/tui/altimate/workspace.tsx b/packages/opencode/src/plugin/tui/altimate/workspace.tsx index 7c1d04af9b..cf715a10ae 100644 --- a/packages/opencode/src/plugin/tui/altimate/workspace.tsx +++ b/packages/opencode/src/plugin/tui/altimate/workspace.tsx @@ -1223,6 +1223,9 @@ function EngineInstallOfferDialog(props: EngineOfferProps) { let mounted = true onCleanup(() => { mounted = false + // Release the single-offer latch however this dialog goes away — chosen, + // dismissed, or replaced — so a later turn can offer again. + engineOfferVisible = false }) // ``onSelect`` is delivered synchronously per Enter keypress; the install is // a multi-minute await. Without this latch a second Enter starts a second @@ -1392,7 +1395,16 @@ function EngineInstallOfferDialog(props: EngineOfferProps) { * the post-scan prompt re-derives its own state from the directory. Node * availability and latch scope are resolved before rendering so the dialog * itself stays sync. */ +let engineOfferVisible = false + async function showEngineInstallOffer(api: TuiPluginApi): Promise { + // The attach re-probes a repairable failure on every turn, so the offer can + // be raised again while an earlier one is still up — including mid-install, + // where a fresh idle dialog replaces the "Installing…" one and then swallows + // the user's keystrokes into its own filter. Observed end-to-end: after a + // successful install the pane showed a second offer in its idle phase and + // typing went to the dialog rather than the prompt. One offer at a time. + if (engineOfferVisible) return const offer = await describeOffer(api.state.path.directory) // Null means the situation resolved between the attach and this dialog — an // engine appeared, or the project is no longer bound. Say nothing. @@ -1403,6 +1415,7 @@ async function showEngineInstallOffer(api: TuiPluginApi): Promise { return } const major = await detectNodeMajor() + engineOfferVisible = true api.ui.dialog.replace(() => ( )) diff --git a/packages/opencode/test/altimate/workspace/engine-install-offer.test.ts b/packages/opencode/test/altimate/workspace/engine-install-offer.test.ts index a2397eb025..f2f28cb65e 100644 --- a/packages/opencode/test/altimate/workspace/engine-install-offer.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-install-offer.test.ts @@ -4,7 +4,7 @@ // fallback emits when there is no surface, and the command/Node detection the // dialog's "Install now" gate depends on. Everything routes through // `syncInternals`, so no process is spawned and no MCP state is touched. -import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test" import { ensure, installCommand, @@ -22,6 +22,7 @@ import { type EngineOffer, type LocalMcpConfig, } from "../../../src/altimate/workspace/engine-sync" +import { Process } from "../../../src/util/process" import type { CachedBinding } from "../../../src/altimate/workspace/state" const ORIGINAL_FLAG = process.env.ALTIMATE_WORKSPACE @@ -311,22 +312,39 @@ describe("headless notice stream", () => { }) describe("install deadline", () => { - // Regression guard. The install originally used execFile, whose `timeout` - // kills the child. Moving to Process.run for the Windows shim quietly lost - // that: Process.spawn consults `timeout` only inside its abort handler, as - // the grace before SIGKILL, so with no abort signal there is no deadline and - // a stalled npm leaves the dialog on "Installing…" forever. Measured: with - // `timeout` alone an 8s sleep ran 8004ms; with an abort signal, 502ms. - test("a stalled install is reported rather than hanging", async () => { + // The invariant is that installEngine hands the spawn an abort signal, which + // is the ONLY thing that produces a deadline: Process.spawn consults + // `timeout` solely inside its abort handler, as the grace before SIGKILL, so + // without a signal a stalled npm runs forever and the dialog sits on + // "Installing…". Measured on the real helper — an 8s sleep took 8004ms under + // `timeout` alone and 502ms under an abort signal. + // + // An earlier version of this test stubbed syncInternals.install to return a + // timeout error and asserted that error came back. That asserted nothing: + // it echoed the stub and passed just as happily with the abort signal + // deleted. This spies on the real call instead. + test("passes an abort signal to the spawn, not just a timeout", async () => { install({}) - // Stand in for npm stalling past the deadline. - syncInternals.install = async () => ({ ok: false, error: "npm did not finish within 5 minutes" }) - const result = await installEngine() - expect(result.ok).toBe(false) - if (!result.ok) expect(result.error).toContain("did not finish") + delete syncInternals.install + const spy = spyOn(Process, "run").mockResolvedValue({ + code: 0, + stdout: Buffer.alloc(0), + stderr: Buffer.alloc(0), + }) + try { + const result = await installEngine() + expect(result.ok).toBe(true) + expect(spy).toHaveBeenCalled() + const opts = spy.mock.calls[0]?.[1] as { abort?: AbortSignal } | undefined + // The load-bearing assertion: a real AbortSignal was supplied. + expect(opts?.abort).toBeInstanceOf(AbortSignal) + expect(opts?.abort?.aborted).toBe(false) + } finally { + spy.mockRestore() + } }) - test("the deadline is a real duration, not zero or unset", () => { + test("the deadline is a real duration", () => { expect(INSTALL_TIMEOUT_MS).toBeGreaterThan(0) expect(Number.isFinite(INSTALL_TIMEOUT_MS)).toBe(true) }) From 89923187e695e84f1a14b885528504988755be9f Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Thu, 27 Aug 2026 04:39:08 +0800 Subject: [PATCH 05/11] fix(workspace): reserve the offer slot before awaiting discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The single-offer guard checked the latch and then awaited three times before setting it, so two dispatches arriving close together could both pass — worse than the bug it fixed, since the second dialog can replace an installing one and start a concurrent global npm install. The slot is now reserved before the first await and released if discovery is suppressed or fails, and only the raise that owns the latch may free it. --- .../src/plugin/tui/altimate/workspace.tsx | 58 ++++++++++++++----- 1 file changed, 43 insertions(+), 15 deletions(-) diff --git a/packages/opencode/src/plugin/tui/altimate/workspace.tsx b/packages/opencode/src/plugin/tui/altimate/workspace.tsx index cf715a10ae..bf783230df 100644 --- a/packages/opencode/src/plugin/tui/altimate/workspace.tsx +++ b/packages/opencode/src/plugin/tui/altimate/workspace.tsx @@ -1203,6 +1203,8 @@ interface EngineOfferProps { * so the dialog itself stays sync (same shape as ``browserAvailable``). */ nodeMajor: number | null latchScope: LatchScope | null + /** Which raise owns the single-offer latch; only the owner releases it. */ + generation: number } /** One DialogSelect for every phase — the row set changes, the component never @@ -1224,8 +1226,9 @@ function EngineInstallOfferDialog(props: EngineOfferProps) { onCleanup(() => { mounted = false // Release the single-offer latch however this dialog goes away — chosen, - // dismissed, or replaced — so a later turn can offer again. - engineOfferVisible = false + // dismissed, or replaced — but only if this dialog still owns it. A + // superseded dialog tearing down must not free a slot the newer one holds. + if (engineOfferGeneration === props.generation) engineOfferVisible = false }) // ``onSelect`` is delivered synchronously per Enter keypress; the install is // a multi-minute await. Without this latch a second Enter starts a second @@ -1396,6 +1399,9 @@ function EngineInstallOfferDialog(props: EngineOfferProps) { * availability and latch scope are resolved before rendering so the dialog * itself stays sync. */ let engineOfferVisible = false +/** Identifies which raise owns the latch, so a superseded dialog's teardown + * cannot free a slot that a newer dialog is still holding. */ +let engineOfferGeneration = 0 async function showEngineInstallOffer(api: TuiPluginApi): Promise { // The attach re-probes a repairable failure on every turn, so the offer can @@ -1404,21 +1410,43 @@ async function showEngineInstallOffer(api: TuiPluginApi): Promise { // the user's keystrokes into its own filter. Observed end-to-end: after a // successful install the pane showed a second offer in its idle phase and // typing went to the dialog rather than the prompt. One offer at a time. + // + // The slot is reserved BEFORE the first await. Discovery below awaits three + // times, and a check-then-act guard placed after them lets two dispatches + // that arrive close together both pass — which is worse than the bug it + // fixes, because the second dialog can replace an installing one and start a + // concurrent global npm install. if (engineOfferVisible) return - const offer = await describeOffer(api.state.path.directory) - // Null means the situation resolved between the attach and this dialog — an - // engine appeared, or the project is no longer bound. Say nothing. - if (!offer) return - const latchScope = await currentLatchScope() - if (isEngineSkipActive(api, offer.workspaceId, latchScope, Date.now())) { - log.info("engine install offer suppressed by 7-day latch", { workspaceId: offer.workspaceId }) - return - } - const major = await detectNodeMajor() engineOfferVisible = true - api.ui.dialog.replace(() => ( - - )) + const generation = ++engineOfferGeneration + const release = () => { + // Only the current owner may free the slot. + if (engineOfferGeneration === generation) engineOfferVisible = false + } + try { + const offer = await describeOffer(api.state.path.directory) + // Null means the situation resolved between the attach and this dialog — an + // engine appeared, or the project is no longer bound. Say nothing. + if (!offer) return release() + const latchScope = await currentLatchScope() + if (isEngineSkipActive(api, offer.workspaceId, latchScope, Date.now())) { + log.info("engine install offer suppressed by 7-day latch", { workspaceId: offer.workspaceId }) + return release() + } + const major = await detectNodeMajor() + api.ui.dialog.replace(() => ( + + )) + } catch (err) { + release() + throw err + } } // ───────────────────────────────────────────────────────────────────────────── From a1795ec9869e4a0de2e1be6d363daa03fee47912 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Thu, 27 Aug 2026 05:01:45 +0800 Subject: [PATCH 06/11] fix(workspace): hold an install latch across dialog dismissal Dismissing mid-install tears the dialog down and freed the only offer latch while npm kept running, so the next turn's repair retry could raise a fresh offer whose Install now started a second global install against the same prefix. The dialog latch answers "is an offer on screen"; a separate latch now answers "is an install running", and only the second survives dismissal. --- .../src/plugin/tui/altimate/workspace.tsx | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/packages/opencode/src/plugin/tui/altimate/workspace.tsx b/packages/opencode/src/plugin/tui/altimate/workspace.tsx index bf783230df..cbefc59573 100644 --- a/packages/opencode/src/plugin/tui/altimate/workspace.tsx +++ b/packages/opencode/src/plugin/tui/altimate/workspace.tsx @@ -1295,6 +1295,15 @@ function EngineInstallOfferDialog(props: EngineOfferProps) { const runInstall = async () => { setPhase("installing") + engineInstallInFlight = true + try { + await performInstall() + } finally { + engineInstallInFlight = false + } + } + + const performInstall = async () => { const result = await installEngine() if (!result.ok) { installing = false @@ -1402,6 +1411,15 @@ let engineOfferVisible = false /** Identifies which raise owns the latch, so a superseded dialog's teardown * cannot free a slot that a newer dialog is still holding. */ let engineOfferGeneration = 0 +/** Held for the lifetime of an `npm i -g`, independently of the dialog. + * + * The install outlives the dialog that started it: dismissing mid-install + * tears the component down and frees the offer latch, but npm keeps running. + * Without this, the next turn's repair retry raises a fresh offer whose + * "Install now" starts a SECOND `npm i -g` against the same global prefix. + * The dialog latch answers "is an offer on screen"; this one answers "is an + * install still running", and only the second survives dismissal. */ +let engineInstallInFlight = false async function showEngineInstallOffer(api: TuiPluginApi): Promise { // The attach re-probes a repairable failure on every turn, so the offer can @@ -1417,6 +1435,12 @@ async function showEngineInstallOffer(api: TuiPluginApi): Promise { // fixes, because the second dialog can replace an installing one and start a // concurrent global npm install. if (engineOfferVisible) return + if (engineInstallInFlight) { + // An install started from an earlier dialog is still running; offering + // again would invite a second concurrent global install. + log.info("engine install offer suppressed while an install is in flight") + return + } engineOfferVisible = true const generation = ++engineOfferGeneration const release = () => { From 9e66e77d44c2eb0587582c749e4cb6278c6fa22b Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Thu, 27 Aug 2026 05:21:15 +0800 Subject: [PATCH 07/11] fix(workspace): don't offer a local install for a remote workspace `attach ` runs the plugin on the client while the binding, PATH and MCP session live on the server, so Install now would install on the wrong machine behind a success toast. The offer now refuses when the server's directory is absent locally and says where the fix belongs. Also drops the select filter, which persisted across phase changes and stranded the recovery rows. --- .../src/plugin/tui/altimate/workspace.tsx | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/packages/opencode/src/plugin/tui/altimate/workspace.tsx b/packages/opencode/src/plugin/tui/altimate/workspace.tsx index cbefc59573..f3eb2ed29d 100644 --- a/packages/opencode/src/plugin/tui/altimate/workspace.tsx +++ b/packages/opencode/src/plugin/tui/altimate/workspace.tsx @@ -24,6 +24,7 @@ import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" import type { BuiltinTuiPlugin } from "@opencode-ai/tui/builtins" import { createHash } from "node:crypto" +import { existsSync } from "node:fs" import open from "open" import { createSignal, onCleanup, onMount } from "solid-js" import { @@ -50,6 +51,7 @@ import { import { readLocalBinding, recordApprovedBinding } from "@/altimate/workspace/state" import { describeOffer, + installCommand, installEngine, nodeMajor as detectNodeMajor, MIN_NODE_MAJOR, @@ -1372,6 +1374,12 @@ function EngineInstallOfferDialog(props: EngineOfferProps) { { if (option.value === "busy") return @@ -1434,6 +1442,25 @@ async function showEngineInstallOffer(api: TuiPluginApi): Promise { // that arrive close together both pass — which is worse than the bug it // fixes, because the second dialog can replace an installing one and start a // concurrent global npm install. + // `attach ` runs this plugin on the CLIENT while the binding, the PATH + // that matters and the MCP session all live on the SERVER. Probing PATH here + // would describe the wrong machine, and "Install now" would install npm on + // the client, leaving the server exactly as it was behind a success toast. + // attach.ts recognises that case the same way — the server's directory does + // not exist locally — so use it and refuse to act, saying where the fix goes. + // + // Not a complete answer: a client that happens to have the same path, with a + // binding, is still misread. Closing that needs server-side discovery and + // install behind an API, which this PR does not add. + if (!existsSync(api.state.path.directory)) { + log.info("engine install offer suppressed: not the host that owns this workspace") + api.ui.toast({ + variant: "warning", + message: `This workspace's engine is missing on the server, not on this machine. Run there: ${installCommand()}`, + duration: 30_000, + }) + return + } if (engineOfferVisible) return if (engineInstallInFlight) { // An install started from an earlier dialog is still running; offering From 68037195bbd0568af46c779fde5df8e448f0427c Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Thu, 27 Aug 2026 05:29:16 +0800 Subject: [PATCH 08/11] fix(workspace): show the engine offer in an attached run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With `run --attach`, the headless marker is set on the local CLI process while the attach flow runs in the server process, so the server published the offer command and counted the publish as delivery — and the run event loop, the only thing the user is watching, had no handler for it. Neither default nor JSON mode showed anything. The loop now renders it, on stderr for the same reason the local notice is there. --- packages/opencode/src/cli/cmd/run.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index c11ba45a03..c6822099ea 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -30,6 +30,8 @@ import { Locale } from "../../util/locale" import { Tracer, FileExporter, HttpExporter, type TraceExporter } from "../../altimate/observability/tracing" // altimate_change start — upstream_fix: type-only import for the tracing-config cast (see tracer setup below) import type { ConfigV1 } from "@opencode-ai/core/v1/config/config" +// altimate_change - render the workspace engine offer in an attached run +import * as WorkspaceEngine from "@/altimate/workspace/engine-sync" // altimate_change end // When a tool's parameters can't be statically inferred (legacy fork tools whose @@ -761,6 +763,26 @@ You are speaking to a non-technical business executive. Follow these rules stric break } + // altimate_change start — an attached run is the only surface that can + // show the workspace engine offer. The headless marker is set on THIS + // process, but with --attach the attach flow and isHeadless() run in + // the server process, so the server publishes the offer command and + // treats the publish as delivery — while this event loop, which has + // no handler for it, is the only thing the user is looking at. + if ( + event.type === "tui.command.execute" && + (event.properties as { command?: string }).command === WorkspaceEngine.OFFER_COMMAND + ) { + // stderr for the same reason as the local headless notice: stdout + // is raw JSON events under --format json. + process.stderr.write( + `This workspace's integration tools need the local engine on the server. Install it there with: ${WorkspaceEngine.installCommand()}` + + EOL, + ) + continue + } + // altimate_change end + if (event.type === "permission.asked") { const permission = event.properties if (permission.sessionID !== sessionID) continue From 33d37f3777a86e27c2e712a754e5103f964b2c02 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Thu, 27 Aug 2026 05:39:30 +0800 Subject: [PATCH 09/11] fix(workspace): verify the installed engine before reporting success MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit npm installs into its configured global prefix, whose bin directory need not be on PATH — so a zero exit could leave the next attach finding nothing while the dialog promised tools on the next message and the offer was raised again. The install now re-runs the attach's own discovery and reports the PATH reason instead. Install now also requires npm, not just Node 20+: several distros package them separately. --- .../src/altimate/workspace/engine-sync.ts | 33 ++++++++++- .../src/plugin/tui/altimate/workspace.tsx | 12 +++- .../workspace/engine-install-offer.test.ts | 57 ++++++++++++++++++- 3 files changed, 98 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/engine-sync.ts b/packages/opencode/src/altimate/workspace/engine-sync.ts index 0a15fd6e61..4eb4ecca42 100644 --- a/packages/opencode/src/altimate/workspace/engine-sync.ts +++ b/packages/opencode/src/altimate/workspace/engine-sync.ts @@ -177,6 +177,7 @@ export const syncInternals: { publishOffer?: () => Promise printLine?: (line: string) => void nodeMajor?: () => Promise + npmAvailable?: () => boolean install?: (spec: string) => Promise } = {} @@ -516,6 +517,15 @@ export function nodeMajor(): Promise { }) } +/** Whether npm can be invoked at all. Node and npm are separate packages on + * several Linux distributions, so Node 20+ is not sufficient to conclude that + * `npm i -g` will run — without this the offer enables "Install now" and the + * install fails immediately with ENOENT. */ +export function npmAvailable(): boolean { + if (syncInternals.npmAvailable) return syncInternals.npmAvailable() + return which(process.platform === "win32" ? "npm.cmd" : "npm") !== null +} + /** Run the global install. Only ever reached from an explicit user choice — * rule 3 forbids reaching it from the attach flow itself. * @@ -537,7 +547,28 @@ export async function installEngine(): Promise { const deadline = AbortSignal.timeout(INSTALL_TIMEOUT_MS) try { const result = await Process.run([npm, "i", "-g", spec], { abort: deadline, nothrow: true }) - if (result.code === 0) return { ok: true } + if (result.code === 0) { + // A zero exit is not the same as a usable engine. npm installs into its + // configured global prefix, whose bin directory need not be on PATH — so + // the install genuinely succeeds while the next attach still finds + // nothing, and the offer is raised again after a success message. Re-run + // the same discovery the attach will do, and fail with the reason. + const installedBin = which(ENGINE_BINARY) + if (!installedBin) { + return { + ok: false, + error: `npm installed it, but ${ENGINE_BINARY} is not on PATH — add your npm global bin directory to PATH`, + } + } + const installedVersion = await versionOf(installedBin) + if (!installedVersion || compareVersions(installedVersion, MIN_ENGINE_VERSION) < 0) { + return { + ok: false, + error: `npm installed it, but ${ENGINE_BINARY} on PATH reports ${installedVersion ?? "no version"}`, + } + } + return { ok: true } + } if (deadline.aborted) { return { ok: false, error: `npm did not finish within ${Math.round(INSTALL_TIMEOUT_MS / 60_000)} minutes` } } diff --git a/packages/opencode/src/plugin/tui/altimate/workspace.tsx b/packages/opencode/src/plugin/tui/altimate/workspace.tsx index f3eb2ed29d..8b64296bb5 100644 --- a/packages/opencode/src/plugin/tui/altimate/workspace.tsx +++ b/packages/opencode/src/plugin/tui/altimate/workspace.tsx @@ -54,6 +54,7 @@ import { installCommand, installEngine, nodeMajor as detectNodeMajor, + npmAvailable, MIN_NODE_MAJOR, OFFER_COMMAND, type EngineOffer, @@ -1204,6 +1205,9 @@ interface EngineOfferProps { /** Node major on PATH, or null when Node is absent. Resolved by the caller * so the dialog itself stays sync (same shape as ``browserAvailable``). */ nodeMajor: number | null + /** Whether npm itself can be invoked. Node 20+ is not enough: several Linux + * distributions package node and npm separately. */ + hasNpm: boolean latchScope: LatchScope | null /** Which raise owns the single-offer latch; only the owner releases it. */ generation: number @@ -1238,7 +1242,7 @@ function EngineInstallOfferDialog(props: EngineOfferProps) { let installing = false const command = () => props.offer.command - const canInstall = () => props.nodeMajor !== null && props.nodeMajor >= MIN_NODE_MAJOR + const canInstall = () => props.nodeMajor !== null && props.nodeMajor >= MIN_NODE_MAJOR && props.hasNpm const title = () => { const tools = `${props.offer.declared} integration tool${props.offer.declared === 1 ? "" : "s"}` @@ -1251,7 +1255,9 @@ function EngineInstallOfferDialog(props: EngineOfferProps) { parts.push( props.nodeMajor === null ? `(needs Node ${MIN_NODE_MAJOR}+ to install — Node was not found on PATH)` - : `(needs Node ${MIN_NODE_MAJOR}+ to install — found Node ${props.nodeMajor})`, + : props.nodeMajor < MIN_NODE_MAJOR + ? `(needs Node ${MIN_NODE_MAJOR}+ to install — found Node ${props.nodeMajor})` + : `(needs npm to install — npm was not found on PATH)`, ) } const err = failure() @@ -1485,11 +1491,13 @@ async function showEngineInstallOffer(api: TuiPluginApi): Promise { return release() } const major = await detectNodeMajor() + const hasNpm = npmAvailable() api.ui.dialog.replace(() => ( diff --git a/packages/opencode/test/altimate/workspace/engine-install-offer.test.ts b/packages/opencode/test/altimate/workspace/engine-install-offer.test.ts index f2f28cb65e..9cffa1290f 100644 --- a/packages/opencode/test/altimate/workspace/engine-install-offer.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-install-offer.test.ts @@ -9,6 +9,7 @@ import { ensure, installCommand, installEngine, + npmAvailable, installSpec, nodeMajor, describeOffer, @@ -324,7 +325,11 @@ describe("install deadline", () => { // it echoed the stub and passed just as happily with the abort signal // deleted. This spies on the real call instead. test("passes an abort signal to the spawn, not just a timeout", async () => { - install({}) + // Discovery now runs after a zero exit, so the installed engine has to look + // usable or installEngine reports the PATH failure instead. That is the + // round-9 behaviour; this test is about the abort signal, so make discovery + // succeed and keep the assertion on the spawn options. + install({ which: "/usr/local/bin/datamate", version: MIN_ENGINE_VERSION }) delete syncInternals.install const spy = spyOn(Process, "run").mockResolvedValue({ code: 0, @@ -349,3 +354,53 @@ describe("install deadline", () => { expect(Number.isFinite(INSTALL_TIMEOUT_MS)).toBe(true) }) }) + +describe("install success is verified, not assumed", () => { + // npm installs into its configured global prefix, whose bin directory need + // not be on PATH. A zero exit therefore does not mean the next attach will + // find anything — and reporting success there promises tools that never + // arrive, then raises the offer again. + test("a zero exit with the engine still absent from PATH is a failure", async () => { + install({ which: null }) + delete syncInternals.install + const spy = spyOn(Process, "run").mockResolvedValue({ + code: 0, + stdout: Buffer.alloc(0), + stderr: Buffer.alloc(0), + }) + try { + const result = await installEngine() + expect(result.ok).toBe(false) + if (!result.ok) expect(result.error).toContain("not on PATH") + } finally { + spy.mockRestore() + } + }) + + test("a zero exit with a below-floor engine on PATH is a failure", async () => { + install({ which: "/usr/local/bin/datamate", version: "0.5.9" }) + delete syncInternals.install + const spy = spyOn(Process, "run").mockResolvedValue({ + code: 0, + stdout: Buffer.alloc(0), + stderr: Buffer.alloc(0), + }) + try { + const result = await installEngine() + expect(result.ok).toBe(false) + if (!result.ok) expect(result.error).toContain("0.5.9") + } finally { + spy.mockRestore() + } + }) +}) + +describe("npmAvailable", () => { + test("false when npm is not on PATH, true when it is", () => { + install({}) + syncInternals.npmAvailable = () => false + expect(npmAvailable()).toBe(false) + syncInternals.npmAvailable = () => true + expect(npmAvailable()).toBe(true) + }) +}) From fe77f09bc70d39bd0bf3f65071e5ceb3de86bbfc Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Thu, 27 Aug 2026 05:47:09 +0800 Subject: [PATCH 10/11] fix(workspace): render the attached-run offer before the idle break MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The handler sat after the loop's idle check, so an offer arriving in the same batch as idle was never rendered — the loop had already stopped. Moved above it. An offer published strictly after idle still cannot be shown there, since the stream is over; that residual is noted in place and affects only the attached-run surface. --- packages/opencode/src/cli/cmd/run.ts | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index c6822099ea..b9533fc820 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -755,14 +755,6 @@ You are speaking to a non-technical business executive. Follow these rules stric UI.error(err) } - if ( - event.type === "session.status" && - event.properties.sessionID === sessionID && - event.properties.status.type === "idle" - ) { - break - } - // altimate_change start — an attached run is the only surface that can // show the workspace engine offer. The headless marker is set on THIS // process, but with --attach the attach flow and isHeadless() run in @@ -781,8 +773,23 @@ You are speaking to a non-technical business executive. Follow these rules stric ) continue } + // Placed BEFORE the idle break deliberately: the loop stops on idle, + // so a handler after it never runs for an offer that arrives in the + // same batch. An offer published strictly after idle still cannot be + // shown here — the stream is over by then — which is a real gap when + // the attach exceeds its bounded wait. The TUI and local headless + // paths are unaffected. // altimate_change end + if ( + event.type === "session.status" && + event.properties.sessionID === sessionID && + event.properties.status.type === "idle" + ) { + break + } + + if (event.type === "permission.asked") { const permission = event.properties if (permission.sessionID !== sessionID) continue From 78288ede5f084008fd8399e3439a4e7ffec59e91 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Thu, 27 Aug 2026 06:00:01 +0800 Subject: [PATCH 11/11] test(workspace): keep the invariants, drop the echoes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two tests asserted a stub's own return value and would have passed with the function they named deleted; one asserted only that a constant is positive. The TUI routing invariant was covered twice — the real publish path stays. Every survivor was mutation-checked: printing to stdout, dropping the abort signal, trusting npm's exit code, removing the headless branch and never publishing each turn at least one test red. --- .../workspace/engine-install-offer.test.ts | 52 +------------------ 1 file changed, 2 insertions(+), 50 deletions(-) diff --git a/packages/opencode/test/altimate/workspace/engine-install-offer.test.ts b/packages/opencode/test/altimate/workspace/engine-install-offer.test.ts index 9cffa1290f..506541f6cf 100644 --- a/packages/opencode/test/altimate/workspace/engine-install-offer.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-install-offer.test.ts @@ -9,12 +9,10 @@ import { ensure, installCommand, installEngine, - npmAvailable, installSpec, nodeMajor, describeOffer, isHeadless, - INSTALL_TIMEOUT_MS, resetForTests, syncInternals, ENGINE_BINARY, @@ -113,36 +111,13 @@ describe("nodeMajor", () => { syncInternals.which = () => null expect(await nodeMajor()).toBeNull() }) - - test("parses the major out of a v-prefixed version", async () => { - syncInternals.nodeMajor = async () => 22 - expect(await nodeMajor()).toBe(22) - }) }) describe("offer routing — engine missing", () => { - test("raises the dialog over the event bus, with no toast and no printed line", async () => { - const h = install({}) - syncInternals.offer = (offer) => { - h.offers.push(offer) - return true - } - expect(await ensure("s1")).toEqual({ kind: "engine-missing", declared: 2 }) - expect(h.offers).toHaveLength(1) - expect(h.offers[0]).toMatchObject({ - reason: "engine-missing", - workspaceId: "42", - workspaceName: "analytics", - declared: 2, - command: `npm i -g ${ENGINE_PACKAGE}@${MIN_ENGINE_VERSION}`, - }) - // A surface owns it — the fallbacks must stay silent. - expect(h.toasts).toHaveLength(0) - expect(h.printed).toHaveLength(0) - }) - test("headless prints exactly one line naming workspace and command, and no toast", async () => { + expect(isHeadless()).toBe(false) process.env.ALTIMATE_CODE_HEADLESS = "1" + expect(isHeadless()).toBe(true) const h = install({}) expect(await ensure("s1")).toEqual({ kind: "engine-missing", declared: 2 }) expect(h.toasts).toHaveLength(0) @@ -271,14 +246,6 @@ describe("describeOffer — the TUI re-derives its own detail", () => { }) }) -describe("headless detection", () => { - test("off by default, on when the run command marks it", () => { - expect(isHeadless()).toBe(false) - process.env.ALTIMATE_CODE_HEADLESS = "1" - expect(isHeadless()).toBe(true) - }) -}) - describe("headless notice stream", () => { // Regression guard: the notice used to go to stdout, which `run --format // json` documents as raw JSON events. A human-readable line there was line 1 @@ -348,11 +315,6 @@ describe("install deadline", () => { spy.mockRestore() } }) - - test("the deadline is a real duration", () => { - expect(INSTALL_TIMEOUT_MS).toBeGreaterThan(0) - expect(Number.isFinite(INSTALL_TIMEOUT_MS)).toBe(true) - }) }) describe("install success is verified, not assumed", () => { @@ -394,13 +356,3 @@ describe("install success is verified, not assumed", () => { } }) }) - -describe("npmAvailable", () => { - test("false when npm is not on PATH, true when it is", () => { - install({}) - syncInternals.npmAvailable = () => false - expect(npmAvailable()).toBe(false) - syncInternals.npmAvailable = () => true - expect(npmAvailable()).toBe(true) - }) -})