Commit graph

267 commits

Author SHA1 Message Date
Pascal André
b31d9e3772
feat(settings): add OpenCode image generation and storage settings (#1193)
## Summary

OpenCode 2.2 ships two opt-in experimental image features. This PR
exposes them in **Settings → Providers → Images**, a new folded group
beside Models and Web search:

- **Image generation** (`experimental.image_generate`): Off, **On,
automatic model**, or a default image model from the native `GET
/api/model?types=image` catalog. Agents then get OpenCode's background
`image_generate` tool while a connected provider offers an image model;
each image asks permission first.
- **Store images once** (`experimental.media_blob`): attached and
generated images are stored once in OpenCode's deduplicated blob table
instead of inside every message row. CodeNomad's direct SQL readers
already hydrate these refs.

No new instance-proxy route: everything stays in OpenCode and its
configuration.

## Design

- **Global scope only.** OpenCode reads `media_blob` (like
`subagent_depth`) from the whole `experimental` object of the
highest-priority document that has one. Writing a project block would
silently hide inherited experimental settings, so the project document
is never written; a project that sets its own choice is reported under
the control.
- **Contract-gated, never version-gated.** The native `experimental`
object has `additionalProperties: false`, so an unknown key would break
an older daemon's configuration. Each control appears only when
`Config.InfoEncoded` declares its key; the backend refuses writes
otherwise. 2.2.1 shows both, 2.2.0 only media storage, 2.1.x neither
(the group says so).
- **Existing authorized document path.**
`PluginControls.readConfigDocuments`/`editConfigDocument` plus
`native-setting-document.ts` targeted JSONC edits, as for Web search and
subagent depth: ownership, deletion and connection fences, comments and
unrelated fields preserved. Turning a setting off removes the key and
any `experimental` object it leaves empty.
- **On demand.** The folded group mounts nothing; the contract and the
image-model catalog are read only while it is open, with coalesced
rereads on config/model/credential events and reconnects.

## Validation

- Server units (`image-settings.test.ts`): contract detection, OpenCode
value forms, effective values and project overrides, JSONC preservation,
empty-object cleanup, route validation.
- New isolated native fixture `scripts/test-image-settings-native.mjs`
(added to the compatibility job for the minimum and latest runtimes,
`--expect-supported` on latest). Run locally on Windows against the
exact CLIs:
- **2.2.1**: both settings detected, written, accepted by the native
schema and hot-reloaded; reset restores the original `experimental`
object.
- **2.2.0**: media storage only; generation write refused, file
untouched.
  - **2.1.4 / 2.1.2**: no settings; writes refused, file untouched.
- Browser regression (real `ProvidersSettingsSection`): no read while
folded, one write per change, no replay after a failed write,
unsupported runtime, 900/380 px widths. The existing web-search tests
still pass.
- `npm run typecheck`, server `tsc --noEmit`, help checks (10 locales).

Not covered here: rendering generated images in the transcript
(OpenCode's completion notice is a synthetic message CodeNomad hides
today). That is the follow-up PR.
2026-10-29 16:47:47 +01:00
Pascal André
a097b9a250
feat(extensions): read a project's issues and pull requests through panel extension API 3 (#1191)
#500 asks for a view of a repository's issues and pull requests next to the sessions working on them. Panel extensions are the place for such a view, but they run sandboxed: no network, no project folder, no credentials. Extension API 3 adds one bounded, host-owned capability, `repository.workItems.read`, so an addon lists and opens a project's issues and pull/merge requests without receiving any of those. Worktree actions stay out of scope for a later, separately consented capability.

Host (server/src/forge/): the project folder's `upstream` remote, else `origin`, else any forge remote, strictly validated (host or bracketed IPv6, HTTPS port, path segments), names the repository. github.com, gitlab.com, codeberg.org and gitea.com are read through the user's signed-in `gh api`, `glab api` or `tea api` (tea's stderr status checked, its login matched to the exact authority), falling back to their anonymous REST API for public repositories; any other host is identified and read only through a CLI signed in to it, never contacted anonymously. CodeNomad stores no token. CLIs run without a shell, quietly, within 15 seconds and 4 MiB; anonymous bodies are bounded while streaming. Each read returns one kind, at most 20 items up to page 50, with the open total; GitHub search words are literal quoted terms, and only items under the repository's web address are published. Discovery and page reads are shared loads cancelled with their last caller, never joined once cancelled and never evicted while running; every process or fetch runs within one host-wide budget (four running, sixteen waiting).

Extension API 3: exact permission set, disabled install, dedicated consent text in ten locales; `codenomad.workItems.list({ kind, query, page, fresh })` and `open(kind, number)`. Routes recheck consent before and after each read, cancel the read of a disconnected caller and drop results when the project closed. `open` asks the server for the item's page on the repository's own forge and opens it through the host's opener, one at a time and at most once a second measured at the opener. API 3 panels receive the host colours; the store's consent check is generalised to a permission and also serves API 2.

Docs: PANEL_EXTENSIONS.md and the extensions help page in ten languages.

Validation: forge.test.ts (remotes, CLI output formats, provider mapping and totals, literal search and foreign items, self-hosted access, bounded streams, shared and cancelled loads, budget, pagination), route tests (consent, digest, bounds, revocation, closing, caller disconnection), work-items-channel.test.ts (delayed answers open one tab, three on the first version), API 3 manifests, the extension browser suite, test-panel-extension-native.mjs --api3/--api2, the root typecheck and help checks. Live, an isolated CLI server with the Contribute addon installed from its ZIP listed NeuralNomadsAI/CodeNomad (gh) and gitlab-org/cli (glab) with totals, labels, search, exact numbers and opening, in English and French; Codeberg read anonymously. Three review rounds ended with no findings.

Refs #500.
2026-10-29 16:45:07 +01:00
Pascal André
34406b3ada
feat(help): add a download page with exact release links and a new home page (#1190)
## Summary

- New **Download** help page (10 languages): an OS-detecting button
linking straight to the recommended Tauri build, then a static
Tauri/Electron table with every asset and its size, version and publish
date, and the winget/npm commands. The header Download link,
`install.md`, `support.md` and the README point to it.
- New **help home page** (10 languages): cockpit positioning, a
*Download CodeNomad* call to action, a silent 46 s tour that autoplays
on desktop only (poster and YouTube link on narrow screens and with
reduced motion), and short Missions / phone access / long histories
sections before the existing orientation sections.
- **Exact links without network in the builder**: `release:<id>` links
and `{{release}}`, `{{download-button}}`, `{{tour-video}}` directives
resolve from `docs/help/downloads.json` and optional `--release` data;
without data, links fall back to the latest release page.
- **Automatic refresh**: `scripts/resolve-help-release.mjs` (CI only)
picks the newest stable release whose assets are fully uploaded;
`help-pages.yml` republishes after a successful *Release Binaries* push
run on `main`, building trusted `dev` sources with read-only
permissions, the token scoped to the resolver step and one production
concurrency group. Pull requests never resolve or publish.

## Safety

- Release data is revalidated in the builder (exact
`github.com/NeuralNomadsAI/CodeNomad/releases/download/<tag>/<asset>`
URLs, no credentials/query/fragment/traversal, positive sizes, UTC
dates) and escaped.
- CSP stays `default-src 'none'`; only `media-src 'self'` is added. The
download script is allowed on the download page only, the tour video on
the home page only.

## Validation

- `npm run test:help`: 27/27 (26 pages × 10 locales, offline; resolver
selection/pagination, release validation, directive problems, 22
detector cases, workflow gates).
- `npm run build:help` with and without the release fixture.
- `node scripts/test-help-browser.mjs`: 46/46 (download button,
JavaScript disabled, video playback, reduced motion, narrow widths,
RTL).
- Read-only resolver run against the live API resolved `v1.0.0`.

The `workflow_run` path can only be observed on the next stable release
on `main`; `workflow_dispatch` from `dev` exercises resolver → build →
deploy.
2026-10-29 16:06:06 +01:00
Pascal André
ca514ed2ea
chore(opencode): qualify and recommend runtime 2.2.1 (#1188)
## Summary

- Qualify OpenCode **2.2.1** (latest stable, 29 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, 2.1.4 and 2.2.0 daemons stay ready and get an optional upgrade
offer. 2.2.1 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.2.1
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.4 → v2.2.0 → v2.2.1; 661 + 134 commits)

Reviewed from the exact tags `v2.1.4` (`68bc26b7da`), `v2.2.0`
(`1097e2668b`) and `v2.2.1` (`3ebcf9a32c`), the published npm packages,
and an authenticated OpenAPI dump from each official CLI running in its
own sandbox. The [changelog](https://opencode.ai/changelog) covers
2.2.0. It had no 2.2.1 entry at review time (29 October), so 2.2.1 was
reviewed from its commit log.

- **Authenticated OpenAPI:** 124 → 130 → **134 paths**, 149 → 156 →
**162 operations**. Each dump matches the tagged
`packages/protocol/openapi.json`.
- **2.2.0 adds** `GET /api/plugin/gui`; `POST
/api/experimental/generate/{text,image,decisions}`; `POST`/`DELETE
/api/experimental/fs/watch`; `GET /api/experimental/media/{hash}`; and
`GET /api/experimental/usage/limits`.
- **2.2.0 removes** `POST
/api/experimental/voice/session/{sessionID}/instructions`.
- **2.2.1 adds** six `/api/experimental/schedule` operations (list,
create, get, delete, pause, resume), gated by `experimental.schedules`.
  - **Changed operations:**
- `GET /api/model` gains an optional `types` filter. Without it the
route lists chat models only. Image and decision models are new in
2.2.0, so the default listing matches 2.1.4.
    - `GET /api/event` gains an optional `types` filter.
    - `GET /api/vcs/diff` gains an optional `untracked` parameter.
- `POST /api/experimental/generate` becomes the deprecated alias of
`/generate/text`.
- Nine session, project and saved-permission routes drop
`LocationNotFoundError` from their 404 responses: they no longer boot a
Location ([#58997](https://github.com/anomalyco/opencode/pull/58997)).
- `GET /api/agent` can now answer 503 while plugins settle
([#59082](https://github.com/anomalyco/opencode/pull/59082)).
- In 2.2.1, the session log and persistent-PTY operation IDs move under
`experimental.*`. Their paths are unchanged.
- **Schemas:** all 103 consumed-family schemas are byte-identical from
2.1.4 to 2.2.1. These are `V2EventEncoded`, `Session.*` (messages,
parts, inbox, info), `Form.*`, `Permission.*`, `Prompt*`, `Event.*`,
`Location.*` and PTY/Shell. The session prompt, environment, message,
form/permission reply and RPC operations are unchanged too. The only
`Config.InfoEncoded` changes are additive opt-ins:
`experimental.media_blob`, `experimental.image_generate`,
`experimental.schedules` and `timezone`. Model `type`, prompt-cache
`cache` settings, `FileDiff.Info.more`, `Mcp.Server.title` and
`ServerInfo.hostname` are additive fields.
- **Admission:** every predicate CodeNomad's
`compatibility/negotiate.ts` checks holds on 2.2.0 and 2.2.1. That
covers the canonical API, `session_environment` and the 2.1 replay shape
(`native` without `state`, compaction `metadata` without `recent`).
- **Instance proxy:** none of the new routes is on the allowlist. That
includes the GUI plugin bundle, generation, file watch, media blobs,
usage limits and schedules. The removed voice route was never exposed.
The routes CodeNomad does proxy kept their paths.
- **Published declarations** (2.1.0 pins → 2.1.4 → 2.2.1):
- **Client:** 143 → 147 → 185 methods. 2.2.1 mirrors the experimental
methods under `client.experimental.*`. The only removal is
`voice.instructions`, which CodeNomad doesn't use. 2.2.1 doesn't send
the `experimental-media-uri` capability, so media reads stay inline.
- **Schema:** 2.2.0 adds media refs (`MediaRef`,
`Session.Message.MediaUser`, inbox `MediaUserPayload`), `generate`,
`usage-limits` and `Model.Type`. 2.2.1 adds `goal` and `schedule`. The
one removal since 2.1.0 is `SessionEvent.Source` (2.1.4).
- **Plugin:** 2.2.0 adds the `gui/*` SDK (excluded from the host
runtime) and `Host.packageID`. 2.2.1 adds
`SessionIdle`/`SessionIdleBackground`. The event `subscribe` domain
gains an optional `types` filter; CodeNomad's plugins don't subscribe
through it.
- **Typecheck:** the server and UI typecheck with **0 errors** against
the 2.1.4 and 2.2.1 client/schema/plugin declarations, through scratch
`paths` overrides. The pinned 2.1.0 control is also clean.
- **Host modules:** CodeNomad's three daemon-loaded bundles resolve
`@opencode/plugin` (`Plugin`), `/effect` (`Plugin`, `Rpc`, `Location`),
`/promise/adapter` (`fromPromise`), `/rpc` (`Rpc`), `effect` and
`effect/encoding` (`Base64Url`) from the host. `@opencode/schema` and
`@opencode/client` are bundled from the 2.1.0 pins, and
`@opencode/schema/location` imports are type-only. A probe plugin loaded
in an isolated **2.2.1** daemon resolves every one of these modules and
bindings, as on 2.1.4. The host Effect stays **4.0.1**, with the same
lockfile hash.
- **Private native services:** the pending-request reader and Missions
recurrence use keyed native services: `LocationServiceMap`, `Form`,
`Permission`, `Location`, `Job`, `Database`, `Session` and `Bus`. Their
keys are unchanged at v2.2.1, and the members CodeNomad calls are
unchanged. The changes are additive: `Permission.assertNotDenied`,
`Session.*WithRefs`, an `image` Job kind, and
`LocationServiceMap.canonical` normalizing paths.
- **Storage** (migrations from 2.1.4 to 2.2.1): `blob`/`blob_ref`
(always created; written only with `experimental.media_blob`) and
`schedule` are new. `session_pending` is dropped; CodeNomad never reads
it. A `session_v2.allowance` column is added and dropped within 2.2.0.
- The tables CodeNomad reads directly (`session_message`, `session_v2`,
`session_inbox`, `event`, `event_sequence`, `kv`) keep their shape.
- The pruning plugin's `media-blob.ts` already projects `media:<sha256>`
refs.
- The pruning fixture enables `media_blob` explicitly, so the
always-present `blob` table doesn't change its expectation.
- Missions reads `session_inbox` only by count and ID in production
code.
- **Behavior changes checked:**
- **Code Mode:** 2.2.1 moves programs onto one job queue
([#57976](https://github.com/anomalyco/opencode/pull/57976)). The
run-to-completion regression behind the 2.1.2 minimum passes on 2.1.4,
2.2.0 and 2.2.1: `{ total: 2000, beforeAwait: 1000, count: 1000 }`.
- **Session form polling** returns `[]` without booting a cold Location
([#59083](https://github.com/anomalyco/opencode/pull/59083)). Forms are
in memory, so nothing pending is hidden. This matches CodeNomad's
existing-only pending reader.
- **Location keys and `session.create` directories** are canonical on
POSIX too: trailing slash removed, `posix.normalize`
([#59327](https://github.com/anomalyco/opencode/pull/59327)). Windows
already normalized. This is low risk; it was reviewed in source only,
since the location and ownership fixtures run on Windows and the POSIX
certification covers migration, Code Mode and Git-degraded.
- **Location keep-alive:** the changelog line "keep project services
alive … with a 24-hour inactivity cap"
([#57274](https://github.com/anomalyco/opencode/pull/57274)) doesn't
match the shipped tree. That commit was superseded before 2.1.4, and no
cap exists at v2.2.0 or v2.2.1. From 2.1.4 to 2.2.1,
`location-activity.ts` only canonicalizes paths and generalizes Job
kinds. Long-waiting Missions sessions keep their Location as on 2.1.4.
- **Plugin readiness:** each runner step and tool call now waits for
plugin activation
([#51133](https://github.com/anomalyco/opencode/pull/51133)), and
agent/provider lists wait for plugins. Presence leases stay outside the
discovery root, so this adds no reload.
- **Service lifecycle:** registration and config renames are retried on
Windows. The managed service ignores a saved `XDG_STATE_HOME`
([#58815](https://github.com/anomalyco/opencode/pull/58815)); CodeNomad
never sets it. The `OPENCODE_SERVER_USERNAME` change was added and
reverted within 2.2.0. Detached POSIX children are killed when OpenCode
exits; service start uses its own spawn path.
  - **2.2.1 opt-ins:**
- schedules, both the API and the agent tool, need
`experimental.schedules`;
    - `image_generate` needs `experimental.image_generate`;
    - `/goal` runs only when invoked;
- the subagent tool description now invites follow-ups to *running*
subagents, with the same mechanics as 2.1.4.
- **Other changes:** compaction sizing from the provider's count, MCP
malformed-tool tolerance, OAuth refresh coordination
([#54023](https://github.com/anomalyco/opencode/pull/54023)) and
webfetch redirect rules. They touch no consumed contract and were
reviewed in source; of these, the native suites exercise compaction
only.
- **Upstream #52922** (Bus routing before Location fanout) is still
open: keep the discovery and event-relay mitigations.

**Verdict:** no consumed contract changed from 2.1.4 to 2.2.1.

- The **2.1.0 client/plugin pins can stay**.
- The **2.1.2 minimum has no reason to move**.
- 2.2.1 is admitted through the existing contract recognition, and
nothing found blocks recommending it once the native suites pass on it.
- No CodeNomad bug was found.

Bumping the pins is optional. It would bring the 2.2 client's
discard-tool-result-payload option and its "register running sessions
missed while disconnected" fix
([#53012](https://github.com/anomalyco/opencode/pull/53012)). It would
also move the replay reducer, so it belongs in its own PR. The UI and
server already typecheck against 2.2.1.

**Not verified here:**
- the full native suites against 2.2.0/2.2.1 (only the Code Mode
regression and the host-module probe ran);
- the TUI pruning companion under OpenTUI 0.7. It uses only
`Plugin.define`, `ctx.data.session` and `ctx.data.listen`, so the 0.7
renderable changes don't apply, but this wasn't run;
- POSIX canonicalization on a real Linux/macOS runtime;
- the 2.2.1 release notes, which were unpublished at review time.
## Isolated Windows qualification

Base: dev `157766206`; Node **25.2.1** (local; CI runs the pinned
24.20.0); exact official CLI **2.2.1**; unchanged locked client/plugin
**2.1.0**.

- **Build and checks:** production server and native plugin builds
(pruning, automation, Missions), root typecheck.
- **Native runtime:** Code Mode job ordering (`{ total: 2000,
beforeAwait: 1000, count: 1000 }` on 2.2.1), plugin runtime, automation;
complete pruning, history, proxy, navigation and media; Location,
worktree families/management and event relay; permission receipts; Git
init/degraded.
- **Missions:** contract, family control, native and trajectory; **all
11 recurring Mission journeys** (`ABCDGELFWQN`).
- **Sessions:** environment, fork, aside, interrupt, lifecycle, blank
cleanup (`--conditions=browser`, as in CI), compaction isolation, prompt
skills, provider usage/accounts/selection, service usage, web search,
plugin packages, output limit, Promise-tool failure, subagent depth,
temporary workspaces.
- **Migrations:** history from **2.1.0, 2.1.2, 2.1.4 and 2.2.0** to
2.2.1.
- **Installation:** fresh install, native upgrade and legacy replacement
fixtures.
- **Units after the recommendation change:**
compatibility/transport/update **35/35**; full server **2,628 passed, 8
skipped, 1 cancelled** (`index.test.ts` shutdown test timed out at 5 s
under full-suite load and passes **4/4** alone); ten-locale help checks
pass.

In CI, `latest` resolves to 2.2.1: 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-29 15:04:29 +01:00
heunghingwan
1577662068
fix(transcript): finish settlement reads from liveness and inbox evidence (#1187)
Some checks are pending
Publish help / build (push) Waiting to run
Publish help / deploy (push) Blocked by required conditions
A settlement whose terminal event fell inside a disconnect window had no
trigger: finishOpenCodeDataEvent never ran, so the SDK's settlement read
was never finished and a tool row stayed "pending" until a reload. A
queued prompt delivered during the gap also vanished from both the queue
and the transcript.

The settlement read from #1169 is extracted as completeSettlementRead and
now also runs from two pieces of evidence the client already gathers:
the 30-second liveness pass reports each run it believed working that the
final activity read no longer lists (only while the transcript still
holds an unsettled tool), and the inbox sync reports an admitted item the
authoritative inbox no longer lists. Neither path has a terminal event
that invalidated the SDK's message key, so both invalidate it before the
read; otherwise the sync engine would skip it on any reducer that synced
before. The execution-epoch fence still lets only a newer execution
suppress the publication, and the display window keeps its membership.

Regressions start from a reducer whose first sync completed and expect
exactly one read: a liveness-observed settlement heals a stranded tool,
a newer execution suppresses that publication while the reducer still
reconciles, an inbox sync admits the delivered prompt, the production
liveness pass heals end to end, and a still-active run reads nothing.
Store suites and the UI typecheck pass.
2026-10-29 10:10:48 +01:00
Pascal André
940d7e9960
fix(ui): take authority for unchanged parts of rows that streamed during a read (#1184)
Reducer reads merge their page with the rows streamed during the fetch: a
row that changed keeps its live version. That decision was per row, so
progress on one tool of an assistant message kept the whole live row,
including a sibling tool the stream never settled. With a tool stranded
"pending" after a lost session.tool.called and a background tool reporting
progress in the same message, the settlement read fetched the completed
state and the merge threw it away, leaving the tool pending.

Within a row changed since the read's snapshot, each identified content
part (a tool call, for example) left unchanged since the snapshot now takes
the page's version, while changed or new parts, parts without an identity
and the row's own fields keep their live version. Rows that did not change,
rows created during the fetch, the time.completed exclusion and the
destructive fence are unchanged. The snapshot is the serialized row the
read already captures; only rows that changed parse it again.

A merge unit test and a reducer sync where a sibling tool reports progress
during the fetch cover it; both fail without the change. The store suites
around opencode-data, session-api, compaction, settlement, idle, session
events, request authority and pruning, and the UI typecheck pass.
2026-10-28 21:02:04 +01:00
heunghingwan
cde99a3af5
fix(transcript): settle tools stranded pending after a lost tool.called (#1169)
A tool row could stay "pending" forever while later tools completed and
even the next round streamed, until the user switched sessions. The UI's
"pending" is the reducer's tool status `streaming`; a tool leaves it only
through `session.tool.called`, and `session.tool.success` is reduced only
from `running`, so a lost `called` made the later success a no-op.

The SDK already reconciles such rows: on an execution terminal event with
a streaming or running tool it invalidates and re-reads the session's
messages, and CodeNomad keeps the reducer alive for it. That read was
skipped while the stream was not connected, and its result was never
published to the display.

finishOpenCodeDataEvent now completes it: on an execution terminal event
whose reducer still holds a streaming or running tool, it calls the SDK's
message.sync without invalidating, sharing the SDK's in-flight read when
connected and starting it when not, then projects the reconciled page
with the display window's membership and cursor kept, and retries
retirement. The read completes while the session keeps streaming and
keeps every live event. Only a newer session.execution.started
supersedes the publication; same-round activity such as a late tool
event does not.

Regressions cover disconnected and connected recovery, a new step
streaming during the read and a late same-round tool success; each fails
without the change. Store suites, both PR CI UI unit steps,
project-tab-restore, session-rendering and the UI typecheck pass.
2026-10-28 20:50:35 +01:00
Pascal André
a448bb1f6a
perf(missions): send summaries of closed missions to the panel (#1180)
Step 5 of 6 toward removing the Missions limits (#1167 follow-up), on
top of steps 1-4.

## Change
- Plugin RPCs `list` (summary row per mission from hints; full maps only
for open missions + up to 4 requested, each re-read from its range;
unchanged rows skipped via `summariesVersion`) and `mission` (one
range's map + its cleanups).
- `GET /missions?include=&summaries=` and `GET /missions/:missionID`;
fallback to the full `snapshot` when the plugin lacks the new reads
(`rpc.method_not_found`).
- UI store holds full maps + rows; picker/marks/completion seeding use
rows; detail, Needs you, task tree and readers use full maps of the
selection and reader target. Activity observes only the maps sent.
- Bridge routes that authorize native effects keep the full snapshot
(hint contract).
- Visible: a selected closed mission shows the loading line until its
map arrives; a closed unselected row's permission mark counts only its
declared actors' requests. No help change (nothing documented there).

## Read counts (10,000 history entries, 200 closed + 1 live)
`list()`: 0 full scans, live range only, 201 rows · with one closed
included: +50 entries, rows not resent · `mission(id)`: its 50 entries ·
`GET /missions`: 0 full scans after the index build; activity only for
open/included.

## Validation
- `mission-list.test.ts` (equivalence over random histories with
damage/purges, version skipping, staleness, label bound), read-budget
tests, route fallback tests, new `mission-summaries` browser test;
adapted fixtures keep assertions. Mechanism mutations each fail a test.
- Server and UI typecheck; 1,210 server tests; UI mission/i18n tests;
all 36 mission browser files; `test-missions-native.mjs` on isolated
OpenCode 2.2.0 and 2.1.4.
- `automation-forms.test.ts` fails identically on unmodified `681b85486`
(pre-existing, investigated separately).
2026-10-28 20:16:17 +01:00
Pascal André
8e8ea0653e
fix(history): search recent history first, cover more of it and search further back (#1182)
## Summary

Follow-up to #1161 and #1164. In a large project, the ranked project
search covered only the **oldest** messages: the scan walked rowids
upward under a 1.5 s budget, so recent conversations were never reached,
and identical searches covered 5,000 to 7,000 messages depending on
load. Each 1,000-row statement also sorted and evaluated its whole chunk
before returning its first row, blocking the daemon for about 70 ms at a
time.

- **Newest first:**
- The scan streams 128-row ranges from an O(1) horizon, without a
temporary sort.
  - It covers a deterministic window of the 16,384 most recent messages.
- **Cheaper per row:**
- A necessary UTF-8 byte check on the raw row rejects messages that
cannot contain a query term before any JSON is decoded.
  - Only possible matches pay for the exact readable-text mask.
  - The existing time, candidate, session and judge guards remain.
- **40 results** in the project search window; the agent tool keeps 10
by default and 20 at most.
- **Search further back:**
- When older history remains, it ranks the next older window for the
same query and options through an authenticated per-location
continuation.
- The continuation is bound to scope, query, options and the original
horizon, so later appends never enter it.
- Older windows replace the list, because ranking scores are
window-local.
  - The coverage note names the examined range in all ten locales.
- Query, scope or option changes, closing and leaving the session reset
it.
- The conversation scope is unchanged.

## Measurements (synthetic 40,000-message corpus, `node
scripts/benchmark-ranked-history.mjs 681b85486`)

| Metric | Before | After |
|---|---:|---:|
| Messages scanned | 19,001–22,001, oldest first | 16,384 each run,
newest first |
| Scan duration | 1,503–1,550 ms | 650–739 ms |
| First-row latency | 50–76 ms | 0.8–1.8 ms |
| Longest synchronous step | 70–82 ms | 1.4–1.9 ms |
| Rows fully decoded | 30,000–34,500 | 3,276 |

## Validation

- Superset property checks against the exact mask: 12,547 cases,
including Unicode case crossings, escapes and surrogate pairs.
- Deterministic coverage and streaming latency regressions.
- Cursor tampering, binding and excluded appends; store lifecycle.
- Session-pruning and history routes 155/155; native bundle 1/1; UI unit
44/44.
- Browser: history 3/3; search-highlight 12, plus 1 optional benchmark
skipped.
- Help 9/9; both typechecks.
- Isolated native history fixture on OpenCode 2.2.0: 26 checks.

Oversized file touched: `message-section.tsx` (~1,823 lines).
2026-10-28 19:26:40 +01:00
Pascal André
954bb13d10
fix(ui): keep nested repositories' sessions and requests in their own project (#1174)
With a project open on a parent folder (such as the home directory) and another on a repository inside it, the repository's new sessions showed in the parent's session list, and its permission requests in the parent's interruption dock, banner and header count. The workspace manager owns nested checkouts of other repositories inside an opened folder and routes their events and requests to it; that ownership is unchanged. The UI applied its session-list scope only when loading the inventory, not to sessions arriving from live events.

Sessions are scoped at display. The store keeps every session it receives; getWorkspaceSessionScope (session-state.ts) derives, reactively and cached per instance, the complete families with a member in the opened folder or a registered worktree (selectWorkspaceFamilyIds, shared with the inventory), plus the active session's family so a session opened explicitly is shown where it was opened. An unread or failed inventory and a Git-unavailable folder keep everything in scope. The session list, the tab indicator (now derived from the sessions snapshot and scope instead of incremental counters, which removes syncInstanceSessionIndicator), the idle fade, and completion, compaction and retry notices read it. Being derived, the scope converges with a late inventory and either family order without refusing, deferring or refetching sessions.

Requests are shown where they belong: the dock, banner and header show a nested repository's request in an open project that lists its directory and holds the request, else in the owning folder, so no request is left without a dock; a request of a session in the project's scope stays with it, and needs-input notices follow the dock. Native queues, answering, recovery and receipts are unchanged.

An unknown session's compaction notice and retry toast wait for its metadata read, then apply the same scope check, and only while the project is open on the same OpenCode data generation. fetchSessionInfo publishes nothing once its project closed or its client was replaced, and pending reads coalesce per generation.

Validation: reproduced on dev against an isolated OpenCode 2.2.0 and the real CLI server (nested session in the parent's list, its permission in the parent's dock); with this change the parent's list, dock and tab indicator stay empty while the nested project shows both. New session-workspace-scope.test.ts and interruption-scope.test.ts fail on dev and pass here, and are in the CI list. Session, status, event, interruption and pending-request unit tests, the dock, permission, header and session-list browser tests, the full browser suite (automation-forms' Mission editor case also fails on the unmodified base) and the root typecheck pass. Eight review rounds ended with no findings.

Fixes #1154.
2026-10-28 18:49:14 +01:00
Pascal André
d1e3fe6946
fix(ui): seed an opened session's reducer from its loaded page (#1181)
Some checks are pending
Publish help / build (push) Waiting to run
Publish help / deploy (push) Blocked by required conditions
Opening a session that was not known to be idle filled its empty session
reducer with the SDK's own message.sync, a second transcript read on top
of the window load. Restoring project tabs therefore read the selected
transcript twice, which project-tab-restore pins at one read
("restoring other workspaces must not restart and requeue the selected
transcript read"), and every such open paid an extra request.

The first latest-window load now seeds that empty reducer from the page
it just loaded: its newest 20 rows, the SDK's own page size, through the
existing syncMessages snapshot path, so the reducer reaches the same
state without another read and later deltas for a message that was
already streaming still reach the display. The guards are unchanged:
only an empty reducer outside rotation and resyncs is seeded, for the
session's own directory, and known-idle sessions are skipped.

session-api-running-open covers the warm, cold and new-step opens and
now asserts a single transcript read; the cold case fails without the
seed. project-tab-restore (50/50), the store suites around opencode-data,
session-api, request authority and pruning, and the UI typecheck pass.
2026-10-28 18:08:21 +01:00
Pascal André
87ae7050c6
fix(tauri): ship a single desktop entry in the Linux AppImage (#1179)
## Problem

Every Tauri Linux build since 2026-10-28 fails in "Package and verify
Tauri AppImage" with `Expected exactly one desktop entry in the Tauri
AppImage, found 2`. This blocks dev pre-releases and releases.

## Cause

Tauri's AppImage bundler copies `bundle.linux.appimage.files` into
`usr/share/applications` next to its generated `CodeNomad.desktop`,
symlinks `CodeNomad.desktop` at the AppDir root, then runs linuxdeploy
without a desktop-file argument (tauri-bundler `linuxdeploy.rs`).
linuxdeploy logs `No desktop file specified, using first desktop file
found` and deploys whichever entry the directory listing returns first.
When that is the hidden alias `ai.neuralnomads.codenomad.client.desktop`
(`NoDisplay=true`, added in #334), it lands at the root beside Tauri's
entry. The 2026-10-27 build picked `CodeNomad.desktop`; today's builds
pick the alias. No repository change between them touches packaging.

## Fix

- Remove the alias from `bundle.linux.appimage.files` only. Inside an
AppImage, files under `usr/share/applications` are not registered with
the desktop; integration tools install the root entry. deb/rpm keep the
alias for GTK app id association.
- Document the constraint in `dev-docs/TAURI_UPDATER.md`.

## Validation

- This PR's Tauri Linux packaging job extracts the AppImage and requires
exactly one root desktop entry.
2026-10-28 17:41:19 +01:00
heunghingwan
67ae94e5c5
fix(ui): complete transcript reads under sustained session streaming (#1156)
Opening a session that is already generating (after a UI restart, or a
subagent box opened while its child session runs) could leave its
transcript empty or frozen. Two reads restarted on every same-session
event, so a continuously streaming session never completed them: the
session reducer's message.list proxy looped until no event arrived during
the fetch (26,239 reads in two seconds in the regression), and
loadMessages retried a latest-window load on any live revision change
until it showed a load error. The reducer of a newly opened session also
held no rows, so a message already streaming when it opened stayed frozen.

Both reads now snapshot the session's reducer rows when the fetch starts.
Only destructive events during the fetch (deletion, revert commit, inbox
cancellation, compaction start, end or failure) restart a reducer read;
otherwise the page is merged with the snapshot: the page is the base, a
live row new or changed since the snapshot keeps its live version (a row
changed only in the root time.completed that SDK step boundaries stamp
takes the page), and rows created during the fetch ride along. Compaction
text no longer restarts reads; buffered text is flushed before the
snapshot and after the fetch, so it is reduced exactly once.

A first latest-window load merges its page the same way and commits it
while live revisions advance, then, unless the session is known idle,
fills an empty session reducer with the SDK's own latest page for the
session's directory, so later deltas reach the display. Reloads and
history windows keep their revision checks, and rows kept live are
reconciled by the SDK's full-text endings and end-of-round refresh.

Regressions cover the sustained-streaming read (one fetch), the reconnect
and settlement races, revert and cancellation fences, rows created during
a fetch, compaction text during a read and warm and cold opens of a
streaming session; each fails with its fix reverted. Store suites, both PR
CI UI unit steps, history-navigation (70/70) and the UI typecheck pass.

Co-authored-by: Pascal André <pascalandr@gmail.com>
2026-10-28 17:01:46 +01:00
Pascal André
681b854865
feat(missions): bound journal capacity per mission, not per project (#1178)
Step 4 of 6 toward removing the Missions limits (#1167 follow-up), on
top of steps 1-3 (#1175, #1176, #1177).

## Change
- **Per-mission capacity:** each mission holds at most 2,000 ordinary
entries plus its own 36-entry deletion reserve (2,036 per range). **No
project-wide cap.**
- Capacity checks and appends read only the target mission's range (no
full scan). Safety decisions keep their full reads (accepted: their cost
grows with history).
- Reserve: only the mission's own tombstone and already reserved
receipts; works on every storage.
- Full project reads bounded per range (and stray entries together);
memory = per-mission bound × missions, documented trade-off.
- Purge is retention-only (20 newest settled deletions kept);
capacity-pressure purge, creation pre-check and related re-checks
removed.
- Existing mission data is negligible (projects were capped at 20
missions, barely used): **no legacy, migration or rollback handling by
design.**
- Docs: `MISSIONS.md` safety limits; help sentence on freeing storage
updated in 10 locales.

## Validation
- Read-budget test at 10,000 history entries; project beyond one
mission's bound fully works; capacity checks read only their range; full
mission refuses ordinary entries/new Stop but deletes through its
reserve (with and without removal). Each red with its guarantee mutated.
- Native stage: 2,200 entries across 100 finished missions on an
isolated runtime, then create/declare/readout/delete with retention
purge.
- Server and UI typecheck; 1,508 server tests; `test:help`;
`test-missions-native.mjs` on isolated OpenCode 2.2.0 and 2.1.4.
2026-10-28 15:44:19 +01:00
Pascal André
b38af07fbe
perf(missions): serve read-mostly paths from journal hints (#1177)
Step 3 of 6 toward removing the Missions limits (#1167 follow-up), on
top of steps 1 (#1175) and 2 (#1176).

## Change
Read-mostly paths take candidates from the activation's journal hints
and re-read only those ranges:

| Path | Before | After |
|---|---|---|
| Per-turn context hook | 2 full reads per model turn | 1 hinted
membership read; 0 for sessions no mission names |
| `inspect` (no start), list, `observeTask`, decision candidates | 1
full each | 0 full + candidate ranges |
| Outbox listing | 1 full + 4 per item | 0 full + 1 range per open
mission (per-item damage gates stay full) |
| Presence retention | 1 full per interval | 0 when hints show open
work; full read confirms an empty answer before release |
| Change notifications after lifecycle/delete | 1 unused full read | 0 |

Safety decisions keep authoritative full reads (creation, membership
refusal, damage gates, delegation, reports, lifecycle, deletion, cleanup
authority, purge, capacity).

**Staleness (≤ 60 s, writers outside the generation protocol):** hook
treats the coordinator as a non-member meanwhile (tools still select by
full read); outbox only delays (deterministic message/receipt IDs,
re-checked by a full read). `MISSIONS.md` lists the hinted callers.

## Validation
- `journal-read-budget.test.ts` (1,900 finished-mission events): hinted
paths do no full scan; safety paths still see writes the hints miss;
presence release confirmed by full read; hook single read and tool
hiding; ordering/damage parity; 60 s bound. 15 source mutations each
fail a named test.
- New native stage (100 finished missions, isolated runtime only):
`inspect` and the model turn read no full journal; snapshot and damage
gate do.
- `receipt-projection.test.ts`: an outbox pass refused by its item's
full read now counts as failed instead of throwing (same backoff, no
duplicate message).
- Server and UI typecheck; 1,358 server tests;
`test-missions-native.mjs` on isolated OpenCode 2.2.0 and 2.1.4.

Note for step 4: mutations stay O(history) (capacity scans, rule-1 full
reads).
2026-10-28 14:35:25 +01:00
Pascal André
21bfe855ce
feat(missions): keep an in-memory index of the mission journal (#1176)
Step 2 of 6 toward removing the Missions limits (#1167 follow-up), on
top of step 1 (#1175).

## Change
- New `MissionJournalIndex` (`missions/journal-index.ts`):
activation-owned, never persisted. Holds per-range session references
(coordinator, prepared-by, dispatching, native-bound), ordering data,
open state, local damage, cleanups and stray-entry count; answers
`refs`, `openIDs`, `aggregate`, `cleanups`.
- Built by one key-ordered scan (`MissionJournal.scanRanges`) with
`missionView`'s parsing/purge/reducer; kept current by marking each
mission dirty **before** every ordinary write attempt; a per-project
write generation (host-global, like the append lock) detects foreign
writers and rebuilds; any failure drops it. Nominates candidates only:
decisions must re-read storage.
- Cannot observe bytes written outside `MissionJournal` after its build
(other OS process, earlier builds); documented and pinned by a test.
- Recurrence passage storage marked `isolated`: never feeds or serves
the index.
- Wiring only (activation creates, passes to `MissionControl`,
disposes). **No caller reads it yet.** `MISSIONS.md` "Journal index"
paragraph.

## Validation
- `journal-index.test.ts`: property test (24 seeds × 70 steps,
failed/unconfirmed writes, interrupted purges, damage, new activations,
foreign writes) equals full `snapshot()`/`events()` on every answer and
rescans only on activation/foreign write; deterministic cross-mission
fixture; unreadable records; incarnation/blind-spot. Passage trap test
in `native-passage-business.test.ts`.
- 11 source mutations each fail a named test.
- Server and UI typecheck; 1,343 server tests;
`test-missions-native.mjs` on isolated OpenCode 2.2.0.
2026-10-28 13:56:25 +01:00
Pascal André
9ed72ae068
refactor(missions): read one mission's journal range (#1175)
Step 1 of 6 toward removing the Missions limits (#1167 follow-up): make
per-operation cost depend on active work, not history.

## Change
- `MissionJournal.missionEvents(missionID)` / `missionView(missionID)`:
one scan of `<prefix>/<missionID>/` with the same parsing, append/purge
exclusion, read bound and purge-remnant resolution as `events()`,
reduced by the same private `reduce()` that `snapshot()` now delegates
to.
- A view's discarded count and damage flags are its own; the snapshot's
are their aggregate plus entries outside every mission range.
- Recurrence passage adapter accepts its own mission's range.
- **No caller switches yet**: behaviour unchanged. `MISSIONS.md`
describes the read.

## Validation
- `journal-mission-view.test.ts`: 24 seeded 70-step histories of real
`MissionControl` operations (all 15 written event types, shared
sessions, failed ACKs, pending cleanups, interrupted purges) with
injected damage, prefix-extension IDs and timestamp ties; after every
step each view equals its snapshot mission, views partition `events()`,
counts/flags/cleanups aggregate to the snapshot. Red when the discarded
count is dropped, the range's trailing slash removed, or the record
check skipped.
- `recurrence-passage.test.ts`: 121-event passage read by range.
- Server and UI typecheck; all server missions/routes/opencode-missions
tests; `test-missions-native.mjs` on isolated OpenCode 2.2.0.
2026-10-28 12:27:05 +01:00
Pascal André
84d2d0a245
fix(missions): free journal capacity when deleted missions settle (#1173)
Follow-up to #1167.

## Problem
A project's mission journal holds 2,000 entries and never removed any.
Deleting a mission appended a tombstone and cleanup receipts on top of
its events, so a project that keeps creating and deleting missions
eventually filled the journal permanently: no create, and no delete
either (the tombstone needed room).

## Change
- **Purge on settlement:** once every cleanup target of a deleted
mission has its receipt, its events are removed from native plugin
storage (`storage.remove`, passed through a narrow
`MissionStorage.remove`; storage without it keeps today's behaviour).
- **Retained:** the 20 most recent settled deletions stay in full
(cleanup history, lost-ACK checks); all eligible ones are purged when
capacity is needed. A deleted mission stays while it references a
session another unsettled mission could still clean up.
- **Deletion record:** one compact record per purged mission at
`codenomad-missions/v2/deleted/<projectToken>/<missionID>`, outside the
scanned prefix (no journal capacity, exact-key reads). It refuses
recreation (`mission-deleted`) before any native effect on create,
prepare and coordinator start replays, and answers an exact deletion
replay with the original summary.
- **Crash-safe, resumable:** record first, then `mission.created`,
tombstone, intents, receipts; an incomplete group with a record is
dropped from readers; late writes are removed by the next purge; damaged
journals are never purged.
- **Full journal recovers:** a 36-entry reserve, usable only by
tombstones and reserved receipts; reads bounded at 2,036.
- Legacy tombstones are purged the same way.
- Docs: `MISSIONS.md`; help (EN + 9 translations) cleanup-history
sentence.

## Known risks
- Rolling back to an older build reads a mid-purge journal as damaged, a
reserve-using journal as over its limit, and ignores deletion records (a
retried creation could recreate a purged mission).
- Under capacity pressure the recent cleanup history is purged too; a
delete whose ACK was lost in that window then shows a false "missing"
error.
- Deletion records accumulate (one small exact-key entry per purged
mission).

## Validation
- `journal-purge.test.ts` (10 tests, each red with its piece removed):
200 create/delete cycles, recreation refusal and replay, pending cleanup
kept, crash at every step, late writes, legacy tombstones, full journal
of live missions, no-remove storage, shared sessions, damaged journals.
- Native `test-missions-native.mjs` gains a settled-deletion purge stage
(red when the plugin lacks `remove`).
- Server and UI typecheck; 1,331 server missions/routes tests;
`test:help`; native run on isolated OpenCode 2.2.0.
2026-10-28 11:59:23 +01:00
Pascal André
49f2efe8f9
fix(sessions): read OpenCode media blobs like native history (#1172)
Follow-up to the #1158 review (pre-existing P3).

## Problem
With OpenCode 2.2.0's opt-in `experimental.media_blob`, inline files are
stored as `media:<sha256>` refs (`blob`/`blob_ref`) and native reads
hydrate them. CodeNomad's direct SQL readers saw the refs: pruning
reported a false `conflict` (the UI hashes hydrated content), assets
looked unavailable, navigation emitted unhydrated JSON.

## Change
- New `session-pruning/media-blob.ts` mirrors native `blob.ts` hydration
(tool files → data URIs, user files → data, missing blob → native
placeholder), bounded before loading bytes.
- Preview, prune candidates, prune transaction, navigation windows and
assets read the hydrated form; existing size bounds apply to it.
- Pruning compares the hydrated form, writes survivors back exactly as
stored, and like native content rewrites releases the message's
`blob_ref` rows for hashes it no longer names, deleting a blob only when
unreferenced.
- Decided by stored representation, not version: no refs → unchanged;
refs without recognisable blob tables → `unsupported_storage` (distinct
from `conflict`).
- Docs: `SESSION_PRUNING_RPC.md`, `SESSION_HISTORY_NAVIGATION.md`,
`PANEL_EXTENSIONS.md`.

## Validation
- `media-blob.test.ts` (projection, placeholders, bounds, preview,
prune, shared blob retained, unsupported storage, assets, navigation).
- New native check `scripts/test-session-media-blob-native.mjs` (run by
`test-session-pruning-native.mjs`): 2.2.0 with media_blob passes; 2.1.4
passes; red on unmodified dev.
- Server and UI typecheck; all session-pruning, route, proxy and
panel-asset tests.
2026-10-28 10:41:25 +01:00
Pascal André
4e113583c8
fix(missions): surface a failed first recurring admission at once (#1171)
Follow-up to the #1158 review (pre-existing P2).

## Problem
When the first native admission of a due recurring passage threw, the
runner returned `unknown`, the due adapter reported `pending` and the
clock treated the wake as successful: warning cleared, sleep up to 1 h.
The passage looked silently `starting` for that hour.

## Change
- `native-recurrence-due.ts`: new wake result `admission-unknown` for
that case.
- `native-service-clock.ts`: after its reread, while the passage is
still pending and unadmitted, that result is a failed wake: the existing
redacted `admission-failed` warning is published at once and the
existing 30 s backoff retries the same pending identities. Nothing
archived, no cursor advance, no new identity.
- `MISSIONS_RECURRING.md` describes it.

## Validation
- `recurring-day.e2e` test M: injected admission refusal and failing
observation read (new fixture `failQuery`): warning on the first wake,
no start before 30 s, exactly one admission with the original identities
at 30 s, survives restart. Red without the fix.
- Server typecheck; all missions tests;
`test-recurring-simple-native.mjs ABCD` on isolated OpenCode 2.2.0 and
2.1.4.
2026-10-28 10:37:25 +01:00
Pascal André
4d8db216b8
fix(missions): remove the 20-mission project limit (#1170)
Fixes #1167.

## Problem
A project refused its 21st mission with `Project mission limit reached`,
counting finished missions too. Long sessions whose coordinator starts
missions itself hit it and could only continue after deleting missions
by hand. The limit only bounded refresh cost.

## Change
- Creation (panel, `mission_prepare`, the coordinator's
`mission_inspect` start) no longer counts missions; the snapshot lists
every mission.
- The `mission-limit` code, its no-effect receipt path and
`missions.control.creation.limit` (10 locales) are removed;
`request-conflict` stays the only no-effect creation refusal.
- Refresh cost stays bounded: activity observation reads at most 160
actors per refresh (the former 8 × 20), active missions first, then the
most recently updated; the rest read as unknown.
- Unchanged: 8 actors and 96 tasks per mission, the 2,000-entry project
journal (follow-up), the 20 most recent settled cleanups.
- `dev-docs/MISSIONS.md` states the current bounds. Help never
documented the limit; nothing to update there.

## Validation
- `control.test`: 25 missions created and listed. `activity.test`: 31
missions / 241 actors → exactly 160 session reads, the oldest active
mission observed.
- Creation-fence forged/mismatched no-effect receipt matrix kept on
`request-conflict`; damaged-storage refusal kept.
- Server and UI typecheck; all server missions, routes and native
missions tests; UI mission and i18n unit tests.
2026-10-28 10:13:37 +01:00
heunghingwan
e3a03f3fde
fix(ui): reconnect silently dead SSE streams so missed questions recover (#1162)
Some checks are pending
Publish help / build (push) Waiting to run
Publish help / deploy (push) Blocked by required conditions
On mobile, and behind tunnels or proxies that black-hole an idle
connection, the shared event stream could stay open while no data
arrived. The UI kept showing a connected state, so a question asked
meanwhile never reached the interruption dock until the app reconnected
for another reason.

ServerEvents now runs a stream staleness watchdog
(lib/stream-staleness.ts): a connected stream that carries no batch, open
or ping for 45 s, checked every 15 s, is restarted once per silent
episode, and an `online` event or the page becoming visible restarts it
as soon as that threshold has elapsed. Each connection attempt gets its
own threshold, and only callbacks from the current connection count as
activity. The restart takes the ordinary reconnect path, whose refresh
reloads pending questions and permissions through syncPendingRequests.

The server pings every 15 s, so a healthy idle stream is never
restarted; the refresh conventions document that coupling. The watchdog
interval is unref'd so Node test processes exit naturally.

stream-staleness.test.ts covers the threshold, episodes, the
per-connection baseline, the probes and the lifecycle, and now runs in PR
CI. The UI typecheck, the CI UI unit-test set, the server-events
lifecycle test and the event-subscriber and interruption-dock browser
tests pass.
2026-10-27 22:12:22 +01:00
Pascal André
444991d352
fix(events): reconcile a recipient instead of dropping an unroutable event (#1165)
When the event relay could not resolve whether a workspace owns an
event's location (the lookup failed twice, for example while the shared
OpenCode connection was being replaced), it logged a warning, cached
"not owner" for two seconds and dropped the event while the stream stayed
connected. A question form or any other update for that workspace could
then stay missing until an unrelated refresh.

The relay now treats such a failure like a lost connection for that
recipient alone: the failed lookup is evicted from the cache, and that
instance receives an `error` status ("event routing failed") followed by
`connected`, which runs the client's existing reconnect reconciliation.
Other workspaces keep receiving the event, and the shared subscription is
not failed, so one workspace's lookup cannot resync every instance.
Pulses are leading and trailing per instance, at most every 5 s and
doubling to 60 s while failures persist; pending pulses are cleared on
reconnect, workspace stop or error and shutdown, and skipped when the
stream is not connected or the workspace is no longer listed.

Repeated pulses exposed that TrailingResyncCoordinator chained every
request, so a slow pass accumulated one full reconciliation per request.
It now keeps one active pass per instance plus a dirty bit: requests
during a pass share one trailing pass, and each returned promise settles
after a pass that began after its request. Passes still start on a
microtask, so callers keep their timing.

A relay regression covers a persistently failing recipient next to a
healthy owner (immediate pulse, trailing pulse, doubling backoff), and a
coordinator regression covers eight requests during an active pass; both
fail without their change. The server workspace and event suites, the
CI UI unit-test set and both typechecks pass.
2026-10-27 21:55:12 +01:00
Pascal André
5a49ac36b2
feat(history): rank whole-project transcript search, keep conversation search chronological (#1164)
## Summary

PR 2 of 2 after #1161. The transcript search window's **project scope**
uses the ranked engine; the **conversation scope** keeps its literal,
chronological, paginated search and code path.

- **Project scope:**
- One request returns the best 20 results in relevance order: rare words
weigh more, an exact phrase ranks first, and each conversation appears
once before repeats. There is no next-page traversal.
- Every query term is highlighted in the excerpts, in the preview and in
the destination conversation when a result is opened.
- Compact notes report a partial scan, excluded folders, or a runtime
without ranked search.
- **Route:** `POST /api/workspaces/:id/session-history/ranked` calls
only the plugin's `historyRanked` RPC.
- It applies the same ownership, provenance, deletion, connection and
cancellation fences as the literal history route, through scope helpers
shared in `session-history-scope.ts`.
- It validates counts and every contributing location before publishing
and strips provenance.
  - It records an unsupported method per connection.
- Disjoint worktree roots are interleaved by native rank before session
diversity is applied.
- UI strings and help pages are updated in all ten locales.

## Validation

- Server and UI typechecks pass.
- Routes 13/13, covering ownership rejection, fences, invalid provenance
and the unsupported state.
- Store tests 6/6.
- Chromium:
  - history 2/2;
  - search-highlight 12, plus 1 optional benchmark skipped;
  - session-search 1/1.

The browser cases cover ranked order, highlighting, opening a result and
the unchanged conversation scope.

Oversized files touched: `message-section.tsx` (~1,816 lines),
`message-block.tsx` (~1,775), `api-client.ts` (~833).
2026-10-27 21:43:48 +01:00
Pascal André
7a206a9297
feat(history): rank past-session search and expose it to agents as session_search (#1161)
## Summary

Agents can now find past discussions themselves. The bundled
session-pruning plugin registers a Code Mode tool,
`codenomad.session_search`, ranked by relevance over the calling
session's own project history. This is PR 1 of 2: server/plugin only;
the search window keeps its current behaviour until PR 2 wires the same
engine.

- **Tool scope:** the caller's directory tree, native workspace and
project (plus `global`) history, never other projects. The caller's
location is checked before and after the read, and excerpts are flagged
as untrusted transcript data. The tool follows backend presence like the
plugin's RPCs.
- **Ranking:** up to six Unicode terms; a precise SQL prefilter over
rowid chunks (superset of the JS judge: ASCII folding, Unicode case
classes, literal `\u` escape fallback) yields per-term IDF weights. The
best candidates are judged one bounded body at a time on word
boundaries, with an exact-phrase bonus, prose before tool payloads,
recency as tie-break and one hit per session before repeats. Optional
inclusive UTC `since`/`until`. Candidate, session, judge and time caps
are reported.
- **Shared limits:** scope SQL, readable-part extraction, the 16 MiB
message bound and 40 ms slices move to `history-content.ts` and are
shared with the literal search.
- **For PR 2:** a versioned `historyRanked` RPC returns the same hit
fields plus per-session provenance, so the broker can validate ownership
as for `history`. Existing RPC methods, HTTP routes and UI are
unchanged; the generic RPC proxy stays closed.

Inspired by `@rizal72/oc-session-search`.

## Validation

- Session-pruning tests 98/98, including the package regression; native
plugin bundle 1/1; server typecheck.
- Regressions: Unicode terms and accented case variants, weights, phrase
bonus, diversity, dates, prefilter precision (multi-line and accented
messages lacking a term are not candidates), a rare old match outranking
600+ newer frequent ones, scope isolation, cancellation, caps, skipped
rows, tool registration and presence disposal.
- Isolated native history fixture on OpenCode 2.2.0: 23 checks,
including a ranked query (3 hits across 2 sessions). No shared daemon or
user database was used.
2026-10-27 19:41:50 +01:00
Pascal André
687085463f
fix(ui): keep session alerts out of the Preferences window (#1163)
#1148: with the native Preferences window open, a provider's usage-limit
error showed the same modal in the main window and in Preferences, and
each copy had to be acknowledged separately.

Preferences renders only the settings screen, but it imports the shared
stores, and stores/sessions.ts wired every session event handler on
import. Its event stream therefore ran handleSessionError, which opens an
alert for each session.execution.failed, and the other session handlers
(TUI toasts, permissions, session and worktree updates) as well. Session
toasts only stayed silent there because no session is loaded.

The session handlers are now wired only outside the Preferences window,
which shows no session. Catalog invalidations and connection status keep
their own wiring, so settings still follow live changes. A session alert,
toast or notice appears in the windows showing sessions and is
acknowledged once. dev-docs/SETTINGS.md records the rule.

Validation: a new preferences-window browser test renders the real
Preferences window, delivers a session.execution.failed event through
the real server-event dispatcher (the connection status proves the
window received it) and asserts no alert appears; it fails on dev. The
Preferences browser tests, fixture wiring, session event unit tests and
the root typecheck pass.
2026-10-27 18:25:02 +01:00
Pascal André
a868a8e720
feat(missions): blue prepared missions, quiet interrupted chevron, full-panel create form (#1155)
## Changes

- **Prepared missions are blue.** A created, never-started mission gets
a blue mark (`--accent-primary`), like a prompt ready to send.
- **Interrupted no longer bubbles into the collapsed chevron.** Like a
collapsed session parent, the chevron only counts answers, running work
and unseen completions; interrupted, paused and prepared missions colour
only their own rows. The unused `missionsPanel.attention.interrupted.*`
keys are removed from all locales.
- **The creation form replaces the list and detail.** While creating,
the current-mission line, list, schedules and selected detail are hidden
(still mounted, so pending controls and retries survive) until Create or
Cancel.
- **Nesting depth in the creation form.** Options embed the existing
project depth editor (`MissionSubagentDepth`). OpenCode only reads
`experimental.subagent_depth` per config file, so the value is the
project's and applies to every conversation there; the editor says so.
- **Button templates.** `.window-text-button` had no rule anywhere,
leaving ~18 mission buttons unstyled. It is now a shared primitive in
`styles/components/window.css` (borderless accent text, hover tint,
muted when disabled). The creation footer's **Save as brief** uses the
footer's `button-secondary` template; the bordered **Answer** button
uses `.window-action`.

## Docs

`dev-docs/MISSIONS.md`, `dev-docs/UI_CONVENTIONS.md` (window button
primitives), and `docs/help/*/missions.md` in English plus the nine
translations.

## Validation

- UI typecheck; mission and i18n unit tests; `npm run test:help`.
- All 34 `tests/browser/mission*.test.ts` files pass. Updated:
`mission-status-marks` (blue computed colour, quieter chevron label),
`mission-editor-profiles` and `mission-editor-lifetime` (the form
replaces the list; leave it before browsing).
- Visual check of the real components through the mission-control
fixture: prepared mark, collapsed chevron, full-panel form with depth,
footer buttons, selected detail.
2026-10-27 14:51:10 +01:00
Pascal André
339a0844ca
fix(opencode): stop an emptied project experimental block from capping subagent depth (#1153)
## Problem

A mission asked to nest subagents three levels deep stopped at the first
level: every nested call returned `Subagent depth limit reached (1).
Increase "experimental.subagent_depth"…`, although the global OpenCode
config sets `experimental.subagent_depth: 5`. The project file
`.opencode/opencode.jsonc` contained only `"experimental": {}`.

## Cause

- OpenCode reads `Config.latest(entries, "experimental")?.subagent_depth
?? 1` (`core/src/tool/plugin/subagent.ts:129`): the **whole**
`experimental` object of the highest-priority document that has one,
unmerged (`core/src/config.ts:24-27`). An empty project block therefore
hides the global depth.
- CodeNomad's depth editor reset (**Inherit**) removed only
`subagent_depth`, leaving that empty block behind.
- The editor computed the effective depth as the last document defining
`subagent_depth`, so it showed the global 5 while OpenCode applied 1.

## Change

- **Inherit** also removes a project `experimental` object it leaves
empty; foreign experimental keys, comments and formatting are kept.
- The effective depth follows OpenCode's read; a project block without
the depth reports the native default (unknown when the contract declares
none) instead of the hidden global value.
- `dev-docs/MISSIONS.md` describes both. Help already says that an empty
field inherits, which is now true in every case.

## Validation

- `subagent-depth-settings.test.ts`: shadowing read, Inherit keeps
foreign experimental keys, an empty project block reported as hiding the
global depth and removed by Inherit after an override.
- Server typecheck.
- `scripts/test-subagent-depth-settings-native.mjs` against an isolated
OpenCode 2.1.4.
2026-10-27 13:03:36 +01:00
Pascal André
048cd65183
fix(missions): one status palette, no agent-decided failure, resumable missions (#1150)
fix(missions): one status palette, no agent-decided failure, resumable missions (#1150)

#1147 reported a red chevron beside a running mission because another,
older mission had failed. Missions mixed two colour systems: rows showed
session-style attention when there was some and otherwise a lifecycle
colour (blue running, green completed forever, red failed), the collapsed
chevron added "failed", and the task tree had its own palette. Red meant
both "your answer is expected" and "this ended badly", a failed mission
offered no action, and a one-time coordinator could end a mission as
failed on its own.

Colour now follows the session list everywhere (mission and schedule
rows, the selected mark, task icons, the chevron) and shows only what
needs attention now:
- red: an answer is requested, counted like Needs you (declared actors
  and observed family; schedule rows without a passage read count the
  live passage's conversation and loaded subsessions);
- yellow, pulsing: observed work;
- orange: interrupted or paused, with an action to continue (an
  interrupted schedule, or a running one whose latest run failed);
- green: a completed mission this window has not shown yet;
- everything else is neutral: outcomes (done, failed, blocked, waiting,
  retired) are status text and icon shapes.
missionMark/scheduleMark in mission-picker-model.ts compute one mark per
entry. Like a collapsed session parent, the chevron colours only while
the list is collapsed, from the other entries (paused is not counted).

"Seen" works like an idle session: mission-view-state keeps the
completed missions this window has shown under its own layout key and
signal, so recording one never wakes project-view readers (an early
write through the project view re-ran session-view effects and aborted
an in-flight outline read). The first read counts missions already
completed as seen; a completion is marked when selected while the panel
is visible in a focused window (createDocumentAttention, now shared with
SessionView).

One-time coordinators no longer decide a failure: the plugin's
MissionControl sets completedFinalOnly, and the refusal, coordinator
context and tool description tell the coordinator to ask the human
through the native question tool. Only the human stops a mission.
Recurring passages keep their own control and completed|failed.

Interrupted missions get an action:
- A running mission's coordinator owes its final report: observed idle
  with an empty inbox and no coordinator notification still being
  delivered (native returns excluded), it projects idle-without-report;
  when nothing else runs the mission reads Interrupted and coordinator
  recovery replaces Pause.
- A failed mission reads as interrupted unless stopped since, and the
  same recovery resumes it: under the mutation lock it refuses with
  already-member when one of its sessions joined another active
  mission (as at creation), otherwise appends mission.reopened at the
  exact revision and sends the ordinary recovery nudge at the new
  revision. The reducer resumes only a failure, drops its summary and
  lets a later Stop end it; a later finish uses a revision-bound event
  identity. The unwired authority policy accepts the same resume.

dev-docs/MISSIONS.md records the rules; the Missions help page gains
"Colours and interrupted missions" in all ten languages, and new strings
are added in every locale.

Validation: mission-resume.test.ts (completed-only refusal, passage-style
failed finishes, resume once with conflicts and no second nudge,
shared-session refusal, reducer reopen/Stop rules); activity.test.ts
(owed final report, queued inbox, pending and native-return reports,
non-running missions); mission-picker-model unit tests; the new
mission-status-marks browser test (marks, collapsed chevron, unseen
completion surviving reload, recovery replacing Pause, neutral done and
blocked tasks, failed mission resume, recurring requests including
independent actors and a zero selected count, quiet stopped/paused
schedules), red on dev; updated mission-control, mission-briefing and
mission-recovery tests. All Missions server tests, the full browser
suite (four shards), help checks, UI and root typechecks pass.
2026-10-27 05:05:26 +01:00
Pascal André
24d9a18fa8
fix(shell): hide each panel at the end of its own compression (#1149)
Narrowing a window moved the panels back and forth: at 800 px the right
panel closed while the left one jumped back from 220 to 340 px, then
shrank a second time; at 600 px the left panel closed and the right one
reappeared, only to close again at 580 px. Widening repeated the
sequence in reverse with its own swaps.

Narrowing now shrinks the conversation to its minimum, then the right
panel to its minimum, where it hides, then the left panel to its
minimum, where it hides. Each panel shrinks once and hides at the end of
its own compression, so the other panel never changes width at that
moment and only the conversation takes the room; widening retraces
exactly the same layouts.

Each side panel keeps whether the user wants it (opened, or closed
while docked) and docks while the window has its room, in a fixed order:
the session panel first, the right panel only beside the full session
panel. A wanted panel without room hides and returns with the room, so
the layout follows the window width alone; a minimizing window's tiny
viewport hides both panels and restoring it brings them back. A panel
opened without room floats over the conversation; dismissing it keeps
the docked choice, and it docks once it has room. A panel closed while
docked stays closed. Dismissing a floating panel by clicking outside
leaves focus on the clicked control; a panel the layout hides hands
focus to its toggle when it held focus.

Touch tablets remember the wanted panels per orientation. A floating
panel never docks while the window already has its new shape but the
orientation has not changed yet, so rotation memory records only the
user's choices; the shape is read from the orientation media query,
which stays reliable on zoomed, near-square windows.

Implementation: `resolveEmbeddedDrawers` docks the right panel only
beside the left one at its chosen width and caps each panel's chosen
width by the room. `useDrawerChrome` replaces the open state, the
"awaiting room" marks and their effects with a wanted and a floating
signal per panel; docked and shown states derive from them and the
window width. The public API keeps its shape (`leftOpen` means shown;
`setLeftOpen(true)` opens docked or floating, `setLeftOpen(false)`
dismisses silently).

Validation: a shell-navigation sweep narrows 1400 to 400 px in 20 px
steps, requiring panels never to grow back or reappear, then widens and
requires the same layout at every width. Regressions cover outside
dismissal focus, rotation with a floating panel, a wanted panel docked
by restored room across rotations, and rounded near-square viewports.
drawer-layout unit tests cover each panel hiding at the end of its
compression. Every browser file that drives the panels passes; the user
validated the behaviour in a separate Electron dev instance; four
gatekeeper rounds ended with no P0-P2 finding. Help is updated in all
ten languages.
2026-10-27 03:28:25 +01:00
heunghingwan
2064682d40
test(ui): share one fixture daemon per run across the browser suite (#1146)
Browser tests that don't need their own server now borrow their fixture
page from one Vite server per run instead of each starting one.

run.mjs (local) and shard.mjs (CI) spawn the fixture daemon as a child. It
announces its endpoint and secret as one stdout line; the runner hands them
to the test processes through the environment and stops the daemon with an
authenticated request when its slice ends, failing the run when that
teardown fails or has to be forced. The daemon watches its owner's pid and
cleans up after an owner that ended without stopping it; on Windows it is
spawned detached so the owner's exit doesn't end it first. A test file run
on its own gets a daemon child of its own. fixture-borrow.ts registers
namespaced routes, fixture-exemptions.ts lists the files that keep their
own servers (stateful SSE middleware, custom transforms, Electron hosts),
and fixture-wiring.test.ts enforces borrow-or-exempt. AGENTS.md sanctions
the daemon's single-writer optimizer cache, and dev-docs/BROWSER_TESTING.md
documents the commands, the daemon model, logs and limits. Twenty owned
fixtures also dispose their cache when createServer() rejects.

Measured locally, 20 representative files run one at a time took 797 s
against 1,091 s on owned servers (about 27 % less); the CI shards' test
streams show no consistent change.

Validation: the daemon, run, borrow and wiring tests pass (11/11),
including split and noisy handshakes, a start that never announces,
pipelined shutdown requests, a forced stop seen through by a minimal
runner, and protected cleanup after a killed owner; the full suite ran
through npm run test:browser on Windows, and the four CI shards passed.
2026-10-27 02:11:40 +01:00
Pascal André
fb22f6e969
feat(shell): close panels that lose their room and reveal list choices (#1144)
In a window too narrow for a docked panel, the panel floated over the
conversation and covered the messages. On phones and narrow windows, the
floating session panel also stayed open over the session the user had
just chosen, so every choice needed a second tap to see it.

A docked panel that loses its room (the window narrows, or the other panel
opens) now closes instead of floating. It returns when the room does,
unless the user opened it meanwhile; a panel closed by the user stays
closed. Minimizing a window briefly reports a tiny viewport, so without
the return every minimize would lose both panels. A panel opened without
room still floats over the conversation, as before.

Choosing a session, the shown one included, or Instance Info from the
floating session panel's list now closes that panel to show the choice;
reopened, it still marks the shown session. Back and Forward select
without closing it. Docked panels are unchanged.

Implementation: `useDrawerChrome` closes an open drawer when its docked
state turns false and marks it as awaiting room; an effect reopens it,
the session panel first and the other only if both then fit docked, and
opening a drawer clears the mark. The layout inputs are shared by the
initial layout, the docked state and the return. On touch tablets each
rotation's orientation memory decides, counting a panel closed for room
as open in the orientation it leaves. A panel closed by the layout hands
focus to its toggle when it last held focus, even when its re-render
already removed the focused control: the shell remembers which panel's
content last took focus until focus deliberately moves to the page. The
sidebar's list selection handler closes the floating session panel; the
history buttons keep using the ordinary selection path.

Validation: shell-navigation browser tests cover closing and returning
(narrowing, a simulated minimize, the other panel taking the room, a
user-closed panel staying closed, a panel opened without room floating),
the phone list, re-selection and Back/Forward behaviour, and each
panel's focus hand-off; drawer-width covers a 1024x768 tablet rotating
twice. All fail on the previous code. Help is updated in all ten
languages.
2026-10-25 21:52:03 +01:00
Pascal André
2eb1fb1797
fix(tauri): stop F11 from freezing the Linux app (#1143)
Pressing F11 froze the Tauri app on Linux under X11 or XWayland: GNOME
reported "CodeNomad is not responding" and the window never went
fullscreen. The released 1.0.0 AppImage froze the same way, so this
predates the AppImage display-stack fix (#1138), which keeps GTK on X11
for this very shortcut.

A backtrace of the frozen process showed a deadlock between two
threads:
- global-hotkey's X11 thread received F11 and ran the plugin handler on
  that thread. toggle_fullscreen_window then asked the main thread
  whether the window was focused (is_focused) and waited.
- The key grab itself moves X11 focus. Its focus events reached the main
  thread, where update_fullscreen_shortcut re-registered F11 and waited
  for the X11 thread to confirm.

The handler now queues the toggle on the main thread
(run_on_main_thread) and returns, so the shortcut thread is free to
answer the registration.

Two leaks from #1138 are closed on the way:
- prefer_x11_display exported GDK_BACKEND=x11,wayland, which every child
  inherited: the backend, the shared OpenCode service it may start, agent
  shells, editors and the browser opened for links. Their GUI programs
  then ran under XWayland. CodeNomad now sets its own GTK's preference
  with gdk_set_allowed_backends, and children keep the session's choice.
  A GDK_BACKEND set by the session still wins.
- Children that leave an AppImage's mount started in AppRun's working
  directory inside it (PWD=/tmp/.mount_*/usr for the browser opened by a
  link). use_host_environment now starts them in / while ours is inside
  the mount; callers that pick a directory (backend, editors) still do.

dev-docs/HOTKEYS.md records that a global-shortcut handler only queues
work on the main thread; dev-docs/TAURI_UPDATER.md describes the GTK
backend preference and the working directory.

Validation on Ubuntu 24.04 GNOME Wayland, with the #1141 CI AppImage
repacked around a binary built from this branch:
- After focusing the window, about ten F11 presses toggle fullscreen in
  and out. The main thread stays in its event loop (poll), where the
  CI build and the 1.0.0 release deadlock on the first press
  (gdb backtraces).
- The window is still an XWayland client. The backend and the OpenCode
  service carry neither GDK_BACKEND nor mount paths.
- The help link opens Firefox with / as working directory, PWD=/ and no
  GDK_BACKEND.
- The appimage_environment and appimage_exit tests pass, and the Linux
  release binary builds.
2026-10-25 21:25:16 +01:00
Pascal André
77675a4a0b
fix(missions): keep an unsent mission draft across tabs and projects (#1142)
An unfinished mission draft behaved differently depending on how the user
left it (#1130). After switching to another project and back, the draft
was still shown but Create stayed disabled until the editor was
cancelled. After switching the right panel to Files or Status, the draft
was gone.

Two causes:
- MissionControl kept each open editor's activity as a one-shot view
  capture (createMissionViewFence). A capture is invalidated by any
  identity or visibility transition, including a round trip, so once the
  project was hidden the editor stayed inactive for good, with Create
  and its network-backed controls disabled.
- The open editor and its draft lived in component signals. The right
  panel renders only the selected tab, so another tab disposed both.

The open editor now lives in window memory per panel scope
(stores/mission-editor-state.ts, keyed by instance and folder), never in
the native view layout. Its activity is the live view activity, so a
hidden project's editor works again on return. Operations still capture
their own view fence at submit and ignore completions from an earlier
view lifetime. When the panel unmounts, an idle editor stashes its draft
in its entry and reopens with it: objective, title, notes, schedule,
playbook, profiles and execution mode, the chosen brief, open sections,
and its request identity. An editor with a request in flight or held
still closes and settles through its creation or recurrence hold, which
keeps precedence over a stashed draft. Cancel, a saved result or a
deletion closes the entry, so a later editor starts blank.

dev-docs/MISSIONS.md records the editor's window-memory scope and that a
view capture guards one operation, never a component's lifetime.

Validation: a new mission-editor-lifetime browser test drives a create
and an edit draft through a hidden/visible round trip, a panel remount
and an instance round trip. It checks the restored fields, that Create
or Save is enabled and sends the kept draft once, and that cancelled or
saved editors stay closed. On dev it fails at the first round trip, with
Create disabled. The existing transition matrix also asserts that an
editor unmounted while its request is in flight does not reopen. The
Missions browser suites, the UI own-tsconfig and root typechecks pass.
2026-10-25 18:32:03 +01:00
heunghingwan
13450749b8
feat(preview): open loopback conversation links in the web preview (#1119)
Some checks are pending
Publish help / build (push) Waiting to run
Publish help / deploy (push) Blocked by required conditions
When the frontend is reached from a browser, an agent-printed
`http://localhost:3000` link points at the browser's machine, not the
backend host that runs the dev server, so clicking it opens a dead tab.
Many development apps also break inside the opaque preview, where
`localStorage`, `sessionStorage` and `document.cookie` throw.

A plain left click on an explicit http(s) loopback link (`localhost`, a
literal `127.x.x.x`, `[::1]`, `0.0.0.0`) in session Markdown now opens it
in that session's web preview. Middle and modifier clicks, external
links and Markdown rendered without a session keep the browser default.
Interception waits until the server meta is known and stays off where
previews are unavailable, such as Remote Control while the host keeps
web previews off. Intercepted links without an author title get a
localized tooltip naming the Ctrl/⌘-click and middle-click escape hatch.

The preview runtime bridge gives opaque documents whose storage access
throws one stable in-memory `localStorage`, `sessionStorage` and
`document.cookie` object each for the current page load, with property
access and enumeration, `Max-Age` taking precedence over `Expires`, and
detection by reading alone, so working storage (a per-token preview host
opened on its own) stays native. The help in all ten locales and the
preview paragraph of architecture.md describe both.

Validation: the loopback predicate unit test, the runtime-bridge shim
tests, the Markdown loopback-link browser test, the server preview,
proxy and Remote Control suites, both typechecks and the help checks
pass; the interception and the shim were checked in Chromium against
the real preview proxy and Remote Control surface.
2026-10-25 15:46:39 +01:00
Pascal André
a6c19d19d7
fix(tauri): leave the display stack to the host in the Linux AppImage (#1138)
The Tauri AppImage showed a black window on up-to-date Linux systems
(#1136: Arch, sway, AMD, Mesa 26.2). WebKit aborted with "Could not create
surfaceless EGL display: EGL_BAD_ALLOC", and no WebKit or GDK variable
helped.

Tauri CLI 2.11.5's linuxdeploy copied the build runner's display stack
into the image: Ubuntu 24.04's libwayland-client, -cursor, -egl and
-server, libxkbcommon, libxcb-render and -shm, libXau and libXdmcp.
AppRun puts usr/lib first on the library path, so the host's Mesa EGL
loaded the stale libwayland-client and failed even on the surfaceless
platform. This is tauri-apps/tauri#15976, fixed upstream in CLI 2.12.1.
On a current Arch userland (Mesa 26.2.5, wayland 1.26), `eglinfo -p
surfaceless` initializes with the host libraries and fails with the
1.0.0 image's bundled ones, or with its libwayland-client alone.

Packaging:
- The Tauri CLI moves to ^2.12.4.
- The Linux job defines APPIMAGE_HOST_LIBRARIES once (wayland,
  xkbcommon, xcb, X11, Xau, Xdmcp, EGL, GL, gbm, drm). It is passed to
  linuxdeploy as LINUXDEPLOY_EXCLUDED_LIBRARIES, because 2.12.4's own
  excludelist still deploys GDK's display libraries.
- The AppImage verification step rejects an image that bundles any of
  them.

Runtime, because the 2.12 GTK hook leaves GDK_BACKEND unset and exports
mount-scoped module paths:
- main() sets GDK_BACKEND=x11,wayland unless the session sets it. This
  keeps the XWayland windowing that the F11 X11 key grab needs, with a
  Wayland fallback.
- appimage_environment.rs drops variables inside the mount (byte-wise,
  plus the lossy spelling of the Node-serialized shell snapshot) and the
  mount's path-list entries. GIO_MODULE_DIR, unlike GIO_EXTRA_MODULES,
  replaces the host's module directory. It applies to children that
  outlive the mount: the backend (and through it the shared OpenCode
  service), the editors CodeNomad opens, and link handlers.
- Links open through external_open.rs. On Linux it uses the opener
  plugin's launchers with the host environment, detached; elsewhere it
  delegates to the plugin. The renderer reaches it through a new
  open_external_url command (http, https, mailto). The windows hold that
  command instead of opener:allow-open-url.
- The plugin's injected click handler is off. Its replacement,
  lib/native/external-link-clicks.ts, takes the same decisions in the
  application and Preferences windows and in trusted same-origin SideCar
  documents. Opaque previews and panel extensions stay fenced.
- The unused @tauri-apps/plugin-opener npm dependency is dropped.

Validation:
- Ubuntu 24.04: the Linux crate compiles, and the appimage_* tests
  (path lists, non-UTF-8 entries and mounts, lossy snapshot) and the
  capability test pass.
- The inventory check accepts GTK, WebKit and GLib, and rejects flat,
  multiarch, dangling-symlink and GLES copies.
- Browser tests drive the real link handler (target="_blank", Ctrl,
  Shift, nested, mailto; plain, Alt, tel and custom schemes untouched)
  and the real SideCarView and preview frames in a stubbed Tauri window.
  Each fails without its fix.
- The UI own-tsconfig and root typechecks pass.
2026-10-25 15:45:34 +01:00
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é
3f9be8e364
docs(previews): describe how root-relative preview resources resolve (#1132)
Some checks are pending
Publish help / build (push) Waiting to run
Publish help / deploy (push) Blocked by required conditions
architecture.md stated that opaque preview documents send no Referer to
CodeNomad, so all their root-relative resources relied on the runtime
bridge's path rewriting. Stylesheet subresources are the exception: a CSS
`@import "/theme.css"` or `url()` that the proxy does not rewrite is
requested with the stylesheet's URL as Referer, even from an opaque
document. The static UI and dev-proxy handlers then proxy it to the
preview named by that Referer when the request has a CodeNomad session,
as every request has with authentication disabled.

The paragraph now lists what the proxy and the runtime bridge rewrite,
which other root-relative requests the Referer fallback serves and under
which condition, and that Remote Control refuses that fallback while
previews are off.

Validation: the statements match a real-Chromium review of the preview
proxy, with authentication enabled and disabled, across sandboxed
iframes, standalone previews, nested frames and popups; help checks pass.
2026-10-24 21:29:54 +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é
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