Commit graph

1378 commits

Author SHA1 Message Date
Pascal André
4ced2d18da
fix(missions): give mission roots OpenCode session identifiers (#1133)
## Problem

Missions on OpenCode Zen's free models (for example
`opencode/deepseek-v4.1-flash-free`) fail at the coordinator's very
first model request with **"Free usage limit reached. Connect a provider
or subscribe to OpenCode Go…"**, while ordinary conversations on the
same model and network keep working.

## Cause

Missions created its coordinators, recurring passage coordinators and
independent task roots with `ses_` + 26 hex characters of a SHA-256.
OpenCode passes that ID to the provider as the session identity
(`x-opencode-session-id`, `x-opencode-session`, `X-Session-Id`,
`x-session-affinity`; `core/src/session/model-request.ts`), and native
children send their **root's** ID as affinity
(`core/src/session/affinity.ts`), so one such root blocks the whole
mission family.

Isolated probes against the real free tier (keyless OpenCode 2.1.4,
OpenCode-generated control IDs accepted throughout) characterized two
checks:

| Check | Requirement | Otherwise |
|---|---|---|
| Shape | `ses_` + 12 lowercase hex + 14 alphanumerics (any case/charset
for the 14) | 403 `FreeTierError` |
| Time | the 12 hex decode, as OpenCode's descending identifier, to a
date between ~86–90 days before and 24–30 h after the request | 429
`FreeUsageLimitError` |

A hash prefix decodes to an arbitrary date over the ~795-day cycle, so
most mission roots failed the time check. A real mission A/B (UI-created
vs. the same mission attached to an OpenCode-created root) reproduced
it: derived coordinator refused, native coordinator completed; a child
with an ordinary ID under a hash-ID parent was refused too.

## Change

- `missions/session-identity.ts`: mission roots take OpenCode's
descending identifier for their **real creation time** followed by 14
hex characters derived from the mission or task identity. Ownership
stays provable from the ID alone.
- Creation: one-time coordinators (`control.ts`), independent task roots
(`control.ts`) and recurring passage coordinators
(`recurrence-store.ts`, passage `createdAt`). The time comes from the
real clock (`options.now ?? Date.now()`), not the journal's monotonic
timestamp, which may run ahead of it.
- Ownership checks match the derived suffix and also accept the original
26-hex derivation held by existing journals: managed-root creation
(`mission-root-creation.ts`, `ensureActorSession`), cleanup targets
(`session-cleanup.ts`), recurring passage parsing/archive/current reads,
and the creation readback (`mission-creation-pipeline.ts`, which no
longer needs the exact coordinator ID in advance; the hold's `sessionID`
is optional).
- `dev-docs/MISSIONS.md` documents the identity rule.

Task roots and recurring coordinators record their ID before creating
the session, so retries still reuse the exact root. A one-time
coordinator takes the clock at creation.

## Compatibility

Existing missions keep their coordinator IDs (and remain refused on free
models); new missions are accepted. Like any OpenCode session, a mission
root older than about three months is refused by the free tier. No UI,
API or journal schema change; no user-facing help change.

## Validation

- `session-identity.test.ts`: time prefix against IDs OpenCode 2.1.4
generated at known milliseconds, descending order, suffix derivation,
legacy acceptance, rejection of other missions/tasks/shapes.
- Server typecheck; all Missions server tests (`src/missions`, mission
routes, plugin, `src/opencode/missions`): 1140 pass, 1 pre-existing
environment skip.
- Native scripts against an isolated OpenCode 2.1.4:
`test-missions-native`, `-trajectory`, `-family-control`, `-contract`
pass.
- Real free tier: a mission created through the real CodeNomad routes
and plugin had its coordinator's first request accepted with the new
identifier. The remaining real checks (native subagent child,
independent task root, recurring coordinator) are pending: the probing
exhausted the network's free allowance (an OpenCode-generated control is
now refused too) and will be rerun after it resets.
2026-10-25 02:10:47 +02:00
Pascal André
4b163382bc
feat(remote-control): serve web previews to paired devices (#1131)
Remote Control blocked `/previews/*`, so a phone or browser paired through
the tunnel had no web preview at all: the entry was hidden and the agent's
dev server stayed out of reach. Previews now work there, behind a host
setting that is on by default.

A preview's sandboxed document sends neither the device cookie nor a usable
`Origin`, so the Remote Control gate admits a live `/previews/<token>/…`
capability on its token alone, as local listeners already do, while the
host's Web previews switch is on. The ingress, which refused every
WebSocket, now admits preview upgrades only: `admitRemoteUpgrade()` parses
the target exactly as the preview proxy does and the manager hands the
socket to the application's path-preview upgrade handler, never to the
shared upgrade event (so `/previews/<token>/../../sidecars/…` cannot reach
another handler). Every other upgrade still gets 404.

The switch lives in Settings → Remote Access → Remote Control. Only the
host can change it (`PUT /api/remote-control/previews`); the registry
stores only the opt-out. Turning it off refuses later preview requests,
including the Referer-based root-relative asset fallback, and closes live
preview WebSockets; remote `/api/meta` then reports `previewsUnavailable`,
which hides Open web preview on paired devices.

Exposing previews through a public tunnel makes their address a bearer
credential, so path-proxied preview responses now carry
`Content-Security-Policy: sandbox` with the iframe's own flags (one shared
constant, `previews/sandbox.ts`) and `Referrer-Policy: same-origin`, on
every content type. A preview document opened outside its iframe stays an
opaque origin instead of running with CodeNomad's origin and device cookie,
and the capability path stays out of Referer headers to other sites. Such a
standalone document then behaves like the preview iframe: its root-relative
resources rely on the runtime bridge's path rewriting, without the Referer
fallback. The per-token preview.localhost host keeps its own origin and
headers.

Help (Remote Control, mobile and client overview pages, all ten locales),
dev-docs/REMOTE_CONTROL.md, architecture.md and UI_SHELL.md describe the
behavior; the switch label and caption are translated in all locales.

Validation: a Remote Control surface test drives the real application
through the ingress: a preview page and an `Origin: null` form post work
without a device cookie and carry the sandbox and referrer headers, unknown
tokens and non-preview or dot-segment upgrades get 404, a preview WebSocket
upgrades, a paired device cannot change the switch, and turning it off
closes the live WebSocket, refuses the preview and its Referer asset
fallback, and flags the metadata.
Registry, metadata and proxy tests cover persistence of the opt-out, the
flag and the headers per entry point; browser tests cover the switch and
the conversation header with previews on and off.
2026-10-24 20:25:25 +02:00
Pascal André
036a008c2c
fix(ui): restore the web preview entry on mobile, hide it through Remote Control (#1128)
Phones lost their only entry to the session web preview when the
conversation actions were reorganized: on mobile, the Full screen action
took the preview's place instead of joining it. Previews still work there
through direct (LAN) access, which serves them through the capability
iframe proxy. Remote Control, on the other hand, serves no preview content
(`/previews/*` is host-only), so its clients, desktop browsers included,
were offered a preview that could only fail to load.

The conversation header now offers the preview on every platform, inline
and in its overflow menu, with Full screen added on mobile. `/api/meta`
reports `remoteControl: true` for requests that arrived through the Remote
Control ingress, `lib/server-meta.ts` exposes it as `remoteControlClient()`,
and the shell hides the preview entry for those clients. The shell asks for
the server metadata on mount; the read is cached and shared with the other
callers, and the entry stays visible until a Remote Control answer arrives.

The Remote Control help page says, in all ten locales, that conversation
actions hide Open web preview there; dev-docs/UI_SHELL.md records the
convention.

Validation: a header-windows browser test drives the real conversation
header on a Pixel 5 profile and on desktop Chrome, with and without the
Remote Control metadata, and checks the inline buttons and the overflow
menu. It fails on the previous shell (no preview entry on the phone, and a
preview entry kept through Remote Control). meta.test.ts and the Remote
Control surface test cover the flag locally and through the real ingress
(the surface fixture's metadata gains the `host` field /api/meta needs).
The UI unit tests run by CI, the server routes, proxy and Remote Control
suites, the help checks and both typechecks pass.
2026-10-24 15:07:18 +02:00
Pascal André
39c0fa5833
chore(opencode): qualify and recommend runtime 2.1.4 (#1126)
## Summary

- Qualify OpenCode **2.1.4** (latest stable, 23 October) with the
existing locked **2.1.0 client/plugin** dependencies, and **recommend**
it.
- Keep the technical **minimum at 2.1.2**: no consumed contract changed,
so the minimum has no new reason to move.
- 2.1.2 and 2.1.3 daemons stay ready and get an optional upgrade offer.
2.1.4 is the `tested` version. Newer and custom labels keep
authenticated contract recognition.
- The reviewed-contract fast path lists the 2.1.2 minimum and the 2.1.4
target.
- The recommended version is updated in the help (all ten locales), both
READMEs, `CONTRIBUTING.md`, `dev-docs/OPENCODE_COMPATIBILITY.md` and the
architecture skill.

## Contract review (v2.1.3 → v2.1.4, 72 commits)

- **Authenticated OpenAPI:** 121 → **124 paths**, 146 → **149
operations**. No operation changed. The changes:
  - added `POST /api/experimental/client-mcp/token`;
  - added `GET /api/experimental/client-mcp` (WebSocket);
  - added `POST /api/debug/heap-dump`;
- `Config.InfoEncoded` gains the opt-in
`experimental.background_compaction`.

The session event (`V2EventEncoded`) and message schemas are unchanged.
None of the new routes is on the instance proxy allowlist, and the proxy
relays WebSockets only for SideCars and previews.
- **Published declarations:**
  - the client adds `mcp.client.token` and `debug.heapDump`;
- the schema and plugin changes are type refactors and unused-export
removals, among them `SessionID.descending`, `SessionEvent.Source` and
`PromptInput.FileAttachment.create`; `Session.Message.ID.fromEvent` now
takes any string instead of an event ID. CodeNomad uses none of these,
and every host module its native plugin bundles import
(`@opencode/plugin`, `/effect`, `/promise/adapter`, `/rpc`,
`@opencode/schema/location`, `/session`) still exists.
- **MCP elicitation forms**
([#58184](https://github.com/anomalyco/opencode/pull/58184)): an MCP
server asking for input during a tool call now creates a global-session
Form whose `form.created` event carries the server's location.
CodeNomad's live path requires that location for global Forms and shows
them as a **Project request**. Review found that the live path dropped
the location from the queued Form, so its reply lacked
`x-opencode-directory` and failed for a worktree or subdirectory until a
pending-request rescan. **#1127** fixes that; recovery already kept it.

A native probe (minimal stdio MCP server, a real Code Mode `execute`
call, CodeNomad-style reply) gives:

| Runtime | Form listed for the location | `form.created` carries the
location | Reply completes the tool |
  | --- | --- | --- | --- |
  | 2.1.3 | yes | **no** | yes |
  | 2.1.4 | yes | **yes** | yes (`action: accept`, `channel: stable`) |

On 2.1.3 such a request only appeared after a pending-request rescan; on
2.1.4 it appears live. The probe sends the location header itself, so it
checks the runtime contract; #1127 covers CodeNomad's live reply path.
- **Other changes** are covered by the native suites below:
- compaction (oldest-first shortening, ignored buffers, overflow
detection);
  - the agent's model for sessions without one;
  - V2 agent files;
  - V1 global-session import attribution;
  - MCP lifecycle fixes.
- **Live check:** this machine's shared daemon runs 2.1.4, and the
CodeNomad desktop session works through it.

## Isolated Windows qualification

Base: dev `9ac792fff`; Node **24.20.0**; exact official CLI **2.1.4**;
unchanged locked client/plugin **2.1.0**.

- **Build and checks:** production server/UI/native plugin builds,
server and root typechecks, native bundle guard.
- **Native runtime (9/9):**
  - Code Mode job ordering, plugin runtime, automation;
  - complete pruning, history, ownership, worktree and event relay;
  - loaded-only pending snapshot;
- Missions contract, family control, native and Compare/Wayfinder
trajectory.
- **Sessions (15/15):** environment, fork, aside, interrupt, lifecycle,
blank cleanup, prompt skills, provider usage/accounts/selection, service
usage, web search, plugin packages, output limit, Promise-tool failure.
- **Recurring (5/5):** all **11 recurring Mission journeys**, subagent
depth, **2 MiB** compaction, permission receipts.
- **Migrations (10/10):**
- history from **2.0.3, beta-19271, 2.0.32, 2.1.0, 2.1.1, 2.1.2 and
2.1.3** to 2.1.4;
  - Git degraded/init;
  - temporary workspaces.
- **Installation (3/3), with the 2.1.4 recommendation:**
  - fresh install of the 2.1.2 minimum;
- native **2.0.15 → 2.1.4** upgrade, after which the already-running
daemon keeps its PID and 2.0.15 version;
  - explicit OpenCode **1.18.10 → 2.1.4** replacement.
- **After the recommendation change:**
- admission, transport, update, installation and instance proxy units
**116 passed, 1 POSIX-only skipped**;
  - full server **2,471 passed, 8 skipped**, no failure or cancellation;
  - UI units **1,699/1,699**;
  - focused browser **126/126**;
  - native pruning with the real rendered UI;
  - the exact CI invocation of the Code Mode regression (without tsx);
  - ten-locale help checks (**9/9**).

This is Windows automation evidence. In CI, `latest` resolves to 2.1.4:
the compatibility job runs the native suites against the minimum and
latest on Windows, and the migration and Git-degraded certification runs
them on Ubuntu, Windows and macOS. WSL, packaged desktop,
installers/updater end-to-end and real mobile devices keep their
separate release gates.
2026-10-24 13:34:36 +02:00
Pascal André
719bc1c967
fix(forms): keep a live global Form's location for its reply (#1127)
## Problem

OpenCode 2.1.4
([#58184](https://github.com/anomalyco/opencode/pull/58184)) delivers
MCP elicitation requests live, as global-session Forms (`sessionID:
"global"`) whose `form.created` event carries the server's location.

CodeNomad's live event path (`stores/instances.ts`) queued
`event.data.form`, which has no `location`. `formRequestOptions()` reads
`form.location`, so the dock's reply and cancellation went out **without
`x-opencode-directory`**. For a Form created in a worktree or
subdirectory, OpenCode answered `FormNotFoundError` until a
pending-request rescan replaced the entry with its located copy.

Found by the independent review of the 2.1.4 qualification (#1126),
which reproduced the headerless `FormNotFoundError` on an isolated 2.1.4
daemon.

## Fix

Recovery already attaches the snapshot location to global Forms. The
live path now does the same with the event location before queuing.
Session-owned Forms are unchanged, since their replies need no location
header.

`dev-docs/NATIVE_INTERRUPTION_UX.md` states that live and recovered
global Forms keep their location.

## Tests

- New regression in `instances-pending-requests.test.ts`: a real SSE
dispatch of two global Forms from a worktree, then a reply and a
cancellation. Both must send `x-opencode-directory` for that worktree.
It **fails without the fix**, because the queued Form has no location.
- With the fix:
  - pending-request, compaction and Form store tests: 55/55;
  - full UI unit suite: 1700/1700;
  - interruption dock and mobile interruption browser tests: 43/43;
  - UI typecheck.
2026-10-24 13:34:31 +02:00
Pascal André
6c2601b065
fix(previews): never forward the app's cookies to the previewed site (#1125)
When an iframe preview loads, the browser sends the CodeNomad origin's
cookies with the frame's document request: the parent page initiates that
navigation, so the SameSite=Lax session cookie and any SideCar service
cookie set at the app origin are attached. The HTTP preview proxy forwarded
that Cookie header unchanged to the preview target, which can be any
external site. Opening an untrusted page in a browser or mobile client's
preview therefore handed it a working CodeNomad session.

Preview HTTP requests now drop the Cookie header, matching preview
WebSocket upgrades, which already forwarded none. Target Set-Cookie headers
were and remain stripped, so previews keep their behavior: the path proxy
never carried cookies a previewed site could have set. All three entry
points share proxyPreviewTargetRequest: the /previews/<token> path proxy,
the per-token preview.localhost capability host and the root-relative
asset fallback resolved from a preview Referer. Desktop native guests load
the target directly and are unaffected.

Validation: a new sidecar-proxy regression sends the CodeNomad session and
a SideCar service cookie through each entry point and asserts the preview
upstream receives no Cookie header. It fails on the previous proxy (both
cookies reached the upstream through all three). The server proxy, preview,
SideCar and Remote Control suites pass (192 tests) and the server typecheck
is clean.
2026-10-24 12:49:02 +02:00
Pascal André
9ac792fffe
fix(server): authenticate the path the router routes, not the raw target (#1124)
The authentication hook decided whether a request needs a session from the
raw request target, while Fastify's router matches a decoded path. A
percent-encoded or absolute-form target therefore reached protected routes
without a session: "/%61pi/workspaces" routed as "/api/workspaces", and
"/%73idecars/<id>/..." reached a SideCar, on any listener the server
exposes, including LAN listeners protected by the server password. Remote
Control was not affected: its gate already judged a decoded path.

Both checks now share `routedPath()` (server/routed-path.ts), which returns
exactly the path find-my-way matches: cut at the first "?" or ";" (Fastify's
default semicolon delimiter) and decoded with decodeURI, so reserved
characters such as %2F stay encoded and %25 is never decoded twice. Targets
the router would reinterpret (absolute-form, a leading "//", a fragment) or
reject (malformed percent-encoding) are refused with 400 before any access
decision. The Remote Control gate used a broader decodeURIComponent
variant; sharing the router-exact helper keeps the two classifications
identical to routing.

Validation: `routed-path.test.ts` checks the helper against the decoder of
the router Fastify itself loads and its refusals. `request-path-auth.test.ts`
drives the real HTTP application over raw sockets: encoded /api, /sidecars
and /workspaces targets, queries and semicolons get 401 without a session,
reinterpreted or malformed targets get 400, public routes stay public and a
session still reaches encoded paths. It fails on the previous hook
("/%61pi/test/echo" answered 200). The Remote Control surface tests and the
whole server suite pass, and the server typecheck is clean.
2026-10-24 10:17:31 +02:00
Pascal André
5994944f84
feat(settings): manage desktop data profiles and drop the startup question (#1123)
Choosing a desktop data profile needed `CODENOMAD_PROFILE` or, on a first
packaged launch with several restorable profiles, a native question that
blocked startup. Settings only named a non-default profile and offered to
delete all the other profiles at once.

Settings > General > Restore previous state now ends with one folded line,
"Data profile: <name>". Unfolded, it lists every profile on the computer,
the open one first and the others by last use, each with its size, creation
date and last use. A radio chooses the profile the next launch opens (any
profile of this configuration; choosing the open one again cancels), "New
profile" creates one by name, "Restart in <name>" restarts into the choice,
and each other profile has its own delete button with a confirmation. A
launch pinned by `CODENOMAD_PROFILE`, its alias or a development run shows
the profiles without letting the user choose; deletion still works.

Without a remembered choice, a packaged launch no longer asks: it opens the
most recently used of the default, `dev` and `dev-v2` profiles (ties go in
that order) and remembers it, as it already did with zero or one candidate.
A launch that finds the selection lock held opens the detected profile
without remembering it instead of waiting.

Implementation: the existing host listing gains the open profile, creation
(folder birth time, links not followed) and last-use (newest client-state
write) dates, the remembered next-launch profile and whether the launch is
pinned. Two host operations are added on both hosts, granted to local and
Preferences windows only and serialized with list and delete: choosing the
next launch's profile (validated with the `CODENOMAD_PROFILE` grammar,
written under the selection lock, overwriting this configuration's entry,
creating a new profile's scope folder inside the lock and removing it again
if the write fails) and restarting through the graceful developer-mode
relaunch. Electron's throwaway question storage, relaunch and private
hand-offs are removed; it still sweeps leftover question folders and drops
the two hand-off variables an older build's relaunch can carry. Tauri keeps
only the invalid-profile error dialog. `CODENOMAD_DESKTOP_PROFILE` and the
server's `desktopProfile` meta field are removed, since nothing displays them.

Only a profile whose name leads back to its own folder can be chosen, so a
folder the deprecated alias made under another profile's name (such as
default-<hash>) is listed under its stored name but not offered. The
listing reports the next launch's profile only when the launch would open
it (the launch's own remembered-profile check), and Restart in <name> lists
again first and restarts only while that still names the shown profile.
Choosing with the keyboard keeps focus on the chosen radio.

Validation: UI browser tests for the manager (listing, choose and restart,
new profile and name validation, single deletion, macOS note, pinned
launches, plain browser), Electron native tests (listing dates, current,
next launch, pinned, set-next-launch outcomes, relaunch wiring, preload,
automatic choice, lock and sweep) and Tauri tests (the same cases, grants
and command wiring). Developer doc and help (10 languages) updated.
2026-10-24 09:46:30 +02:00
Pascal André
628a80a5c9
feat(settings): give Updates its own card and the data profile a Startup row (#1122)
Some checks are pending
Publish help / build (push) Waiting to run
Publish help / deploy (push) Blocked by required conditions
## Summary

- **Updates** is its own card again (About → Updates → Diagnostics →
connectivity), subtitle restored in 10 languages: latest version,
install/download + Refresh, then the **update feed** with its help and
save error last. Rows resuming after actions open with their separator.
Help speaks of the Updates card again (10 locales).
- **Data profile** moves from About to General's startup card (Restore
previous state), above Other saved profiles; still only for an explicit
non-default profile (`current-profile-settings-row.tsx`, shared by
inline settings and Preferences). Label key moved in 10 languages;
desktop help and `DESKTOP_DATA_PROFILES.md` point to the new place.
- The Clear saved startup state button keeps space above the rows'
separator.

## Validation

- `server-info`: card order, feed out of About, after the actions, with
its separator; no data profile in Info.
- `other-profiles`: running profile named above other saved profiles,
clear of the button, Electron and Tauri Preferences; default profile
never named.
- `desktop-updates`, `npm run test:help`, UI typecheck.
2026-10-24 08:44:52 +02:00
Pascal André
d6f9867051
fix(server): send the server version in the UI manifest User-Agent (#1121)
The manifest host's analytics showed one address fetching version.json
about every six seconds for a whole day. The server reads the manifest
once per start, so that is a server restarting in a loop, but every
request carried the same bare "CodeNomad-CLI" User-Agent: nothing told
which CodeNomad version was involved.

The UI manifest read and the UI package download now send
"CodeNomad-CLI/<server version>". Nothing else changes: the manifest is
still read once per start, and a failed read still falls back to the
cached or bundled UI.

Validation: a new remote-ui test serves the manifest from a local HTTP
host and asserts the exact User-Agent it receives; it fails with the
bare User-Agent. Server own-tsconfig and root typechecks pass.
2026-10-24 07:59:57 +02:00
Pascal André
f400d5beb9
fix(transcript): load large tool images on demand instead of inline (#1118)
Some checks are pending
Publish help / build (push) Waiting to run
Publish help / deploy (push) Blocked by required conditions
A session whose history holds image tool results (for example `read` on
PNG files) never opened over a slow link (#1113): the 200-message page
was 16.8 MB, 15.7 MB of it base64 in five tool results. It took 20 s at
6.5 Mbit/s, and a reverse proxy cut the transfer, so the UI showed
"Failed to fetch". Every open, reload and history window paid for every
image, shown or not.

Transcript pages now carry large tool images by reference. The UI's
native message list reads (createInstanceFetch) and history windows send
the `x-codenomad-media: deferred` opt-in. For those reads only, the
server replaces each tool image whose data URI exceeds 32 KiB with a
`/api/workspaces/<id>/session-media/<session>/<message>/<part>/<index>/
<sha256>` path, in the instance proxy (forwardRuntimeRequest gains an
optional whole-JSON transform for successful JSON responses) and in the
history window route. toolImageSource resolves that path on the API
base, so the existing lazy `<img>` loads only the images that are shown,
as binary rather than base64.

The new session-media route checks that the workspace owns the session,
reads the native message, and refuses a session that moved during the
read and messages at or after an undo boundary. It serves the bytes only
when the image still matches the digest, as an immutable, nosniff,
sandboxed image; anything else falls back to "Unable to display this
image".

What stays whole:
- Single-message reads (`session.message.get`), so pruning revisions
  keep hashing native content.
- Live events, user attachments, small images, non-image files and
  error responses.
- Requests without the opt-in.
The opt-in header is never forwarded to OpenCode. On the reported
session, the page drops from 16.8 MB to about 1.1 MB.

Validation:
- session-media.test.ts: only large renderable tool images change, and
  only their URI.
- routes/session-media.test.ts: exact bytes and headers; invalid
  references, digest and position mismatches, the undo boundary,
  ownership and a move during the read.
- proxy.test.ts: the transform applies to successful JSON only and
  releases once.
- instance-proxy.test.ts: an opted-in list read is deferred, the opt-in
  is not forwarded, and plain list or single-message reads stay whole.
- session-navigation.test.ts: history windows.
- sdk-manager.test.ts: only list reads opt in, keeping other headers.
- tool-content.test.ts: path resolution, forged paths rejected; it is
  now listed in the PR build.
- tool-images.test.ts: a deferred image decodes from the route in the
  real ToolCall renderer, and a missing one shows the fallback.

The UI own-tsconfig, server and root typechecks pass, as do:
- the server suite (2,467 passed; one index.test.ts process test timed
  out under load and passes alone, as on dev);
- the UI unit step of the PR build (564 tests);
- seven browser suites (278 tests).
2026-10-23 23:38:00 +02:00
Pascal André
47e02795b2
fix(session): clear a queued follow-up's badge when it is delivered (#1117)
A follow-up sent while the agent works (follow-up behaviour "queue") shows
the "Queued" badge until the engine delivers it. After some turns the
badge never cleared: the message was answered, the engine went idle, and
only closing and reopening the session removed it (#1112). The stale
pending entry also kept the message pinned below the running turn.

The session view lists queued prompts from the session reducer's inbox
through a memo. CodeNomad replaces that reducer while the session stays
open: an authoritative resync after an event-queue overflow, a rotation
failure or a reconnect, and a pruning invalidation. The replacement was a
plain property write, so the memo kept reading the disposed reducer,
which never sees `session.inbox.delivered`. On 0.20.1 nothing rebound it
until the view remounted; on dev the reducer retirement at settlement
happened to rebind it at the end of the turn, but the badge stayed for
the rest of the turn, and indefinitely when retirement waits (another
prompt still parked, an unsettled tool or shell).

Every replacement now goes through replaceTranscriptEntry, which assigns
the new reducer, disposes the previous one and bumps a per-transcript
entryRevision signal that getOpenCodeSessionInbox tracks. Only that
session's inbox observers re-run; a transcript destroyed and recreated is
still rebound by the instance revision. Several queued follow-ups keep
their badges until each is delivered; steer-mode actions on a delivered
prompt disappear the same way.

Validation: opencode-inbox-rebinding.test.ts queues two follow-ups,
replaces the reducer by pruning invalidation and by an event-queue
overflow, then delivers them one by one with no terminal event: each
leaves the memo at once. Both cases fail without the fix. A browser test
in session-rendering.test.ts runs the real SessionView and SSE dispatcher:
queued badge, a pruning event, delivery, badge gone while the turn still
runs; it fails without the fix with the badge still visible. The session
fixture's native inbox now lists parked prompts as an array and stubs the
permission and form lists, as a real daemon does, so an authoritative
resync completes. The new unit file is listed in the PR build's UI unit
step, which passes locally (64 files, 562 tests), as do the UI own-tsconfig
and root typechecks and the six browser suites using the session fixture
(206 tests).
2026-10-23 22:48:05 +02:00
Pascal André
7f1ee6ccfb
fix(titlebar): show the hexagon logo at 20 px (#1116)
## Summary

At 16 px the title bar's hexagon was too small for its C and n to read.
Compared in the Windows and macOS title bars on light and dark palettes
at 16, 20 and 24 px, 20 px reads clearly, stays close to the menu text
and leaves margins in macOS's 28 px bar (24 px nearly filled it).

## Validation

- `brand-logo` browser test expects the 20 px title bar logo;
`macos-titlebar` still finds it centred and clear of the traffic lights.
- `dev-docs/UI_CONVENTIONS.md` records the size.
2026-10-23 21:59:53 +02:00
Pascal André
42cab13a0e
feat(brand): adopt the hexagon CN logo, black or white by palette (#1114)
## Summary

- New logo: a hexagon framing a C and an n, in equal bands (ring, gap,
strokes, counters share one width), readable down to 16 px.
- **App icon**: white ring and letters over black gaps, transparent
canvas, hexagon spanning top to bottom; one fixed icon for light and
dark taskbars, docks and tabs.
- **In the UI** (`components/brand-logo.tsx`): black hexagon with white
gaps on light palettes, white version on dark ones, picked in CSS from
`data-theme` (system appearance when unset), so it also works outside
ThemeProvider (title bar). Loading screen (always dark) uses the white
one. Sizes: 4rem beside a text-3xl title, 3.2rem narrow column, 5.3rem
loading screen, 16px title bar.
- **Icons rendered from the vector**: `scripts/generate-icons.mjs` draws
every `.ico` entry (16-256 px), `.icns` slot, Linux size and the 1024 px
master straight from `images/CodeNomad-Icon.svg` with Chromium, instead
of shrinking one large PNG; it writes the containers itself, so Electron
drops `png2icons`/`pngjs`. One command replaces BUILD.md's four manual
steps.

## Validation

- New `tests/browser/brand-logo.test.ts`: real title bar + home page;
which hexagon each logo shows on light, dark and system (both ways)
palettes, with heights; red without the logo stylesheet.
- UI + Electron typechecks, Electron tests (283), `macos-titlebar` and
`notification-icon-badge` browser tests, `npm run test:help`.
- Icon checks: ICO entries 16-256 (DIB, PNG at 256), ICNS slots
icp4-ic14; UI captures on porcelain, linen, mist and ember palettes.
2026-10-23 19:18:38 +02:00
heunghingwan
8cbe141e6e
fix(transcript): keep streaming reasoning rendering past the display bound (#1106)
## Problem

Long model reasoning still appears to freeze roughly 30 seconds in, even
after #1088 kept the store streaming across shared-stream reconnects.
The visible thinking text stops growing while the panel keeps visibly
refreshing — the live duration keeps ticking, the transcript keeps
following, and the reasoning scroller stays pinned to its bottom. To a
user watching a long think, it is indistinguishable from the earlier
store freeze, which is why the previous fix looked like it had not
worked.

## Root cause

The record display projection caps every reasoning part's text at
`REASONING_RENDER_CHARACTER_LIMIT` (10 000 characters —
`extractReasoningTextForRender` inside `buildRecordDisplayData`,
introduced by #648's memory bounding). The cap applies while the part
streams, not only once it settles:

- A reasoning stream at typical model speed crosses 10 000 characters
around 30 seconds in — the same moment the connection-recheck freeze
used to surface, so the two bugs produced nearly identical symptoms.
- Past the bound, every delta still re-projects the message (the row
re-renders, the header duration ticks, the transcript keeps following —
"refreshing, just no content"), but the displayed text is byte-for-byte
the same 10 000-character prefix, so `ReasoningStreamOutput`'s `<pre>`
never changes again until the part completes.
- The store keeps the whole text the whole time and copy actions read it
whole; only the display copy is silently capped, with no reveal.

## Changes

- `stores/message-v2/record-display-cache.ts`: a reasoning part without
`time.completed` (still streaming) now projects its whole `text`. That
is a reference to the store's string, not a copy — per delta it is
cheaper than the previous sliced 10 000-character copy. Once the part
completes, the projection returns to the bounded 10 000-character
prefix, preserving #648's retained-copy bound for settled transcripts.
- `dev-docs/TEXT_DISPLAY_BOUNDS.md` describes the split: completed
reasoning keeps the bounded prefix; a still-streaming part renders
whole.

## Behavior compatibility

- Completed reasoning renders exactly as before (bounded prefix,
whole-text copy).
- Timeline markers, hover previews and search slice their own text from
the raw parts (`message-timeline.tsx` still bounds previews at 4096
characters) and are unchanged.
- A reasoning part that never settles (interrupted mid-think) keeps its
whole text by reference — it renders live and retains no extra copy.
- No help-page change: the bound was never documented in the user help
and no labels, controls or defaults changed.

## Validation

- Red→green unit regression in `instance-store.test.ts`: the streaming
projection keeps the whole text past the bound; the completed projection
keeps the bounded prefix. Pre-fix it fails at the first assertion (10
000 vs 10 020 characters).
- New browser regression `tests/browser/reasoning-stream.test.ts` (real
`SessionView`, native SSE dispatcher): three ~6.9K-character reasoning
deltas keep rendering whole past the bound, and
`session.reasoning.ended` returns the card to the 10 000-character
prefix. Pre-fix it fails exactly like the live bug: `AssertionError:
delta 2 renders whole (got 10000)`.
- The `session.tsx` fixture gains `startReasoning` / `reasoningDelta` /
`endReasoning` emitters plus an opt-in `revealThinking` preferences hook
(thinking rows are hidden by default).
- `tsc --noEmit` clean; `instance-store` 30/30; `empty-content` +
`message-part-grouping` + `render-text-window` 18/18.
- Shared-fixture `message-scroll` suite: 127/128 **identically on this
branch and on the clean base commit**; the single failure is the first
test's page-load navigation timeout, a pre-existing local Vite
cold-start issue (the same environment phenomenon class #1088
documented), not a regression.

---------

Co-authored-by: Pascal André <pascalandr@gmail.com>
2026-10-23 11:48:13 +02:00
Pascal André
c82dd8fbf6
fix(notifications): show Windows system notifications and keep their permission (#1109)
Some checks are pending
Publish help / build (push) Waiting to run
Publish help / deploy (push) Blocked by required conditions
## Summary

System notifications on Windows: everything about them in one PR.

1. **Windows data profiles never showed system notifications**
(`fix(windows): show system notifications from every data profile`).
Tauri runs each data profile, such as **dev**, under its own
AppUserModelID (`…client.scope.s<hash>`), and its notifications carry
that ID; Windows shows a desktop app's notifications only under an ID it
knows, and the installer's Start menu shortcut carries the stable ID
alone. Windows recorded every notification (100 on the affected machine)
and displayed none. Every launch now registers the running ID under
`HKCU\Software\Classes\AppUserModelId\<identifier>` (`DisplayName`
CodeNomad, `IconUri` the bundled web app icon
`resources\server\public\pwa-192x192.png`, which the packaged-resource
smoke check requires; `windows_notification_identity.rs`). Electron
always uses the stable ID.
2. **The permission was lost at every restart on Windows.** The
notification plugin's page script reports *denied* there until the page
requests permission, though Windows never asks, so Settings asked again
after each restart. The check now asks the native plugin command, which
answers granted on desktop: granted once, kept.
3. **Notifications were held back whenever the window was visible**
(`fix(notifications): hold system notifications back only while
focused`). With **Notify when the app is focused** off (the default), an
open window stays visible behind other applications or on another
screen, so nothing was sent unless CodeNomad was minimized. Delivery is
now skipped only while the window is visible and focused
(`document.hasFocus()`). The setting's subtitle and the Settings help
say *focused* in all ten languages; `dev-docs/SETTINGS.md` records the
rule.

Out of scope by decision: several CodeNomad windows open on the same
project may each send the event's notification (as they already do when
the setting is on), and focus inside a browser preview counts as leaving
the page.

## Validation

- Affected machine: no profile notification showed before; after
registering its ID as above, a notification sent under it showed in the
notification centre under CodeNomad.
- `cargo test --bins -- windows_notification_identity` (writes the key,
reads back both values).
- `tests/browser/system-notification-focus.test.ts`: real routed
notification from a visible page; nothing while focused, one once focus
is lost, one while focused with the setting on; red on the previous
visibility check.
- `npm run typecheck --workspace @codenomad/ui`, `npm run test:help`.
2026-10-23 04:03:26 +02:00
Pascal André
5b69a52822
fix(brand): let the app icon span its square edge to edge (#1110)
## Summary

The app icon looked smaller than its neighbours in the Windows taskbar:
the square source SVG kept an empty ~4 % margin on each side, so the 24
px icon drew the CN in 22 x 12 px on a transparent background.

- `images/CodeNomad-Icon.svg` is cropped to a square centred on the
mark, touching the left and right edges (height stays about half: the CN
is twice as wide as tall).
- Master PNG, in-app copy, Electron/Tauri `.ico`/`.icns`/`.png` and
Linux sizes regenerated: 24 px now 24 x 14 px, 32 px now 32 x 18 px.
- In-app logos use the same PNG, so their heights shrink by the same
ratio to keep the approved on-screen sizes (5.55rem, 4.44rem narrow,
7.4rem loading); `dev-docs/UI_CONVENTIONS.md` updated.

## Validation

- Regenerated `icon.png` loses no opaque pixel of the master; the mark
spans columns 0-1023.
- Measured bounding boxes in the 16/24/32/48 px `.ico` entries before
and after.
- `npm run typecheck --workspace @codenomad/ui`.
2026-10-23 03:13:06 +02:00
Pascal André
019203e31e
feat(notifications): show Clear all and Settings as icons in the history (#1107)
## Summary

- The notification history's **Clear all** and **Settings** text buttons
become icon buttons (red trash can, gear), like the panel's close
button.
- Their labels stay as tooltips (`title`) and accessible names
(`aria-label`), with the existing translations. **Mark all as read**
keeps its text.
- Shared `window-icon-button` geometry; the existing toast-history
action classes keep hover, danger and disabled styling.

## Validation

- Screenshots of the real `ToastHistoryPanel` with two notifications,
dark and light themes, including the hover state.
- `npm run typecheck --workspace @codenomad/ui`.
2026-10-23 02:19:35 +02:00
Pascal André
7709860b61
feat(desktop): give macOS windows native traffic lights and one menu bar (#1105)
## Summary

On macOS, CodeNomad showed **two menu bars**: the system menu bar and,
inside the window, the UI title bar with the same
File/Edit/View/Window/Help menus plus Windows-style buttons at the
right.

macOS windows now follow the platform convention (as in VS Code or
Slack):
- The menus live only in the **global menu bar**.
- The **native traffic lights** sit at the left of CodeNomad's title
bar, which keeps the theme colours and shows the CodeNomad mark
(Preferences: its title) after them.
- The main window's title bar now also holds **Settings, the
notification history bell and Remote Access**, so the project tab bar
gives that space to the tabs (and drops its separator).
- Windows, Linux and browsers are unchanged.

## Implementation

- **Tauri**: new `title_bar.rs` configures local application and
Preferences windows: overlay title bar with a hidden title on macOS, no
decorations elsewhere.
- **Electron**: `titleBarStyle: "hidden"` on macOS, `frame: false`
elsewhere, for the same two windows.
- Both keep the traffic lights at their **standard place**. Tauri loses
a custom `traffic_light_position` once the window is shown and resized
(it came back to the default on the VM), so on macOS the UI bar takes
the native **28 px** title bar height instead, which centres them, and
leaves their 60 px clear in every writing direction.
- `NativeTitlebar` drops the in-window menus and window buttons on
macOS. The actions move from `InstanceTabs` into a shared `AppActions`
component, rendered by the title bar on macOS and by the tab bar
elsewhere (`appActionsInTitlebar`).
- The native close button of Preferences goes through the existing
guarded close request, like Cmd+W.
- `dev-docs/UI_SHELL.md` describes the per-platform title bar; the
Settings help says where the gear and bell are on macOS, in all ten
languages.

## Validation

- **macOS 15 test VM**, Electron app and Tauri build patched with this
change: traffic lights and mark centred in the 28 px bar in both hosts
(measured from screenshots), no in-window menus or buttons, actions at
the right end opening Preferences, Remote Access and the notification
history below the bar, Preferences title after the lights, and its red
button closing the window.
- New `tests/browser/macos-titlebar.test.ts` renders the real title bar
and tab bar as macOS and as Windows.
- `tab-chrome`, `new-instance-launcher`, `opencode-setup` and
`notification-icon-badge` browser tests (58 pass, 1 skipped), `npm run
test:help`, `npm run typecheck` pass.
2026-10-23 02:18:04 +02:00
Pascal André
3ef6b0c564
fix(brand): size large CodeNomad logos by their title (#1108)
## Summary

Large logos follow their title instead of the window, on the plain empty
state's ratio (6rem logo above a `text-3xl` title, 3.2x):

- **Home page** and **branded empty session**: 6rem (the home page's
12rem rule from 640 x 721 px goes; the session drops `h-48`).
- **Narrow session column**: the title shrinks to 1.5rem, so the logo
shows 4.8rem.
- **Loading screen**: 8rem (128 px) beside its larger 2.8rem title,
instead of 180 px.
- `dev-docs/UI_CONVENTIONS.md` records the rule.

## Validation

- Real home page, branded and plain empty states, narrow session column
and loading screen in dark and light at 1100 x 760: logo/title 96/30,
96/30, 96/30, 76.8/24 and 128/44.8 px.
- Tried in the owner's installed app through a temporary override.
- `npm run typecheck --workspace @codenomad/ui`.
2026-10-23 02:17:56 +02:00
Pascal André
bbda09754a
fix(titlebar): size the CodeNomad mark at 14 px (#1104)
Some checks are pending
Publish help / build (push) Waiting to run
Publish help / deploy (push) Blocked by required conditions
## Summary

- The title-bar mark goes from 20 px to **14 px** high, at the owner's
request after trying it live: it now matches the visual weight of the
menu labels, with 11 px of margin above and below it in the 36 px bar.
- Only `.native-titlebar-logo` height changes; the brand container still
centres it vertically.

## Validation

- Tried live in the owner's installed Tauri app with a 14 px override.
- Screenshots of the real `NativeTitlebar` at 14 px in dark and light
themes, 1x and 2x: mark 14×26.5 px at y=11 in the 36 px bar.
2026-10-23 00:13:34 +02:00
Pascal André
8f62c214dc
fix(titlebar): size the CodeNomad mark at 20 px (#1103)
## Summary

- The title-bar mark from #1102 goes from 24 px to **20 px** high, at
the owner's request: 8 px of margin above and below it in the 36 px bar,
closer to the weight of the menu labels.
- Only `.native-titlebar-logo` height changes; the brand container still
centres it vertically.

## Validation

- Screenshot of the real `NativeTitlebar` at 20 px in dark and light
themes, 1x and 2x: mark 20×38 px at y=8 in the 36 px bar.
2026-10-22 20:01:11 +02:00
Pascal André
dc6b0e7f09
feat(titlebar): show the CodeNomad mark instead of the window title (#1102)
## Summary

- The main window's custom title bar (Windows and Linux) shows the new
CN mark instead of the text "CodeNomad" before its menus. The
Preferences window keeps its text title.
- `NativeTitlebar` gains an optional `logo` prop: it renders
`packages/ui/src/images/CodeNomad-Mark.svg` (the mark from
`images/CodeNomad-Icon.svg` cropped to its bounds) 24 px high, centred
in the 36 px bar, with the title as `alt` so the accessible name stays
"CodeNomad".
- The image is `draggable={false}` so pressing it still moves the
window, and it hides below 640 px like the title did.
- `BUILD.md` lists the cropped asset beside its source.

## Validation

- `npm run typecheck --workspace @codenomad/ui`: pass.
- Screenshots of the real `NativeTitlebar` at 1x and 2x in dark and
light themes: mark 24×45.5 px at y=6 in the 36 px bar,
`alt="CodeNomad"`, Preferences title unchanged.
2026-10-22 19:11:57 +02:00
Pascal André
4ece6dafbf
feat(brand): replace the logo with the bare CN mark (#1099)
## Summary

- Replace the CodeNomad logo with the new **bare CN mark**: a bold C and
N in saturated blue `#0088ad` on a transparent background, an orange
ring `#f7970e` at the centre of the C and a trace joining it to the N.
No tile.
- One source drives every placement: the Electron and Tauri app icons
(ICO, ICNS, PNG, Linux hicolor sizes), the favicon and PWA icons, the
home, empty-session, empty-state and loading screens, and the help site.
- No code or layout changes: every placement keeps its square image box.

## Assets

- `images/CodeNomad-Icon.svg` (new): vector source, the mark on a square
552-unit canvas. The trace overlaps the N stem so anti-aliasing leaves
no seam where the shapes meet.
- `images/CodeNomad-Icon.png` and
`packages/ui/src/images/CodeNomad-Icon.png`: 1024×1024 transparent
export of the SVG.
- `electron/resources/icon.{icns,ico,png}`: regenerated with
`packages/electron-app/scripts/generate-icons.js` and copied to
`packages/tauri-app/src-tauri/`. The ICO keeps its nine frames (16–256)
and the ICNS its twelve entries.
- `packages/tauri-app/src-tauri/icons/linux/*.png`: exported straight
from the SVG at 32–512 px.
- `BUILD.md` documents the source and the regeneration steps, including
the Tauri copies.

## Validation

- Rendered every ICO frame (16–256 px) on dark and light backgrounds:
legible from 24 px; at 16 px the trace fades and the ring reads as an
orange dot.
- Screenshots of the real `FolderSelectionView`, `BrandedEmptyState`,
`EmptyState` and loading screen components in dark and light themes.
- `node --import tsx --test
tests/browser/notification-icon-badge.test.ts` (favicon drawing from the
icon): 6/6 pass.
- `npm run test:help`: pass.
2026-10-22 17:47:13 +02:00
Pascal André
01b20b5b78
chore(opencode): qualify and recommend runtime 2.1.3 (#1098)
Some checks are pending
Publish help / build (push) Waiting to run
Publish help / deploy (push) Blocked by required conditions
## Summary

- Qualify OpenCode **2.1.3** (latest stable, 22 October) with the
existing locked **2.1.0 client/plugin** dependencies, and **recommend**
it.
- Keep the technical **minimum at 2.1.2**: nothing CodeNomad consumes
changed, so the minimum has no new reason to move.
- A 2.1.2 daemon stays ready and gets an optional upgrade offer. 2.1.3
is the `tested` version. Newer and custom labels keep authenticated
contract recognition.
- The reviewed-contract fast path lists 2.1.3 beside the 2.1.2 minimum.
- The recommended version is updated in the help (all ten locales), both
READMEs, `dev-docs/OPENCODE_COMPATIBILITY.md` and the architecture
skill.

## Contract review (v2.1.2 → v2.1.3, 66 commits)

- **Authenticated OpenAPI:** 119 → **121 paths**, 144 → **146
operations**. Every consumed operation is unchanged. The only changes:
  - added `GET /api/experimental/voice/models`;
- added `POST /api/experimental/voice/session/{sessionID}/instructions`;
- an optional hosted `session.id` in the `POST
/api/experimental/voice/session` response.

  None of these is on the instance proxy allowlist, so they stay closed.
- **Published declarations:** the client adds only `voice.models` /
`voice.instructions`. The schema changes only typing (agent `color`
decoding, `Durable` as a tagged union). The plugin is unchanged.
- **Windows managed services** move their default port from **45278 to
28894** ([#57428](https://github.com/anomalyco/opencode/pull/57428)).
CodeNomad discovers the daemon through the selected CLI's `service
status` and the service registration, and assumes no port.
- Live check: this machine's shared daemon was upgraded to 2.1.3 and
restarted on 28894, and the running CodeNomad desktop kept working
through it.
- **Core:** mostly refactors, plus behaviour fixes that the native
suites below exercise:
  - compaction summary output limit;
  - `keepID` validated as a message ID;
  - MCP media kept out of attachments;
  - only changed instruction entries resent;
  - relative-directory V1 migration;
  - legacy `apply_patch` permission mapped to `edit`.

## Isolated Windows qualification

Base: dev `333d73452`, rebased on `853b14f2f` (#1097) for the final UI
unit run; Node **24.20.0**; exact official CLI **2.1.3**; unchanged
locked client/plugin **2.1.0**.

- **Build and checks:** production server/UI/native plugin builds,
server and root typechecks, native bundle guard.
- **Native runtime (9/9):**
  - Code Mode job ordering, plugin runtime, automation;
  - complete pruning, history, ownership, worktree and event relay;
  - loaded-only pending snapshot;
- Missions contract, family control, native and Compare/Wayfinder
trajectory.
- **Sessions (15/15):** environment, fork, aside, interrupt, lifecycle,
blank cleanup, prompt skills, provider usage/accounts/selection, service
usage, web search, plugin packages, output limit, Promise-tool failure.
- **Recurring (5/5):** all **11 recurring Mission journeys**, subagent
depth, **2 MiB** compaction, permission receipts.
- **Migrations (9/9):**
- history from **2.0.3, beta-19271, 2.0.32, 2.1.0, 2.1.1 and 2.1.2** to
2.1.3;
  - Git degraded/init;
  - temporary workspaces.
- **Installation (3/3), with the 2.1.3 recommendation:**
  - fresh install of the 2.1.2 minimum;
- native **2.0.15 → 2.1.3** upgrade, after which the already-running
daemon keeps its PID and 2.0.15 version;
  - explicit OpenCode **1.18.10 → 2.1.3** replacement.
- **After the recommendation change:**
  - admission/transport/update/setup units **36/36**;
  - full server **2,463 passed, 8 skipped**, no failure or cancellation;
  - UI units **1,694/1,694** (with #1097);
  - focused browser **126/126**;
  - native pruning with the real rendered UI;
  - the exact CI invocation of the Code Mode regression (without tsx);
  - ten-locale help checks.

The UI unit run on `333d73452` itself had two failures, in
`instances-pending-compaction.test.ts` (`toast.dismiss is not a
function`). They come from #1096 and also fail on unmodified `dev`;
#1097 fixes that fixture.

This is Windows automation evidence. In CI, `latest` now resolves to
2.1.3: the compatibility job runs the native suites against the minimum
and latest on Windows, and the migration and Git-degraded certification
runs them on Ubuntu, Windows and macOS. WSL, packaged desktop,
installers/updater end-to-end and real mobile devices keep their
separate release gates.
2026-10-22 16:17:20 +02:00
Pascal André
853b14f2f3
test(ui): stub toast dismissal in the pending-compaction fixture (#1097)
## Problem

Since #1096, the UI unit job on `dev` fails in two
`instances-pending-compaction.test.ts` tests, with `TypeError:
toast.dismiss is not a function` (also in #1096's own PR run).

#1096 shows one bubble per session, so a later compaction end dismisses
the session's earlier bubble. These tests end several compactions in one
session. Under Node with tsx, `import toast from "solid-toast"` yields
the CommonJS module object, which has no `dismiss`. The fixture replaced
only `toast.custom`, so the dismissal threw.

The app is not affected: Vite resolves the real `toast` function, which
has `dismiss`.

## Fix

The fixture also replaces `toast.dismiss` with a no-op and restores it
after each test, as `session-lifecycle-signals.test.ts` already does.
Test-only change.

## Validation

- Reproduced on unmodified `dev` (`333d73452`): 2 of 7 tests in the file
fail.
- With the fix:
- `instances-pending-compaction`, `compaction-notice` and
`session-lifecycle-signals`: 18/18;
  - full UI unit suite: 1694/1694;
  - UI typecheck.
2026-10-22 16:09:53 +02:00
Pascal André
333d734521
fix(notifications): show one notification per session compaction (#1096)
One automatic compaction stacked several identical "Session … was compacted" notifications. The notification path was not idempotent: every delivered session.compaction.ended created a bubble (and a history entry when routed there), and the same event reaches a window once per project tab open on its folder (the server publishes one envelope per owning workspace) and again when the event stream replays it.

stores/compaction-notice.ts claims each compaction end once per window by its durable position (aggregate and sequence, or the event ID without one), within a bounded memory of recent compactions; later deliveries still update the session's status in their tab but no longer notify. One bubble per session: a later compaction of the same session replaces the bubble still shown, each bubble is forgotten when it expires unless a newer one replaced it, and a notice routed away from the screen retains nothing. Each distinct compaction still reaches the history when routed there.

Validation: unit tests for the claims and the bubble lifecycle; a browser regression delivers one compaction to two tabs on the same folder plus a replay through the real event dispatcher and toaster and asserts one bubble and one history entry, then one replaced bubble and a second entry for the next compaction (three bubbles without the claim, two without the replacement). notification-icon-badge and compaction-responsiveness pass; UI own-tsconfig typecheck and root typecheck pass. Gatekeeper review (GPT-6.1 Sol): one P3 (retained bubble handles) fixed, then zero actionable findings at 585140abfe6b9379a19186a5846fd9da1b2c4273.

Fixes #986
2026-10-22 15:34:44 +02:00
Pascal André
f8763d7dd5
fix(transcript): name the agent and model in message headers like the composer (#1095)
Agent message headers showed raw identifiers (build • claude-code/claude-opus-5-5[1m]:fast • xhigh) while the composer footer names the same choice Build, Opus 5.5 Fast, xhigh. The header now looks up the agent and model names in the instance's agent and provider catalogs, as the composer selectors do, and keeps the identifier when an agent or model is missing from them. A usage-metrics browser test asserts the header text and fails on the previous code; usage-metrics and the UI typecheck pass. Independent review approved with no findings.
2026-10-22 14:04:06 +02:00
Pascal André
9f93729882
fix(opencode): explain why OpenCode 2 cannot be installed beside an older command (#1093)
## Problem

On an Ubuntu 24.04 VM, OpenCode 1.17.18 was installed by curl in
`~/.opencode/bin` and also linked as `~/.local/bin/opencode`. With that
setup, the OpenCode panel offered **no installation**, only the generic
"Select a supported executable or install @opencode/cli on the execution
host" advice.

On macOS/Linux the user npm prefix is `~/.local`, so that link occupies
OpenCode's command name exactly where OpenCode 2 would be installed
beside the older version. npm refuses to link over it, and CodeNomad
never modifies a foreign installation. The plan therefore correctly had
no prefix, but nothing told the user why.

## Fix

`sharedInstallationPlan` reports the obstacle on the foreign
installation, forwarded as `foreignInstallation.blocked`:

| `reason` | `path` | When |
|---|---|---|
| `occupied` | the occupying command | a non-npm command holds
OpenCode's name in the user npm bin directory |
| `unwritable` | the user npm prefix | the prefix, or a recognized npm
installation in it (an `@opencode/cli` launcher, a Windows npm shim or
an `opencode-ai` 1.x installation), cannot be written |

The installation notice then says how to clear it: move or rename that
command, or fix the folder's permissions, then choose **Check status and
updates**. The usual curl layout, on PATH only through
`~/.opencode/bin`, still offers **Install OpenCode 2 for CodeNomad**.
This was verified on the same VM, where the installation then succeeded.

The English messages and their nine translations, the `opencode.md` help
page in all ten locales and `dev-docs/OPENCODE_RUNTIME_SETUP.md`
describe the obstacle.

## Tests

- `legacy-installation.test.ts`: builds the exact VM layout (link in the
user npm bin directory) and checks that the plan never offers a prefix
over the foreign command and reports `occupied`. The ordinary layout
must still offer the prefix, and a read-only prefix must report
`unwritable` (POSIX, non-root).
- `opencode-setup.test.ts`: renders the occupied notice with no install
action.
- A second POSIX regression puts a verified npm OpenCode 1.x
installation in a read-only user prefix and expects `unwritable`, never
`occupied`.
- Results:
- the `opencode-update` unit tests: 51/51 on Windows (1 POSIX-only
skipped); on the Ubuntu VM as uid 1000, 48 passed, 0 failed, 4
Windows-only skipped, including both POSIX cases;
  - the `opencode-setup` browser tests: 34/34;
  - the server and UI typechecks;
  - `npm run test:help`.
2026-10-22 13:49:20 +02:00
Pascal André
6b8279ca46
fix(tauri): keep the Linux native menu bar hidden without losing accelerators (#1092)
## Problem

On Linux the **Tauri** app showed a GTK menu bar ("File Edit View Window
Help", in English until the renderer reported its labels) above the
application's own title-bar menus, so every window had two menu rows.

Windows and Linux present menus in the title bar, and the host already
called `hide_menu()` on non-macOS platforms, but on Linux the hide never
stuck. Windows are created invisible, hidden, then shown, and Tao shows
a window with GTK's `show_all()`, which shows every child widget again,
including the menu bar.

## Fix

`menu_bar::hide` (new `menu_bar.rs`) replaces the four scattered
`hide_menu()` calls: local windows, Preferences, fullscreen restore and
fullscreen toggle.

- **Windows**: same `hide_menu()` as before.
- **macOS**: nothing, since its menu bar is global.
- **Linux**: on the main thread, where GTK widgets live, the GTK menu
bar is also marked **no-show-all**, so `show_all()` leaves it hidden.
- GTK only activates the accelerators of on-screen widgets. A merely
hidden bar would therefore have disabled native accelerators such as
`Mod+Shift+N` (New Window) that the visible bar provided, and that
`docs/help` documents. The bar answers `can-activate-accel` with `true`,
connected once per bar.

`gtk` becomes a direct Linux dependency, already locked at 0.18.2
through Tauri. `dev-docs/UI_SHELL.md` documents the hidden native bar;
the public help stays accurate.

## Validation

Ubuntu 24.04 VM, AppImages repacked around the released AppDir:

| Build | GTK menu bar | Ctrl+Shift+N |
|---|---|---|
| Original binary | shown above the title-bar menus | opens a new window
|
| Hide + no-show-all only (intermediate) | gone | falls through to the
renderer's New session ✗ |
| **This PR** | gone in the main, second and Preferences windows | opens
a new window ✓ |

Quitting leaves no helper process or crash report. `cargo check -p
codenomad-tauri` passes on Windows, and the Linux release build compiles
the GTK calls.
2026-10-22 13:05:51 +02:00
Pascal André
9d9fc8d235
fix(tauri): stop AppImage helpers before exit so WebKit never crashes on quit (#1091)
## Problem

Quitting the Linux **Tauri AppImage** can show Ubuntu's "Sorry, Ubuntu
24.04 has experienced an internal error" dialog. apport records a
`WebKitWebProcess` crash with **SIGBUS**.

The AppImage runtime unmounts the bundle as soon as the main process
exits. WebKit's helper processes, started from that mount, can still be
running for a moment: their next page fault in the vanished mount raises
SIGBUS.

- Ubuntu 24.04 VM, `1.0.0-dev-20261022-1840c710` AppImage: 2 crashes in
3 quits.
- The same build extracted with `--appimage-extract` (no mount) never
crashed.
- Stable releases shipped Tauri Linux as a `.deb`, so the problem comes
with the AppImage distribution.

## Fix

On `RunEvent::Exit`, which covers ordinary quits and updater restarts,
an AppImage launch (`APPIMAGE` + `APPDIR` set) stops its direct child
processes whose executable lies under the mount, before the process
exits. The WebKit helpers are direct children of the main process. The
new module is `appimage_exit.rs`:

- each child is pinned with a **pidfd**, then checked again as a child
of this process running from the mount, so a process that reused a PID
is never signalled;
- `SIGTERM`, then pidfd polling up to 1.5 s, then `SIGKILL` and a final
0.5 s;
- children running from elsewhere (the shared OpenCode service) keep
running;
- `.deb`/Electron, Windows and macOS are unchanged; kernels without
`pidfd_open` keep the previous behaviour.

`dev-docs/TAURI_UPDATER.md` documents the exit contract and its
validation.

## Validation

- **Linux unit tests** (new): `/proc/<pid>/stat` parsing with command
names containing spaces and parentheses, and a real child started from a
temporary "mount" that is stopped while a child started elsewhere keeps
running. They pass on Ubuntu 24.04.
- **Real AppImages** on the Ubuntu 24.04 VM, repacked with appimagetool
around the released AppDir, once with the original binary and once with
the fixed one:

| Trial | Original binary | Fixed binary |
|---|---|---|
| Secondary `WebKitWebProcess` frozen (`SIGSTOP`) before closing the
window | survives the main process, i.e. outlives the mount | stopped
before the main process exits |
| Normal quit | 0.06 s, no leftover | 0.08 s, no leftover, no crash
report |

  The shared OpenCode service kept running throughout.
- `cargo check -p codenomad-tauri` passes on Windows.
- Running the whole crate suite on the VM, only the cross-host election
tests (they need the full checkout with `tsx`) and the zsh test (zsh not
installed) failed. Linux CI has both.
2026-10-22 12:36:48 +02:00
Pascal André
1840c71054
fix(timeline): mirror every transcript block in order and keep clicks on the clicked marker (#1086)
Some checks are pending
Publish help / build (push) Waiting to run
Publish help / deploy (push) Blocked by required conditions
## What changes for the user
The timeline now matches the transcript block for block, and a click
lands exactly where it should.

- **One marker per visible block, in transcript order:** each reply,
each thinking run, each tool (or collapsed tool group), each compaction
and each system context. Markers follow Message content: a block that is
hidden in the transcript is hidden in the timeline too.
- **The clicked marker stays active** until you scroll, and its own
block, not just the start of its message, is placed at the reading
position. This also works for history that is not loaded yet.
- **While scrolling,** the active marker is the first block below the
reading line.

## Reproduced in numbers before fixing
We replayed 45 of the user's real sessions (65,241 messages, exported
read-only) into the real `SessionView`, with the outline, windows and
previews served by the real server readers. Each session was measured at
1280×800 with two presets, using 16 real pointer clicks and 8 wheel
scrolls per run. Baseline and fix used the same seeds.

| Metric | Before, Complet | Before, Balanced | After, both presets |
|---|---|---|---|
| Sessions out of sync | 43/45 | 42/45 | 0/90 runs |
| Markers / expected | 68,949 / 130,818 | 68,949 / 98,759 | 130,818 /
130,818 and 98,759 / 98,759 |
| Click: clicked marker active | 53.6% (46.0% the one above) | 57.2% |
1,440 / 1,440 |
| Click: block at reading position | 41.5% (median off by 35 px) | 82.2%
| 1,440 / 1,440 (median 0.27 px) |
| Scroll: reading-line block active | 93.1% | 92.2% | 505 / 505 moving
wheels |

What was wrong with the markers in the Complet runs:
- 29,782 tool markers were collapsed into one marker per step.
- 31,777 thinking blocks and 282 system blocks had no marker.
- There were 10,232 order inversions.

## Causes
1. Once an outline existed, the outline projection replaced the markers
of loaded messages too (`message-section.tsx`). It collapsed a step's
tools into one marker placed before its reply, and dropped system
blocks.
2. Resident segments merged thinking into replies, dropped thinking-only
steps and skipped system parts.
3. After a click or a scroll, the active marker was the message's first
marker. The message itself came from a scroll offset that rounds a row
starting just below the top to the previous message.
4. Clicks navigated to the message, not to the block.
5. Jumps into unloaded history had no row to hold while the target was
not yet rendered.

## How
- **Server (outline):** each assistant step can now carry an optional
`layout` field. It lists the step's visible parts in order: thinking
runs, reply runs and each tool, at most 64 entries. It is computed in
the same bounded SQL. Older plugin copies don't send it, and the client
then falls back to the counts: thinking, then text, then tools.
- **Markers:** `buildTimelineSegments` emits one marker per block, keyed
`messageId:type:ordinal`. Loaded messages keep their exact markers.
Unloaded steps are placed at their native position from `layout`.
- **Click and scroll:** the list's `scrollToKey(key, selector)` holds
the selected block on every frame, survives row remounts, and follows
Virtua's estimate until the row renders. `timeline-transcript-blocks.ts`
maps markers to blocks and finds the block at the reading line.
- **Saved indexes:** outline indexes saved without `layout` are
revalidated once.
- **Docs and help:** updated in `dev-docs/SESSION_HISTORY_NAVIGATION.md`
and in the help pages for all 10 locales.

## Behaviour to be aware of
- **The timeline has more markers:** about +90% with Complet and +43%
with Balanced on this sample, because every tool, thinking and system
block now has its own marker.
- **Tool icons for unloaded steps:** a step not loaded yet shows its
first tool's icon on all of its tool markers, until its window loads.
- **Plugin requirement:** the pruning plugin must ship with the new
`layout` field.

## Validation
- **New regression test:** `tests/browser/timeline-sync.test.ts` passes
4/4 and fails 4/4 on unmodified dev. Fixtures are synthetic.
- **Measurement harness:** the opt-in qualification `npm run
qualify:browser:timeline-sync`.
- **Browser suites:**

  | Suite | Result |
  |---|---|
  | history-navigation | 65 |
  | message-scroll | 128 |
  | session-rendering | 47 |
  | render-cost | 17 |
  | full-source-copy | 12 |
  | search-highlight | 11 |
  | system-messages | 10 |
  | timeline-tool-filters | pass |
| streaming-outline (no full reprojection over 64 token deltas) | pass |
  | session-search | pass |
  | transcript-presets | pass |

- **Other checks:** UI unit tests 75/75, server session-pruning 70/70,
help 9/9 and the UI typecheck pass.
- **After rebasing on dev:** the typecheck passes, along with
timeline-sync 4/4, timeline-tool-filters, full-source-copy 12/12,
usage-metrics 5/5 and streaming-outline.

Oversized files touched: `message-section.tsx` (~1,637 lines),
`virtual-follow-list.tsx` (~1,368), `message-timeline.tsx` (~508),
`message-timeline.css` (204).
2026-10-22 05:32:43 +02:00
Pascal André
761aa66486
fix(opencode): keep a working connection when a periodic recheck is inconclusive (#1090)
The shared OpenCode connection is rechecked by the first acquisition more
than 30 seconds after its last check, by rerunning CLI discovery (`service
status`, `service get password` and an authenticated status probe, bounded by
the caller's deadline). When that discovery failed or reported no service,
the healthy connection was invalidated. A slow daemon, or slow CLI spawns on
Windows, could therefore tear down a working connection. Every event stream
on it then broke and the relay reconnected in the middle of streaming: the
reconnection the transcript now survives (#1088), triggered without any real
failure.

Discovery failure and absence are now inconclusive. The working connection
is kept and the next recheck waits for the following interval, so a slow
service is not re-probed on every request. A discovered daemon that differs
in URL, PID, version or credentials still replaces the connection
immediately. A dead connection is still retired by failed location
validation (`withClient`), proxy transport or authentication failures and
upstream event-stream EOF/errors, after which the next acquisition discovers
or starts the service as before. An inconclusive recheck never starts
another service. A frozen daemon that accepts TCP but never answers is now
detected through those paths, bounded by Undici's inactivity timeouts,
rather than by the periodic recheck.

Validation: two new `opencode-service` tests fail on the previous code. One
keeps the connection through a timed-out and an empty recheck without
rediscovering before the next interval or starting a service. The other
keeps a live event stream delivering events while another request's recheck
fails. The shared-service, connection generation, setup reconnect, event
relay/routing/ownership, subscriber lifetime, pending discovery, plugin
lifecycle and proxy suites pass (82/82), the wider workspace manager,
CLI/WSL lifecycle, native registration and opencode-update suites pass
(227/227), and the server typecheck is clean.
2026-10-22 00:47:04 +02:00
heunghingwan
0d6629c129
fix(transcript): keep streaming live across shared-stream reconnects (#1088)
Some checks are pending
Publish help / build (push) Waiting to run
Publish help / deploy (push) Blocked by required conditions
fix(transcript): keep streaming live across shared-stream reconnects

## Problem

Long model reasoning streams stop updating the transcript roughly 30
seconds in: the visible thinking text freezes, and the display only
catches up when the thinking phase ends. Shorter streams never freeze.

A diagnostic reproduction against the real SDK/dispatcher pinned the
freeze exactly: after a mid-stream `server.connected`, ten live
reasoning deltas left the store text frozen at its pre-reconnect value
and **zero** `message.list` reads were even attempted — even
`session.reasoning.ended` and the execution terminal events could not
recover it.

## Root causes

1. **~30 s trigger** — after `CONNECTION_RECHECK_INTERVAL_MS`, any
request through the shared OpenCode service re-runs discovery; a failed
or changed probe (a busy daemon is slow to answer) invalidates the
healthy connection. The shared event stream tears down and resubscribes,
and every subscription starts with `server.connected`.
2. **Reset mid-stream** — the UI treated `server.connected` as a full
data reset (`destroyOpenCodeData`). The fresh reducer starts empty:
reasoning deltas no-op (`editReasoning` cannot find the message) and
nothing repopulates the projection until a later `session.step.started`
inserts the assistant row again.
3. **Starving recovery** — repopulation requires one `message.list` read
to return with an unchanged event revision; each arriving delta restarts
the read (the guard that never merges an obsolete page into newer
streaming messages). During continuous streaming that read cannot
complete, so any reconciliation starves until the stream pauses.

## Changes

- `stores/opencode-data.ts`: `server.connected` now runs
`reconnectOpenCodeData` instead of a bare destroy. A shared-stream
reconnect is transport recovery, not a data reset:
- The reset semantics are kept (generation fences, observer rebinding,
REST state untouched) — every existing reconnect test passes unchanged.
- Before the reset, each live session's last projected page is captured
and seeded into the fresh reducer through the existing `syncMessages`
snapshot mechanism, so post-reconnect deltas keep applying and the
display keeps streaming.
- One trailing reconciliation is scheduled per session (new non-dropping
`reconnectResync` mode): it fetches only after the stream stays quiet
for 1 s with a stable revision, then swaps in the authoritative page to
repair anything the disconnect window missed. It never drops events and
defers to dropping-mode recovery when an overflow collapse or pruning
takes over.
- `stores/opencode-data-reconnect.test.ts` (new, in the CI unit list):
three regressions — the revision-race read invariant (obsolete pages
never merge), dropping-mode admission (frozen until the stream pauses),
and reconnect continuity (deltas keep displaying; the quiet snapshot
republishes).

## Behavior compatibility

Suites covering reconnect semantics (projection reset, inbox observer
rebinding, compaction reconnect fencing, replay fences, generation
recovery, request authority) pass without modification.

Dropping-mode resync semantics (overflow, pruning) are unchanged and now
pinned by a regression test.

`opencode-data.ts` is 978 lines after this change (over the ~800
target); splitting the reconnect path into its own module is a
reasonable follow-up.

## Pre-existing failures (not caused by this PR)

Four tests fail identically on a clean checkout (verified by stashing
this change and re-running): the pinned `@opencode/client` 2.1.0 /
event-manifest contracts drifted and the fixtures were not updated:

- `opencode-transcript-replay.test.ts` × 2:
`session.compaction.started`/`ended` fixtures no longer decode (the
manifest now requires `data.recent`); both integration tests die in
fixture construction before any assertion runs.
- `opencode-data.test.ts` ("preserves SDK step, snapshot, reasoning…"):
expects the old `native` field on reasoning parts; the 2.1 reducer maps
it to `state`.
- `session-pruning-projection.test.ts`: expects a provider
`reasoningField` mapping that the current contract no longer returns.

These are fixture/expectation updates against the pinned SDK, unrelated
to the streaming machinery; CI may already be red on them. They should
be repaired in a separate PR so this fix stays reviewable.

## Validation

- Red→green: the new reconnect regression fails on the pre-fix tree at
the frozen-store assertion (`'before '` vs `'before after0 after1 '`)
and passes with the fix.
- UI unit baseline (CI file list + new file): 552 tests, 548 pass, the
same 4 pre-existing failures before and after.
- `tsc --noEmit`: the same 4 pre-existing errors before and after.
- Browser: mission-control 24/24, provider-usage 13/13,
mission-task-reader 18/18, message-scroll 128/128. `part-durations`
page-load timeouts reproduce identically on a clean tree (Vite
cold-start environment issue).
2026-10-21 23:34:37 +02:00
Pascal André
1f0fc4fe9f
fix(worktrees): omit a checkout mid-removal instead of failing the whole inventory (#1089)
fix(worktrees): omit a checkout mid-removal instead of failing the whole inventory

Removing a worktree with `git worktree remove` deletes its `.git` file first and
the rest of the folder afterwards, which takes minutes when the checkout still
holds `node_modules`. During that window the folder and its registration still
exist, so the native inventory inspected the entry, `readCheckoutIdentity` hit
ENOENT on `.git`, and the shared scan failed the entire inventory. Every
ownership check depending on it failed too: conversations in other worktrees
showed "Internal Server Error" for messages, timeline and permission receipts,
and pending-request recovery reported every folder of the project as
unverifiable, until the removal finished.

The inventory now treats an entry whose Git layout no longer exists (missing
`.git`, or a `.git` pointer whose administrative directory is gone after an
interrupted removal) like a vanished folder: it is omitted and the other
checkouts are listed normally. Any other identity failure keeps failing the
inventory closed. A half-removed checkout is not a usable worktree, so
conversations still placed there stay unowned until it is gone, while the rest
of the project keeps working.

Validation: a new isolated Git fixture registers healthy, mid-removal and
orphaned worktrees, deleting the orphan's administrative directory during
inspection (after Git's annotation snapshot) to cover that race. On the
previous code it fails with `ENOENT: stat '<removing>/.git'`; with the fix the
inventory lists only the root and the healthy checkout. The native custody, inventory, display fence,
directory, session evacuation and event ownership suites pass (40/40), and the
server typecheck is clean.
2026-10-21 22:24:01 +02:00
Pascal André
1b97c7df8a
fix(transcript): jump between user messages with Alt+Up/Down, also from the composer (#1087)
Shift+Up/Down already selects text in every text field, so the user-message jump could not work from the composer, where people usually are. The defaults become Alt+Up/Down, and Cmd+Option+Up/Down on macOS, where Option+Up/Down moves to a paragraph's start or end in text fields. Neither edits text, so the jump now also works from the composer and other text fields, keeping the draft, its caret and focus; Shift+Up/Down selects text again everywhere. Saved custom bindings are unchanged.

The key guard allows text fields and leaves modified arrows where they already mean something: code editors (Monaco moves lines) and terminals, arrow-key widgets, selects and popup triggers (Alt+Down opens them, such as the new-instance launcher), fields that move a list's active option (aria-activedescendant, input[aria-controls], such as the mission picker search), non-text inputs and IME composition. The composer's / and @ picker moves its selection with every arrow key, so its root carries data-prompt-picker and the jump yields while it is open. Platform defaults live in defaultHotkeyBindings, like prompt-alternate-send. The help shortcuts table (10 locales) and HOTKEYS.md describe the new defaults.

Validation: unit tests for the platform defaults (no conflict on either platform) and the key guard; browser regressions jump with Alt from the conversation and the composer (draft, caret and focus kept; Shift+ArrowUp still selects), keep the key for an open composer picker, and, with a real MissionPicker for Alt and emulated-macOS Cmd+Option, move the picker search's option without moving the conversation (the guard checks fail without their selectors). hotkeys-settings and composer-picker-enter pass; UI own-tsconfig typecheck, root typecheck and test:help pass. Gatekeeper review (GPT-6.1 Sol): one P2 (mission picker search) fixed, then zero actionable findings at 7eb2cb081da4ebb1a04e5a7391322deaeb154dd4.

Refs #1075
2026-10-21 20:36:02 +02:00
Pascal André
b731dd6eb8
fix(transcript): keep the collapsed token summary muted and the chevron level at any font size (#1085)
fix(transcript): keep the collapsed token summary muted and the chevron level at any font size

#1084 put the expanded token chips on the disclosure's own row. In doing
so, the collapsed summary lost the shared metric-row class that set its
10px muted text. The chips keep their own style, but the ellipsis of a
truncated summary (narrow layouts) was drawn at 16px in the primary text
colour. The summary now carries that text style itself, so its ellipsis
matches the chips again.

The chevron's level offset was a fixed 5px, computed for a 12px chevron.
The chevron is sized in rem and the chips in px, so under a non-default
root font size (24px) its centre sat 3px below the first chip row. The
offset is now half the difference between the chip height and the
chevron's rem size, which keeps it level at any root font size.

Validation: usage-metrics now asserts the collapsed summary's 10px muted
text and the chevron alignment at a 24px root font. Both assertions
failed before this change (16px; 3px offset). usage-metrics 5/5,
status-tokens 4/4 and transcript-presets 1/1 browser tests pass, and so
does the UI typecheck.
2026-10-21 18:58:56 +02:00
heunghingwan
c716f796d8
ui: keep the message token disclosure on one row in both states (#1084)
## Summary

The token/cost line that closes every agent message previously turned
into two
rows when expanded: a bare `Tokens` label line (chevron + label) with
the six
metric chips on a second line below it. This PR keeps the disclosure on
one row
in both states — expanding swaps the collapsed summary chips for all six
chips
on the very same line as the chevron.

## Problem

- Collapsed, the row is a compact single line: `› INPUT 3K · OUTPUT 54 ·
COST $0.76`.
- Expanding it replaced that line with a label-only row (`› Tokens`)
plus a
separate chip row, so a one-line affordance grew to two rows and pushed
the
  transcript content down twice as much.

## Change

- **Expanded = same row.** The six chips (input, output, reasoning,
cache read,
cache write, cost) now render in place of the summary chips, beside the
chevron. The `Tokens` label line and the separate content row are gone;
at
wide widths the expanded card is exactly as tall as the collapsed card.
- **Wrapping is preserved.** Expansion exists to read the values, so the
expanded row is never ellipsized: when width runs out, the chips wrap
onto
further lines in place (this wrapping behaviour is unchanged from
before).
- **Collapsed is untouched.** The collapsed summary keeps its single
  ellipsized line; its rendering is pixel-identical to before.

**Expanded — wide layout** (before shows the label line + chip row;
after is one row):

<img width="884" height="344" alt="wide-expanded"
src="https://github.com/user-attachments/assets/06abd5cd-b15d-4b29-ab39-1202d4c35ca5"
/>


**Expanded — narrow layout, 220 px** (both wrap instead of truncating;
after drops the label line and starts the chips beside the chevron):

<img width="390" height="339" alt="narrow-expanded"
src="https://github.com/user-attachments/assets/e4e5fbe7-a37c-42f6-8f73-3628d21ac2c6"
/>


**Collapsed — unchanged** (wide and narrow; pixel-identical
before/after, narrow still ellipsizes):

<img width="884" height="310" alt="wide-collapsed"
src="https://github.com/user-attachments/assets/71b40a15-6a19-4232-8eef-e0ce1327ff90"
/>


<img width="390" height="175" alt="narrow-collapsed"
src="https://github.com/user-attachments/assets/f77ebdd9-0540-4c74-9f2b-3a3fb64a7d9a"
/>


## Implementation notes

- `packages/ui/src/components/message-usage.tsx`: the toggle now always
renders
`chevron + chips` — the collapsed state renders the three summary chips,
the
  expanded state renders all six in the same slot. The separate expanded
  content `<div>` and its `aria-controls` wiring are removed.
- `packages/ui/src/styles/messaging/message-usage.css`:
`.message-step-usage`
is the expanded wrapping row (`flex-wrap`, no truncation); the collapsed
`.message-step-usage-summary` keeps its single-line ellipsis. The unused
  `.message-step-usage-label` / `.message-step-usage-content` rules are
  deleted. The chevron pins to the first chip row when the row wraps.
- **Accessibility:** the button's `aria-label` carries all six full
values when
expanded (`aria-label` overrides the visible chips, so they are repeated
there deliberately); the collapsed label is unchanged. The shared
disclosure
  chevron rotation keeps working via the generic `aria-expanded` rule.
- **dev-docs/TRANSCRIPT.md:** the message-token disclosure convention
note is
  updated to describe the one-row layout.

## Help pages

No help change needed: `docs/help/*/conversations.md` documents the line
as
*"expand it for reasoning and cache counts"*, which remains true — the
documented behaviour is unchanged and the two-row layout was never
documented.

## Validation

- TDD red→green: the new assertions were watched failing first (`0 !==
6`
  chips on the row, stale `aria-controls`), then passing.
- `tests/browser/usage-metrics.test.ts` — 5/5, now also covering:
expanded
chips on the toggle's row; single row at wide width; wrapping onto
multiple
  rows at 220 px in LTR **and** RTL with no horizontal overflow and no
  ellipsis; chevron level with the first chip row; Space/Enter collapse
  restores the three-chip summary.
- Related suites that render the usage row: `status-tokens` (4/4),
  `transcript-presets` (1/1), `search-highlight` (0 failures).
- Visual: before/after screenshots captured from the real fixture at 900
px and
220 px (the composites above); collapsed rendering verified
pixel-identical.
2026-10-21 18:52:11 +02:00
Pascal André
7a1fc90310
docs(agents): keep AGENTS.md to universal rules and agent docs to the current state (#1082)
AGENTS.md loaded about 33,000 tokens into every agent session, subagent and reviewer, mostly per-feature contracts, and the agent documentation around it had accumulated duplicates, dated logs and history of removed designs. This PR makes AGENTS.md a short set of rules that apply everywhere plus an index of area documents, and leaves AGENTS.md, dev-docs and the repository skills describing only the current state.

Three commits, reviewable separately:

1. **Move (lossless).** Every per-area rule moved verbatim from AGENTS.md into its area document (existing ones, or six new ones: UI_CONVENTIONS, UI_SHELL, COMPOSER, TRANSCRIPT, SETTINGS, PROJECTS). A script split the old file into 208 units, required one destination for each and checked all 199 content units verbatim at their destination. AGENTS.md went from 134,000 to 16,000 characters.
2. **Trim.** A per-area ponytail review removed rules stated two or three times (keeping the more precise copy), dated validation logs, per-version qualification sections below the current minimum, abandoned designs and self-references; seven documents whose surviving rules live elsewhere were deleted. It also fixed statements the code contradicts (locale registration, the Mission play/recovery condition, plugin package updates, cleanup progress UI, timeline index persistence, automation restart/targets, stale pins and minimums).
3. **Current state only.** The new AGENTS.md rule: no history (removed features and their names, former behavior, provenance, dated logs) and no denials; correct a contradicted statement at its source. DEVELOPER_MODE.md is merged into BROWSER_AUTOMATION.md and every Developer Mode mention is gone (including the README and the browser help page in ten locales). Names that only made sense against older designs are renamed with every reference updated, MISSIONS_ENVIRONMENT_ADMISSION is folded into SESSION_ENVIRONMENT, and INDEX lists every document. The server npm README now states the real OpenCode minimum (2.1.2).

AGENTS.md is now about 14,000 characters (roughly 3,500 tokens); AGENTS.md, dev-docs and the vendor README went from about 11,200 lines to under 4,700.

Validation: every relative Markdown link and every cited dev-docs path resolves; no reference to a renamed or deleted document remains; `npm run test:help` passes for all ten locales.

Review: three independent GPT-6.1 Sol (high) reviews by area found binding rules lost in the trim and current-state passes; 25 were restored as present-tense facts or prohibitions and four contradicting statements corrected, each checked against code. A Fable 5.1 gatekeeper review of the whole PR passed; its follow-up routes session recovery from the index, points to dev-docs/INDEX.md and documents the family identity read.
2026-10-21 16:59:43 +02:00
Pascal André
9fb6996576
fix(worktrees): separate fresh recovery and display refresh outcomes (#1080)
## Follow-up to #1076
Separate required-fresh outcomes from display refresh demand. Fresh
failure rejects only fresh callers, queued display work still runs, and
no fresh flag leaks into the next display read. Concurrent fresh calls
coalesce; cached completion cannot satisfy fresh recovery.

One serialized read/publication lane retains priorities and ordering.
Initial display fallback cannot erase a fresh snapshot; existing display
dirty/trailing coalescing retained. No backend/move safety change,
polling or mutation replay.

## Validation
Original queue fails deterministic fresh+fresh+display and
concurrent-fresh regressions. Fixed: relevant UI units82/82
(worktree-ready14/14), worktrees browser13/13, focused interruption-dock
recovery2/2, UI typecheck and diff check pass. Node24.20.0, private
dependencies; no full browser sweep or live app/shared daemon/userDB.
Server unchanged; native fixture not rerun.

Three focused files, none oversized.
2026-10-21 14:53:07 +02:00
Pascal André
ad844d0645
feat(transcript): jump to the previous or next user message with Shift+Up/Down (#1077)
Finding "what did the agent answer when I asked…" meant scrolling or hovering the timeline marker by marker. Two new commands, Previous User Message and Next User Message, appear in the command palette and in Settings → Keyboard shortcuts, bound by default to Shift+ArrowUp and Shift+ArrowDown. From the conversation (where focus lands after a send), they place the nearest user message before or after the reading position at the top, paging its history window in when it is not resident, like a timeline click. Text fields, arrow-key widgets, IME composition and modal dialogs keep the keys, so Shift+Arrow still selects text in the composer; key repeat does not repeat the jump.

components/user-message-navigation.ts searches the timeline's markers, including the structural index beyond the resident window, and uses list order for a current message without a marker. MessageSection handles the shortcut in the capture phase, before the transcript reads arrows as a scroll gesture; the palette commands reach it through JUMP_USER_MESSAGE_EVENT. The jump requests the index even while the timeline is hidden, and a jump finding nothing resident before it arrives runs once it does. The last target stays the reference while loading or shown, since the list cannot raise a message near the end to its top. Both are forgotten in cancelWindowNavigation, which every other navigation (First/Latest, sends, paging, session changes, deactivation) passes through, and on scroll gestures, so a late index never overrides a newer navigation.

The public help lists the shortcut in all ten locales and states the 74-action catalog (it said 49); HOTKEYS.md and AGENTS.md describe the dispatch; labels are translated in every locale.

Validation: unit tests for the target search and key guard plus the catalog count (now run in CI); browser regressions for resident and windowed jumps, the composer keeping Shift+ArrowUp, the deferred jump with a hidden timeline, an explicit Latest dropping it, and repeated jumps near the end (the last three fail without their mechanism). hotkeys-settings, header-windows and history-navigation suites pass; UI own-tsconfig typecheck, root typecheck and test:help pass. Gatekeeper review (GPT-6.1 Sol): one P2 (deferred jump surviving Latest) fixed, then zero actionable findings at 7ab3f17d3cac6be694f989fde8d217c313599f9d (rebased on dev as bf6e09990ba7b65789d5789fe00d87b5e1362625; only the help pages' catalog count conflicted).

Refs #1075
2026-10-21 14:42:17 +02:00
Pascal André
1dabb5110f
docs(help): bring the user guide back in line with the current app (#1079)
docs(help): bring the user guide back in line with the current app

The help was written on 2026-10-14 and about forty user-visible changes merged since without touching it, so several pages described controls that no longer exist: Missions still listed four playbooks and a Save step for preferences that now save on each change, the depth editor kept its old names, notifications still had their former switches, and voice input pointed to a microphone button that is not in the composer.

Every English page was re-checked claim by claim against the current labels (English UI messages) and behaviour, then the nine translations were updated to match, using each locale's own UI labels. Notable corrections: Missions (Compare two models, autosaving defaults with Apply to / Global / Reset, Subsession nesting depth, prepared hand-offs, retired-task interrupts, the named causes of "Mission Control is unavailable", standalone server support); conversations and history (reasoning/tool durations, token lines adding up the cleanup selection, presets including Nothing, timeline markers following Message content, in-place reveal of long text, Open in session, focus after send, Queue message / Steer now, Retrying); settings (notification routing table and defaults, Stats, Mode, info tips, Server setting badge, localized native menus); shortcuts (72 actions, Clear shortcut, Ctrl/Cmd+K); voice (More actions entries, Save status); files (live Workspace refresh, Show 200 more); OpenCode, install, server and updates (2.1.1 recommended, OpenCode 2 beside an older installation, version card first, private loopback listener for Missions, preview releases offered once all builds are published); providers, integrations, browser, SideCars, desktop and remote pages. Translations also repaired older drift, mainly the project-tab closing passages.

The Chat settings table header (Default state, its title, item column, the thinking row and its accessible label) was still English in eight locales; it is now translated so the help can cite what users see. AGENTS.md now asks every PR changing user-visible behavior to update the affected help pages in the same PR, since CI validates structure and links but not whether the text still matches the UI; it also corrects the needs-input notification default. USER_HELP.md no longer claims native menus stay English.

Validation: npm run test:help (all ten locales, 25 pages, heading ids unchanged), a full help build, and the i18n and transcript-visibility unit tests.
2026-10-21 14:38:29 +02:00
Pascal André
f314123dd1
fix(opencode): qualify and require 2.1.2 for correct Code Mode execution (#1078)
Raise the approved external runtime minimum and recommendation to 2.1.2 after reproducing incorrect native Code Mode job ordering on 2.1.0/2.1.1. Keep the independently qualified 2.1.0 client/plugin build pins and preserve bounded contract recognition for custom/future runtimes. Add the real isolated execute regression to the minimum/latest compatibility matrix, align supported installer fixtures without weakening assertions, and update setup/help in all ten locales. Two author-side review passes found no blockers; the final focused set passes 93/93 and native Code Mode passes. Qualification includes builds/typechecks, UI/browser, native pruning/Missions/recurrence, migrations and isolated installation/upgrade. Record the broad server shutdown timeout and its two unchanged isolated passes precisely. Admin merge explicitly authorized by the maintainer without waiting for CI.
2026-10-21 14:36:51 +02:00
Pascal André
8c6a90922e
fix(recovery): reconcile removed worktrees without hiding live interruptions (#1076)
## Problem and fix
A cached UI worktree descriptor can keep a removed idle directory
required after native/backend inventory healed. On inventory-only
missing/not-owned recovery failures, perform ONE fresh worktree
inventory read and retry ONLY directories that genuinely became optional
through the existing loaded-only pending broker.

The failed-move clue exposed another guard gap: preserve source/target
locations and pending move identity after failed/incomplete
reconciliation until complete family inventory plus fresh activity
succeeds. An old read cannot release a newer move, and mutations are
never replayed.

## Safety
- Root/catalogue selection, active sessions, known Forms/permissions and
uncertain move locations remain required.
- No blanket exclusion, directory/session deletion/move, daemon restart,
ordinary pending-list fallback, fake empty coverage or warning
suppression.
- Existing broker negative proofs and post-IO ownership/deletion fences
remain; successful sibling recovery and edited drafts retained.
- The precise12:58 warning in the screenshot is not proven causal: both
directories are now absent from live/Git inventory and not active.
Current healed state does not establish historical bad registration.

## Validation
Red store and real-browser regressions reproduce on base. Green:146 UI
tests,78 server tests,2 focused browser cases; both typechecks and diff
check pass. Isolated real Git/filesystem fixture exercises external
removal with warm display/stale native registration. Read upstream2.1
refresh implementation/test (refresh prunes absent registrations; list
alone does not discover).
No full browser sweep/real-daemon fixture. WSL mocks pass, live
WSL/native hosts unverified. No installed app/shared daemon/user
database/config mutations.

Oversized touched sources: api-client.ts~820, instances.ts~2420,
session-api.ts~2050. Added tests extend existing CI-listed files.
2026-10-21 14:34:17 +02:00
Pascal André
f5dd76d79c
fix(tests): read the abort cause structurally so the server build compiles (#1073)
The supersession regression from #1071 read Error.cause, which the server's own tsconfig library does not declare. Local checks had forced --lib ES2022 and missed it; the packaged build (tsc -p tsconfig.json in the server prebuild) failed with TS2550, breaking the Tauri Windows prerelease build and skipping every other build and the npm publication.

Read the cause through a structural type instead. Validation: server and UI typecheck with their own tsconfig report 0 diagnostics, root npm run typecheck passes, provisioning tests pass. Gatekeeper review (GPT-6.1 Sol): zero actionable findings at 5546acd6f1d228679310dbbe3995e30e45215e66, including a scan of the #1070/#1071 sources for other build-incompatible APIs.
2026-10-21 12:41:07 +02:00
Pascal André
53d2a636de
fix(plugins): report a provisioning cut short by a replaced connection as superseded (#1071)
Replacing the shared OpenCode connection aborts every request still in flight on the old one (the transport's lifetime signal is aborted without a reason). When that happened while the bundled plugins were being provisioned, the backend logged "[ERROR] Cannot provision bundled plugins without the connected daemon's discovery directory" with "This operation was aborted", although the new connection provisions them again. A standalone-server user on WSL reported exactly this line (#1069) and could not tell it apart from a real failure.

Provisioning moves out of index.ts into opencode/desktop-plugin-provisioning.ts. A failure while resolving the discovery directory or installing presence is reported at error level only when the connection is still current; on a replaced connection it is logged at debug as superseded. Behaviour is otherwise unchanged: both steps still return false and the next connection retries.

Validation: a regression drives the real OpenCodeSharedService with a client using the connection's own transport against a daemon that never answers, replaces the connection during the config.get read and requires one debug supersession (with the same abort) and no error; unit cases keep current-connection failures at error level for both steps and treat an install cut short by a replacement as superseded. Related plugin lifecycle, readiness, shared-service and standalone startup tests pass; server typecheck clean. Independent gatekeeper review (GPT-6.1 Sol): zero actionable findings at da387f019c548ed2b19405d1e068ab761176f7e1. Hosted CI was still running at merge.

Why the reporter's connection was replaced is not addressed here; the surrounding log lines were requested on the issue.

Refs #1069.
2026-10-21 12:30:17 +02:00
Pascal André
92ba215254
fix(missions): run Missions on the standalone server and name each unavailable cause (#1070)
## Problem
Missions dispatch through the owning backend's loopback automation
bridge, not through the desktop window. Yet the bridge and the Missions
plugin were only provided when a desktop host launched the server. On
the standalone server (`npx @neuralnomads/codenomad…`, whose default
listener is HTTPS-only), Missions never loaded, and Mission Control told
users to "Reconnect CodeNomad", which cannot help (#1069). Tauri on
macOS/Linux sets no native parent and was affected the same way.

## Fix
- **Private loopback listener:** the HTTP listener always starts. When
HTTP was not requested, it is private and unannounced, on a random
`127.0.0.1` port.
- **Bridge and plugin:** the bridge is published whenever a loopback
HTTP listener exists, and the Missions plugin is provisioned once that
publication settles, so early connections never skip it.
- **Automation stays desktop-only:** without a desktop host, the
bridge's `browser-claim` answers only `purpose: "mission"`, which the
Missions plugin now sends. Browser tools therefore never select an owner
that cannot open a page. Remote Control keeps routing into the
application users reach.
- **One deletion fence per backend:** all listeners now share one
`WorktreeDeletionFence`, and with it the mission creation/recurrence
holds. Previously each listener had its own, so a deletion through HTTPS
could not drain a mission admitted through the HTTP bridge. This could
already happen in desktop dual-listener mode.
- **Exact unavailable causes,** localized in all ten locales:
- `host-unavailable`: this backend has no bridge; reported before
reading OpenCode.
- `plugin-unavailable`: OpenCode has not loaded the plugin. Refresh,
then use **Reload OpenCode configuration**.
- `plugin-failed`: OpenCode failed to load the plugin; its error is in
the Plugins list.

## Validation
- New real startup test: the actual entry point as a standalone
HTTPS-only server, isolated profile, no OpenCode. It proves the private
`127.0.0.1` bridge is published, authenticated (401 without token),
unannounced and removed on shutdown.
- Bridge route tests: mission-only claims without a desktop host,
ownership still required, native requests fail closed. The plugin test
asserts that the claim carries the purpose.
- Missions route tests cover each unavailable reason, and
`host-unavailable` reads nothing from OpenCode.
- The isolated native Missions fixture
(`scripts/test-missions-native.mjs`) passes on **OpenCode 2.1.2**
through the real bridge route with no desktop host.
- Server and UI typecheck: 0 diagnostics. Full server suite running.

Refs #1069
2026-10-21 12:17:49 +02:00
Pascal André
3be331dfa9
fix(transcript): add up each agent message's tokens over its cleanup selection (#1063)
Some checks are pending
Publish help / build (push) Waiting to run
Publish help / deploy (push) Blocked by required conditions
#1059 showed a token line only on agent messages whose model call ended with text, so in OpenCode V2, where an agent message is usually followed by tool calls in the same native message, most messages had no line.

Every agent message now shows a token line at the end of its final text, the row that already carries "delete tools and reasoning". It adds up the message's own native usage and that of every message holding a tool or reasoning part the cleanup selects, reusing that selection instead of a separate projection. Tool-only steps get no line and no line sits between tools, so technical groups stay whole and their Delete stays available; failed messages that show text keep their line; prompts, queued prompts and omitted-part gaps follow the cleanup scope because it is the same selection. A message whose text is followed by tools also counts in the next message's line, since its tools belong to that selection. The line renders inside the content row, so settling or the Tokens filter never shifts the positionally rendered items after it. projectUsageLines, the reserved usage item, its cache signature, hasVisibleUsage and the border sentinel are removed; sumMessageUsage is the only helper.

Validation: sumMessageUsage unit test; usage-metrics covers a line on a text-then-read message placed before its tools, an answer adding up the messages holding its selected reads, one deletable read group, line geometry, the Tokens filter and a settling text-then-tool step that keeps its expanded tool element (fails on the earlier insertion). usage-metrics, search-highlight, status-tokens, full-source-copy, reasoning-collapse, history-navigation, system-messages and render-cost browser tests, session-actions and message-part-grouping unit tests and the UI typecheck pass. Reviewed independently, including by GPT-6.1 Sol (high).
2026-10-21 03:59:47 +02:00
Pascal André
00f39ada16
fix(timeline): space every marker evenly (#1062)
The rail doubled the gap wherever a tool marker started a different message, a collapsed tool group ended or a prompt followed an assistant with tools: a V1 notion of tools belonging to their assistant message. OpenCode V2 records each model call as its own message, so almost every tool started a new group while tools called together stayed tight, giving irregular, pronounced gaps that meant nothing to the reader. Every visible marker now follows the previous one by one uniform gap, and the message-tool bookkeeping is removed.

Validation: render-cost checks the exact 10,000-marker extent with uniform gaps; timeline-tool-filters, history-navigation and system-messages browser tests and the UI typecheck pass.
2026-10-21 03:28:29 +02:00
Pascal André
da0dc279a7
ci(tauri): stop the refcount negative control at its first crash and warn when a runner never races (#1061)
The Windows runtime handle regression builds a copy with the counter fix reverted and required that negative control to crash within ten processes before the corrected build's five runs. The race is timing-dependent: of the last ten PR builds, five runners reproduced it (1 to 8 crashes in ten) and five never did, so tests-tauri-windows failed about every other pull request, unrelated to the change under test.

The negative control now runs up to fifty processes (about 0.16 s each) and stops at the first native fault. On GitHub Actions, a runner that never reproduces it emits a warning naming its CPU instead of failing; local --baseline runs still fail, so a change to the fix cannot pass on a machine that never races. The corrected build's runs, the accepted fault codes and the clean-teardown check for surviving processes are unchanged; the counter-boundary and vendored-Tao checks guard the fix on every runner. Docs state that corrected runs on a warning machine provide no timing evidence.

Validation: syntax check and two independent reviews; the sample comes from the tests-tauri-windows logs of the ten most recent PR Build Validation runs.
2026-10-21 02:35:20 +02:00