fix(desktop): bundle runtime .mjs scripts into the server bundle - #208
Conversation
nox-0x
left a comment
There was a problem hiding this comment.
Approving — clean fix for a real silent failure, with the right shape: one source-of-truth module for runtime script paths, build-time staging that aborts on missing source, manifest-driven smoke check, and a boot-time warning. Test coverage matches the failure modes (source-tree sync, exports↔staging coverage, settings.statusLine command path), and the bundle side is covered by the existing smoke-test extension.
The load-bearing claim — import.meta.dirname surviving bun --target=node bundling and resolving to the bundle dir at runtime — is already proven in tree by the embedded-dashboard resolution in run.ts:170-171, so the contract is internally consistent.
One minor follow-up (not blocking): the comment block in stageNodePtySpawnHelper (build-binary.ts) still says "import.meta.dirname is NOT populated in a bun --target=node bundle", which contradicts both run.ts and this PR's design. Worth a one-line cleanup so future readers don't get conflicting guidance.
The statusline renderer (providers/statusline.mjs) and the per-agent MCP channel-server (channel-server/dist.mjs) are referenced by path at runtime, not by import — so `bun build` never included them in the desktop server bundle. Spawned sessions got a statusLine command pointing at a missing file (CC swallows the error → no statusline) and an MCP config whose subprocess script didn't exist (agent send/create_agent tools dead). - Centralize resolution in src/scriptPaths.ts: import.meta.dirname is src/ from source and the bundle dir when bundled (bun concatenates all modules into index.js), so one set of relative paths works in both contexts. This also fixes runtime.ts's ../channel-server/ resolution, which escaped the bundle dir entirely. - stageRuntimeScripts() in build-binary.ts copies each RUNTIME_SCRIPTS entry into the bundle (aborting the build on a missing source) and writes a runtime-scripts.manifest. - smoke-test-bundle.sh fails if any manifest entry is missing from the staged bundle — closes the false-pass that hid this bug. - Server warns at boot when a runtime script is missing, mirroring the gemini-cli existsSync precedent (CC otherwise swallows the failure). - script-paths.test.ts guards the staging contract: RUNTIME_SCRIPTS ↔ tree sync, exported constants auto-derived (a forgotten entry fails the test), and the --settings statusLine.command pointing at an existing file. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
7a93df6 to
cabe950
Compare
Problem
Claude Code's statusline doesn't apply in the desktop app (works on hosted). Root cause: two
.mjsfiles are loaded by path at runtime, not byimport, sobun build --target=nodenever includes them andbuild-binary.tsnever copied them:providers/statusline.mjs— injected into every spawned session's--settingsstatusLine.command. In the bundled server the path resolves inside app Resources where the file doesn't exist; CC swallows the statusline error → no statusline, zero symptoms.channel-server/dist.mjs— the per-agent MCP channel-server subprocess. Same latent bug: in the desktop, spawned agents'send/create_agentMCP tools would be dead. Worse,runtime.tsresolved it as../channel-server/dist.mjs, which escapes the bundle dir entirely once bundled.Same bug class as the spawn-helper fix already in
build-binary.ts. This was a false-pass:embedded-mode-integration.test.tsexplicitly setsstatusLine: {enabled: false}, so nothing covered it.Solution
flowchart LR subgraph src ["tsx from source"] SP1["scriptPaths.ts<br/>import.meta.dirname = src/"] --> A1["src/providers/statusline.mjs"] SP1 --> B1["src/channel-server/dist.mjs"] end subgraph bundle ["bun bundle (desktop)"] SP2["index.js (all modules concatenated)<br/>import.meta.dirname = bundle dir"] --> A2["bundle/providers/statusline.mjs"] SP2 --> B2["bundle/channel-server/dist.mjs"] end BB["build-binary.ts<br/>stageRuntimeScripts()"] -- "copies RUNTIME_SCRIPTS<br/>+ writes manifest" --> A2 BB --> B2 SMOKE["smoke-test-bundle.sh"] -- "fails if any manifest<br/>entry missing" --> bundlesrc/scriptPaths.ts(new) — single source of truth.import.meta.dirnameissrc/from source and the bundle dir when bundled (bun concatenates every module intoindex.js), so the same relative paths work in both contexts. All three call sites (claude-code.ts,runtime.ts,run.ts) now import from it.build-binary.ts—stageRuntimeScripts()copies eachRUNTIME_SCRIPTSentry into the bundle preserving relative paths, aborts the build (exit 1) on a missing source, and writesruntime-scripts.manifest.smoke-test-bundle.sh— loops over the manifest and fails on any missing script (or a missing/empty manifest), so the bundle-side check tracks the staging list automatically.run.ts— boot-time warning when a runtime script is missing (mirrors the gemini-cliexistsSyncprecedent; CC otherwise swallows the failure silently in the field).script-paths.test.ts(new) — staging contract:RUNTIME_SCRIPTS↔ source tree sync; exported script constants are auto-derived from the module's exports so a forgotten third constant fails the test; the--settingsstatusLine.command points at an existing file;statusLine.enabled: falsestill suppresses injection.Testing
make checkgreen: 409 server + 209 dashboard tests (4 new)import.meta.dirnamesemantics in bun--target=nodebundles verified empirically (survives verbatim; Node populates it with the bundle dir)statusline.mjsexecuted standalone from its bundle location — full two-line render with live hierarchy enrichment[startup]warning; emptied manifest → smoke FAILs; removed source script → build aborts exit 1Risks
src/tree shape.pr-artifact.yml's DMG build + headless smoke test is the end-to-end gate for the desktop artifact.Alternatives considered
statusline.mjsnext toindex.jsand keeping per-file literals: rejected — no single literal works forruntime.tsin both contexts (../channel-server/is correct fromsrc/agents/but escapes the bundle dir when bundled).scriptPaths.ts: rejected in favor of a build-written manifest — the manifest asserts what the build actually intended to stage and works without repo source.🤖 Generated with Claude Code