Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
3563b66
refactor(desktop): dock terminal under chat and simplify file rail
OutThisLife Jun 9, 2026
bfa311f
fix(desktop): make the terminal a resizable, themed side pane
OutThisLife Jun 9, 2026
b29c104
feat(desktop): show platform hotkey hints in the command palette
OutThisLife Jun 9, 2026
2973d3d
fix(desktop): drop the active check on the command-palette terminal item
OutThisLife Jun 9, 2026
82543e7
fix(desktop): remove active/check states from the command palette
OutThisLife Jun 9, 2026
c5c3988
fix(desktop): allow ⌥/Shift-drag selection over mouse-mode TUIs
OutThisLife Jun 9, 2026
4109fbb
feat(desktop): tell the in-pane agent it's embedded in the GUI
OutThisLife Jun 9, 2026
8631d5d
refactor(desktop): drop the redundant Ctrl+` terminal-toggle fallback
OutThisLife Jun 9, 2026
544a86b
fix(desktop): read live terminal selection for ⌘/Ctrl+L
OutThisLife Jun 9, 2026
0022e95
feat(desktop): make any agent aware it's in the Hermes desktop GUI
OutThisLife Jun 9, 2026
1e7435c
Merge branch 'main' of github.com:NousResearch/hermes-agent into bb/d…
OutThisLife Jun 9, 2026
88c48ee
feat(desktop): give the GUI agent a read_terminal tool
OutThisLife Jun 9, 2026
3ff656b
chore(desktop): trim read_terminal comments
OutThisLife Jun 9, 2026
4995ec1
Merge remote-tracking branch 'origin/main' into bb/desktop-terminal-b…
OutThisLife Jun 10, 2026
2fac595
feat(desktop): add a terminal toggle to the statusbar
OutThisLife Jun 10, 2026
36383cc
fix(desktop): relabel the terminal header button to what it does
OutThisLife Jun 10, 2026
f65f9b8
fix(desktop): move the terminal toggle next to the version item
OutThisLife Jun 10, 2026
b8e3587
feat(desktop): default the terminal to PowerShell on Windows
OutThisLife Jun 10, 2026
0e32602
feat(desktop): show a persistent divider on the terminal pane
OutThisLife Jun 10, 2026
b0377ff
refactor(desktop): resolve the terminal shell instead of hardcoding it
OutThisLife Jun 10, 2026
5ef5fa1
fix(desktop): repaint the terminal on light/dark switch
OutThisLife Jun 10, 2026
3aa1555
fix(desktop): match the terminal palette to VS Code Light+/Dark+
OutThisLife Jun 10, 2026
eb09e47
fix(desktop): tighten command palette search to substring matching
OutThisLife Jun 10, 2026
e08af43
fix(desktop): blend the terminal header into the skin surface
OutThisLife Jun 10, 2026
13d9b60
fix(desktop): re-resolve the terminal surface on skin switch
OutThisLife Jun 10, 2026
e428006
Merge remote-tracking branch 'origin/main' into bb/desktop-terminal-b…
OutThisLife Jun 10, 2026
19474d2
chore(desktop): drop redundant terminal theming header comment
OutThisLife Jun 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions agent/agent_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ def init_agent(
thinking_callback: callable = None,
reasoning_callback: callable = None,
clarify_callback: callable = None,
read_terminal_callback: callable = None,
step_callback: callable = None,
stream_delta_callback: callable = None,
interim_assistant_callback: callable = None,
Expand Down Expand Up @@ -417,6 +418,7 @@ def init_agent(
agent.thinking_callback = thinking_callback
agent.reasoning_callback = reasoning_callback
agent.clarify_callback = clarify_callback
agent.read_terminal_callback = read_terminal_callback
agent.step_callback = step_callback
agent.stream_delta_callback = stream_delta_callback
agent.interim_assistant_callback = interim_assistant_callback
Expand Down
13 changes: 12 additions & 1 deletion agent/agent_runtime_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ def _ra():


AGENT_RUNTIME_POST_HOOK_TOOL_NAMES = frozenset(
{"todo", "session_search", "memory", "clarify", "delegate_task"}
{"todo", "session_search", "memory", "clarify", "read_terminal", "delegate_task"}
)


Expand Down Expand Up @@ -1784,6 +1784,17 @@ def _execute(next_args: dict) -> Any:
),
next_args,
)
elif function_name == "read_terminal":
def _execute(next_args: dict) -> Any:
from tools.read_terminal_tool import read_terminal_tool as _read_terminal_tool
return _finish_agent_tool(
_read_terminal_tool(
start_line=next_args.get("start_line"),
count=next_args.get("count"),
callback=getattr(agent, "read_terminal_callback", None),
),
next_args,
)
elif function_name == "delegate_task":
def _execute(next_args: dict) -> Any:
return _finish_agent_tool(agent._dispatch_delegate_task(next_args), next_args)
Expand Down
16 changes: 16 additions & 0 deletions agent/prompt_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -885,6 +885,22 @@ def build_environment_hints() -> str:
f"`uname -a && whoami && pwd`."
)

# Hermes desktop GUI — any agent running under the desktop app should know
# it. HERMES_DESKTOP marks the backend powering the chat; HERMES_DESKTOP_TERMINAL
# marks a hermes launched in the embedded terminal pane. Both set by main.cjs.
_truthy = ("1", "true", "yes")
_in_desktop = (os.getenv("HERMES_DESKTOP") or "").strip().lower() in _truthy
_in_desktop_term = (os.getenv("HERMES_DESKTOP_TERMINAL") or "").strip().lower() in _truthy
if _in_desktop or _in_desktop_term:
_desktop_hint = "Runtime surface: you're running inside the Hermes desktop GUI app."
if _in_desktop_term:
_desktop_hint += (
" You're in its embedded terminal pane, beside the GUI chat — the user can "
"select your output (⌥-drag on macOS, Shift-drag elsewhere) and press "
"⌘/Ctrl+L to send it to the chat composer."
)
hints.append(_desktop_hint)

if is_wsl():
hints.append(WSL_ENVIRONMENT_HINT)

Expand Down
19 changes: 19 additions & 0 deletions agent/tool_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -1065,6 +1065,25 @@ def _execute(next_args: dict) -> Any:
tool_duration = time.time() - tool_start_time
if agent._should_emit_quiet_tool_messages():
agent._vprint(f" {_get_cute_tool_message_impl('clarify', function_args, tool_duration, result=function_result)}")
elif function_name == "read_terminal":
def _execute(next_args: dict) -> Any:
from tools.read_terminal_tool import read_terminal_tool as _read_terminal_tool
return _read_terminal_tool(
start_line=next_args.get("start_line"),
count=next_args.get("count"),
callback=getattr(agent, "read_terminal_callback", None),
)
function_result, function_args = _run_agent_tool_execution_middleware(
agent,
function_name=function_name,
function_args=function_args,
effective_task_id=effective_task_id,
tool_call_id=getattr(tool_call, "id", "") or "",
execute=_execute,
)
tool_duration = time.time() - tool_start_time
if agent._should_emit_quiet_tool_messages():
agent._vprint(f" {_get_cute_tool_message_impl('read_terminal', function_args, tool_duration, result=function_result)}")
elif function_name == "delegate_task":
tasks_arg = function_args.get("tasks")
if tasks_arg and isinstance(tasks_arg, list):
Expand Down
172 changes: 144 additions & 28 deletions apps/desktop/electron/main.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,11 @@ const {
} = require('./hardening.cjs')

let nodePty = null
let nodePtyDir = null

try {
nodePty = require('node-pty')
nodePtyDir = path.dirname(require.resolve('node-pty/package.json'))
} catch {
// Packaged builds set `files:` in package.json, which excludes node_modules
// from the asar. Workspace dedup also hoists this native dep to the repo
Expand All @@ -78,10 +80,12 @@ try {
const path = require('node:path')
const resourcesPath = process.resourcesPath
if (resourcesPath) {
nodePty = require(path.join(resourcesPath, 'native-deps', 'node-pty'))
nodePtyDir = path.join(resourcesPath, 'native-deps', 'node-pty')
nodePty = require(nodePtyDir)
}
} catch {
nodePty = null
nodePtyDir = null
}
}

Expand Down Expand Up @@ -3271,14 +3275,18 @@ function setAndPersistZoomLevel(window, zoomLevel) {
const next = clampZoomLevel(zoomLevel)
window.webContents.setZoomLevel(next)
window.webContents
.executeJavaScript(`try { localStorage.setItem(${JSON.stringify(ZOOM_STORAGE_KEY)}, ${JSON.stringify(String(next))}) } catch {}`)
.executeJavaScript(
`try { localStorage.setItem(${JSON.stringify(ZOOM_STORAGE_KEY)}, ${JSON.stringify(String(next))}) } catch {}`
)
.catch(error => rememberLog(`[zoom] persist failed: ${error?.message || error}`))
}

function restorePersistedZoomLevel(window) {
if (!window || window.isDestroyed()) return
window.webContents
.executeJavaScript(`(() => { try { return localStorage.getItem(${JSON.stringify(ZOOM_STORAGE_KEY)}) } catch { return null } })()`)
.executeJavaScript(
`(() => { try { return localStorage.getItem(${JSON.stringify(ZOOM_STORAGE_KEY)}) } catch { return null } })()`
)
.then(stored => {
if (stored == null || !window || window.isDestroyed()) return
const level = clampZoomLevel(Number(stored))
Expand Down Expand Up @@ -4137,9 +4145,7 @@ async function requestJsonForProfile(profile, path, method, body) {
const conn = await ensureBackend(profile)
const url = `${conn.baseUrl}${path}`
const opts = { method, body, timeoutMs: DEFAULT_FETCH_TIMEOUT_MS }
return conn.authMode === 'oauth'
? fetchJsonViaOauthSession(url, opts)
: fetchJson(url, conn.token, opts)
return conn.authMode === 'oauth' ? fetchJsonViaOauthSession(url, opts) : fetchJson(url, conn.token, opts)
}

async function probeRemoteAuthMode(rawUrl) {
Expand Down Expand Up @@ -4213,7 +4219,8 @@ async function testDesktopConnectionConfig(input = {}) {
// The block under test: a per-profile entry or the global remote. Coerce has
// already normalized the URL and resolved token inheritance for the scope.
const block = key ? config.profiles?.[key] || null : config.remote
const wantRemote = block?.mode === 'remote' || (!key && config.mode === 'remote') || (input.mode === 'remote' && block)
const wantRemote =
block?.mode === 'remote' || (!key && config.mode === 'remote') || (input.mode === 'remote' && block)
// ``/api/status`` is public on every gateway (no creds needed), so a
// reachability test works for local, token, and oauth modes alike — we only
// need a base URL. For a remote config we normalize the URL from the input;
Expand Down Expand Up @@ -4478,7 +4485,9 @@ async function spawnPoolBackend(profile, entry) {
rememberLog(`Hermes backend for profile "${profile}" exited (${signal || code})`)
backendPool.delete(profile)
if (!ready) {
rejectStart?.(new Error(`Hermes backend for profile "${profile}" exited before it became ready (${signal || code}).`))
rejectStart?.(
new Error(`Hermes backend for profile "${profile}" exited before it became ready (${signal || code}).`)
)
}
})

Expand Down Expand Up @@ -5248,17 +5257,19 @@ async function mergeRemoteProfileSessions(searchParams, remoteProfiles) {
let total = (Number(base.total) || 0) - remoteProfiles.reduce((n, p) => n + (profileTotals[p] || 0), 0)

// Swap each remote profile's stale local rows/total for the remote's real ones.
await Promise.all(remoteProfiles.map(async name => {
const list = await remoteSessionList(name, remoteParams).catch(() => null)
if (!list) {
delete profileTotals[name] // dead remote → drop its stale local total too
return
}
const rows = rowsOf(list)
merged.push(...rows)
profileTotals[name] = Number(list.total) || rows.length
total += profileTotals[name]
}))
await Promise.all(
remoteProfiles.map(async name => {
const list = await remoteSessionList(name, remoteParams).catch(() => null)
if (!list) {
delete profileTotals[name] // dead remote → drop its stale local total too
return
}
const rows = rowsOf(list)
merged.push(...rows)
profileTotals[name] = Number(list.total) || rows.length
total += profileTotals[name]
})
)

const recency = s => s?.[order] ?? s?.started_at ?? 0
merged.sort((a, b) => recency(b) - recency(a))
Expand Down Expand Up @@ -5516,22 +5527,121 @@ function findGitRoot(start) {
return null
}

function terminalShellCommand() {
if (IS_WINDOWS) {
return { args: [], command: process.env.COMSPEC || 'cmd.exe' }
function isExecutableFile(filePath) {
if (!filePath || !path.isAbsolute(filePath)) {
return false
}

const configuredShell = process.env.SHELL || ''
const shellPath =
(path.isAbsolute(configuredShell) && fs.existsSync(configuredShell) && configuredShell) ||
['/bin/zsh', '/bin/bash', '/bin/sh'].find(candidate => fs.existsSync(candidate)) ||
'/bin/sh'
try {
fs.accessSync(filePath, fs.constants.X_OK)
return true
} catch {
return false
}
}

function posixShellSpec(shellPath) {
const shellName = path.basename(shellPath)
const interactiveArgs = shellName.includes('zsh') || shellName.includes('bash') ? ['-il'] : ['-i']

return { args: interactiveArgs, command: shellPath, name: shellName }
}

let spawnHelperChecked = false

// node-pty execs a `spawn-helper` binary on macOS/Linux to launch the shell in a
// fresh session. The prebuilt that ships in node-pty's `prebuilds/` (and the
// staged copy under resources/native-deps) loses its execute bit through npm
// pack / electron-builder file collection, so every nodePty.spawn() dies with
// "posix_spawnp failed". Restore +x once, lazily, before the first spawn.
function ensureSpawnHelperExecutable() {
if (spawnHelperChecked || IS_WINDOWS || !nodePtyDir) {
return
}

spawnHelperChecked = true

const arch = process.arch
const candidates = [
path.join(nodePtyDir, 'build', 'Release', 'spawn-helper'),
path.join(nodePtyDir, 'prebuilds', `${process.platform}-${arch}`, 'spawn-helper')
]

for (const helper of candidates) {
try {
const mode = fs.statSync(helper).mode

if ((mode & 0o111) !== 0o111) {
fs.chmodSync(helper, mode | 0o755)
}
} catch {
// Not present in this layout (e.g. compiled build vs prebuild); skip.
}
}
}

// Windows PowerShell 5.1 ships at a fixed System32 path on every Windows box;
// prefer it only after PowerShell 7+ (`pwsh`).
function windowsPowerShellPath() {
const systemRoot = process.env.SystemRoot || process.env.windir || 'C:\\Windows'
const builtin = path.join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe')

return isExecutableFile(builtin) ? builtin : findOnPath('powershell.exe')
}

// Map a resolved shell path to its spawn spec, picking interactive flags by
// family: PowerShell drops its logo banner (so the prompt sits flush like the
// POSIX shells), cmd needs nothing, and everything else (zsh/bash/fish/sh…)
// gets POSIX interactive-login flags.
function shellSpecFor(shellPath) {
const name = path.basename(shellPath).toLowerCase()

if (name.startsWith('pwsh') || name.startsWith('powershell')) {
return { args: ['-NoLogo'], command: shellPath, name }
}

if (name.startsWith('cmd')) {
return { args: [], command: shellPath, name }
}

return posixShellSpec(shellPath)
}

// Best installed Windows shell: PowerShell 7+ (`pwsh`), then Windows PowerShell
// 5.1, then comspec/cmd.exe as the universal fallback.
function windowsShellSpec() {
const command =
findOnPath('pwsh.exe') || findOnPath('pwsh') || windowsPowerShellPath() || process.env.COMSPEC || 'cmd.exe'

return shellSpecFor(command)
}

// Resolve the interactive shell for the embedded terminal: an explicit user
// override wins, otherwise auto-detect the best one installed for the platform.
function terminalShellCommand() {
// HERMES_DESKTOP_SHELL is the cross-platform escape hatch (a path or a bare
// name on PATH); $SHELL is honored on POSIX, where it's the user's canonical
// choice, but ignored on Windows, where it's usually a stray MSYS/Git path
// node-pty can't spawn natively.
const override = (process.env.HERMES_DESKTOP_SHELL || (IS_WINDOWS ? '' : process.env.SHELL) || '').trim()

if (override) {
const resolved = isExecutableFile(override) ? override : findOnPath(override)

if (resolved) {
return shellSpecFor(resolved)
}
}

if (IS_WINDOWS) {
return windowsShellSpec()
}

const shellPath = ['/bin/zsh', '/bin/bash', '/bin/sh'].find(candidate => isExecutableFile(candidate))

return posixShellSpec(shellPath || '/bin/sh')
}

function safeTerminalCwd(cwd) {
const candidate = path.resolve(String(cwd || app.getPath('home')))

Expand Down Expand Up @@ -5569,6 +5679,11 @@ function terminalShellEnv() {
env.TERM_PROGRAM = 'Hermes'
env.TERM_PROGRAM_VERSION = app.getVersion()

// Let a hermes/--tui launched in this pane know it's embedded in the desktop
// GUI (build_environment_hints surfaces this). Distinct from HERMES_DESKTOP,
// which marks the agent *backend* and gates cron/gateway behavior.
env.HERMES_DESKTOP_TERMINAL = '1'

return env
}

Expand Down Expand Up @@ -5640,6 +5755,8 @@ ipcMain.handle('hermes:terminal:start', async (event, payload = {}) => {
throw new Error('PTY support is unavailable. Reinstall desktop dependencies and restart Hermes.')
}

ensureSpawnHelperExecutable()

const id = crypto.randomUUID()
const { args, command, name } = terminalShellCommand()
const cwd = safeTerminalCwd(payload?.cwd)
Expand Down Expand Up @@ -5962,7 +6079,6 @@ ipcMain.handle('hermes:uninstall:run', async (_event, payload) => {
return runDesktopUninstall(String(mode || ''))
})


app.whenReady().then(() => {
if (IS_MAC) {
Menu.setApplicationMenu(buildApplicationMenu())
Expand Down
6 changes: 4 additions & 2 deletions apps/desktop/src/app/chat/composer/controls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { AudioLines, Layers3, Loader2, Square, SteeringWheel } from '@/lib/icons'
import { formatCombo } from '@/lib/keybinds/combo'
import { cn } from '@/lib/utils'

import type { ConversationStatus } from './hooks/use-voice-conversation'
Expand Down Expand Up @@ -62,6 +63,7 @@ export function ComposerControls({
}) {
const { t } = useI18n()
const c = t.composer
const steerLabel = `${c.steer} (${formatCombo('mod+enter')})`

if (conversation.active) {
return <ConversationPill {...conversation} disabled={disabled} />
Expand All @@ -73,9 +75,9 @@ export function ComposerControls({
<div className="ml-auto flex shrink-0 items-center gap-(--composer-control-gap)">
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
{canSteer && (
<Tip label={c.steer}>
<Tip label={steerLabel}>
<Button
aria-label={c.steer}
aria-label={steerLabel}
className={GHOST_ICON_BTN}
disabled={disabled}
onClick={onSteer}
Expand Down
Loading
Loading