Preserve profile ownership proven at the secondary socket boundary rather
than trusting arbitrary wire profile fields. Retire transient local owners
with the profile pool, while keeping durable and exact remote ownership first.
Salvage #103774 with two invariant tests and routing documentation. The
profile-only fallback was also identified in the earlier #103770; this
version retains the producer provenance and retirement boundary.
Live Desktop renderer with two isolated serve backends: Reject previously
failed after clearing durable bindings, leaving approval pending. With this
change, the same action sends deny to the owning backend and pending clears.
Existing-binding controls pass on both sides. No vendor inference used.
Fixes#103755
Salvaged-from: 8a3c545e255b66b6ca4bc4b99cd725c5f7a08632
Recognize the full producer task header when a batch goal spans lines,
so the next task goal and preceding transcript footer cannot become part
of the displayed result. Extend the existing invariant and real SQLite
producer probe with that case.
Make the live probe artifact path explicit, run Chromium headlessly, and
document its synthetic fixtures and integration fidelity limits.
Keep compact completion labels while rendering only result and job output bodies, including legacy and batch deliveries. Credit Gyarados4157's #101083 investigation; avoid rendering its full model instruction envelope.
Remove whole-document whitespace cleanup that erased hard breaks and fenced-code spacing across all five ingestion paths. Limit generated-image gap joining to the removed image itself. Keep soft newlines as soft breaks.
Investigated #97117 and the approaches in #97431 (@wooyongbin3-cpu) and #97175 (@Jackal991); both leave fenced-code whitespace and sibling ingestion paths exposed, so remove the destructive normalization instead.
Salvage #99095 (e7ea53074ab2b64a1530641659399d9f1bb4b435), completing one-time migration for both boolean values and using the existing storage helpers. Hydration and local toggles never edit backend configuration. Fixes#99076.
Desktop AGENTS.md requires a compat fallback to be 'tied to an identified
older runtime' so a future cleanup knows when it can be deleted. The
original comment said only 'older backend plugins'; name the actual
boundary: backends before #35395 (May 2026) omit the attachments key.
Review follow-up on the salvage of #104529.
#96269 and #102465 fixed the same blank-sidebar symptom with the same
"narrow the persisted filter to ids the live tree names" mechanism.
resolveLiveProjectFilter (the earlier PR) is the single resolver; the
duplicate sanitizeProjectFilter is removed. What #102465 adds beyond it stays:
detached rows file under NO_PROJECT_ID, so filtering to Home keeps Home's rows.
The sidebar's persisted project filter is a membership whitelist over tree node
ids. Ids that the active profile's tree does not resolve (picked in another
profile, or left over after a project was deleted / an update rebuilt the tree)
used to filter every row out — headers rendered, zero sessions, until Local
Storage was cleared. Narrow the persisted filter to ids the live tree resolves;
dead ids are inert, never fatal. Memo-only, never written back.
Salvage of #96269 (three commits: fix + two lint passes, folded).
`persistSshConnectionToken()` writes the per-serve session token adopted for
an SSH connection onto its v2 registry entry, but the registry never read it
back: `normalizeRegistry()` rebuilt an `kind === 'ssh'` entry from
`normalizeSshConfig()` alone, which describes only the DIAL (host, user, port,
keyPath, remoteHermesPath, remoteProfile). The sibling remote/cloud branch
preserves `entry.token`; the ssh branch did not.
The token therefore survived only in the mtime-keyed in-process cache. On the
next cold read — an app restart, or any process that re-parses
connections.json — it was silently dropped, so `resolveRemoteBackend()`
decrypted an empty value and dialed with `reuseToken = ''`. That fails the
`Boolean(reuseToken)` clause of remote-lifecycle's `reusable` gate, so a
HEALTHY owned backend was classified not-reusable, reaped by `cleanupStale()`
and respawned on a new port behind a new tunnel — while the renderer kept
dialing its cached `wsUrl?token=` at the old credential and got 403 forever.
`normalizeConnectionInput()` had the same omission: `saveRegistryConnection()`
resolves the surviving envelope via `resolvePersistedRemoteToken()` and passes
it in, but the ssh branch dropped it, so a plain label rename wiped the live
backend's reuse credential and re-armed the same loop.
Both branches now carry the token exactly the way the remote branch does.
There is no auth-mode choice on an ssh entry that could invalidate the
envelope, so no drop condition is needed.
Fixes#103795
- The placeholder ("assistant response not persisted") is removed: it turned a
wholly-empty persisted row into TEXT, which in reconcileResumeMessages could
replace a text-only live stream row at the same ordinal until the turn
settled. The sidecar fallback alone covers the #68321 field repro.
- Phase filter matches the backend (codex_responses_adapter _OutputScan._message):
commentary AND analysis are reasoning-channel narration, not the reply.
- Tests trimmed to the two contracts that are red on main; six that passed on
main (pre-existing reasoning-part behaviour) are gone. File renamed for what
it now covers.
Field-level reproduction (2026-09-02, v0.21.0, in the issue thread): an
assistant row with content length 0 whose user-visible response exists
only in reasoning / reasoning_content / codex_message_items renders
live, then disappears from the transcript after a session/profile
switch-back. DB intact - the row is still there on every re-read; only
the rehydrated render loses it. Six independent confirmations across
macOS and Windows since 2026-07-22; all prior fixes (#68329 envelope
normalization, closed implemented_on_main; #77644 mid-turn reconcile;
#101470 compaction-display projections) addressed adjacent producers,
not this one.
Two producers in toChatMessages hydration, both pinned by the new
chat-messages.reasoning-survival.test.ts (fails 9/9 on current main):
1. codex_message_items never read. Responses-API turns can persist with
`content` empty while the reply the user saw lives only in the
message-items sidecar ({type:'message', role:'assistant', phase,
content:[{type:'output_text', text}]}). The live stream painted that
text; hydration ignored the sidecar, so the rehydrated bubble came
back blank - and the blank chatMessageText at the same role-ordinal
then made reconcileResumeMessages drop the cached row's parts
(sameText/extension/isLiveTailRow all fail), erasing the reply on
every switch-back. Fix: codexMessageItemText() extracts
non-commentary assistant output_text as the bubble's text when
content and reasoning produced no parts. Persisted content still
wins when present; commentary narration is never promoted.
2. the zero-parts drop. An assistant row hydrating to no parts at all
returned early and vanished from the transcript entirely - the
"all assistant messages gone; user messages remain" shape. Fix: a
wholly empty assistant row (display_kind != 'hidden') paints a
_(assistant response not persisted)_ placeholder instead of
disappearing. Hidden scaffolding rows keep dropping as designed
(pinned by a test).
Also adds codex_message_items to the SessionMessage type (the gateway
has shipped it since the branch-copy lane; the type never declared it).
Verification (native Windows, node 24.17):
- new file: 9 passed (fails 9/9 without the hydration changes)
- src/lib full dir: 1243 passed
- reconcile suites (utils + resume-structural-parts): 115 passed
- streaming/timeline reasoning-part suites: 25 passed
- tsc --build tsconfig.json: clean
Fixes#68321
- session-control-goal.tsx: a 'send' dispatch against a busy session now
parks the kickoff on the composer queue (the backend already resumed the
goal) instead of reporting continuationFailed. Shared helper
queueKickoffIfSessionBusy() extracted from slash.ts so both paths agree.
- gateway-switch.ts: wipeSessionListsForGatewaySwitch clears
$sessionControlBySession (runtime-id keyed; new backend re-mints ids).
Dead resetSessionControlAfterGatewayRebind removed; clearSessionControl
now called from the session delete path beside clearQueuedPrompts.
- session-control.tsx: read/hydration failures use controlUnavailable copy,
not actionFailed.
- i18n: heartbeatDueWaitingForIdle added to ja/ru/zh-hant; new
continuationQueued/continuationBusy/controlUnavailable in all locales.
- Sidebar: the entry joins SIDEBAR_NAV (same chrome, active state, data-tour
handle as the other rows) instead of a one-off Button below the rail;
i18n moves to sidebar.nav['session-import'].
- Reuse common.retry / common.refresh / common.back instead of duplicating them
under sessionImport in six locales.
- hermes_cli/foreign_session_browser.py -> foreign_sessions_browser.py so it
sorts as a sibling of the foreign_sessions module it extends.
Browse the backend host's foreign CLI session logs, preview a bounded read-only
transcript, and continue a copy in Hermes under the selected profile. Reuses the
hermes_cli.foreign_sessions parsers and the portability validator/writer;
imports are transactional and deduplicated on the recorded origin.
A delayed browser could miss the 900ms terminal event and spin forever after the updater exited. Retain terminal delivery until the page acknowledges it, bound unavailable-client teardown and failed requests, and preserve a truthful final display.
Fixes#103747. Builds on OutThisLife and Teknium detached handoff work in #83634 and the #75895 quiet-window design. Continues Axl Ibiza Windows update investigation (#60233, #94107, #100763), including source/review contributions carried by merged #93353 and #85170. Existing #102373, #103140, #95719, #97299 and #103632 retain their separate scopes.
When a sash drag folds a tool zone to its rail, the store commit re-renders
only the wrappers whose style prop changed; the flex sibling the gesture
had pinned to `flex: 0 1 <px>` kept that inline preview and could not grow
into the freed space. Restore every captured style attribute before the
commit, on every release path.
A sash drag used to stop the moment its seam partner hit its min size, so a
row of panes behaved like unrelated boxes: growing the Browser tile could not
take space from Chat once the pane between them was at its floor. The drag
now plans the whole run from the pointerdown sizes — the partner donates
first, then its next visible sibling, and so on — and commits once on
release (fixed zones get px overrides, the flex run gets weights).
A reverse drag after a cascade stays local to the seam. Release only folds a
tool zone that THIS gesture took to its floor, so an unrelated rail already
resting there (a minimized Terminal) no longer cancels the Files/Chat commit.
Salvaged from #103166 (Jerry Gooch), trimmed to the sash-drag change; the
width/height lock feature, zone-body context menu and the restored-tool CSS
floor are held as separate decisions.
Review follow-ups on the #96187 cherry-pick: skip on win32 like the
sibling tests that shell out; reuse the file's `exec` helper; one temp
root so a failing second mkdtemp cannot leak the shim dir; quote the
shim's redirect target; guard the payload-prefix sentinel so a renamed
loop marker can never make the test execute the real spawn payload;
drop the lockMetadata fields the assertions never read. Move the
mutexPath contract into withRemoteUpdateMutex's doc comment instead of
a third inline restatement.
`apps/desktop/'` is not a real path. It is a literal single-quote directory
holding a full absolute path as nested subdirectories:
apps/desktop/'/var/folders/5h/.../hermes-update-mutex-LMF9y5/home/.hermes-update-in-progress.mutex'
Both files are 0 bytes, nothing in the tree references them, and the leading
and trailing `'` are part of the filenames. They are the fingerprint of the
double-quoted mutex path in `withRemoteUpdateMutex()`: the path reaches Python
with its shell quotes still attached, so it is treated as CWD-relative and
`os.makedirs()` materialises the whole absolute path under whatever directory
the process happened to be in. Running
`apps/desktop/electron/remote-lifecycle.test.ts` from `apps/desktop`
reproduces it on the spot.
They were swept in by a `git add .` in 36620578f0, an unrelated menu-label
commit.
This only removes the committed artifact. The generator is a separate concern
already covered by open PRs (#99189, #96187, #96260) and issues (#99133,
#96212, #96188); those repair the quoting but none of them deletes these two
files, so the litter would survive whichever one lands.
expandRemotePath() returns an already-quoted shell fragment
("$HOME"'/path'), but three call sites wrapped its output in shq()
again: the withRemoteUpdateMutex python argv, the reservation/lock/
owner_file assignments in buildSpawnCommand, and the identity values in
buildOwnedStaleTerminationCommand.
The remote shell strips only one quoting layer, so python received a
mutex path with literal quote characters in it (creating a directory
literally named ' in $HOME), and the payload's mkdir "$reservation"
loop spun on a path that can never exist. Every Desktop SSH backend
spawn hung until the connect timeout, retried, and left an orphaned
flock queue behind; stale-owner cleanup always printed REFUSED for the
same reason.
The regression test parses the composed command with a real sh — the
same parse the remote login shell performs — and requires the mutex
path and the payload's reservation paths to come out fully expanded.
Whether dangerous commands run unasked is state worth seeing at a glance,
so the approval pill (yolo lightning) leaves STATUSBAR_HIDDEN_BY_DEFAULT.
Existing stores were seeded with it hidden, so the hidden-set key moves to
`hermes.desktop.statusbarHidden.v2`, seeded from v1 minus `approval-mode`:
other customizations survive, the zap appears once on update, and hiding
it again persists under the new key.
The whole-bar preference lived at `hermes.desktop.statusbarVisible`. For a
stretch (d399c164 → 120e465c) the atom's fallback was `false`, so any
install that launched in that window persisted a hidden bar the user never
chose, and flipping the fallback back to `true` only helped fresh stores.
Move the preference to `hermes.desktop.statusbarVisible.v2` and do not seed
it from v1: every existing install comes back to "on" once on update, and a
hide made afterwards persists under the new key. Fresh installs are on by
default as before.
spawnPoolBackend() is not on every dial path: a primary route (startHermes),
a registry remote scope (connectRegistryBackend), a reused primary SSH
backend, or a guard rejection all settle the claim without requesting a
slot, so a foreground mark set for that dial stayed in pendingForegroundSpawns
and would have upgraded the next background hydration spawn of the same key.
applySpawnPriority() now returns the cleanup; both IPC handlers run it in a
finally around the claim. The mark is also taken right before the slot
request instead of at function entry, so the remote branch never consumes it.
Every user open first probes its route (sharedPrimaryRoute /
isAttachedSharedRemote) with getConnection / getConnectionFor, and only then
dials the secondary. With #102496 only the second dial carried
priority: 'foreground', so main started (or joined) the spawn as a background
slot wait on the probe and the click still waited out the probe's 20 s
RECONNECT_ATTEMPT_TIMEOUT_MS before promotion kicked in. Thread the priority
into both probes; the activation doors (ensureGatewayForProfile /
ensureGatewayForAgent) pass 'foreground' explicitly.
Also drop the renderer-side isBackgroundSlotWaitTimeout + the try/catch whose
two branches both rethrew: Electron rebuilds IPC rejections as a plain Error,
so name/silent/priority never reached the renderer and the helper was dead.
Test: gateway-spawn-priority.test.ts asserts every dial of a foreground open
carries the tag and an untagged open never does (red on the #102496 head).
Follow-ups to the #102496 salvage in the Electron main process:
- pendingForegroundSpawns leaked: promoteInFlightLocalSpawn marked the key
even when the pool entry already existed (the common click path), and
nothing consumed it. After that backend was reaped, the next dial for the
key - normally 10 s roster hydration - spawned as foreground and sat in the
reserved slot. Mark only when no entry exists yet; spawnPoolBackend consumes
the mark before any early return (remote route included) so it never
outlives the dial.
- The "waiting for a free local slot" log fired for a foreground request that
was granted the reserved slot immediately (condition was queuedCount > 0
after request()). The request now reports `queued`; log only then.
- One promotePoolEntry() and one logPoolSpawnFailure() replace three copies of
the promote snippet and two copies of the background/foreground log branch;
spawnPoolBackend reads entry.spawnPriority instead of a second opts channel;
isBackgroundSlotWaitTimeout is an instanceof check (same process as the
class, no duck typing).