Compare commits

...
Author SHA1 Message Date
Brooklyn Nicholson fb1c886bf9 feat(desktop): browse + upload to the gateway filesystem on remote backends
Desktop file ops assumed the agent shared the client's filesystem, so on a
remote gateway (VPS over tailscale) image uploads, the Files sidebar, context
attachments, and the cwd picker all pointed at the wrong machine.

- gateway: fs.list / fs.read_text / fs.read_data_url / fs.git_root run on the
  agent host (shapes mirror the Electron fs IPC); image.attach_bytes writes
  client-uploaded bytes into $HERMES_HOME/images.
- renderer: desktop-fs facade routes reads + path selection through fs.* when
  $connection.mode === 'remote', else local IPC. Files sidebar, preview, and
  the image/file/folder/cwd pickers flow through it; a RemotePathPicker modal
  browses the gateway when native dialogs can't. Image attach falls back to
  byte upload when a client path can't resolve on the gateway.
2026-06-05 21:23:33 -05:00
brooklyn! e375c33f70 fix(tui): clean force-send of queued messages (#40235)
Force-sending a queued message (double-empty-enter, or interrupt-mode
submit) flipped busy→false optimistically, so the queue drain raced the
still-unwinding turn: duplicate user bubble, a stray "queued: …" note, and
the cancelled turn's "Operation interrupted…" reply leaking in.

interruptTurn gains `keepBusy`: hold busy until the gateway's real settle
edge (message.complete, suppressed while interrupted), which drains the
queued message exactly once — desktop "send now" parity. The interrupt
paths now queue + interrupt instead of optimistically sending.
2026-06-06 01:39:10 +00:00
brooklyn! ac177cea87 Merge pull request #40234 from NousResearch/bb/desktop-queue-arrow-edit-v2
feat(desktop): arrow-key history + queue editing in composer
2026-06-05 20:38:37 -05:00
Brooklyn Nicholson ce50030634 feat(desktop): integrate arrow history with the message queue
Builds on @naqerl's arrow up/down history (previous commit), making
ArrowUp do the right thing when a queue exists.

ArrowUp/ArrowDown priority:
1. Editing a queued turn → walk older/newer through queued entries,
   saving each edit; ArrowDown past the newest exits and restores the
   pre-edit draft.
2. Empty composer + queued turns → ArrowUp opens the newest queued entry
   for editing (the row's pencil), so Enter saves it back to the queue
   instead of firing a new message — the gap the history nav had alone.
3. Otherwise → sent-message history recall (unchanged).

Also: Esc cancels an in-progress queue edit (else interrupts).

Cleanups on the integrated code: fold the browse-state reset into the
existing session-change effect (drop the duplicate ref+effect); reuse
loadIntoComposer for history recall; sort imports; add curly braces +
the runDrain sessionId dep (lint).
2026-06-05 20:33:53 -05:00
naqerl f94363d1f0 feat(desktop): arrow up/down to navigate previous user messages 2026-06-05 20:32:29 -05:00
brooklyn! 0cbcc75935 fix(desktop): reliable composer message queue (#40221)
* fix(desktop): make composer message queue reliable

The queue felt 'dumb' because of three real bugs:

1. Drained-after-interrupt sends went silent. cancelRun sets
   interrupted:true and nothing reset it; submitPromptText's optimistic
   seed preserved it, and the message stream drops every delta while
   interrupted. So Send-now-while-busy and any interrupt+drain submitted
   the next turn into a muted session. Fix: a fresh submit is a new turn —
   seed interrupted:false.

2. Back-to-back queue drains stalled. The drain fires on the busy->false
   settle edge, but busyRef (synced from the busy store by a separate
   effect) can still read true on that same edge, so the drained send hit
   the busy guard, returned false, and the entry was never removed. Fix:
   fromQueue sends bypass the busyRef guard (the queue drain lock
   serializes them); the user path keeps the guard.

3. Double-enter-to-interrupt killed single non-queue turns. The hidden
   450ms timer meant a natural double-tap after sending stopped the agent.
   Fix: empty Enter while busy is a no-op; interrupting is explicit —
   Stop button or Esc.

Also: clean stop (no [interrupted] marker), Send-now works while busy
(promote + interrupt + auto-drain), settle on the interrupted completion
path. Adds regression tests and unblocks the prompt-actions suite by
completing its stale @/hermes mock.

* fix(desktop): float the queue panel as an overlay so the chat doesn't resize

The queue list rendered in-flow inside the composer root, so its height
fed --composer-measured-height (the composer rect drives the thread's
bottom padding + last-message clearance). Queuing a message grew that
rect and the whole chat visibly resized.

Anchor the panel out of flow above the composer (absolute bottom-full,
capped at 40vh with internal scroll). It no longer contributes to the
measured height, so the thread layout stays put and the list overlays the
(already faded) chat. Still collapsible via the panel's own
disclosure header.

* fix(desktop): queue panel collapsed by default + shared border with composer

- Default the queue disclosure to collapsed (compact 'N queued' pill)
  instead of expanded.
- Drop the gap and merge the panel into the composer: square bottom
  corners, no bottom border/radius, and overlap down by the Root's pt-2
  (-mb-2) so the panel's borderless bottom lands on the composer surface's
  top border — one continuous bordered shape.

* style(desktop): tighten queue panel padding

* style(desktop): trim queue-ux comments to house style

* style(desktop): drop 'Cursor' references from comments
2026-06-05 20:21:41 -05:00
Gille 0c0a707744 fix(desktop): repair macOS updater helper (#40217) 2026-06-05 20:05:32 -05:00
Teknium 78122c52cf test(slack): drop /q alias assertion now displaced by /version cap clamp
Slack's native-slash manifest hard-caps at 50 (_SLACK_MAX_SLASH_COMMANDS).
Adding the /version canonical claims a pass-1 slot, so the lowest-priority
pass-2 alias (/q for /quit) clamps off the end. /q stays reachable via
/hermes q. Surviving aliases (/btw /bg /reset) still prove alias parity.
2026-06-05 18:05:05 -07:00
Brooklyn Nicholson 30340eae2f Include git SHA in /version output via banner label helper.
Reuses format_banner_version_label() so CLI, TUI, gateway, and desktop show upstream/local commit when available.
2026-06-05 18:05:05 -07:00
Brooklyn Nicholson 9c1bb8d2c7 Add /version slash command across CLI, gateway, TUI, and desktop.
Surfaces Hermes Agent version info on demand without leaving chat; works mid-run like /help and /update.
2026-06-05 18:05:05 -07:00
teknium1 aa52cd3b57 test(desktop): unmount between IME composition repro cases
The new IME repro test has two it() blocks but the desktop suite registers
no global testing-library auto-cleanup, so the first render() leaked its
editor into the second test and getByTestId('editor') matched two nodes.
Add afterEach(cleanup) so each case renders into a fresh DOM.
2026-06-05 18:05:00 -07:00
xxxigm da9425bf9b test(desktop): cover IME-composed send-button visibility (Chinese/Japanese/Korean)
DOM repro that drives compositionstart -> input(preedit) -> compositionend with
no trailing input event and asserts the composer payload (send button) becomes
visible for committed CJK/IME input. Regression guard for #39614.
2026-06-05 18:05:00 -07:00
xxxigm 8e629b9f38 fix(desktop): flush committed IME text on compositionend so the send button appears
Typing committed multi-character IME text (e.g. Chinese "你好", and equally
Japanese/Korean or any IME-composed script) left the send button hidden until
an unrelated edit. Input events during composition carry uncommitted preedit
text and are intentionally skipped; the code assumed a trailing input event
after compositionend would deliver the finalized text, but Chromium does not
reliably emit one on Windows IMEs. The committed text therefore never reached
composer state, so `hasComposerPayload` stayed false and the send button stayed
hidden (deleting a char fired a non-composition input that finally synced it).

Flush the live editor text into composer state in onCompositionEnd. Extract the
shared sync into flushEditorToDraft so input and compositionend both update
state.

Fixes #39614
2026-06-05 18:05:00 -07:00
teknium1 be2c64be02 fix(desktop): wire serializeJsonBody into OAuth request path
The salvaged helper exported serializeJsonBody but main.cjs still inline-built
the request body, leaving the export dead and the test decoupled from the real
path. Use it at the fetchJsonViaOauthSession site so the helper's coverage
exercises production body construction. Byte-identical output.
2026-06-05 18:04:45 -07:00
helix4u b8234e7599 fix(desktop): avoid restricted oauth request header 2026-06-05 18:04:45 -07:00
Teknium 3c231eb397 chore: release v0.16.0 (2026.6.5) (#40206)
The Surface Release — native desktop app, browser admin panel,
remote-gateway connect, Simplified Chinese desktop UI, leaner default
skill set, NVIDIA/skills trusted tap, fuzzy model picker, /undo.

874 commits · 542 PRs · 170 contributors · 399 issues closed.
2026-06-05 17:55:43 -07:00
Teknium ea266f43e9 fix(file-ops): make rg/grep search error guard reachable and preserve partial matches (#39858)
The error guard in _search_with_rg/_search_with_grep was unreachable and,
if it had fired, would have discarded valid results.

Two root causes:

1. Unreachable. Both methods pipe the search through `| head` with no
   pipefail, so the pipeline reported head's exit code (0), masking rg/grep's
   error code (2). The guard never fired. Worse, because _exec merges stderr
   into stdout (stderr=subprocess.STDOUT), the error text was then parsed as
   bogus match lines instead of being surfaced — the user got garbage matches
   with no indication the search failed.

2. Latent results-dropping. The original `not result.stdout.strip()` check
   was always False on error (error text lives in stdout), and the
   `hasattr(result, 'stderr')` branch was dead code (ExecuteResult has no
   stderr field). A naive broadening to `exit_code == 2` would have nuked
   real matches whenever rg/grep also hit a non-fatal error (e.g. one
   unreadable file in a tree that otherwise matched), which both tools signal
   with exit 2.

Fix:
- Prefix the piped command with `set -o pipefail` so rg/grep's real exit
  status propagates. rg exits 0 on a truncating head; grep exits 141
  (SIGPIPE), so the strict `== 2` guard ignores truncated-success.
- Add _split_tool_diagnostics() to separate tool diagnostics from match
  output by tool prefix and output shape. Diagnostics never become matches;
  on a hard error they are the message to surface.
- Only surface an error when exit==2 AND no usable match payload remains, so
  partial errors keep their real matches.

Tests: tests/tools/test_search_error_guard.py drives both methods through the
real local backend (hard error surfaced, partial error keeps matches,
truncation no false error, files_only/count exclude diagnostics) plus unit
coverage for the splitter.

Supersedes #39710.
2026-06-05 17:44:52 -07:00
54 changed files with 2752 additions and 238 deletions
+2 -2
View File
@@ -1,7 +1,7 @@
{
"id": "hermes-agent",
"name": "Hermes Agent",
"version": "0.15.1",
"version": "0.16.0",
"description": "Self-improving open-source AI agent by Nous Research with ACP editor integration, persistent memory, skills, and rich tool support.",
"repository": "https://github.com/NousResearch/hermes-agent",
"website": "https://hermes-agent.nousresearch.com/docs/user-guide/features/acp",
@@ -9,7 +9,7 @@
"license": "MIT",
"distribution": {
"uvx": {
"package": "hermes-agent[acp]==0.15.1",
"package": "hermes-agent[acp]==0.16.0",
"args": ["hermes-acp"]
}
}
@@ -17,6 +17,8 @@
//! the bootstrap-complete check.
use std::path::{Path, PathBuf};
#[cfg(target_os = "macos")]
use std::process::Command;
use tracing_appender::non_blocking::WorkerGuard;
/// Returns the canonical Hermes home directory, respecting $HERMES_HOME if set.
@@ -103,10 +105,37 @@ pub fn copy_self_to_hermes_home() -> std::io::Result<()> {
std::fs::create_dir_all(parent)?;
}
std::fs::copy(&src, &dest)?;
repair_macos_installer_helper(&dest);
tracing::info!(?src, ?dest, "copied installer to HERMES_HOME");
Ok(())
}
#[cfg(target_os = "macos")]
fn repair_macos_installer_helper(path: &Path) {
// The staged helper may inherit quarantine from the downloaded installer.
// Desktop later launches this exact file for in-app updates, so make it
// executable before the update handoff reaches LaunchServices/Gatekeeper.
let _ = Command::new("/usr/bin/xattr")
.args(["-cr"])
.arg(path)
.status();
let verify = Command::new("/usr/bin/codesign")
.arg("--verify")
.arg(path)
.status();
if !matches!(verify, Ok(status) if status.success()) {
let _ = Command::new("/usr/bin/codesign")
.args(["--force", "--sign", "-"])
.arg(path)
.status();
}
}
#[cfg(not(target_os = "macos"))]
fn repair_macos_installer_helper(_path: &Path) {}
/// Where install.ps1 writes the bootstrap-complete marker (existence-only file
/// the Electron app also checks). Per main.cjs:
/// const BOOTSTRAP_COMPLETE_MARKER = path.join(ACTIVE_HERMES_ROOT, '.hermes-bootstrap-complete')
+29 -3
View File
@@ -28,6 +28,7 @@ const { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } = requ
const { runBootstrap } = require('./bootstrap-runner.cjs')
const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs')
const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs')
const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs')
const {
authModeFromStatus,
buildGatewayWsUrl,
@@ -1313,6 +1314,31 @@ function resolveUpdaterBinary() {
return fileExists(candidate) ? candidate : null
}
function repairMacUpdaterHelper(updater) {
if (!IS_MAC || !updater) return
try {
execFileSync('/usr/bin/xattr', ['-cr', updater], { stdio: 'ignore' })
} catch (err) {
rememberLog(`[updates] macOS updater helper quarantine repair skipped: ${err.message}`)
}
try {
execFileSync('/usr/bin/codesign', ['--verify', updater], { stdio: 'ignore' })
return
} catch {
// Unsigned or invalid helper. Apply a local ad-hoc signature so Gatekeeper
// does not block the staged updater before it can run.
}
try {
execFileSync('/usr/bin/codesign', ['--force', '--sign', '-', updater], { stdio: 'ignore' })
rememberLog('[updates] repaired macOS updater helper signature')
} catch (err) {
rememberLog(`[updates] macOS updater helper signature repair skipped: ${err.message}`)
}
}
// Path to the venv shim whose lock decides whether `hermes update` can write
// fresh entry points. On Windows this is the file the running backend
// `hermes.exe` holds open; on POSIX it's never mandatory-locked.
@@ -1473,6 +1499,7 @@ async function applyUpdates(opts = {}) {
}
emitUpdateProgress({ stage: 'restart', message: 'Handing off to the Hermes updater…', percent: 100 })
repairMacUpdaterHelper(updater)
const updateRoot = resolveUpdateRoot()
const { branch: configuredBranch } = readDesktopUpdateConfig()
@@ -3467,7 +3494,7 @@ function fetchJsonViaOauthSession(url, options = {}) {
reject(new Error(`Unsupported Hermes backend URL protocol: ${parsed.protocol}`))
return
}
const body = options.body === undefined ? undefined : Buffer.from(JSON.stringify(options.body))
const body = serializeJsonBody(options.body)
const timeoutMs = resolveTimeoutMs(options.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS)
const request = electronNet.request({
@@ -3477,8 +3504,7 @@ function fetchJsonViaOauthSession(url, options = {}) {
useSessionCookies: true,
redirect: 'follow'
})
request.setHeader('Content-Type', 'application/json')
if (body) request.setHeader('Content-Length', String(body.length))
setJsonRequestHeaders(request)
let timedOut = false
const timer = setTimeout(() => {
@@ -0,0 +1,20 @@
/**
* Helpers for Electron net.request calls that ride the OAuth session partition.
*
* Electron's ClientRequest forbids app-set restricted headers such as
* Content-Length. Let Chromium frame the body itself; only set the JSON content
* type here.
*/
function serializeJsonBody(body) {
return body === undefined ? undefined : Buffer.from(JSON.stringify(body))
}
function setJsonRequestHeaders(request) {
request.setHeader('Content-Type', 'application/json')
}
module.exports = {
serializeJsonBody,
setJsonRequestHeaders
}
@@ -0,0 +1,34 @@
/**
* Tests for OAuth-session Electron net.request helpers.
*
* Run with: node --test electron/oauth-net-request.test.cjs
*/
const test = require('node:test')
const assert = require('node:assert/strict')
const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs')
test('serializeJsonBody returns undefined for absent bodies', () => {
assert.equal(serializeJsonBody(undefined), undefined)
})
test('serializeJsonBody JSON-encodes request bodies', () => {
const body = serializeJsonBody({ archived: true })
assert.ok(Buffer.isBuffer(body))
assert.equal(body.toString('utf8'), '{"archived":true}')
})
test('setJsonRequestHeaders does not set Electron-restricted Content-Length', () => {
const headers = []
const request = {
setHeader(name, value) {
headers.push([name, value])
}
}
setJsonRequestHeaders(request)
assert.deepEqual(headers, [['Content-Type', 'application/json']])
assert.equal(headers.some(([name]) => name.toLowerCase() === 'content-length'), false)
})
+1 -1
View File
@@ -35,7 +35,7 @@
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/gateway-ws-probe.test.cjs",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs",
"type-check": "tsc -b",
"lint": "eslint src/ electron/",
"lint:fix": "eslint src/ electron/ --fix",
@@ -0,0 +1,108 @@
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import { useRef, useState } from 'react'
import { afterEach, describe, expect, it } from 'vitest'
// No global setupFiles registers auto-cleanup, so unmount between tests —
// otherwise a second render() leaks the first editor and getByTestId('editor')
// matches multiple nodes.
afterEach(cleanup)
// Faithful mirror of index.tsx's composer text wiring for IME input, driven
// through REAL DOM composition + input events on a contentEditable.
//
// Regression repro for #39614: typing committed multi-character IME text (e.g.
// Chinese "你好") used to leave the send button hidden. The input events fired
// during composition carry uncommitted preedit text and are intentionally
// skipped; Chromium then does NOT reliably emit a trailing input event after
// compositionend on Windows IMEs, so the finalized text never reached composer
// state and `hasPayload` stayed false until an unrelated edit forced a sync.
// The fix flushes the live DOM text in onCompositionEnd.
function Harness({ onPayload }: { onPayload: (hasPayload: boolean) => void }) {
const editorRef = useRef<HTMLDivElement>(null)
const composingRef = useRef(false)
const draftRef = useRef('')
const [draft, setDraft] = useState('')
const flushEditorToDraft = (editor: HTMLDivElement) => {
const next = editor.textContent ?? ''
if (next !== draftRef.current) {
draftRef.current = next
setDraft(next)
}
}
onPayload(draft.trim().length > 0)
return (
<div
contentEditable
data-testid="editor"
onCompositionEnd={event => {
composingRef.current = false
flushEditorToDraft(event.currentTarget)
}}
onCompositionStart={() => {
composingRef.current = true
}}
onInput={event => {
if (composingRef.current) {
return
}
flushEditorToDraft(event.currentTarget)
}}
ref={editorRef}
suppressContentEditableWarning
/>
)
}
describe('composer IME composition — send button visibility (#39614)', () => {
it('shows the send button after committing CJK text without a trailing edit', async () => {
let hasPayload = false
const { getByTestId } = render(<Harness onPayload={p => (hasPayload = p)} />)
const editor = getByTestId('editor')
// Compose "你好" the way a Windows Chinese IME does: compositionstart, then
// input events carrying uncommitted preedit text, then compositionend with
// the committed text already in the DOM — and crucially NO input event
// afterwards.
await act(async () => {
fireEvent.compositionStart(editor)
editor.textContent = '你'
fireEvent.input(editor)
editor.textContent = '你好'
fireEvent.input(editor)
fireEvent.compositionEnd(editor)
})
// Before the fix this was false (button hidden) until a further edit.
expect(hasPayload).toBe(true)
expect(editor.textContent).toBe('你好')
})
it('also covers Japanese/Korean and any IME-composed script', async () => {
let hasPayload = false
const { getByTestId } = render(<Harness onPayload={p => (hasPayload = p)} />)
const editor = getByTestId('editor')
for (const committed of ['こんにちは', '안녕하세요']) {
await act(async () => {
fireEvent.compositionStart(editor)
editor.textContent = committed
fireEvent.input(editor)
fireEvent.compositionEnd(editor)
})
expect(hasPayload).toBe(true)
// Clear for the next script.
await act(async () => {
editor.textContent = ''
fireEvent.input(editor)
})
expect(hasPayload).toBe(false)
}
})
})
+199 -50
View File
@@ -24,9 +24,17 @@ import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
import { $composerAttachments, clearComposerAttachments, type ComposerAttachment } from '@/store/composer'
import {
browseBackward,
browseForward,
deriveUserHistory,
isBrowsingHistory,
resetBrowseState
} from '@/store/composer-input-history'
import {
$queuedPromptsBySession,
enqueueQueuedPrompt,
promoteQueuedPrompt,
type QueuedPromptEntry,
removeQueuedPrompt,
shouldAutoDrainOnSettle,
@@ -123,6 +131,7 @@ export function ChatBar({
const attachments = useStore($composerAttachments)
const queuedPromptsBySession = useStore($queuedPromptsBySession)
const scrolledUp = useStore($threadScrolledUp)
const sessionMessages = useStore($messages)
const activeQueueSessionKey = queueSessionKey || sessionId || null
const queuedPrompts = useMemo(
@@ -136,12 +145,6 @@ export function ChatBar({
const draftRef = useRef(draft)
const previousBusyRef = useRef(busy)
const drainingQueueRef = useRef(false)
// Set when the user explicitly interrupts the running turn via the Stop
// button (busy + empty composer). It suppresses the next busy→false
// auto-drain so an explicit Stop actually halts instead of immediately
// firing the head of the queue. The queue is preserved; the user resumes
// it deliberately via Cmd/Ctrl+K, Enter, or the per-row "send now" arrow.
const userInterruptedRef = useRef(false)
const urlInputRef = useRef<HTMLInputElement | null>(null)
const [urlOpen, setUrlOpen] = useState(false)
@@ -198,6 +201,7 @@ export function ChatBar({
return
}
resetBrowseState(prev)
setRestingPlaceholder(pickPlaceholder(sessionId ? followUpPlaceholders : newSessionPlaceholders))
}, [followUpPlaceholders, newSessionPlaceholders, sessionId])
@@ -549,16 +553,10 @@ export function ChatBar({
}
}, [trigger])
const handleEditorInput = (event: FormEvent<HTMLDivElement>) => {
// During IME composition the DOM contains uncommitted preedit text
// mixed with real content. Skip state writes — compositionend will
// deliver the finalized text via a clean input event.
if (composingRef.current) {
return
}
const editor = event.currentTarget
// Pull the live contentEditable text into draftRef + the AUI composer state
// (which drives `hasComposerPayload` → the send button). Shared by the input
// and compositionend paths so committed IME text reaches state through either.
const flushEditorToDraft = (editor: HTMLDivElement) => {
if (editor.childNodes.length === 1 && editor.firstChild?.nodeName === 'BR') {
editor.replaceChildren()
}
@@ -573,6 +571,17 @@ export function ChatBar({
window.setTimeout(refreshTrigger, 0)
}
const handleEditorInput = (event: FormEvent<HTMLDivElement>) => {
// During IME composition the DOM contains uncommitted preedit text
// mixed with real content. Skip state writes — compositionend flushes
// the finalized text (see onCompositionEnd).
if (composingRef.current) {
return
}
flushEditorToDraft(event.currentTarget)
}
const triggerAdapter: Unstable_TriggerAdapter | null =
trigger?.kind === '@' ? at.adapter : trigger?.kind === '/' ? slash.adapter : null
@@ -715,6 +724,74 @@ export function ChatBar({
}
}
// ArrowUp/ArrowDown navigate, in priority order: the queue (edit entries in
// place) then sent-message history. The history ring is derived from live
// session messages each press — single source of truth, no mirror.
if (event.key === 'ArrowUp') {
const currentDraft = draftRef.current
// Editing a queued turn → walk to the older entry.
if (queueEdit && stepQueuedEdit(-1)) {
event.preventDefault()
triggerKeyConsumedRef.current = true
return
}
// Empty composer + a queued turn → open the newest queued entry for edit
// (the row's pencil), not a text recall. Enter saves it back to the queue.
if (!currentDraft.trim() && !queueEdit && queuedPrompts.length > 0) {
event.preventDefault()
triggerKeyConsumedRef.current = true
beginQueuedEdit(queuedPrompts[queuedPrompts.length - 1]!)
return
}
// Don't hijack a typed draft unless already browsing — they'd lose it.
if (currentDraft.trim() && !isBrowsingHistory(sessionId)) {
return
}
event.preventDefault()
triggerKeyConsumedRef.current = true
const history = deriveUserHistory(sessionMessages, chatMessageText)
const entry = browseBackward(sessionId, currentDraft, history)
if (entry !== null) {
loadIntoComposer(entry, $composerAttachments.get())
}
return
}
if (event.key === 'ArrowDown') {
// Editing a queued turn → walk to the newer entry (past the newest exits).
if (queueEdit) {
event.preventDefault()
triggerKeyConsumedRef.current = true
stepQueuedEdit(1)
return
}
// Browsing sent history → step toward the present, restoring the draft.
if (isBrowsingHistory(sessionId)) {
event.preventDefault()
triggerKeyConsumedRef.current = true
const history = deriveUserHistory(sessionMessages, chatMessageText)
const result = browseForward(sessionId, history)
if (result !== null) {
loadIntoComposer(result.text, $composerAttachments.get())
}
}
return
}
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
@@ -724,7 +801,32 @@ export function ChatBar({
return
}
// Empty Enter while busy is a no-op — interrupting is explicit (Stop/Esc),
// never a stray Enter after sending. With a payload, submitDraft queues it.
if (busy && !hasComposerPayload) {
return
}
submitDraft()
return
}
if (event.key === 'Escape') {
// Editing a queued turn → Esc cancels the edit, restoring the prior draft.
if (queueEdit) {
event.preventDefault()
exitQueuedEdit('cancel')
return
}
// Otherwise Esc interrupts the running turn (Stop-button parity).
if (busy) {
event.preventDefault()
triggerHaptic('cancel')
void Promise.resolve(onCancel())
}
}
}
@@ -890,6 +992,42 @@ export function ChatBar({
focusInput()
}
// Walk queued entries while editing (ArrowUp = older, ArrowDown = newer),
// saving the in-progress edit on each step. Stepping newer past the last
// entry exits edit mode and restores the pre-edit draft.
const stepQueuedEdit = (direction: -1 | 1) => {
if (!queueEdit) {
return false
}
const index = queuedPrompts.findIndex(e => e.id === queueEdit.entryId)
const target = index + direction
if (index < 0 || target < 0) {
return index >= 0 // at the oldest: swallow; missing entry: let it fall through
}
const saved = updateQueuedPrompt(queueEdit.sessionKey, queueEdit.entryId, {
attachments: cloneAttachments($composerAttachments.get()),
text: draftRef.current
})
const next = queuedPrompts[target]
if (next) {
setQueueEdit({ ...queueEdit, entryId: next.id })
loadIntoComposer(next.text, next.attachments)
} else {
setQueueEdit(null)
loadIntoComposer(queueEdit.draft, queueEdit.attachments)
}
triggerHaptic(saved ? 'success' : 'selection')
focusInput()
return true
}
const exitQueuedEdit = (action: 'cancel' | 'save'): boolean => {
if (!queueEdit) {
return false
@@ -958,13 +1096,14 @@ export function ChatBar({
}
removeQueuedPrompt(activeQueueSessionKey, entry.id)
resetBrowseState(sessionId)
return true
} finally {
drainingQueueRef.current = false
}
},
[activeQueueSessionKey, onSubmit, queuedPrompts]
[activeQueueSessionKey, onSubmit, queuedPrompts, sessionId]
)
const drainNextQueued = useCallback(
@@ -978,41 +1117,40 @@ export function ChatBar({
)
const sendQueuedNow = useCallback(
(id: string) => runDrain(entries => entries.find(e => e.id === id && id !== queueEdit?.entryId)),
[queueEdit, runDrain]
(id: string) => {
if (!activeQueueSessionKey || id === queueEdit?.entryId) {
return false
}
if (busy) {
// Promote to the head, then interrupt. The gateway always emits a
// settle (message.complete + session.info running:false) when the
// turn unwinds, and the busy→false auto-drain below sends this entry.
promoteQueuedPrompt(activeQueueSessionKey, id)
triggerHaptic('selection')
void Promise.resolve(onCancel())
return true
}
return runDrain(entries => entries.find(e => e.id === id))
},
[activeQueueSessionKey, busy, onCancel, queueEdit, runDrain]
)
// Auto-drain on busy → false (turn settled). An explicit user interrupt
// (Stop button) sets userInterruptedRef so we skip exactly one auto-drain:
// the user asked to halt, so we must not immediately re-send the queue.
// The queued turns stay intact and the user resumes them on demand.
// Auto-drain on busy → false (turn settled). Queued turns always flow once
// the session is idle again — whether the turn finished naturally or the
// user interrupted it. Interrupting to reach a queued message is the whole
// point of the queue, so we never suppress the drain. To cancel queued
// turns, the user deletes them from the panel.
useEffect(() => {
const wasBusy = previousBusyRef.current
previousBusyRef.current = busy
// Clear the interrupt latch when a new turn starts (false → true). This
// guards the sub-frame race where a Stop click lands after busy already
// flipped false (button not yet unmounted): the stale latch can no longer
// survive into the next turn and wrongly suppress its natural auto-drain.
if (busy && !wasBusy) {
userInterruptedRef.current = false
return
}
const interrupted = userInterruptedRef.current
// Consume the interrupt latch on any settle so a later natural completion
// is not wrongly suppressed.
if (!busy && wasBusy && interrupted) {
userInterruptedRef.current = false
}
if (
shouldAutoDrainOnSettle({
isBusy: busy,
queueLength: queuedPrompts.length,
userInterrupted: interrupted,
wasBusy
})
) {
@@ -1053,12 +1191,8 @@ export function ChatBar({
} else if (hasComposerPayload) {
queueCurrentDraft()
} else {
// Stop button: an explicit interrupt must actually halt the running
// turn. Mark the interrupt so the busy→false auto-drain effect skips
// re-sending the queue — otherwise a queued follow-up would fire the
// instant we cancel and Stop would appear to "never work". Queued
// turns are preserved; the user sends them on demand.
userInterruptedRef.current = true
// Stop button (the only way to reach here while busy with an empty
// composer — empty Enter is short-circuited in the keydown handler).
triggerHaptic('cancel')
void Promise.resolve(onCancel())
}
@@ -1067,6 +1201,7 @@ export function ChatBar({
} else if (draft.trim() || attachments.length > 0) {
const submitted = draft
triggerHaptic('submit')
resetBrowseState(sessionId)
clearDraft()
clearComposerAttachments()
void onSubmit(submitted, { attachments })
@@ -1136,6 +1271,7 @@ export function ChatBar({
}
triggerHaptic('submit')
resetBrowseState(sessionId)
clearDraft()
await onSubmit(text)
}
@@ -1208,8 +1344,17 @@ export function ChatBar({
data-placeholder={placeholder}
data-slot={RICH_INPUT_SLOT}
onBlur={() => window.setTimeout(closeTrigger, 80)}
onCompositionEnd={() => {
onCompositionEnd={event => {
composingRef.current = false
// The input events fired *during* composition were skipped (they
// carried uncommitted preedit text), and Chromium does NOT reliably
// emit a trailing input event after compositionend on Windows IMEs.
// Without flushing here, committed multi-character IME input (e.g.
// Chinese "你好", Japanese, Korean) never reaches composer state, so
// `hasComposerPayload` stays false and the send button stays hidden
// until an unrelated edit forces a sync (#39614).
flushEditorToDraft(event.currentTarget)
}}
onCompositionStart={() => {
composingRef.current = true
@@ -1284,7 +1429,11 @@ export function ChatBar({
)}
<SkinSlashPopover draft={draft} onSelect={selectSkinSlashCommand} />
{activeQueueSessionKey && queuedPrompts.length > 0 && (
<div className="relative z-6 mb-1 px-0.5">
// Out of flow so the queue never inflates the composer's measured
// height (that drives thread bottom padding → chat resizes on
// queue). Overlaps -mb-2 onto the surface's top border for a shared
// edge; capped + scrollable. Overlays the chat instead of pushing it.
<div className="absolute inset-x-0 bottom-full z-6 -mb-2 max-h-[40vh] overflow-y-auto">
<QueuePanel
busy={busy}
editingId={queueEdit?.entryId ?? null}
@@ -23,16 +23,16 @@ const entryPreview = (entry: QueuedPromptEntry, c: Translations['composer']) =>
export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendNow }: QueuePanelProps) {
const { t } = useI18n()
const c = t.composer
const [collapsed, setCollapsed] = useState(false)
const [collapsed, setCollapsed] = useState(true)
if (entries.length === 0) {
return null
}
return (
<div className="rounded-2xl border border-border/65 bg-[color-mix(in_srgb,var(--dt-card)_70%,transparent)] py-0.5 shadow-[0_0_0_1px_color-mix(in_srgb,var(--dt-card)_30%,transparent)_inset]">
<div className="rounded-t-2xl border border-b-0 border-border/65 bg-[color-mix(in_srgb,var(--dt-card)_70%,transparent)] pt-0.5 pb-1">
<button
className="flex w-full items-center gap-1.5 px-2.5 py-1 text-left text-[0.72rem] font-medium text-muted-foreground/92 transition-colors hover:text-foreground/90"
className="flex w-full items-center gap-1.5 px-2 py-0.5 text-left text-[0.72rem] font-medium text-muted-foreground/92 transition-colors hover:text-foreground/90"
onClick={() => setCollapsed(open => !open)}
type="button"
>
@@ -41,15 +41,16 @@ export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendN
</button>
{!collapsed && (
<div className="space-y-0.5 px-1.5 pb-0.5">
<div className="space-y-0.5 px-1 pb-0.5">
{entries.map(entry => {
const isEditing = editingId === entry.id
const attachmentsCount = entry.attachments.length
const sendLabel = busy ? c.sendQueuedNext : c.sendQueuedNow
return (
<div
className={cn(
'group/queue-row flex items-center gap-1.5 rounded-lg border border-transparent px-1.5 py-1',
'group/queue-row flex items-center gap-1.5 rounded-lg border border-transparent px-1.5 py-0.5',
'transition-colors duration-300 ease-out hover:bg-(--chrome-action-hover) hover:transition-none',
isEditing && 'border-[color-mix(in_srgb,var(--dt-composer-ring)_40%,transparent)] bg-accent/25'
)}
@@ -97,11 +98,11 @@ export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendN
<Pencil size={11} />
</Button>
</Tip>
<Tip label={c.sendQueuedNow}>
<Tip label={sendLabel}>
<Button
aria-label={c.sendQueuedNow}
aria-label={sendLabel}
className="h-5 w-5 rounded-md"
disabled={busy || isEditing}
disabled={isEditing}
onClick={() => onSendNow(entry.id)}
size="icon-xs"
type="button"
@@ -3,6 +3,7 @@ import { useCallback } from 'react'
import { requestComposerFocus, requestComposerInsert } from '@/app/chat/composer/focus'
import { formatRefValue } from '@/components/assistant-ui/directive-text'
import { attachmentId, contextPath, pathLabel } from '@/lib/chat-runtime'
import { fsReadFileDataUrl, selectPaths } from '@/lib/desktop-fs'
import {
addComposerAttachment,
type ComposerAttachment,
@@ -36,6 +37,27 @@ function isImagePath(filePath: string): boolean {
return IMAGE_EXTENSION_PATTERN.test(filePath)
}
// Thumbnail source for an attached image. Locally-held paths (drag/paste saves,
// local picks) read off the client; when that fails on a remote backend the
// path lives on the gateway host, so fall back to the gateway data-url read.
async function loadImagePreviewDataUrl(filePath: string): Promise<string | undefined> {
try {
const local = await window.hermesDesktop?.readFileDataUrl(filePath)
if (local) {
return local
}
} catch {
// Path isn't on the client (remote-picked image) — try the gateway below.
}
try {
return await fsReadFileDataUrl(filePath)
} catch {
return undefined
}
}
export interface DroppedFile {
/** Browser-native File handle. Absent for in-app drags (e.g. project tree). */
file?: File
@@ -228,7 +250,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
const pickContextPaths = useCallback(
async (kind: 'file' | 'folder') => {
const paths = await window.hermesDesktop?.selectPaths({
const paths = await selectPaths({
title: kind === 'file' ? 'Add files as context' : 'Add folders as context',
defaultPath: currentCwd || undefined,
directories: kind === 'folder'
@@ -291,19 +313,13 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
attachToMain(baseAttachment)
try {
const previewUrl = await window.hermesDesktop?.readFileDataUrl(filePath)
const previewUrl = await loadImagePreviewDataUrl(filePath)
if (previewUrl) {
addComposerAttachment({ ...baseAttachment, previewUrl })
}
return true
} catch (err) {
notifyError(err, 'Image preview failed')
return true
if (previewUrl) {
addComposerAttachment({ ...baseAttachment, previewUrl })
}
return true
}, [])
const attachImageBlob = useCallback(
@@ -338,7 +354,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
)
const pickImages = useCallback(async () => {
const paths = await window.hermesDesktop?.selectPaths({
const paths = await selectPaths({
title: 'Attach images',
defaultPath: currentCwd || undefined,
filters: [
@@ -12,6 +12,7 @@ import { Streamdown } from 'streamdown'
import { HERMES_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions'
import { PageLoader } from '@/components/page-loader'
import { fsReadFileDataUrl, fsReadFileText } from '@/lib/desktop-fs'
import { cn } from '@/lib/utils'
import type { PreviewTarget } from '@/store/preview'
@@ -179,21 +180,19 @@ function looksBinaryBytes(bytes: Uint8Array) {
}
async function readTextPreview(filePath: string) {
if (window.hermesDesktop.readFileText) {
try {
return await window.hermesDesktop.readFileText(filePath)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
try {
return await fsReadFileText(filePath)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (!message.includes("No handler registered for 'hermes:readFileText'")) {
throw error
}
if (!message.includes("No handler registered for 'hermes:readFileText'")) {
throw error
}
}
// Back-compat for a running Electron process whose preload hasn't been
// restarted since readFileText was added. readFileDataUrl already existed.
const dataUrl = await window.hermesDesktop.readFileDataUrl(filePath)
const dataUrl = await fsReadFileDataUrl(filePath)
const [, metadata = '', data = ''] = dataUrl.match(/^data:([^,]*),(.*)$/) || []
const base64 = metadata.includes(';base64')
const mimeType = metadata.replace(/;base64$/, '') || undefined
@@ -441,7 +440,7 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
try {
if (isImage) {
const dataUrl = await window.hermesDesktop.readFileDataUrl(filePath)
const dataUrl = await fsReadFileDataUrl(filePath)
if (active) {
setState({ dataUrl, loading: false })
@@ -68,6 +68,7 @@ import { useGatewayBoot } from './gateway/hooks/use-gateway-boot'
import { useGatewayRequest } from './gateway/hooks/use-gateway-request'
import { ModelPickerOverlay } from './model-picker-overlay'
import { ModelVisibilityOverlay } from './model-visibility-overlay'
import { RemotePathPicker } from './remote-path-picker'
import { RightSidebarPane } from './right-sidebar'
import { $terminalTakeover } from './right-sidebar/store'
import { PersistentTerminal, TerminalSlot } from './right-sidebar/terminal/persistent'
@@ -672,6 +673,7 @@ export function DesktopController() {
<GatewayConnectingOverlay />
<BootFailureOverlay />
<CommandPalette />
<RemotePathPicker />
{settingsOpen && (
<Suspense fallback={null}>
@@ -0,0 +1,265 @@
import { act, cleanup, render } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $desktopBoot } from '@/store/boot'
import { $gatewayState } from '@/store/session'
import { useGatewayBoot } from './use-gateway-boot'
// End-to-end-ish repro of the "remote VPS → stuck on CONNECTING, no Settings"
// bug that drives the REAL useGatewayBoot hook + REAL HermesGateway through a
// fake WebSocket we fully control. No Docker / no real port: from the desktop's
// point of view a "remote VPS" is just a WebSocket that opens once and later
// refuses to reopen, so that is exactly (and only) what we fake.
//
// The previous test (gateway-connecting-overlay.test.tsx) hand-set the stores
// and asserted the overlays; this one proves the HOOK actually PRODUCES that
// stuck store combo — closing the "inferred by reading code" gap on the
// post-boot reconnect loop.
type Listener = (ev: unknown) => void
// Minimal WebSocket stand-in implementing only what json-rpc-gateway.connect()
// touches: readyState, add/removeEventListener('open'|'error'|'close'), close().
class FakeWebSocket {
static OPEN = 1
static CLOSED = 3
// Flipped by the test: 'open' = next socket connects; 'fail' = next socket
// errors (a dead remote). Mirrors a VPS going away after the first connect.
static mode: 'open' | 'fail' = 'open'
static instances: FakeWebSocket[] = []
readyState = 0
private listeners: Record<string, Set<Listener>> = {}
constructor(public url: string) {
FakeWebSocket.instances.push(this)
const willOpen = FakeWebSocket.mode === 'open'
// Resolve on the next microtask/macrotask so connect()'s promise wiring is
// in place before open/error fires (matches real async socket handshake).
setTimeout(() => {
if (willOpen) {
this.readyState = FakeWebSocket.OPEN
this.emit('open', {})
} else {
this.readyState = FakeWebSocket.CLOSED
this.emit('error', {})
}
}, 0)
}
addEventListener(type: string, fn: Listener) {
;(this.listeners[type] ??= new Set()).add(fn)
}
removeEventListener(type: string, fn: Listener) {
this.listeners[type]?.delete(fn)
}
close() {
this.readyState = FakeWebSocket.CLOSED
this.emit('close', {})
}
// Force-drop an open socket, as a sleeping laptop / restarted remote would.
drop() {
this.readyState = FakeWebSocket.CLOSED
this.emit('close', {})
}
private emit(type: string, ev: unknown) {
for (const fn of this.listeners[type] ?? []) fn(ev)
}
}
function fakeDesktop() {
const conn = {
authMode: 'token' as const,
baseUrl: 'https://vps.example.com',
profile: 'default',
token: 't',
wsUrl: 'wss://vps.example.com/api/ws?token=t'
}
return {
getConnection: vi.fn(async () => conn),
getGatewayWsUrl: vi.fn(async () => conn.wsUrl),
getBootProgress: vi.fn(async () => ({
error: null,
fakeMode: false,
message: '',
phase: 'init',
progress: 0,
running: true,
timestamp: Date.now()
})),
onBootProgress: vi.fn(() => () => undefined),
onBackendExit: vi.fn(() => () => undefined),
onPowerResume: vi.fn(() => () => undefined),
onWindowStateChanged: vi.fn(() => () => undefined),
touchBackend: vi.fn(async () => undefined),
profile: { get: vi.fn(async () => ({ profile: 'default' })) }
}
}
function Harness() {
useGatewayBoot({
handleGatewayEvent: () => undefined,
onConnectionReady: () => undefined,
onGatewayReady: () => undefined,
refreshHermesConfig: async () => undefined,
refreshSessions: async () => undefined
})
return null
}
const originalWebSocket = globalThis.WebSocket
beforeEach(() => {
vi.useFakeTimers()
FakeWebSocket.mode = 'open'
FakeWebSocket.instances = []
;(globalThis as { WebSocket: unknown }).WebSocket = FakeWebSocket
;(window as { hermesDesktop?: unknown }).hermesDesktop = fakeDesktop()
$gatewayState.set('idle')
$desktopBoot.set({
error: null,
fakeMode: false,
message: '',
phase: 'init',
progress: 0,
running: true,
timestamp: Date.now(),
visible: true
})
})
afterEach(() => {
cleanup()
vi.useRealTimers()
;(globalThis as { WebSocket: unknown }).WebSocket = originalWebSocket
delete (window as { hermesDesktop?: unknown }).hermesDesktop
})
// Let pending microtasks (awaits) AND the queued 0ms socket open/error fire.
async function flushAsync() {
await act(async () => {
await vi.advanceTimersByTimeAsync(0)
})
}
// Drive the exponential backoff forward by its full cap so the next scheduled
// reconnect attempt actually runs (1s,2s,4s,8s,15s,15s…). Returns after the
// attempt's async work settles.
async function advanceBackoff() {
await act(async () => {
await vi.advanceTimersByTimeAsync(15_000)
})
}
describe('useGatewayBoot remote reconnect loop (real hook, fake socket)', () => {
it('INITIAL boot against a dead VPS: getConnection hangs (waitForHermes) → app sits in the connecting combo, then fails', async () => {
// The report's actual path: a fresh launch pointed at an unreachable VPS.
// startHermes()'s remote branch awaits waitForHermes() for 45s before it
// throws, so the renderer's `await desktop.getConnection()` stays pending
// that whole window. During it: gatewayState is still 'idle' (connect was
// never reached) and boot.error is null → connecting=true → the fullscreen
// CONNECTING overlay, latched, blocking Settings.
let rejectConn: (e: Error) => void = () => undefined
const desktop = fakeDesktop()
desktop.getConnection = vi.fn(
() =>
new Promise((_resolve, reject) => {
rejectConn = reject
})
)
;(window as { hermesDesktop?: unknown }).hermesDesktop = desktop
render(<Harness />)
await flushAsync()
// getConnection is still pending — the dead-VPS wait. No socket was ever
// created, gatewayState never left idle, boot.error is null.
expect(FakeWebSocket.instances).toHaveLength(0)
expect($gatewayState.get()).not.toBe('open')
expect($desktopBoot.get().error).toBeNull()
// ^ connecting === true here → fullscreen CONNECTING, no Settings.
// After ~45s waitForHermes gives up and getConnection rejects → boot()
// catch → failDesktopBoot → the BootFailureOverlay recovery surface.
await act(async () => {
rejectConn(new Error('Hermes backend did not become ready: timeout'))
await vi.advanceTimersByTimeAsync(0)
})
expect($desktopBoot.get().error).toBeTruthy()
})
it('a remote that drops post-boot keeps looping with NO boot.error (the dead-end CONNECTING combo)', async () => {
render(<Harness />)
await flushAsync()
// Initial boot connected.
expect($gatewayState.get()).toBe('open')
expect($desktopBoot.get().error).toBeNull()
expect(FakeWebSocket.instances).toHaveLength(1)
// The remote VPS goes away: drop the live socket, and make every reopen
// fail from here on.
FakeWebSocket.mode = 'fail'
act(() => FakeWebSocket.instances[0].drop())
await flushAsync()
// Burn a couple backoff cycles BEFORE the escalation threshold (<6 attempts,
// ~the first ~15s). This is the window where stock and fixed behave the
// same: socket down, hook retrying, gatewayState non-open, boot.error still
// null → CONNECTING covers the screen with no recovery surface. (Past ~45s
// the fix raises boot.error; that's asserted in the next test.)
await advanceBackoff()
expect($gatewayState.get()).not.toBe('open')
expect($desktopBoot.get().error).toBeNull()
// It is actively retrying, not idle — more sockets were minted.
expect(FakeWebSocket.instances.length).toBeGreaterThan(1)
})
it('FIX: after the prolonged drop the hook raises a recoverable boot error (the escape hatch)', async () => {
render(<Harness />)
await flushAsync()
expect($desktopBoot.get().error).toBeNull()
FakeWebSocket.mode = 'fail'
act(() => FakeWebSocket.instances[0].drop())
await flushAsync()
// Walk the backoff past the >=6 attempt threshold (~45s of failures).
for (let i = 0; i < 8; i += 1) {
await advanceBackoff()
}
// The hook surfaced the recoverable error → BootFailureOverlay (Use local
// gateway / Sign in / Retry) becomes reachable instead of CONNECTING.
expect($desktopBoot.get().error).toBeTruthy()
})
it('FIX: a successful reconnect clears the recoverable error', async () => {
render(<Harness />)
await flushAsync()
FakeWebSocket.mode = 'fail'
act(() => FakeWebSocket.instances[0].drop())
await flushAsync()
for (let i = 0; i < 8; i += 1) {
await advanceBackoff()
}
expect($desktopBoot.get().error).toBeTruthy()
// The remote comes back: next reconnect attempt opens.
FakeWebSocket.mode = 'open'
await advanceBackoff()
expect($gatewayState.get()).toBe('open')
expect($desktopBoot.get().error).toBeNull()
})
})
+234
View File
@@ -0,0 +1,234 @@
import { useStore } from '@nanostores/react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Loader } from '@/components/ui/loader'
import type { HermesReadDirEntry } from '@/global'
import { fsReadDir } from '@/lib/desktop-fs'
import { cn } from '@/lib/utils'
import { $remotePathPicker, resolveRemotePathPicker } from '@/store/remote-path-picker'
function parentDir(path: string): string | null {
const trimmed = path.replace(/[\\/]+$/, '')
const idx = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'))
if (idx <= 0) {
return idx === 0 ? '/' : null
}
return trimmed.slice(0, idx)
}
function baseName(path: string): string {
return (
path
.replace(/[\\/]+$/, '')
.split(/[\\/]+/)
.filter(Boolean)
.pop() ?? path
)
}
// Browses the GATEWAY filesystem (via fs.list) so users on a remote backend can
// pick files/folders that exist on the agent host rather than their own machine.
// Mirrors the native selectPaths contract: resolves with absolute gateway paths
// (or [] when cancelled).
export function RemotePathPicker() {
const request = useStore($remotePathPicker)
if (!request) {
return null
}
return <RemotePathPickerDialog key={request.id} />
}
function RemotePathPickerDialog() {
const request = useStore($remotePathPicker)
const options = request?.options ?? {}
const directoriesMode = Boolean(options.directories)
const allowMultiple = options.multiple !== false && !directoriesMode
const [dir, setDir] = useState<string>(options.defaultPath ?? '')
const [entries, setEntries] = useState<HermesReadDirEntry[]>([])
const [selected, setSelected] = useState<Set<string>>(new Set())
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const allowedExtensions = useMemo(() => {
const exts = (options.filters ?? []).flatMap(filter => filter.extensions)
return exts.length > 0 ? new Set(exts.map(ext => ext.toLowerCase().replace(/^\./, ''))) : null
}, [options.filters])
const load = useCallback(async (target: string) => {
setLoading(true)
setError(null)
const result = await fsReadDir(target)
setDir(result.path ?? target)
setEntries(result.entries ?? [])
setError(result.error ?? null)
setSelected(new Set())
setLoading(false)
}, [])
// Loads the initial directory. `load` is stable and defaultPath is fixed for
// this keyed instance, so this runs once; navigation calls `load` directly.
useEffect(() => {
void load(options.defaultPath ?? '')
}, [load, options.defaultPath])
const visibleEntries = useMemo(() => {
return entries.filter(entry => {
if (entry.isDirectory) {
return true
}
if (directoriesMode) {
return false
}
if (!allowedExtensions) {
return true
}
const ext = baseName(entry.name).split('.').pop()?.toLowerCase() ?? ''
return allowedExtensions.has(ext)
})
}, [allowedExtensions, directoriesMode, entries])
const cancel = useCallback(() => resolveRemotePathPicker([]), [])
const confirm = useCallback(() => {
if (directoriesMode) {
resolveRemotePathPicker([dir])
return
}
if (selected.size > 0) {
resolveRemotePathPicker([...selected])
}
}, [dir, directoriesMode, selected])
const onEntryClick = useCallback(
(entry: HermesReadDirEntry) => {
if (entry.isDirectory) {
void load(entry.path)
return
}
if (directoriesMode) {
return
}
if (!allowMultiple) {
resolveRemotePathPicker([entry.path])
return
}
setSelected(prev => {
const next = new Set(prev)
if (next.has(entry.path)) {
next.delete(entry.path)
} else {
next.add(entry.path)
}
return next
})
},
[allowMultiple, directoriesMode, load]
)
const parent = parentDir(dir)
const title = options.title || (directoriesMode ? 'Select a folder' : 'Select files')
const confirmLabel = directoriesMode ? 'Use this folder' : `Attach${selected.size > 1 ? ` (${selected.size})` : ''}`
const confirmDisabled = directoriesMode ? !dir : selected.size === 0
return (
<Dialog onOpenChange={value => !value && cancel()} open>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<div className="flex items-center gap-1.5 text-xs text-(--ui-text-tertiary)">
<Button
aria-label="Up one folder"
disabled={!parent || loading}
onClick={() => parent && void load(parent)}
size="icon-xs"
variant="ghost"
>
<Codicon name="arrow-up" size="0.9rem" />
</Button>
<span className="truncate font-mono" title={dir}>
{dir || '…'}
</span>
</div>
<div className="h-72 overflow-y-auto rounded-md border border-(--ui-stroke-secondary) bg-background/40">
{loading ? (
<div className="flex h-full items-center justify-center">
<Loader />
</div>
) : error ? (
<div className="flex h-full items-center justify-center px-4 text-center text-xs text-destructive">
Could not read this folder ({error}).
</div>
) : visibleEntries.length === 0 ? (
<div className="flex h-full items-center justify-center px-4 text-center text-xs text-(--ui-text-tertiary)">
{directoriesMode ? 'No subfolders here.' : 'No matching files here.'}
</div>
) : (
<ul className="py-1">
{visibleEntries.map(entry => {
const isSelected = selected.has(entry.path)
return (
<li key={entry.path}>
<button
className={cn(
'flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs hover:bg-(--chrome-action-hover)',
isSelected && 'bg-(--chrome-action-hover)'
)}
onClick={() => onEntryClick(entry)}
type="button"
>
<Codicon
className={entry.isDirectory ? 'text-(--ui-accent)' : 'text-(--ui-text-tertiary)'}
name={entry.isDirectory ? 'folder' : 'file'}
size="0.95rem"
/>
<span className="flex-1 truncate">{entry.name}</span>
{!entry.isDirectory && isSelected && <Codicon name="check" size="0.9rem" />}
{entry.isDirectory && <Codicon name="chevron-right" size="0.85rem" />}
</button>
</li>
)
})}
</ul>
)}
</div>
<DialogFooter>
<Button onClick={cancel} type="button" variant="ghost">
Cancel
</Button>
<Button disabled={confirmDisabled} onClick={confirm} type="button">
{confirmLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -1,6 +1,7 @@
import ignore from 'ignore'
import type { HermesReadDirEntry, HermesReadDirResult } from '@/global'
import { fsGitRoot, fsReadDir, fsReadFileDataUrl } from '@/lib/desktop-fs'
export type ProjectTreeEntry = HermesReadDirEntry
@@ -63,15 +64,11 @@ function ancestorDirs(root: string, dir: string) {
}
async function gitRootFor(start: string) {
if (!window.hermesDesktop?.gitRoot) {
return null
}
const key = clean(start)
let cached = gitRootCache.get(key)
if (!cached) {
cached = window.hermesDesktop.gitRoot(key)
cached = fsGitRoot(key)
gitRootCache.set(key, cached)
}
@@ -80,18 +77,14 @@ async function gitRootFor(start: string) {
/** Read .gitignore at `dir` if it actually exists — never probe missing files. */
async function readGitignore(dir: string): Promise<GitignoreRule | null> {
if (!window.hermesDesktop?.readDir || !window.hermesDesktop.readFileDataUrl) {
return null
}
try {
const listing = await window.hermesDesktop.readDir(dir)
const listing = await fsReadDir(dir)
if (!listing.entries.some(e => e.name === '.gitignore' && !e.isDirectory)) {
return null
}
const text = decodeDataUrl(await window.hermesDesktop.readFileDataUrl(`${dir}/.gitignore`))
const text = decodeDataUrl(await fsReadFileDataUrl(`${dir}/.gitignore`))
return { base: dir, ig: ignore().add(text) }
} catch {
@@ -138,11 +131,7 @@ async function filterIgnored(entries: HermesReadDirEntry[], rootPath: string, di
}
export async function readProjectDir(dirPath: string, rootPath = dirPath): Promise<HermesReadDirResult> {
if (!window.hermesDesktop) {
return { entries: [], error: 'no-bridge' }
}
const result = await window.hermesDesktop.readDir(dirPath)
const result = await fsReadDir(dirPath)
return { ...result, entries: await filterIgnored(result.entries, rootPath, dirPath) }
}
+2 -1
View File
@@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Loader } from '@/components/ui/loader'
import { Tip } from '@/components/ui/tooltip'
import { selectPaths } from '@/lib/desktop-fs'
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
import { cn } from '@/lib/utils'
import { $panesFlipped } from '@/store/layout'
@@ -68,7 +69,7 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
const effectiveTab: RightSidebarTabId = terminalTakeover ? 'files' : activeTab
const chooseFolder = async () => {
const selected = await window.hermesDesktop?.selectPaths({
const selected = await selectPaths({
defaultPath: hasCwd ? currentCwd : undefined,
directories: true,
multiple: false,
@@ -437,11 +437,18 @@ export function useMessageStream({
const completedState = updateSessionState(sessionId, state => {
// Late completion from an already-cancelled turn: cancelRun has
// already finalized the bubble and added the [interrupted] marker;
// re-running the dedupe below would erase that marker and replace
// the partial with the (just-cancelled) full text.
// already finalized the bubble (kept the partial text, dropped it if
// empty). Re-running the dedupe below would replace the partial with
// the just-cancelled full text, so we settle and bail instead.
if (state.interrupted) {
return state
return {
...state,
awaitingResponse: false,
busy: false,
needsInput: false,
pendingBranchGroup: null,
streamId: null
}
}
const streamId = state.streamId
@@ -9,6 +9,8 @@ import type { SessionInfo } from '@/types/hermes'
import { usePromptActions } from './use-prompt-actions'
vi.mock('@/hermes', () => ({
getProfiles: vi.fn(async () => ({ profiles: [] })),
setApiRequestProfile: vi.fn(),
transcribeAudio: vi.fn()
}))
@@ -39,27 +41,31 @@ function sessionInfo(overrides: Partial<SessionInfo> = {}): SessionInfo {
}
interface HarnessHandle {
submitText: (text: string) => Promise<boolean>
submitText: (text: string, options?: { attachments?: never[]; fromQueue?: boolean }) => Promise<boolean>
}
function Harness({
busyRef,
onReady,
onSeedState,
refreshSessions,
requestGateway
}: {
busyRef?: MutableRefObject<boolean>
onReady: (handle: HarnessHandle) => void
onSeedState?: (state: Record<string, unknown>) => void
refreshSessions: () => Promise<void>
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
}) {
const activeSessionIdRef: MutableRefObject<string | null> = { current: RUNTIME_SESSION_ID }
const selectedStoredSessionIdRef: MutableRefObject<string | null> = { current: RUNTIME_SESSION_ID }
const busyRef = { current: false }
const localBusyRef = busyRef ?? { current: false }
const actions = usePromptActions({
activeSessionId: RUNTIME_SESSION_ID,
activeSessionIdRef,
branchCurrentSession: async () => true,
busyRef,
busyRef: localBusyRef,
createBackendSessionForSend: async () => RUNTIME_SESSION_ID,
handleSkinCommand: () => '',
refreshSessions,
@@ -67,8 +73,18 @@ function Harness({
selectedStoredSessionIdRef,
startFreshSessionDraft: () => undefined,
sttEnabled: false,
updateSessionState: (_sessionId, updater) =>
updater({ messages: [], busy: false, awaitingResponse: false } as never)
updateSessionState: (_sessionId, updater) => {
// Seed with interrupted:true so we can prove a fresh submit clears it.
const next = updater({
messages: [],
busy: false,
awaitingResponse: false,
interrupted: true
} as never) as unknown as Record<string, unknown>
onSeedState?.(next)
return next as never
}
})
useEffect(() => {
@@ -164,3 +180,82 @@ describe('usePromptActions /title', () => {
expect($sessions.get()[0]?.title).toBe('Old title')
})
})
describe('usePromptActions submit / queue drain semantics', () => {
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
it('clears a leftover interrupted flag on a fresh submit (so the new turn streams)', async () => {
const seeds: Record<string, unknown>[] = []
const requestGateway = vi.fn(async () => ({}) as never)
let handle: HarnessHandle | null = null
render(
<Harness
onReady={h => (handle = h)}
onSeedState={s => seeds.push(s)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
/>
)
await handle!.submitText('hello after a stop')
// The optimistic seed must reset interrupted:false even though the prior
// session state had interrupted:true — otherwise the message stream drops
// every delta of this brand-new turn.
expect(seeds.length).toBeGreaterThan(0)
expect(seeds.every(s => s.interrupted === false)).toBe(true)
expect(requestGateway).toHaveBeenCalledWith('prompt.submit', {
session_id: RUNTIME_SESSION_ID,
text: 'hello after a stop'
})
})
it('a fromQueue drain sends even when busyRef is still true on the settle edge', async () => {
// busyRef lags $busy by one effect tick on the busy→false settle edge, so a
// drained queue send would otherwise hit the busy guard and silently no-op.
const busyRef = { current: true }
const requestGateway = vi.fn(async () => ({}) as never)
let handle: HarnessHandle | null = null
render(
<Harness
busyRef={busyRef}
onReady={h => (handle = h)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
/>
)
const accepted = await handle!.submitText('queued message', { fromQueue: true })
expect(accepted).toBe(true)
expect(requestGateway).toHaveBeenCalledWith('prompt.submit', {
session_id: RUNTIME_SESSION_ID,
text: 'queued message'
})
})
it('a normal (non-queue) submit still respects the busyRef guard', async () => {
const busyRef = { current: true }
const requestGateway = vi.fn(async () => ({}) as never)
let handle: HarnessHandle | null = null
render(
<Harness
busyRef={busyRef}
onReady={h => (handle = h)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
/>
)
const accepted = await handle!.submitText('should be blocked')
expect(accepted).toBe(false)
expect(requestGateway).not.toHaveBeenCalledWith('prompt.submit', expect.anything())
})
})
@@ -2,10 +2,9 @@ import type { AppendMessage, ThreadMessage } from '@assistant-ui/react'
import { type MutableRefObject, useCallback } from 'react'
import { getProfiles, transcribeAudio } from '@/hermes'
import { appendTextPart, branchGroupForUser, type ChatMessage, chatMessageText, textPart } from '@/lib/chat-messages'
import { branchGroupForUser, type ChatMessage, chatMessageText, textPart } from '@/lib/chat-messages'
import {
attachmentDisplayText,
INTERRUPTED_MARKER,
parseCommandDispatch,
parseSlashCommand,
pathLabel,
@@ -178,6 +177,42 @@ export function usePromptActions({
[selectedStoredSessionIdRef, updateSessionState]
)
// Remote gateways (e.g. a VPS over tailscale) cannot see the client's local
// filesystem, so a path-based `image.attach` fails with "image not found".
// Fall back to uploading the bytes the Electron client already holds.
const uploadImageAttachmentBytes = useCallback(
async (sessionId: string, attachment: ComposerAttachment): Promise<ImageAttachResponse | null> => {
const path = attachment.path
if (!path) {
return null
}
let data = attachment.previewUrl
if (!data && window.hermesDesktop?.readFileDataUrl) {
try {
data = await window.hermesDesktop.readFileDataUrl(path)
} catch {
return null
}
}
if (!data) {
return null
}
const result = await requestGateway<ImageAttachResponse>('image.attach_bytes', {
session_id: sessionId,
filename: pathLabel(path),
data
})
return result.attached ? result : null
},
[requestGateway]
)
const syncImageAttachmentsForSubmit = useCallback(
async (
sessionId: string,
@@ -192,14 +227,28 @@ export function usePromptActions({
continue
}
const result = await requestGateway<ImageAttachResponse>('image.attach', {
session_id: sessionId,
path: attachment.path
})
let result: ImageAttachResponse | null = null
if (!result.attached) {
try {
const pathResult = await requestGateway<ImageAttachResponse>('image.attach', {
session_id: sessionId,
path: attachment.path
})
if (pathResult.attached) {
result = pathResult
}
} catch {
result = null
}
if (!result) {
result = await uploadImageAttachmentBytes(sessionId, attachment)
}
if (!result?.attached) {
const label = attachment.label || (attachment.path ? pathLabel(attachment.path) : 'image')
throw new Error(result.message || `Could not attach ${label}`)
throw new Error(result?.message || `Could not attach ${label}`)
}
const attachedPath = result.path || attachment.path
@@ -215,7 +264,7 @@ export function usePromptActions({
}
}
},
[requestGateway]
[requestGateway, uploadImageAttachmentBytes]
)
const submitPromptText = useCallback(
@@ -237,7 +286,11 @@ export function usePromptActions({
[contextRefs, terminalContextBlocks, visibleText].filter(Boolean).join('\n\n') ||
(hasImage ? 'What do you see in this image?' : '')
if (!text || busyRef.current) {
// Queue drains fire on the busy→false settle edge, where busyRef (synced
// from $busy by a separate effect) may still read true — honoring it would
// bounce the drained send. The drain lock serializes them; the user path
// keeps the guard so a stray Enter mid-turn can't double-submit.
if (!text || (!options?.fromQueue && busyRef.current)) {
return false
}
@@ -270,7 +323,10 @@ export function usePromptActions({
awaitingResponse: true,
pendingBranchGroup: null,
sawAssistantPayload: false,
interrupted: state.interrupted
// Fresh submit = new turn — clear any leftover interrupt flag, else
// mutateStream/completeAssistantMessage drop every delta of this turn
// (what made drained-after-interrupt sends go silent).
interrupted: false
}),
selectedStoredSessionIdRef.current
)
@@ -531,6 +587,7 @@ export function usePromptActions({
session_id: sessionId,
title: arg
})
const finalTitle = (result?.title || arg).trim()
const queued = result?.pending === true
@@ -689,24 +746,24 @@ export function usePromptActions({
const cancelRun = useCallback(async () => {
const sessionId = activeSessionId || activeSessionIdRef.current
setMutableRef(busyRef, false)
setBusy(false)
setAwaitingResponse(false)
const finalizeMessages = (messages: ChatMessage[]) =>
messages.map(message =>
message.pending
? {
...message,
parts: chatMessageText(message).trim()
? appendTextPart(message.parts, INTERRUPTED_MARKER)
: [...message.parts, textPart(INTERRUPTED_MARKER.trim())],
pending: false
}
: message
)
// Interrupting keeps whatever was already generated and just
// stops — no "[interrupted]" marker. A pending/streaming message with no
// body text is dropped entirely so we never leave an empty bubble behind.
const finalizeMessages = (messages: ChatMessage[], streamId?: string | null) =>
messages
.filter(
message =>
!((message.pending || message.id === streamId) && !chatMessageText(message).trim())
)
.map(message =>
message.pending || message.id === streamId ? { ...message, pending: false } : message
)
if (!sessionId) {
setMutableRef(busyRef, false)
setBusy(false)
setMessages(finalizeMessages($messages.get()))
return
@@ -715,24 +772,12 @@ export function usePromptActions({
updateSessionState(sessionId, state => {
const streamId = state.streamId
const messages = streamId
? state.messages.map(message =>
message.id === streamId
? {
...message,
parts: chatMessageText(message).trim()
? appendTextPart(message.parts, INTERRUPTED_MARKER)
: [...message.parts, textPart(INTERRUPTED_MARKER.trim())],
pending: false
}
: message
)
: finalizeMessages(state.messages)
const messages = finalizeMessages(state.messages, streamId)
return {
...state,
messages,
busy: false,
busy: true,
awaitingResponse: false,
streamId: null,
pendingBranchGroup: null,
@@ -743,6 +788,8 @@ export function usePromptActions({
try {
await requestGateway('session.interrupt', { session_id: sessionId })
} catch (err) {
setMutableRef(busyRef, false)
setBusy(false)
notifyError(err, 'Stop failed')
}
}, [activeSessionId, activeSessionIdRef, busyRef, requestGateway, updateSessionState])
@@ -117,10 +117,6 @@ function messageContentText(content: unknown): string {
return Array.isArray(content) ? content.map(partText).join('').trim() : ''
}
const INTERRUPTED_ONLY_RE = /^_?\[interrupted\]_?$/i
const isInterruptedOnlyMessage = (text: string) => INTERRUPTED_ONLY_RE.test(text.trim())
export const Thread: FC<{
clampToComposer?: boolean
cwd?: string | null
@@ -220,7 +216,6 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
const messageStatus = useAuiState(s => s.message.status?.type)
const isPlaceholder = messageStatus === 'running' && content.length === 0
const interruptedOnly = useMemo(() => isInterruptedOnlyMessage(messageText), [messageText])
const enterRef = useEnterAnimation(messageStatus === 'running', `assistant-message:${messageId}`)
if (isPlaceholder) {
@@ -236,10 +231,7 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
ref={enterRef}
>
<div
className={cn(
'wrap-anywhere min-w-0 max-w-full overflow-hidden text-pretty text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground',
interruptedOnly && 'text-[0.8rem] leading-5 text-muted-foreground/82'
)}
className="wrap-anywhere min-w-0 max-w-full overflow-hidden text-pretty text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground"
data-slot="aui_assistant-message-content"
>
{hoistedTodos.length > 0 && <HoistedTodoPanel todos={hoistedTodos} />}
@@ -260,7 +252,7 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
</ErrorPrimitive.Root>
</MessagePrimitive.Error>
</div>
{messageText.trim().length > 0 && !interruptedOnly && (
{messageText.trim().length > 0 && (
<AssistantFooter messageId={messageId} messageText={messageText} onBranchInNewChat={onBranchInNewChat} />
)}
</MessagePrimitive.Root>
@@ -0,0 +1,143 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { $desktopBoot } from '@/store/boot'
import { $desktopOnboarding } from '@/store/onboarding'
import { $gatewayState, setGatewayState } from '@/store/session'
import { BootFailureOverlay } from './boot-failure-overlay'
import { GatewayConnectingOverlay } from './gateway-connecting-overlay'
// Repro for the "remote gateway → stuck on CONNECTING, no way to settings"
// report. The connecting overlay (z-1200, full-screen, pointer-events on) is
// shown whenever `gatewayState !== 'open' && !boot.error`. The ONLY escape
// hatch — BootFailureOverlay, which has "Use local gateway" / "Sign in" /
// "Retry" — only renders when `boot.error` is set.
//
// useGatewayBoot only calls failDesktopBoot() (which sets boot.error) when the
// INITIAL boot() throws. After the first successful connect (bootCompleted),
// any later socket drop goes through scheduleReconnect(), which loops FOREVER
// against the dead remote and never sets boot.error. So gatewayState sits at
// 'closed'/'error' with boot.error null → CONNECTING forever, recovery overlay
// never appears, settings unreachable.
function resetStores() {
setGatewayState('idle')
$desktopBoot.set({
error: null,
fakeMode: false,
message: 'ready',
phase: 'renderer.ready',
progress: 100,
running: false,
timestamp: Date.now(),
visible: false
})
$desktopOnboarding.set({
configured: true,
flow: { status: 'idle' },
mode: 'oauth',
providers: null,
reason: null,
requested: false,
firstRunSkipped: false,
manual: false
})
}
beforeEach(resetStores)
afterEach(cleanup)
// The connecting overlay renders "CONN" + a scrambled tail inside one
// uppercase span; match that node specifically so the recovery overlay's
// "Lost connection…" copy doesn't read as a false positive.
const isConnectingShown = () =>
screen.queryAllByText((_, el) => /^CONN[/\\|\-_=+<>~:*A-Z]*$/.test(el?.textContent?.trim() ?? '')).length > 0
const isRecoveryShown = () =>
Boolean(screen.queryByText(/use local gateway/i) || screen.queryByText(/retry/i) || screen.queryByText(/sign in/i))
describe('connecting overlay vs recovery surface', () => {
it('hard initial-boot failure surfaces the recovery overlay (the working path)', () => {
// failDesktopBoot() ran: error set, gateway never opened.
$desktopBoot.set({ ...$desktopBoot.get(), error: 'Hermes backend did not become ready', running: false, visible: true })
setGatewayState('error')
render(
<>
<GatewayConnectingOverlay />
<BootFailureOverlay />
</>
)
expect(isRecoveryShown()).toBe(true)
// Connecting overlay bows out when boot.error is set.
expect(isConnectingShown()).toBe(false)
})
it('REPRO: remote socket drops AFTER a successful boot → stuck on CONNECTING, no recovery, no settings', () => {
// 1. Initial boot succeeded: gateway opened, boot completed (no error).
setGatewayState('open')
const { rerender } = render(
<>
<GatewayConnectingOverlay />
<BootFailureOverlay />
</>
)
expect(isConnectingShown()).toBe(false)
// 2. The remote VPS socket drops (sleep/wake, remote restart, network).
// bootCompleted is true, so useGatewayBoot routes this through
// scheduleReconnect() — boot.error stays NULL.
setGatewayState('closed')
rerender(
<>
<GatewayConnectingOverlay />
<BootFailureOverlay />
</>
)
// The connecting overlay reappears and latches...
expect(isConnectingShown()).toBe(true)
// ...with NO recovery surface, because boot.error was never set.
expect(isRecoveryShown()).toBe(false)
// 3. Reconnect loops forever against the dead remote: gatewayState bounces
// closed → error → closed, boot.error never gets set. The user is
// pinned on CONNECTING with no path to Settings indefinitely.
setGatewayState('error')
rerender(
<>
<GatewayConnectingOverlay />
<BootFailureOverlay />
</>
)
expect($desktopBoot.get().error).toBeNull()
expect(isConnectingShown()).toBe(true)
expect(isRecoveryShown()).toBe(false)
})
it('FIX: once the prolonged reconnect raises a recoverable boot error, the recovery overlay takes over', () => {
// Mirrors what useGatewayBoot.scheduleReconnect() now does after ~45s of
// failed post-boot reconnects: it calls failDesktopBoot(), flipping the UI
// from the dead-end CONNECTING overlay to the recovery surface.
setGatewayState('error')
$desktopBoot.set({
...$desktopBoot.get(),
error: 'Lost connection to the Hermes gateway and could not reconnect.',
running: false,
visible: true
})
render(
<>
<GatewayConnectingOverlay />
<BootFailureOverlay />
</>
)
// Escape hatch is now reachable; the connecting overlay bows out.
expect(isRecoveryShown()).toBe(true)
expect(screen.getByText(/use local gateway/i)).toBeTruthy()
expect(isConnectingShown()).toBe(false)
})
})
+3
View File
@@ -372,6 +372,9 @@ export interface HermesReadDirEntry {
export interface HermesReadDirResult {
entries: HermesReadDirEntry[]
error?: string
// Absolute directory the entries were read from. Set by the gateway `fs.list`
// RPC (remote backends); the local Electron readDir omits it.
path?: string
}
export interface HermesPreviewFileChanged {
+1
View File
@@ -698,6 +698,7 @@ export const en: Translations = {
attachments: count => `${count} attachment${count === 1 ? '' : 's'}`,
editingInComposer: 'Editing in composer',
editQueued: 'Edit queued turn',
sendQueuedNext: 'Send queued turn next',
sendQueuedNow: 'Send queued turn now',
deleteQueued: 'Delete queued turn',
previewUnavailable: 'Preview unavailable',
+1
View File
@@ -580,6 +580,7 @@ export interface Translations {
attachments: (count: number) => string
editingInComposer: string
editQueued: string
sendQueuedNext: string
sendQueuedNow: string
deleteQueued: string
previewUnavailable: string
+1
View File
@@ -827,6 +827,7 @@ export const zh: Translations = {
attachments: count => `${count} 个附件`,
editingInComposer: '正在输入框中编辑',
editQueued: '编辑排队回合',
sendQueuedNext: '下一个发送排队回合',
sendQueuedNow: '立即发送排队回合',
deleteQueued: '删除排队回合',
previewUnavailable: '预览不可用',
-1
View File
@@ -7,7 +7,6 @@ import { type ChatMessage, type ChatMessagePart, chatMessageText, textPart } fro
import type { ComposerAttachment } from '@/store/composer'
import type { ModelOptionsResponse, SessionInfo } from '@/types/hermes'
export const INTERRUPTED_MARKER = '\n\n_[interrupted]_'
export const SLASH_COMMAND_RE = /^\/[^\s/]*(?:\s|$)/
export const BUILTIN_PERSONALITIES = [
'helpful',
+91
View File
@@ -0,0 +1,91 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { HermesGateway } from '@/hermes'
import { $gateway } from '@/store/gateway'
import { resolveRemotePathPicker } from '@/store/remote-path-picker'
import { $connection } from '@/store/session'
import { fsGitRoot, fsReadDir, fsReadFileDataUrl, isRemoteBackend, selectPaths } from './desktop-fs'
const request = vi.fn()
const readDir = vi.fn()
const readFileDataUrl = vi.fn()
const gitRoot = vi.fn()
const desktopSelectPaths = vi.fn()
function setRemote(remote: boolean) {
$connection.set(remote ? ({ mode: 'remote' } as never) : null)
}
beforeEach(() => {
request.mockReset()
readDir.mockReset()
readFileDataUrl.mockReset()
gitRoot.mockReset()
desktopSelectPaths.mockReset()
$gateway.set({ request } as unknown as HermesGateway)
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = {
readDir,
readFileDataUrl,
gitRoot,
selectPaths: desktopSelectPaths
}
})
afterEach(() => {
$connection.set(null)
$gateway.set(null)
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
})
describe('desktop-fs facade', () => {
it('routes reads to local IPC when not remote', async () => {
setRemote(false)
readDir.mockResolvedValue({ entries: [] })
await fsReadDir('/p')
expect(readDir).toHaveBeenCalledWith('/p')
expect(request).not.toHaveBeenCalled()
expect(isRemoteBackend()).toBe(false)
})
it('routes directory listing to fs.list when remote', async () => {
setRemote(true)
request.mockResolvedValue({ entries: [], path: '/srv' })
const result = await fsReadDir('/srv')
expect(request).toHaveBeenCalledWith('fs.list', { path: '/srv' })
expect(result.path).toBe('/srv')
expect(readDir).not.toHaveBeenCalled()
})
it('unwraps the data url from fs.read_data_url when remote', async () => {
setRemote(true)
request.mockResolvedValue({ dataUrl: 'data:image/png;base64,AAAA' })
expect(await fsReadFileDataUrl('/srv/x.png')).toBe('data:image/png;base64,AAAA')
expect(request).toHaveBeenCalledWith('fs.read_data_url', { path: '/srv/x.png' })
})
it('returns gateway git root when remote', async () => {
setRemote(true)
request.mockResolvedValue({ root: '/srv/repo' })
expect(await fsGitRoot('/srv/repo/a')).toBe('/srv/repo')
})
it('uses the native picker locally and the remote picker when remote', async () => {
setRemote(false)
desktopSelectPaths.mockResolvedValue(['/local/a.png'])
expect(await selectPaths({ title: 'pick' })).toEqual(['/local/a.png'])
setRemote(true)
const pending = selectPaths({ title: 'pick' })
resolveRemotePathPicker(['/srv/a.png'])
expect(await pending).toEqual(['/srv/a.png'])
// Remote selection never touches the native dialog.
expect(desktopSelectPaths).toHaveBeenCalledTimes(1)
})
})
+64
View File
@@ -0,0 +1,64 @@
import type { HermesReadDirResult, HermesReadFileTextResult, HermesSelectPathsOptions } from '@/global'
import { $gateway } from '@/store/gateway'
import { openRemotePathPicker } from '@/store/remote-path-picker'
import { $connection } from '@/store/session'
// On a remote gateway (e.g. a VPS over tailscale) the agent's filesystem lives
// on the server, but the Electron IPC helpers only see the client machine. This
// facade routes reads + path selection through gateway `fs.*` RPCs when remote,
// and falls back to local Electron IPC against a locally-spawned backend.
export const isRemoteBackend = (): boolean => $connection.get()?.mode === 'remote'
function gw<T>(method: string, params: Record<string, unknown>): Promise<T> {
const gateway = $gateway.get()
if (!gateway) {
throw new Error('Hermes gateway unavailable')
}
return gateway.request<T>(method, params)
}
const unavailable = (): never => {
throw new Error('File reading is unavailable')
}
export function fsReadDir(path: string): Promise<HermesReadDirResult> {
if (isRemoteBackend()) {
return gw('fs.list', { path })
}
return window.hermesDesktop?.readDir?.(path) ?? Promise.resolve({ entries: [], error: 'no-bridge' })
}
export function fsReadFileText(path: string): Promise<HermesReadFileTextResult> {
if (isRemoteBackend()) {
return gw('fs.read_text', { path })
}
return window.hermesDesktop?.readFileText?.(path) ?? unavailable()
}
export async function fsReadFileDataUrl(path: string): Promise<string> {
if (isRemoteBackend()) {
return (await gw<{ dataUrl?: string }>('fs.read_data_url', { path })).dataUrl ?? unavailable()
}
return window.hermesDesktop?.readFileDataUrl?.(path) ?? unavailable()
}
export async function fsGitRoot(path: string): Promise<string | null> {
if (isRemoteBackend()) {
return (await gw<{ root?: string | null }>('fs.git_root', { path })).root ?? null
}
return window.hermesDesktop?.gitRoot?.(path) ?? null
}
export async function selectPaths(options: HermesSelectPathsOptions = {}): Promise<string[]> {
if (isRemoteBackend()) {
return openRemotePathPicker(options)
}
return (await window.hermesDesktop?.selectPaths?.(options)) ?? []
}
@@ -15,6 +15,7 @@ describe('desktop slash command curation', () => {
expect(isDesktopSlashSuggestion('/branch')).toBe(true)
expect(isDesktopSlashSuggestion('/skin')).toBe(true)
expect(isDesktopSlashSuggestion('/usage')).toBe(true)
expect(isDesktopSlashSuggestion('/version')).toBe(true)
expect(isDesktopSlashSuggestion('/yolo')).toBe(true)
expect(isDesktopSlashCommand('/yolo')).toBe(true)
})
@@ -43,6 +43,7 @@ const DESKTOP_COMMAND_META = [
['/title', 'Rename the current session'],
['/undo', 'Remove the last user/assistant exchange'],
['/usage', 'Show token usage for this session'],
['/version', 'Show Hermes Agent version'],
['/yolo', 'Toggle YOLO — auto-approve dangerous commands']
] as const
@@ -0,0 +1,147 @@
import { beforeEach, describe, expect, it } from 'vitest'
import {
$perSessionBrowse,
browseBackward,
browseForward,
deriveUserHistory,
isBrowsingHistory,
resetBrowseState
} from './composer-input-history'
const SESSION_A = 'session-a'
const SESSION_B = 'session-b'
// Newest-first user text ring, what the caller passes to browse*.
const HISTORY = ['third', 'second', 'first']
const MSG = (role: string, text: string) => ({ id: '', role, text })
beforeEach(() => {
$perSessionBrowse.set({})
})
describe('deriveUserHistory', () => {
it('returns user messages newest-first with empty/whitespace skipped', () => {
const messages = [
MSG('user', ' '),
MSG('assistant', 'hi'),
MSG('user', 'first'),
MSG('user', 'second')
]
expect(deriveUserHistory(messages, m => m.text)).toEqual(['second', 'first'])
})
})
describe('browseBackward', () => {
it('returns null when history is empty', () => {
expect(browseBackward(SESSION_A, '', [])).toBeNull()
})
it('returns the most recent entry on first press and saves the draft', () => {
const result = browseBackward(SESSION_A, 'unsent draft', HISTORY)
expect(result).toBe('third')
expect($perSessionBrowse.get()[SESSION_A]!.draftSnapshot).toBe('unsent draft')
})
it('moves to older entries on subsequent presses and stops at the oldest', () => {
expect(browseBackward(SESSION_A, '', HISTORY)).toBe('third')
expect(browseBackward(SESSION_A, '', HISTORY)).toBe('second')
expect(browseBackward(SESSION_A, '', HISTORY)).toBe('first')
expect(browseBackward(SESSION_A, '', HISTORY)).toBeNull()
})
it('uses caller-provided history, not a mirrored ring', () => {
// The store never owns the ring — the caller passes it every press.
// If the ring changes between presses (e.g. a new message was sent),
// the next press sees the updated ring and the cursor continues
// from where it was within it.
expect(browseBackward(SESSION_A, '', ['youngest', 'older'])).toBe('youngest')
// Caller added a new message; ring is now [brand-new, youngest, older].
// Cursor was at 0, next press advances to 1 -> "youngest".
expect(
browseBackward(SESSION_A, '', ['brand-new', 'youngest', 'older'])
).toBe('youngest')
// One more press -> "older".
expect(
browseBackward(SESSION_A, '', ['brand-new', 'youngest', 'older'])
).toBe('older')
})
})
describe('browseForward', () => {
it('returns null when not browsing', () => {
expect(browseForward(SESSION_A, HISTORY)).toBeNull()
})
it('moves toward the present', () => {
browseBackward(SESSION_A, 'draft', HISTORY) // cursor 0 -> 'third'
browseBackward(SESSION_A, '', HISTORY) // cursor 1 -> 'second'
expect(browseForward(SESSION_A, HISTORY)).toEqual({
text: 'third',
returnedToPresent: false
})
})
it('restores the saved draft and resets when reaching the present', () => {
browseBackward(SESSION_A, 'my original draft', HISTORY)
const result = browseForward(SESSION_A, HISTORY)
expect(result).toEqual({ text: 'my original draft', returnedToPresent: true })
expect(isBrowsingHistory(SESSION_A)).toBe(false)
})
})
describe('per-session isolation', () => {
it('tracks cursor and draft independently per session', () => {
browseBackward(SESSION_A, 'draft-a', HISTORY)
browseBackward(SESSION_A, '', HISTORY) // older
browseBackward(SESSION_B, 'draft-b', HISTORY)
const a = $perSessionBrowse.get()[SESSION_A]!
const b = $perSessionBrowse.get()[SESSION_B]!
expect(a.cursor).toBe(1)
expect(a.draftSnapshot).toBe('draft-a')
expect(b.cursor).toBe(0)
expect(b.draftSnapshot).toBe('draft-b')
})
})
describe('resetBrowseState', () => {
it('clears cursor and draft snapshot', () => {
browseBackward(SESSION_A, 'draft', HISTORY)
resetBrowseState(SESSION_A)
const s = $perSessionBrowse.get()[SESSION_A]!
expect(s.cursor).toBe(-1)
expect(s.draftSnapshot).toBe('')
})
})
describe('session switch behavior', () => {
it('resets the previous session cursor and lets the new session derive its own ring', () => {
// Session A: user browsed into the past
browseBackward(SESSION_A, '', HISTORY)
expect(isBrowsingHistory(SESSION_A)).toBe(true)
// Caller switches to session B; resets A's browse state
resetBrowseState(SESSION_A)
// Session B's ring is derived from B's messages, not A's
const sessionBMessages = [MSG('user', 'hello-b'), MSG('user', 'world-b')]
const sessionBHistory = deriveUserHistory(sessionBMessages, m => m.text)
expect(browseBackward(SESSION_B, '', sessionBHistory)).toBe('world-b')
expect(browseBackward(SESSION_B, '', sessionBHistory)).toBe('hello-b')
expect(isBrowsingHistory(SESSION_A)).toBe(false)
})
})
@@ -0,0 +1,158 @@
import { atom } from 'nanostores'
/**
* Per-session input history browse state.
*
* The user-text ring is **derived from the live session messages** on each
* keypress — it is not mirrored anywhere. This keeps a single source of truth
* and avoids the entire class of seeding/dedup bugs that come from trying to
* keep a parallel ring in sync with submit/queue/voice paths.
*
* We only persist the cursor and the saved draft:
* - `cursor` — index into the derived user-text ring (0 = newest, larger = older).
* `-1` means "not browsing".
* - `draftSnapshot` — the composer text at the moment the user started
* browsing, so ArrowDown back to the "present" restores it.
*/
export interface SessionBrowseState {
cursor: number
draftSnapshot: string
}
const $perSessionBrowse = atom<Record<string, SessionBrowseState>>({})
function ensure(sessionId: string): SessionBrowseState {
const all = { ...$perSessionBrowse.get() }
let s = all[sessionId]
if (!s) {
s = { cursor: -1, draftSnapshot: '' }
all[sessionId] = s
$perSessionBrowse.set(all)
}
return s
}
function persist() {
$perSessionBrowse.set({ ...$perSessionBrowse.get() })
}
function valid(sessionId: string | null | undefined): sessionId is string {
return typeof sessionId === 'string' && sessionId.length > 0
}
/**
* Derive the user-text ring (newest first) from session messages.
* The caller is responsible for providing already-session-scoped messages.
*/
export function deriveUserHistory<T extends { role: string }>(
messages: readonly T[],
getText: (m: T) => string
): string[] {
const out: string[] = []
for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i]!
if (m.role !== 'user') {continue}
const t = getText(m).trim()
if (t) {out.push(t)}
}
return out
}
/**
* Start browsing backward, or step to the next older entry.
* Returns the text to place in the composer, or null if already at the oldest
* entry (or the ring is empty).
*/
export function browseBackward(
sessionId: string | null | undefined,
currentDraft: string,
history: readonly string[]
): string | null {
if (!valid(sessionId) || history.length === 0) {
return null
}
const s = ensure(sessionId)
if (s.cursor === -1) {
s.draftSnapshot = currentDraft
s.cursor = 0
} else if (s.cursor < history.length - 1) {
s.cursor += 1
} else {
return null
}
persist()
return history[s.cursor]!
}
/**
* Browse forward toward the present. When reaching the "newest" entry the
* saved draft is restored and the cursor resets.
*/
export function browseForward(
sessionId: string | null | undefined,
history: readonly string[]
): { text: string; returnedToPresent: boolean } | null {
if (!valid(sessionId)) {
return null
}
const s = ensure(sessionId)
if (s.cursor === -1) {
return null
}
if (s.cursor > 0) {
s.cursor -= 1
persist()
return { text: history[s.cursor]!, returnedToPresent: false }
}
// At newest; moving forward restores the saved draft.
const text = s.draftSnapshot
s.cursor = -1
s.draftSnapshot = ''
persist()
return { text, returnedToPresent: true }
}
/** Clear browse state for a session (e.g. on session switch or new submit). */
export function resetBrowseState(sessionId: string | null | undefined) {
if (!valid(sessionId)) {
return
}
const all = { ...$perSessionBrowse.get() }
const existing = all[sessionId]
if (!existing) {return}
all[sessionId] = { cursor: -1, draftSnapshot: '' }
$perSessionBrowse.set(all)
}
/** True if the user is currently browsing history for this session. */
export function isBrowsingHistory(sessionId: string | null | undefined): boolean {
if (!valid(sessionId)) {
return false
}
const s = $perSessionBrowse.get()[sessionId]
return s ? s.cursor >= 0 : false
}
export { $perSessionBrowse }
+21 -10
View File
@@ -7,6 +7,7 @@ import {
dequeueQueuedPrompt,
enqueueQueuedPrompt,
getQueuedPrompts,
promoteQueuedPrompt,
removeQueuedPrompt,
shouldAutoDrainOnSettle,
updateQueuedPrompt,
@@ -63,6 +64,20 @@ describe('composer queue store', () => {
expect(getQueuedPrompts(SESSION_KEY).map(entry => entry.text)).toEqual(['draft two'])
})
it('promotes a queued entry to the front', () => {
const first = enqueueQueuedPrompt(SESSION_KEY, { attachments: [], text: 'first' })
const second = enqueueQueuedPrompt(SESSION_KEY, { attachments: [], text: 'second' })
const third = enqueueQueuedPrompt(SESSION_KEY, { attachments: [], text: 'third' })
expect(first).not.toBeNull()
expect(second).not.toBeNull()
expect(third).not.toBeNull()
expect(promoteQueuedPrompt(SESSION_KEY, third!.id)).toBe(true)
expect(getQueuedPrompts(SESSION_KEY).map(entry => entry.text)).toEqual(['third', 'first', 'second'])
expect(promoteQueuedPrompt(SESSION_KEY, third!.id)).toBe(false)
})
it('updates queued text and attachment snapshot', () => {
const first = enqueueQueuedPrompt(SESSION_KEY, { attachments: [attachment('f-1')], text: 'draft one' })
const editedAttachments = [attachment('f-2'), attachment('f-3', 'image')]
@@ -103,26 +118,22 @@ describe('composer queue store', () => {
})
describe('shouldAutoDrainOnSettle', () => {
const base = { isBusy: false, queueLength: 1, userInterrupted: false, wasBusy: true }
const base = { isBusy: false, queueLength: 1, wasBusy: true }
it('drains the next queued prompt when a turn completes naturally', () => {
it('drains the next queued prompt when a turn settles', () => {
expect(shouldAutoDrainOnSettle(base)).toBe(true)
})
it('does NOT drain when the user explicitly interrupted (Stop button)', () => {
// Regression: previously the Stop button "never worked" because cancelling
// a turn flipped busy → false and the queue immediately re-fired its head.
expect(shouldAutoDrainOnSettle({ ...base, userInterrupted: true })).toBe(false)
it('drains after an interrupt — the settle edge is the same', () => {
// Interrupting to reach a queued message is the point of the queue; the
// gateway emits the same settle whether the turn finished or was stopped.
expect(shouldAutoDrainOnSettle(base)).toBe(true)
})
it('does not drain when the queue is empty', () => {
expect(shouldAutoDrainOnSettle({ ...base, queueLength: 0 })).toBe(false)
})
it('does not drain when interrupted even if the queue is also empty', () => {
expect(shouldAutoDrainOnSettle({ ...base, queueLength: 0, userInterrupted: true })).toBe(false)
})
it('ignores steady busy state (no true → false transition)', () => {
expect(shouldAutoDrainOnSettle({ ...base, isBusy: true })).toBe(false)
})
+27 -14
View File
@@ -137,6 +137,26 @@ export const removeQueuedPrompt = (key: string | null | undefined, id: string):
return true
}
export const promoteQueuedPrompt = (key: string | null | undefined, id: string): boolean => {
const sid = sidOf(key)
if (!sid) {
return false
}
const queue = queueFor(sid)
const index = queue.findIndex(e => e.id === id)
if (index <= 0) {
return false
}
const entry = queue[index]!
writeSession(sid, [entry, ...queue.slice(0, index), ...queue.slice(index + 1)])
return true
}
export const updateQueuedPrompt = (
key: string | null | undefined,
id: string,
@@ -194,33 +214,26 @@ export interface AutoDrainSettleInput {
wasBusy: boolean
isBusy: boolean
queueLength: number
userInterrupted: boolean
}
/**
* Decide whether the composer should auto-drain the next queued prompt when a
* turn settles (busy transitions true → false).
*
* The queue auto-advances when a turn *completes naturally*, but must NOT
* advance when the user *explicitly interrupted* the turn via the Stop button.
* Conflating the two made the Stop button appear to "never work": cancelling a
* turn flipped busy → false, the queue immediately re-fired its head, and the
* agent kept running. An explicit interrupt means stop — the queued turns are
* preserved and the user resumes them deliberately (Cmd/Ctrl+K, Enter, or the
* per-row "send now" arrow).
* Queued turns always advance once the session is idle again, whether the turn
* finished naturally or the user interrupted it. Interrupting to reach a queued
* message is the whole point of the queue, so we never suppress the drain. The
* gateway guarantees a settle (message.complete + session.info running:false)
* even after an interrupt, so this single edge reliably advances the queue. To
* cancel queued turns the user deletes them from the panel.
*/
export const shouldAutoDrainOnSettle = (params: AutoDrainSettleInput): boolean => {
const { isBusy, queueLength, userInterrupted, wasBusy } = params
const { isBusy, queueLength, wasBusy } = params
// Only react to a true → false transition; ignore steady state and entry.
if (isBusy || !wasBusy) {
return false
}
// An explicit Stop suppresses exactly one auto-drain.
if (userInterrupted) {
return false
}
return queueLength > 0
}
@@ -0,0 +1,41 @@
import { atom } from 'nanostores'
import type { HermesSelectPathsOptions } from '@/global'
export interface RemotePathPickerRequest {
id: number
options: HermesSelectPathsOptions
resolve: (paths: string[]) => void
}
// Holds the currently open remote path-picker request, if any. The picker
// modal subscribes and resolves the promise when the user confirms or cancels.
// Used only when the desktop is connected to a remote gateway, where the native
// OS dialog (which browses the client machine) is the wrong filesystem.
export const $remotePathPicker = atom<RemotePathPickerRequest | null>(null)
let nextRequestId = 0
export function openRemotePathPicker(options: HermesSelectPathsOptions = {}): Promise<string[]> {
// Only one picker at a time; cancel any prior request.
const previous = $remotePathPicker.get()
if (previous) {
previous.resolve([])
}
return new Promise<string[]>(resolve => {
$remotePathPicker.set({ id: (nextRequestId += 1), options, resolve })
})
}
export function resolveRemotePathPicker(paths: string[]): void {
const request = $remotePathPicker.get()
if (!request) {
return
}
$remotePathPicker.set(null)
request.resolve(paths)
}
+4
View File
@@ -9015,6 +9015,10 @@ class HermesCLI:
elif canonical == "update":
if self._handle_update_command():
return False
elif canonical == "version":
from hermes_cli.main import _print_version_info
_print_version_info(check_updates=True)
elif canonical == "paste":
self._handle_paste_command()
elif canonical == "image":
+11
View File
@@ -7932,6 +7932,8 @@ class GatewayRunner:
return await self._handle_profile_command(event)
if _cmd_def_inner.name == "update":
return await self._handle_update_command(event)
if _cmd_def_inner.name == "version":
return await self._handle_version_command(event)
# Catch-all: any other recognized slash command reached the
# running-agent guard. Reject gracefully rather than falling
@@ -8288,6 +8290,9 @@ class GatewayRunner:
if canonical == "update":
return await self._handle_update_command(event)
if canonical == "version":
return await self._handle_version_command(event)
if canonical == "debug":
return await self._handle_debug_command(event)
@@ -10913,6 +10918,12 @@ class GatewayRunner:
return event.platform_update_id <= recorded_uid
async def _handle_version_command(self, event: MessageEvent) -> str:
"""Handle /version — show the running Hermes Agent version."""
from hermes_cli.banner import format_banner_version_label
return format_banner_version_label()
async def _handle_help_command(self, event: MessageEvent) -> str:
"""Handle /help command - list available commands."""
from hermes_cli.commands import gateway_help_lines
+2 -2
View File
@@ -14,8 +14,8 @@ Provides subcommands for:
import os
import sys
__version__ = "0.15.1"
__release_date__ = "2026.5.29"
__version__ = "0.16.0"
__release_date__ = "2026.6.5"
def _ensure_utf8():
+2
View File
@@ -216,6 +216,7 @@ COMMAND_REGISTRY: list[CommandDef] = [
CommandDef("image", "Attach a local image file for your next prompt", "Info",
cli_only=True, args_hint="<path>"),
CommandDef("update", "Update Hermes Agent to the latest version", "Info"),
CommandDef("version", "Show Hermes Agent version", "Info", aliases=("v",)),
CommandDef("debug", "Upload debug report (system info + logs) and get shareable links", "Info"),
# Exit
@@ -349,6 +350,7 @@ ACTIVE_SESSION_BYPASS_COMMANDS: frozenset[str] = frozenset(
"steer",
"stop",
"update",
"version",
}
)
+3 -1
View File
@@ -6647,7 +6647,9 @@ def cmd_import(args):
def _print_version_info(*, check_updates: bool = True) -> None:
print(f"Hermes Agent v{__version__} ({__release_date__})")
from hermes_cli.banner import format_banner_version_label
print(format_banner_version_label())
print(f"Project: {PROJECT_ROOT}")
# Show Python version
+1 -1
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "hermes-agent"
version = "0.15.1"
version = "0.16.0"
description = "The self-improving AI agent — creates skills from experience, improves them during use, and runs anywhere"
readme = "README.md"
# Upper bound is load-bearing, not cosmetic. uv resolves the project's
+2
View File
@@ -40,6 +40,8 @@ IGNORED_PATTERNS = [
re.compile(r"^Claude", re.IGNORECASE),
re.compile(r"^Copilot$", re.IGNORECASE),
re.compile(r"^Cursor(\s+Agent)?$", re.IGNORECASE),
re.compile(r"^Codex$", re.IGNORECASE),
re.compile(r"^github-advanced-security(\[bot\])?$", re.IGNORECASE),
re.compile(r"^GitHub\s*Actions?$", re.IGNORECASE),
re.compile(r"^github-actions(\[bot\])?$", re.IGNORECASE),
re.compile(r"^dependabot", re.IGNORECASE),
+10
View File
@@ -1167,6 +1167,16 @@ AUTHOR_MAP = {
"chenzeshi@live.com": "chen1749144759",
"mor.aleksandr@yahoo.com": "MorAlekss",
"276649498+ztexydt-cqh@users.noreply.github.com": "ztexydt-cqh",
# v0.16.0 additions
"teknium@nous.dev": "teknium1",
"alaamohanad169@gmail.com": "alaamohanad169-ship-it",
"archer@ouyangdeMac-mini.local": "Archerouyang", # display name 欧阳
"batosk2@gmail.com": "Sarbai", # git email for PR #33438 author (display: Брагарник Дмитро)
"info@aminvakil.com": "aminvakil",
"nikpolale@gmail.com": "polnikale",
"sarveshagl1327@gmail.com": "sarvesh1327", # salvaged via #38655
"sohyuanchin@gmail.com": "wysie",
"bedirhan@codeway.co": "bedirhancode",
"ash@users.noreply.github.com": "ash",
"andrewho.sf@gmail.com": "andrewhosf",
# April 2026 Honcho bug-fix consolidation (#15381)
+28
View File
@@ -0,0 +1,28 @@
"""Tests for the /version slash command."""
from unittest.mock import patch
from cli import HermesCLI
from hermes_cli.commands import GATEWAY_KNOWN_COMMANDS, resolve_command
def test_version_command_is_registered():
cmd = resolve_command("version")
assert cmd is not None
assert cmd.name == "version"
assert cmd.category == "Info"
assert resolve_command("v") is cmd
def test_version_is_gateway_known():
assert "version" in GATEWAY_KNOWN_COMMANDS
assert "v" in GATEWAY_KNOWN_COMMANDS
def test_process_command_version_prints_version_info():
cli_obj = HermesCLI.__new__(HermesCLI)
with patch("hermes_cli.main._print_version_info") as mock_print:
assert cli_obj.process_command("/version") is True
mock_print.assert_called_once_with(check_updates=True)
+12
View File
@@ -0,0 +1,12 @@
"""Tests for gateway /version command."""
import asyncio
from hermes_cli.banner import format_banner_version_label
def test_gateway_version_command_returns_release_line():
from gateway.run import GatewayRunner
result = asyncio.run(GatewayRunner._handle_version_command(None, None)) # type: ignore[arg-type]
assert result == format_banner_version_label()
+9 -3
View File
@@ -336,13 +336,19 @@ class TestSlackNativeSlashes:
)
def test_includes_aliases_as_first_class_slashes(self):
"""Aliases (/btw, /bg, /reset, /q) must be registered as standalone
slashes this is the whole point of native-slashes parity."""
"""Aliases (/btw, /bg, /reset) must be registered as standalone
slashes this is the whole point of native-slashes parity.
Note: Slack's manifest hard-caps slash commands at 50
(``_SLACK_MAX_SLASH_COMMANDS``). Canonical names win slots first,
then aliases, so the lowest-priority aliases can be clamped off
once the registry fills the cap (e.g. ``/q`` once ``/version``
landed). The surviving aliases below still prove alias parity;
anything dropped remains reachable via ``/hermes <command>``."""
names = {n for n, _d, _h in slack_native_slashes()}
assert "btw" in names
assert "bg" in names
assert "reset" in names
assert "q" in names
def test_telegram_parity(self):
"""Every Telegram bot command must be registerable on Slack too.
+160
View File
@@ -0,0 +1,160 @@
"""Regression tests for the rg/grep error guard in content search.
The guard in ``_search_with_rg`` / ``_search_with_grep`` had two defects on
``origin/main`` (see PR replacing #39710):
1. **Unreachable on a hard error.** Both methods pipe the search through
``| head`` with no ``pipefail``, so the pipeline reported head's exit code
(0), masking rg/grep's error code (2). The guard never fired, and the
error text merged into stdout by ``_exec`` (``stderr=subprocess.STDOUT``)
was parsed as bogus match lines instead of being surfaced.
2. **Would have nuked partial results if it ever did fire.** A broad
``exit_code == 2`` check discards real matches whenever rg/grep also hit a
non-fatal error (e.g. one unreadable file in a tree that otherwise
matched), which both tools signal with exit 2.
The fix adds ``set -o pipefail`` so the real exit code propagates, splits
tool diagnostics from match output by *shape*, and only surfaces an error
when exit==2 AND no usable match payload remains.
These tests drive the real methods through the real local terminal backend.
"""
import os
import shutil
import pytest
from tools.file_operations import (
ShellFileOperations,
_split_tool_diagnostics,
)
from tools.environments.local import LocalEnvironment
def _ops(root):
return ShellFileOperations(LocalEnvironment(cwd=str(root)), cwd=str(root))
@pytest.fixture
def match_tree(tmp_path):
"""A tree with several files all containing 'needle'."""
for i in range(5):
(tmp_path / f"f{i}.txt").write_text(f"needle line {i}\n")
return tmp_path
@pytest.fixture
def partial_error_tree(tmp_path):
"""A tree with matches plus one unreadable file (forces exit 2 + matches)."""
for i in range(4):
(tmp_path / f"f{i}.txt").write_text(f"needle line {i}\n")
sub = tmp_path / "sub"
sub.mkdir()
locked = sub / "locked.txt"
locked.write_text("needle in locked\n")
os.chmod(locked, 0o000)
yield tmp_path
os.chmod(locked, 0o755) # let pytest clean up tmp_path
# Run every test once per available backend method.
_METHODS = ["_search_with_grep"]
if shutil.which("rg"):
_METHODS.append("_search_with_rg")
def _search(ops, method, pattern, path, **kw):
fn = getattr(ops, method)
return fn(pattern, str(path), kw.get("file_glob"), kw.get("limit", 50),
kw.get("offset", 0), kw.get("output_mode", "content"),
kw.get("context", 0))
@pytest.mark.parametrize("method", _METHODS)
class TestSearchErrorGuard:
def test_happy_path_returns_matches(self, method, match_tree):
res = _search(_ops(match_tree), method, "needle", match_tree)
assert res.error is None
assert len(res.matches) == 5
def test_hard_error_is_surfaced(self, method, match_tree):
# An invalid regex makes rg/grep exit 2 with only diagnostics in
# stdout. The guard MUST surface it — not return empty matches.
res = _search(_ops(match_tree), method, "[", match_tree)
assert res.error is not None, "search error was silently swallowed"
assert "Search failed" in res.error
assert not res.matches
def test_partial_error_keeps_matches(self, method, partial_error_tree):
# rg/grep exit 2 because of the unreadable file, but the readable
# files matched. Those matches must be preserved, not discarded.
res = _search(_ops(partial_error_tree), method, "needle", partial_error_tree)
assert res.error is None, f"partial error wrongly surfaced: {res.error!r}"
assert len(res.matches) >= 4
def test_no_match_is_empty_not_error(self, method, match_tree):
res = _search(_ops(match_tree), method, "zzznomatchzzz", match_tree)
assert res.error is None
assert not res.matches
def test_truncation_no_false_error(self, method, tmp_path):
# head truncates a large result set. With pipefail, grep exits 141
# (SIGPIPE) on truncation; the strict `== 2` guard must ignore it.
big = tmp_path / "big.txt"
big.write_text("".join(f"needle {i}\n" for i in range(3000)))
res = _search(_ops(tmp_path), method, "needle", tmp_path, limit=5)
assert res.error is None, f"truncated success wrongly errored: {res.error!r}"
assert len(res.matches) == 5
def test_files_only_excludes_diagnostics(self, method, partial_error_tree):
# files_only mode must not list a diagnostic line as a fake file path.
res = _search(_ops(partial_error_tree), method, "needle",
partial_error_tree, output_mode="files_only")
assert res.error is None
assert res.files, "expected matching files"
assert all("Permission denied" not in f and "locked.txt" not in f
for f in res.files), f"diagnostic leaked into files: {res.files}"
def test_count_mode_with_partial_error(self, method, partial_error_tree):
res = _search(_ops(partial_error_tree), method, "needle",
partial_error_tree, output_mode="count")
assert res.error is None
assert res.total_count >= 4
class TestSplitToolDiagnostics:
"""Unit coverage for the shape-based diagnostic/payload splitter."""
def test_pure_error_has_empty_payload(self):
out = "rg: regex parse error:\n (?:[)\n ^\nerror: unclosed character class\n"
diagnostics, payload = _split_tool_diagnostics(out)
assert payload.strip() == ""
assert "regex parse error" in diagnostics
def test_partial_error_separates_matches(self):
out = ("rg: sub/locked.txt: Permission denied (os error 13)\n"
"a.txt:1:needle here\nb.txt:2:needle there\n")
diagnostics, payload = _split_tool_diagnostics(out)
assert "Permission denied" in diagnostics
assert "a.txt:1:needle here" in payload
assert "b.txt:2:needle there" in payload
assert "Permission denied" not in payload
def test_files_only_is_payload(self):
diagnostics, payload = _split_tool_diagnostics("src/a.py\nsrc/b.py\n")
assert diagnostics == ""
assert payload == "src/a.py\nsrc/b.py"
def test_count_lines_are_payload(self):
diagnostics, payload = _split_tool_diagnostics("src/a.py:3\nsrc/b.py:1\n")
assert diagnostics == ""
assert "src/a.py:3" in payload
def test_context_lines_and_separator_are_payload(self):
out = "a.py:5:hit\na.py-6-after\n--\nb.py:9:hit\n"
diagnostics, payload = _split_tool_diagnostics(out)
assert diagnostics == ""
assert "--" in payload
assert "a.py-6-after" in payload
+211
View File
@@ -0,0 +1,211 @@
"""Tests for the remote-browsing filesystem RPCs (fs.*) and image.attach_bytes.
These power the desktop app when it talks to a gateway on a remote host (e.g. a
VPS over tailscale): the Files sidebar and path pickers browse the gateway's
filesystem via fs.list / fs.read_text / fs.read_data_url / fs.git_root, and
locally-held images are pushed to the gateway via image.attach_bytes.
"""
from __future__ import annotations
import base64
import importlib
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
# 1x1 transparent PNG.
_PNG_1x1 = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
)
@pytest.fixture()
def hermes_home(tmp_path, monkeypatch):
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setenv("HERMES_HOME", str(home))
yield home
@pytest.fixture()
def server(hermes_home):
with patch.dict(
"sys.modules",
{
"hermes_cli.env_loader": MagicMock(),
"hermes_cli.banner": MagicMock(),
},
):
mod = importlib.import_module("tui_gateway.server")
yield mod
mod._sessions.clear()
mod._pending.clear()
mod._answers.clear()
mod._methods.clear()
importlib.reload(mod)
def _call(server, method: str, params: dict) -> dict:
return server.handle_request({"id": "1", "method": method, "params": params})
# ── fs.list ──────────────────────────────────────────────────────────
def test_fs_list_returns_sorted_entries(server, tmp_path):
work = tmp_path / "proj"
work.mkdir()
(work / "b_file.txt").write_text("x")
(work / "a_dir").mkdir()
(work / "node_modules").mkdir() # hidden by filter
resp = _call(server, "fs.list", {"path": str(work)})
result = resp["result"]
assert result["path"] == str(work.resolve())
names = [e["name"] for e in result["entries"]]
# Directories first, then files; node_modules filtered out.
assert names == ["a_dir", "b_file.txt"]
assert result["entries"][0]["isDirectory"] is True
assert result["entries"][1]["isDirectory"] is False
def test_fs_list_missing_dir_reports_error(server, tmp_path):
resp = _call(server, "fs.list", {"path": str(tmp_path / "nope")})
assert resp["result"]["entries"] == []
assert resp["result"]["error"] == "ENOENT"
# ── fs.read_text ─────────────────────────────────────────────────────
def test_fs_read_text_reads_file(server, tmp_path):
target = tmp_path / "hello.py"
target.write_text("print('hi')\n")
resp = _call(server, "fs.read_text", {"path": str(target)})
result = resp["result"]
assert result["text"] == "print('hi')\n"
assert result["language"] == "python"
assert result["binary"] is False
assert result["truncated"] is False
def test_fs_read_text_missing_file_errors(server, tmp_path):
resp = _call(server, "fs.read_text", {"path": str(tmp_path / "gone.txt")})
assert resp["error"]["code"] == 4016
def test_fs_read_text_flags_binary(server, tmp_path):
target = tmp_path / "blob.bin"
target.write_bytes(b"\x00\x01\x02\x03")
result = _call(server, "fs.read_text", {"path": str(target)})["result"]
assert result["binary"] is True
# ── fs.read_data_url ─────────────────────────────────────────────────
def test_fs_read_data_url_encodes_file(server, tmp_path):
target = tmp_path / "pixel.png"
target.write_bytes(_PNG_1x1)
result = _call(server, "fs.read_data_url", {"path": str(target)})["result"]
assert result["dataUrl"].startswith("data:image/png;base64,")
encoded = result["dataUrl"].split(",", 1)[1]
assert base64.b64decode(encoded) == _PNG_1x1
def test_fs_read_data_url_rejects_oversized(server, tmp_path):
target = tmp_path / "big.bin"
target.write_bytes(b"x")
# Patch the cap below the file size to exercise the guard deterministically.
with patch.object(server, "_FS_DATA_URL_MAX_BYTES", 0):
resp = _call(server, "fs.read_data_url", {"path": str(target)})
assert resp["error"]["code"] == 4017
# ── fs.git_root ──────────────────────────────────────────────────────
def test_fs_git_root_walks_up(server, tmp_path):
(tmp_path / ".git").mkdir()
nested = tmp_path / "a" / "b"
nested.mkdir(parents=True)
result = _call(server, "fs.git_root", {"path": str(nested)})["result"]
assert result["root"] == str(tmp_path.resolve())
def test_fs_git_root_none_when_absent(server, tmp_path):
result = _call(server, "fs.git_root", {"path": str(tmp_path)})["result"]
assert result["root"] is None
# ── image.attach_bytes ───────────────────────────────────────────────
@pytest.fixture()
def image_session(server):
"""A session that bypasses agent build so _sess() resolves cleanly."""
sid = "sid-img"
# Non-empty so _sess_nowait()'s truthiness check treats it as present.
server._sessions[sid] = {"image_counter": 0}
with patch.object(server, "_start_agent_build", lambda *a, **k: None), patch.object(
server, "_wait_agent", lambda s, rid: None
):
yield sid
def test_image_attach_bytes_writes_and_attaches(server, hermes_home, image_session):
data_url = "data:image/png;base64," + base64.b64encode(_PNG_1x1).decode()
resp = _call(
server,
"image.attach_bytes",
{"session_id": image_session, "data": data_url, "filename": "shot.png"},
)
result = resp["result"]
assert result["attached"] is True
saved = Path(result["path"])
assert saved.exists()
assert saved.read_bytes() == _PNG_1x1
assert saved.suffix == ".png"
# Lands under the gateway's HERMES_HOME, not the client.
assert str(saved).startswith(str(hermes_home / "images"))
assert server._sessions[image_session]["attached_images"] == [str(saved)]
def test_image_attach_bytes_infers_extension_from_mime(server, image_session):
data_url = "data:image/webp;base64," + base64.b64encode(_PNG_1x1).decode()
result = _call(
server,
"image.attach_bytes",
{"session_id": image_session, "data": data_url},
)["result"]
assert Path(result["path"]).suffix == ".webp"
def test_image_attach_bytes_rejects_empty(server, image_session):
resp = _call(
server,
"image.attach_bytes",
{"session_id": image_session, "data": ""},
)
assert resp["error"]["code"] == 4015
+106 -18
View File
@@ -285,6 +285,63 @@ class ExecuteResult:
exit_code: int = 0
def _split_tool_diagnostics(output: str) -> tuple[str, str]:
"""Separate rg/grep diagnostic lines from real match output.
``_exec`` runs commands with ``stderr=subprocess.STDOUT``, so error and
warning text from ``rg``/``grep`` is interleaved with match lines in a
single stream. Diagnostics must not be parsed as matches, and on a hard
failure they are the error message to surface.
Returns ``(diagnostics, payload)`` where ``payload`` contains only lines
that look like real search output a match line (``file:line:content``),
a files-only path, a count line, or a context line/separator. Everything
else (tool-prefixed errors, rg's multi-line ``regex parse error`` block
with its indented carets, blank lines) is folded into ``diagnostics``.
Classifying by *shape* rather than by error prefix is what lets the
exit-2 guard distinguish a pure failure (no usable payload surface the
error) from a partial failure (some files matched, one was unreadable
keep the matches). It also means error text can never be mis-parsed as a
match, a latent bug that predates the exit-code fix.
"""
diagnostics: list[str] = []
payload: list[str] = []
for line in output.split('\n'):
if not line.strip():
continue
# Tool diagnostics always carry the "<tool>: " prefix (e.g.
# "rg: <file>: Permission denied", "grep: Invalid regular
# expression", "rg: regex parse error:"). Check this first: a real
# match path can legitimately contain "-<digit>" (e.g. a tmp dir like
# ".../pytest-686/..."), which the shape regex would otherwise treat
# as a match line.
stripped = line.lstrip()
if stripped.startswith("rg: ") or stripped.startswith("grep: "):
diagnostics.append(line)
continue
# Otherwise classify by output shape. rg's regex-parse-error block
# also emits an indented caret line and a trailing "error: ..." line
# with no tool prefix; neither matches a search-output shape, so they
# fall through to diagnostics.
# match / count : "<path>:<...>" (has a colon; rg -c uses path:count)
# files_only : "<path>" (no whitespace, no leading colon)
# context line : "<path>-<line>-" or the "--" group separator
if line == "--" or _SEARCH_OUTPUT_RE.match(line):
payload.append(line)
else:
diagnostics.append(line)
return '\n'.join(diagnostics), '\n'.join(payload)
# A real rg/grep output line starts with a path token and is followed by a
# ``:`` (match/count), a ``-`` (context), or nothing (files_only). Tool
# diagnostics ("rg: ...", "grep: ...", "error: ...", indented carets) never
# match because the path token forbids whitespace and a leading tool prefix
# like "rg" is followed by ": " (space) which the negated class rejects.
_SEARCH_OUTPUT_RE = re.compile(r'^([A-Za-z]:)?[^\s:][^\n]*?[:\-]\d|^[^\s:][^\s]*$')
def _parse_search_context_line(line: str) -> tuple[str, int, str] | None:
"""Parse grep/rg context output in ``path-line-content`` format.
@@ -2038,24 +2095,40 @@ class ShellFileOperations(FileOperations):
fetch_limit = limit + offset + 200 if context > 0 else limit + offset
cmd_parts.extend(["|", "head", "-n", str(fetch_limit)])
cmd = " ".join(cmd_parts)
# `set -o pipefail` so rg's exit status propagates through `| head`.
# Without it the pipeline reports head's status (0), masking rg's
# error code (2) and making the guard below unreachable. rg handles a
# truncating head cleanly (exit 0 on SIGPIPE), so pipefail does not
# introduce false errors on a successful-but-truncated search.
cmd = "set -o pipefail; " + " ".join(cmd_parts)
result = self._exec(cmd, timeout=60)
# rg exit codes: 0=matches found, 1=no matches, 2=error
if result.exit_code == 2 and not result.stdout.strip():
error_msg = result.stderr.strip() if hasattr(result, 'stderr') and result.stderr else "Search error"
# _exec merges stderr into stdout (stderr=subprocess.STDOUT), so rg's
# diagnostic lines ("rg: <file>: <error>", "rg: regex parse error:")
# are interleaved with match output. Split them out: diagnostics must
# not be parsed as matches, and on a hard error they ARE the message.
diagnostics, payload = _split_tool_diagnostics(result.stdout)
# rg exit codes: 0=matches found, 1=no matches, 2=error. rg returns 2
# even on partial errors (e.g. one unreadable file in a tree that
# otherwise matched), so only surface an error when exit==2 AND no
# usable match payload remains. Otherwise we keep the real matches.
if result.exit_code == 2 and not payload.strip():
error_msg = diagnostics.strip() or result.stdout.strip() or "Search error"
return SearchResult(error=f"Search failed: {error_msg}", total_count=0)
# Parse the diagnostic-free payload so error text never becomes a match.
stdout = payload
# Parse results based on output mode
if output_mode == "files_only":
all_files = [f for f in result.stdout.strip().split('\n') if f]
all_files = [f for f in stdout.strip().split('\n') if f]
total = len(all_files)
page = all_files[offset:offset + limit]
return SearchResult(files=page, total_count=total)
elif output_mode == "count":
counts = {}
for line in result.stdout.strip().split('\n'):
for line in stdout.strip().split('\n'):
if ':' in line:
parts = line.rsplit(':', 1)
if len(parts) == 2:
@@ -2074,7 +2147,7 @@ class ShellFileOperations(FileOperations):
# so naive split(":") breaks. Use regex to handle both platforms.
_match_re = re.compile(r'^([A-Za-z]:)?(.*?):(\d+):(.*)$')
matches = []
for line in result.stdout.strip().split('\n'):
for line in stdout.strip().split('\n'):
if not line or line == "--":
continue
@@ -2138,23 +2211,38 @@ class ShellFileOperations(FileOperations):
fetch_limit = limit + offset + (200 if context > 0 else 0)
cmd_parts.extend(["|", "head", "-n", str(fetch_limit)])
cmd = " ".join(cmd_parts)
# `set -o pipefail` so grep's exit status propagates through `| head`
# (without it the pipeline reports head's 0, masking grep's error 2).
# A truncating head makes grep exit 141 (SIGPIPE) on an otherwise
# successful search; the strict `== 2` guard below ignores that, so
# pipefail does not turn truncated results into false errors.
cmd = "set -o pipefail; " + " ".join(cmd_parts)
result = self._exec(cmd, timeout=60)
# grep exit codes: 0=matches found, 1=no matches, 2=error
if result.exit_code == 2 and not result.stdout.strip():
error_msg = result.stderr.strip() if hasattr(result, 'stderr') and result.stderr else "Search error"
# _exec merges stderr into stdout, so grep's diagnostic lines
# ("grep: <file>: <error>") are interleaved with matches. Split them
# out so they're never parsed as matches and so a hard error has a
# clean message.
diagnostics, payload = _split_tool_diagnostics(result.stdout)
# grep exit codes: 0=matches found, 1=no matches, 2=error. grep
# returns 2 on partial errors (e.g. an unreadable file) even when
# other files matched, so only surface an error when exit==2 AND no
# usable match payload remains.
if result.exit_code == 2 and not payload.strip():
error_msg = diagnostics.strip() or result.stdout.strip() or "Search error"
return SearchResult(error=f"Search failed: {error_msg}", total_count=0)
stdout = payload
if output_mode == "files_only":
all_files = [f for f in result.stdout.strip().split('\n') if f]
all_files = [f for f in stdout.strip().split('\n') if f]
total = len(all_files)
page = all_files[offset:offset + limit]
return SearchResult(files=page, total_count=total)
elif output_mode == "count":
counts = {}
for line in result.stdout.strip().split('\n'):
for line in stdout.strip().split('\n'):
if ':' in line:
parts = line.rsplit(':', 1)
if len(parts) == 2:
@@ -2172,7 +2260,7 @@ class ShellFileOperations(FileOperations):
# so naive split(":") breaks. Use regex to handle both platforms.
_match_re = re.compile(r'^([A-Za-z]:)?(.*?):(\d+):(.*)$')
matches = []
for line in result.stdout.strip().split('\n'):
for line in stdout.strip().split('\n'):
if not line or line == "--":
continue
+243
View File
@@ -4980,6 +4980,249 @@ def _(rid, params: dict) -> dict:
return _err(rid, 5027, str(e))
_DATA_URL_MIME_EXT = {
"image/png": ".png",
"image/jpeg": ".jpg",
"image/jpg": ".jpg",
"image/gif": ".gif",
"image/webp": ".webp",
"image/bmp": ".bmp",
"image/tiff": ".tiff",
"image/svg+xml": ".svg",
"image/x-icon": ".ico",
"image/vnd.microsoft.icon": ".ico",
}
@method("image.attach_bytes")
def _(rid, params: dict) -> dict:
"""Attach an image uploaded as bytes (base64 / data URL).
Unlike ``image.attach`` (which resolves a path on the gateway host), this
writes the client-supplied bytes into ``$HERMES_HOME/images`` on the
gateway. Used by the desktop app when the gateway is remote (e.g. a VPS)
and the UI file picker yields a path that only exists on the client.
"""
import base64
import re
session, err = _sess(params, rid)
if err:
return err
raw = str(params.get("data", "") or "").strip()
if not raw:
return _err(rid, 4015, "data required")
from cli import _IMAGE_EXTENSIONS
mime = ""
payload = raw
m = re.match(r"^data:([^;,]*)(;base64)?,(.*)$", raw, re.DOTALL)
if m:
mime = (m.group(1) or "").strip().lower()
payload = m.group(3) or ""
try:
blob = base64.b64decode(payload, validate=False)
except Exception:
return _err(rid, 4016, "invalid image data")
if not blob:
return _err(rid, 4016, "empty image data")
ext = Path(str(params.get("filename", "") or "")).suffix.lower()
if ext not in _IMAGE_EXTENSIONS:
ext = _DATA_URL_MIME_EXT.get(mime, "")
if ext not in _IMAGE_EXTENSIONS:
ext = ".png"
session["image_counter"] = session.get("image_counter", 0) + 1
img_dir = _hermes_home / "images"
img_dir.mkdir(parents=True, exist_ok=True)
img_path = (
img_dir
/ f"upload_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{session['image_counter']}{ext}"
)
try:
img_path.write_bytes(blob)
except Exception as e:
session["image_counter"] = max(0, session["image_counter"] - 1)
return _err(rid, 5027, str(e))
session.setdefault("attached_images", []).append(str(img_path))
return _ok(
rid,
{
"attached": True,
"path": str(img_path),
"count": len(session["attached_images"]),
"text": f"[User attached image: {img_path.name}]",
**_image_meta(img_path),
},
)
# Filesystem browsing RPCs (fs.*) run on the GATEWAY host. The desktop app uses
# them when connected to a remote gateway (e.g. a VPS over tailscale) so the
# Files sidebar and path pickers browse the agent's filesystem rather than the
# client's. Shapes mirror the Electron `hermes:fs:*` IPC handlers so the same
# renderer components consume either source unchanged.
_FS_READDIR_HIDDEN = frozenset({
".git", ".hg", ".svn", ".cache", ".next", ".turbo", ".venv",
"__pycache__", "build", "dist", "node_modules", "target", "venv",
})
_FS_TEXT_READ_MAX_BYTES = 512 * 1024
_FS_DATA_URL_MAX_BYTES = 16 * 1024 * 1024
_FS_LANGUAGE_BY_EXT = {
".c": "c", ".conf": "ini", ".cpp": "cpp", ".css": "css", ".csv": "csv",
".go": "go", ".graphql": "graphql", ".h": "c", ".hpp": "cpp",
".html": "html", ".ini": "ini", ".java": "java", ".js": "javascript",
".json": "json", ".jsx": "jsx", ".kt": "kotlin", ".lua": "lua",
".md": "markdown", ".php": "php", ".py": "python", ".rb": "ruby",
".rs": "rust", ".sh": "shell", ".sql": "sql", ".swift": "swift",
".toml": "toml", ".ts": "typescript", ".tsx": "tsx", ".xml": "xml",
".yaml": "yaml", ".yml": "yaml",
}
def _fs_resolve(params: dict, *, default_to_cwd: bool = False) -> Path:
"""Resolve an fs.* path param on the gateway host.
Relative paths resolve against the session/terminal cwd (same base as path
completions). When ``default_to_cwd`` and no path is given, returns the cwd.
"""
raw = str(params.get("path", "") or "").strip()
base = _completion_cwd(params)
if not raw:
return Path(base)
expanded = os.path.expanduser(os.path.expandvars(raw))
p = Path(expanded)
if not p.is_absolute():
p = Path(base) / p
return p
def _fs_stat_file(rid, params: dict):
"""Resolve + stat a regular file. Returns (resolved, stat, None) or (None, None, err)."""
target = _fs_resolve(params)
try:
resolved = target.resolve()
st = resolved.stat()
except FileNotFoundError:
return None, None, _err(rid, 4016, f"file not found: {target}")
except OSError as e:
return None, None, _err(rid, 5027, str(e))
if not resolved.is_file():
return None, None, _err(rid, 4016, f"not a file: {resolved}")
return resolved, st, None
@method("fs.list")
def _(rid, params: dict) -> dict:
import errno as _errno
target = _fs_resolve(params, default_to_cwd=True)
try:
resolved = target.resolve()
except Exception:
resolved = target
try:
entries = []
with os.scandir(resolved) as it:
for entry in it:
if entry.name in _FS_READDIR_HIDDEN:
continue
try:
is_dir = entry.is_dir()
except OSError:
is_dir = False
entries.append(
{
"name": entry.name,
"path": str(Path(resolved) / entry.name),
"isDirectory": is_dir,
}
)
entries.sort(key=lambda e: (0 if e["isDirectory"] else 1, e["name"].lower()))
return _ok(rid, {"path": str(resolved), "entries": entries})
except OSError as e:
code = _errno.errorcode.get(getattr(e, "errno", None), "read-error")
return _ok(rid, {"path": str(resolved), "entries": [], "error": code})
@method("fs.read_text")
def _(rid, params: dict) -> dict:
import mimetypes
resolved, st, err = _fs_stat_file(rid, params)
if err:
return err
to_read = min(st.st_size, _FS_TEXT_READ_MAX_BYTES)
try:
with open(resolved, "rb") as f:
chunk = f.read(to_read)
except OSError as e:
return _err(rid, 5027, str(e))
binary = b"\x00" in chunk[:4096]
text = "" if binary else chunk.decode("utf-8", errors="replace")
mime = mimetypes.guess_type(str(resolved))[0] or "application/octet-stream"
return _ok(
rid,
{
"binary": binary,
"byteSize": st.st_size,
"language": _FS_LANGUAGE_BY_EXT.get(resolved.suffix.lower(), "text"),
"mimeType": mime,
"path": str(resolved),
"text": text,
"truncated": st.st_size > _FS_TEXT_READ_MAX_BYTES,
},
)
@method("fs.read_data_url")
def _(rid, params: dict) -> dict:
import base64
import mimetypes
resolved, st, err = _fs_stat_file(rid, params)
if err:
return err
if st.st_size > _FS_DATA_URL_MAX_BYTES:
return _err(rid, 4017, f"file too large: {resolved.name}")
try:
data = resolved.read_bytes()
except OSError as e:
return _err(rid, 5027, str(e))
mime = mimetypes.guess_type(str(resolved))[0] or "application/octet-stream"
b64 = base64.b64encode(data).decode("ascii")
return _ok(rid, {"path": str(resolved), "dataUrl": f"data:{mime};base64,{b64}"})
@method("fs.git_root")
def _(rid, params: dict) -> dict:
target = _fs_resolve(params, default_to_cwd=True)
try:
start = target.resolve()
if start.is_file():
start = start.parent
except Exception:
start = target
current = start
for _ in range(50):
try:
if (current / ".git").exists():
return _ok(rid, {"root": str(current)})
except OSError:
return _ok(rid, {"root": None})
parent = current.parent
if parent == current:
break
current = parent
return _ok(rid, {"root": None})
@method("image.detach")
def _(rid, params: dict) -> dict:
session, err = _sess(params, rid)
@@ -1114,6 +1114,43 @@ describe('createGatewayEventHandler', () => {
}
})
it('keepBusy interrupt holds busy until the gateway settles and suppresses the cancelled turns final_response', () => {
// Force-send: interrupt holds busy so the drain waits for the real settle
// instead of racing it (the race duplicated the bubble, leaked a "queued: …"
// note, and surfaced the cancelled turn's "Operation interrupted…" reply).
const appended: Msg[] = []
const ctx = buildCtx(appended)
ctx.gateway.gw.request = vi.fn(async () => ({ status: 'interrupted' }))
const onEvent = createGatewayEventHandler(ctx)
patchUiState({ sid: 'sess-1' })
onEvent({ payload: {}, type: 'message.start' } as any)
onEvent({ payload: { text: 'thinking…' }, type: 'reasoning.delta' } as any)
expect(getUiState().busy).toBe(true)
turnController.interruptTurn(
{ appendMessage: (msg: Msg) => appended.push(msg), gw: ctx.gateway.gw, sid: 'sess-1', sys: ctx.system.sys },
{ keepBusy: true }
)
// Held busy: the drain effect keys off busy→false, so it must not fire yet.
expect(getUiState().busy).toBe(true)
// The cancelled turn settles with a backend interrupted final_response.
const before = appended.length
onEvent({
payload: { text: 'Operation interrupted: waiting for model response (4.1s elapsed).' },
type: 'message.complete'
} as any)
// Settle flips busy false (the single drain edge) and the backend
// "Operation interrupted…" line is suppressed (not appended).
expect(getUiState().busy).toBe(false)
expect(appended.slice(before).some(m => typeof m.text === 'string' && m.text.includes('Operation interrupted'))).toBe(
false
)
})
it('persists an abandoned (timed-out) clarify into the transcript when the clarify tool completes', () => {
const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended))
+15 -2
View File
@@ -182,7 +182,12 @@ class TurnController {
resetFlowOverlays()
}
interruptTurn({ appendMessage, gw, sid, sys }: InterruptDeps) {
// `keepBusy` holds the session busy after interrupting so a queued message
// drains on the gateway's real settle edge (message.complete, suppressed
// while `interrupted`) instead of racing the still-unwinding turn — the race
// duplicated the user bubble, leaked a "queued: …" note, and surfaced the
// cancelled turn's "[interrupted]" reply.
interruptTurn({ appendMessage, gw, sid, sys }: InterruptDeps, opts: { keepBusy?: boolean } = {}) {
this.interrupted = true
gw.request<SessionInterruptResponse>('session.interrupt', { session_id: sid }).catch(() => {})
@@ -218,9 +223,17 @@ class TurnController {
sys('interrupted')
}
patchUiState({ status: 'interrupted' })
this.clearStatusTimer()
if (opts.keepBusy) {
// `idle()` already cleared busy; re-assert it so the drain waits for settle.
patchUiState({ busy: true, status: 'interrupting…' })
return
}
patchUiState({ status: 'interrupted' })
this.statusTimer = setTimeout(() => {
this.statusTimer = null
patchUiState({ status: 'ready' })
+19 -23
View File
@@ -220,25 +220,28 @@ export function useSubmission(opts: UseSubmissionOptions) {
// - 'steer' : inject into the current turn via session.steer; falls
// back to queue when steer is rejected (no agent / no
// tool window).
// - 'interrupt' (default): cancel the in-flight turn, then send the
// new text as a fresh prompt so it actually moves.
// - 'interrupt' (default): queue the text + interrupt with `keepBusy`; the
// busy→false settle edge drains it once (desktop parity).
// No optimistic send → no duplicate bubble / race note.
//
// `opts.fallbackToFront` controls whether a steer fallback re-inserts
// at the front of the queue (used by the queue-edit path to preserve
// a picked item's position); the mainline submit path always appends.
// `opts.fallbackToFront` re-inserts at the queue head (queue-edit picks keep
// their position); the mainline submit path appends.
const handleBusyInput = useCallback(
(full: string, opts: { fallbackToFront?: boolean } = {}) => {
const live = getUiState()
const mode = live.busyInputMode
const fallback = (note: string) => {
const enqueueText = () => {
if (opts.fallbackToFront) {
composerRefs.queueRef.current.unshift(full)
composerActions.syncQueue()
} else {
composerActions.enqueue(full)
}
}
const fallback = (note: string) => {
enqueueText()
sys(note)
}
@@ -260,25 +263,14 @@ export function useSubmission(opts: UseSubmissionOptions) {
return
}
// 'interrupt' (default): tear down the current turn, then send.
// `interruptTurn` fires `session.interrupt` without awaiting; if
// the gateway is still mid-response when `prompt.submit` lands,
// `send()`'s catch path re-queues with a "queued: ..." sys note
// (`isSessionBusyError`) — so a lost race degrades to queue
// semantics, not a dropped message.
// 'interrupt': queue + interrupt(keepBusy); the settle edge drains it once.
enqueueText()
if (live.sid) {
turnController.interruptTurn({ appendMessage, gw, sid: live.sid, sys })
turnController.interruptTurn({ appendMessage, gw, sid: live.sid, sys }, { keepBusy: true })
}
if (hasInterpolation(full)) {
patchUiState({ busy: true })
return interpolate(full, send)
}
send(full)
},
[appendMessage, composerActions, composerRefs, gw, interpolate, send, sys]
[appendMessage, composerActions, composerRefs, gw, sys]
)
const dispatchSubmission = useCallback(
@@ -380,7 +372,11 @@ export function useSubmission(opts: UseSubmissionOptions) {
lastEmptyAt.current = now
if (doubleTap && live.busy && live.sid) {
return turnController.interruptTurn({ appendMessage, gw, sid: live.sid, sys })
// Force-send: keep busy when a message is queued so the settle edge
// drains it once (no race). Empty queue = plain Stop → 'ready'.
const hasQueued = composerRefs.queueRef.current.length > 0
return turnController.interruptTurn({ appendMessage, gw, sid: live.sid, sys }, { keepBusy: hasQueued })
}
if (doubleTap && live.sid && composerRefs.queueRef.current.length) {
Generated
+1 -1
View File
@@ -1390,7 +1390,7 @@ wheels = [
[[package]]
name = "hermes-agent"
version = "0.15.1"
version = "0.16.0"
source = { editable = "." }
dependencies = [
{ name = "croniter" },