Skip to content

feat(computer_use): add switch_desktop with overlay-safe restart - #1

Merged
lEWFkRAD merged 3 commits into
lEWFkRAD:feat/computer-use-windowsfrom
Icather:feat/switch-desktop-fix
Jun 15, 2026
Merged

feat(computer_use): add switch_desktop with overlay-safe restart#1
lEWFkRAD merged 3 commits into
lEWFkRAD:feat/computer-use-windowsfrom
Icather:feat/switch-desktop-fix

Conversation

@Icather

@Icather Icather commented Jun 15, 2026

Copy link
Copy Markdown

Summary

Implements switch_desktop action that safely switches virtual desktops without killing the overlay subprocess.

Why

ctrl+win+left/right via key() action successfully switches desktop but destroys the full-screen tkinter overlay child process, crashing Hermes Dashboard/Gateway. This is because any SendInput-based virtual-desktop transition tears down the overlay's display context.

Solution

Three-phase approach to keep the overlay alive:

  1. Stop overlay — gracefully shut down the tkinter subprocess
  2. Switch desktop — two-phase SendInput (press → sleep 80ms → release)
  3. Restart overlay — set _dead = False (prevent crash-recovery) and start on new desktop
# core flow
overlay_client.stop()          # 1. stop
_send_inputs(press)            # 2. switch
_send_inputs(release)
overlay_client._dead = False   # 3. restart (not a crash)
overlay_client.start()

Changes

File Change
windows_backend.py Added _switch_desktop_via_keybd() helper + switch_desktop() method
schema.py Added "switch_desktop" action with direction (left/right) parameter
tool.py Added dispatch branch with hasattr guard for non-Windows backends

Testing

  • Windows 10 with 2+ virtual desktops
  • Desktop switch successful, overlay restores correctly on new desktop
  • direction validation tested (invalid values return error)
  • Non-Windows backends return "not supported" via hasattr guard

Stop the overlay subprocess before switching virtual desktops,
then restart it on the new desktop — avoids the tkinter display
context teardown that kills the overlay during SendInput-based
Ctrl+Win+Left/Right.

- _switch_desktop_via_keybd(): stop overlay → two-phase SendInput
  → restart overlay
- switch_desktop() method on WindowsUIABackend
- Schema and dispatch updated with 'switch_desktop' action and
  'direction' parameter

Co-authored-by: lEWFkRAD
@Icather

Icather commented Jun 15, 2026

Copy link
Copy Markdown
Author

Status update after several rounds of debugging — the root cause is clearer now, and it's not what I initially thought.

What we tested

  1. Keyboard SendInput ( via ) — works correctly in isolation (standalone Python script), the overlay stop/restart pattern is solid. But inside Hermes, under the Dashboard's Chat tab, the session crashes ~7s after the switch.

  2. COM API ( via pyvda, no SendInput at all) — same crash. COM initialization even failed on the tool executor thread () because the thread pool doesn't pre-init COM apartments.

  3. Direct fallback by the model — when failed (COM init), the model fell back to the raw action. This worked perfectly with zero crashes — but only under the full system gateway (the separate gateway process started from the Dashboard's System page).

Root cause: process architecture, not keyboard simulation

Hermes has two gateway modes, and the crash only happens in one:

Mode Where the agent runs result
Dashboard Chat tab (embedded) Inside the uvicorn Dashboard process ( in-process) 💥 Gateway WS drops (code 1006) → Node.js TUI parent receives SIGHUP → kills gateway
System gateway (separate process) Independent subprocess ✅ Works — desktop switch, overlay restart, model continues normally

When the agent and the Dashboard share the same process, any virtual-desktop transition causes the (Node.js) to receive a SIGHUP and perform a graceful shutdown of the gateway. The exact trigger mechanism (Console event? Electron window visibility? PTY disconnect?) is still under investigation, but it's definitely NOT caused by the keyboard-injection method — changing SendInput to COM made zero difference.

What this means for the PR

The code itself (overlay-safe restart pattern) is correct and tested working. The crash is a pre-existing Hermes architectural limitation: the embedded gateway (Chat tab) cannot survive a virtual-desktop switch regardless of how the switch is triggered. The path has the same problem — we just got lucky that the model fell back to it during a session where the full gateway happened to be running.

I've added a docstring note about this limitation. The PR is still ready for review — the implementation is sound, but I wanted to be transparent about where the boundary lies between this feature and Hermes' runtime architecture.

@Icather

Icather commented Jun 15, 2026

Copy link
Copy Markdown
Author

Final conclusion after a full day of bisecting. Root cause identified and fixed.

Root cause: SendInput batch timing

The embedded gateway (Dashboard Chat tab) runs the agent in-process with the Dashboard's uvicorn server. It has a single-channel event loop that cannot handle multi-batch SendInput without disruption. The full system gateway (spawned via the System page) is multi-channel by design (handles simultaneous WebSocket + WeChat clients) and is unaffected.

SendInput pattern Embedded gateway Full gateway
Two-batch: press (3 key-down) → sleep 80ms → release (3 key-up) ❌ Crash (SIGHUP cascade) ✅ Works
Single-batch: key-down → key-down → key-down → key-up → key-up → key-up (same as existing _press_combo) ✅ Works ✅ Works

The embedded gateway's single-channel design predates computer-use on Windows. When SendInput delivers two batches with a sleep interval, the PTY pipeline gets disrupted and the Node.js TUI parent triggers a graceful shutdown (SIGHUP → kill gateway). The full gateway's multi-client dispatch loop handles this without issue.

Fix applied

Changed _switch_desktop_via_keybd to use the same single-batch SendInput sequence as _press_combo (the existing key() helper). This matches physical keyboard behavior exactly and works in both gateway modes.

Bisect log (for posterity)

  1. Two-batch + full gateway: Worked (early test)
  2. COM API + embedded: Failed at CoInitialize, no switch executed, model fell back to key()
  3. No overlay stop/start + full gateway: Worked — but was accidentally testing with full gateway
  4. No overlay stop/start + embedded: Crashed — proved overlay lifecycle is NOT the cause
  5. Single-batch + embedded: Works — confirmed the fix

Takeaway for future Windows contributors

Any tool using SendInput should use single-batch delivery. Multi-batch SendInput with sleep intervals between batches will work under the full system gateway but crash the embedded (Chat tab) gateway — which is the gateway most users encounter during first-time testing.

Two-batch SendInput (press → sleep → release) crashes the embedded
gateway (Dashboard Chat tab) because its single-channel event loop
cannot handle multi-batch keyboard injection without disrupting the
PTY pipeline.  The full system gateway is unaffected because its
multi-client dispatch loop handles concurrent channels.

Switch to single-batch SendInput matching _press_combo semantics
(hold modifiers → tap arrow → release).  This works in both
embedded and full gateway modes.
@Icather
Icather marked this pull request as ready for review June 15, 2026 08:51
Copilot AI review requested due to automatic review settings June 15, 2026 08:51

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

Adds a new switch_desktop action to the computer-use tool to switch Windows virtual desktops via a Ctrl+Win+Arrow SendInput sequence while restarting the overlay.

Changes:

  • Implemented Windows backend virtual-desktop switching with overlay stop/restart.
  • Added tool dispatcher handling for switch_desktop.
  • Extended the action schema with switch_desktop and a direction parameter.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 5 comments.

File Description
tools/computer_use/windows_backend.py Adds SendInput-based virtual desktop switching and a new backend action method.
tools/computer_use/tool.py Routes switch_desktop requests to the backend.
tools/computer_use/schema.py Adds switch_desktop to allowed actions and defines direction.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread tools/computer_use/windows_backend.py
Comment thread tools/computer_use/windows_backend.py
Comment thread tools/computer_use/windows_backend.py
Comment thread tools/computer_use/tool.py Outdated
Comment thread tools/computer_use/schema.py
- Validate direction before key dispatch (unknown values return False)
- Log exceptions instead of silently swallowing them
- Add 150ms delay before overlay restart to avoid DWM race
- Route switch_desktop through _maybe_follow_capture for consistency
- Add JSON Schema if/then to require direction for switch_desktop
@Icather

Icather commented Jun 15, 2026

Copy link
Copy Markdown
Author

Final conclusion after a full day of bisecting. The issue is fully understood now.

Root cause: SendInput batch timing

The embedded gateway (Dashboard Chat tab) runs the agent in-process with the Dashboard's uvicorn server. It has a single-channel event loop that cannot handle multi-batch SendInput without disruption. The full system gateway (spawned via the System page) is multi-channel by design (supporting simultaneous WebSocket + WeChat clients) and is unaffected.

SendInput pattern Embedded gateway Full gateway
Two-batch: press → sleep 80ms → release ❌ Crash (SIGHUP cascade) ✅ Works
Single-batch: hold → tap → release (matches ) ✅ Works ✅ Works

Why

The embedded gateway's single-channel design predates computer-use on Windows. When SendInput delivers two batches with a sleep interval, something in the event loop / PTY pipeline gets disrupted and the Node.js TUI parent triggers a graceful shutdown (SIGHUP → kill gateway). The full gateway handles this because its multi-client dispatch loop is designed for concurrent WebSocket + WeChat sessions.

Fix applied

Changed to use the same single-batch SendInput sequence as (the existing helper):

Takeaway for future Windows computer-use contributors

Any tool that needs SendInput should use single-batch delivery. Multi-batch SendInput with sleep intervals will work under the full gateway but crash the embedded (Chat tab) gateway — which is the first gateway most users encounter during testing.

@lEWFkRAD
lEWFkRAD merged commit 94e4f72 into lEWFkRAD:feat/computer-use-windows Jun 15, 2026
@lEWFkRAD

Copy link
Copy Markdown
Owner

Merged — thank you @Icather 🙏 Your switch_desktop is now on feat/computer-use-windows and rode straight up into NousResearch#43927.

One follow-up commit on top (ea294af): the overlay restart force-cleared _OverlayClient._dead before start(), which would resurrect the overlay even when the user had killed it with HERMES_COMPUTER_USE_OVERLAY=0 (or after it had already failed itself off). I gated the restart on whether the overlay was actually running before the switch instead, so a deliberately-disabled overlay stays down — start() already no-ops while _dead is set, so that guard is now the single source of truth.

Folded in the dependency-free test you'd offered to include — live-restart respawns, disabled-stays-down, and invalid-direction. The disabled-stays-down case fails on the old force-clear, so it pins the regression. Full computer_use suites: 140 passed on Windows 11, footgun scan clean.

The single-batch SendInput rework and the embedded-vs-full-gateway testing you did is exactly the real-use coverage I can't reach from one box — much appreciated.

@Icather
Icather deleted the feat/switch-desktop-fix branch June 15, 2026 16:32
lEWFkRAD pushed a commit that referenced this pull request Jun 21, 2026
Phase 1 of the pluggable cron-scheduler refactor (Axis B — the trigger).
No call-site changes; this phase only makes the abstraction exist + tested
in isolation.

Task 1.1: cron/scheduler_provider.py — the EXPERIMENTAL CronScheduler ABC.
  Required surface is name + start; is_available()/stop() carry safe defaults.
  is_available has a no-network invariant. Docstring marks it experimental
  until the Chronos provider (Phase 4) validates the shape.
Task 1.2: InProcessCronScheduler wraps the historical 60s ticker loop, calling
  cron.scheduler.tick(sync=False) exactly as the raw ticker does. Uses
  stop_event.wait(interval) for responsive stop (both raw tickers already do).

Tests: ABC-is-abstract, default-is_available, the InProcess loop drives tick
and stops, stop() no-op, and test_abc_growth_stays_additive (the forward-compat
guard: required abstractmethods must stay exactly {name, start}, so the three
Phase-4 hooks land as NON-abstract additions).

tick() internals in cron/scheduler.py are byte-unchanged (only new file added).
Phase 0 characterization tests still green. Full tests/cron/: 445 passed.
lEWFkRAD added a commit that referenced this pull request Jul 13, 2026
…s (blocker #1)

A window title is attacker-influenceable (browser tab/doc titles), and the
@window: expansion spliced it raw into a bash command the model is told to run.
Now:
- resolve the title to an exact native handle via the sidecar and emit commands
  that target `--hwnd <int>` (an integer — injection-proof);
- ambiguous matches are listed for the model to disambiguate by handle, never
  auto-driven (also addresses wrong-window control);
- the title only ever appears shlex.quote()'d in the no-handle fallback.

Tests: parse + actionable-block + malicious-title-quoting (3 pass).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
lEWFkRAD pushed a commit that referenced this pull request Jul 19, 2026
…onnect ladder can't freeze silently (NousResearch#66377)

The Telegram gateway could go silently deaf for hours: the reconnect ladder
stalled mid-way (e.g. "attempt 4/10, reconnecting in 40s" then nothing) while
the process stayed active(running), so Restart=always never fired.

Root class: every recovery path — the ladder's re-entry
(_schedule_polling_recovery), the pending-update probe (_probe_pending_updates),
and PTB's error callback — gates new recovery on _polling_error_task.done(). If
that single task wedges on any hung await, all recovery returns early forever
and nothing retries.

The heartbeat loop is a separate task, so make it an independent, cause-agnostic
watchdog: if the same recovery task stays in-flight past
_POLLING_ERROR_TASK_STUCK_TIMEOUT (300s — well beyond a healthy ladder attempt's
bounded stop+drain+start+backoff), force a retryable-fatal so the background
reconnector rebuilds the adapter instead of relying on the frozen ladder. This
guarantees progress regardless of *where* the stall is (issue direction #1),
tracked locally so no task-assignment site needs to change.

Also salvages @koduri-mahesh-bhushan-chowdary's NousResearch#66492 (drain-await timeout),
which closes the one concrete wedge vector documented in the incident
(_drain_polling_connections' unbounded shutdown()/initialize() on a wedged
CLOSE-WAIT pool). The watchdog covers the rest of the class.

Co-authored-by: Koduri Mahesh Bhushan Chowdary <mkoduri73@gmail.com>
lEWFkRAD pushed a commit that referenced this pull request Jul 25, 2026
…reaming

Two real render-cost wins found by inspection (no behavior change):

1. Sidebar re-rendered on every stream token. $sessionStates is republished on
   every message delta (tens/sec during a turn), and the derived ID computeds
   ($workingSessionIds, $attentionSessionIds, $backgroundRunningSessionIds)
   allocated a fresh array each time. nanostores notifies on !==, so the whole
   ChatSidebar + every mounted row re-rendered per token even when the working/
   attention/background set was unchanged. Return the previous array reference
   when the contents match → nanostores skips the notify unless the set actually
   changes. Turns streaming from O(visible rows)/token into O(0) for the sidebar.

2. Tool rows normalized the FULL uncapped detail every render. `looksRedundant`
   (lowercase + whitespace-collapse over the entire read_file/terminal payload)
   ran twice in the ToolEntry render body, so every completed tool re-normalized
   its whole output on every stream tick of the running message. Memoize on the
   view fields so it recomputes only when the tool's content changes.

Both are correctness-preserving (stable refs + memoization). The CI stream
scenario drives $messages directly, not the publishSessionState path, so it
won't reflect #1 — verified by inspection.
lEWFkRAD pushed a commit that referenced this pull request Jul 25, 2026
Blocking #1 — gateway-connecting-overlay.tsx reduced-motion regression:
the top `if (reduce) setPhase('gone')` fired unconditionally on mount
whenever reduce-motion was on, so every OS reduced-motion user lost the
CONNECTING overlay during cold boot entirely (jumped to 'gone' before the
gateway was even open). The intent was to skip the exit *choreography*,
not to skip showing the overlay. Removed the unconditional top block and
the redundant nested preview block; kept only the third branch
(`gatewayState === 'open' && shownRef.current` → `reduce ? 'gone' :
'text-out'`) which correctly gates the short-circuit on connect. Also
fixed `if(reduce)` missing-space, 6-space misindent, and the same 3-line
comment pasted three times.

Nit #1 — tsconfig excludes e2e, so specs were never typechecked in CI.
Added tsconfig.e2e.json (extends base, includes e2e/ + playwright.config.ts,
adds @playwright/test types) and wired it into the typecheck script. This
surfaced three latent type errors that are fixed in the same commit:
  - fix-electron-tracing.ts: `app._context` and `electron._playwright` are
    private APIs — added `as any` on the access before the existing cast.
  - playwright.config.ts: `reducedMotion: 'reduce'` directly under `use:`
    is not a valid UseOptions property in playwright 1.58; it's a
    BrowserContextOption accessed via `contextOptions: { reducedMotion:
    'reduce' }`. The old form was silently ignored at runtime, so
    reduced-motion emulation wasn't actually active — screenshots could
    catch overlays mid-fade (exactly what the comment warned about).

Nit #2 — fix-electron-tracing.ts reaches into Playwright internals
(_playwright, _allContexts, _context) with no public contract. Added a
header comment calling out the `@playwright/test` exact pin (=1.58.2) so a
future bump knows to re-verify the private symbols still exist.

Nit NousResearch#3 — main.ts TEST_WORKER_INDEX block had stray 6-space indentation.

Verified: tsc -p . && tsconfig.electron && tsconfig.e2e → 0 errors;
vitest boot-failure-overlay (3/3) + boot-failure-reauth (21/21) pass;
npm run build clean; playwright e2e/boot-failure.spec.ts 2/2 pass.
lEWFkRAD pushed a commit that referenced this pull request Jul 25, 2026
…native extension)

unicode61 indexes a CJK run as ONE token, so 2-char Korean terms (일본,
구글, 우리, ...) can never match it and the trigram tokenizer needs >=3
chars per term — any query containing a 1-2 char CJK token falls through
to a LIKE full-table scan (measured 3-6.4s CPU per query on a 6.8GB
production state.db; the #1 base cost behind a 12.4s session_search
average on CJK workloads).

This ships a ~250-line loadable FTS5 tokenizer (no deps) that wraps
unicode61: maximal CJK runs inside its tokens are re-emitted as
overlapping character bigrams (Lucene CJKAnalyzer semantics), everything
else passes through unchanged. FTS5 phrase semantics turn consecutive
sub-tokens into exact substring matching down to 2-char terms at index
speed.

Build: native/fts5_cjk/build.sh -> ~/.hermes/lib/libfts5_cjk.so
(override: HERMES_FTS5_CJK_SO).

Salvaged from PR NousResearch#65544; the schema integration lands separately on the
v23 external-content layout.
lEWFkRAD pushed a commit that referenced this pull request Jul 25, 2026
…add same-pid self-reclaim guard

Hardening on top of the salvaged dead-PID lease reclamation from PR NousResearch#65775
(@the3asic):

- Probe via psutil.pid_exists (hard dependency; CONTRIBUTING.md critical
  rule #1) with the contributor's os.kill(pid, 0) POSIX probe retained
  only as a scaffold-phase fallback when psutil is missing.
- Same-process holders (pid == os.getpid()) are never probed and never
  self-reclaimed — another thread's live lease is owned by the lease
  refresher/release path.
- Any probe doubt (exceptions, permission errors) conservatively keeps
  the lease until normal TTL expiry; Windows stays TTL-only.
- Tests: psutil-first dead-pid reclaim (probe call pinned), os.kill
  fallback path, probe-doubt keeps lease, same-pid no self-reclaim,
  legacy holder + Windows paths assert NO probe via either API.
lEWFkRAD pushed a commit that referenced this pull request Aug 3, 2026
…own (NousResearch#74136)

Fix-up for the cherry-picked cooldown persistence: the PR's tests mocked
the DB (SimpleNamespace(_db=MagicMock())), which cannot prove the cooldown
survives a restart. Replace with the production shape — a real SessionDB
on disk behind the real AsyncSessionDB facade — and add a restart
regression: fail a hygiene compression on runner #1, tear it down, build a
fresh GatewayRunner on the SAME database, and assert the cooldown is still
honored (no compression agent instantiated). Also updates the timeout test
to assert the DB-backed record_compression_failure_cooldown write instead
of the removed in-memory dict.

Sabotage-verified: reverting gateway/run.py to the in-memory dict makes
the restart test fail.
lEWFkRAD pushed a commit that referenced this pull request Aug 3, 2026
Users following abbreviated links guess /docs/quickstart and
/docs/installation and hit raw GitHub-Pages 404s — the real pages live
under /docs/getting-started/. Add client redirects for both.

Consumer-onboarding audit finding #1, Aug 2026.
lEWFkRAD pushed a commit that referenced this pull request Aug 3, 2026
The #1 patch failure class in production (state.db mining, 250k-window)
is a re-send of an edit that already landed: 'old_string and new_string
are identical' (299 occurrences) plus a share of hunk-not-found errors
where the new text is already in the file. These errored, sending
models into re-read/re-patch loops.

New tools/fuzzy_match.is_already_applied(content, old, new) — a
conservative check requiring (1) non-trivial new_string (>=8 chars),
(2) EXACT presence of new_string, (3) old_string gone (unless
identical). Wired into three sites:

- patch_replace (replace mode): returns success + no_change: true +
  an explicit note instead of the identical-strings / no-match error.
- V4A validation phase: an already-applied hunk validates as a no-op
  so multi-hunk patches no longer fail wholesale when one hunk landed
  in a prior call.
- V4A apply phase: mirrors the same skip so the two phases agree.

Genuine no-matches (new text absent) and half-applied renames (old
text still present) keep their error behavior — covered by tests.
lEWFkRAD pushed a commit that referenced this pull request Aug 3, 2026
process(action='wait') hitting its window returned status='timeout'
with a terse note — models read it as an error and re-issued identical
waits (process is the #1 exact-duplicate tool call in production: 511
dupes in a 400k-msg window; wait is 57% of all process actions).

The timeout result now carries:
- process_running: true — machine-readable 'this is a status, not a
  failure'
- an explicit note: 'Wait window of Ns elapsed — the process is still
  running. This is not an error. Uptime: Ms.' plus the right next step:
  when notify_on_complete is set, 'you will be notified on exit — do
  more work instead of waiting again'; otherwise a pointer to
  notify_on_complete for next time.
- the clamp note (requested > max) now composes with the status note
  instead of replacing it.

Exited/interrupted results are unchanged.
lEWFkRAD pushed a commit that referenced this pull request Aug 3, 2026
…e-review #1)

revoke_commit_admission() used to invoke the holder-qualified lease
release unconditionally — including while an admitted commit was still
mutating SessionDB — letting a second compressor acquire the durable
lock mid-commit and interleave with the first commit's writes.

The admission_revoked flag store stays lock-free, but the lease-release
decision now coordinates with the fence lock:
- revoke acquires the fence lock non-blocking; on success no commit can
  be in flight (an admitted commit retains the lock until finish_commit)
  and the release runs immediately, still under the lock so a racing
  begin_commit cannot slip between the check and the release.
- on failure the release is deferred: finish_commit() re-checks
  _admission_revoked and performs it AFTER the commit completes (prompt
  even if the worker thread is later parked), and the begin_commit
  refusal path does the same for a revoke that lost the race to a
  transient lock-setup/cancel boundary. All paths are idempotent with
  the worker's own outer cleanup (DB release is holder-qualified).

Invariant encoded + tested: no second compressor can acquire the durable
lock while an admitted commit is still mutating; after a post-revoke
commit finishes the lease is released promptly. Both regressions
(revoke-during-commit deferral, revoke-before-commit immediate release +
refused begin_commit) are sabotage-verified.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants