What happened?
Entire's hooks never fire when OpenCode is used through the OpenCode Desktop app (Mac). The CLI/TUI works. No error surfaces anywhere — not in the app, not in .entire/logs/ — because the failure is swallowed.
Root cause: the desktop app runs the OpenCode server on Node (inside an Electron utility process), not Bun, and our plugin calls the Bun global directly.
cmd/entire/cli/agent/opencode/entire_plugin.ts spawns every hook through Bun:
Bun.spawn — line 37 (callHook)
Bun.spawnSync — line 58 (callHookSync)
Bun.spawnSync — line 95 (fireTurnStart)
Bun.spawn — line 143 (session-start)
Under Node each call throws ReferenceError: Bun is not defined before any process is spawned, and every call site is wrapped in catch {} ("plugin failures must not crash OpenCode"). So the plugin loads, registers, receives events, and fires zero hooks, silently: no session-start, no turn-start (so no context injection), no turn-end (so no checkpoints), no session-end.
Why the runtime differs
The desktop app does not shell out to the opencode CLI. It forks an Electron utility process and imports a Node build of the server into it (opencode repo, v1.18.18):
packages/desktop/src/main/index.ts:378 → spawnLocalServer(...) — the default path (the alternate "v2 CLI daemon" path is gated behind OPENCODE_SIDECAR_V2=1, index.ts:64,333)
packages/desktop/src/main/sidecar.ts:57 → await import("virtual:opencode-server")
- resolves to
packages/opencode/dist/node/node.js (electron.vite.config.ts:6,68), built by script/build-node.ts with target: "node"
OpenCode handles its own side of this by nulling out the shell helper it hands plugins:
// packages/opencode/src/plugin/index.ts:164-165
// @ts-expect-error
$: typeof Bun === "undefined" ? undefined : Bun.$,
That guard landed in opencode 535343bf56 ("refactor(server): replace Bun serve with Hono node adapters"), the commit that created the node build for the desktop app. Note the typed plugin API still declares $: BunShell as non-optional (packages/plugin/src/index.ts:65), so this is a general trap for OpenCode plugins, not just ours.
Everything else on the path is fine: plugin discovery is the same code in both runtimes (.opencode/plugins/*.ts globbed at packages/opencode/src/config/plugin.ts:22), the desktop probes the user's login shell for PATH before forking the sidecar (packages/desktop/src/main/server.ts:44-55, inherited via createSidecarEnv()), and no plugin-disabling flags are set.
Steps to reproduce
entire enable --agent opencode in a repo (writes .opencode/plugins/entire.ts).
- Open that repo in the OpenCode Desktop app, run a prompt that edits a file, then commit.
- No checkpoint is created and the commit gets no
Entire-Checkpoint trailer. The same flow in opencode (TUI or opencode run) works.
Isolated repro of the mechanism (our plugin's exact hook shape, run under each runtime):
--- node --- HOOK SWALLOWED ERROR: ReferenceError Bun is not defined
--- bun --- HOOK OK turn-end
Fix
Replace the four Bun.spawn/Bun.spawnSync calls with node:child_process spawn/spawnSync. That module works identically in both runtimes — verified:
--- node --- result: HOOK RAN turn-end stdin={"session_id":"x"} status: 0
--- bun --- result: HOOK RAN turn-end stdin={"session_id":"x"} status: 0
spawnSync keeps the blocking semantics turn-start/turn-end depend on (state ready before mid-turn commits; completion before opencode run tears down its event loop). The file's only other Bun-ism is the import type line, which is erased at load. The header comment "Requires Bun runtime" should go too.
Test coverage: the plugin currently has no test that exercises it under Node. A canary that loads entire_plugin.ts with node and asserts a hook actually spawns would have caught this.
Follow-ups found while diagnosing (separate from the fix above)
-
.ts plugin extension is a latent hazard. Plugins are loaded with a bare await import(fileURL) (packages/opencode/src/plugin/loader.ts:135) and OpenCode registers no TS loader. Bun imports .ts natively; Node only does since type stripping became default (22.18+/24+). Verified the import succeeds on Node 26; Electron 42's exact Node build is unverified. The discovery glob accepts {ts,js}, so writing the file as .js removes the dependency on runtime TS support entirely. If it does fail there the symptom differs — OpenCode publishes a visible Failed to load plugin session error (plugin/index.ts:191-213) — so a user seeing a toast means this, and silence means the Bun issue above.
-
The desktop sidecar is one long-lived server shared across all sessions, unlike one CLI process per run. resetSessionTracking (line 109) assumes a single active session at a time, so two sessions open in the same project will thrash the store, fire spurious session-start, and mis-attribute turn-start. And server.instance.disposed (line 247), our only reliable session-end trigger, now fires at app quit rather than per session.
-
Storage skew to check when validating. The app overrides XDG_STATE_HOME to Electron's userData (packages/desktop/src/main/server.ts:52), but the session DB lives under XDG_DATA_HOME (packages/core/src/database/database.ts:53), which it leaves alone — so opencode export <id> from our hook shares the CLI's DB. However the DB filename is channel-scoped (opencode-<channel>.db for anything not latest/beta/prod, database.ts:54) and the desktop's channel is stamped at build time, defaulting to dev (electron.vite.config.ts:8-13). A dev/nightly app build paired with a stable CLI means different DB files, so opencode export finds nothing even once hooks fire.
Environment
- Entire CLI 0.10.1-nightly.202608130635.9862b0dd6
- macOS, Darwin 25.6.0 arm64
- Agent: OpenCode — Desktop app (
@opencode-ai/desktop 1.18.18, Electron 42.3.3)
What happened?
Entire's hooks never fire when OpenCode is used through the OpenCode Desktop app (Mac). The CLI/TUI works. No error surfaces anywhere — not in the app, not in
.entire/logs/— because the failure is swallowed.Root cause: the desktop app runs the OpenCode server on Node (inside an Electron utility process), not Bun, and our plugin calls the
Bunglobal directly.cmd/entire/cli/agent/opencode/entire_plugin.tsspawns every hook through Bun:Bun.spawn— line 37 (callHook)Bun.spawnSync— line 58 (callHookSync)Bun.spawnSync— line 95 (fireTurnStart)Bun.spawn— line 143 (session-start)Under Node each call throws
ReferenceError: Bun is not definedbefore any process is spawned, and every call site is wrapped incatch {}("plugin failures must not crash OpenCode"). So the plugin loads, registers, receives events, and fires zero hooks, silently: nosession-start, noturn-start(so no context injection), noturn-end(so no checkpoints), nosession-end.Why the runtime differs
The desktop app does not shell out to the
opencodeCLI. It forks an Electron utility process and imports a Node build of the server into it (opencode repo, v1.18.18):packages/desktop/src/main/index.ts:378→spawnLocalServer(...)— the default path (the alternate "v2 CLI daemon" path is gated behindOPENCODE_SIDECAR_V2=1,index.ts:64,333)packages/desktop/src/main/sidecar.ts:57→await import("virtual:opencode-server")packages/opencode/dist/node/node.js(electron.vite.config.ts:6,68), built byscript/build-node.tswithtarget: "node"OpenCode handles its own side of this by nulling out the shell helper it hands plugins:
That guard landed in opencode
535343bf56("refactor(server): replace Bun serve with Hono node adapters"), the commit that created the node build for the desktop app. Note the typed plugin API still declares$: BunShellas non-optional (packages/plugin/src/index.ts:65), so this is a general trap for OpenCode plugins, not just ours.Everything else on the path is fine: plugin discovery is the same code in both runtimes (
.opencode/plugins/*.tsglobbed atpackages/opencode/src/config/plugin.ts:22), the desktop probes the user's login shell forPATHbefore forking the sidecar (packages/desktop/src/main/server.ts:44-55, inherited viacreateSidecarEnv()), and no plugin-disabling flags are set.Steps to reproduce
entire enable --agent opencodein a repo (writes.opencode/plugins/entire.ts).Entire-Checkpointtrailer. The same flow inopencode(TUI oropencode run) works.Isolated repro of the mechanism (our plugin's exact hook shape, run under each runtime):
Fix
Replace the four
Bun.spawn/Bun.spawnSynccalls withnode:child_processspawn/spawnSync. That module works identically in both runtimes — verified:spawnSynckeeps the blocking semanticsturn-start/turn-enddepend on (state ready before mid-turn commits; completion beforeopencode runtears down its event loop). The file's only other Bun-ism is theimport typeline, which is erased at load. The header comment "Requires Bun runtime" should go too.Test coverage: the plugin currently has no test that exercises it under Node. A canary that loads
entire_plugin.tswithnodeand asserts a hook actually spawns would have caught this.Follow-ups found while diagnosing (separate from the fix above)
.tsplugin extension is a latent hazard. Plugins are loaded with a bareawait import(fileURL)(packages/opencode/src/plugin/loader.ts:135) and OpenCode registers no TS loader. Bun imports.tsnatively; Node only does since type stripping became default (22.18+/24+). Verified the import succeeds on Node 26; Electron 42's exact Node build is unverified. The discovery glob accepts{ts,js}, so writing the file as.jsremoves the dependency on runtime TS support entirely. If it does fail there the symptom differs — OpenCode publishes a visibleFailed to load pluginsession error (plugin/index.ts:191-213) — so a user seeing a toast means this, and silence means theBunissue above.The desktop sidecar is one long-lived server shared across all sessions, unlike one CLI process per run.
resetSessionTracking(line 109) assumes a single active session at a time, so two sessions open in the same project will thrash the store, fire spurioussession-start, and mis-attributeturn-start. Andserver.instance.disposed(line 247), our only reliablesession-endtrigger, now fires at app quit rather than per session.Storage skew to check when validating. The app overrides
XDG_STATE_HOMEto Electron's userData (packages/desktop/src/main/server.ts:52), but the session DB lives underXDG_DATA_HOME(packages/core/src/database/database.ts:53), which it leaves alone — soopencode export <id>from our hook shares the CLI's DB. However the DB filename is channel-scoped (opencode-<channel>.dbfor anything not latest/beta/prod,database.ts:54) and the desktop's channel is stamped at build time, defaulting todev(electron.vite.config.ts:8-13). A dev/nightly app build paired with a stable CLI means different DB files, soopencode exportfinds nothing even once hooks fire.Environment
@opencode-ai/desktop1.18.18, Electron 42.3.3)