Commit Graph

559 Commits

Author SHA1 Message Date
Jeremy McKeehen ff57f173d8 fix(desktop): keep pin list identity gateway-wide
Pin localStorage was keyed per connection and profile, so an unpin
reloaded a stale copy on switch and re-asserted pinned=true.
Scope pins by connection only so they survive rescope and stay isolated per gateway.
2026-08-26 00:22:27 -07:00
Teknium 57876f4b71 fix(desktop): typecheck fixes for salvaged tests (afterEach import, routed-request mock typing) 2026-08-26 00:21:29 -07:00
LovePlayCode 1808d33a24 fix(desktop): keep owner-routed tile gateways out of idle prune
Bot chats stay on a secondary while chrome stays on the launch profile.
The keep-set only counted busy sessions, so idle prune closed the tile
socket and resume spun forever. Keep open tiles, route catalog reads to
the owner, and hydrate model/provider from resume.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 00:21:29 -07:00
Zeus-Deus 693dd5d042 fix(desktop): guard stale refresh ownership 2026-08-25 22:51:31 -07:00
Zeus-Deus c2bce09d48 fix(desktop): recover failed gateway switch setup 2026-08-25 22:51:31 -07:00
Zeus-Deus b687c6b2c1 fix(desktop): cancel timed-out gateway activations 2026-08-25 22:51:31 -07:00
Zeus-Deus 300fcdbfbd fix(desktop): harden gateway-switch commit ordering for stalled and overlapping switches (#93937)
Review follow-up. The two-phase switch closed the runtime-id leak for a
single switch, but two shapes still broke the wipe→publish ordering:

Overlapping: click A commits (wipes) and waits for its activation behind
the profile-store mutex; click B finishes its dial and wipes too; then A's
activation lands and publishes A AFTER B's destructive wipe, and A's
endGatewaySwitch() dropped the (boolean) barrier while B was mid-commit.
Now the wipe runs INSIDE the serialized section via a `beforeActivate`
commit hook on ensureGatewayAgent, synchronously right before the socket
is activated; the hook re-checks the click revision and declines when
superseded, so a queued-then-superseded switch neither wipes nor
activates. The barrier is token-owned (latest switch wins), and the mutex
wait is a loop so waiters that wake together can't run interleaved.

Stalled: a wedged spawn / ticket mint / IPC (the #93454 class) left the
spinner up, swallowed later clicks on the same source, and — inside
ensureGatewayAgent — latched the mutex and the barrier. Every await is now
bounded (dial, activation, descriptor lookups, setLastUsed) via a shared
withTimeout helper extracted from use-gateway-boot. A commit that timed
out after the new source was already published counts as committed; one
that stalls after the wipe lowers the barrier and repaints the source
that is still active.

Tests: queued-then-superseded switch, dial that never answers (and retry
not swallowed), activation stalled after/before publication, barrier
ownership, commit-hook ordering inside the mutex, bounded descriptor
lookup releasing the mutex. All fail against the previous revision.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 22:51:31 -07:00
Brooklyn Nicholson dc998a2d59 fix(desktop): re-arm pending clarify cards in place
A hydrated Ask/clarify row stays complete after session or bot switch, so
the live card never mounts. Re-arm the existing transcript row and keep
the provider tool id instead of appending a duplicate at the tail.

Co-authored-by: frendo <frendo.wu@gmail.com>
2026-08-24 15:09:19 -05:00
ethernet 28b758d5ca feat(desktop): make Cmd/Ctrl+L focus the composer from anywhere
The chord previously only acted when a terminal or preview selection
existed. A new bubble-phase window fallback now moves focus to the
composer on an unclaimed press, like the address-bar chord in a browser.

Existing owners keep priority: selection handlers claim the press on the
capture phase, a user-rebound action marks the event handled, and a
focused terminal with no selection keeps Ctrl+L as clear-screen via
composerFocusBlockedBySurface().

The chord matcher moves from the terminal feature to
src/lib/keybinds/chords.ts as isComposerChord: it now has three
consumers and the old name (isAddSelectionShortcut) was wrong at the
composer call site. The fixed panel row view.terminalSelection becomes
view.selectionToComposer because it covers preview selections too.
2026-08-24 12:04:03 -07:00
hermes-seaeye[bot] 74ad422d50 fmt(js): npm run fix on merge (#94046)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-24 17:03:13 +00:00
Brooklyn Nicholson 11f3ebe230 fix(desktop): move the Linux HUD with a native compositor drag
Wayland clients cannot place themselves, so the JS setBounds drag is a
no-op there. Make the composer bar a -webkit-app-region drag handle on
Linux (input carved out with no-drag) and let the compositor move it.

Co-authored-by: Tony Simons <214744153+asimons81@users.noreply.github.com>
2026-08-24 06:48:10 -05:00
Eva 9153be2a51 feat(shared): heartbeat and socket-generation invalidation in JsonRpcGatewayClient
The shared-client half of the gateway.ping heartbeat contract (#89958);
tracks lastInboundAt, sends pings, invalidates a silently-dead socket.
Part of #83166.
2026-08-24 04:01:53 -07:00
Teknium ec5e369fe6 Merge pull request #93784 from NousResearch/salv/81234-retry-carrier
fix: /retry and /undo no longer replay an older message after compaction (#81233, salvage #81234)
2026-08-24 03:31:29 -07:00
dougatbuck 855e191d89 test(desktop): document connection-scoped filesystem keys 2026-08-24 03:19:47 -07:00
dougatbuck 30150fd082 fix(desktop): isolate files across connections 2026-08-24 03:19:47 -07:00
Teknium 1a95d0d58e Merge branch 'pr-81234' into salv/81234-retry-carrier 2026-08-24 03:15:07 -07:00
Jakub Wolniewicz 0b62c27bf1 fix(desktop): route remote file requests by connection 2026-08-23 23:33:06 -07:00
Michael Nguyen 6eb77df1aa fix(desktop): route SSH media through active connection 2026-08-23 21:59:22 -07:00
fangliquanflq c2090ba6b4 fix(desktop): distinguish provider quota exhaustion 2026-08-23 18:43:12 -07:00
kshitij 49f3e67e45 test(desktop): assert the isDisabled flip-back actually notifies
Post-merge review follow-up for #93080: the isDisabled guard test
documented 'flipping back also notifies' but never asserted it — a
regression making the true->false transition silent (e.g. gating
disabledChanged on truthiness) would still pass. Pin the flip-back
notify with a beforeFlipBack capture (mutation-checked: gating the
seed on newDisabled truthiness now fails this test).
2026-08-24 00:07:19 +05:30
kshitij 90803f22a5 fix(desktop): track isDisabled flips in the adapter no-op notify gate
Review follow-up: __internal_setAdapter assigned this.isDisabled before
the fast path but never fed it into the new 'changed' flag, so an
isDisabled-only flip on an otherwise-identical adapter swap would have
been silently swallowed. Seed 'changed' with the isDisabled comparison
and add a guard test (mutation-checked: reverting the seed fails it).
2026-08-24 00:04:10 +05:30
kshitijk4poor 4019518edd fix(desktop): stop no-op adapter swaps notifying the thread runtime + derive backend venv from the selected interpreter
Two independent desktop-boot/runtime bugs found driving the app over CDP
against current main, each pinned by a regression test:

1) Adapter no-op notify loop: IncrementalExternalStoreThreadRuntimeCore.
   __internal_setAdapter's fast path (same isRunning + same messageRepository)
   called _notifySubscribers() unconditionally. ChatRuntimeBoundary passes a
   fresh adapter literal every render, so any subscriber whose notification
   re-renders the boundary loops render->setAdapter->notify->render until
   React kills the tile with 'Maximum update depth exceeded' (reproduced live
   on every bot-profile switch; session tile dies behind its error boundary).
   Now the fast path notifies only when extras/suggestions/capabilities
   actually changed.

2) Dual-venv interpreter mismatch: findPythonForRoot() prefers .venv over
   venv, but createPythonBackend() hardcoded venvRoot=root/venv for
   PYTHONPATH. A checkout with BOTH venvs (dev .venv 3.12 + install venv
   3.11) got a 3.12 interpreter with 3.11-compiled native wheels on
   PYTHONPATH and died on the first import (pydantic_core) before the
   gateway bound - the renderer then showed 'Gateway offline' on every
   profile. venvRootForPython() now maps the selected interpreter back to
   ITS venv; root/venv remains the fallback for system pythons only.
2026-08-24 00:04:10 +05:30
Teknium 102a30f6fb fix(desktop): typing /voice points at the composer voice button
/voice arms SERVER-side capture (voice.record → PortAudio on the backend
host) — meaningless on desktop, which has its own composer-native voice
conversation (mic menu / Ctrl+B). It was already suppressed from the
slash palette, but typing it got the generic 'advanced' shrug that never
mentioned the button exists. New composer-voice unavailability reason
with a message naming the actual surface.
2026-08-23 04:44:27 -07:00
hermes-seaeye[bot] 891ec3fb6c fmt(js): npm run fix on merge (#92896)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-23 11:25:25 +00:00
Teknium c012a364eb fix(desktop): spoken replies use the connected gateway's TTS, not a stowaway local backend
The speak-stream WebSocket resolved its URL through the bare v1
getConnection/getGatewayWsUrl pair, which answers for the PRIMARY backend.
When a registry remote connection rides over a machine that also has a
local Hermes install (the common case — the installer always installs the
full agent), spoken replies dialed the LOCAL backend and hit its
unconfigured TTS, while chat (connectionScoped REST) correctly went remote.
Users saw 'configure STT/TTS' although their remote gateway had voice
fully configured.

Resolve the PCM socket through the same (connectionId, profile) bridges
store/gateway's openSecondary uses, and never overwrite a backend-namespace
profile the registry mint already wrote into the URL (SSH remoteProfile
aliasing, sharedRemote scoping).

Every REST audio call already carried connectionScoped(); this was the one
remaining self-built audio URL. Contract pinned by
voice-playback.routing.test.ts (sabotage-verified: 2/4 fail on the old
resolver).
2026-08-23 03:54:52 -07:00
Teknium 10f0d2278b feat(desktop): client-direct voice — use the active profile's STT/TTS keys from the desktop, no audio relay
Lowest-hop voice path in both directions for desktop + remote gateway:
mic audio goes straight to the profile's STT provider and reply text is
synthesized on the desktop with the profile's TTS provider. The
desktop-gateway link carries only text (which the chat stream carries
anyway). No second key store: GET /api/audio/voice-config returns the
profile's resolved provider/model/language/key using the exact resolution
chains transcription_tools/tts_tool use, over the authenticated REST
channel. Keys live in renderer memory only.

Backend:
- tools/voice_client_config.py: single resolver; per-provider client
  wire shapes (openai-multipart, xai-stt, elevenlabs-stt, openai-speech,
  elevenlabs-tts). Server-host-only providers (local whisper, edge,
  command/plugin) and missing credentials resolve to {mode: relay}.
  xAI OAuth stays relay (bearer refreshes server-side).
- web_server.py: GET /api/audio/voice-config, profile-scoped via the
  same _config_profile_scope seam as /api/audio/transcribe.
- config_defaults.py: voice.client_direct gate (default true).

Desktop:
- lib/voice-client-direct.ts: config fetch keyed by (connection,
  profile) with 60s TTL, provider-direct STT + TTS calls, sentence
  cutter mirroring the server pipeline's contract.
- Dictation (use-prompt-actions + session-tile) tries client-direct
  first; null -> existing relay unchanged; provider rejections surface.
- voice-playback.ts: client-direct speech session as the top rung of
  startSpeechStream/playSpeechText; WS relay + POST fallback unchanged
  below it. Barge-in via the same stopVoicePlayback sequence bump.

Validation: 13/13 backend E2E (real temp HERMES_HOME + real resolution),
live FastAPI TestClient E2E (direct + gate-flip), 15/15 client tests
(wire shapes, scope-keyed caching, rejection surfacing, sentence cutter),
sibling suites 72/72 + 36/36, tsc + eslint + ruff clean.

Docs: voice-mode.md client-direct section ships in this PR.
2026-08-23 03:54:52 -07:00
Teknium 0404020f7b Merge PR #90006: connection-bound Bot Mode actions, reconciled with name-identity + fail-closed canonical resolution
Salvage of saralilyb's remote-bot routing work onto current main:
- kept: immutable (connectionId, profile) owner capture, requestForBot
  routing, backendTargetProfile aliasing, group session owners,
  connection-qualified deletion, focused-owner atoms, remote roster
  merge, Electron profile-delete routing, sdk/store/transcript changes
- reconciled: canonical Bot Chat resolution stays NAME-identity (the
  'Bot Chat' registry row) and FAIL-CLOSED on lookup errors — now
  consulted on the bot's own source via the captured owner route, so
  remote bots get the same no-fork guarantees
- dropped: pointer-pin plumbing (preferredSessionIds, saveBotMeta chat
  writes, pin verification) — superseded by name-identity on main;
  renderer-side remote DM delivery (deliverRemoteRosterMentions /
  pollRemoteDmReply / ensureRemoteCanonicalChat) — superseded by the
  message_agent tool architecture (#91802/#91915: middleware identifies,
  never delivers); pointer-era test files deleted on main
- openStoredBotChat/createCanonicalChat: remote opens keep Desktop's
  chrome home (keepAllProfilesScope: true on routed opens); local bots
  keep the measured workspace re-home
- prepareBotSource: capability gate only — routed RPCs never require
  activation authority
2026-08-22 22:24:54 -07:00
hermes-seaeye[bot] 4b860d8193 fmt(js): npm run fix on merge (#92399)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-22 17:34:58 +00:00
Teknium 40f3e58f6d fix(desktop): stop manufacturing duplicate toolCallIds at the fold, repair poisoned cached tails
Two follow-up layers on top of the salvaged runtime-boundary guard (#87871):

- coalesceToolOnlyAssistants now folds via concatToolPartsUnique, dropping an
  incoming tool-call part whose toolCallId the predecessor already carries.
  Two individually-clean rows sharing an id (structural carry-over re-attaching
  a cached row's calls) no longer become one crashing message — and no longer
  render the same call twice. Root-cause analysis by @marketing2981 (#87857).
- loadTranscriptTail repairs a poisoned persisted tail on read; installs
  already carrying a duplicate in hermes.transcript-tail.v1:* stop
  crash-looping after upgrade instead of re-deriving the same collision
  every launch.
- Regression tests for all three layers, incl. the end-to-end repository link
  test (from #92093 by @RasputinKaiser) and the cross-message ids-stay-
  untouched contract (per-response tool numbering, e.g. Kimi — #90545 by
  @M7MMAD-OMAR). Each test sabotage-verified against its reverted layer.
2026-08-22 10:30:31 -07:00
PRATHAMESH75 9f8dca34dc fix(desktop): dedupe duplicate toolCallId parts at the runtime boundary (#87857)
A message whose content carries two tool-call parts with the same
toolCallId makes assistant-ui's useResources throw
"Duplicate key toolCallId-<id> in useResources", which the workspace
error boundary turns into a renderer crash loop that blanks the window.
The existing withUniqueToolCallIds dedup runs only on the static
toChatMessages output; the streaming reducer (which can append the same
tool-call part twice under an optimistic-update ordering) and tool-only
assistant coalescing both reach the runtime without passing through it.

Add withUniqueToolCallIdsWithinMessage and apply it in
useRuntimeMessageRepository, the single ChatMessage->ThreadMessage
boundary shared by the static and streaming paths, right where the
repeated-message.id guard already lives. The dedup is per-message (the
assistant-ui key space is per-message) and returns the same reference
when clean, so the repository's identity cache is untouched in the
common no-duplicate case.
2026-08-22 10:30:31 -07:00
poisdahl a5b326a471 Merge remote-tracking branch 'origin/main' into agent/81234-merge-20260821
# Conflicts:
#	tests/agent/test_reference_handoff_active_turn.py
2026-08-22 16:47:39 +02:00
hermes-seaeye[bot] fc7523ca31 fmt(js): npm run fix on merge (#92034)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-22 03:38:21 +00:00
hermes-seaeye[bot] 8286c46502 fmt(js): npm run fix on merge (#92032)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-22 03:31:35 +00:00
SHL0MS 3cc7f220cd fix(desktop): strip off-scheme paint from selection copies
Chromium's native selection copy serializes the selection as text/html
with every element's computed color inlined. Copied from a dark theme,
body text lands on the clipboard as near-white (the app ink computes to
color(srgb 0.902 0.929 0.953 / 0.94)); pasted into a light-background
target such as an email, it is invisible.

The renderer never writes rich text itself, so this payload can only
come from Chromium's serializer — which runs after copy handlers decline,
meaning clipboardData reads back empty inside the event. The new guard
therefore decides from the live DOM: it scores the computed ink of the
selected text against the rendered theme mode, and only when they are
opposite schemes does it own the payload, writing text/plain plus a
tag-structured text/html with no paint declarations.

Structure (headings, lists, tables, links, bold/italic, code layout)
survives; colors come from the paste target's defaults. A generic
font-family anchor (sans-serif, monospace inside code) keeps receivers
that convert HTML to rich text on their own compose font instead of the
Times browser default. Same-scheme copies and selections starting inside
editable fields pass through untouched.
2026-08-21 22:25:22 -05:00
Teknium 334bcbac93 fix(desktop): error card honors the classifier's retry verdict + failing-session identity (review feedback)
Addresses @helix4u's review on #91493:
- conversation_loop now stamps failure_retryable (the real ClassifiedError
  verdict) next to failure_reason; error_surface prefers it and only falls
  back to the reason set for older results. Fallback set corrected to match
  classify_api_error (auth, format_error, billing_unverified now
  non-retryable).
- The descriptor carries the failing session's provider/model captured at
  classification time; Copy error details prefers them over the foreground
  composer atoms.
- Open logs is labeled 'Open Desktop logs' on remote/cloud connections —
  the local folder holds transport logs, not the remote runtime's.
- API-exception module allowlist widened to botocore/boto3/google/grpc/
  requests/aiohttp so other adapter SDKs don't misclassify as gateway.
2026-08-21 15:24:03 -07:00
Teknium 50f1e414bc polish(desktop): rename error-card action to 'Copy error details'
'Copy diagnostics' was dev-speak; match the familiar OS-error phrasing.
All five locales + docs updated.
2026-08-21 15:24:03 -07:00
Teknium 98f6fc549a feat(desktop): failed turns name the failing layer with recovery actions
Turn errors now carry a structured {layer, code, retryable} descriptor
(agent/error_surface.py) built from the same classifier the retry loop
uses. The tui_gateway stamps it on terminal error frames, retained
failed-turn snapshots, and resume replay; the Desktop error card renders
the layer title (provider / endpoint / streaming / auth / billing /
gateway / runtime / disk) plus matched actions: Retry, Switch provider,
Open logs, Copy diagnostics.

Older backends that omit the descriptor keep today's behavior (generic
title, string-sniff fallbacks) — the field is advisory on both sides.
2026-08-21 15:24:03 -07:00
Teknium 1bf8bd2c7d feat(models): 'ox alpha' now finds x-preview-f-free in every model picker
The OpenCode Zen wire slug for the Ox Alpha stealth model is opaque
(x-preview-f-free); users searching the picker for 'ox' or 'ox-alpha'
found nothing. Adds the search alias across all four synced alias
tables (CLI, desktop, web, TUI) plus tests. Wire id is unchanged and
still what renders and gets sent to the provider, matching the k3 →
kimi-k3 precedent. No canonical-dedup collision with opencode-go's
keyed ox-alpha-free slug.
2026-08-21 14:38:19 -07:00
Brooklyn Nicholson 272b007f8c feat(desktop): give hiding the tab strip a command, and a way back
The strip could only be hidden by an undiscoverable double-tap, and once hidden
the zone had no chrome left to click — no tab, no ✕, no menu holding "Show".
This puts it on the same footing as the status bar, whose hide has never
stranded anyone: ⌥⌘T, a ⌘K row, the shell context menu, and the zone menu, which
now prints the keystroke on the row that takes the strip away so the way back is
stated at the moment it matters. All four resolve their target zone the same way
the other tab verbs do (hovered, else focused, else the workspace) and describe
themselves from what is on screen rather than from a stored value, so "toggle"
always means the opposite of what the user is looking at.

Adds an app-wide default alongside it, in Appearance next to Session List
Density — auto, always, or never, matching VS Code's `workbench.editor.showTabs`
and Zed's `tab_bar.show` for people who want one answer everywhere instead of a
per-zone choice they repeat. A zone that has stated its own preference still
wins, and neither value can strand a pane.
2026-08-21 13:25:29 -05:00
poisdahl 13fcf2fe38 Merge remote-tracking branch 'origin/main' into agent/81234-merge-20260821 2026-08-21 16:02:59 +02:00
poisdahl abf87e7248 Merge current main into composite-carrier fix 2026-08-21 15:56:45 +02:00
Tranquil-Flow deec043276 fix(desktop): scope WSL bridge state by profile 2026-08-21 00:49:41 -07:00
Sara Burke ba42346e99 Merge latest upstream into hybrid remote Bot routing
Carry the reviewed hybrid Bot routing branch through current main at
4a5b6dd45 without rewriting prior signed commits.
2026-08-20 16:52:28 -04:00
Brooklyn Nicholson 9ed308f901 refactor(desktop): one identity chip behind every avatar
Messaging platforms, the MCP servers tab, and the connector surfaces each
drew their own square brand chip, and the three copies had already drifted
on radius, glyph size, and what an unknown name falls back to.

They now share one AvatarChip. Size and radius stay the caller's call;
everything else is fixed in one place, so a Slack row and a Linear card
wear the same mark. brandFor also stops caring how a name is spelled —
catalog slug, registry id, or display name all reach the same glyph.
2026-08-20 14:07:53 -05:00
Sara Burke 052513f65b Merge current upstream into hybrid remote Bot routing
Adopt upstream's one-hidden-forever-chat model and remove the per-bot hidden
session browser while preserving connection-bound remote Bot opening, the full
remote action menu, same-name isolation, and global Sessions navigation.
2026-08-20 10:26:19 -04:00
hermes-seaeye[bot] f43eabee5f fmt(js): npm run fix on merge (#90822)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-20 13:13:15 +00:00
hermes-seaeye[bot] e2c4793efa fmt(js): npm run fix on merge (#90818)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-20 12:56:20 +00:00
Brooklyn Nicholson b3f6218aa5 fix(desktop): a checkbox reports whether it is ticked, not the string "on"
An unset checkbox's `.value` is "on" per the HTML spec, and the inventory read
the value before the state — so a ticked box and an empty one both came back as
`value: "on"`, and the agent had no way to tell them apart. Anything built on
reading a form back ("is the newsletter box already checked?") was answering
from a coin flip.

The state check now runs first, gated on the input's type rather than on
`checked` being defined — it is defined, as false, on every input including
text fields, which is what made the original ordering look reasonable. ARIA
checkboxes and switches built out of divs read `aria-checked`, which the old
branch would have missed anyway since a div has no `.checked`.

Found by putting the extracted helper under a direct test. It was unreachable
before: the only way to observe it was to run a whole action against a whole
document.
2026-08-20 05:26:37 -05:00
Brooklyn Nicholson df06be0a16 refactor(desktop): give the act engine's pure logic real modules
act-in-page.ts had grown to 1,054 lines: one function holding twenty-eight
closures, of which roughly four hundred lines were pure string and geometry
work that never touched the holder, the action, or any page state. None of it
could be tested. `slug`, `affinity`, `coin` and the rest were reachable only by
running a whole action against a whole document and inferring what they must
have done.

Four modules now, at the seams the dependency graph actually has:

  types.ts       the six shared interfaces, re-exported from act-in-page.ts so
                 no import site anywhere changes
  naming.ts      what an element is called — labels, slugs, stems, anchors
  visibility.ts  whether it is really there and whether it is on screen
  identity.ts    the re-bind ladder and handle minting

Each is a factory rather than a bag of exports, and that shape is load-bearing
rather than taste. These sources are stringified into the guest page, where
module scope does not exist; separate exports would be separate names for the
bundler to mangle independently of the call sites inside the stringified core.
A factory that stringifies whole has no cross-module reference to break. The
core takes the kits as a parameter for the same reason — it names nothing it
did not receive.

That failure mode deserves spelling out, because it is why this is not a plain
import. The renderer minifies. An imported binding referenced inside the core
would be renamed to something the injected bundle never declares, and it would
break in packaged builds ONLY — green in dev, green under vitest, broken for
users. `actEngineSource()` is now the single supported way to obtain the
source, so a partial injection is not something a caller can express.

The self-containment test moves with it, and gets stronger: it evaluates the
assembled bundle rather than one function, which is what the guest actually
receives. It caught the contract change on the first run.

Bodies moved unchanged; the only edits are closure captures becoming
parameters, and `coin` taking the counter it used to read off the holder. The
engine is 634 lines, all of it orchestration. The 53 existing engine tests pass
untouched, which is the evidence the move preserved behaviour, and 27 new tests
exercise the extracted helpers directly for the first time.
2026-08-20 05:26:37 -05:00
Brooklyn Nicholson 0fd3b61ea9 feat(desktop): durable element handles, and a delta instead of the whole page
Every drive_preview action answered with the entire inventory — around 120
elements of ref, role, label, and an up-to-eight-rung `:nth-child` selector
chain. On a real app shell that was ~24.5k characters, re-sent after every
click, so a ten-step task paid for ten copies of a page that had barely moved.

Handles are now durable and legible. An element is named after what it is and
what it says — `btn-sign-in`, `inp-email`, `srch-search-projects` — minted once
per page and never reused, with duplicates disambiguated as `btn-edit`,
`btn-edit-1`. Each one remembers a stable attribute, its role, its accessible
name, and the nearest landmark it sits in, so when a framework destroys the
node and builds a new one the handle moves across and the agent is told
`rebound` rather than being handed a removal it has to react to and an addition
it has to re-read. The re-bind ladder is anchortree's (Apache-2.0), minus its
geometry rung, which can never clear the threshold on its own.

Because the handles hold, the first look at a page returns the inventory and
every look after it returns only what moved. `changed` carries the ref and
whichever of label/value/disabled actually shifted — role and selector are
absent by construction, since a change in either would mean the re-bind ladder
was looking at a different element. A delta gives way to a full re-read when
half the page is new, where there is nothing left to reuse.

The selector column is gone with it. It was 74% of the inventory on an
85-element page, nothing downstream ever read it, and a positional chain is
wrong the moment a sibling appears. An `#id` or `[data-testid]` survives when
the page offers one; everything else is addressed by handle.

Legibility is what makes the delta work rather than a nicety. `+ btn-sign-in`
on turn nine reads on its own, where `+ @e42` sends the model back to an
inventory twenty thousand tokens ago.

Measured on an 85-element app shell: 18,693 -> 4,930 characters for a baseline,
and a steady turn that moved two things costs ~200.
2026-08-20 05:26:37 -05:00