From 19053013ef9cb83d123d1a3321a3f0471c04ecfe Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Fri, 28 Aug 2026 03:34:26 +0800 Subject: [PATCH 01/10] feat(workspace): offer to install the engine a bound workspace needs A bound workspace whose declared tools need the local engine used to report a missing engine as a toast with a command in it. This replaces that with an offer: Install now / Copy command / Not now. The install only ever runs from an explicit choice; the turn boundary picks the installed engine up on the next message. The offer crosses to the TUI over the event bus and is re-derived there; headless prints one stderr line. --- .../src/altimate/workspace/engine-offer.ts | 199 +++++++++ .../src/altimate/workspace/engine-overlay.ts | 76 +++- .../src/altimate/workspace/engine-seams.ts | 7 + packages/opencode/src/cli/cmd/run.ts | 22 + .../src/plugin/tui/altimate/workspace.tsx | 378 +++++++++++++++++- .../test/altimate/plugin/workspace.test.ts | 72 +++- .../workspace/engine-install-offer.test.ts | 330 +++++++++++++++ .../altimate/workspace/engine-overlay.test.ts | 43 +- 8 files changed, 1092 insertions(+), 35 deletions(-) create mode 100644 packages/opencode/src/altimate/workspace/engine-offer.ts create mode 100644 packages/opencode/test/altimate/workspace/engine-install-offer.test.ts diff --git a/packages/opencode/src/altimate/workspace/engine-offer.ts b/packages/opencode/src/altimate/workspace/engine-offer.ts new file mode 100644 index 000000000..955171898 --- /dev/null +++ b/packages/opencode/src/altimate/workspace/engine-offer.ts @@ -0,0 +1,199 @@ +// altimate_change - new file +// +// The install offer for a workspace whose engine is missing or too old. +// +// State-free on purpose: the TUI plugin runs in its own module realm and +// receives the offer as a bare command over the event bus, so it re-derives +// the detail here from disk and PATH rather than from the overlay's memory. +// Offer, never install on the flow's own account — `installEngine` only ever +// runs from an explicit "Install now". +import { execFile } from "node:child_process" +import { Process } from "@/util/process" +import { AppRuntime } from "@/effect/app-runtime" +import { EventV2Bridge } from "@/event-v2-bridge" +import { TuiEvent } from "@/server/tui-event" +import { readLocalBinding } from "./state" +import { isHeadless, log, syncInternals } from "./engine-seams" +import { declaredBounded, notify, printLine, versionOf, which } from "./engine-probes" +import { ENGINE_BINARY, ENGINE_PACKAGE, MIN_ENGINE_VERSION, clearsFloor, type Toast } from "./engine-types" + +/** 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 +/** 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, because the plugin runtime loads plugins in a separate realm. + * `CommandExecute` carries no payload, so the plugin re-derives the offer + * with `describeOffer()`. */ +export const OFFER_COMMAND = "altimate.workspace.engineInstallOffer" + +/** 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, in the same realm. Returns true when it + * took ownership. Deliberately synchronous: it claims the offer and renders + * out-of-band rather than making the turn boundary wait for a person. */ +export type OfferHandler = (offer: EngineOffer) => boolean + +export type InstallResult = { ok: true } | { ok: false; error: string } + +/** 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()}` +} + +/** Re-derive the current "no usable engine" state for a directory, from the + * binding on disk and the engine on PATH. Null when there is nothing to offer: + * unbound, or an engine that clears the floor. */ +export async function describeOffer(directory: string): Promise { + const binding = syncInternals.resolveBinding + ? await syncInternals.resolveBinding(directory) + : 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 && clearsFloor(found)) return null + const declared = (await declaredBounded(workspaceId))?.keys.length ?? 0 + return { + reason: bin ? "engine-too-old" : "engine-missing", + workspaceId, + workspaceName: binding.datamateName, + declared, + ...(bin ? { found: found ?? "unknown" } : {}), + command: installCommand(), + } +} + +/** Node major on PATH, or null when Node is absent. Gates "Install now": with + * no Node there is nothing to run npm with, so the offer shows 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) + }) + }) +} + +/** Whether npm can be invoked at all. Node and npm are separate packages on + * several Linux distributions, so Node 20+ does not imply `npm i -g` runs. */ +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. + * + * `npm.cmd` on Windows: a normal Node install exposes npm as a command shim, + * and nothing here spawns a shell. The deadline comes from an abort signal: + * `Process.spawn` consults `timeout` only inside its abort handler. A zero + * exit is not a usable engine — npm's global bin directory need not be on + * PATH — so the same discovery the turn boundary does runs before success. */ +export async function installEngine(): Promise { + const spec = installSpec() + if (syncInternals.install) return syncInternals.install(spec) + const npm = process.platform === "win32" ? "npm.cmd" : "npm" + 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) { + 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 (!clearsFloor(installedVersion)) { + 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` } + } + 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) } + } +} + +/** 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 + } +} + +/** Hand the offer to a same-realm 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 + } +} + +/** One printed line for headless `run`. */ +export function describeOfferLine(offer: EngineOffer): string { + const tools = `${offer.declared} integration tool${offer.declared === 1 ? "" : "s"}` + return 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}` +} + +/** Offer via the dialog surface when there is one; otherwise print (headless) + * or toast (bus unavailable). Exactly one of these happens. */ +export async function offerOrNotify(offer: EngineOffer, toast: Toast): Promise { + if (isHeadless()) { + printLine(describeOfferLine(offer)) + return + } + if (offerInstall(offer)) return + if (await publishOffer()) return + await notify(toast) +} diff --git a/packages/opencode/src/altimate/workspace/engine-overlay.ts b/packages/opencode/src/altimate/workspace/engine-overlay.ts index 6110c8684..6e25ed171 100644 --- a/packages/opencode/src/altimate/workspace/engine-overlay.ts +++ b/packages/opencode/src/altimate/workspace/engine-overlay.ts @@ -36,9 +36,10 @@ import { type ScopedBinding, } from "./engine-seams" import { declaredBounded, notify, printLine, resolveBinding, versionOf, which } from "./engine-probes" +import { installCommand, offerOrNotify, type EngineOffer } from "./engine-offer" import { ENGINE_BINARY, - INSTALL_COMMAND, + INSTALL_HELPS, REPAIRABLE, TOOL_PREFIX, clearsFloor, @@ -56,6 +57,7 @@ import { } from "./engine-types" export * from "./engine-types" +export * from "./engine-offer" export { isEnabled, isHeadless, isServe, syncInternals } from "./engine-seams" /** Sessions remembered per process. It is a memo; an evicted session just re-settles. */ @@ -78,10 +80,16 @@ function now(): number { async function probeEngine(): Promise { const at = now() - if (probeMemo && (probeMemo.result.kind === "ok" || at - probeMemo.at < FAILED_PROBE_TTL_MS)) { + const bin = which(ENGINE_BINARY) + // A usable engine is remembered for the process. A missing one is asked + // about on every call — `which` is a PATH scan, no process spawn — so an + // install made from the offer dialog (which runs in another module realm and + // cannot reach this memo) is seen on the next turn. A too-old or broken one + // costs a spawn to re-check, so that is rate-limited by the TTL. + if (probeMemo && probeMemo.result.kind === "ok") return probeMemo.result + if (probeMemo && probeMemo.result.kind === "too-old" && bin && at - probeMemo.at < FAILED_PROBE_TTL_MS) { return probeMemo.result } - const bin = which(ENGINE_BINARY) let result: Probe if (!bin) { result = { kind: "missing" } @@ -494,19 +502,43 @@ async function reconcile(sessionID: string, directory: string, state: DirectoryS count === undefined ? `Workspace "${workspace.name}" has integration tools that run on the local engine, which is not installed.` : `Workspace "${workspace.name}" declares ${count} integration tool${count === 1 ? "" : "s"}. They run on the local engine, which is not installed.` - await announceRefusal(sessionID, outcome, { - title: `Workspace "${workspace.name}" needs the local engine`, - message: `${what} Install it with: ${INSTALL_COMMAND}`, - variant: "warning", - }) + await announceRefusal( + sessionID, + outcome, + { + title: `Workspace "${workspace.name}" needs the local engine`, + message: `${what} Install it with: ${installCommand()}`, + variant: "warning", + }, + { + reason: "engine-missing", + workspaceId: workspace.id, + workspaceName: workspace.name, + declared: count ?? 0, + command: installCommand(), + }, + ) return } record(sessionID, refusal) - await announceRefusal(sessionID, refusal, { - title: `Workspace "${workspace.name}": engine not usable`, - message: describeRefusal(refusal.found, workspace.name), - variant: "warning", - }) + const declared = (await declaredFor(workspace.id))?.keys.length ?? 0 + await announceRefusal( + sessionID, + refusal, + { + title: `Workspace "${workspace.name}": engine not usable`, + message: describeRefusal(refusal.found, workspace.name), + variant: "warning", + }, + { + reason: "engine-too-old", + workspaceId: workspace.id, + workspaceName: workspace.name, + declared, + found: refusal.found ?? "unknown", + command: installCommand(), + }, + ) return } @@ -574,16 +606,26 @@ async function reconcile(sessionID: string, directory: string, state: DirectoryS /** Tell the session about a refusal, once per unchanged verdict. * - * The substitution point for the install offer: when `installWouldHelp(outcome)` - * a dialog replaces the toast here; it never adds a second message. Headless - * `run` prints one stderr line instead. */ -export async function announceRefusal(sessionID: string, outcome: Outcome, toast: Toast): Promise { + * The substitution point for the install offer: when installing would help + * and an `offer` is supplied, the offer surface (dialog, headless line, or + * toast fallback) replaces the toast — never adds to it. Otherwise headless + * `run` prints one stderr line and the TUI gets the toast. */ +export async function announceRefusal( + sessionID: string, + outcome: Outcome, + toast: Toast, + offer?: EngineOffer, +): Promise { const rec = sessions.get(sessionID) ?? record(sessionID, outcome) const detail = "error" in outcome ? outcome.error : "found" in outcome ? String(outcome.found) : "" const declared = "declared" in outcome ? String(outcome.declared ?? "?") : "" const signature = `${outcome.kind}:${detail}:${declared}:${toast.title}` if (rec.announced === signature) return rec.announced = signature + if (offer && INSTALL_HELPS[outcome.kind]) { + await offerOrNotify(offer, toast) + return + } if (isHeadless()) { printLine(`${toast.title}: ${toast.message}`) return diff --git a/packages/opencode/src/altimate/workspace/engine-seams.ts b/packages/opencode/src/altimate/workspace/engine-seams.ts index 0e53f0063..bec912e0d 100644 --- a/packages/opencode/src/altimate/workspace/engine-seams.ts +++ b/packages/opencode/src/altimate/workspace/engine-seams.ts @@ -7,6 +7,7 @@ import { Instance } from "@/project/instance" import { Log } from "@/altimate/util/log" import type { CachedBinding } from "./state" import type { Declared, LocalMcpConfig, McpEntry, McpStatus, Toast } from "./engine-types" +import type { EngineOffer, InstallResult } from "./engine-offer" export const log = Log.create({ service: "workspace-engine" }) @@ -22,6 +23,12 @@ export const syncInternals: { declared?: (workspaceId: string) => Promise notify?: (toast: Toast) => Promise printLine?: (line: string) => void + /** Install-offer seams (see engine-offer.ts). */ + offer?: (offer: EngineOffer) => boolean + publishOffer?: () => Promise + nodeMajor?: () => Promise + npmAvailable?: () => boolean + install?: (spec: string) => Promise instanceDirectory?: () => string | null headless?: () => boolean serve?: () => boolean diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 3f6bf66bd..bf86d46ae 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 { OFFER_COMMAND, installCommand } from "@/altimate/workspace/engine-offer" // altimate_change end // When a tool's parameters can't be statically inferred (legacy fork tools whose @@ -752,6 +754,26 @@ You are speaking to a non-technical business executive. Follow these rules stric UI.error(err) } + // altimate_change start — an attached run is the only surface that can + // show the workspace engine offer. With --attach the turn boundary and + // isHeadless() run in the server process, which 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. + // Placed before the idle break: the loop stops on idle, so a handler + // after it never runs for an offer that arrives in the same batch. + if ( + event.type === "tui.command.execute" && + (event.properties as { command?: string }).command === OFFER_COMMAND + ) { + // stderr: 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: ${installCommand()}` + + EOL, + ) + continue + } + // altimate_change end + if ( event.type === "session.status" && event.properties.sessionID === sessionID && diff --git a/packages/opencode/src/plugin/tui/altimate/workspace.tsx b/packages/opencode/src/plugin/tui/altimate/workspace.tsx index 2a75f93c4..201f9b34e 100644 --- a/packages/opencode/src/plugin/tui/altimate/workspace.tsx +++ b/packages/opencode/src/plugin/tui/altimate/workspace.tsx @@ -24,8 +24,9 @@ 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, onMount } from "solid-js" +import { createSignal, onCleanup, onMount } from "solid-js" import { ConflictError, ForbiddenError, @@ -48,6 +49,17 @@ import { resolveProjectIdentifier, } from "@/altimate/workspace/detect" import { readLocalBinding, recordApprovedBinding } from "@/altimate/workspace/state" +import { + describeOffer, + installCommand, + installEngine, + nodeMajor as detectNodeMajor, + npmAvailable, + MIN_NODE_MAJOR, + OFFER_COMMAND, + type EngineOffer, +} from "@/altimate/workspace/engine-offer" +import { useClipboard } from "@opencode-ai/tui/context/clipboard" import { AltimateApi } from "@/altimate/api/client" import { Log } from "@/altimate/util/log" @@ -1143,6 +1155,357 @@ async function runFlow(api: TuiPluginApi, directory: string): Promise { )) } +// ───────────────────────────────────────────────────────────────────────────── +// Engine install offer. The workspace engine overlay 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 + /** 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 +} + +/** 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) + // 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 + // Release the single-offer latch however this dialog goes away — chosen, + // 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 + // ``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 && props.hasNpm + + 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)` + : 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() + 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") + engineInstallInFlight = true + try { + await performInstall() + } finally { + engineInstallInFlight = false + } + } + + const performInstall = async () => { + 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") + return + } + setPhase("installed") + // 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 reconciling this session from here. The plugin runtime + // loads this file in its own realm, so the overlay module here is not the + // one the server consults. Nothing needs to: the turn boundary looks for a + // missing engine on PATH again every turn, so the engine just installed is + // picked up on the next message without a restart. + 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. + * + * The overlay 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. */ +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 + // 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. + // + // 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. + // `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 + // 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 = () => { + // 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() + const hasNpm = npmAvailable() + api.ui.dialog.replace(() => ( + + )) + } catch (err) { + release() + throw err + } +} + // ───────────────────────────────────────────────────────────────────────────── // Plugin registration // ───────────────────────────────────────────────────────────────────────────── @@ -1173,6 +1536,17 @@ const tui: TuiPlugin = async (api) => { runFlow(api, api.state.path.directory).catch((err) => reportFlowFailure(api, err)) }, }, + { + // Raised by the workspace engine overlay 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 +1569,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 cf8ed145f..391b92697 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 000000000..33e2e3fb2 --- /dev/null +++ b/packages/opencode/test/altimate/workspace/engine-install-offer.test.ts @@ -0,0 +1,330 @@ +// altimate_change - new file +// +// The "no usable engine" offer: which surface gets it, what the fallback +// emits when there is no surface, what the TUI re-derives, and the install +// path's gates and verification. Everything routes through `syncInternals`. +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test" +import { + ENGINE_BINARY, + ENGINE_PACKAGE, + MIN_ENGINE_VERSION, + beforeTurn, + describeOffer, + installCommand, + installEngine, + installSpec, + nodeMajor, + resetForTests, + settledOutcome, + syncInternals, + type EngineOffer, + type Toast, +} from "../../../src/altimate/workspace/engine-overlay" +import { Process } from "../../../src/util/process" +import type { CachedBinding } from "../../../src/altimate/workspace/state" + +const DIR = "/tmp/analytics" +const ORIGINAL_FLAG = process.env.ALTIMATE_WORKSPACE +const ORIGINAL_SPEC = process.env.ALTIMATE_ENGINE_INSTALL_SPEC + +const binding: CachedBinding = { + datamateId: 42, + datamateName: "analytics", + repoRemote: null, + projectPath: DIR, + linkedAt: 0, +} as CachedBinding + +type Harness = { offers: EngineOffer[]; toasts: Toast[]; printed: string[]; published: number } + +/** No engine on PATH (or an old one) plus captured surfaces. */ +function install(opts: { + which?: string | null + version?: string | null + declaredKeys?: string[] + headless?: boolean + bus?: boolean + surface?: boolean + bound?: boolean +}): Harness { + const h: Harness = { offers: [], toasts: [], printed: [], published: 0 } + process.env.ALTIMATE_WORKSPACE = "1" + syncInternals.serve = () => false + syncInternals.headless = () => opts.headless === true + syncInternals.instanceDirectory = () => DIR + syncInternals.resolveBinding = async () => (opts.bound === false ? null : 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.notify = async (toast) => { + h.toasts.push(toast) + } + syncInternals.printLine = (line) => { + h.printed.push(line) + } + syncInternals.publishOffer = async () => { + if (opts.bus === false) return false + h.published += 1 + return true + } + if (opts.surface) { + syncInternals.offer = (offer) => { + h.offers.push(offer) + return true + } + } + const config: { mcp?: Record } = { mcp: {} } + let loaded = false + syncInternals.config = { + invalidate: async () => { + loaded = false + }, + get: async () => { + if (!loaded) { + const { overlay } = await import("../../../src/altimate/workspace/engine-overlay") + config.mcp = {} + await overlay(DIR, config) + loaded = true + } + return config + }, + } + syncInternals.mcp = { + status: async () => ({ datamate: { status: "connected" } }), + add: async () => {}, + remove: async () => {}, + tools: async () => ({}), + } + return h +} + +beforeEach(() => resetForTests()) +afterEach(() => { + resetForTests() + for (const key of Object.keys(syncInternals)) delete (syncInternals as Record)[key] + 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", () => { + delete process.env.ALTIMATE_ENGINE_INSTALL_SPEC + 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.tgz" + expect(installCommand()).toBe("npm i -g /tmp/datamate.tgz") + }) +}) + +describe("nodeMajor", () => { + test("null when node is not on PATH", async () => { + syncInternals.which = () => null + expect(await nodeMajor()).toBeNull() + }) +}) + +describe("offer routing — engine missing", () => { + test("a same-realm surface takes the offer and nothing else is emitted", async () => { + const h = install({ surface: true }) + await beforeTurn("s1") + expect(h.offers).toEqual([ + { + reason: "engine-missing", + workspaceId: "42", + workspaceName: "analytics", + declared: 2, + command: installCommand(), + }, + ]) + expect(h.published).toBe(0) + expect(h.toasts).toEqual([]) + expect(h.printed).toEqual([]) + expect(settledOutcome("s1")).toEqual({ kind: "engine-missing", declared: 2 }) + }) + test("in a TUI the offer is published over the bus and nothing is printed or toasted", async () => { + const h = install({}) + await beforeTurn("s1") + expect(h.published).toBe(1) + expect(h.toasts).toEqual([]) + expect(h.printed).toEqual([]) + }) + test("falls back to the toast only when the bus is unavailable", async () => { + const h = install({ bus: false }) + await beforeTurn("s1") + expect(h.published).toBe(0) + expect(h.toasts).toHaveLength(1) + expect(h.toasts[0].message).toContain(installCommand()) + }) + test("headless prints exactly one line naming workspace and command, and no toast", async () => { + const h = install({ headless: true }) + await beforeTurn("s1") + await beforeTurn("s1") + expect(h.printed).toEqual([ + `Workspace "analytics": 2 integration tools need the local engine, which is not installed. Install it with: ${installCommand()}`, + ]) + expect(h.toasts).toEqual([]) + expect(h.published).toBe(0) + }) + test("singularises the tool count", async () => { + const h = install({ headless: true, declaredKeys: ["dbt_build_model"] }) + await beforeTurn("s1") + expect(h.printed[0]).toContain("1 integration tool need") + }) + test("the offer is raised once per session per verdict", async () => { + const h = install({}) + await beforeTurn("s1") + await beforeTurn("s1") + expect(h.published).toBe(1) + await beforeTurn("s2") + expect(h.published).toBe(2) + }) +}) + +describe("offer routing — engine too old", () => { + test("carries the found version and the update command", async () => { + const h = install({ surface: true, which: "/usr/local/bin/datamate", version: "0.6.3" }) + await beforeTurn("s1") + expect(h.offers).toEqual([ + { + reason: "engine-too-old", + workspaceId: "42", + workspaceName: "analytics", + declared: 2, + found: "0.6.3", + command: installCommand(), + }, + ]) + expect(settledOutcome("s1")).toEqual({ kind: "engine-too-old", found: "0.6.3" }) + }) + test("headless, the printed line names the found version", async () => { + const h = install({ headless: true, which: "/usr/local/bin/datamate", version: "0.6.3" }) + await beforeTurn("s1") + expect(h.printed).toEqual([ + `Workspace "analytics": 2 integration tools need ${ENGINE_BINARY} ${MIN_ENGINE_VERSION}+ (found 0.6.3). Update with: ${installCommand()}`, + ]) + }) + test("a broken engine reports 'unknown' rather than a version", async () => { + const h = install({ surface: true, which: "/usr/local/bin/datamate", version: null }) + await beforeTurn("s1") + expect(h.offers[0]).toMatchObject({ reason: "engine-too-old", found: "unknown" }) + }) +}) + +describe("offer is not raised when an engine is usable", () => { + test("a healthy engine never reaches the offer path", async () => { + const h = install({ surface: true, which: "/usr/local/bin/datamate", version: "0.7.0" }) + await beforeTurn("s1") + expect(h.offers).toEqual([]) + expect(h.published).toBe(0) + expect(h.printed).toEqual([]) + expect(settledOutcome("s1")?.kind).toBe("attached") + }) +}) + +describe("describeOffer — the TUI re-derives its own detail", () => { + test("describes a missing engine", async () => { + install({}) + expect(await describeOffer(DIR)).toEqual({ + reason: "engine-missing", + workspaceId: "42", + workspaceName: "analytics", + declared: 2, + command: installCommand(), + }) + }) + test("describes an engine below the floor, naming the version found", async () => { + install({ which: "/usr/local/bin/datamate", version: "0.6.3" }) + expect(await describeOffer(DIR)).toMatchObject({ reason: "engine-too-old", found: "0.6.3" }) + }) + test("returns null when an engine already clears the floor", async () => { + install({ which: "/usr/local/bin/datamate", version: "0.7.0" }) + expect(await describeOffer(DIR)).toBeNull() + }) + test("returns null when the project is not bound", async () => { + install({ bound: false }) + expect(await describeOffer(DIR)).toBeNull() + }) +}) + +describe("headless notice stream", () => { + test("the default printer writes to stderr, never stdout", async () => { + const h = install({ headless: true }) + delete syncInternals.printLine + const err = spyOn(process.stderr, "write").mockImplementation(() => true) + const out = spyOn(process.stdout, "write").mockImplementation(() => true) + try { + await beforeTurn("s1") + expect(err).toHaveBeenCalledTimes(1) + expect(String(err.mock.calls[0]?.[0])).toContain(installCommand()) + expect(out).not.toHaveBeenCalled() + } finally { + err.mockRestore() + out.mockRestore() + } + expect(h.printed).toEqual([]) + }) +}) + +describe("install deadline", () => { + test("passes an abort signal to the spawn, not just a timeout", async () => { + syncInternals.which = () => "/usr/local/bin/datamate" + syncInternals.versionOf = async () => "0.7.0" + const run = spyOn(Process, "run").mockImplementation(async (_cmd, opts) => { + expect(opts?.abort).toBeInstanceOf(AbortSignal) + return { code: 0, stdout: Buffer.from(""), stderr: Buffer.from("") } as never + }) + try { + expect(await installEngine()).toEqual({ ok: true }) + expect(run).toHaveBeenCalledTimes(1) + } finally { + run.mockRestore() + } + }) +}) + +describe("install success is verified, not assumed", () => { + test("a zero exit with the engine still absent from PATH is a failure", async () => { + syncInternals.which = () => null + const run = spyOn(Process, "run").mockImplementation( + async () => ({ code: 0, stdout: Buffer.from(""), stderr: Buffer.from("") }) as never, + ) + try { + const result = await installEngine() + expect(result.ok).toBe(false) + if (!result.ok) expect(result.error).toContain("not on PATH") + } finally { + run.mockRestore() + } + }) + test("a zero exit with a below-floor engine on PATH is a failure", async () => { + syncInternals.which = () => "/usr/local/bin/datamate" + syncInternals.versionOf = async () => "0.6.3" + const run = spyOn(Process, "run").mockImplementation( + async () => ({ code: 0, stdout: Buffer.from(""), stderr: Buffer.from("") }) as never, + ) + try { + const result = await installEngine() + expect(result.ok).toBe(false) + if (!result.ok) expect(result.error).toContain("0.6.3") + } finally { + run.mockRestore() + } + }) + test("a non-zero exit reports npm's last lines", async () => { + const run = spyOn(Process, "run").mockImplementation( + async () => ({ code: 1, stdout: Buffer.from(""), stderr: Buffer.from("boom\nEACCES denied") }) as never, + ) + try { + expect(await installEngine()).toEqual({ ok: false, error: "boom EACCES denied" }) + } finally { + run.mockRestore() + } + }) +}) diff --git a/packages/opencode/test/altimate/workspace/engine-overlay.test.ts b/packages/opencode/test/altimate/workspace/engine-overlay.test.ts index eaea653cb..9ba161ad5 100644 --- a/packages/opencode/test/altimate/workspace/engine-overlay.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-overlay.test.ts @@ -111,6 +111,9 @@ function install(opts: { h.lines.push(line) } syncInternals.now = () => h.clock + // No TUI bus in this harness, so a refusal that would raise the install + // offer falls back to the toast, which is what these tests observe. + syncInternals.publishOffer = async () => false syncInternals.mcp = { status: async () => h.live ? { datamate: { status: h.status, ...(h.statusError ? { error: h.statusError } : {}) } } : {}, @@ -246,13 +249,13 @@ describe("overlay — what the config loader gets", () => { expect(h.probes).toBe(1) resetForTests() - const missing = install({ version: "0.6.3" }) - await overlay(DIR, missing.config) - await overlay(DIR, missing.config) - expect(missing.probes).toBe(1) - missing.clock += FAILED_PROBE_TTL_MS - await overlay(DIR, missing.config) - expect(missing.probes).toBe(2) + const old = install({ version: "0.6.3" }) + await overlay(DIR, old.config) + await overlay(DIR, old.config) + expect(old.probes).toBe(1) + old.clock += FAILED_PROBE_TTL_MS + await overlay(DIR, old.config) + expect(old.probes).toBe(2) }) test("a binding read that throws leaves the config as loaded", async () => { @@ -438,7 +441,7 @@ describe("beforeTurn — what a turn boundary does", () => { await beforeTurn("s1") expect(h.invalidates).toBe(1) expect(h.added).toHaveLength(1) - expect(pinnedWorkspace(h.added[0])).toBe("7") + expect(pinnedWorkspace(h.added[0] as LocalMcpConfig)).toBe("7") expect(pinnedWorkspace(h.config.mcp!.datamate as LocalMcpConfig)).toBe("7") expect(managedWorkspace()).toEqual({ id: "7", name: "growth" }) expect(settledOutcome("s1")?.kind).toBe("attached") @@ -626,20 +629,30 @@ describe("beforeTurn — what a turn boundary does", () => { expect(h.added).toEqual([]) }) - test("an engine installed after a refusal is picked up once the probe is asked again", async () => { + test("an engine installed after a refusal is picked up at the next turn boundary", async () => { + // The install dialog runs in another module realm and cannot reach the + // probe memo, so a missing engine is looked for on PATH every turn. const h = install({ which: null }) await beforeTurn("s1") expect(settledOutcome("s1")?.kind).toBe("engine-missing") h.which = "/usr/local/bin/datamate" - // Within the TTL the failed probe is not repeated... await beforeTurn("s1") - expect(settledOutcome("s1")?.kind).toBe("engine-missing") - expect(h.added).toEqual([]) - // ...the install offer invalidates it explicitly; a later turn re-probes on its own. + expect(h.added).toHaveLength(1) + expect(pinnedWorkspace(h.added[0] as LocalMcpConfig)).toBe("42") + expect(settledOutcome("s1")?.kind).toBe("attached") + }) + + test("a too-old engine is re-probed only after the TTL, or when the probe is invalidated", async () => { + const h = install({ version: "0.6.3" }) + await beforeTurn("s1") + expect(h.probes).toBe(1) + h.version = "0.7.0" + await beforeTurn("s1") + expect(h.probes).toBe(1) + expect(settledOutcome("s1")?.kind).toBe("engine-too-old") invalidateProbe() await beforeTurn("s1") - expect(h.added).toHaveLength(1) - expect(pinnedWorkspace(h.added[0])).toBe("42") + expect(h.probes).toBe(2) expect(settledOutcome("s1")?.kind).toBe("attached") }) From a5a563a0157512f587f89ac41c1ab485ac348d25 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Fri, 28 Aug 2026 05:32:58 +0800 Subject: [PATCH 02/10] fix: let the install offer return in a session that outlives the Not-now latch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `announceRefusal` deduped the offer once per session per verdict, so a session open for longer than the 7-day "Not now" latch never saw the offer again — the TUI's latch check was unreachable. The offer route's dedupe now expires on the same TTL, `OFFER_SKIP_TTL_MS`, defined once in `engine-offer` and shared with the plugin's latch. Toast-only refusals keep "once per session". --- .../src/altimate/workspace/engine-offer.ts | 4 +++ .../src/altimate/workspace/engine-overlay.ts | 27 ++++++++++++++----- .../src/plugin/tui/altimate/workspace.tsx | 6 +++-- .../workspace/engine-install-offer.test.ts | 18 +++++++++++++ 4 files changed, 47 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/engine-offer.ts b/packages/opencode/src/altimate/workspace/engine-offer.ts index 955171898..159952fc6 100644 --- a/packages/opencode/src/altimate/workspace/engine-offer.ts +++ b/packages/opencode/src/altimate/workspace/engine-offer.ts @@ -28,6 +28,10 @@ export const INSTALL_TIMEOUT_MS = 300_000 * `CommandExecute` carries no payload, so the plugin re-derives the offer * with `describeOffer()`. */ export const OFFER_COMMAND = "altimate.workspace.engineInstallOffer" +/** How long "Not now" silences the offer for a workspace. The TUI latch and + * the per-session announce dedupe both key on this, so a session that + * outlives the latch sees the offer again instead of waiting for a new one. */ +export const OFFER_SKIP_TTL_MS = 7 * 24 * 60 * 60 * 1000 /** A "no usable engine" state, described well enough for an interactive * surface to act on it without re-deriving anything. */ diff --git a/packages/opencode/src/altimate/workspace/engine-overlay.ts b/packages/opencode/src/altimate/workspace/engine-overlay.ts index 6e25ed171..c4dba8030 100644 --- a/packages/opencode/src/altimate/workspace/engine-overlay.ts +++ b/packages/opencode/src/altimate/workspace/engine-overlay.ts @@ -36,7 +36,7 @@ import { type ScopedBinding, } from "./engine-seams" import { declaredBounded, notify, printLine, resolveBinding, versionOf, which } from "./engine-probes" -import { installCommand, offerOrNotify, type EngineOffer } from "./engine-offer" +import { OFFER_SKIP_TTL_MS, installCommand, offerOrNotify, type EngineOffer } from "./engine-offer" import { ENGINE_BINARY, INSTALL_HELPS, @@ -265,14 +265,19 @@ export async function managedWorkspaceLoaded( /** `retried`: this session already spent its one re-add on a failed handshake. * Per session, so "start a new session to try again" is true. */ -type SessionRecord = { outcome: Outcome; announced?: string; retried?: boolean } +type SessionRecord = { outcome: Outcome; announced?: string; announcedAt?: number; retried?: boolean } const sessions = new Map() const declaredCache = new Map() function record(sessionID: string, outcome: Outcome): SessionRecord { const previous = sessions.get(sessionID) sessions.delete(sessionID) - const next: SessionRecord = { outcome, announced: previous?.announced, retried: previous?.retried } + const next: SessionRecord = { + outcome, + announced: previous?.announced, + announcedAt: previous?.announcedAt, + retried: previous?.retried, + } sessions.set(sessionID, next) while (sessions.size > MAX_TRACKED_SESSIONS) { const oldest = sessions.keys().next().value @@ -609,7 +614,11 @@ async function reconcile(sessionID: string, directory: string, state: DirectoryS * The substitution point for the install offer: when installing would help * and an `offer` is supplied, the offer surface (dialog, headless line, or * toast fallback) replaces the toast — never adds to it. Otherwise headless - * `run` prints one stderr line and the TUI gets the toast. */ + * `run` prints one stderr line and the TUI gets the toast. + * + * The offer route's "once" expires with the "Not now" latch: a session that + * stays open past `OFFER_SKIP_TTL_MS` is offered again, so the latch (which + * the TUI checks on every offer) decides, not the age of the session. */ export async function announceRefusal( sessionID: string, outcome: Outcome, @@ -620,9 +629,15 @@ export async function announceRefusal( const detail = "error" in outcome ? outcome.error : "found" in outcome ? String(outcome.found) : "" const declared = "declared" in outcome ? String(outcome.declared ?? "?") : "" const signature = `${outcome.kind}:${detail}:${declared}:${toast.title}` - if (rec.announced === signature) return + const offering = !!offer && INSTALL_HELPS[outcome.kind] + const at = now() + if (rec.announced === signature) { + const expired = offering && rec.announcedAt !== undefined && at - rec.announcedAt >= OFFER_SKIP_TTL_MS + if (!expired) return + } rec.announced = signature - if (offer && INSTALL_HELPS[outcome.kind]) { + rec.announcedAt = at + if (offering) { await offerOrNotify(offer, toast) return } diff --git a/packages/opencode/src/plugin/tui/altimate/workspace.tsx b/packages/opencode/src/plugin/tui/altimate/workspace.tsx index 201f9b34e..969f905b2 100644 --- a/packages/opencode/src/plugin/tui/altimate/workspace.tsx +++ b/packages/opencode/src/plugin/tui/altimate/workspace.tsx @@ -57,6 +57,7 @@ import { npmAvailable, MIN_NODE_MAJOR, OFFER_COMMAND, + OFFER_SKIP_TTL_MS, type EngineOffer, } from "@/altimate/workspace/engine-offer" import { useClipboard } from "@opencode-ai/tui/context/clipboard" @@ -1176,7 +1177,8 @@ function engineSkipKey(workspaceId: string, scope: LatchScope | null): string { ) } -/** Same 7-day TTL and clock-rewind handling as the post-scan latch. */ +/** Same clock-rewind handling as the post-scan latch; the TTL is the one the + * attach side's announce dedupe expires on, so both agree on "7 days". */ function isEngineSkipActive( api: TuiPluginApi, workspaceId: string, @@ -1187,7 +1189,7 @@ function isEngineSkipActive( if (!rec || typeof rec.skippedAt !== "number") return false const delta = nowMs - rec.skippedAt if (delta < 0) return false - return delta < SKIP_TTL_MS + return delta < OFFER_SKIP_TTL_MS } function recordEngineSkip( 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 33e2e3fb2..2328c15bb 100644 --- a/packages/opencode/test/altimate/workspace/engine-install-offer.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-install-offer.test.ts @@ -20,6 +20,7 @@ import { type EngineOffer, type Toast, } from "../../../src/altimate/workspace/engine-overlay" +import { OFFER_SKIP_TTL_MS } from "../../../src/altimate/workspace/engine-offer" import { Process } from "../../../src/util/process" import type { CachedBinding } from "../../../src/altimate/workspace/state" @@ -185,6 +186,23 @@ describe("offer routing — engine missing", () => { await beforeTurn("s2") expect(h.published).toBe(2) }) + test("a session that outlives the Not-now latch is offered again", async () => { + // The TUI re-checks its 7-day latch on every offer; the dedupe here must + // not outlast that latch, or a long-lived session never sees the offer + // return after "Not now" expires. + let clock = 1_000_000 + syncInternals.now = () => clock + const h = install({}) + await beforeTurn("s1") + clock += OFFER_SKIP_TTL_MS - 1 + await beforeTurn("s1") + expect(h.published).toBe(1) + clock += 1 + await beforeTurn("s1") + expect(h.published).toBe(2) + await beforeTurn("s1") + expect(h.published).toBe(2) + }) }) describe("offer routing — engine too old", () => { From 63f4e7aab3c1b5ad09d34fd39383524489d202b3 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Fri, 28 Aug 2026 05:45:45 +0800 Subject: [PATCH 03/10] fix: re-probe a too-old engine as soon as the file on PATH changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The too-old probe memo was honoured for 30s by path alone, so an engine updated in place by the offer's `npm i -g` stayed refused on the next message — and nothing could invalidate the memo, since the offer runs in another module realm. The memo now also carries the binary's fingerprint (size + mtime, symlinks followed) and ends when it changes; an un-stat-able binary falls back to the TTL. --- .../src/altimate/workspace/engine-overlay.ts | 28 +++++++++++----- .../src/altimate/workspace/engine-probes.ts | 15 +++++++++ .../src/altimate/workspace/engine-seams.ts | 1 + .../altimate/workspace/engine-overlay.test.ts | 33 +++++++++++++++++++ 4 files changed, 69 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/engine-overlay.ts b/packages/opencode/src/altimate/workspace/engine-overlay.ts index c4dba8030..62c1b1380 100644 --- a/packages/opencode/src/altimate/workspace/engine-overlay.ts +++ b/packages/opencode/src/altimate/workspace/engine-overlay.ts @@ -35,7 +35,7 @@ import { syncInternals, type ScopedBinding, } from "./engine-seams" -import { declaredBounded, notify, printLine, resolveBinding, versionOf, which } from "./engine-probes" +import { declaredBounded, fingerprint, notify, printLine, resolveBinding, versionOf, which } from "./engine-probes" import { OFFER_SKIP_TTL_MS, installCommand, offerOrNotify, type EngineOffer } from "./engine-offer" import { ENGINE_BINARY, @@ -72,7 +72,7 @@ const DECLARED_RETRY_MS = 60_000 type Probe = { kind: "ok"; version: string } | { kind: "missing" } | { kind: "too-old"; found: string | null } -let probeMemo: { result: Probe; at: number } | null = null +let probeMemo: { result: Probe; at: number; fingerprint: string | null } | null = null function now(): number { return syncInternals.now ? syncInternals.now() : Date.now() @@ -85,9 +85,19 @@ async function probeEngine(): Promise { // about on every call — `which` is a PATH scan, no process spawn — so an // install made from the offer dialog (which runs in another module realm and // cannot reach this memo) is seen on the next turn. A too-old or broken one - // costs a spawn to re-check, so that is rate-limited by the TTL. + // costs a spawn to re-check, so that is rate-limited by the TTL — but only + // while the file on PATH is the same one: an update written over it (the + // offer's `npm i -g` on an old engine) changes the fingerprint and is + // re-probed on the next turn, just as an install is. + const seen = bin ? fingerprint(bin) : null if (probeMemo && probeMemo.result.kind === "ok") return probeMemo.result - if (probeMemo && probeMemo.result.kind === "too-old" && bin && at - probeMemo.at < FAILED_PROBE_TTL_MS) { + if ( + probeMemo && + probeMemo.result.kind === "too-old" && + bin && + probeMemo.fingerprint === seen && + at - probeMemo.at < FAILED_PROBE_TTL_MS + ) { return probeMemo.result } let result: Probe @@ -97,12 +107,14 @@ async function probeEngine(): Promise { const version = await versionOf(bin) result = clearsFloor(version) ? { kind: "ok", version: version! } : { kind: "too-old", found: version } } - probeMemo = { result, at } + probeMemo = { result, at, fingerprint: seen } return result } /** Forget the last probe, so the next turn boundary looks for the engine - * again immediately. The install offer calls this after an install. */ + * again immediately. Nothing in production calls this — the offer dialog runs + * in another module realm — which is why the probe itself notices an engine + * that appeared or changed on PATH. Kept for tests and diagnostics. */ export function invalidateProbe(): void { probeMemo = null } @@ -649,8 +661,8 @@ export async function announceRefusal( } /** Is a re-probe worth asking for on the next turn? Exposed for the install - * offer, which schedules nothing itself: it installs, invalidates the probe, - * and the next turn boundary attaches. */ + * offer, which schedules nothing itself: it installs, and the next turn + * boundary sees the new or changed binary on PATH and attaches. */ export function isRepairable(outcome: Outcome | undefined): boolean { return !!outcome && REPAIRABLE[outcome.kind] } diff --git a/packages/opencode/src/altimate/workspace/engine-probes.ts b/packages/opencode/src/altimate/workspace/engine-probes.ts index 1ca22bb5b..fb4656d8b 100644 --- a/packages/opencode/src/altimate/workspace/engine-probes.ts +++ b/packages/opencode/src/altimate/workspace/engine-probes.ts @@ -2,6 +2,7 @@ // // Everything that asks the outside world a question: the binary, its // version, the workspace allowlist, and the user-facing surfaces. +import { statSync } from "fs" import launch from "cross-spawn" import { which as whichBinary } from "@opencode-ai/core/util/which" import { AltimateApi } from "@/altimate/api/client" @@ -34,6 +35,20 @@ export function which(cmd: string): string | null { return syncInternals.which ? syncInternals.which(cmd) : whichBinary(cmd) } +/** Identity of the file behind a PATH hit, cheap enough to ask every turn: + * size and mtime of the target (symlinks followed, so an npm bin shim whose + * package was reinstalled reads as changed). Null when it cannot be stat'ed; + * with nothing to compare, the caller's memo falls back to its TTL. */ +export function fingerprint(bin: string): string | null { + if (syncInternals.fingerprint) return syncInternals.fingerprint(bin) + try { + const stat = statSync(bin) + return `${stat.size}:${stat.mtimeMs}` + } catch { + return null + } +} + /** `datamate --version`, stdout only. The engine prints its real package * version here; its MCP `serverInfo` was a hard-coded placeholder on the very * engines the floor excludes, so the handshake cannot be asked instead. diff --git a/packages/opencode/src/altimate/workspace/engine-seams.ts b/packages/opencode/src/altimate/workspace/engine-seams.ts index bec912e0d..78234349d 100644 --- a/packages/opencode/src/altimate/workspace/engine-seams.ts +++ b/packages/opencode/src/altimate/workspace/engine-seams.ts @@ -20,6 +20,7 @@ export const syncInternals: { resolveBinding?: (directory: string) => Promise which?: (cmd: string) => string | null versionOf?: (bin: string) => Promise + fingerprint?: (bin: string) => string | null declared?: (workspaceId: string) => Promise notify?: (toast: Toast) => Promise printLine?: (line: string) => void diff --git a/packages/opencode/test/altimate/workspace/engine-overlay.test.ts b/packages/opencode/test/altimate/workspace/engine-overlay.test.ts index 9ba161ad5..ed78236b1 100644 --- a/packages/opencode/test/altimate/workspace/engine-overlay.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-overlay.test.ts @@ -54,6 +54,7 @@ type Harness = { /** Whether MCP holds a client under the key — set when MCP "bootstraps" from * the first config load, then tracked through add/remove, as in the runtime. */ live?: boolean + fingerprint: string | null } function install(opts: { @@ -89,6 +90,7 @@ function install(opts: { toasts: [], lines: [], clock: 1_000_000, + fingerprint: "bin-1", } process.env.ALTIMATE_WORKSPACE = opts.flag === false ? "" : "1" syncInternals.serve = () => opts.serve === true @@ -111,6 +113,7 @@ function install(opts: { h.lines.push(line) } syncInternals.now = () => h.clock + syncInternals.fingerprint = () => h.fingerprint // No TUI bus in this harness, so a refusal that would raise the install // offer falls back to the toast, which is what these tests observe. syncInternals.publishOffer = async () => false @@ -656,6 +659,36 @@ describe("beforeTurn — what a turn boundary does", () => { expect(settledOutcome("s1")?.kind).toBe("attached") }) + test("a too-old engine updated in place is re-probed on the next turn, inside the TTL", async () => { + // The offer's install writes the new engine over the old one at the same + // PATH entry, from another module realm that cannot invalidate this memo. + // The file's fingerprint changing is what ends the memo, not the clock. + const h = install({ version: "0.6.3" }) + await beforeTurn("s1") + expect(h.probes).toBe(1) + expect(settledOutcome("s1")?.kind).toBe("engine-too-old") + h.version = "0.7.0" + h.fingerprint = "bin-2" + h.clock += 1_000 + await beforeTurn("s1") + expect(h.probes).toBe(2) + expect(settledOutcome("s1")?.kind).toBe("attached") + // A binary that cannot be stat'ed has no fingerprint to compare, so the + // memo falls back to its TTL alone — never a spawn on every turn. + resetForTests() + const u = install({ version: "0.6.3" }) + u.fingerprint = null + await beforeTurn("s1") + u.version = "0.7.0" + u.clock += 1_000 + await beforeTurn("s1") + expect(u.probes).toBe(1) + u.clock += FAILED_PROBE_TTL_MS + await beforeTurn("s1") + expect(u.probes).toBe(2) + expect(settledOutcome("s1")?.kind).toBe("attached") + }) + test("a failed probe is repeated on its own after the TTL", async () => { const h = install({ version: "0.6.3" }) await beforeTurn("s1") From f945a98065ae10246babbf69c582160f09568529 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Fri, 28 Aug 2026 06:06:31 +0800 Subject: [PATCH 04/10] fix: re-raise the offer hourly past the latch window; scope the attached-run line to its session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The attach-side dedupe expired seven days after the offer was raised, but the TUI latch runs from "Not now", which can come later — a long-lived session could wait a second full window. Past the window the offer is now re-raised every `OFFER_RECHECK_MS` and the TUI suppresses it until its latch ends. The offer command now carries the session it was raised for, so an attached headless run prints only its own offer, not another session's in the directory. --- .../src/altimate/workspace/engine-offer.ts | 21 ++++++++++++----- .../src/altimate/workspace/engine-overlay.ts | 14 +++++++---- .../src/altimate/workspace/engine-seams.ts | 2 +- packages/opencode/src/cli/cmd/run.ts | 5 +++- packages/opencode/src/server/tui-event.ts | 6 +++++ .../workspace/engine-install-offer.test.ts | 23 +++++++++++++++---- 6 files changed, 54 insertions(+), 17 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/engine-offer.ts b/packages/opencode/src/altimate/workspace/engine-offer.ts index 159952fc6..500a75691 100644 --- a/packages/opencode/src/altimate/workspace/engine-offer.ts +++ b/packages/opencode/src/altimate/workspace/engine-offer.ts @@ -32,6 +32,11 @@ export const OFFER_COMMAND = "altimate.workspace.engineInstallOffer" * the per-session announce dedupe both key on this, so a session that * outlives the latch sees the offer again instead of waiting for a new one. */ export const OFFER_SKIP_TTL_MS = 7 * 24 * 60 * 60 * 1000 +/** Once a session's offer is older than the latch, how often it is raised + * again while the verdict stands. The TUI's latch starts when "Not now" is + * chosen, not when the offer was raised, so the attach side cannot know when + * it ends: it re-raises at this cadence and the TUI suppresses until then. */ +export const OFFER_RECHECK_MS = 60 * 60 * 1000 /** A "no usable engine" state, described well enough for an interactive * surface to act on it without re-deriving anything. */ @@ -156,12 +161,16 @@ export async function installEngine(): Promise { } } -/** Ask the TUI to raise the offer. False when the bus is unavailable. */ -async function publishOffer(): Promise { - if (syncInternals.publishOffer) return syncInternals.publishOffer() +/** Ask the TUI to raise the offer. False when the bus is unavailable. The + * session is carried so an attached headless run, which reads the same event + * stream, prints the offer raised for its own session only. */ +async function publishOffer(sessionID: string): Promise { + if (syncInternals.publishOffer) return syncInternals.publishOffer(sessionID) try { await AppRuntime.runPromise( - EventV2Bridge.Service.use((events) => events.publish(TuiEvent.CommandExecute, { command: OFFER_COMMAND })), + EventV2Bridge.Service.use((events) => + events.publish(TuiEvent.CommandExecute, { command: OFFER_COMMAND, sessionID }), + ), ) return true } catch (err) { @@ -192,12 +201,12 @@ export function describeOfferLine(offer: EngineOffer): string { /** Offer via the dialog surface when there is one; otherwise print (headless) * or toast (bus unavailable). Exactly one of these happens. */ -export async function offerOrNotify(offer: EngineOffer, toast: Toast): Promise { +export async function offerOrNotify(offer: EngineOffer, toast: Toast, sessionID: string): Promise { if (isHeadless()) { printLine(describeOfferLine(offer)) return } if (offerInstall(offer)) return - if (await publishOffer()) return + if (await publishOffer(sessionID)) return await notify(toast) } diff --git a/packages/opencode/src/altimate/workspace/engine-overlay.ts b/packages/opencode/src/altimate/workspace/engine-overlay.ts index 62c1b1380..9a81db83d 100644 --- a/packages/opencode/src/altimate/workspace/engine-overlay.ts +++ b/packages/opencode/src/altimate/workspace/engine-overlay.ts @@ -36,7 +36,7 @@ import { type ScopedBinding, } from "./engine-seams" import { declaredBounded, fingerprint, notify, printLine, resolveBinding, versionOf, which } from "./engine-probes" -import { OFFER_SKIP_TTL_MS, installCommand, offerOrNotify, type EngineOffer } from "./engine-offer" +import { OFFER_RECHECK_MS, OFFER_SKIP_TTL_MS, installCommand, offerOrNotify, type EngineOffer } from "./engine-offer" import { ENGINE_BINARY, INSTALL_HELPS, @@ -630,7 +630,11 @@ async function reconcile(sessionID: string, directory: string, state: DirectoryS * * The offer route's "once" expires with the "Not now" latch: a session that * stays open past `OFFER_SKIP_TTL_MS` is offered again, so the latch (which - * the TUI checks on every offer) decides, not the age of the session. */ + * the TUI checks on every offer) decides, not the age of the session. The + * latch is measured from the user's "Not now", which can come well after the + * offer was raised, so after the first expiry the offer is re-raised every + * `OFFER_RECHECK_MS` rather than once per further window — the TUI keeps + * suppressing it until its latch really ends. */ export async function announceRefusal( sessionID: string, outcome: Outcome, @@ -643,14 +647,16 @@ export async function announceRefusal( const signature = `${outcome.kind}:${detail}:${declared}:${toast.title}` const offering = !!offer && INSTALL_HELPS[outcome.kind] const at = now() + let repeat = false if (rec.announced === signature) { const expired = offering && rec.announcedAt !== undefined && at - rec.announcedAt >= OFFER_SKIP_TTL_MS if (!expired) return + repeat = true } rec.announced = signature - rec.announcedAt = at + rec.announcedAt = repeat ? at - OFFER_SKIP_TTL_MS + OFFER_RECHECK_MS : at if (offering) { - await offerOrNotify(offer, toast) + await offerOrNotify(offer, toast, sessionID) return } if (isHeadless()) { diff --git a/packages/opencode/src/altimate/workspace/engine-seams.ts b/packages/opencode/src/altimate/workspace/engine-seams.ts index 78234349d..c885beae1 100644 --- a/packages/opencode/src/altimate/workspace/engine-seams.ts +++ b/packages/opencode/src/altimate/workspace/engine-seams.ts @@ -26,7 +26,7 @@ export const syncInternals: { printLine?: (line: string) => void /** Install-offer seams (see engine-offer.ts). */ offer?: (offer: EngineOffer) => boolean - publishOffer?: () => Promise + publishOffer?: (sessionID: string) => Promise nodeMajor?: () => Promise npmAvailable?: () => boolean install?: (spec: string) => Promise diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index bf86d46ae..f8fd0492f 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -761,9 +761,12 @@ You are speaking to a non-technical business executive. Follow these rules stric // which has no handler for it, is the only thing the user is looking at. // Placed before the idle break: the loop stops on idle, so a handler // after it never runs for an offer that arrives in the same batch. + // The stream carries every session's events for this directory; only + // the offer raised for this run's session is this run's to print. if ( event.type === "tui.command.execute" && - (event.properties as { command?: string }).command === OFFER_COMMAND + (event.properties as { command?: string }).command === OFFER_COMMAND && + (event.properties as { sessionID?: string }).sessionID === sessionID ) { // stderr: stdout is raw JSON events under --format json. process.stderr.write( diff --git a/packages/opencode/src/server/tui-event.ts b/packages/opencode/src/server/tui-event.ts index 73412b877..93d3f7e6d 100644 --- a/packages/opencode/src/server/tui-event.ts +++ b/packages/opencode/src/server/tui-event.ts @@ -31,6 +31,12 @@ export const TuiEvent = { ]), Schema.String, ]), + // altimate_change start — the workspace engine install offer is published + // as a command for the TUI plugin, and an attached headless run reads the + // same stream: the session it was raised for lets that run print only its + // own offer, not another session's in the same directory. + sessionID: Schema.optional(Schema.String), + // altimate_change end }, }), ToastShow: EventV2.define({ 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 2328c15bb..439d9ea56 100644 --- a/packages/opencode/test/altimate/workspace/engine-install-offer.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-install-offer.test.ts @@ -20,7 +20,7 @@ import { type EngineOffer, type Toast, } from "../../../src/altimate/workspace/engine-overlay" -import { OFFER_SKIP_TTL_MS } from "../../../src/altimate/workspace/engine-offer" +import { OFFER_RECHECK_MS, OFFER_SKIP_TTL_MS } from "../../../src/altimate/workspace/engine-offer" import { Process } from "../../../src/util/process" import type { CachedBinding } from "../../../src/altimate/workspace/state" @@ -36,7 +36,7 @@ const binding: CachedBinding = { linkedAt: 0, } as CachedBinding -type Harness = { offers: EngineOffer[]; toasts: Toast[]; printed: string[]; published: number } +type Harness = { offers: EngineOffer[]; toasts: Toast[]; printed: string[]; published: number; publishedFor: string[] } /** No engine on PATH (or an old one) plus captured surfaces. */ function install(opts: { @@ -48,7 +48,7 @@ function install(opts: { surface?: boolean bound?: boolean }): Harness { - const h: Harness = { offers: [], toasts: [], printed: [], published: 0 } + const h: Harness = { offers: [], toasts: [], printed: [], published: 0, publishedFor: [] } process.env.ALTIMATE_WORKSPACE = "1" syncInternals.serve = () => false syncInternals.headless = () => opts.headless === true @@ -66,9 +66,10 @@ function install(opts: { syncInternals.printLine = (line) => { h.printed.push(line) } - syncInternals.publishOffer = async () => { + syncInternals.publishOffer = async (sessionID) => { if (opts.bus === false) return false h.published += 1 + h.publishedFor.push(sessionID) return true } if (opts.surface) { @@ -178,13 +179,16 @@ describe("offer routing — engine missing", () => { await beforeTurn("s1") expect(h.printed[0]).toContain("1 integration tool need") }) - test("the offer is raised once per session per verdict", async () => { + test("the offer is raised once per session per verdict, naming the session it is for", async () => { const h = install({}) await beforeTurn("s1") await beforeTurn("s1") expect(h.published).toBe(1) await beforeTurn("s2") expect(h.published).toBe(2) + // An attached headless run reads every session's events for the directory + // and prints only the offer raised for its own session. + expect(h.publishedFor).toEqual(["s1", "s2"]) }) test("a session that outlives the Not-now latch is offered again", async () => { // The TUI re-checks its 7-day latch on every offer; the dedupe here must @@ -202,6 +206,15 @@ describe("offer routing — engine missing", () => { expect(h.published).toBe(2) await beforeTurn("s1") expect(h.published).toBe(2) + // The latch runs from "Not now", which may come long after the offer was + // raised, so once the window has passed the offer is re-raised hourly — + // never held for another full window. + clock += OFFER_RECHECK_MS - 1 + await beforeTurn("s1") + expect(h.published).toBe(2) + clock += 1 + await beforeTurn("s1") + expect(h.published).toBe(3) }) }) From bd50331ae6423f347f181bb1eb80b2fddb8ee0cf Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Fri, 28 Aug 2026 06:25:51 +0800 Subject: [PATCH 05/10] fix: print the headless refusal line once per process, not once per session A headless `run` whose agent uses the task tool creates a child session, and that session's first catalog settled the same verdict and printed the same stderr line again. The headless line is now deduplicated per verdict across the process; sessions still track their own announcement for the toast and offer surfaces. --- .../opencode/src/altimate/workspace/engine-overlay.ts | 10 ++++++++++ .../altimate/workspace/engine-install-offer.test.ts | 9 +++++++++ 2 files changed, 19 insertions(+) diff --git a/packages/opencode/src/altimate/workspace/engine-overlay.ts b/packages/opencode/src/altimate/workspace/engine-overlay.ts index 9a81db83d..d9a6931b0 100644 --- a/packages/opencode/src/altimate/workspace/engine-overlay.ts +++ b/packages/opencode/src/altimate/workspace/engine-overlay.ts @@ -280,6 +280,8 @@ export async function managedWorkspaceLoaded( type SessionRecord = { outcome: Outcome; announced?: string; announcedAt?: number; retried?: boolean } const sessions = new Map() const declaredCache = new Map() +/** Verdict signatures a headless process has already printed to stderr. */ +const headlessPrinted = new Set() function record(sessionID: string, outcome: Outcome): SessionRecord { const previous = sessions.get(sessionID) @@ -655,6 +657,13 @@ export async function announceRefusal( } rec.announced = signature rec.announcedAt = repeat ? at - OFFER_SKIP_TTL_MS + OFFER_RECHECK_MS : at + if (isHeadless()) { + // A headless `run` is one process with one stderr, whatever sessions it + // creates along the way (a sub-agent's session settles the same verdict + // and would print the same line). One line per verdict per process. + if (headlessPrinted.has(signature)) return + headlessPrinted.add(signature) + } if (offering) { await offerOrNotify(offer, toast, sessionID) return @@ -680,6 +689,7 @@ export function resetForTests(): void { sessions.clear() turnTools.clear() declaredCache.clear() + headlessPrinted.clear() } /** Test-only views. */ 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 439d9ea56..1797faaa2 100644 --- a/packages/opencode/test/altimate/workspace/engine-install-offer.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-install-offer.test.ts @@ -241,6 +241,15 @@ describe("offer routing — engine too old", () => { `Workspace "analytics": 2 integration tools need ${ENGINE_BINARY} ${MIN_ENGINE_VERSION}+ (found 0.6.3). Update with: ${installCommand()}`, ]) }) + test("headless, a sub-agent's session in the same process prints nothing more", async () => { + // One `run` is one process with one stderr: the task tool's child session + // settles the same verdict and must not repeat the line. + const h = install({ headless: true, which: "/usr/local/bin/datamate", version: "0.6.3" }) + await beforeTurn("parent") + await beforeTurn("child") + await beforeTurn("parent") + expect(h.printed).toHaveLength(1) + }) test("a broken engine reports 'unknown' rather than a version", async () => { const h = install({ surface: true, which: "/usr/local/bin/datamate", version: null }) await beforeTurn("s1") From edc94cf674ad489dc51b70e8def09219a1f01c55 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Fri, 28 Aug 2026 06:34:46 +0800 Subject: [PATCH 06/10] fix: wait for the TUI kv store to hydrate before consulting the Not-now latch The store is empty until kv.json has been read, so an offer raised on the first message after a restart read a persisted "Not now" as absent and showed the dialog inside the seven days. `api.kv.ready` is a plain getter, so the offer polls it (25 ms, bounded at 3 s) and on timeout proceeds as before. --- .../src/plugin/tui/altimate/workspace.tsx | 26 +++++++++++++- .../test/altimate/plugin/workspace.test.ts | 36 ++++++++++++++++++- 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/plugin/tui/altimate/workspace.tsx b/packages/opencode/src/plugin/tui/altimate/workspace.tsx index 969f905b2..40214bfd1 100644 --- a/packages/opencode/src/plugin/tui/altimate/workspace.tsx +++ b/packages/opencode/src/plugin/tui/altimate/workspace.tsx @@ -1177,6 +1177,27 @@ function engineSkipKey(workspaceId: string, scope: LatchScope | null): string { ) } +/** The KV store starts empty and fills in once the persisted file is read + * (`api.kv.ready`). A latch checked before that reads as absent, so an offer + * raised on the first message after a restart would ignore a "Not now" that + * is still in force. `ready` is a plain getter with nothing to await, so poll + * it — bounded, and on timeout proceed as if hydrated rather than never + * answer. Resolves to whether the store was ready. */ +const KV_READY_TIMEOUT_MS = 3_000 +const KV_READY_POLL_MS = 25 +async function awaitKvReady( + kv: { readonly ready: boolean }, + timeoutMs = KV_READY_TIMEOUT_MS, + pollMs = KV_READY_POLL_MS, +): Promise { + const deadline = Date.now() + timeoutMs + while (!kv.ready) { + if (Date.now() >= deadline) return false + await new Promise((resolve) => setTimeout(resolve, pollMs)) + } + return true +} + /** Same clock-rewind handling as the post-scan latch; the TTL is the one the * attach side's announce dedupe expires on, so both agree on "7 days". */ function isEngineSkipActive( @@ -1486,6 +1507,9 @@ async function showEngineInstallOffer(api: TuiPluginApi): Promise { // engine appeared, or the project is no longer bound. Say nothing. if (!offer) return release() const latchScope = await currentLatchScope() + if (!(await awaitKvReady(api.kv))) { + log.warn("kv store not hydrated in time; checking the engine install latch against what is loaded") + } if (isEngineSkipActive(api, offer.workspaceId, latchScope, Date.now())) { log.info("engine install offer suppressed by 7-day latch", { workspaceId: offer.workspaceId }) return release() @@ -1571,5 +1595,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, isEngineSkipActive, recordEngineSkip } +export { isSkipActive, recordSkip, isEngineSkipActive, recordEngineSkip, awaitKvReady } // altimate_change end diff --git a/packages/opencode/test/altimate/plugin/workspace.test.ts b/packages/opencode/test/altimate/plugin/workspace.test.ts index 391b92697..d4cf5abfe 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, isEngineSkipActive, recordEngineSkip } = await import( +const { isSkipActive, recordSkip, isEngineSkipActive, recordEngineSkip, awaitKvReady } = await import( "../../../src/plugin/tui/altimate/workspace" ) const { projectNameFromRemote, detectProjectRemote } = await import( @@ -532,3 +532,37 @@ describe("Engine install-offer latch", () => { expect(isEngineSkipActive(api, workspaceId, scope, now + DAY)).toBe(false) }) }) + +describe("engine install offer — kv hydration", () => { + // The store starts empty until kv.json has been read; a "Not now" latch + // checked before that reads as absent. The offer waits for `ready`. + test("waits for the store to hydrate before the latch is consulted", async () => { + let ready = false + setTimeout(() => { + ready = true + }, 60) + const t0 = Date.now() + expect( + await awaitKvReady( + { + get ready() { + return ready + }, + }, + 1_000, + 5, + ), + ).toBe(true) + expect(Date.now() - t0).toBeGreaterThanOrEqual(50) + }) + test("returns at once when the store is already hydrated", async () => { + const t0 = Date.now() + expect(await awaitKvReady({ ready: true }, 1_000, 5)).toBe(true) + expect(Date.now() - t0).toBeLessThan(50) + }) + test("gives up after the timeout so a stuck read never blocks the offer", async () => { + const t0 = Date.now() + expect(await awaitKvReady({ ready: false }, 40, 5)).toBe(false) + expect(Date.now() - t0).toBeGreaterThanOrEqual(35) + }) +}) From 5c8587fd7c81798cfefae3c9ee4f67733fb5a5a5 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Fri, 28 Aug 2026 06:47:22 +0800 Subject: [PATCH 07/10] fix: give the engine install a real deadline over npm's whole process tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Process.run` settles on `close`, and the abort only signalled npm's own pid, so a descendant that kept the stderr pipe open held `installEngine` past the five minutes and the dialog stayed on "Installing…". The install now runs in its own process group, settles on the child's `exit`, and at the deadline signals the group — SIGTERM, then SIGKILL after a grace — before reporting the timeout. --- .../src/altimate/workspace/engine-offer.ts | 140 +++++++++++++----- .../src/altimate/workspace/engine-seams.ts | 4 + .../workspace/engine-install-offer.test.ts | 73 ++++----- 3 files changed, 140 insertions(+), 77 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/engine-offer.ts b/packages/opencode/src/altimate/workspace/engine-offer.ts index 500a75691..8c4cc698b 100644 --- a/packages/opencode/src/altimate/workspace/engine-offer.ts +++ b/packages/opencode/src/altimate/workspace/engine-offer.ts @@ -7,7 +7,8 @@ // the detail here from disk and PATH rather than from the overlay's memory. // Offer, never install on the flow's own account — `installEngine` only ever // runs from an explicit "Install now". -import { execFile } from "node:child_process" +import { execFile, type ChildProcess } from "node:child_process" +import launch from "cross-spawn" import { Process } from "@/util/process" import { AppRuntime } from "@/effect/app-runtime" import { EventV2Bridge } from "@/event-v2-bridge" @@ -117,48 +118,117 @@ export function npmAvailable(): boolean { return which(process.platform === "win32" ? "npm.cmd" : "npm") !== null } -/** Run the global install. Only ever reached from an explicit user choice. - * - * `npm.cmd` on Windows: a normal Node install exposes npm as a command shim, - * and nothing here spawns a shell. The deadline comes from an abort signal: - * `Process.spawn` consults `timeout` only inside its abort handler. A zero - * exit is not a usable engine — npm's global bin directory need not be on - * PATH — so the same discovery the turn boundary does runs before success. */ +/** Options are the process-group and deadline handling for the one command + * the offer runs. Nothing here spawns a shell. */ +export type InstallRun = { code: number | null; timedOut: boolean; stderr: string } +/** After the deadline's SIGTERM, how long the tree gets before SIGKILL and the + * run is reported as timed out regardless of what is still alive. */ +export const INSTALL_KILL_GRACE_MS = 5_000 + +/** Run the install command with a real deadline. npm forks a tree (scripts, + * node), and a descendant that outlives npm can keep the stderr pipe open, so + * the run settles on the child's `exit`, never on `close`, and the deadline + * signals the whole process group (POSIX: the child is its own group leader; + * Windows: taskkill /T) — SIGTERM first, SIGKILL after the grace, then the run + * reports the timeout whether or not anything is still holding a pipe. */ +export function runInstall( + argv: string[], + timeoutMs = INSTALL_TIMEOUT_MS, + graceMs = INSTALL_KILL_GRACE_MS, +): Promise { + if (syncInternals.runInstall) return syncInternals.runInstall(argv, timeoutMs) + return new Promise((resolve) => { + const grouped = process.platform !== "win32" + let child: ChildProcess + try { + child = launch(argv[0], argv.slice(1), { + stdio: ["ignore", "ignore", "pipe"], + detached: grouped, + windowsHide: process.platform === "win32", + }) + } catch (err) { + resolve({ code: null, timedOut: false, stderr: err instanceof Error ? err.message : String(err) }) + return + } + let stderr = "" + child.stderr?.on("data", (chunk) => { + stderr = (stderr + String(chunk)).slice(-4096) + }) + let timedOut = false + let settled = false + let hard: ReturnType | undefined + const finish = (code: number | null) => { + if (settled) return + settled = true + clearTimeout(timer) + if (hard) clearTimeout(hard) + resolve({ code, timedOut, stderr }) + } + const killTree = (signal: NodeJS.Signals) => { + if (grouped && child.pid) { + try { + process.kill(-child.pid, signal) + return + } catch { + // The group is already gone; fall through to the child itself. + } + } + if (process.platform === "win32") { + void Process.stop(child) + return + } + try { + child.kill(signal) + } catch { + // Already exited. + } + } + const timer = setTimeout(() => { + timedOut = true + killTree("SIGTERM") + hard = setTimeout(() => { + killTree("SIGKILL") + finish(null) + }, graceMs) + }, timeoutMs) + child.once("exit", (code) => finish(code)) + child.once("error", (err) => { + stderr = stderr || err.message + finish(null) + }) + }) +} + +/** `npm i -g ` with a deadline (`runInstall`). A zero exit is not a + * usable engine — npm's global bin directory need not be on PATH — so the same + * discovery the turn boundary does runs before success. */ export async function installEngine(): Promise { const spec = installSpec() if (syncInternals.install) return syncInternals.install(spec) const npm = process.platform === "win32" ? "npm.cmd" : "npm" - 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) { - 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 (!clearsFloor(installedVersion)) { - return { - ok: false, - error: `npm installed it, but ${ENGINE_BINARY} on PATH reports ${installedVersion ?? "no version"}`, - } + const run = await runInstall([npm, "i", "-g", spec]) + if (run.timedOut) { + return { ok: false, error: `npm did not finish within ${Math.round(INSTALL_TIMEOUT_MS / 60_000)} minutes` } + } + if (run.code === 0) { + 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`, } - 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` } + const installedVersion = await versionOf(installedBin) + if (!clearsFloor(installedVersion)) { + return { + ok: false, + error: `npm installed it, but ${ENGINE_BINARY} on PATH reports ${installedVersion ?? "no version"}`, + } } - return { ok: false, error: err instanceof Error ? err.message : String(err) } + return { ok: true } } + const detail = run.stderr.trim().split(/\r?\n/).slice(-3).join(" ") + return { ok: false, error: detail || `npm exited with code ${run.code ?? "unknown"}` } } /** Ask the TUI to raise the offer. False when the bus is unavailable. The diff --git a/packages/opencode/src/altimate/workspace/engine-seams.ts b/packages/opencode/src/altimate/workspace/engine-seams.ts index c885beae1..d2a795ece 100644 --- a/packages/opencode/src/altimate/workspace/engine-seams.ts +++ b/packages/opencode/src/altimate/workspace/engine-seams.ts @@ -27,6 +27,10 @@ export const syncInternals: { /** Install-offer seams (see engine-offer.ts). */ offer?: (offer: EngineOffer) => boolean publishOffer?: (sessionID: string) => Promise + runInstall?: ( + argv: string[], + timeoutMs: number, + ) => Promise<{ code: number | null; timedOut: boolean; stderr: string }> nodeMajor?: () => Promise npmAvailable?: () => boolean install?: (spec: string) => Promise 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 1797faaa2..cdf14d0d2 100644 --- a/packages/opencode/test/altimate/workspace/engine-install-offer.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-install-offer.test.ts @@ -12,6 +12,7 @@ import { describeOffer, installCommand, installEngine, + runInstall, installSpec, nodeMajor, resetForTests, @@ -21,7 +22,6 @@ import { type Toast, } from "../../../src/altimate/workspace/engine-overlay" import { OFFER_RECHECK_MS, OFFER_SKIP_TTL_MS } from "../../../src/altimate/workspace/engine-offer" -import { Process } from "../../../src/util/process" import type { CachedBinding } from "../../../src/altimate/workspace/state" const DIR = "/tmp/analytics" @@ -313,58 +313,47 @@ describe("headless notice stream", () => { }) describe("install deadline", () => { - test("passes an abort signal to the spawn, not just a timeout", async () => { - syncInternals.which = () => "/usr/local/bin/datamate" - syncInternals.versionOf = async () => "0.7.0" - const run = spyOn(Process, "run").mockImplementation(async (_cmd, opts) => { - expect(opts?.abort).toBeInstanceOf(AbortSignal) - return { code: 0, stdout: Buffer.from(""), stderr: Buffer.from("") } as never - }) - try { - expect(await installEngine()).toEqual({ ok: true }) - expect(run).toHaveBeenCalledTimes(1) - } finally { - run.mockRestore() - } + const posix = process.platform !== "win32" + test.skipIf(!posix)("settles on the child's exit even when a descendant keeps stderr open", async () => { + // npm forks a tree; a straggler holding the pipe must not hold the run. + const t0 = Date.now() + const run = await runInstall(["sh", "-c", "sleep 5 >&2 2>/dev/null & exit 0"], 4_000, 200) + expect(run.code).toBe(0) + expect(run.timedOut).toBe(false) + expect(Date.now() - t0).toBeLessThan(2_000) + }) + test.skipIf(!posix)("the deadline terminates a tree that ignores SIGTERM and reports the timeout", async () => { + const t0 = Date.now() + const run = await runInstall(["sh", "-c", "trap '' TERM; sleep 30"], 200, 200) + expect(run.timedOut).toBe(true) + expect(Date.now() - t0).toBeLessThan(3_000) + }) + test("a timed-out run is reported as such, not as an npm failure", async () => { + syncInternals.runInstall = async () => ({ code: null, timedOut: true, stderr: "" }) + const result = await installEngine() + expect(result.ok).toBe(false) + if (!result.ok) expect(result.error).toContain("did not finish within") }) }) describe("install success is verified, not assumed", () => { test("a zero exit with the engine still absent from PATH is a failure", async () => { syncInternals.which = () => null - const run = spyOn(Process, "run").mockImplementation( - async () => ({ code: 0, stdout: Buffer.from(""), stderr: Buffer.from("") }) as never, - ) - try { - const result = await installEngine() - expect(result.ok).toBe(false) - if (!result.ok) expect(result.error).toContain("not on PATH") - } finally { - run.mockRestore() - } + syncInternals.runInstall = async () => ({ code: 0, timedOut: false, stderr: "" }) + const result = await installEngine() + expect(result.ok).toBe(false) + if (!result.ok) expect(result.error).toContain("not on PATH") }) test("a zero exit with a below-floor engine on PATH is a failure", async () => { syncInternals.which = () => "/usr/local/bin/datamate" syncInternals.versionOf = async () => "0.6.3" - const run = spyOn(Process, "run").mockImplementation( - async () => ({ code: 0, stdout: Buffer.from(""), stderr: Buffer.from("") }) as never, - ) - try { - const result = await installEngine() - expect(result.ok).toBe(false) - if (!result.ok) expect(result.error).toContain("0.6.3") - } finally { - run.mockRestore() - } + syncInternals.runInstall = async () => ({ code: 0, timedOut: false, stderr: "" }) + const result = await installEngine() + expect(result.ok).toBe(false) + if (!result.ok) expect(result.error).toContain("0.6.3") }) test("a non-zero exit reports npm's last lines", async () => { - const run = spyOn(Process, "run").mockImplementation( - async () => ({ code: 1, stdout: Buffer.from(""), stderr: Buffer.from("boom\nEACCES denied") }) as never, - ) - try { - expect(await installEngine()).toEqual({ ok: false, error: "boom EACCES denied" }) - } finally { - run.mockRestore() - } + syncInternals.runInstall = async () => ({ code: 1, timedOut: false, stderr: "boom\nEACCES denied" }) + expect(await installEngine()).toEqual({ ok: false, error: "boom EACCES denied" }) }) }) From 07a80a10c2c340d6fa4d7366d1cc13759f047e53 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Fri, 28 Aug 2026 06:54:26 +0800 Subject: [PATCH 08/10] fix: keep the install deadline's SIGKILL armed after npm itself exits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit npm, the group leader, normally dies on the deadline's SIGTERM, and its exit cleared the escalation timer — so a descendant that ignored SIGTERM outlived the reported timeout. Once the deadline has fired the SIGKILL to the process group stays scheduled; the group outlives its leader while any member is alive. --- .../opencode/src/altimate/workspace/engine-offer.ts | 7 ++++++- .../altimate/workspace/engine-install-offer.test.ts | 12 ++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/altimate/workspace/engine-offer.ts b/packages/opencode/src/altimate/workspace/engine-offer.ts index 8c4cc698b..ff1f0db90 100644 --- a/packages/opencode/src/altimate/workspace/engine-offer.ts +++ b/packages/opencode/src/altimate/workspace/engine-offer.ts @@ -161,7 +161,10 @@ export function runInstall( if (settled) return settled = true clearTimeout(timer) - if (hard) clearTimeout(hard) + // Past the deadline the escalation stays armed: npm (the group leader) + // usually dies on SIGTERM, but a descendant that ignores it must still + // get the SIGKILL, so the leader's exit does not cancel it. + if (hard && !timedOut) clearTimeout(hard) resolve({ code, timedOut, stderr }) } const killTree = (signal: NodeJS.Signals) => { @@ -187,6 +190,8 @@ export function runInstall( timedOut = true killTree("SIGTERM") hard = setTimeout(() => { + // The group outlives its leader while any member is alive, so this + // reaches survivors even after npm itself has exited. killTree("SIGKILL") finish(null) }, graceMs) 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 cdf14d0d2..7665f649c 100644 --- a/packages/opencode/test/altimate/workspace/engine-install-offer.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-install-offer.test.ts @@ -328,6 +328,18 @@ describe("install deadline", () => { expect(run.timedOut).toBe(true) expect(Date.now() - t0).toBeLessThan(3_000) }) + test.skipIf(!posix)("a descendant that ignores SIGTERM is still killed after the leader exits", async () => { + // npm (the leader) dies on the deadline's SIGTERM; the escalation must + // survive its exit and reach the straggler. + const marker = `31.${process.pid}` + const run = await runInstall(["sh", "-c", `(trap '' TERM; exec sleep ${marker}) & sleep 30`], 200, 300) + expect(run.timedOut).toBe(true) + await new Promise((resolve) => setTimeout(resolve, 600)) + const survivors = Bun.spawnSync(["pgrep", "-f", `sleep ${marker}`]) + .stdout.toString() + .trim() + expect(survivors).toBe("") + }) test("a timed-out run is reported as such, not as an npm failure", async () => { syncInternals.runInstall = async () => ({ code: null, timedOut: true, stderr: "" }) const result = await installEngine() From b6457424f25cc65b96d621f8ace287ecc7dfe35a Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Fri, 28 Aug 2026 08:51:47 +0800 Subject: [PATCH 09/10] chore: rebase onto the tenant-scoped overlay (declaredFor takes the workspace) --- packages/opencode/src/altimate/workspace/engine-overlay.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/altimate/workspace/engine-overlay.ts b/packages/opencode/src/altimate/workspace/engine-overlay.ts index d9a6931b0..47ffda6b2 100644 --- a/packages/opencode/src/altimate/workspace/engine-overlay.ts +++ b/packages/opencode/src/altimate/workspace/engine-overlay.ts @@ -540,7 +540,7 @@ async function reconcile(sessionID: string, directory: string, state: DirectoryS return } record(sessionID, refusal) - const declared = (await declaredFor(workspace.id))?.keys.length ?? 0 + const declared = (await declaredFor(workspace))?.keys.length ?? 0 await announceRefusal( sessionID, refusal, From 089bb62237daab8b9be762915facc995cf6e4177 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Fri, 28 Aug 2026 09:20:54 +0800 Subject: [PATCH 10/10] fix: drop the filter box from the engine install offer dialog The dialog already skipped filtering (`skipFilter`); the box itself only collected stray keystrokes while the install ran. `renderFilter` is passed through the plugin dialog API to the component that already supports it. --- .../opencode/src/plugin/tui/altimate/workspace.tsx | 12 +++++++----- packages/plugin/src/tui.ts | 3 +++ packages/tui/src/plugin/adapters.tsx | 3 +++ 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/plugin/tui/altimate/workspace.tsx b/packages/opencode/src/plugin/tui/altimate/workspace.tsx index 40214bfd1..0c40fbf13 100644 --- a/packages/opencode/src/plugin/tui/altimate/workspace.tsx +++ b/packages/opencode/src/plugin/tui/altimate/workspace.tsx @@ -1401,12 +1401,14 @@ function EngineInstallOfferDialog(props: EngineOfferProps) { { if (option.value === "busy") return diff --git a/packages/plugin/src/tui.ts b/packages/plugin/src/tui.ts index b96a5d7b7..70c15b8f4 100644 --- a/packages/plugin/src/tui.ts +++ b/packages/plugin/src/tui.ts @@ -180,6 +180,9 @@ export type TuiDialogSelectProps = { onFilter?: (query: string) => void onSelect?: (option: TuiDialogSelectOption) => void skipFilter?: boolean + // altimate_change start — a fixed-option dialog can hide the filter box entirely + renderFilter?: boolean + // altimate_change end current?: Value } diff --git a/packages/tui/src/plugin/adapters.tsx b/packages/tui/src/plugin/adapters.tsx index fb2b104b6..5fb7b7dd0 100644 --- a/packages/tui/src/plugin/adapters.tsx +++ b/packages/tui/src/plugin/adapters.tsx @@ -245,6 +245,9 @@ export function createTuiApiAdapters(input: Input): Omit )