A Z.ai desktop user reported thinking reverting to medium after one turn,
burning ~200% of a week's credits in 4 days despite reasoning_effort: false
in config.yaml. Four compounding bugs:
- _session_info reported reasoning_effort "" for disabled reasoning,
indistinguishable from unset — the desktop adopted it after the first
turn, wiping its sticky "thinking off" pick so every later chat
reverted to the default effort.
- config.set key=reasoning always wrote agent.reasoning_effort to global
config.yaml, so every desktop model-menu selection (preset.effort ??
'medium') clobbered the user's configured value. Now session-scoped
like the messaging gateway's /reasoning, landing on
create_reasoning_override so lazily-built sessions keep it too.
- YAML `reasoning_effort: false`/`off`/`no` (boolean False) was coerced
to "" by every loader's `str(x or "")`, silently re-enabling thinking.
parse_reasoning_effort now treats False/"false"/"disabled" as
{"enabled": False}; loaders (tui gateway, gateway, cli, cron,
delegate) pass the raw value through. The desktop config reader also
crashed on the boolean (false.trim()), aborting voice/STT settings.
- The zai provider profile never sent thinking on the wire, and GLM-4.5+
defaults to thinking ON server-side — so disabling reasoning was a
silent no-op on direct Z.ai, the actual token burner. The profile now
emits extra_body.thinking {"type": "enabled"|"disabled"} for
thinking-capable GLM models, mirroring the DeepSeek profile.
Also: /new (session reset) now carries reasoning_config across the
rebuild like model_override; config.get reasoning prefers the session's
live value and maps a config False to "none"; Settings shows "Off"
instead of a blank select for hand-written false.
When the renderer sends a DELETE /api/profiles/{name} request, the IPC
handler tears down the profile's pool backend (or primary backend) via
prepareProfileDeleteRequest. However, the very next line calls
ensureBackend(profile), which spawns a fresh pool backend for the just-
deleted profile. The new backend's startup path calls ensure_hermes_home(),
which recreates the profile directory — defeating the deletion and leaving
the process as a zombie.
On the next Desktop restart the cycle repeats: the profile directory exists,
the Desktop spawns a backend, the backend recreates the directory after
deletion, and PIDs accumulate indefinitely.
Fix: make prepareProfileDeleteRequest return the torn-down profile name.
The IPC handler uses this to route the DELETE to the primary backend
instead of spawning a new pool backend for the deleted profile.
Fixes#52279
Replace the loopback/PKCE-callback server and manual-paste fallback with
the RFC 8628 device-code flow as the only xAI Grok OAuth login path. The
flow works in headless/SSH/container sessions with no 127.0.0.1 listener,
shrinking the local attack surface.
- Poll the token endpoint with server-provided interval, honoring
slow_down and expires_in; store tokens with auth_mode
oauth_device_code.
- Adaptive proactive refresh skew for short-lived device-code JWTs;
rotated tokens sync back to auth.json, the global root store, and the
credential pool (no refresh-token replay).
- Clear source suppression on successful re-login (CLI + dashboard) and
drop the duplicate dashboard pool entry so exactly one seeded
device_code entry exists.
- Use the shared device_code source name for consistency with the
nous/codex device-code providers.
- Desktop: remove the loopback OAuth flow states and dead type variants;
pkce providers' sign-in URL selection is unchanged.
- Docs (EN + zh-Hans) rewritten for device-code login; drop the deleted
--manual-paste flag from documented commands.
The colored-square rail stops scaling once a user racks up many profiles:
tiny drag targets and an endless horizontal scroll strip. Past a threshold
(13) the rail swaps the squares for a compact select dropdown — same active
tint + initial glyph, minus the drag-reorder / long-press-recolor / per-row
context menu that only make sense at small counts. Two render paths behind
one flag; the left default↔all toggle, the "+" create button, and Manage
stay put in both. Rename/delete/color remain reachable via Manage.
Both Desktop picker surfaces (status-bar model menu, settings/onboarding
dialog) only asked the connected gateway's model.options once a session
existed; before that they fell back to the Desktop REST/global options, which
can't see virtual providers a remote gateway exposes — including the MoA
presets from #53817. Centralize the fetch rule in requestModelOptions(): prefer
the connected gateway whenever one exists (no session_id needed — the RPC
resolves disk config), REST only when no gateway is connected.
The status-bar MoA preset section now renders from the same model.options
payload (the virtual `moa` provider row) instead of the local /api/model/moa
REST config, so remote presets appear correctly; the row is filtered out of
the main provider groups so presets don't list twice. Preset selection keeps
the persistent switchTo path from #56417 and drops the vestigial session gate —
like regular model rows, a pre-session pick ships on the next session.create.
Fixes#53817.
Rebased and reconciled with #56417 (persistent MoA selection), which landed
after this PR was opened and covered its one-shot-/moa half.
attachImagePath fetched its thumbnail through readDesktopFileDataUrl, which in
remote mode routes every read to the gateway fs bridge. Paperclip picks,
clipboard saves, and OS drops always produce paths on the LOCAL machine, so the
gateway read 404s — toasting "image preview failed" and dropping the thumbnail
even though the attach itself works (upload reads local bytes via the Electron
bridge). Read the local bridge first and fall back to the remote facade, which
still serves in-app drags from the remote project tree. Local mode is
unchanged (the facade already reads locally there).
Follow-up to #56572, which restored the remote paperclip picker and made this
path reachable from the picker as well.
startUpdatePoller() only called checkBackendUpdates() — never checkUpdates().
The statusbar version pill reads $updateStatus (set by checkUpdates()), so the
commit-behind counter stayed null after restart. It only appeared when the user
manually clicked the pill, which triggered checkUpdates() via openUpdateOverlayFor.
Added void checkUpdates() in three places alongside the existing
checkBackendUpdates() calls:
- On startup in startUpdatePoller()
- In the 30-minute setInterval callback
- In the onFocus handler
checkUpdates() uses the Electron IPC bridge (local git check), not the gateway,
so no mode gating is needed. The existing $updateChecking atom guard prevents
double-fire on overlap.
Fixes#53079
parseSlashCommand used /^(\S+)\s*(.*)$/ where `.` can't cross a newline and
`$` anchors end-of-string, so any slash command whose arg contained a newline
(/goal <multi-line text>, a skill command with a long pasted context) failed
the whole match, parsed as an empty name, and rendered "empty slash command"
while the payload vanished — cleared from the composer and absent from the
Up-arrow history ring, which only derives from sent user messages.
- name now splits on any whitespace ([\s\S]* arg), matching the CLI and the
gateway's split(maxsplit=1); multiline args flow to slash.exec intact
- the residual empty-name branch (bare "/", "/ text") restores the submitted
text to the composer draft instead of eating it
Fixes#41323. Fixes#55510.
Follow-up to the #39227 salvage: config refreshes fire mid-session too
(gateway events, settings saves), so applying terminal.cwd
unconditionally would yank the workspace out from under an attached
session. Gate the override on activeSessionIdRef like the sibling
reasoning/tier settings, keep branch refresh on the live cwd, and add
coverage for the active-session path. Also lint-polish the new test
file (typed config mock, prettier formatting).
The MoA preset section in the composer model dropdown presented presets like
persistent model selections, but selecting one dispatched the one-shot `/moa`
command (command.dispatch name=moa) — it ran a single turn through MoA and then
silently reverted to the prior model. The user saw MoA context for one message,
then it vanished with no indication.
Route MoA preset selection through the same persistent path real provider
selections use: onSelectModel({ model: preset, provider: 'moa' }) →
config.set model="<preset> --provider moa" → the gateway's switch_model. The
check mark now reflects the real current selection (currentProvider === 'moa'
&& currentModel === preset) instead of transient local state, and the
now-unused activeMoaPreset state is removed.
Tests: new model-menu-panel.test.tsx (2) — selecting a preset calls
onSelectModel with provider 'moa' (persistent), and the check renders on the
active preset. tsc -b clean.
prompt.submit is fire-and-forget — turn completion is signaled by stream /
message.complete events, not the RPC return — but it inherited the generic 30s
default RPC timeout. A turn that legitimately takes >30s to ACK (MoA presets
running references + aggregator in series, deep reasoning, large tool chains)
popped a false 'request timed out: prompt.submit' toast at 30s while the turn
was still running and streamed its real answer in 60-120s later (#55024).
Add PROMPT_SUBMIT_REQUEST_TIMEOUT_MS (1_800_000 = the backend's
agent.gateway_timeout ceiling) and pass it on all four prompt.submit call sites
(submit, resume-recovery retry, regenerate, rewind), mirroring the existing
SESSION_LIST_REQUEST_TIMEOUT_MS opt-out precedent. Widen the GatewayRequest
type (+ the inline requestGateway prop type) to carry the optional timeoutMs the
runtime impl already accepts.
Tests: use-prompt-actions/index.test.tsx 34/34 pass; tsc -b clean.
Dragging a folder from Explorer/Finder into the composer failed with "file
not found on gateway and no data_url provided", on local gateways too.
extractDroppedFiles tagged every OS drop as a File-bearing entry, so
partitionDroppedFiles routed the folder to the upload pipeline and
file.attach tried to read a directory's bytes — a directory has none, and
there is no data_url to send. This regressed in 4906dcfc25, which routed
OS drops through file.attach to reach a remote gateway but did not exclude
directories, which also carry a File handle.
Detect directories at drop time with DataTransferItem.webkitGetAsEntry(),
the only synchronous way to tell a dropped folder from a file. A dropped
directory now becomes a path-only entry with isDirectory set, which routes
to a @folder: ref exactly like the folder picker, instead of the file
upload path that cannot stage a directory.
Process transfer.items before transfer.files: webkitGetAsEntry lives only
on items, and claiming the folder's path there first lets the files
fallback dedup skip the same entry (Chromium lists a dropped folder in
both). Path-based dedup and the getPathForFile resolution are preserved.
Dark-mode connector lines and ring outlines read too faint. Double the
two live knobs: MODE_DEFAULTS.dark.lineAlpha 0.12->0.24 and
RING_PARAMS.dark.ringAlpha 0.03->0.06. (MODE_DEFAULTS.ringAlpha is dead;
the outline is drawn from RING_PARAMS.)
The last welded composer engine. The `@`/`/` trigger state, detection
(refreshTrigger), the adapter-driven item list + its effects, popover selection,
closeTrigger, commitTypedSlashDirective, and the contentEditable chip insertion
(replaceTriggerWithChip) move verbatim into hooks/use-composer-trigger.ts behind a
hook that takes the editor refs + the two completion sources (at/slash). ChatBar's
input/keydown/keyup paths + the popover render consume the returned API; the
keydown navigation block stays in place (no key-handling restructure), and
triggerKeyConsumedRef is exposed so keyup still skips its post-consume refresh.
ChatBar 1,248 → 1,047. Behaviour-preserving: typecheck 0 errors, eslint clean,
and the composer DOM repro suite (slash-nav, enter-submit, IME composition,
trigger-popover) is green — the documented IME/caret/focus edge fixes ride along
verbatim. (The 1 attachments.test.tsx failure is pre-existing on main.)
fallback-model.ts (1,696) folded into assistant-ui/tool/fallback-model/ with
three cohesive, self-contained leaf modules extracted (verbatim moves):
- types.ts (83) — the shared tool-view types/interfaces.
- format.ts (133) — pure value formatting/parsing (isRecord, compactPreview,
clampForDisplay, prettyJson, parseMaybeObject, unwrapToolPayload, numberValue,
contextValue, formatDurationSeconds).
- targets.ts (75) — url/path/preview detection + disclosure ids (looksLikeUrl,
findFirstUrl, hostnameOf, isPreviewableTarget, toolPart/GroupDisclosureId).
index.ts (1,434) keeps the tool-specific assembler (TOOL_META, titles, the count
machinery, subtitle/detail/diff, buildToolView) and re-exports the leaf modules,
so consumers importing `./fallback-model` are unchanged (folder index resolution)
— no importer or channel edits needed. The count/result/detail helpers reach
across each other around buildToolView, so they stay together to avoid a circular
split; the three leaves are the clean cut.
Behaviour-preserving: typecheck 0 errors, eslint clean, fallback-model test 24/26
(the 2 browser_navigate title failures are pre-existing on main — `hostnameOf`
intentionally includes the pathname; verified identical on the un-split file).
The merged #55859 left the star-map NodeContextMenu import and the
canvas onContextMenu prop out of perfectionist's required order, failing
`npm run lint` in the desktop workspace. Reorder both.
TUI /journey gets d/e with confirm + $EDITOR; desktop gets a right-click
context menu with inline edit modal. Both refresh the graph after mutation.
Extract openInEditor into the shared TUI editor helper.
Bring Hermes-Setup.exe's UI onto the shared design tokens (self-contained, no
desktop-component coupling) and add two capabilities:
- design: flat stage rows (running step opaque, rest muted), neutral check /
destructive cross, running fourier-flow Loader, hairline --stroke-nous
borders, fill-less log panel; ported BrandMark (nous-girl) + HackeryButton +
Loader standalone; re-synced button variants; de-boxed success/failure.
- theme: follow the OS light/dark via the authoritative Tauri window theme
(theme.ts + onThemeChanged, core🪟allow-theme), with Nous dark seed
colors in styles.css so the --ui-*/--dt-* chain derives correctly.
- updates: split the monolithic "Updating" bar into handoff -> download ->
rebuild (+ install on macOS) stages via a shared update_stages() builder, a
live elapsed timer on the running stage, and a dev-only fake-boot preview
(gated on import.meta.env.DEV, stripped from the shipped bundle).
Adds hooks/use-composer-url-dialog.test.tsx (renderHook): @url: directive
fallback, host onAddUrl preference + clear/close, and the blank-input no-op.
First unit coverage for an extracted composer engine — previously none of this
logic was testable while welded into the DOM-coupled ChatBar.
Moves the docked↔floating state, dock/float/toggle actions, drag-gesture wiring,
and the on-screen re-clamp effect out of ChatBar into
hooks/use-composer-popout.ts, verbatim. ChatBar passes its composerRef in and
consumes the returned popout state/handlers; the secondary-window gate and the
shared persisted atom stay encapsulated in the hook.
Moves the resting-placeholder state + the conversation-change re-roll effect +
the disabled/reconnecting/starting derivation out of ChatBar into
hooks/use-composer-placeholder.ts, verbatim. The hook owns its own i18n + browse
reset; ChatBar just reads the derived string.
Moves the URL dialog's open/value state, autofocus-on-open effect, and submit
(host onAddUrl or an @url: directive) out of ChatBar into
hooks/use-composer-url-dialog.ts, verbatim. ChatBar just wires the returned
openUrlDialog into the context menu and the state into <UrlDialog>.
Moves the chat-focused Esc-cancel listener (the latest-handler ref + the
register-once window keydown effect) out of ChatBar into
hooks/use-composer-esc-cancel.ts, verbatim. Encapsulating the latest-closure ref
inside its own hook is the first of the plan's "delete the latest-closure refs"
cleanups: it's no longer a loose ref in the 1.4k-line component, just an
implementation detail of a focused side-effect hook keyed on busy/awaitingInput/
onCancel.
Moves the CodingStatusRow hand-offs (openInWorktree + branch-off / convert /
list / switch) out of ChatBar into hooks/use-composer-branch.ts, verbatim. The
hook depends only on cwd + draftRef + clearDraft (backend coupling via the
projects store); nothing about ChatBar's render. Dead projects/composer-store
imports drop out of index.tsx.
Probe the projects.* RPC surface, block create with a clear update hint,
and avoid the raw "unknown method" toast. Includes i18n for en, zh, ja,
and zh-hant.
FixesNousResearch/hermes-agent#54999
Update the shared queued-edit ref synchronously with React state so draft persistence sees the correct edit mode while loading and restoring queued prompts. Also drop the accidental node_modules symlink from the PR.
Ensure Windows desktop and local terminal teardown kill full process trees so Git Bash descendants cannot survive wrapper exits and accumulate across retries.
Audit follow-up. ChatBar subscribed to the whole `$statusItemsBySession` (a
computed that rebuilds the entire map) + `$previewStatusBySession` maps just to
derive a boolean, so every per-item status mutation (a subagent tick, the 5s
background poll) and every OTHER session's change re-rendered the ~1.4k
component. The queue hook likewise subscribed to the whole `$queuedPromptsBySession`
map.
- Add `useSessionStatusPresence` — a coarse edge (useSyncExternalStore) that
flips only when the stack shows/hides; ChatBar uses it for the styling
data-attr instead of the two map subscriptions.
- Add generic `useSessionSlice(store, key)` — subscribes to one session's array,
bailing out when other sessions churn (the plain atom keeps per-key refs
stable). The queue hook now reads its slice through it.
Result: ChatBar re-renders only when the stack's presence flips or this session's
queue changes — not on background/subagent status streaming or other sessions.
Verified: typecheck clean, 0 lint errors, composer tests 39/40 (pre-existing
attachments failure unrelated).
Two composer fixes:
- **Paste/input lag** — `flushEditorToDraft` serializes the whole editor
(`composerPlainText` is O(n)); running it on every event during a burst
(holding a key, or holding Cmd+V into a growing editor) was O(n²). Coalesce
the input/paste path to one flush per animation frame. Lossless: the
contentEditable DOM is the source of truth and submit + the compositionend /
keydown paths re-read it synchronously (those stay immediate).
- **Detached-composer dock glow** — was `fixed inset-x-0` (full viewport, spilled
under the sessions sidebar). Switched to `absolute inset-x-0`, so it anchors to
the chat-column root the docked composer centers in — the glow now spans only
the thread area, matching the actual dock target.
Verified: typecheck clean, 0 lint errors, composer DOM repro tests pass.
Lift the submit orchestration out of ChatBar into
composer/hooks/use-composer-submit.ts: `submitDraft` (the one decision tree —
queue-edit save · slash-now-while-busy · queue · drain · send · stop),
`dispatchSubmit` (the shared send-with-restore primitive + the external-submit
listener), and `steerDraft`.
This is the seam where the draft and queue engines meet; it now reads both clean
APIs as explicit inputs instead of closing over inline state. ChatBar is left as
a thin coordinator that owns the shared `queueEditRef` and wires the four engines
(draft · queue · submit · metrics/voice/drop) into render.
Behaviour-identical (verbatim move). Verified: typecheck clean, composer DOM
repro tests (enter-submit, IME, slash-now, steer, drain) pass.
Lift the queue subsystem out of ChatBar into composer/hooks/use-composer-queue.ts:
the per-session queue-store binding + queuedPrompts, in-place queued-prompt
editing (begin/step/exit), the shared drain lock + send-then-remove sequence,
manual send-now, bounded auto-drain, and the three queue effects (re-key migrate,
idle auto-drain, queue-edit cleanup).
It consumes the draft API (draftRef/clearDraft/loadIntoComposer/focusInput) and
writes the coordinator-owned `queueEditRef` the draft engine reads — so the
draft↔queue coupling is two explicit deps, not an inline tangle. `steerDraft`
and the chat-focus Esc-cancel stay in ChatBar (not queue-internal).
Behaviour-identical (verbatim move). Verified: typecheck clean, composer DOM
repro tests + queue/edit paths pass.
De-entangle the draft spine: lift the source-of-truth engine (the imperative
composer-runtime subscription, edit primitives, focus, edge selectors, and
per-session load/clear/stash/restore) out of ChatBar into
composer/hooks/use-composer-draft.ts.
The draft↔queue cycle is broken by making `queueEditRef` a coordinator-owned
ref ChatBar threads into the hook (explicit dep, not an implicit shared global).
The contentEditable *event* handlers stay in ChatBar (they bridge into the
trigger engine) and drive the primitives the hook exposes.
Behaviour-preserving (verbatim move); typing perf preserved. Verified: typecheck
clean, composer DOM repro tests (enter-submit, IME, slash-nav) + text-guard pass.