Commit Graph

600 Commits

Author SHA1 Message Date
teknium1
eb049af69c Revert "feat(desktop): copy and Bot Mode group turns follow no-deadline prompts"
This reverts commit 0ea07255e0.
2026-10-01 13:23:12 -07:00
teknium1
eb481d0cd9 Revert "test(desktop): a group turn blocked on the user's approval outlives the hard cap"
This reverts commit 58dffc74ef.
2026-10-01 13:23:12 -07:00
teknium1
423eef696e Revert "chore: review follow-ups — stale timeout comments, softer docs claim, group-turn cap credits only user-blocked intervals"
This reverts commit bdf4a5d632.
2026-10-01 13:23:12 -07:00
hermes-seaeye[bot]
973b27f5c8 fmt(js): npm run fix on merge (#130322)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-10-01 11:10:50 +00:00
Brooklyn Nicholson
bdf4a5d632 chore: review follow-ups — stale timeout comments, softer docs claim, group-turn cap credits only user-blocked intervals 2026-10-01 03:50:55 -05:00
Brooklyn Nicholson
58dffc74ef test(desktop): a group turn blocked on the user's approval outlives the hard cap 2026-10-01 03:50:55 -05:00
Brooklyn Nicholson
0ea07255e0 feat(desktop): copy and Bot Mode group turns follow no-deadline prompts
A Bot Mode group turn blocked on the user's clarify/approval answer pushes its
runaway hard cap out instead of expiring the card under the room; the offline
toast and the Settings description stop promising an approval timeout; the
approval.respond deadline comments describe what it bounds now.
2026-10-01 03:50:55 -05:00
Brooklyn Nicholson
330d9d6df9 fix(desktop): wrap wide markdown and fences instead of scrolling the transcript (#70451)
Wide markdown content forced horizontal panning: fenced code in tool
bodies, user bubbles and the bot group room grew sideways scrollbars (the
group room clipped with no scroll affordance at all), and huge plain-text
fallbacks never wrapped.

- markdown-text: min-w-0 on the container so the flex chain can't grow
  from its content; HugeTextFallback chunks soft-wrap
- compact-markdown (tool bodies): fenced code soft-wraps
- user-message-text: fences soft-wrap inside the bubble
- group-chat-view: room bodies soft-wrap pre instead of overflow-x-auto
  (the clip with no scroll affordance, #91706 surface)
- tables keep content-sized auto layout, so cells wrap at word
  boundaries; an unbreakable token scrolls inside the table card, like
  code cards do, instead of squeezing every column letter by letter

Co-authored-by: yuexiong <xiongyue_hnu@163.com>
Co-authored-by: FalconOrtiz <falcon.ortiz11@gmail.com>
Co-authored-by: networthexplained <networthexplained@users.noreply.github.com>
2026-10-01 02:20:13 -05:00
Brooklyn Nicholson
9e631991f5 fix(desktop): resolve the Bot Screen RFB URL against the route target profile
Absorbed from #120970 by @KoNit-K: resolveScreenWsUrl fell back to
route.profile, which for an aliased bot (route.profile = the launch
home, targetProfile = the backend profile) minted the display ticket
against the wrong profile and connected the RFB pane to :20's screen
instead of the bot's — the same #120966 misroute the displayRequest
choke-point fix covers for RPCs. resolveSiblingWsUrl must see the
target profile, same as every gateway call.

Co-authored-by: KoNit-K <konit.block@protonmail.com>
2026-10-01 01:45:31 -05:00
finn763
4d4e9d623c fix(desktop): scope Bot Screen display.* to the bot backend profile
display.* handlers are @_profile_scoped: without an explicit profile
param the gateway answers for the launch home, so every Bot Mode screen
pane attached to :20. Dedicated secondaries forward params unchanged, so
displayRequest now sends the route backend profile on every call.
2026-10-01 01:45:31 -05:00
hermes-seaeye[bot]
031d2237ad fmt(js): npm run fix on merge (#130087)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-10-01 06:34:34 +00:00
Brooklyn Nicholson
0d9579ff71 style(desktop): import and JSX prop order for the hub iframe policy 2026-10-01 00:39:41 -05:00
Brooklyn Nicholson
2a400b4ade fix(desktop): restore Skills Hub links, clipboard, and frame pinning
The Bot Mode Skills Hub embed was crippled by three stacked denials, each
individually deliberate (#91612): the iframe sandbox omitted popups and
the clipboard-write permission policy; the CVE-2026-70608 window-open
handler denied every window.open with no trusted-origin delegation; and
the session permission handlers refused clipboard-sanitized-write for
every origin. The frame could also navigate itself off the picker URL
into the un-embeddable docs site, after which its links were dead too.

Consolidated fix, gated on the EXACT hub origins (Vercel and the GitHub
Pages mirror, hub-iframe-policy.ts):

- skills-hub.tsx iframe: allow="clipboard-write" plus
  allow-popups/allow-popups-to-escape-sandbox on the sandbox.
- window-open-policy.ts: {action:'deny'} stays unconditional; a
  TrustedWindowOpenOptions {getOpenerOrigin, openExternalUrl} may
  additionally delegate hub-origin http/https/mailto opens to the
  audited openExternalUrl as a deny side effect. Opaque artifact frames
  (origin 'null') and every other origin keep the CVE-2026-70608
  side-effect-free posture.
- main.ts permission handlers: clipboard-sanitized-write granted only to
  the hub origins (request and check paths); reads stay denied.
- main.ts will-frame-navigate: hub frames are pinned to the picker URL —
  escape navigations are denied and the frame reloaded onto the picker.

The sandbox/allow posture is asserted through component behavior in the
picker suite (no source-text reading), and the policy module is unit-
tested directly.

Supersedes #97945 (whose two other iframe call sites were deleted by the
native catalog, and whose test read the .tsx source with a regex) and

Fixes #91612

Co-authored-by: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com>
Co-authored-by: lepkov <lepkov@users.noreply.github.com>
2026-10-01 00:39:41 -05:00
Brooklyn Nicholson
33ebf9f11a fix(desktop): keep agent browser control after pop-out
After pop-out the live webview lives in the Browser window while the chat
window still owns the active-session gate, so drive_preview/read_preview
answered NOTHING_OPEN/empty from the chat window. Forward both over the
shared window relay (IPC in the app, BroadcastChannel as a dev fallback)
and answer with the pop-out's result; a silent pop-out falls back to the
legacy local error. Both pop-out legs now ride one relay surface.

Adapted from #101530 by FalconOrtiz (rebased onto the server-requests
split; transport moved from BroadcastChannel-only to the IPC relay so it
works in packaged file:// windows).

Co-authored-by: FalconOrtiz <falcon.ortiz11@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-30 23:45:41 -05:00
Miguel Chaves
809b1aa50b fix(desktop): route pop-out browser annotations to origin 2026-09-30 23:45:41 -05:00
Brooklyn Nicholson
544df42d78 fix(desktop/radio): resume the preserved stream when dictation lands in the composer
Fluid Voice (and dictation tools like it) pauses other audio over the
macOS media session to hear the microphone, then inserts the transcript
into the focused field — a guarded paste in Chromium/Electron. No
media-session resume signal ever follows, which is why preserving the
stream alone (the reporter's own experiment) still left radio paused.

Track the generation that last reached 'playing' (playGeneration); an
external pause keeps the element intact (status 'paused'), and a paste
into an editable field resumes it via a plain element.play(). The
plugin's own Pause stays destructive (toggle() from 'live' runs stop(),
clearing the element), so a paste never resumes an intentionally paused
stream and Play reconnects from scratch. Never resumed on window focus.

Credit: Kokhlo's PR #108115 contributed the non-destructive external
pause and the survival test.

Co-authored-by: kokhlo <konstantin.khlopkov93@gmail.com>
2026-09-30 23:11:19 -05:00
kokhlo
356a80bfe3 fix(desktop/radio): preserve stream on external pause for Fluid Voice resume (#108113)
Browser/media controls pause the element outside our buttons. The old handler
tore down the stream (cleared src, invoked load(), removed the element, and
advanced the generation) before macOS Fluid Voice or any external media
controller could resume it.

The internal Pause control already increments the generation first, so its
resulting media pause is safely ignored by current(). The external-event
branch can therefore be non-destructive: set status to 'paused' instead of
calling stop(). The 'playing' event already transitions the state back to
'live', so resume is possible.

Add a focused lifecycle test: external pause changes status to 'paused',
element and src survive, and a synthetic 'playing' event brings the stream
back to 'live'.
2026-09-30 23:11:19 -05:00
Brooklyn Nicholson
4235742381 fix(desktop): fall back the Skills Hub picker to GitHub Pages when Vercel refuses
Vercel's WAF denies some residential IP ranges for the whole docs domain,
which leaves the Bot Mode Skills Hub picker on a 403 block page with no
alternate origin (#118203). Probe the primary picker URL before each
browse session and fall back to the equivalent GitHub Pages deployment
when it is unreachable, refuses us, or hangs past an 8s deadline; picker
installs are accepted from either trusted origin but still gated on our
own frame's contentWindow. Documented as an FAQ troubleshooting entry.

Re-lands the surviving half of #118206 (its Capabilities picker target
was since deleted by the native catalog).

Fixes #118203

Co-authored-by: KoNit-K <konit.block@protonmail.com>
2026-09-30 23:07:37 -05:00
hermes-seaeye[bot]
95ee74db64 fmt(js): npm run fix on merge (#129578)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-09-30 19:52:04 +00:00
Brooklyn Nicholson
0dfc3e39b9 fix(bots): keep the Bot Chat stale-tile probe discard-only so background refreshes never take focus 2026-09-30 14:23:52 -05:00
Brooklyn Nicholson
99a8d3717e test(desktop): the non-primary relay test allows the sole-connection roster clear
#108111's test asserted no relay call at all, but ee6fb0ed22 already sends one
empty bot_relay.roster.sync to a sole connection so it forgets a departed
machine's agents. Once the local source is filtered, the remote primary is that
sole connection. Assert the actual contract: only that clear goes out, to the
primary, and the local source is never polled or pinned.
2026-09-30 11:29:53 -05:00
Brooklyn Nicholson
c69eda0d56 chore(desktop): regenerate the i18n key manifest; type the canonical-chat mocks without import() 2026-09-30 11:05:17 -05:00
Brooklyn Nicholson
aecf596db9 refactor(bots): share the stale Bot Chat tile probe between the roster click and tip open
isStaleBotChatTile(canonicalIds) replaces the copy in openStoredBotChat, whose
try/catch duplicated the optional call; the stale-tiles test no longer
contradicts its own comment.
2026-09-30 10:31:18 -05:00
Brooklyn Nicholson
fe5b728bd9 fix(bots): size live_message_count with SessionDB's own display projection
The count left out DISPLAY_VISIBLE_SQL, so model-only rows from
micro-compaction were counted while the reader painted none, and the history
wait this count exists to skip still ran. SessionDB.display_message_count now
counts the same set _display_rows_from_conn paints, through _read_one instead
of reaching into db._lock/_conn, and one _live_count_field serves all three
listing paths. The field is declared on SessionListRow, ProfileSessionPreview
and ProfileCanonicalSession (extra=forbid) and the desktop derives it from the
generated contract instead of hand-typing it twice.
2026-09-30 10:12:37 -05:00
Brooklyn Nicholson
3ace90ae4b refactor(bots): type the compression-tip tile probe off the SDK host
The stale-tile probe added before the tip open reached the host hook through a
cast and split the forceResume comment. Call the typed optional hook directly.
2026-09-30 09:57:26 -05:00
finn763
f8bf71b06e fix(desktop): discard stale Bot Chat tile before opening compression tip
Every Bot Chat compression left two tabs captioned with the bot name: the
lineage guard from 202997b51d matches through sidebar-attached lineage
metadata, which a hidden Bot Chat lineage never carries. Run the same
owner-scoped stale-Bot Chat probe the roster click runs inside
openStoredBotChat, against the registry id + resolved tip, before
host.openSession. Covers activity-refresh and reclaim opens. Side tabs
with other titles are preserved.

Closes #120810

(cherry picked from commit 60fc979349ae90aefba3465efc13c961da37b556)
2026-09-30 09:57:07 -05:00
kokhlo
80c0d1a2f4 fix(bots): key the Bot Chat history wait on paintable rows
A bot whose canonical Bot Chat session has message_count > 0 but zero
paintable rows can never be opened from the roster: the click demands a
painted transcript (expectHistory), no read mode serves active = 0,
compacted = 0 rows, so the open burns the full 60s hydration budget and
fails closed, and the fail-closed registry guard then refuses to mint a
replacement either.

Report the paintable size next to the denormalized total:

- session.list title lookups and profiles.list canonical/last_session rows
  now carry live_message_count — the count of display-set rows
  (active = 1 OR compacted = 1) a stored-transcript read actually paints,
  distinct display groups counted once.
- the desktop keys expectHistory off live_message_count when present,
  falling back to message_count for older gateways, so the count and the
  paint gate can no longer disagree. An all-folded chat opens as
  expected-empty instead of wedging for the whole budget.

(cherry picked from commit ddee47676f5f0669f7cb84681dc2f6c4a72ee4c6)
2026-09-30 09:57:04 -05:00
KoNit-K
9cbc6a5ac0 fix(desktop): skip non-primary local bot relay route 2026-09-30 09:15:47 -05:00
Siddharth Balyan
5eea87882a Clarify: one question shape and one result shape on every surface (skip, cancel, timeout and undelivered are told apart) (#127760)
* refactor(desktop): split clarify-tool.tsx into a clarify/ folder

Pure moves, no behaviour change. Parsing, the question-card core
(shell, choice rows, question block), the delivery watchdog, and each
pending/settled card get their own file so the core can be reused.

* feat(clarify): one questions[] shape with per-question status and one outcome

The clarify tool now takes only questions=[{question, choices?, multi_select?}].
A wrong shape is a tool error that names the right one, so a model that
sends the old top-level question/choices corrects itself on the next call.

Every result has the same shape on every surface: each response carries
status (answered, skipped, unanswered) with user_response null unless
answered, and the result carries one outcome (submitted, cancelled,
timed_out, undelivered) plus an optional surface-written notice. The
timeout and cancel sentences that used to pose as the user's answer are
gone, and the compressor reads status instead of matching their prefixes.

The callback contract is callback(questions) -> {answers, outcome, notice?}
(None = skipped, missing qid = unanswered). tui_gateway settles a batch the
same way for the last lock, a cancel, an interrupt and the deadline, and
clarify.lock keeps a null answer as a skip. A multi-select answer that is
not a JSON array counts as one typed ("Other") answer. The tool-row preview
reads the first question.

* feat(clarify): every surface asks through the one question card

CLI: the batch panel is the only panel; Enter on an empty field skips a
question, Ctrl+C cancels and keeps the locked answers, the deadline returns
timed_out. -q and -z return undelivered with a no-user notice.

Ink TUI: the single-question prompt is gone; the card supports multi-select
(Space or a digit toggles, Enter locks, typed Other text joins the picks),
an empty submit skips, Esc cancels, and "Batch" names are dropped.

Desktop: the single-question card is deleted and its keyboard handling moved
into the one card (arrows, letters and digits, auto-advance, Enter picks then
confirms). Confirm enables at one answer; blank questions lock null; Skip and
a composer message cancel; the settled card shows No answer for unanswered
questions. The bots room card follows the same contract.

Messaging: one card per question with a "Reply skip to skip" line in every
locale; timeouts and delivery failures map to timed_out and undelivered.

* fix(clarify): cancelled for a released messaging card, labels win over the skip word

A prose reply to a messaging choice card, /new and session end released
the wait with "", which read as timed_out. They now resolve with a
CANCELLED marker and the tool gets outcome cancelled.

A typed reply that matches a choice label ("Skip") now resolves to that
choice; the skip word applies only when no choice matches.

The bots room card sends the picked labels as they are; the tool already
strips the recommendation label. Unused OUTCOMES and a new docstring and
comment are removed.

* fix(tui): re-editing a multi-select answer keeps its picks

The Ink card stored a multi-select answer as display text ("A, B"), so
revisiting the question put the whole string into Other and re-locked it
as one item. The answers map now keeps the raw JSON array, the card
restores the picks on revisit, and only the display lines format it.

Comments that earlier edits reworded are restored to their original
words, minus the phrases the change made wrong. The compute-host clarify
lock accepts None for a skipped question.

* test(clarify): align three checks with the one-answer confirm and the regenerated keys

The desktop Cmd+Enter test now expects a send with one answer (the blank
question locks null), the compressor test expects the single-answer summary
the code gives, and locales/_keys.desktop.json is regenerated for the
removed and added clarify strings.

* test(tui): the one-question card header is singular
2026-09-29 18:33:44 +05:30
kshitijk4poor
a042567296 test: tighten the audit's new assertions after review
- relay-deliver-budget: the ceiling/timeout composition is checked
  against the imported constants (which the mirror tests already tie to
  the Python defaults) instead of re-reading config_defaults.py.
- terminal env sync: the key recorder also traps get()/[] and is an
  instance, the probe fails loudly if the bridge looks up no keys (so the
  gateway-only-key check cannot go vacuous), one _cli_env_map() helper
  serves both tests, and the docstring says what is actually driven.
- live_system_guard bypass reuses _live_system_guard_is_active().
- retaindb no-client prefetch asserts no thread is spawned through the
  module's spawn_context_thread seam, not a private list.

Each changed assertion re-proven red by its production mutation.
2026-09-29 15:58:55 +05:30
kshitijk4poor
34a5f95a30 test: restore five contracts the preservation review found uncovered
An independent review of every deleted test against its named keeper
found contracts whose only proof had gone, or that no test had ever
pinned. Each restoration is one assertion in an existing test, proven red
by the production mutation named:

- gateway /model reply shows the context length the async resolver
  returns (the deleted async-matches-sync test was its only check);
  red when resolve_display_context_length_async drops the value.
- parse_model_switch_args("sonnet") has scope "default" (the deleted
  tuple test covered the no-flag case).
- shared metrics runtime sends nothing when collection is on but send is
  off; red when the gate reads resolved.enabled instead of resolved.send.
- the terminal env-sync test also probes every key gateway.run's bridge
  looks up, so a gateway-only key (which the old source parse caught)
  still fails it.
- desktop: the deliver ceiling equals the backend's lock wait plus every
  attempt, read from the Python defaults.

Also: the tools/bot_relay.py mirror comment names relay-budget.ts, and a
comment orphaned by the Windows encoding-test deletion goes with it.
2026-09-29 15:58:55 +05:30
kshitijk4poor
7e6b8d9ec8 test(desktop): pin the bot_relay.deliver budget by import, not by regexing relay.ts
relay-deliver-budget.test.ts read relay.ts as text: it regexed each
constant ("must stay a literal so this test can read it") and matched the
deliver call shape within 400 characters of 'bot_relay.deliver', which
AGENTS.md bans. The regex passes with the identifier wired into the wrong
argument and fails on a correct refactor.

The #93911 budget constants move to relay-budget.ts and are imported by
relay.ts and both tests. The mirror checks against the Python defaults are
unchanged. The call-site check becomes a behavioural one in relay.test.ts:
the mocked requestProfile for bot_relay.deliver must receive
RELAY_DELIVER_TIMEOUT_MS (red when the call passes any other deadline).
2026-09-29 15:58:55 +05:30
Brooklyn Nicholson
e96cbb0d39 fix(desktop): bots plugin uses Tip and Codicon over native title and glyphs
Avatar and chip rows move their hints into Tip; rows whose text or
aria-expanded already says it drop the title. The installed check is a
Codicon.
2026-09-28 11:50:16 -05:00
hermes-seaeye[bot]
b23b8bcf7f fmt(js): npm run fix on merge (#126332)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-09-28 11:22:53 +00:00
Teknium
399d956903 feat(bot_desktop): Bot Screen, computer_use and the browser run inside the terminal backend (#121169)
* feat(docker): publish nousresearch/hermes-sandbox:desktop for terminal backends

The terminal backends (docker, modal, daytona, singularity) all default to
nikolaik/python-nodejs:python3.11-nodejs20, a bare Python+Node base. For Bot
Screen, computer_use and the browser to run INSIDE that sandbox instead of on
the gateway host, the sandbox image needs the display stack.

docker/sandbox-desktop.Dockerfile is that base plus:
  - the everyday tools it lacked (jq, ripgrep, fd, tmux, less, nano, vim,
    zip, rsync, tree, procps, htop, sudo for the base's uid-1000 `pn`)
  - the exact package set the Hermes -desktop image installs (TigerVNC,
    Xfce components, dbus, xauth, fonts)
  - Playwright's headed Chromium (same build as the -desktop image)
  - cua-driver 0.28.2 from its pinned release tarball

No Hermes inside; the default user stays root like the base so nothing
changes for people who just switch docker_image. Desktop processes run as
`pn`. 4.27 GB on amd64.

docker.yml gains a `sandbox` variant with its own cache scope and repository
(nousresearch/hermes-sandbox:desktop, :main-desktop, :<release>-desktop);
the docker-integration suite is skipped for it (no Hermes to test) and
docker/sandbox-desktop-smoke.sh runs instead: as `pn`, every launcher and
cua-driver binary resolves, the real launcher.sh publishes :20, the RFB
socket completes the 3.8 handshake relayed over `docker exec -i` stdio, and
a headed Chromium maps a window on that display. hadolint lints the new
Dockerfile in docker-lint.yml.

* feat(docker): sandbox desktop base on python3.13-nodejs26

Matches the Hermes image (Python 3.13 / Node 26) and the top of requires-python;
the default docker_image tag it inherited was Python 3.11 / Node 20. Same pn
uid 1000, Debian 13; smoke (launcher, RFB relay, headed Chromium) passes.

* feat(docker): bake agent-browser into hermes-sandbox:desktop

The browser tools drive the agent-browser CLI; when the browser follows the
terminal backend that CLI has to exist inside the sandbox. Pinned to the same
^0.26.0 range the gateway resolves, --ignore-scripts like the gateway's npx path.

* feat(bot_desktop): the screen, computer_use and the browser follow the terminal backend

A user who sandboxes `terminal` (docker/ssh/singularity) had the agent's
screen, cua-driver and Chromium running on the gateway HOST beside that
sandbox: Bot Screen gave a headless host a display, the Xfce panel carries
xfce4-terminal, and `computer_use` could open a shell outside the boundary
the sandbox exists for.

Now the desktop lives where the terminal lives:

- tools/environments/streams.py: one primitive per spawn-per-call backend, the
  local argv prefix that runs its remainder inside the sandbox with stdio open
  (`docker exec -i`, `ssh`, `apptainer exec`). SDK backends (modal, daytona,
  vercel) have none and report so.
- tools/bot_desktop/sandbox_host.py: launcher.sh runs inside the sandbox as
  the image's `pn`; the pane's RFB bytes ride a 12-line python relay over that
  prefix; `cua-driver mcp` is the prefix + the sandbox image's own driver.
- tools/bot_desktop/placement.py + `bot_desktop.placement` (auto|terminal|
  gateway). `auto` follows the backend; a sandbox that cannot host a screen
  REFUSES with the opt-in named instead of silently using the host.
- runtime.start/stop/status/published_env branch on placement; the pane,
  lease, epoch fencing and CLI are unchanged.
- cua_backend: the MCP invocation is the sandbox one when the screen is
  there; the host driver's runtime contract is irrelevant then; check_fn is
  true under a terminal placement without a host binary.
- browser_tool_session: agent-browser invocations are wrapped in the prefix
  with the daemon, socket dir and profile inside the sandbox; screenshots are
  fetched back so MEDIA: paths keep working; recycle closes the sandbox
  daemon.
- web_routers/display.py: the bridge pumps a relay's stdio when the screen is
  in a sandbox, a unix socket otherwise.

Live on docker with nousresearch/hermes-sandbox:desktop: start/observe/RFB
handshake through the dashboard bridge, human takeover fences the agent
(HumanHasControl) and keystrokes reach the sandbox Xvnc, handback restores,
three start/stop rounds leave zero desktop processes; computer_use capture
and list_windows see only the sandbox's Xfce; browser_navigate/snapshot/
vision run with Chromium and agent-browser inside the container and zero
host processes on the bot profile; modal + auto refuses naming the opt-in.

* feat(desktop): Screen pane shows where a sandbox-placed screen runs; Install is host-only

DesktopStatus gains placement ('gateway' | 'terminal:<backend>'). A sandbox
image lacking the stack is a blocker naming hermes-sandbox:desktop, shown in
place of Start; install_command stays None there because the pane's Install
button runs the package manager on the gateway host, the wrong machine, and
display.install refuses for the same reason. The pane header carries
'Screen runs inside the docker sandbox, with the terminal' (4 locales).

* fix(bot_desktop): "is the screen in the sandbox" is a disk check on hot paths, never a config read

Every browser command and CUA spawn asked in_sandbox(), which resolves placement by
loading config, which initializes HERMES_HOME. Under a test's fake home that raised
HomeInitializationError from _run_browser_command; on a real host it read config per
click. Hot paths now ask sandbox_screen_running(): the start marker on disk, written
only by a sandbox start. Policy (in_sandbox) stays for start/install, where config is
the question. display.observe gates on "an RFB endpoint exists" for either placement.

* test(moa): late-accounting sink test asserts the wedged slot's row, not sink order

Under CI load the poll loop can see the interrupt before collecting the fast slot, so the
fast slot also arrives late and first; the test then failed on sink_calls[0]. The
contract is that the wedged slot's real usage reaches the sink.

* chore: retrigger CI (zero-job dispatch failure, auto-heal)

* fix(bot_desktop): a sandbox that died under a live screen fails loudly, never falls to the host

sandbox_screen_running() drops a start marker whose terminal environment is no longer
registered (stale after a process restart). When the environment object outlives its
container, the browser's sandbox wrap now checks the published DISPLAY and raises
"the screen inside the terminal backend's sandbox is gone; start it again" instead of
KeyError('AGENT_BROWSER_PROFILE'). Live: fresh sandbox navigate ok; docker rm -f the
container; next navigate returns that error; zero host Chromium either way.

* feat(terminal): nousresearch/hermes-sandbox:desktop is the default container sandbox

Every container backend (docker, modal, daytona, singularity) now defaults to the
sandbox image with the desktop stack, so Bot Screen, computer_use and the browser
run inside the sandbox for everyone who never chose an image; Python 3.13 / Node 26
match the Hermes image. One constant (DEFAULT_SANDBOX_IMAGE) replaces six copies of
the old literal. Migration 47 moves saved configs still holding the OLD default and
never touches an image the user pinned. Docker reuse recreates a container built
from another image, or the flip would silently never take effect for anyone with a
persisted container (live: old container removed, new one on 3.13 / Node 26 with
Xvnc, cua-driver, agent-browser present).

* chore: retrigger CI (zero-job dispatch failure)

* chore: retrigger CI (zero-job dispatch failure, auto-heal)

* chore: retrigger CI (zero-job dispatch failure, auto-heal)

* chore(config): template stamps v47 and shows the new default sandbox image

The template is what install.sh / docker / doctor --fix seed; a stamp behind
DEFAULT_CONFIG makes every fresh install migrate on first run.

* feat(sandbox): the default image change is a decision, not a surprise

A persisted Docker sandbox on another image is kept when docker_image is unset;
only a written docker_image (an explicit pin) recreates it. The pin verdict
travels as TERMINAL_DOCKER_IMAGE_PINNED through both terminal bridges (process
env and per-profile scope) and the container-config allowlist.

Approval surfaces, all through hermes_cli.sandbox_image_switch: the interactive
CLI asks once at startup (y = pin the new image, n = pin the current one, Enter =
ask later); the Screen pane shows the same choice with Switch / Keep buttons via
display.switchSandboxImage; `hermes config set terminal.docker_image …` is the
same answer from any shell. Gateways and cron never decide: they keep the sandbox
and log the notice.

Migration 47 now unsets a saved image equal to the OLD default instead of
rewriting it to the new one — that value was the template copied, not a pin, and
rewriting it would have made the runtime recreate existing sandboxes unasked.

Modal restores its snapshot and Daytona reuses its labeled sandbox regardless of
the configured image, so existing sandboxes there were already untouched.

* fix(config): both plain defaults that preceded the desktop sandbox image are template copies

main pinned nikolaik/python-nodejs:python3.14-nodejs22 (cd0f97f833) without a migration;
a saved config holding either literal is unset by migration 47, so it follows the default
and existing sandboxes get the keep-or-switch decision instead of a silent recreate.

* ci(docker): build the sandbox image on release/dispatch, not every main push

Leaves docker.yml exactly as on main. The sandbox image carries no Hermes code,
so two 4 GB multi-arch builds per merge bought nothing. sandbox-image.yml builds
and smokes on a PR that edits its own Dockerfile/smoke, and publishes only on a
release or a manual dispatch with publish=true. Stable tag stays :desktop.

* fix(config): keep main's config.py/config_defaults.py edits under the sandbox-image delta

The rebase resolved both files wholesale with the branch side, dropping main's move to
hermes_yaml (the 3.14 runtime venv has no PyYAML) and the 3.14 base pin. This is main's
version plus exactly the branch's own changes: DEFAULT_SANDBOX_IMAGE, the pin verdict in the
env bridge, placement defaults and the v47 stamp.

* test: sandbox-image tests read config.yaml through hermes_yaml (no PyYAML on the 3.14 runtime)

* fix(bot_desktop): read the sandbox marker BOM-tolerantly (windows footgun lint)

* fix(bot_desktop): placement is the authority; sandbox screen survives restarts

Review findings on the sandbox-hosted Bot Screen, each reproduced live first.

Authority. The browser preflight and the CUA invocation keyed off screen
LIVENESS, so `placement: terminal` with the screen not yet up handed the tool an
unchanged host command. `runtime.tool_placement()` is now the one resolver:
terminal placement starts the sandbox screen on demand (no auto_start opt-in
inside the user's own sandbox), refused placement raises its reason, and neither
ever yields the host. placement.resolve() answers a local backend from env alone
so the common case costs no config load on the spawn path.

Restart. sandbox_screen_running() deleted the marker whenever the process-local
terminal registry was empty, i.e. after every gateway restart, while Xvnc kept
running in the container; stop() then returned False and left it. The marker
now records the owning container; liveness comes from `docker inspect` on it,
stop/status re-attach to the recorded owner (even after the placement setting
moved), and only a container that is gone drops the marker.

SSH. remote_argv emitted `bash -c <script>` as three words; OpenSSH joins them
and the remote login shell ran `bash -c export` and the rest itself. The script
travels as one quoted word for ssh (remote_command knows the backend); docker
and apptainer keep argv.

CDP reach. agent-browser inside the sandbox reports the sandbox's loopback;
the Browser Use harness, browser_exec and the vault supervisor connect from the
host and got connection refused. streams.forward_port() proxies a local port
over the exec stream (same relay as the RFB bridge) and the CDP URL is rewritten
to the local end.

pids limit. --pids-limit 256 counts threads; measured on the desktop image the
desktop stack is 44, one Chromium tab 212, the agent's browser with two tabs
488. Past the cap every further docker exec died with "procReady not received".
Default is 2048 with the measurements in the comment.

Replacement. An approved image switch force-removed the old container before
`docker run` tried the new image; a private tag or registry outage left nothing.
The image is inspected/pulled first and the old container kept on failure.

Desktop integration. The sandbox start never passed the dock's browser launcher
(no Browser icon) and the thumbnail needed a host launcher pid + host ImageGrab
(always None). The dock runs the sandbox's Playwright Chromium on the shared
profile; the thumbnail is grabbed inside the sandbox (Pillow baked into the
image). The browser profile moves from /tmp — a 512 MB tmpfs emptied on every
container stop — to the desktop user's home, so logins follow the container.

Pin provenance. A TERMINAL_DOCKER_IMAGE written in a routed profile's .env is a
pin even when it spells the default; the scope compared values before.

* docs(bot-screen): no literal tmp path in the profile-location note

* fix(bot_desktop): docker inspect liveness probe closes stdin (TUI subprocess guard)

* fix(bot_desktop): adopting a screen the sandbox kept records the marker

Live ssh probe: after the host's state was lost while the sandbox kept its
Xvnc, start() took the idempotent early return (display already published)
and never wrote the host marker, so status/thumbnail/stop lost the screen.
Record the adopted display like a fresh launch.

Docs: what an ssh host of your own must carry, and why a Dockerfile ENV is
not enough for a login session (PLAYWRIGHT_BROWSERS_PATH via /etc/environment).

* docker(sandbox-desktop): login sessions find the browser (PLAYWRIGHT_BROWSERS_PATH via /etc/environment)

* docs(bot-screen): what the Apptainer path inherits from the image and what it does not

* chore(config): sandbox-image migration is 47→48 (main took 47 for compression.threshold_tokens)

* chore: retrigger CI (zero-job dispatch failure, auto-heal)
2026-09-28 03:34:07 -07:00
hermes-seaeye[bot]
cd3f453f3e fmt(js): npm run fix on merge (#125819)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-09-27 23:22:39 +00:00
hermes-seaeye[bot]
2e627917a6 fmt(js): npm run fix on merge (#125798)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-09-27 23:01:44 +00:00
Brooklyn Nicholson
bb09271d6a fix(desktop): show why a Kanban task is blocked and let it be unblocked 2026-09-27 15:09:50 -05:00
Brooklyn Nicholson
aa25f9e85f fix(desktop): Kanban board switcher outside the full-page layout
A Kanban board opened in a split route tile rendered no board switcher:
the board contributed it to WORKSPACE_PAGE_HEADER_AREA unconditionally,
and only the workspace pane paints that area. The tile's contribution
also leaked into another page's header and shared its id with the full
page's, so closing the tile removed the page's switcher.

Add WorkspacePageHeaderControl (exported via the plugin SDK). The
workspace pane's render provides a private host context; inside it the
control projects into the page header, anywhere else it renders inline.
The board mounts BoardSwitcher once, through it, in its own header row.

Fixes #123597

Originally authored by Justin Haynes (@jhaynes).
2026-09-27 13:18:46 -05:00
Adolanium
d1167fdff7 fix(desktop): match custom:<key> providers in the settings and bot model pickers
model.info and saved profiles report a user-defined provider as custom:<key>, but the catalog row uses the bare key as its slug. Settings > Model and the Bot Mode picker compared the two with ===, so a saved custom provider never found its row. Settings showed a duplicate custom:<key> entry and a Set up provider button, and the bot editor fell back to the manual form.

Both now match rows with catalogProviderMatches, like the composer picker already does. Settings uses a small findCatalogProvider helper for every row lookup, including the aux and MoA slots and the endpoint passed on Set to main. catalogProviderMatches is now exported through the plugin SDK so the bot picker can use it.
2026-09-27 12:49:56 -05:00
hermes-seaeye[bot]
8c9fe96400 fmt(js): npm run fix on merge (#125322)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-09-27 14:20:28 +00:00
hermes-seaeye[bot]
e54d550521 fmt(js): npm run fix on merge (#125310)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-09-27 14:06:23 +00:00
Brooklyn Nicholson
bbf670a98d style(kanban): satisfy the curly and blank-line lint rules in the alias-resolution change
Mechanical eslint --fix over the two files this branch touched; no behavior
change. 35 kanban notify tests still pass.
2026-09-27 08:52:49 -05:00
funky-xamarin
c4700d60de fix(desktop): preserve alias task cache invalidation 2026-09-27 08:52:49 -05:00
funky-xamarin
23d95743a0 fix(desktop): resolve default Kanban board before event subscription 2026-09-27 08:52:49 -05:00
hermes-seaeye[bot]
7d6da01e98 fmt(js): npm run fix on merge (#125233)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-09-27 12:41:42 +00:00
chelsealong
f9f153ab18 fix(desktop): clamp Bot Screen clipboard paste to the bridge's 256 KiB cap
clipboardPasteFrom was called with unbounded clipboard text; the gateway's
RfbClientFilter closes the display WebSocket on any ClientCutText over
_MAX_CUT_TEXT (256 KiB), turning an oversized paste into a full stream
disconnect instead of a dropped paste. Clamp before forwarding and add a
test pinning the ceiling.
2026-09-27 07:26:01 -05:00
chelsealong
2d6657d72c fix(desktop): forward client clipboard paste into Bot Screen
Take over over a bot's remote screen never sent the client's text
clipboard to noVNC: screen-pane.tsx built the RFB session but had no
paste handler and never called clipboardPasteFrom, so Cmd/Ctrl+V into
a remote input did nothing (#123089). The gateway already gates
ClientCutText as lease-checked input, so the missing leg was purely
client-side.

Add an explicit-gesture `paste` listener on the canvas, forwarded only
while this viewer's RFB client is not view-only (i.e. holds the
lease); no polling, no clipboard logging. Listener is torn down with
every detach so it never outlives its RFB client.
2026-09-27 07:26:01 -05:00
Brooklyn Nicholson
68a9817b57 fix(desktop): background Bot Chat re-resumes never navigate
A session.reclaimed (or roster-activity) wake re-resolves the open bot
chat so the next send doesn't eat a stale runtime id — but it went through
the full navigating open, so with the Kanban board (or any other route)
in the foreground the route flipped to the Bot Chat. Background events
must offer, not hijack.

host.openSession gains refreshInPlace: the wake still consults the
registry, dials the owner backend, and stamps the owner hint, but never
calls the core open (route/tile), never re-publishes the bots workspace
scope, and never flips the all-profiles view. It refreshes through the
same levers the SDK's own hydration probe uses — the tile delegate's
resumeTile(refreshTranscript), or the armed requestSessionResume that
the route-resume effect consumes only while the route already points at
the session. A chat that isn't on screen re-opens nothing.

openBotCanonicalChat threads this as background: true, and a background
re-resume never MINTS a missing registry row. The reclaim listener and
the roster-activity refresh both pass it.

Fixes https://github.com/NousResearch/hermes-agent/issues/121874
2026-09-27 07:05:20 -05:00
Brooklyn Nicholson
f4a86aa135 fix(lint): sort named imports in screen-hero 2026-09-27 06:26:52 -05:00