Repository navigation
fix(opencode): keep OpenCode 2 panes Working across plugin reloads - #23700
Conversation
OpenCode 2 disposes and re-sets-up every plugin whenever its plugins dir changes, while sessions keep running. The status plugin published a final Idle on dispose, so a pane read Done mid-turn. Orca also rewrote the plugin file on every PTY spawn, so opening any terminal triggered that reload. Dispose now releases the factory's bookkeeping without publishing a verdict; the next lifecycle event settles the pane, and Orca's ended-process reconciliation still retires panes whose agent exited. The plugin file is written only when its bytes differ.
…stall too The relay's canonical-config install still unlinked and rewrote the status plugin on every OpenCode launch over SSH, which restarts every plugin in a remote OpenCode 2 server. Share one install-currency check (lstat + the existing byte comparison) between the local and relay writers, and pin write-if-changed with mtime so the tests also fail on filesystems that reuse a freed inode.
85ca09b to
75c16dc
Compare
… down OpenCode 1 disposes a plugin only when it tears the instance down, and that teardown cancels every running session, so the Idle published on dispose is true there; the cancelled run's own idle may never reach the plugin. Only OpenCode 2 disposes on a hot reload while turns keep running. The generated module serves both hosts, so the OpenCode 2 setup() entry point now tells the shared factory that sessions outlive disposal; the server() path keeps the previous disposal behaviour, including the hand-off to a surviving factory.
OpenCode 2 loads plugins through file-level symlinks and reads the revision from the target's mtime, so a user whose Orca plugin file is a symlink (per-file dotfile managers) failed the regular-file check and got a write through the link, and a reload, on every spawn. The config-dir and relay installs now skip the write when the resolved target already has Orca's bytes; when stale they behave as before. Only the per-source overlay keeps the regular-file check, since a link there mirrors a user entry. Installers also skip the write inside a guarded block rather than returning early, so later install steps still run.
…eighbours Creating a file symlink on Windows needs Developer Mode or admin rights.
Review status: ready for human reviewVerdict: the fix does what the ticket asks. An OpenCode 2 pane now stays Working for the whole turn when a terminal opens or the plugin reloads, and shows Done when the turn really ends. Three review rounds, an architecture review, and start and end release-readiness checks are done. The last review round and the final readiness check came back clean, with nothing blocking. Commits added during review
Live test on a separate Mac with real OpenCode 2.0.18Each build ran as a hidden dev app with its own isolated home folder and its own OpenCode background service. main =
Opening a terminal while the agent runs This PR keeps the Working spinner: Plugin reload forced mid-turn. main shows Done while This PR stays Working through the reload: This PR then shows Done once OpenCode prints FINISHED: Quitting OpenCode mid-turn does not leave the pane stuck on Working: Known limits, handled by other PRs
Validation
|
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
- Host-aware plugin disposal — the generated factory gains
sessionsOutliveDispose; OpenCode 2'ssetup()sets it sodisposereleases only its own bookkeeping and publishes nothing, while OpenCode 1'sserver()path keeps main's final-Idle / fallback hand-off. This is the fix for the false Done. - Write-if-changed plugin install — new
src/shared/opencode-installed-plugin.ts(isInstalledOpenCodePluginCurrentfollows symlinks;isOverlayOpenCodePluginCurrentrequires a regular file) wired into the local global installer, the per-user overlay, and the relay's canonical-config install, so a spawn no longer bumps the file's mtime and no longer triggers OpenCode 2's plugin reload. - Tests — new host-entry dispose tests run the generated module through both
server()andsetup(); write-if-changed and symlink tests cover the global file, overlay, and relay install; the generated-source SHA digests are updated.
I read the full diff plus the surrounding runtime/installer sources, and ran the four changed test files: 76 pass. The assertions are exact (toEqual on the emitted event-name sequence), so the OpenCode 2 reload test genuinely fails if dispose publishes Idle — the coverage is real, not tautological.
ℹ️ Residual risk when the plugin unloads without a reload
Dropping the OpenCode 2 dispose Idle means that if a plugin is ever unloaded mid-turn outside a reload while the process stays alive, the pane keeps its last state until the next session event or the agent command exits. The author already documents this and could not trigger the path; Orca's ended-process reconciliation still covers the process-death case. Flagging it only so the trade-off stays visible if OpenCode's unloading behavior changes.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
|
| ' if (sessionsOutliveDispose) {', | ||
| ' if (desiredFactoryID === factoryID) {', | ||
| ' clearStatusRetry();', | ||
| ' statusRevision += 1;', | ||
| ' }', |
There was a problem hiding this comment.
Answered prompt stays Waiting When OpenCode 2 reloads the plugin while a question or permission prompt is displayed, disposal removes the record that links the prompt to its owner but leaves the pane Waiting. The reloaded plugin cannot match the user's reply to that record, so it sends no status update and the pane can stay Waiting until another lifecycle event arrives.
Knowledge Base Used: Agent integrations and hooks
| ' if (sessionsOutliveDispose) {', | ||
| ' if (desiredFactoryID === factoryID) {', | ||
| ' clearStatusRetry();', | ||
| ' statusRevision += 1;', | ||
| ' }', |
There was a problem hiding this comment.
Completed turn stays Working After a mid-turn OpenCode 2 reload, the new plugin has no record of the running turn. If its session lookup fails or times out when the turn-end event arrives, it discards that event because it has no record to clear. Disposal no longer sends a final Idle, so the pane remains Working even though the turn ended.
Knowledge Base Used: Agent integrations and hooks
|
Navigate logical layers of code changes, visualize relationships, and explore their blast radius. No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Advanced Run ID: 📒 Files selected for processing (9)
Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 5 remain after this review. 📝 WalkthroughWalkthroughThe status plugin now handles disposal differently when sessions outlive it. The OpenCode 2 setup enables this behavior, and tests cover disposal and reload events. OpenCode plugin installers now check whether generated source matches the installed file before rewriting it. Tests cover matching and stale files, timestamps, and symlinks. Priority: ⬇️ Low Merge Risk: ⚪ Minimal · up to The change keeps OpenCode 2 panes showing Working across plugin reloads and avoids rewriting an unchanged plugin file. The author reports live testing and passing CI. No blocking risk was found in the supplied context. Security Architecture ReviewSecurity architecture risk: 🔵 Low · up to The change addresses false Done states during plugin reloads without an identified expansion of access or file-writing authority. It remains unclear how a pane recovers its status if the plugin unloads permanently while a turn is active. Retained concerns Security review detailsSecurity Blast Radius
Trust Boundaries and Controls
Hardening Proposals
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
Thanks 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 |
…3700, #22943, #23720, #23793, #23776, #23796) into #23059 #23573 (keep a turn's bar on the prompt that opened it) is ported onto the turn-record membership instead of kept as a second owner lookup: - The running turn's bar comes from nativeChatTurnMembership, which now returns barTurnKey beside liveTurnKey. Where the host states each row's turn they are the same key: the running root turn's anchor, and a message handed over mid-turn is already scoped to that turn. Where the host states no scope, rows still group by position, so barTurnKey is the user entry the running record names (through the same anchors) while liveTurnKey keeps the positional live group, as #23573 kept row liveness on the newest prompt. - activeTurnOpenedBy, selectStructuredAgentTurnBars' openedBy and selectNativeChatActiveTurnKey are removed; the desktop list, the slot builder and both turn-status hooks read barTurnKey. - The queued-send clock (queuedUntil: a turn counts from the previous turn's end when its send waited behind it) is kept, read in the same one-pass walk over root turn records that keys timing by anchor. - Its tests are adapted to pass a journal: each runs on a host that states no scope (the original expectations), and the desktop and mobile bar tests also run on a scoped host. #23621 (a message Claude folds into its running turn no longer splits the turn) is host-only and merged as is: the translator contract carries both openTurnInLiveProviderCycle and #23059's command-turn members. Both sides grew the Claude translator past its line budget, so its result branch moves unchanged into claude-result-journaling.ts, beside claude-message-journaling.ts (the pre-commit lint refuses the merge without it). #23801's Codex child path (no error-driven end) and #23059's Codex /compact guard (a command turn ends only on turn/completed) both stand.







ELI5
OpenCode 2 restarts a plugin (and the plugins loaded after it) whenever its file changes, but the agent keeps working through the restart. When Orca's status plugin was shut down for the restart, it told Orca "this session is finished", so the pane showed Done while the agent was still running. Orca also rewrote that plugin file every time you opened any terminal, which is what kept causing the restarts.
Now, under OpenCode 2, the plugin says nothing about a session when it is shut down, and Orca only rewrites the plugin file when its contents actually changed. OpenCode 1 keeps its old behaviour, because there a shutdown really does stop every running turn.
What Changed
Before (user): with an OpenCode 2 agent mid-turn, opening a terminal anywhere in Orca flipped that agent's pane to Done with an unread bell. It stayed Done until the turn really ended or something else refreshed it.
After (user): the pane stays Working for the whole turn and turns Done when OpenCode finishes the turn.
Mechanism, before:
disposepublished a final Idle, or re-published whatever another plugin instance still tracked. On a reload that was a false "turn ended". The re-created instance had no record of the running turn, so nothing corrected the pane until the turn's own end event.OpenCodeHookService.buildPtyEnvran on every PTY spawn and rewrote the plugin file unconditionally, both in OpenCode's global config folder and in the per-user overlay. So each new terminal changed the plugin file and triggered that reload.Mechanism, after:
dispose(status-plugin-factory-source.ts) releases only its own bookkeeping: tracked busy owners, pending blockers and any unsent preview. It cancels its retry and publishes nothing. The pane keeps its last delivered state, and the next lifecycle event from the re-created instance settles it. A turn's end reports Done, and a question or permission prompt reports waiting.server(), OpenCode 2 throughsetup(). So the choice is made at runtime: the OpenCode 2setup()entry point (status-plugin-setup-source.ts) tells the shared factory that sessions outlive disposal. The OpenCode 1server()path keeps main's disposal exactly: a final Idle, or a hand-off to a surviving instance.hook-service.ts) and in the SSH relay's install into the remote OpenCode config folder (opencode-canonical-config.ts), through one shared check (src/shared/opencode-installed-plugin.ts). In the global config folder and the relay install, the check compares the bytes of the file a symlink points to, because OpenCode 2 loads through symlinks and reads the target's modification time; a user whose plugin file is a per-file dotfile symlink no longer gets a write through the link, and a reload, on every spawn. Only the per-user overlay also requires a regular file, because a symlink there mirrors a user entry, never Orca's file. When the bytes differ, each site writes exactly as before (the global folder writes through a link; the relay and overlay replace it). Each installer skips the write inside a guarded block rather than returning early, so any later install step in the same function still runs. Opening a terminal, or launching another OpenCode pane over SSH, no longer rewrites Orca's plugin file. (With a user-setOPENCODE_CONFIG_DIR, Orca still re-links the user's own plugins into its overlay on each spawn, as on main. A symlink or hard link keeps the original file's modification time, so this does not reload anything; the one exception is Windows when a hard link fails (for example across drives) and Orca copies the file instead, which can still trigger a reload, but that reload no longer shows a false Done.)Why
sessionexposesget,wait,hook, and so on, but no status list). A reloaded instance would have to guess, and in the meantime the pane has already flashed Done and fired an unread bell.Known weaknesses:
Linked Issue
Linear STA-8629 (OpenCode2 shows done while still working)
Visual Proof
Real OpenCode 2.0.14 in an isolated dev Orca. In each shot the agent is in the middle of
sleep 40.Before (main): a plain terminal was opened while the agent was sleeping. The OpenCode 2 server reloaded its plugins, and within 2 seconds the
mainrow shows a green check and an unread bell even though the agent is still running.After (this branch): opening a terminal no longer reloads the plugins. To test the dispose change on its own, a reload was forced mid-turn by editing the installed plugin file. The
mainrow keeps its working spinner through the reload and turns Done only after OpenCode reports the turn finished.Testing
Live, macOS, real
opencode2.0.14 through Orca's agent launcher, with a recorder plugin logging every OpenCode event:Automated:
hook-plugin-dispose-host.test.ts) run the generated module through each entry point:server()disposal mid-turn publishes Idle, and an OpenCode 2setup()reload mid-turn publishes nothing while the re-created instance reports Done when the turn ends.setup(), or making the factory ignore it, fails both OpenCode 2 reload tests; forcing OpenCode 2 behaviour ontoserver()fails 9 tests (the seven OpenCode 1 disposal tests and both new OpenCode 1 host tests); requiring a regular file in the global/relay check fails 3 symlink tests; letting the overlay check follow symlinks fails 1.src/main/opencode,src/main/opencode2,src/main/mimo/hook-service, the relay plugin-overlay and OpenCode tests, the shared file-compare and installed-plugin tests, and the PTY env tests that mention OpenCode.pnpm tc:nodeandcheck:code-quality:changedpass.Not run: Windows, Linux and SSH. The plugin change only edits the generated JavaScript source, which is identical on every host and has no platform branches. SSH and WSL panes run the same plugin source. The install change is a byte compare (plus
lstatin the overlay), which behaves the same on every platform.Review
Agent skill upstream boundary
docs/reference/agent-skill-sharing-upstream-boundary.mdand copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.Notes
Out of scope, seen during QA:
session.step.*andsession.tool.*. The plugin's lifecycle mapping expectssession.next.*for those, so they never mark a pane busy. This PR does not rely on them.Checklist
N/Awith reasonpnpm lint,pnpm typecheck,pnpm test, andpnpm buildpass (or CI will cover; local preferred)