Uh oh!
There was an error while loading. Please reload this page.
feat(workspace): offer to install the engine a bound workspace needs - #1158
feat(workspace): offer to install the engine a bound workspace needs#1158ralphstodomingo wants to merge 5 commits into
Conversation
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: trueThanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
ralphstodomingo
commented
Aug 26, 2026
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit:45e30dece9
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| function printLine(line: string): void { | ||
| if (syncInternals.printLine) return syncInternals.printLine(line) | ||
| try { | ||
| process.stdout.write(line + "\n") |
There was a problem hiding this comment.
Preserve JSON framing for headless engine notices
When run --format json is used in a bound workspace with a missing or outdated engine, this writes a human-readable line directly to stdout, while run.ts promises raw JSON events and serializes its other stdout records through emit(). The resulting stream contains a non-JSON line, breaking line-oriented JSON consumers and CI automation; emit a structured JSON record in JSON mode or send this notice to stderr.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Confirmed and fixed in 0d942e6.
Reproduced before changing anything: run --format json in a bound workspace with no engine on PATH produced 5 stdout lines, of which line 1 was the plain notice and the other 4 were valid JSON. So the framing break is real, not theoretical.
Fixed by sending the notice to stderr rather than emitting a JSON record. It is a status notice rather than run output, which is the same reason run already writes its own > builder · <model> status line to stderr — so stderr is correct in both formats, not just a JSON-mode workaround. JSON stays parseable, and a terminal user still sees the notice in either format.
Verified after: JSON mode is 4/4 valid JSON on stdout with the notice on stderr once; text mode still prints it exactly once. Added a regression test that drives the real printer (not the seam) and asserts the line reaches stderr and never stdout.
808282a to
83d0636Compareralphstodomingo
commented
Aug 26, 2026
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit:0d942e6b34
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const spec = installSpec() | ||
| if (syncInternals.install) return syncInternals.install(spec) | ||
| return new Promise((resolve) => { | ||
| execFile("npm", ["i", "-g", spec], { timeout: 300_000 }, (err, _stdout, stderr) => { |
There was a problem hiding this comment.
Invoke npm through a Windows-compatible launcher
On Windows, a normal Node installation exposes npm as a command shim rather than an npm executable, so execFile("npm", ...) fails with ENOENT when the user selects Install now, despite the Node-version gate succeeding. The existing install path in packages/opencode/src/lsp/server.ts:213-215 already handles npm as platform-specific; use a Windows-compatible invocation such as cmd.exe /c npm.cmd here as well.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Confirmed and fixed in 1babc37.
The repo already agrees with you: lsp/server.ts:213 does process.platform === "win32" ? "npm.cmd" : "npm" for exactly this reason. execFile spawns no shell, so the bare name would ENOENT on the one platform where the Node gate had just told the user they were good to go.
Fixed via the house Process.run helper with the same platform split rather than cmd.exe /c. Two reasons: it matches the existing precedent in this codebase, and Process.run takes an argv array, so an install spec containing spaces — the local tarball path E2E uses — needs no quoting. (npm.cmd through cmd.exe /c would have needed care there.)
Re-ran the install E2E on Linux after the change since the mechanism moved: dialog to "Install now" to engine 0.7.0 installed into an isolated npm prefix, success toast, tools on the next message. Windows itself is unverified — I have no Windows host — so this rests on matching the existing precedent rather than on a test.
| // altimate_change — carries the fail-open notice when the target could not be | ||
| // attributed to the workspace; a no-op otherwise. | ||
| return Precedence.annotate(precedence, { | ||
| title: `SQL: ${args.query.slice(0, 60)}${args.query.length > 60 ? "..." : ""}`, | ||
| metadata: { rowCount: result.row_count, truncated: result.truncated }, | ||
| output, | ||
| } | ||
| }) |
There was a problem hiding this comment.
Preserve precedence notices on failed local calls
When precedence returns an undetermined fail-open verdict—such as a default dbt adapter whose type cannot be identified—this annotation is applied only to the successful result. If local execution then throws, the catch path returns an unannotated error, losing both the user-facing reason that routing was skipped and the precedence telemetry marker. The same omission exists in the failure paths of sql-explain.ts and schema-inspect.ts; annotate failure results too so the promised non-silent fail-open behavior survives execution errors.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Confirmed as a real gap, but this one is not mine to fix and I am routing it rather than touching it.
sql-execute.ts, sql-explain.ts and schema-inspect.ts are precedence code from the PR below this one in the stack (#1156), not the install offer. This PR changes engine-sync.ts, the workspace TUI plugin, and one env marker in run.ts; it adds no annotation and no precedence path. Fixing it here would put a precedence change in an install-offer PR and split ownership of that code across two PRs.
I have passed it to the session that owns #1156 with your reasoning intact: the undetermined fail-open verdict is annotated only on the success path, so a throw from local execution returns an unannotated error and loses both the user-facing reason routing was skipped and the precedence telemetry marker — and the same omission is in the failure paths of the other two tools.
Flagging for whoever reads this thread: if #1156 lands the fix, it arrives here through the stack rather than as a commit on this branch.
️✅ There are no secrets present in this pull request anymore.If these secrets were true positive and are still valid, we highly recommend you to revoke them. 🦉 GitGuardian detects secrets in your source code to help developers and security teams secure the modern development process. You are seeing this because you or someone else with access to this repository has authorized GitGuardian to scan your pull request. |
Thanks for updating your PR! It now meets our contributing guidelines. 👍 |
83d0636 to
e772cb7Compare6ab3731 to
3caa27fCompareCodex review logKept out of the PR description so the body stays inside the repo's 5,000-char limit. Edited in place as rounds land. Round 1 — 1 finding
Reproduced first: Round 2 — 2 findings
The npm fix follows the split already in this repo rather than the suggested The second finding is precedence code from the PR below this one in the stack. Codex reviews the whole branch diff, so those files are in its view; fixing them here would have split ownership of that code across two PRs. It was routed, confirmed real, and found to be wider than reported — six unannotated exits rather than three — and is fixed on that branch. Round 3 — 2 findings, both mine
The first was mine to introduce: the original used a call whose timeout kills the child, and moving to the shared helper for the Windows fix silently lost that, because the helper consults its timeout only inside its abort handler as the grace before SIGKILL. Measured rather than reasoned about, same helper and an 8s sleep — timeout alone ran 8004ms, an abort signal killed at 502ms. A stalled install now reports that it did not finish, and the install E2E was re-run afterwards to confirm a normal install is unaffected. The second has an exact precedent: the bash tool already strips the non-interactive marker from child environments for the same reason, and the headless marker was not being stripped alongside it. Review auditRe-audited rather than assumed closed, since a verdict can arrive as inline comments, a review body, or a reaction, and inline comments can land after the body. On this PR: 5 inline findings across 3 rounds, every review body boilerplate with no findings inside, no reaction-only verdict, and 5 replies — one per finding. 4 fixed here, 1 routed to the precedence PR and fixed there. A known divergence, stated deliberately
Round 4 — 1 finding
Escape or a click outside dismisses the offer while npm keeps running. The failure path set signals on an unmounted component, so an npm error — or the five-minute timeout added in round 3 — reached nobody. That is the worse half: the timeout exists to say the install gave up, and it was silent in exactly the case where the user had stopped watching. Success also cleared the dialog stack unconditionally, which would close a dialog the user had opened since. Fixed by tracking mount state: completion reports through a toast when the dialog is gone, carrying the error and the command to run by hand, and the dialog is cleared only while this offer still owns it. Verified in a real TUI — Install now, Escape while npm ran, no dialog rows left, install completed and the result still surfaced. That path produced nothing at all before. On reading the verdict. The reaction on the summon comment was 👀, not 👍 — "looking", not "nothing found" — and the finding arrived about three minutes after it. Reading the reaction as a verdict would have produced a "round 4: no findings" report with a real P2 sitting unaddressed. Surfaces were compared by identifier before and after the summon rather than by count, and watched past the first signal. Round 5 — 1 finding
Two dispatches arriving inside that window both pass, and the failure mode is worse than the bug the guard was written for — a second dialog replacing an installing one can start a concurrent global npm install. The slot is now reserved before the first await and released on the suppressed and failed paths, and each raise carries an ownership token so a superseded dialog tearing down cannot free a slot the newer one holds. No unit test for the interleaving itself: driving two concurrent dispatches through the plugin surface would have been less convincing than the structural change, so that is stated rather than implied. A bug found by testing rather than by reviewBetween rounds 4 and 5, the install → next-message row stopped completing on this base. It looked like an input-delivery flake, and had it been reported that way it would have been wrong. Capturing the pane instead showed a second offer dialog, in its idle phase, sitting over the session after the install finished — attach re-probes a repairable failure every turn, so the offer was being raised again mid-install, replacing the "Installing…" dialog and swallowing keystrokes into its own filter. That produced the single-offer latch, which round 5 then correctly identified as racy. Worth recording because the earlier bases passed this row: the retry behaviour that makes the re-raise likely arrived underneath this PR, so evidence gathered before it proved nothing about after it. Second UI capture — no Node on PATHInstall now is absent and the reason is shown: Verification detailUnit 262 pass / 0 fail on the workspace + plugin suites; full E2E against a real bound workspace with the engine absent from PATH, headless runs with stdin closed: dialog with all three options and the live declared count; Install now installs into an isolated prefix and the tools appear on the next message in the same session; Not now suppresses and returns after the latch is aged 8 days; no Node hides Install now; headless prints exactly one line, identical across 3 runs. Verification on the merged baseRe-run of the install row on
That last point is why the post-install message says tools attach on your next message rather than your next session: the attach re-probes a repairable failure each turn, so the engine installed here is picked up without restarting. Separately, the attach behaviour underneath this was verified on a clean build of the attach branch with none of this PR's changes applied — engine absent at turn 1, installed by hand mid-session, tools present at turn 2 — so the repair is the attach layer's, not something this dialog papers over. Re-verified on the final baseThe base moved once more (precedence round 5). Rather than carry the previous result forward, the install row was re-run on this head: dialog with all three options, Install now completes, engine reports 0.7.0 in an isolated prefix, and The number of tool names visible differed between runs (12 earlier, 5 here). That is a screen capture of a reply still streaming, not a measurement — the assertion is that engine tools are present in the same session after installing, which held on both. Nothing in this PR affects how many tools a workspace serves. This PR adds no permission surface: it registers no model-facing tool, redirects no call, and changes no ruleset. Its only outputs are a dialog, a toast, a stderr line, and an npm invocation made on an explicit choice — so the redirect-across-a-permission-boundary trap that shaped the precedence work does not apply here. |
3caa27f to
881cb9eCompareralphstodomingo
commented
Aug 26, 2026
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit:881cb9ed3d
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (syncInternals.install) return syncInternals.install(spec) | ||
| const npm = process.platform === "win32" ? "npm.cmd" : "npm" | ||
| try { | ||
| const result = await Process.run([npm, "i", "-g", spec], { timeout: 300_000, nothrow: true }) |
There was a problem hiding this comment.
Pass an abort signal to enforce the install timeout
When npm stops making progress, this timeout does not terminate it: Process.spawn only uses that option as the grace period after its abort() handler is invoked, while this call supplies no abort signal. Consequently installEngine() can remain pending indefinitely and leave the dialog stuck on “Installing…” rather than failing after five minutes; provide a timed abort signal or implement an actual execution deadline.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Confirmed and fixed in ce6cb79 — and this was a regression I introduced, not a pre-existing gap.
The original used execFile, whose timeout does kill the child. I moved to Process.run for the Windows shim fix in the previous round and silently lost the deadline, because Process.spawn consults timeout only inside its abort handler, as the grace period before escalating to SIGKILL. With no signal supplied that handler never runs.
Measured it rather than reasoning about it — same helper, same options, an 8 second sleep:
{ timeout: 500 }alone: 8004ms (ran to completion, no deadline){ abort: AbortSignal.timeout(500) }: 502ms
Fixed with a real abort signal, and a stalled install now reports "npm did not finish within 5 minutes" rather than leaving the dialog on "Installing…". Re-ran the install E2E afterwards to confirm the abort signal does not disturb a normal install: dialog to Install now to engine 0.7.0 in an isolated prefix, unchanged. Regression test added with the measured numbers in the comment so the next person does not repeat the swap.
| // a dialog or a toast, so the workspace engine offer degrades to a single | ||
| // printed line. An env var because it must be readable from every module | ||
| // realm (the TUI plugin runtime loads plugins in its own). | ||
| process.env["ALTIMATE_CODE_HEADLESS"] = "1" |
There was a problem hiding this comment.
Strip the headless marker from tool child environments
When a run session launches another altimate-code entrypoint such as a TUI or serve through the bash tool, that child inherits this process-wide marker because bash.ts strips ALTIMATE_NON_INTERACTIVE but not ALTIMATE_CODE_HEADLESS. The nested server then makes isHeadless() true, so offerOrNotify() bypasses TuiEvent.CommandExecute and writes the engine notice to stderr instead of showing the interactive install dialog; remove this marker from tool child environments just like the existing non-interactive marker.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Confirmed and fixed in ce6cb79.
Verified the precedent you point at: bash.ts:178 already does delete mergedEnv["ALTIMATE_NON_INTERACTIVE"], with a comment explaining that a child spawned by the bash tool may itself be a server-mode entrypoint that needs the path the parent disabled. ALTIMATE_CODE_HEADLESS is exactly the same class of marker and was not being stripped, so a nested entrypoint launched from a run session would inherit "headless" and print the engine notice to stderr instead of showing the install dialog.
Fixed alongside the existing delete, with a comment tying the two together so they stay in step. Good catch — the marker only exists because the offer needs to know it has no TUI to render into, and the one case where that inference is wrong is precisely a child process that does have one.
ce6cb79 to
03f1218Compareralphstodomingo
commented
Aug 26, 2026
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit:03f121823b
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (!result.ok) { | ||
| setFailure(result.error) | ||
| setPhase("failed") |
There was a problem hiding this comment.
Report install results after the dialog is dismissed
When a user selects Install now and then dismisses the dialog with Escape or an outside click while npm is still running, the install continues but this failure path only updates signals belonging to the now-unmounted component, so an npm error or five-minute timeout is never shown. A successful completion can also call dialog.clear() after another dialog has opened. Keep the operation's completion handling outside the dismissible component, or emit result toasts and clear only when this offer still owns the dialog.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Confirmed and fixed in the head above. Both halves were real.
Failure path: setFailure / setPhase after dismissal update signals on an unmounted component, so an npm error — or the five-minute timeout added in the previous round — reached nobody. That is the worse half, because the timeout exists precisely to tell the user the install gave up, and it was silent in exactly the case where they had stopped watching the dialog.
Success path: dialog.clear() ran unconditionally, so if the user had opened another dialog while npm ran, we would have closed theirs.
Fixed by tracking mount state with onCleanup: completion reports through a toast when the dialog is gone (carrying the error and the command to run by hand), and the dialog is cleared only while this offer still owns it.
Verified in a real TUI rather than by reading: Install now, Escape while npm was still running, dialog gone with no rows left — the install completed and the result still surfaced, and the engine landed in the isolated prefix. Before this change that path produced nothing at all.
7fef85c to
19cb5e5Compare6cc2b33 to
ff9b5b6Compareralphstodomingo
commented
Aug 26, 2026
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit:ff9b5b6ef2
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (engineOfferVisible) return | ||
| const offer = await describeOffer(api.state.path.directory) |
There was a problem hiding this comment.
Reserve the offer slot before awaiting discovery
When two sessions or repair retries dispatch this command close together, both handlers can pass this guard while the first is awaiting describeOffer, credential lookup, or Node detection. The later handler can then call dialog.replace() after the user has started installing from the first dialog, replacing the installing state with a fresh idle offer and allowing a second concurrent global npm install; cleanup of the first dialog can also reset engineOfferVisible while the second remains mounted. Set the in-flight latch before the first await and release it when discovery is suppressed or fails.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Confirmed and fixed in the head above. You are right, and the guard I added a commit earlier was worse than I thought.
Check-then-act: the latch was read, then describeOffer, the credential lookup and Node detection all awaited, and only then was it set. Two dispatches arriving inside that window both pass. And as you note, the failure mode is worse than the one the guard was written for — a second dialog replacing an installing one can start a concurrent global npm install, where the original bug merely swallowed keystrokes.
Fixed by reserving the slot before the first await and releasing it on the suppressed and failed paths. The teardown race you flagged is handled with an ownership token rather than a bare boolean: each raise takes a generation number, and a dialog releases the latch on cleanup only if it still owns it, so a superseded dialog tearing down cannot free a slot the newer one holds.
Verified end to end after the change on an isolated project with the engine absent from PATH: exactly one offer on screen, install completes, no dialog rows left afterwards, the next message reaches the prompt, and engine tools attach in the same session. No unit test for the interleaving itself — I did not find a way to drive two concurrent dispatches through the plugin surface that would be more convincing than the structural change, so I am flagging that rather than implying coverage I do not have.
19cb5e5 to
8740567CompareReplaces the transient toast for a missing or too-old engine with Install now / Copy command / Not now. The offer reaches the TUI on the event bus, since plugins load in a separate realm; the plugin re-derives detail via describeOffer(). Headless run prints one line to stderr so --format json stays parseable. Install runs only from an explicit choice.
Process.spawn consults `timeout` only inside its abort handler, as the grace before SIGKILL, so with no signal there was no deadline and a stalled npm left the dialog on "Installing..." indefinitely. Measured: an 8s sleep ran 8004ms under `timeout`, 502ms under an abort signal. The bash tool now also strips ALTIMATE_CODE_HEADLESS from child environments, as it already does for ALTIMATE_NON_INTERACTIVE.
Escape or a click outside dismisses the offer while npm keeps running. The failure path only set signals on the unmounted component, so a failed install or the five-minute timeout was completely silent; success also cleared the dialog stack unconditionally, which would close whatever had opened in its place. Completion now reports through a toast when the dialog is gone, and only clears a dialog this offer still owns.
Attach re-probes a repairable failure every turn, so the offer could be raised again while one was still up. Mid-install that replaced the "Installing..." dialog with a fresh idle one, which swallowed keystrokes into its own filter — observed end to end: after a successful install, typing never reached the prompt. The offer is now latched while one is on screen.
The single-offer guard checked the latch and then awaited three times before setting it, so two dispatches arriving close together could both pass — worse than the bug it fixed, since the second dialog can replace an installing one and start a concurrent global npm install. The slot is now reserved before the first await and released if discovery is suppressed or fails, and only the raise that owns the latch may free it.
40308bc to
8992318Compareralphstodomingo
commented
Aug 26, 2026
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit:89923187e6
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| // 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 |
There was a problem hiding this comment.
Keep the install latch held after dialog dismissal
When the user selects Install now, dismisses the dialog while npm is still running, and then sends another message, this cleanup releases the only global offer latch even though runInstall() continues in the background. The repair retry can consequently open a fresh offer and allow a second npm i -g against the same global prefix. Fresh evidence beyond the previously fixed concurrent-offer race is this dismissal path explicitly setting engineOfferVisible to false before the install promise settles; keep a separate install-in-flight latch until that promise completes.
Useful? React with 👍 / 👎.
Issue for this PR
Closes#1157
Type of change
What does this PR do?
A bound workspace whose declared tools need the local engine used to report a missing engine as a 10-second 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 — attach still never installs anything on its own.
The part worth reviewing is how the offer reaches the TUI. I first registered a handler with the attach module from the plugin. It typechecked, unit-tested green, and did nothing in a real TUI — the user got the old toast, never the dialog. The plugin runtime loads plugins in a separate module realm, so the instance the plugin imports is not the one attach consults; a globalThis key failed likewise. The offer is therefore published on the event bus, which is what toasts already use, and since that event carries no payload the plugin re-derives the detail itself.
Deliberate details:
How did you verify your code works?
Unit 311 pass / 0 fail on the workspace and plugin suites; full suite green apart from one failure already red on the base. Typecheck clean, lint at baseline on every file touched.
E2E against a real bound workspace with the engine absent from PATH: dialog with all three options and a live declared count; Install now installs into an isolated prefix and tools appear on the next message in the same session; Not now suppresses, and returns once the latch is aged past 7 days; no Node hides Install now; headless prints exactly one line across 3 identical runs.
Not verified: the successful clipboard path — this host has no clipboard backend, so only the "could not confirm" branch ran. Not verified: Windows — the npm shim fix follows existing precedent in this repo rather than a test, and deserves a check before release.
Round-by-round review log (five rounds, seven findings — six fixed here, one routed to the PR below; three were regressions from my own earlier fixes), the second capture, and detailed evidence: see the "Codex review log" comment on this PR.
Screenshots / recordings
Terminal UI, so this is captured pane output rather than an image. Workspace name redacted.
Checklist