## What changes for the user
Four things that silently stopped working with OpenCode V2 work again:
- **"A session finished" notifications** arrive again. A successful or
failed run notifies. A run you stopped yourself does not.
- **Provider retries** show the retrying status and the countdown toast
again.
- **Usage statistics** refresh after each run.
- **The token usage line** under assistant replies (preference,
transcript filter row and hotkey) shows each settled reply's tokens and
cost again.
Per-session transcript reducers are also released again once a run has
settled.
## Why
OpenCode V2 declares `session.idle` and `session.status` in its schema,
but core never publishes them. Runs end with
`session.execution.{succeeded,failed,interrupted}`, and retries arrive
as `session.retry.scheduled`. These features were still keyed only to
the V1 events.
The token usage line had a second problem: it was drawn from V1
`step-finish` parts, which V2 never produces. V2 stores tokens and cost
on each assistant message instead.
A new isolated native fixture,
`scripts/test-session-lifecycle-native.mjs`, confirms this on CLI 2.1.0.
It uses its own storage, port and fake provider, and never the shared
service. In its complete, retry, fail and interrupt scenarios, it sees
no `session.idle` or `session.status` event. Upstream 2.0.27 sources
show the same: these events are declared but never published.
## How
- **Finish notification:** sent from the V2 terminal path after
`session.active` confirms the run ended.
- Succeeded and failed runs notify.
- User and shutdown interrupts stay silent; idle-timeout interrupts
notify.
- Subsessions and unknown sessions stay silent.
- A stray `session.idle` cannot cause a duplicate.
- **Retry:** `session.retry.scheduled` sets the retry state and the
transient toast. The next attempt or the end of the run clears them.
- **Run-end detection:** a shared `lib/native-execution-events.ts`
drives the usage refresh and reducer retirement.
- **Reducer retirement:** it also waits for queued prompts, so prompts
left in the inbox after an interrupt stay listed.
- **Usage line:** moves to `components/message-usage.tsx` with the
existing classes.
- **Removed:**
- the dead V1 `StepCard`, its step-part types and CSS;
- the V1 `session.idle`/`session.status` handlers.
- **AGENTS.md:** one bullet so future features wire to V2 lifecycle
events.
Left for the follow-up cleanup PR: the remaining dead `session.idle`
fallbacks in pending discovery, Missions triggers and test fixtures.
## Validation
New tests that fail on unmodified dev and pass here:
| Suite | dev | this PR |
|---|---|---|
| lifecycle unit | 3/4 fail | 4/4 |
| retirement | 8/9 fail | 9/9 |
| browser `usage-metrics` | 3/3 fail | 3/3 |
| browser `service-usage` | 2/3 fail | 3/3 |
- UI typecheck is clean.
- **Passing unit suites:**
- opencode-data-idle 9, runtime-contract 4, session-native-events 8,
message-part-grouping 6
- opencode-transcript-replay 17, opencode-compaction 11,
session-pruning-projection 2, session-pruning-pagination 6
- session-request-authority 77, opencode-data 30, message-timeline-v2 2,
transcript-visibility 8
- missions 1, instances-pending-compaction 7
- **Passing browser suite:** `system-messages` 10/10.
- The native lifecycle capture passes.
Oversized files touched:
| File | Lines |
|---|---|
| `message-block.tsx` | ~1,760 (down ~240) |
| `message-section.tsx` | ~1,630 |
| `opencode-data.ts` | ~905 |
| `session-events.ts` | ~670 |
| `message-timeline.tsx` | ~645 |
| `message-base.css` | ~675 |
| `session-request-authority.test.ts` | ~2,400 |
| `opencode-data.test.ts` | ~1,215 |
11 KiB
CodeNomad Architecture
Overview
CodeNomad is a SolidJS UI and Fastify server hosted by Electron or Tauri. The official @opencode/client dependency pins live in the server and UI manifests; the runtime CLI is independently managed.
Desktop host -> CodeNomad server -> one shared OpenCode service
^ |
| +-> CodeNomad /api/* and /api/events
+------ UI clients through /workspaces/:id/instance/api/*
There is no @opencode-ai/sdk integration and no legacy packages/opencode-plugin package. The narrow bundled codenomad.automation plugin is documented in DEVELOPER_MODE.md and BROWSER_AUTOMATION.md; it does not own the OpenCode daemon or restore the V1 compatibility runtime.
Shared Service And Locations
packages/server/src/workspaces/opencode-service.ts runs the selected host or WSL CLI's official service status, service start, and service get password lifecycle, validates the authenticated loopback endpoint, and pins that identity while active. It connects to one externally owned global daemon and never stops it on backend shutdown. CodeNomad owns no private daemon port, database, registration, or PID.
OpenCode owns the daemon's standard state and database. Configured allowed environment variables and NODE_EXTRA_CA_CERTS apply only if CodeNomad starts a missing daemon; an existing daemon is unchanged, and legacy OPENCODE_DB/XDG_STATE_HOME ownership settings are ignored. WSL support requires Windows localhost forwarding and executes the Linux CLI lifecycle inside the selected distribution without cross-namespace PID operations.
packages/server/src/workspaces/manager.ts treats selected folders as native OpenCode locations:
- Validate the directory with
client.location.get. - Store the returned
LocationRefand publish the logical workspace. - Reuse the shared service for every additional directory.
- Closing a project tab deletes that occurrence's logical workspace record through
DELETE /api/workspaces/:id, without any native eviction. Sessions continue in OpenCode.
Workspaces are not OpenCode processes and do not own ports or PIDs. Separate tabs opened on the same folder have independent records. Other windows displaying the deleted record close its tab via workspace.stopped and notify; the initiating window stays quiet. Whole-window close, quit, renderer reload and backend shutdown keep their existing restoration behavior. CodeNomad never evicts native Locations.
Native Profiles, Windows, And Client State
Electron and Tauri run one native singleton process and one CodeNomad backend per channel/config profile. A second launch opens another UUID-backed window by default; Advanced settings can restore most-recent-window focus, while --new-window always creates another window. Stable, dev, and non-default config profiles isolate singleton identity, backend/browser storage, and client state.
OpenCode sessions and messages remain shared through the global daemon. Window membership, tabs, drafts, view state, and native bounds are local to each UUID window. Client-state V3 is a per-window envelope over the V2 SHA-256 content-addressed partition graph: immutable partitions are prepared before atomic root publication, writes and migrations are fenced by current ownership and renderer authority, and garbage collection runs after publication while retaining every partition referenced by any window.
Previews use unguessable capabilities for HTTP and WebSocket traffic. Electron and Windows Tauri local windows open HTTP(S) pages in hardened native child webviews with isolated storage; other clients use the existing capability-scoped iframe proxy. Browser preview iframes remain opaque-origin sandboxes without allow-same-origin; preview element comments use a source-checked message bridge instead of parent DOM access. SideCar iframes add allow-same-origin by design: a SideCar is a trusted extension of CodeNomad, and its scripts run with the application's origin and access. /sidecars/* authenticates with the SameSite=Lax session cookie and rejects Origin: null, which an opaque frame cannot satisfy.
SideCar HTTP and WebSocket proxies authenticate the incoming request before removing CodeNomad credentials from forwarded cookies. The configured session-cookie name, the codenomad_session desktop namespace and the Remote Control device cookie are reserved in both directions. Upstream Set-Cookie cannot replace them, including successful and rejected WebSocket handshakes; ordinary service cookies are preserved without decoding their values. The WebSocket handshake uses Node's HTTP parser before switching to the bidirectional stream. This reduces credential exposure to the upstream service; by design, it does not prevent same-origin SideCar JavaScript from calling CodeNomad APIs. Remote Control currently denies SideCar routes and all upgrades; direct LAN/HTTPS access is a separate surface.
API Boundaries
CodeNomad control APIs live under /api/*. Important routes include:
/api/workspacesand/api/workspaces/:id/worktrees/*/api/workspaces/:id/worktrees/:slug/git-status|git-diff|git-stage|git-unstage|git-commit/api/eventsand/api/client-connections/pong/api/storage,/api/settings,/api/filesystem,/api/speech/api/opencode-plugin/automation, authenticated by a per-process loopback token and restricted to CodeNomad-owned locations/api/remote-control/*, managed from the host only;POST /api/remote-control/pairand/remote-pairanswer only through Remote Control
Remote Control decrypts tunnelled TLS inside the backend (@codenomad/remote-tunnel, bundled into dist/remote-control/tunnel-runtime.js) and forwards it to a dedicated loopback ingress. Ingress sockets are classified as remote before parsing, so loopback checks use isLocalRequest() rather than peer addresses. remote-control/gate.ts admits remote requests before CORS, authentication and routing: exact tunnel Host, public Origin on mutations, stripped forwarding headers, host-only path denial on the decoded path, then a paired device credential from devices.ts.
Native OpenCode requests use /workspaces/:id/instance/api/*. The Fastify proxy exposes an explicit method/path allowlist, adds shared-service authorization, validates prompt files, defaults safe requests to the workspace location, and rejects locations/directories outside the selected workspace or its worktrees. Session routes also verify session.location.directory. Never trust a browser-supplied worktree path: resolve ownership server-side. Upstream additions require an explicit proxy review and are not available automatically.
Yolo state endpoints currently live at /workspaces/:id/yolo/sessions/:sessionId; Yolo notifications use /api/events.
Client And Events
packages/ui/src/lib/sdk-manager.ts uses OpenCode.make() and caches generated Promise clients by instance proxy path. packages/ui/src/stores/opencode-client.ts is the root-client authority; native directory/location fields replace old per-worktree SDK clients.
The server holds one client.event.subscribe() stream. InstanceEventBridge maps native location events to CodeNomad instance.event records, and /api/events multiplexes them with workspace and Yolo events for the browser; heartbeats use POST /api/client-connections/pong. The stream is volatile and has no replay guarantee: reconnect must reconcile sessions and pending requests and reread authoritative file/config state. Pending recovery uses the loaded-only broker, not worktree-wide ordinary lists; see compatibility. Relay ordering and ownership fences remain specified in AGENTS.md.
Current native events include session lifecycle/output events (session.created, session.renamed, session.moved, session.execution.*, session.retry.scheduled, session.step.*, session.compaction.*, session.text.*, session.reasoning.*, session.tool.*), background Shell refresh via shell.created, shell.exited, shell.deleted, file invalidation via filesystem.changed, and configuration invalidation via config.updated. Each busy period settles with exactly one session.execution.succeeded/failed/interrupted; the schema still declares session.status and session.idle, but OpenCode V2 never publishes them.
Feature Ownership
| Feature | Owner |
|---|---|
| Sessions, messages, permission/question APIs | OpenCode V2 |
| Shell mode | client.session.shell |
| Conversation instructions | client.session.instructions.entry |
| Background Shell management | Location-scoped OpenCode V2 shell.* API through the ownership-checking proxy; Status panel UI |
| Interactive PTY management | Separate OpenCode V2 pty.* API |
| Workspace lifecycle and directory authorization | CodeNomad |
| Git status/diff/stage/unstage/commit | CodeNomad server |
| Yolo state, persistence and auto-accept | CodeNomad server |
| Browser SSE multiplexing | CodeNomad server |
| Desktop inspection and CDP feedback | Current CodeNomad desktop host and bundled automation plugin, available at normal startup |
| Autonomous browser previews | CodeNomad desktop browser controllers and the same bundled automation plugin, independent of Developer Mode |
| Remote Control transport | OpenTunnel SDK on the device's shared identity; CodeNomad claims its own route and never deletes the identity |
| Remote Control pairing, devices and admission | CodeNomad server (packages/server/src/remote-control/) |
Session Shell and conversation instructions are native features, not plugins. Session Shell remains separate from background Shell and PTY management. The Status panel lists location-scoped native background Shells, refreshes on Shell events/reconnect, displays native metadata, and allows removal. The proxy verifies Shell cwd ownership before ID-scoped operations and preserves native output cursor pagination. Interactive PTYs remain separate. packages/opencode-plugin, server plugin/background-process and per-workspace runtime paths remain deleted and must not be restored; the narrow bundled automation plugin and session-pruning RPC use native V2 discovery and backend presence.
Persistence
CodeNomad configuration resolves through packages/server/src/config/location.ts: config.yaml, state.yaml, remote-control-devices.json, and instances/ under ~/.config/codenomad/. config.json is migration input only.
Implementation Map
Server entrypoints are packages/server/src/index.ts and server/http-server.ts; shared-service authority lives in workspaces/opencode-service.ts and workspaces/manager.ts, event routing in workspaces/instance-events.ts, and bundled automation provisioning in opencode/automation-plugin.ts. UI native calls use stores/opencode-client.ts, session-api.ts and session-actions.ts; CodeNomad routes use lib/api-client.ts. Current integration conventions are in AGENTS.md.