Compare commits

..

13 Commits

Author SHA1 Message Date
Brooklyn Nicholson
9d19cfbb78 refactor(desktop): extract app-version IPC from main.cjs into version-ipc.cjs
Ninth main.cjs cluster peel. The hermes:version handler moves verbatim into
electron/version-ipc.cjs behind a registerVersionIpc({ ipcMain,
resolveHermesVersion, resolveUpdateRoot }) registrar. The version + root resolvers
stay in the main process (shared with the About menu) and are injected.

Channel name unchanged → preload + renderer untouched. Adds
electron/version-ipc.test.cjs (surface + payload behavior).
2026-06-30 14:12:39 -05:00
Brooklyn Nicholson
150e023fe2 refactor(desktop): extract uninstall IPC from main.cjs into uninstall-ipc.cjs
Eighth main.cjs cluster peel. The two hermes:uninstall:* handlers (summary, run)
move verbatim into electron/uninstall-ipc.cjs behind a registerUninstallIpc({
ipcMain, getUninstallSummary, runDesktopUninstall }) registrar. The uninstall
engine stays in the main process and is injected.

Channel names unchanged → preload + renderer untouched. Adds
electron/uninstall-ipc.test.cjs (surface invariant + run mode normalization).
2026-06-30 14:11:50 -05:00
Brooklyn Nicholson
8a48d54193 refactor(desktop): extract VS Code Marketplace theme IPC from main.cjs into vscode-theme-ipc.cjs
Seventh main.cjs cluster peel. The two hermes:vscode-theme:* handlers (fetch,
search) move verbatim into electron/vscode-theme-ipc.cjs behind a
registerVscodeThemeIpc({ ipcMain }) registrar. Both delegate to the
vscode-marketplace sibling module, which the new module requires directly — so
the now-dead require in main.cjs is removed.

Channel names unchanged → preload + renderer untouched. Adds
electron/vscode-theme-ipc.test.cjs (surface invariant).
2026-06-30 13:34:12 -05:00
Brooklyn Nicholson
e167ed7bb1 refactor(desktop): extract project-dir + workspace settings IPC from main.cjs into project-dir-ipc.cjs
Sixth main.cjs cluster peel. The hermes:setting:defaultProjectDir:get/set/pick
handlers + hermes:workspace:sanitize move verbatim into
electron/project-dir-ipc.cjs behind a registerProjectDirIpc({ ipcMain,
readDefaultProjectDir, writeDefaultProjectDir, resolveHermesCwd,
sanitizeWorkspaceCwd }) registrar. The config readers/writers + cwd resolvers stay
in the main process and are injected.

Channel names unchanged → preload + renderer untouched. Adds
electron/project-dir-ipc.test.cjs (surface + set/sanitize behavior; get/pick touch
Electron app/dialog and are exercised in-app only).
2026-06-30 13:32:22 -05:00
Brooklyn Nicholson
0ed0c2d39f refactor(desktop): extract desktop-log IPC handlers from main.cjs into logs-ipc.cjs
Fifth main.cjs cluster peel. The two hermes:logs:* handlers (reveal, recent) move
verbatim into electron/logs-ipc.cjs behind a registerLogsIpc({ ipcMain,
DESKTOP_LOG_PATH, hermesLog, fileExists }) registrar. The log path and the
in-memory ring buffer live in the main process and are injected.

Channel names unchanged → preload + renderer untouched. Adds
electron/logs-ipc.test.cjs (surface invariant + recent-tail behavior).
2026-06-30 13:30:48 -05:00
Brooklyn Nicholson
c147270a1c refactor(desktop): extract auto-update IPC handlers from main.cjs into updates-ipc.cjs
Fourth main.cjs cluster peel. The four hermes:updates:* handlers (check, apply,
branch:get, branch:set) move verbatim into electron/updates-ipc.cjs behind a
registerUpdatesIpc({ ipcMain, checkUpdates, applyUpdates, readDesktopUpdateConfig,
writeDesktopUpdateConfig, DEFAULT_UPDATE_BRANCH }) registrar. The update engine
and on-disk update config stay in the main process and are injected.

Channel names unchanged → preload + renderer untouched. The interleaved
resolveHermesVersion/showAboutPanelFresh helpers + hermes:version handler are
shared with the menu and intentionally left in place. Adds
electron/updates-ipc.test.cjs (surface invariant + branch default fallback +
check-failure payload).
2026-06-30 13:29:38 -05:00
Brooklyn Nicholson
880f5837a1 refactor(desktop): extract terminal (PTY) IPC handlers from main.cjs into terminal-ipc.cjs
Third main.cjs cluster peel. The four hermes:terminal:* handlers (start, write,
resize, dispose) move verbatim into electron/terminal-ipc.cjs behind a
registerTerminalIpc({ ipcMain, nodePty, terminalSessions, ... }) registrar. The
PTY runtime, the shared session registry (also used by app-quit cleanup), and the
shell-spec/env/cwd helpers (deep Windows-PATH + app-path coupling) stay in the
main process and are injected, so the module owns only the request wiring.

Channel names unchanged → preload + renderer untouched. Adds
electron/terminal-ipc.test.cjs (surface invariant + unknown-session no-throw +
PTY-unavailable error).
2026-06-30 13:28:44 -05:00
Brooklyn Nicholson
f3ce17bf9e refactor(desktop): extract filesystem IPC handlers from main.cjs into fs-ipc.cjs
Second main.cjs cluster peel (after git-ipc). The six hermes:fs:* handlers
(readDir, gitRoot, reveal, rename, writeText, trash) move verbatim into
electron/fs-ipc.cjs behind a registerFsIpc({ ipcMain, directoryExists,
expandUserPath }) registrar — same injection pattern as registerGitIpc. Path
hardening / read-dir / git-root come from their sibling modules directly; the
two main-process path helpers are injected so the module stays side-effect free.

Channel names are unchanged, so preload + renderer are untouched. main.cjs drops
~85 lines; the now-dead fs-read-dir / git-root requires in main.cjs are removed.
Adds electron/fs-ipc.test.cjs asserting the hermes:fs:* surface by invariant.
2026-06-30 13:26:53 -05:00
Brooklyn Nicholson
b29bb6ef9d refactor(desktop): assert git-ipc surface by invariant, drop channel snapshot 2026-06-30 02:05:07 -05:00
Brooklyn Nicholson
025c8f0604 refactor(desktop): extract git IPC handlers from main.cjs into git-ipc.cjs
electron/main.cjs is the worst god file in the desktop app (~7.6k lines, 93 IPC
handlers across unrelated domains). Begin peeling cohesive handler clusters into
sibling modules — the established main.cjs pattern.

First cluster: the 19 git/worktree/review IPC handlers (all thin delegators to
the existing git-*-ops modules) move into a new electron/git-ipc.cjs exposing
registerGitIpc({ ipcMain, resolveGitBinary, resolveGhBinary }). The git/gh
binary resolvers stay in main.cjs (Windows PATH discovery) and are injected, so
the new module is pure. Channel names are unchanged, so preload/renderer are
unaffected.

Adds electron/git-ipc.test.cjs (wired into test:desktop:platforms) asserting
the full channel surface and resolver delegation. main.cjs: 7,617 -> 7,530.
2026-06-30 01:42:33 -05:00
brooklyn!
a81c5922a2 Merge pull request #55413 from NousResearch/bb/pre-stop-hook
feat(agent): add pre_verify hook and coding guidance config
2026-06-30 01:10:08 -05:00
Brooklyn Nicholson
821d9f709f feat(agent): add configurable coding_instructions
agent.coding_instructions (a string or list) is appended to the coding brief as
its own stable system block, so users can pin project-wide workflow rules
without editing the shipped brief. Coding-posture only and cache-safe (resolved
once per session; takes effect next session). Empty by default.
2026-06-30 00:59:59 -05:00
Brooklyn Nicholson
a10113658b feat(agent): add pre_verify hook and verify-on-stop coding guidance
Add a `pre_verify` user/plugin/shell hook fired once per turn when the agent
edited code and is about to finish, after the existing verify-on-stop guard. A
hook can keep the agent going one more turn (run a check, defer it, tidy the
diff) by returning {"action":"continue","message":...} (the Claude-Code Stop
shape {"decision":"block","reason":...} is accepted too). Hooks receive coding,
attempt, final_response, and sorted changed_paths so they can self-scope and
self-throttle; the path is bounded by agent.max_verify_nudges and preserves
message-role alternation.

Hermes still ships its default coding guidance (agent.verify_guidance, on by
default), but it now rides the evidence-based verify-on-stop missing-evidence
nudge instead of a separate default pre_verify continuation, so it costs no
extra model turn of its own. Guidance reuses the shared utils.is_truthy_value
parser rather than a local copy.
2026-06-30 00:59:29 -05:00
44 changed files with 1541 additions and 890 deletions

View File

@@ -353,6 +353,29 @@ def _coding_mode(config: Optional[dict[str, Any]]) -> str:
return "auto"
def _coding_instructions(config: Optional[dict[str, Any]]) -> str:
"""Standing operator instructions for the coding posture (config).
``agent.coding_instructions`` — a string or list of strings appended to the
coding brief as an extra stable system block, so a user can pin project-wide
coding-workflow rules (e.g. "for UI work don't run tsc/lint until I approve;
clean the diff before committing") without editing the shipped brief.
Cache-safe: resolved once per session into the stable system-prompt tier,
like the rest of the posture.
"""
if config is None:
try:
from hermes_cli.config import load_config
config = load_config()
except Exception:
config = {}
raw = ((config or {}).get("agent", {}) or {}).get("coding_instructions", "")
if isinstance(raw, (list, tuple)):
return "\n".join(str(item).strip() for item in raw if str(item).strip())
return str(raw or "").strip()
def _resolve_cwd(cwd: Optional[str | Path]) -> Path:
if cwd:
return Path(cwd).expanduser()
@@ -459,6 +482,9 @@ class RuntimeMode:
# only to steer edit-format guidance toward the model's family — see
# ``_edit_format_line``. Fixed for the session, so cache-safe.
model: Optional[str] = None
# Standing operator instructions (``agent.coding_instructions``), appended
# as an extra stable system block. Empty unless the user configures it.
instructions: str = ""
@property
def kind(self) -> str:
@@ -505,6 +531,10 @@ class RuntimeMode:
workspace = build_coding_workspace_block(self.cwd)
if workspace:
blocks.append(workspace)
# Operator instructions ride their own block so the brief (block 0) stays
# byte-stable and cache-keyed independently of user config.
if self.instructions:
blocks.append(f"Operator instructions (from config):\n{self.instructions}")
return blocks
def compact_skill_categories(self) -> frozenset[str]:
@@ -557,6 +587,7 @@ def resolve_runtime_mode(
cwd=resolved_cwd,
config_mode=mode,
model=model,
instructions=_coding_instructions(config),
)

View File

@@ -4810,6 +4810,55 @@ def run_conversation(
agent._verification_stop_nudges)
continue
# User verification-loop gate: when the agent edited code this
# turn, let a registered `pre_verify` hook (plugin/shell) keep it
# going one more turn. The shipped guidance is folded into the
# evidence-based verify-on-stop nudge above, so this path has no
# default continuation cost.
_verify_nudge2 = None
_edited = sorted(getattr(agent, "_turn_file_mutation_paths", set()) or [])
_attempt = getattr(agent, "_pre_verify_nudges", 0)
try:
from agent.verify_hooks import max_verify_nudges
from hermes_cli.plugins import get_pre_verify_continue_message, has_hook
if _edited and has_hook("pre_verify") and _attempt < max_verify_nudges():
# Posture is fixed for the session — resolve once + cache.
coding = getattr(agent, "_resolved_is_coding", None)
if coding is None:
from agent.coding_context import is_coding_context
coding = bool(is_coding_context(platform=getattr(agent, "platform", "") or ""))
agent._resolved_is_coding = coding
_verify_nudge2 = get_pre_verify_continue_message(
session_id=getattr(agent, "session_id", None) or "",
platform=getattr(agent, "platform", "") or "",
model=getattr(agent, "model", "") or "",
coding=coding,
attempt=_attempt,
final_response=final_response,
changed_paths=_edited,
)
except Exception:
logger.debug("pre_verify hook check failed", exc_info=True)
_verify_nudge2 = None
if _verify_nudge2:
agent._pre_verify_nudges = _attempt + 1
final_msg["finish_reason"] = "verify_hook_continue"
# Same alternation contract as verify-on-stop: keep the
# attempted answer in history, follow it with a synthetic
# user nudge, and don't surface the premature answer.
messages.append(final_msg)
messages.append({
"role": "user",
"content": _verify_nudge2,
"_pre_verify_synthetic": True,
})
agent._session_messages = messages
logger.debug("pre_verify nudge issued (attempt %d)",
agent._pre_verify_nudges)
continue
messages.append(final_msg)
_turn_exit_reason = f"text_response(finish_reason={finish_reason})"

View File

@@ -55,32 +55,10 @@ def hermes_client_tag() -> str:
return f"client=hermes-client-v{_hermes_version()}"
def conversation_tag(session_id: str) -> str:
"""Return the ``conversation=...`` tag for a Hermes session/conversation.
Format: ``conversation=<session_id>``. ``session_id`` is the canonical
Hermes conversation identifier (``AIAgent.session_id``) — the same value
used for ``~/.hermes/sessions/`` storage, session logs, and lineage.
Unlike the product/client tags this is high-cardinality (one value per
conversation), so it is only appended when a session id is actually
available — never as part of the always-on base tag set.
"""
return f"conversation={session_id}"
def nous_portal_tags(session_id: str | None = None) -> List[str]:
def nous_portal_tags() -> List[str]:
"""Return the canonical list of Nous Portal product tags.
Always returns a fresh list so callers can mutate it freely
(e.g. ``merged_extra.setdefault("tags", []).extend(nous_portal_tags())``).
When ``session_id`` is provided, a ``conversation=<session_id>`` tag is
appended so Portal usage can be attributed to a specific Hermes
conversation. Callers without a session id (e.g. the auxiliary client's
always-on base tags) omit it and get the canonical two-tag set.
"""
tags = ["product=hermes-agent", hermes_client_tag()]
if session_id:
tags.append(conversation_tag(session_id))
return tags
return ["product=hermes-agent", hermes_client_tag()]

View File

@@ -588,6 +588,17 @@ def _parse_response(event: str, stdout: str) -> Optional[Dict[str, Any]]:
return {"action": "block", "message": _block_message(data.get("reason"), data.get("message"))}
return None
if event == "pre_verify":
# "continue" (Hermes) / "block" (Claude-Code Stop: block the stop) both
# mean keep going; the message/reason is the follow-up for the model. A
# continue with no message is a no-op — let the turn finish.
action = str(data.get("action") or data.get("decision") or "").strip().lower()
if action in {"continue", "block"}:
message = data.get("message") or data.get("reason")
if isinstance(message, str) and message.strip():
return {"action": "continue", "message": message.strip()}
return None
context = data.get("context")
if isinstance(context, str) and context.strip():
return {"context": context}

View File

@@ -443,6 +443,7 @@ def build_turn_context(
agent._turn_failed_file_mutations = {}
agent._turn_file_mutation_paths = set()
agent._verification_stop_nudges = 0
agent._pre_verify_nudges = 0
# Record the execution thread so interrupt()/clear_interrupt() can scope
# the tool-level interrupt signal to THIS agent's thread only.

View File

@@ -273,6 +273,15 @@ def build_verify_on_stop_nudge(
if state == "passed":
return None
# Optional shipped coding guidance, only paid when this evidence gate fires.
try:
from agent.verify_hooks import coding_verify_guidance
guidance = coding_verify_guidance()
except Exception:
guidance = None
addendum = f"\n\n{guidance}" if guidance else ""
if verify_commands:
command_instruction = (
"Run the relevant verification command now ("
@@ -297,7 +306,8 @@ def build_verify_on_stop_nudge(
f"Verification status: {_status_detail(status)}\n\n"
f"Changed paths:\n{_format_changed_paths(paths)}\n\n"
f"{command_instruction} If verification is not possible, explain the "
"concrete blocker instead of claiming the work is fully verified.]"
"concrete blocker instead of claiming the work is fully verified."
f"{addendum}]"
)

69
agent/verify_hooks.py Normal file
View File

@@ -0,0 +1,69 @@
"""Verification-loop helpers for the ``pre_verify`` round-end gate.
When the agent has edited code and is about to verify/finish, the loop fires the
``pre_verify`` hook (user directives resolved by
:func:`hermes_cli.plugins.get_pre_verify_continue_message`). A directive keeps
the agent going one more turn — run a check, defer it, tidy the diff — instead of
stopping immediately.
The shipped coding guidance lives on the evidence-based verification-stop nudge
(``agent/verification_stop.py``), not as a second default stop gate. That keeps
the default token cost tied to the existing "missing verification evidence"
decision while preserving ``pre_verify`` for user/plugin policy.
"""
from __future__ import annotations
from typing import Any, Optional
from utils import is_truthy_value
DEFAULT_MAX_VERIFY_NUDGES = 3
# Shipped guidance appended to the verification-stop nudge when code lacks fresh
# verification evidence. Wording mirrors the user-facing "clean your work"
# workflow, but does not create its own extra model turn.
CODING_VERIFY_GUIDANCE = (
"[Coding] Before you run tests/linters or call this done: if this is "
"creative UI/visual work, hold off on tests and linters until the user says "
"they like the result or you're about to commit. And before every commit, "
"clean your work: keep it KISS/DRY, match the surrounding code style, and be "
"elitist, shorthand, clever, concise, efficient, and elegant."
)
def max_verify_nudges(config: Optional[dict[str, Any]] = None) -> int:
"""Bound on consecutive ``pre_verify`` continue directives per turn (>= 0)."""
agent_cfg = _agent_cfg(config)
raw = agent_cfg.get("max_verify_nudges")
try:
return max(0, int(raw))
except (TypeError, ValueError):
return DEFAULT_MAX_VERIFY_NUDGES
def coding_verify_guidance(config: Optional[dict[str, Any]] = None) -> Optional[str]:
"""Return the optional guidance appended to verification-stop nudges."""
if not is_truthy_value(_agent_cfg(config).get("verify_guidance", True), default=True):
return None
return CODING_VERIFY_GUIDANCE
def _agent_cfg(config: Optional[dict[str, Any]]) -> dict[str, Any]:
if config is None:
try:
from hermes_cli.config import load_config
config = load_config()
except Exception:
config = {}
agent_cfg = (config or {}).get("agent") if isinstance(config, dict) else None
return agent_cfg if isinstance(agent_cfg, dict) else {}
__all__ = [
"CODING_VERIFY_GUIDANCE",
"DEFAULT_MAX_VERIFY_NUDGES",
"coding_verify_guidance",
"max_verify_nudges",
]

View File

@@ -0,0 +1,105 @@
'use strict'
const { shell } = require('electron')
const fs = require('fs')
const path = require('path')
const { readDirForIpc } = require('./fs-read-dir.cjs')
const { gitRootForIpc } = require('./git-root.cjs')
const { resolveRequestedPathForIpc } = require('./hardening.cjs')
// Filesystem IPC: read-dir, git-root, reveal, rename, write-text, trash. Path
// hardening + `~` expansion + dir-existence checks live in the main process and
// are injected so this module stays side-effect free.
function registerFsIpc({ directoryExists, expandUserPath, ipcMain }) {
ipcMain.handle('hermes:fs:readDir', async (_event, dirPath) => readDirForIpc(dirPath))
ipcMain.handle('hermes:fs:gitRoot', async (_event, startPath) => gitRootForIpc(startPath))
// Reveal a path in the OS file manager (Finder / Explorer / Files).
ipcMain.handle('hermes:fs:reveal', async (_event, targetPath) => {
const target = String(targetPath || '').trim()
if (!target) {
return false
}
try {
shell.showItemInFolder(target)
return true
} catch {
return false
}
})
// Rename a file/folder in place. The renderer passes the existing path + a new
// base name; the destination is resolved in the SAME parent dir so a rename can
// never move the item elsewhere or traverse out. Rejects on a name collision.
ipcMain.handle('hermes:fs:rename', async (_event, targetPath, newName) => {
const src = String(targetPath || '').trim()
const name = String(newName || '').trim()
if (!src || !name || name === '.' || name === '..' || name.includes('/') || name.includes('\\')) {
throw new Error('Invalid rename')
}
const dst = path.join(path.dirname(src), name)
if (dst === src) {
return { path: dst }
}
if (fs.existsSync(dst)) {
throw new Error(`"${name}" already exists`)
}
await fs.promises.rename(src, dst)
return { path: dst }
})
// Write a small UTF-8 text file (e.g. a project's IDEA.md at creation). The path
// is hardened (resolveRequestedPathForIpc) and the parent must already exist —
// this never creates directory trees or escapes the allowed roots, and content
// is size-capped so it can't be abused as a bulk-write primitive.
ipcMain.handle('hermes:fs:writeText', async (_event, filePath, content) => {
const raw = String(filePath || '').trim()
if (!raw) {
throw new Error('Invalid path')
}
const text = String(content ?? '')
if (text.length > 1_000_000) {
throw new Error('Content too large')
}
const resolved = resolveRequestedPathForIpc(expandUserPath(raw), { purpose: 'Write text file' })
if (!directoryExists(path.dirname(resolved))) {
throw new Error('Parent directory does not exist')
}
await fs.promises.writeFile(resolved, text, 'utf8')
return { path: resolved }
})
// Move a file/folder to the OS trash (recoverable) — the VS Code "Delete"
// default. `shell.trashItem` routes to Finder/Explorer/Files trash per platform.
ipcMain.handle('hermes:fs:trash', async (_event, targetPath) => {
const target = String(targetPath || '').trim()
if (!target) {
throw new Error('Invalid delete')
}
await shell.trashItem(target)
return true
})
}
module.exports = { registerFsIpc }

View File

@@ -0,0 +1,49 @@
'use strict'
const assert = require('node:assert/strict')
const test = require('node:test')
const { registerFsIpc } = require('./fs-ipc.cjs')
function fakeIpcMain() {
const handlers = new Map()
return {
handlers,
handle(channel, handler) {
assert.ok(!handlers.has(channel), `duplicate registration for ${channel}`)
handlers.set(channel, handler)
}
}
}
test('registerFsIpc wires only hermes:fs:* channels, each to a handler fn', () => {
const ipcMain = fakeIpcMain()
registerFsIpc({ ipcMain, directoryExists: () => true, expandUserPath: p => p })
assert.ok(ipcMain.handlers.size >= 6, `expected the full fs surface, got ${ipcMain.handlers.size}`)
for (const [channel, handler] of ipcMain.handlers) {
assert.match(channel, /^hermes:fs:/, `${channel} is not an fs channel`)
assert.equal(typeof handler, 'function', `${channel} should register a handler`)
}
for (const channel of ['hermes:fs:readDir', 'hermes:fs:rename', 'hermes:fs:trash']) {
assert.ok(ipcMain.handlers.has(channel), `missing ${channel}`)
}
})
test('rename rejects names that traverse out of the parent dir', async () => {
const ipcMain = fakeIpcMain()
registerFsIpc({ ipcMain, directoryExists: () => true, expandUserPath: p => p })
for (const bad of ['..', '.', 'a/b', 'a\\b']) {
await assert.rejects(
() => ipcMain.handlers.get('hermes:fs:rename')({}, '/tmp/x', bad),
/Invalid rename/,
`"${bad}" should be rejected`
)
}
})

View File

@@ -0,0 +1,96 @@
'use strict'
const { scanGitRepos } = require('./git-repo-scan.cjs')
const {
fileDiffVsHead,
repoStatus,
reviewCommit,
reviewCommitContext,
reviewCreatePr,
reviewDiff,
reviewList,
reviewPush,
reviewRevParse,
reviewRevert,
reviewShipInfo,
reviewStage,
reviewUnstage
} = require('./git-review-ops.cjs')
const { addWorktree, listBranches, listWorktrees, removeWorktree, switchBranch } = require('./git-worktree-ops.cjs')
// Register the git/worktree/review IPC handlers. Thin delegators to the
// git-*-ops sibling modules; the git/gh binary resolution lives in the main
// process (Windows PATH discovery) and is injected so this module stays pure.
function registerGitIpc({ ipcMain, resolveGitBinary, resolveGhBinary }) {
// Git-driven worktree management ("Start work" flow). Errors surface to the
// renderer as rejected promises so it can toast a friendly message.
ipcMain.handle('hermes:git:worktreeList', async (_event, repoPath) => listWorktrees(repoPath, resolveGitBinary()))
ipcMain.handle('hermes:git:worktreeAdd', async (_event, repoPath, options) =>
addWorktree(repoPath, options || {}, resolveGitBinary())
)
ipcMain.handle('hermes:git:worktreeRemove', async (_event, repoPath, worktreePath, options) =>
removeWorktree(repoPath, worktreePath, options || {}, resolveGitBinary())
)
ipcMain.handle('hermes:git:branchSwitch', async (_event, repoPath, branch) =>
switchBranch(repoPath, branch, resolveGitBinary())
)
ipcMain.handle('hermes:git:branchList', async (_event, repoPath) => listBranches(repoPath, resolveGitBinary()))
// Compact repo status (branch, ahead/behind, change counts + files) for the
// composer coding rail. Returns null on a non-repo / remote backend so the rail
// hides cleanly rather than erroring.
ipcMain.handle('hermes:git:repoStatus', async (_event, repoPath) => repoStatus(repoPath, resolveGitBinary()))
// Codex-style review pane: list changed files for a scope, fetch one file's
// unified diff, and stage / unstage / revert. Reads return empty on failure;
// mutations reject so the renderer can toast.
ipcMain.handle('hermes:git:review:list', async (_event, repoPath, scope, baseRef) =>
reviewList(repoPath, scope, baseRef, resolveGitBinary())
)
ipcMain.handle('hermes:git:review:diff', async (_event, repoPath, filePath, scope, baseRef, staged) =>
reviewDiff(repoPath, filePath, scope, baseRef, staged, resolveGitBinary())
)
// Working-tree-vs-HEAD diff for one file (the preview's "show the diff" view).
ipcMain.handle('hermes:git:fileDiff', async (_event, repoPath, filePath) =>
fileDiffVsHead(repoPath, filePath, resolveGitBinary())
)
ipcMain.handle('hermes:git:review:stage', async (_event, repoPath, filePath) =>
reviewStage(repoPath, filePath ?? null, resolveGitBinary())
)
ipcMain.handle('hermes:git:review:unstage', async (_event, repoPath, filePath) =>
reviewUnstage(repoPath, filePath ?? null, resolveGitBinary())
)
ipcMain.handle('hermes:git:review:revert', async (_event, repoPath, filePath) =>
reviewRevert(repoPath, filePath ?? null, resolveGitBinary())
)
ipcMain.handle('hermes:git:review:revParse', async (_event, repoPath, ref) =>
reviewRevParse(repoPath, ref, resolveGitBinary())
)
ipcMain.handle('hermes:git:review:commit', async (_event, repoPath, message, push) =>
reviewCommit(repoPath, message, Boolean(push), resolveGitBinary())
)
ipcMain.handle('hermes:git:review:commitContext', async (_event, repoPath) =>
reviewCommitContext(repoPath, resolveGitBinary())
)
ipcMain.handle('hermes:git:review:push', async (_event, repoPath) => reviewPush(repoPath, resolveGitBinary()))
ipcMain.handle('hermes:git:review:shipInfo', async (_event, repoPath) => reviewShipInfo(repoPath, resolveGhBinary()))
ipcMain.handle('hermes:git:review:createPr', async (_event, repoPath) =>
reviewCreatePr(repoPath, resolveGitBinary(), resolveGhBinary())
)
// Repo-first project discovery: scan bounded roots for git repos (pure fs walk,
// no native addon). Never throws to the renderer — failures yield an empty list.
ipcMain.handle('hermes:git:scanRepos', async (_event, roots, options) => {
try {
return await scanGitRepos(roots || [], options || {})
} catch {
return []
}
})
}
module.exports = { registerGitIpc }

View File

@@ -0,0 +1,61 @@
'use strict'
const assert = require('node:assert/strict')
const test = require('node:test')
const { registerGitIpc } = require('./git-ipc.cjs')
function fakeIpcMain() {
const handlers = new Map()
return {
handlers,
handle(channel, handler) {
assert.ok(!handlers.has(channel), `duplicate registration for ${channel}`)
handlers.set(channel, handler)
}
}
}
test('registerGitIpc wires only hermes:git:* channels, each to a handler fn', () => {
const ipcMain = fakeIpcMain()
registerGitIpc({ ipcMain, resolveGitBinary: () => 'git', resolveGhBinary: () => 'gh' })
assert.ok(ipcMain.handlers.size >= 19, `expected the full git surface, got ${ipcMain.handlers.size}`)
for (const [channel, handler] of ipcMain.handlers) {
assert.match(channel, /^hermes:git:/, `${channel} is not a git channel`)
assert.equal(typeof handler, 'function', `${channel} should register a handler`)
}
// Spot-check the load-bearing channels across the worktree / review / scan groups.
for (const channel of ['hermes:git:worktreeList', 'hermes:git:review:commit', 'hermes:git:scanRepos']) {
assert.ok(ipcMain.handlers.has(channel), `missing ${channel}`)
}
})
test('handlers thread the injected resolver into the ops layer', async () => {
const ipcMain = fakeIpcMain()
const calls = []
registerGitIpc({
ipcMain,
resolveGitBinary: () => {
calls.push('git')
return 'git'
},
resolveGhBinary: () => 'gh'
})
// The resolver is consulted synchronously to build the ops call; whatever the
// ops layer does with a non-repo path is irrelevant to the wiring.
try {
await ipcMain.handlers.get('hermes:git:worktreeList')({}, '/definitely/not/a/repo')
} catch {
// ops layer may reject on a bad path — not what this test asserts.
}
assert.deepEqual(calls, ['git'])
})

View File

@@ -0,0 +1,27 @@
'use strict'
const { shell } = require('electron')
const fs = require('fs')
const path = require('path')
// Desktop-log IPC: reveal the log file in the OS file manager + return the
// recent in-memory tail. The log path, the in-memory ring buffer, and the
// file-exists probe live in the main process and are injected.
function registerLogsIpc({ DESKTOP_LOG_PATH, fileExists, hermesLog, ipcMain }) {
ipcMain.handle('hermes:logs:reveal', async () => {
try {
await fs.promises.mkdir(path.dirname(DESKTOP_LOG_PATH), { recursive: true })
if (!fileExists(DESKTOP_LOG_PATH)) {
await fs.promises.appendFile(DESKTOP_LOG_PATH, '')
}
shell.showItemInFolder(DESKTOP_LOG_PATH)
return { ok: true, path: DESKTOP_LOG_PATH }
} catch (error) {
return { ok: false, path: DESKTOP_LOG_PATH, error: error.message }
}
})
ipcMain.handle('hermes:logs:recent', async () => ({ path: DESKTOP_LOG_PATH, lines: hermesLog.slice(-200) }))
}
module.exports = { registerLogsIpc }

View File

@@ -0,0 +1,44 @@
'use strict'
const assert = require('node:assert/strict')
const test = require('node:test')
const { registerLogsIpc } = require('./logs-ipc.cjs')
function fakeIpcMain() {
const handlers = new Map()
return {
handlers,
handle(channel, handler) {
assert.ok(!handlers.has(channel), `duplicate registration for ${channel}`)
handlers.set(channel, handler)
}
}
}
test('registerLogsIpc wires only hermes:logs:* channels, each to a handler fn', () => {
const ipcMain = fakeIpcMain()
registerLogsIpc({ ipcMain, DESKTOP_LOG_PATH: '/tmp/desktop.log', fileExists: () => true, hermesLog: [] })
assert.deepEqual([...ipcMain.handlers.keys()].sort(), ['hermes:logs:recent', 'hermes:logs:reveal'])
for (const handler of ipcMain.handlers.values()) {
assert.equal(typeof handler, 'function')
}
})
test('logs:recent returns the injected path and the last 200 buffered lines', async () => {
const ipcMain = fakeIpcMain()
const hermesLog = Array.from({ length: 250 }, (_, i) => `line ${i}`)
registerLogsIpc({ ipcMain, DESKTOP_LOG_PATH: '/tmp/desktop.log', fileExists: () => true, hermesLog })
const res = await ipcMain.handlers.get('hermes:logs:recent')({})
assert.equal(res.path, '/tmp/desktop.log')
assert.equal(res.lines.length, 200)
assert.equal(res.lines[0], 'line 50')
assert.equal(res.lines.at(-1), 'line 249')
})

View File

@@ -41,12 +41,10 @@ const { adoptServedDashboardToken } = require('./dashboard-token.cjs')
const { waitForDashboardPortAnnouncement } = require('./backend-ready.cjs')
const { dashboardFallbackArgs, sourceDeclaresServe } = require('./backend-command.cjs')
const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs')
const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-marketplace.cjs')
const { buildDesktopBackendEnv, normalizeHermesHomeRoot } = require('./backend-env.cjs')
const { readWindowsUserEnvVar } = require('./windows-user-env.cjs')
const { readWslWindowsClipboardImage } = require('./wsl-clipboard-image.cjs')
const { nativeOverlayWidth: computeNativeOverlayWidth } = require('./titlebar-overlay-width.cjs')
const { readDirForIpc } = require('./fs-read-dir.cjs')
const { readLiveUpdateMarker } = require('./update-marker.cjs')
const {
resolveUnpackedRelease,
@@ -57,24 +55,15 @@ const {
collectRelaunchEnv,
buildRelaunchScript
} = require('./update-relaunch.cjs')
const { gitRootForIpc } = require('./git-root.cjs')
const { addWorktree, listBranches, listWorktrees, removeWorktree, switchBranch } = require('./git-worktree-ops.cjs')
const {
fileDiffVsHead,
repoStatus,
reviewCommit,
reviewCommitContext,
reviewCreatePr,
reviewDiff,
reviewList,
reviewPush,
reviewRevParse,
reviewRevert,
reviewShipInfo,
reviewStage,
reviewUnstage
} = require('./git-review-ops.cjs')
const { scanGitRepos } = require('./git-repo-scan.cjs')
const { registerGitIpc } = require('./git-ipc.cjs')
const { registerFsIpc } = require('./fs-ipc.cjs')
const { registerTerminalIpc } = require('./terminal-ipc.cjs')
const { registerUpdatesIpc } = require('./updates-ipc.cjs')
const { registerLogsIpc } = require('./logs-ipc.cjs')
const { registerProjectDirIpc } = require('./project-dir-ipc.cjs')
const { registerVscodeThemeIpc } = require('./vscode-theme-ipc.cjs')
const { registerUninstallIpc } = require('./uninstall-ipc.cjs')
const { registerVersionIpc } = require('./version-ipc.cjs')
const { OFFICIAL_REPO_HTTPS_URL, isOfficialSshRemote } = require('./update-remote.cjs')
const { resolveBehindCount, shouldCountCommits } = require('./update-count.cjs')
const { runRebuildWithRetry } = require('./update-rebuild.cjs')
@@ -1361,10 +1350,7 @@ function backendSupportsServe(backend) {
let supported = null
if (backend.root) {
try {
const src = fs.readFileSync(
path.join(backend.root, 'hermes_cli', 'subcommands', 'dashboard.py'),
'utf8'
)
const src = fs.readFileSync(path.join(backend.root, 'hermes_cli', 'subcommands', 'dashboard.py'), 'utf8')
supported = sourceDeclaresServe(src)
} catch {
supported = null // source unreadable — fall through to the probe
@@ -2292,9 +2278,7 @@ async function handOffWindowsBootstrapRecovery(reason) {
// --repair (full venv recreate) and drove reinstall loops. The venv interpreter
// and the bootstrap-complete marker are present earlier and are better signals.
const haveRealInstall =
fileExists(venvPython) ||
fileExists(venvHermes) ||
fileExists(path.join(updateRoot, '.hermes-bootstrap-complete'))
fileExists(venvPython) || fileExists(venvHermes) || fileExists(path.join(updateRoot, '.hermes-bootstrap-complete'))
const updaterArgs = haveRealInstall ? ['--update', '--branch', branch] : ['--repair', '--branch', branch]
await releaseBackendLockForUpdate(updateRoot)
@@ -6682,60 +6666,20 @@ ipcMain.handle('hermes:openPreviewInBrowser', async (_event, url) => {
// settings mount and seeds the value into the picker; writing back persists
// it via writeDefaultProjectDir so resolveHermesCwd picks it up on the next
// session spawn (no app restart needed).
ipcMain.handle('hermes:setting:defaultProjectDir:get', async () => ({
dir: readDefaultProjectDir(),
defaultLabel: app.getPath('home'),
resolvedCwd: resolveHermesCwd()
}))
ipcMain.handle('hermes:workspace:sanitize', async (_event, cwd) => sanitizeWorkspaceCwd(cwd))
ipcMain.handle('hermes:setting:defaultProjectDir:set', async (_event, dir) => {
const next = typeof dir === 'string' && dir.trim() ? dir.trim() : null
if (next) {
try {
fs.mkdirSync(next, { recursive: true })
} catch (error) {
throw new Error(`Could not create directory: ${error.message}`)
}
}
writeDefaultProjectDir(next)
return { dir: next }
})
ipcMain.handle('hermes:setting:defaultProjectDir:pick', async () => {
const result = await dialog.showOpenDialog({
title: 'Choose default project directory',
properties: ['openDirectory', 'createDirectory'],
defaultPath: readDefaultProjectDir() || app.getPath('home')
})
if (result.canceled || result.filePaths.length === 0) {
return { canceled: true, dir: null }
}
return { canceled: false, dir: result.filePaths[0] }
// Default-project-dir + workspace settings IPC lives in project-dir-ipc.cjs;
// config readers/writers + cwd resolvers are injected.
registerProjectDirIpc({
ipcMain,
readDefaultProjectDir,
resolveHermesCwd,
sanitizeWorkspaceCwd,
writeDefaultProjectDir
})
ipcMain.handle('hermes:fetchLinkTitle', (_event, url) => fetchLinkTitle(url))
ipcMain.handle('hermes:logs:reveal', async () => {
try {
await fs.promises.mkdir(path.dirname(DESKTOP_LOG_PATH), { recursive: true })
if (!fileExists(DESKTOP_LOG_PATH)) {
await fs.promises.appendFile(DESKTOP_LOG_PATH, '')
}
shell.showItemInFolder(DESKTOP_LOG_PATH)
return { ok: true, path: DESKTOP_LOG_PATH }
} catch (error) {
return { ok: false, path: DESKTOP_LOG_PATH, error: error.message }
}
})
ipcMain.handle('hermes:logs:recent', async () => ({ path: DESKTOP_LOG_PATH, lines: hermesLog.slice(-200) }))
// Desktop-log IPC lives in logs-ipc.cjs; log path + ring buffer are injected.
registerLogsIpc({ DESKTOP_LOG_PATH, fileExists, hermesLog, ipcMain })
function isExecutableFile(filePath) {
if (!filePath || !path.isAbsolute(filePath)) {
@@ -6919,257 +6863,36 @@ function disposeTerminalSession(id) {
return true
}
ipcMain.handle('hermes:fs:readDir', async (_event, dirPath) => readDirForIpc(dirPath))
// Filesystem IPC lives in fs-ipc.cjs; main-process path helpers are injected.
registerFsIpc({ ipcMain, directoryExists, expandUserPath })
ipcMain.handle('hermes:fs:gitRoot', async (_event, startPath) => gitRootForIpc(startPath))
// Git/worktree/review IPC lives in git-ipc.cjs; the git + gh binary resolvers
// stay here (Windows PATH discovery) and are injected into the registrar.
registerGitIpc({ ipcMain, resolveGitBinary, resolveGhBinary })
// Reveal a path in the OS file manager (Finder / Explorer / Files).
ipcMain.handle('hermes:fs:reveal', async (_event, targetPath) => {
const target = String(targetPath || '').trim()
if (!target) {
return false
}
try {
shell.showItemInFolder(target)
return true
} catch {
return false
}
// Terminal/PTY IPC lives in terminal-ipc.cjs; the PTY runtime, session
// registry, and shell helpers stay in the main process and are injected.
registerTerminalIpc({
disposeTerminalSession,
ensureSpawnHelperExecutable,
ipcMain,
nodePty,
safeTerminalCwd,
terminalChannel,
terminalSessions,
terminalShellCommand,
terminalShellEnv
})
// Rename a file/folder in place. The renderer passes the existing path + a new
// base name; the destination is resolved in the SAME parent dir so a rename can
// never move the item elsewhere or traverse out. Rejects on a name collision.
ipcMain.handle('hermes:fs:rename', async (_event, targetPath, newName) => {
const src = String(targetPath || '').trim()
const name = String(newName || '').trim()
if (!src || !name || name === '.' || name === '..' || name.includes('/') || name.includes('\\')) {
throw new Error('Invalid rename')
}
const dst = path.join(path.dirname(src), name)
if (dst === src) {
return { path: dst }
}
if (fs.existsSync(dst)) {
throw new Error(`"${name}" already exists`)
}
await fs.promises.rename(src, dst)
return { path: dst }
})
// Write a small UTF-8 text file (e.g. a project's IDEA.md at creation). The path
// is hardened (resolveRequestedPathForIpc) and the parent must already exist —
// this never creates directory trees or escapes the allowed roots, and content
// is size-capped so it can't be abused as a bulk-write primitive.
ipcMain.handle('hermes:fs:writeText', async (_event, filePath, content) => {
const raw = String(filePath || '').trim()
if (!raw) {
throw new Error('Invalid path')
}
const text = String(content ?? '')
if (text.length > 1_000_000) {
throw new Error('Content too large')
}
const resolved = resolveRequestedPathForIpc(expandUserPath(raw), { purpose: 'Write text file' })
if (!directoryExists(path.dirname(resolved))) {
throw new Error('Parent directory does not exist')
}
await fs.promises.writeFile(resolved, text, 'utf8')
return { path: resolved }
})
// Move a file/folder to the OS trash (recoverable) — the VS Code "Delete"
// default. `shell.trashItem` routes to Finder/Explorer/Files trash per platform.
ipcMain.handle('hermes:fs:trash', async (_event, targetPath) => {
const target = String(targetPath || '').trim()
if (!target) {
throw new Error('Invalid delete')
}
await shell.trashItem(target)
return true
})
// Git-driven worktree management ("Start work" flow). Errors surface to the
// renderer as rejected promises so it can toast a friendly message.
ipcMain.handle('hermes:git:worktreeList', async (_event, repoPath) => listWorktrees(repoPath, resolveGitBinary()))
ipcMain.handle('hermes:git:worktreeAdd', async (_event, repoPath, options) =>
addWorktree(repoPath, options || {}, resolveGitBinary())
)
ipcMain.handle('hermes:git:worktreeRemove', async (_event, repoPath, worktreePath, options) =>
removeWorktree(repoPath, worktreePath, options || {}, resolveGitBinary())
)
ipcMain.handle('hermes:git:branchSwitch', async (_event, repoPath, branch) =>
switchBranch(repoPath, branch, resolveGitBinary())
)
ipcMain.handle('hermes:git:branchList', async (_event, repoPath) => listBranches(repoPath, resolveGitBinary()))
// Compact repo status (branch, ahead/behind, change counts + files) for the
// composer coding rail. Returns null on a non-repo / remote backend so the rail
// hides cleanly rather than erroring.
ipcMain.handle('hermes:git:repoStatus', async (_event, repoPath) => repoStatus(repoPath, resolveGitBinary()))
// Codex-style review pane: list changed files for a scope, fetch one file's
// unified diff, and stage / unstage / revert. Reads return empty on failure;
// mutations reject so the renderer can toast.
ipcMain.handle('hermes:git:review:list', async (_event, repoPath, scope, baseRef) =>
reviewList(repoPath, scope, baseRef, resolveGitBinary())
)
ipcMain.handle('hermes:git:review:diff', async (_event, repoPath, filePath, scope, baseRef, staged) =>
reviewDiff(repoPath, filePath, scope, baseRef, staged, resolveGitBinary())
)
// Working-tree-vs-HEAD diff for one file (the preview's "show the diff" view).
ipcMain.handle('hermes:git:fileDiff', async (_event, repoPath, filePath) =>
fileDiffVsHead(repoPath, filePath, resolveGitBinary())
)
ipcMain.handle('hermes:git:review:stage', async (_event, repoPath, filePath) =>
reviewStage(repoPath, filePath ?? null, resolveGitBinary())
)
ipcMain.handle('hermes:git:review:unstage', async (_event, repoPath, filePath) =>
reviewUnstage(repoPath, filePath ?? null, resolveGitBinary())
)
ipcMain.handle('hermes:git:review:revert', async (_event, repoPath, filePath) =>
reviewRevert(repoPath, filePath ?? null, resolveGitBinary())
)
ipcMain.handle('hermes:git:review:revParse', async (_event, repoPath, ref) =>
reviewRevParse(repoPath, ref, resolveGitBinary())
)
ipcMain.handle('hermes:git:review:commit', async (_event, repoPath, message, push) =>
reviewCommit(repoPath, message, Boolean(push), resolveGitBinary())
)
ipcMain.handle('hermes:git:review:commitContext', async (_event, repoPath) =>
reviewCommitContext(repoPath, resolveGitBinary())
)
ipcMain.handle('hermes:git:review:push', async (_event, repoPath) => reviewPush(repoPath, resolveGitBinary()))
ipcMain.handle('hermes:git:review:shipInfo', async (_event, repoPath) => reviewShipInfo(repoPath, resolveGhBinary()))
ipcMain.handle('hermes:git:review:createPr', async (_event, repoPath) =>
reviewCreatePr(repoPath, resolveGitBinary(), resolveGhBinary())
)
// Repo-first project discovery: scan bounded roots for git repos (pure fs walk,
// no native addon). Never throws to the renderer — failures yield an empty list.
ipcMain.handle('hermes:git:scanRepos', async (_event, roots, options) => {
try {
return await scanGitRepos(roots || [], options || {})
} catch {
return []
}
})
ipcMain.handle('hermes:terminal:start', async (event, payload = {}) => {
if (!nodePty) {
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)
const cols = Math.max(2, Number.parseInt(String(payload?.cols || 80), 10) || 80)
const rows = Math.max(2, Number.parseInt(String(payload?.rows || 24), 10) || 24)
const ptyProcess = nodePty.spawn(command, args, {
cols,
cwd,
env: terminalShellEnv(),
name: 'xterm-256color',
rows
})
terminalSessions.set(id, { pty: ptyProcess, webContentsId: event.sender.id })
const send = (suffix, payload) => {
if (event.sender.isDestroyed()) {
return
}
event.sender.send(terminalChannel(id, suffix), payload)
}
ptyProcess.onData(data => send('data', data))
ptyProcess.onExit(({ exitCode, signal }) => {
terminalSessions.delete(id)
send('exit', { code: exitCode, signal: signal || null })
})
event.sender.once('destroyed', () => disposeTerminalSession(id))
return { cwd, id, shell: name }
})
ipcMain.handle('hermes:terminal:write', (_event, id, data) => {
const sessionInfo = terminalSessions.get(String(id || ''))
if (!sessionInfo) {
return false
}
sessionInfo.pty.write(String(data || ''))
return true
})
ipcMain.handle('hermes:terminal:resize', (_event, id, size = {}) => {
const sessionInfo = terminalSessions.get(String(id || ''))
if (!sessionInfo) {
return false
}
const cols = Math.max(2, Number.parseInt(String(size?.cols || 80), 10) || 80)
const rows = Math.max(2, Number.parseInt(String(size?.rows || 24), 10) || 24)
sessionInfo.pty.resize(cols, rows)
return true
})
ipcMain.handle('hermes:terminal:dispose', (_event, id) => disposeTerminalSession(String(id || '')))
ipcMain.handle('hermes:updates:check', async () =>
checkUpdates().catch(error => ({
supported: true,
branch: readDesktopUpdateConfig().branch,
error: 'check-failed',
message: error?.message || String(error),
fetchedAt: Date.now()
}))
)
ipcMain.handle('hermes:updates:apply', async (_event, payload) =>
applyUpdates(payload || {}).catch(error => ({
ok: false,
error: 'apply-failed',
message: error?.message || String(error)
}))
)
ipcMain.handle('hermes:updates:branch:get', async () => readDesktopUpdateConfig())
ipcMain.handle('hermes:updates:branch:set', async (_event, name) => {
const branch = typeof name === 'string' && name.trim() ? name.trim() : DEFAULT_UPDATE_BRANCH
writeDesktopUpdateConfig({ branch })
return { branch }
// Auto-update IPC lives in updates-ipc.cjs; the update engine + on-disk
// config stay in the main process and are injected.
registerUpdatesIpc({
applyUpdates,
checkUpdates,
DEFAULT_UPDATE_BRANCH,
ipcMain,
readDesktopUpdateConfig,
writeDesktopUpdateConfig
})
// Resolve the canonical Hermes version (the one `release.py` bumps in
@@ -7207,13 +6930,8 @@ function showAboutPanelFresh() {
app.showAboutPanel()
}
ipcMain.handle('hermes:version', async () => ({
appVersion: resolveHermesVersion(),
electronVersion: process.versions.electron,
nodeVersion: process.versions.node,
platform: process.platform,
hermesRoot: resolveUpdateRoot()
}))
// App-version IPC lives in version-ipc.cjs; the version + root resolvers are injected.
registerVersionIpc({ ipcMain, resolveHermesVersion, resolveUpdateRoot })
// ===========================================================================
// Uninstall — remove the Chat GUI (and optionally the agent / user data).
@@ -7406,18 +7124,11 @@ async function runDesktopUninstall(mode) {
return { ok: true, mode, willRemoveAppBundle: Boolean(removeBundle), scriptPath }
}
ipcMain.handle('hermes:uninstall:summary', async () => getUninstallSummary())
ipcMain.handle('hermes:uninstall:run', async (_event, payload) => {
const mode = payload && typeof payload === 'object' ? payload.mode : payload
return runDesktopUninstall(String(mode || ''))
})
// Uninstall IPC lives in uninstall-ipc.cjs; the uninstall engine is injected.
registerUninstallIpc({ getUninstallSummary, ipcMain, runDesktopUninstall })
// Download a VS Code Marketplace extension and return the raw color-theme JSON
// it contributes. No theme code is executed — we only read JSON from the .vsix.
ipcMain.handle('hermes:vscode-theme:fetch', async (_event, id) => fetchMarketplaceThemes(String(id || '')))
// Search the Marketplace for color-theme extensions (empty query = top installs).
ipcMain.handle('hermes:vscode-theme:search', async (_event, query) => searchMarketplaceThemes(String(query || ''), 20))
// VS Code Marketplace theme IPC lives in vscode-theme-ipc.cjs.
registerVscodeThemeIpc({ ipcMain })
// ---------------------------------------------------------------------------
// hermes:// deep links (e.g. hermes://blueprint/morning-brief?time=08:00).

View File

@@ -0,0 +1,55 @@
'use strict'
const { app, dialog } = require('electron')
const fs = require('fs')
// Default-project-directory + workspace-cwd settings IPC: read / write / native
// directory picker, plus workspace-cwd sanitize. The config readers/writers and
// cwd resolvers live in the main process and are injected.
function registerProjectDirIpc({
ipcMain,
readDefaultProjectDir,
resolveHermesCwd,
sanitizeWorkspaceCwd,
writeDefaultProjectDir
}) {
ipcMain.handle('hermes:setting:defaultProjectDir:get', async () => ({
dir: readDefaultProjectDir(),
defaultLabel: app.getPath('home'),
resolvedCwd: resolveHermesCwd()
}))
ipcMain.handle('hermes:workspace:sanitize', async (_event, cwd) => sanitizeWorkspaceCwd(cwd))
ipcMain.handle('hermes:setting:defaultProjectDir:set', async (_event, dir) => {
const next = typeof dir === 'string' && dir.trim() ? dir.trim() : null
if (next) {
try {
fs.mkdirSync(next, { recursive: true })
} catch (error) {
throw new Error(`Could not create directory: ${error.message}`)
}
}
writeDefaultProjectDir(next)
return { dir: next }
})
ipcMain.handle('hermes:setting:defaultProjectDir:pick', async () => {
const result = await dialog.showOpenDialog({
title: 'Choose default project directory',
properties: ['openDirectory', 'createDirectory'],
defaultPath: readDefaultProjectDir() || app.getPath('home')
})
if (result.canceled || result.filePaths.length === 0) {
return { canceled: true, dir: null }
}
return { canceled: false, dir: result.filePaths[0] }
})
}
module.exports = { registerProjectDirIpc }

View File

@@ -0,0 +1,63 @@
'use strict'
const assert = require('node:assert/strict')
const test = require('node:test')
const { registerProjectDirIpc } = require('./project-dir-ipc.cjs')
function fakeIpcMain() {
const handlers = new Map()
return {
handlers,
handle(channel, handler) {
assert.ok(!handlers.has(channel), `duplicate registration for ${channel}`)
handlers.set(channel, handler)
}
}
}
function deps(overrides = {}) {
return {
readDefaultProjectDir: () => '/projects',
resolveHermesCwd: () => '/cwd',
sanitizeWorkspaceCwd: cwd => `safe:${cwd}`,
writeDefaultProjectDir: () => {},
...overrides
}
}
test('registerProjectDirIpc wires the project-dir + workspace settings channels', () => {
const ipcMain = fakeIpcMain()
registerProjectDirIpc({ ipcMain, ...deps() })
assert.deepEqual([...ipcMain.handlers.keys()].sort(), [
'hermes:setting:defaultProjectDir:get',
'hermes:setting:defaultProjectDir:pick',
'hermes:setting:defaultProjectDir:set',
'hermes:workspace:sanitize'
])
})
// `get` / `pick` touch Electron's `app` / `dialog`, which are unavailable under
// `node --test` (require('electron') is a path stub), so they're exercised in-app
// only. The wiring of all four channels is covered by the surface test above.
test('set normalizes a blank dir to null and persists that (clears the override)', async () => {
const ipcMain = fakeIpcMain()
const writes = []
registerProjectDirIpc({ ipcMain, ...deps({ writeDefaultProjectDir: d => writes.push(d) }) })
assert.deepEqual(await ipcMain.handlers.get('hermes:setting:defaultProjectDir:set')({}, ' '), { dir: null })
assert.deepEqual(writes, [null])
})
test('workspace:sanitize delegates to the injected sanitizer', async () => {
const ipcMain = fakeIpcMain()
registerProjectDirIpc({ ipcMain, ...deps() })
assert.equal(await ipcMain.handlers.get('hermes:workspace:sanitize')({}, '/x'), 'safe:/x')
})

View File

@@ -0,0 +1,89 @@
'use strict'
const crypto = require('crypto')
// Terminal (PTY) IPC: start / write / resize / dispose. The PTY runtime, the
// shared session registry, and the shell-spec/env/cwd helpers all live in the
// main process (deep Windows-PATH + app-path coupling) and are injected, so this
// module only owns the request wiring.
function registerTerminalIpc({
disposeTerminalSession,
ensureSpawnHelperExecutable,
ipcMain,
nodePty,
safeTerminalCwd,
terminalChannel,
terminalSessions,
terminalShellCommand,
terminalShellEnv
}) {
ipcMain.handle('hermes:terminal:start', async (event, payload = {}) => {
if (!nodePty) {
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)
const cols = Math.max(2, Number.parseInt(String(payload?.cols || 80), 10) || 80)
const rows = Math.max(2, Number.parseInt(String(payload?.rows || 24), 10) || 24)
const ptyProcess = nodePty.spawn(command, args, {
cols,
cwd,
env: terminalShellEnv(),
name: 'xterm-256color',
rows
})
terminalSessions.set(id, { pty: ptyProcess, webContentsId: event.sender.id })
const send = (suffix, payload) => {
if (event.sender.isDestroyed()) {
return
}
event.sender.send(terminalChannel(id, suffix), payload)
}
ptyProcess.onData(data => send('data', data))
ptyProcess.onExit(({ exitCode, signal }) => {
terminalSessions.delete(id)
send('exit', { code: exitCode, signal: signal || null })
})
event.sender.once('destroyed', () => disposeTerminalSession(id))
return { cwd, id, shell: name }
})
ipcMain.handle('hermes:terminal:write', (_event, id, data) => {
const sessionInfo = terminalSessions.get(String(id || ''))
if (!sessionInfo) {
return false
}
sessionInfo.pty.write(String(data || ''))
return true
})
ipcMain.handle('hermes:terminal:resize', (_event, id, size = {}) => {
const sessionInfo = terminalSessions.get(String(id || ''))
if (!sessionInfo) {
return false
}
const cols = Math.max(2, Number.parseInt(String(size?.cols || 80), 10) || 80)
const rows = Math.max(2, Number.parseInt(String(size?.rows || 24), 10) || 24)
sessionInfo.pty.resize(cols, rows)
return true
})
ipcMain.handle('hermes:terminal:dispose', (_event, id) => disposeTerminalSession(String(id || '')))
}
module.exports = { registerTerminalIpc }

View File

@@ -0,0 +1,69 @@
'use strict'
const assert = require('node:assert/strict')
const test = require('node:test')
const { registerTerminalIpc } = require('./terminal-ipc.cjs')
function fakeIpcMain() {
const handlers = new Map()
return {
handlers,
handle(channel, handler) {
assert.ok(!handlers.has(channel), `duplicate registration for ${channel}`)
handlers.set(channel, handler)
}
}
}
function deps(overrides = {}) {
return {
disposeTerminalSession: () => true,
ensureSpawnHelperExecutable: () => {},
nodePty: { spawn: () => ({ onData() {}, onExit() {} }) },
safeTerminalCwd: c => c || '/',
terminalChannel: (id, suffix) => `hermes:terminal:${id}:${suffix}`,
terminalSessions: new Map(),
terminalShellCommand: () => ({ args: [], command: 'sh', name: 'sh' }),
terminalShellEnv: () => ({}),
...overrides
}
}
test('registerTerminalIpc wires only hermes:terminal:* channels, each to a handler fn', () => {
const ipcMain = fakeIpcMain()
registerTerminalIpc({ ipcMain, ...deps() })
assert.ok(ipcMain.handlers.size >= 4, `expected the full terminal surface, got ${ipcMain.handlers.size}`)
for (const [channel, handler] of ipcMain.handlers) {
assert.match(channel, /^hermes:terminal:/, `${channel} is not a terminal channel`)
assert.equal(typeof handler, 'function', `${channel} should register a handler`)
}
for (const channel of ['hermes:terminal:start', 'hermes:terminal:write', 'hermes:terminal:resize']) {
assert.ok(ipcMain.handlers.has(channel), `missing ${channel}`)
}
})
test('write / resize on an unknown session id return false instead of throwing', async () => {
const ipcMain = fakeIpcMain()
registerTerminalIpc({ ipcMain, ...deps() })
assert.equal(await ipcMain.handlers.get('hermes:terminal:write')({}, 'nope', 'x'), false)
assert.equal(await ipcMain.handlers.get('hermes:terminal:resize')({}, 'nope', {}), false)
})
test('start surfaces a clear error when the PTY runtime is unavailable', async () => {
const ipcMain = fakeIpcMain()
registerTerminalIpc({ ipcMain, ...deps({ nodePty: null }) })
await assert.rejects(
() => ipcMain.handlers.get('hermes:terminal:start')({ sender: {} }, {}),
/PTY support is unavailable/
)
})

View File

@@ -0,0 +1,14 @@
'use strict'
// Uninstall IPC: summarize what a desktop uninstall would remove + run it
// (GUI-only / lite / full). Both delegate to the main-process uninstall engine,
// which is injected.
function registerUninstallIpc({ getUninstallSummary, ipcMain, runDesktopUninstall }) {
ipcMain.handle('hermes:uninstall:summary', async () => getUninstallSummary())
ipcMain.handle('hermes:uninstall:run', async (_event, payload) => {
const mode = payload && typeof payload === 'object' ? payload.mode : payload
return runDesktopUninstall(String(mode || ''))
})
}
module.exports = { registerUninstallIpc }

View File

@@ -0,0 +1,51 @@
'use strict'
const assert = require('node:assert/strict')
const test = require('node:test')
const { registerUninstallIpc } = require('./uninstall-ipc.cjs')
function fakeIpcMain() {
const handlers = new Map()
return {
handlers,
handle(channel, handler) {
assert.ok(!handlers.has(channel), `duplicate registration for ${channel}`)
handlers.set(channel, handler)
}
}
}
test('registerUninstallIpc wires only hermes:uninstall:* channels, each to a handler fn', () => {
const ipcMain = fakeIpcMain()
registerUninstallIpc({ ipcMain, getUninstallSummary: async () => ({}), runDesktopUninstall: async () => ({}) })
assert.deepEqual([...ipcMain.handlers.keys()].sort(), ['hermes:uninstall:run', 'hermes:uninstall:summary'])
for (const handler of ipcMain.handlers.values()) {
assert.equal(typeof handler, 'function')
}
})
test('run normalizes both the {mode} object form and the bare-string form', async () => {
const ipcMain = fakeIpcMain()
const modes = []
registerUninstallIpc({
ipcMain,
getUninstallSummary: async () => ({}),
runDesktopUninstall: async mode => {
modes.push(mode)
return { mode }
}
})
await ipcMain.handlers.get('hermes:uninstall:run')({}, { mode: 'full' })
await ipcMain.handlers.get('hermes:uninstall:run')({}, 'lite')
await ipcMain.handlers.get('hermes:uninstall:run')({}, null)
assert.deepEqual(modes, ['full', 'lite', ''])
})

View File

@@ -0,0 +1,41 @@
'use strict'
// Auto-update IPC: check / apply / branch get+set. The update engine
// (checkUpdates/applyUpdates) and the on-disk update config live in the main
// process and are injected, so this module owns only the request wiring.
function registerUpdatesIpc({
applyUpdates,
checkUpdates,
DEFAULT_UPDATE_BRANCH,
ipcMain,
readDesktopUpdateConfig,
writeDesktopUpdateConfig
}) {
ipcMain.handle('hermes:updates:check', async () =>
checkUpdates().catch(error => ({
supported: true,
branch: readDesktopUpdateConfig().branch,
error: 'check-failed',
message: error?.message || String(error),
fetchedAt: Date.now()
}))
)
ipcMain.handle('hermes:updates:apply', async (_event, payload) =>
applyUpdates(payload || {}).catch(error => ({
ok: false,
error: 'apply-failed',
message: error?.message || String(error)
}))
)
ipcMain.handle('hermes:updates:branch:get', async () => readDesktopUpdateConfig())
ipcMain.handle('hermes:updates:branch:set', async (_event, name) => {
const branch = typeof name === 'string' && name.trim() ? name.trim() : DEFAULT_UPDATE_BRANCH
writeDesktopUpdateConfig({ branch })
return { branch }
})
}
module.exports = { registerUpdatesIpc }

View File

@@ -0,0 +1,76 @@
'use strict'
const assert = require('node:assert/strict')
const test = require('node:test')
const { registerUpdatesIpc } = require('./updates-ipc.cjs')
function fakeIpcMain() {
const handlers = new Map()
return {
handlers,
handle(channel, handler) {
assert.ok(!handlers.has(channel), `duplicate registration for ${channel}`)
handlers.set(channel, handler)
}
}
}
function deps(overrides = {}) {
return {
applyUpdates: async () => ({ ok: true }),
checkUpdates: async () => ({ supported: true }),
DEFAULT_UPDATE_BRANCH: 'main',
readDesktopUpdateConfig: () => ({ branch: 'main' }),
writeDesktopUpdateConfig: () => {},
...overrides
}
}
test('registerUpdatesIpc wires only hermes:updates:* channels, each to a handler fn', () => {
const ipcMain = fakeIpcMain()
registerUpdatesIpc({ ipcMain, ...deps() })
assert.ok(ipcMain.handlers.size >= 4, `expected the full updates surface, got ${ipcMain.handlers.size}`)
for (const [channel, handler] of ipcMain.handlers) {
assert.match(channel, /^hermes:updates:/, `${channel} is not an updates channel`)
assert.equal(typeof handler, 'function', `${channel} should register a handler`)
}
for (const channel of ['hermes:updates:check', 'hermes:updates:apply', 'hermes:updates:branch:set']) {
assert.ok(ipcMain.handlers.has(channel), `missing ${channel}`)
}
})
test('branch:set falls back to the default branch for blank input and persists it', async () => {
const ipcMain = fakeIpcMain()
const writes = []
registerUpdatesIpc({ ipcMain, ...deps({ writeDesktopUpdateConfig: c => writes.push(c) }) })
assert.deepEqual(await ipcMain.handlers.get('hermes:updates:branch:set')({}, ' '), { branch: 'main' })
assert.deepEqual(await ipcMain.handlers.get('hermes:updates:branch:set')({}, 'dev'), { branch: 'dev' })
assert.deepEqual(writes, [{ branch: 'main' }, { branch: 'dev' }])
})
test('check swallows engine failures into a structured error payload', async () => {
const ipcMain = fakeIpcMain()
registerUpdatesIpc({
ipcMain,
...deps({
checkUpdates: async () => {
throw new Error('network down')
}
})
})
const res = await ipcMain.handlers.get('hermes:updates:check')({})
assert.equal(res.error, 'check-failed')
assert.equal(res.message, 'network down')
assert.equal(res.branch, 'main')
})

View File

@@ -0,0 +1,17 @@
'use strict'
// App-version IPC: report the canonical Hermes version (resolved from the source
// tree, falling back to the Electron app version) alongside the Electron/Node
// runtime versions + the resolved Hermes root. The version + root resolvers live
// in the main process and are injected.
function registerVersionIpc({ ipcMain, resolveHermesVersion, resolveUpdateRoot }) {
ipcMain.handle('hermes:version', async () => ({
appVersion: resolveHermesVersion(),
electronVersion: process.versions.electron,
nodeVersion: process.versions.node,
platform: process.platform,
hermesRoot: resolveUpdateRoot()
}))
}
module.exports = { registerVersionIpc }

View File

@@ -0,0 +1,41 @@
'use strict'
const assert = require('node:assert/strict')
const test = require('node:test')
const { registerVersionIpc } = require('./version-ipc.cjs')
function fakeIpcMain() {
const handlers = new Map()
return {
handlers,
handle(channel, handler) {
assert.ok(!handlers.has(channel), `duplicate registration for ${channel}`)
handlers.set(channel, handler)
}
}
}
test('registerVersionIpc wires hermes:version to a handler fn', () => {
const ipcMain = fakeIpcMain()
registerVersionIpc({ ipcMain, resolveHermesVersion: () => '1.2.3', resolveUpdateRoot: () => '/root' })
assert.deepEqual([...ipcMain.handlers.keys()], ['hermes:version'])
assert.equal(typeof ipcMain.handlers.get('hermes:version'), 'function')
})
test('version reports the resolved Hermes version + root alongside runtime versions', async () => {
const ipcMain = fakeIpcMain()
registerVersionIpc({ ipcMain, resolveHermesVersion: () => '1.2.3', resolveUpdateRoot: () => '/root' })
const res = await ipcMain.handlers.get('hermes:version')({})
assert.equal(res.appVersion, '1.2.3')
assert.equal(res.hermesRoot, '/root')
assert.equal(res.electronVersion, process.versions.electron)
assert.equal(res.nodeVersion, process.versions.node)
assert.equal(res.platform, process.platform)
})

View File

@@ -0,0 +1,19 @@
'use strict'
const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-marketplace.cjs')
// VS Code Marketplace color-theme IPC: fetch a theme by extension id + search the
// marketplace. Both delegate to the vscode-marketplace sibling module; no theme
// code is ever executed (only JSON is read from the .vsix).
function registerVscodeThemeIpc({ ipcMain }) {
// Download a VS Code Marketplace extension and return the raw color-theme JSON
// it contributes. No theme code is executed — we only read JSON from the .vsix.
ipcMain.handle('hermes:vscode-theme:fetch', async (_event, id) => fetchMarketplaceThemes(String(id || '')))
// Search the Marketplace for color-theme extensions (empty query = top installs).
ipcMain.handle('hermes:vscode-theme:search', async (_event, query) =>
searchMarketplaceThemes(String(query || ''), 20)
)
}
module.exports = { registerVscodeThemeIpc }

View File

@@ -0,0 +1,30 @@
'use strict'
const assert = require('node:assert/strict')
const test = require('node:test')
const { registerVscodeThemeIpc } = require('./vscode-theme-ipc.cjs')
function fakeIpcMain() {
const handlers = new Map()
return {
handlers,
handle(channel, handler) {
assert.ok(!handlers.has(channel), `duplicate registration for ${channel}`)
handlers.set(channel, handler)
}
}
}
test('registerVscodeThemeIpc wires only hermes:vscode-theme:* channels, each to a handler fn', () => {
const ipcMain = fakeIpcMain()
registerVscodeThemeIpc({ ipcMain })
assert.deepEqual([...ipcMain.handlers.keys()].sort(), ['hermes:vscode-theme:fetch', 'hermes:vscode-theme:search'])
for (const handler of ipcMain.handlers.values()) {
assert.equal(typeof handler, 'function')
}
})

View File

@@ -37,7 +37,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-env.test.cjs electron/backend-probes.test.cjs electron/backend-ready.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/link-title-window.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/git-worktree-ops.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-count.test.cjs electron/update-rebuild.test.cjs electron/update-marker.test.cjs electron/update-relaunch.test.cjs electron/windows-user-env.test.cjs electron/wsl-clipboard-image.test.cjs electron/titlebar-overlay-width.test.cjs electron/window-state.test.cjs electron/windows-hermes-resolution.test.cjs",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/backend-ready.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/link-title-window.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/git-ipc.test.cjs electron/git-worktree-ops.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-count.test.cjs electron/update-rebuild.test.cjs electron/update-marker.test.cjs electron/update-relaunch.test.cjs electron/windows-user-env.test.cjs electron/wsl-clipboard-image.test.cjs electron/titlebar-overlay-width.test.cjs electron/window-state.test.cjs electron/windows-hermes-resolution.test.cjs",
"typecheck": "tsc -p . --noEmit",
"lint": "eslint src/ electron/",
"lint:fix": "eslint src/ electron/ --fix",

View File

@@ -646,6 +646,25 @@ agent:
# force it on or off; the HERMES_VERIFY_ON_STOP env var (1/0) takes precedence.
# verify_on_stop: auto
# Standing operator instructions for the coding posture (when Hermes is in a
# code workspace). Appended to the coding brief as an extra system block, so
# you can pin project-wide workflow rules without editing the shipped brief.
# Accepts a string or a list of strings. Takes effect next session.
# coding_instructions:
# - "For UI work, don't run tsc/lint until I approve the look."
# - "Clean the diff before you commit and push."
# When verify-on-stop finds edited code without fresh verification evidence,
# append guidance for creative UI work (avoid broad tsc/lint/test before visual
# approval) and clean-diff expectations. Set false to keep that nudge terse.
# verify_guidance: true
# A `pre_verify` hook (plugin or shell, see Event Hooks docs) can keep the
# agent going one more turn to verify/clean before finishing. This caps how
# many times one turn may be nudged to continue, so a hook can't trap the loop.
# Default 3.
# max_verify_nudges: 3
# Enable verbose logging
verbose: false

View File

@@ -1,96 +0,0 @@
# Harbor Terminal-Bench Runner
`scripts/run_harbor_terminal_bench.sh` launches Harbor Terminal-Bench runs with
Hermes Agent against an OpenAI-compatible autoscaler gateway.
The script is intentionally generic: it does not hardcode personal usernames,
hostnames, private key paths, or internal gateway IPs. Provide those values via
environment variables when running it.
If `HARBOR_DIR` does not exist, the script clones the patched Harbor fork/ref
from `HARBOR_REPO_URL` and `HARBOR_REF`. The bundled
`scripts/patches/harbor-hermes-custom-endpoint.patch` is kept as a fallback for
unpatched upstream Harbor checkouts and is only applied when
`APPLY_HARBOR_PATCH=1`.
## Required Environment
```bash
export AUTOSCALER_SSH_TARGET="user@example-host"
export AUTOSCALER_SSH_KEY="$HOME/.ssh/id_ed25519"
export AUTOSCALER_REMOTE_GATEWAY="gateway-host-or-ip:30090"
```
You can copy the example environment file:
```bash
cp scripts/harbor-terminal-bench.env.example .env.harbor-terminal-bench
set -a
source .env.harbor-terminal-bench
set +a
```
## Optional Environment
```bash
export HARBOR_DIR="../harbor" # Harbor checkout path
export HARBOR_REPO_URL="git@github.com:NousResearch/harbor-fork.git"
export HARBOR_REF="hermes-custom-endpoint"
# export APPLY_HARBOR_PATCH="1" # Only for unpatched upstream Harbor
export HERMES_MODEL="hermes-large" # Autoscaler model id
export LOCAL_PORT="30090" # Local SSH tunnel port
export N_TASKS="10" # Unset for a full Terminal-Bench run
export N_CONCURRENT="1" # Harbor concurrency
export EXCLUDE_TASK_NAME="gpt2-codegolf" # Excluded by default for smoke runs
```
## Run A Smoke Task
```bash
INCLUDE_TASK_NAME=cancel-async-tasks \
./scripts/run_harbor_terminal_bench.sh
```
## Run A 10-Task Batch
```bash
N_TASKS=10 ./scripts/run_harbor_terminal_bench.sh
```
## Run A Larger Batch
```bash
N_TASKS=50 N_CONCURRENT=4 ./scripts/run_harbor_terminal_bench.sh
```
## Run The Full Dataset
Leave `N_TASKS` unset so Harbor does not receive `--n-tasks`:
```bash
unset N_TASKS
N_CONCURRENT=1 ./scripts/run_harbor_terminal_bench.sh
```
## Head Node Guard
The script refuses to run on hostnames that look like head nodes, for example
hosts containing `-hn1` or `head`, unless explicitly overridden:
```bash
ALLOW_HEAD_NODE_RUN=1 ./scripts/run_harbor_terminal_bench.sh
```
Only use the override when you are sure the host is an appropriate place to run
Docker/Harbor workloads.
## Notes
- Harbor task containers use `http://host.docker.internal:<LOCAL_PORT>/v1` by
default because Hermes runs inside Docker.
- `OPENAI_API_KEY` defaults to `dummy`; it is only used to populate Hermes'
custom provider config for the autoscaler endpoint.
- Set `NO_TUNNEL=1` if a local tunnel or gateway is already running.
- By default the script owns the inference tunnel. It checks `/v1/models`
periodically and restarts the SSH tunnel if it stops responding during a long
eval.

View File

@@ -999,6 +999,21 @@ DEFAULT_CONFIG = {
# "on" — force the prompt posture everywhere.
# "off" — disable entirely.
"coding_context": "auto",
# Standing operator instructions for the coding posture. A string (or
# list of strings) appended to the coding brief as an extra stable
# system block — pin project-wide workflow rules here instead of editing
# the shipped brief, e.g. "For UI work, don't run tsc/lint until I
# approve. Clean the diff before you commit and push." Cache-safe:
# takes effect next session. Empty by default.
"coding_instructions": "",
# When verify-on-stop finds edited code without fresh verification
# evidence, append guidance for creative UI work (avoid broad
# tsc/lint/test before visual approval) and clean-diff expectations.
# Set false to keep the evidence nudge terse.
"verify_guidance": True,
# Upper bound on consecutive `pre_verify` "continue" nudges in a single
# turn, so a user/plugin hook can never trap the loop.
"max_verify_nudges": 3,
# Verification closure: after the agent edits files in a code workspace,
# do not accept a final answer until fresh verification evidence exists
# or the agent explains why it cannot run checks. The loop is bounded

View File

@@ -139,6 +139,15 @@ _DEFAULT_PAYLOADS = {
"model": "gpt-4",
"platform": "cli",
},
"pre_verify": {
"session_id": "test-session",
"platform": "cli",
"model": "gpt-4",
"coding": True,
"attempt": 0,
"final_response": "All done — the change is applied.",
"changed_paths": ["src/app.tsx"],
},
"on_session_start": {"session_id": "test-session"},
"on_session_end": {"session_id": "test-session"},
"on_session_finalize": {"session_id": "test-session"},

View File

@@ -136,6 +136,17 @@ VALID_HOOKS: Set[str] = {
"transform_llm_output",
"pre_llm_call",
"post_llm_call",
# Verification-loop gate. Fired once per turn when the agent has edited code
# and is about to verify/finish (after the verify-on-stop guard). A callback
# may keep the agent going — run a check, defer it, tidy the diff — instead
# of stopping by returning:
# {"action": "continue", "message": "<follow-up instruction>"}
# The Claude-Code Stop shape {"decision": "block", "reason": "..."} (block
# the stop == keep going) is accepted too. Anything else lets the turn
# finish. Hermes' shipped guidance lives in the evidence-based
# verification-stop nudge; this hook is for user/plugin policy and is
# bounded by agent.max_verify_nudges.
"pre_verify",
"pre_api_request",
"post_api_request",
"api_request_error",
@@ -2029,6 +2040,57 @@ def get_pre_tool_call_block_message(
return None
def get_pre_verify_continue_message(
*,
session_id: str = "",
platform: str = "",
model: str = "",
coding: bool = False,
attempt: int = 0,
final_response: str = "",
changed_paths: Optional[List[str]] = None,
) -> Optional[str]:
"""Check user ``pre_verify`` hooks for a directive to keep the agent going.
Fired once per turn when the agent edited code and is about to verify/finish.
A hook keeps the turn going (run a check, defer it, tidy the diff) by
returning::
{"action": "continue", "message": "<follow-up for the model>"}
The Claude-Code Stop shape ``{"decision": "block", "reason": "..."}`` (block
the stop == keep going) is accepted too. The first directive carrying a
non-empty message wins; any other return lets the turn finish. Mirrors
:func:`get_pre_tool_call_block_message` — the call site stays a one-liner.
``coding`` / ``attempt`` let a hook scope itself (``if not coding`` …) and
self-throttle (``if attempt`` …), the same way a ``pre_tool_call`` hook
scopes on ``tool_name``.
"""
hook_results = invoke_hook(
"pre_verify",
session_id=session_id,
platform=platform,
model=model,
coding=coding,
attempt=attempt,
final_response=final_response,
changed_paths=list(changed_paths or []),
)
for result in hook_results:
if not isinstance(result, dict):
continue
action = str(result.get("action") or result.get("decision") or "").strip().lower()
if action not in ("continue", "block"):
continue
message = result.get("message") or result.get("reason")
if isinstance(message, str) and message.strip():
return message.strip()
return None
def _ensure_plugins_discovered(force: bool = False) -> PluginManager:
"""Return the global manager after ensuring plugin discovery has run.

View File

@@ -13,7 +13,7 @@ class NousProfile(ProviderProfile):
def build_extra_body(
self, *, session_id: str | None = None, **context
) -> dict[str, Any]:
return {"tags": nous_portal_tags(session_id=session_id)}
return {"tags": nous_portal_tags()}
def build_api_kwargs_extras(
self,

View File

@@ -1,18 +0,0 @@
# Required for starting an SSH tunnel to an OpenAI-compatible autoscaler gateway.
AUTOSCALER_SSH_TARGET=user@example-host
AUTOSCALER_SSH_KEY=$HOME/.ssh/id_ed25519
AUTOSCALER_REMOTE_GATEWAY=gateway-host-or-ip:30090
# Optional.
HARBOR_DIR=../harbor
HARBOR_REPO_URL=git@github.com:NousResearch/harbor-fork.git
HARBOR_REF=hermes-custom-endpoint
# Set APPLY_HARBOR_PATCH=1 only when using an unpatched upstream Harbor checkout.
# APPLY_HARBOR_PATCH=1
HERMES_MODEL=hermes-large
LOCAL_PORT=30090
N_CONCURRENT=1
EXCLUDE_TASK_NAME=gpt2-codegolf
# Leave N_TASKS unset for a full Terminal-Bench run.
# N_TASKS=10

View File

@@ -1,119 +0,0 @@
diff --git a/src/harbor/agents/installed/hermes.py b/src/harbor/agents/installed/hermes.py
index 5f16cbd5..bb1a5b80 100644
--- a/src/harbor/agents/installed/hermes.py
+++ b/src/harbor/agents/installed/hermes.py
@@ -86,10 +86,26 @@ class Hermes(BaseInstalledAgent):
# ------------------------------------------------------------------
@staticmethod
- def _build_config_yaml(model: str) -> str:
+ def _build_config_yaml(
+ model: str,
+ *,
+ custom_base_url: str | None = None,
+ custom_api_key: str | None = None,
+ ) -> str:
"""Generate a hermes config.yaml with full capabilities enabled."""
+ model_config: str | dict[str, str]
+ if custom_base_url:
+ model_config = {
+ "default": model,
+ "provider": "custom",
+ "base_url": custom_base_url,
+ "api_key": custom_api_key or "",
+ }
+ else:
+ model_config = model
+
config: dict[str, Any] = {
- "model": model,
+ "model": model_config,
"provider": "auto",
"toolsets": ["hermes-cli"],
"agent": {"max_turns": 90},
@@ -351,6 +367,8 @@ class Hermes(BaseInstalledAgent):
# Try native provider key first, fall back to OpenRouter.
hermes_provider_flag: str | None = None
+ custom_base_url: str | None = None
+ custom_api_key: str | None = None
use_native = False
if provider in _NATIVE_PROVIDERS:
@@ -359,7 +377,13 @@ class Hermes(BaseInstalledAgent):
key_val = os.environ.get(key_name)
if key_val:
env[key_name] = key_val
- hermes_provider_flag = native_flag
+ # Hermes Agent v0.18 treats OpenAI-compatible non-OpenAI
+ # endpoints as custom providers configured in config.yaml.
+ if provider == "openai" and os.environ.get("OPENAI_BASE_URL"):
+ custom_base_url = os.environ["OPENAI_BASE_URL"]
+ custom_api_key = key_val
+ else:
+ hermes_provider_flag = native_flag
use_native = True
break
# Forward OPENAI_BASE_URL when using native OpenAI key
@@ -380,10 +404,14 @@ class Hermes(BaseInstalledAgent):
raise ValueError("No API key found. Set OPENROUTER_API_KEY.")
env["OPENROUTER_API_KEY"] = openrouter_key
- # Native providers with --provider flag use just the model name;
- # everything else (OpenRouter, openai direct) uses provider/model.
- cli_model = model if hermes_provider_flag else self.model_name
- config_yaml = self._build_config_yaml(cli_model)
+ # Native providers with --provider flag and custom endpoints use just
+ # the model name; OpenRouter/direct OpenAI keep provider/model.
+ cli_model = model if hermes_provider_flag or custom_base_url else self.model_name
+ config_yaml = self._build_config_yaml(
+ cli_model,
+ custom_base_url=custom_base_url,
+ custom_api_key=custom_api_key,
+ )
# Pass instruction via env var (safe from shell escaping issues)
env["HARBOR_INSTRUCTION"] = instruction
diff --git a/tests/unit/agents/installed/test_hermes_cli.py b/tests/unit/agents/installed/test_hermes_cli.py
index 0b78678b..ab73f4f7 100644
--- a/tests/unit/agents/installed/test_hermes_cli.py
+++ b/tests/unit/agents/installed/test_hermes_cli.py
@@ -52,6 +52,7 @@ class TestHermesRunCommands:
async def test_openai_native_provider(self, temp_dir, monkeypatch):
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
monkeypatch.setenv("OPENAI_API_KEY", "openai-key")
+ monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
agent = Hermes(logs_dir=temp_dir, model_name="openai/gpt-4o")
mock_env = AsyncMock()
mock_env.exec.return_value = AsyncMock(return_code=0, stdout="", stderr="")
@@ -61,6 +62,30 @@ class TestHermesRunCommands:
assert "--provider" not in run_call.kwargs["command"]
assert run_call.kwargs["env"]["OPENAI_API_KEY"] == "openai-key"
+ @pytest.mark.asyncio
+ async def test_openai_base_url_uses_custom_provider(self, temp_dir, monkeypatch):
+ monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
+ monkeypatch.setenv("OPENAI_API_KEY", "dummy")
+ monkeypatch.setenv("OPENAI_BASE_URL", "http://host.docker.internal:30090/v1")
+ agent = Hermes(logs_dir=temp_dir, model_name="openai/hermes-large")
+ mock_env = AsyncMock()
+ mock_env.exec.return_value = AsyncMock(return_code=0, stdout="", stderr="")
+ await agent.run("do something", mock_env, AsyncMock())
+ config_call = mock_env.exec.call_args_list[0]
+ config_yaml = config_call.kwargs["command"].split("<< 'EOF'\n", 1)[1].rsplit(
+ "\nEOF", 1
+ )[0]
+ config = yaml.safe_load(config_yaml)
+ assert config["model"] == {
+ "default": "hermes-large",
+ "provider": "custom",
+ "base_url": "http://host.docker.internal:30090/v1",
+ "api_key": "dummy",
+ }
+ run_call = self._get_run_call(mock_env.exec.call_args_list)
+ assert "--model hermes-large" in run_call.kwargs["command"]
+ assert "--provider" not in run_call.kwargs["command"]
+
@pytest.mark.asyncio
async def test_openrouter_fallback(self, temp_dir, monkeypatch):
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)

View File

@@ -1,255 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'USAGE'
Run Harbor Terminal-Bench with Hermes Agent through an OpenAI-compatible autoscaler gateway.
Required environment:
AUTOSCALER_SSH_TARGET SSH target for the gateway host, for example user@host
AUTOSCALER_SSH_KEY SSH private key path for the gateway host
AUTOSCALER_REMOTE_GATEWAY Remote gateway host:port reachable from AUTOSCALER_SSH_TARGET
Optional environment:
HARBOR_DIR Harbor checkout path (default: ../harbor)
HARBOR_REPO_URL Harbor repo URL to clone if HARBOR_DIR is missing
HARBOR_REF Harbor branch/tag/SHA to clone
APPLY_HARBOR_PATCH=1 Apply bundled patch for unpatched upstream Harbor
HERMES_MODEL Autoscaler model id (default: hermes-large)
LOCAL_PORT Local forwarded port (default: 30090)
N_TASKS Number of tasks to run (unset means full dataset)
N_CONCURRENT Harbor trial concurrency (default: 1)
JOB_NAME Harbor job name (default: hermes-large-tb-<timestamp>)
EXCLUDE_TASK_NAME Task glob to exclude (default: gpt2-codegolf)
INCLUDE_TASK_NAME Task glob to include instead of N_TASKS
OPENAI_API_KEY Dummy/custom provider key (default: dummy)
AUTOSCALER_HEALTH_INTERVAL Tunnel health check interval seconds (default: 30)
ALLOW_HEAD_NODE_RUN=1 Override the head-node safety guard
NO_TUNNEL=1 Do not start an SSH tunnel; use an existing local gateway
Examples:
AUTOSCALER_SSH_TARGET=user@example-host \
AUTOSCALER_SSH_KEY=~/.ssh/id_ed25519 \
AUTOSCALER_REMOTE_GATEWAY=10.0.0.10:30090 \
./scripts/run_harbor_terminal_bench.sh
INCLUDE_TASK_NAME=cancel-async-tasks ./scripts/run_harbor_terminal_bench.sh
N_TASKS=50 N_CONCURRENT=4 ./scripts/run_harbor_terminal_bench.sh
unset N_TASKS; N_CONCURRENT=1 ./scripts/run_harbor_terminal_bench.sh
USAGE
}
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
usage
exit 0
fi
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
harbor_dir="${HARBOR_DIR:-${repo_root}/../harbor}"
harbor_repo_url="${HARBOR_REPO_URL:-git@github.com:NousResearch/harbor-fork.git}"
harbor_ref="${HARBOR_REF:-hermes-custom-endpoint}"
apply_harbor_patch="${APPLY_HARBOR_PATCH:-0}"
harbor_patch="${HARBOR_PATCH:-${repo_root}/scripts/patches/harbor-hermes-custom-endpoint.patch}"
hermes_model="${HERMES_MODEL:-hermes-large}"
local_port="${LOCAL_PORT:-30090}"
n_tasks="${N_TASKS:-}"
n_concurrent="${N_CONCURRENT:-1}"
job_name="${JOB_NAME:-${hermes_model}-tb-$(date +%Y%m%d-%H%M%S)}"
exclude_task_name="${EXCLUDE_TASK_NAME:-gpt2-codegolf}"
include_task_name="${INCLUDE_TASK_NAME:-}"
api_key="${OPENAI_API_KEY:-dummy}"
local_base_url="http://127.0.0.1:${local_port}/v1"
docker_base_url="${DOCKER_BASE_URL:-http://host.docker.internal:${local_port}/v1}"
health_interval="${AUTOSCALER_HEALTH_INTERVAL:-30}"
tunnel_pid_file="${TUNNEL_PID_FILE:-${repo_root}/.harbor-autoscaler-tunnel.pid}"
tunnel_supervisor_pid_file="${TUNNEL_SUPERVISOR_PID_FILE:-${repo_root}/.harbor-autoscaler-tunnel-supervisor.pid}"
hostname_value="$(hostname -f 2>/dev/null || hostname)"
if [[ "${ALLOW_HEAD_NODE_RUN:-0}" != "1" ]] \
&& [[ "${hostname_value}" =~ (^|[-.])(hn[0-9]*|head)([-.]|$) ]]; then
cat >&2 <<EOF
Refusing to launch Harbor eval on possible head node: ${hostname_value}
Run from a workstation or compute allocation. If you are certain this host is
safe, set ALLOW_HEAD_NODE_RUN=1.
EOF
exit 64
fi
require_env() {
local name="$1"
if [[ -z "${!name:-}" ]]; then
echo "Missing required environment variable: ${name}" >&2
usage >&2
exit 2
fi
}
ensure_harbor_checkout() {
if [[ ! -f "${harbor_dir}/pyproject.toml" ]]; then
if [[ -e "${harbor_dir}" ]]; then
echo "HARBOR_DIR exists but is not a Harbor checkout: ${harbor_dir}" >&2
exit 1
fi
echo "Cloning Harbor into ${harbor_dir}"
if [[ -n "${harbor_ref}" ]]; then
git clone --branch "${harbor_ref}" --depth 1 "${harbor_repo_url}" "${harbor_dir}"
else
git clone --depth 1 "${harbor_repo_url}" "${harbor_dir}"
fi
fi
if [[ "${apply_harbor_patch}" == "1" ]]; then
if [[ ! -f "${harbor_patch}" ]]; then
echo "Missing Harbor patch: ${harbor_patch}" >&2
exit 1
fi
(
cd "${harbor_dir}"
if git apply --check "${harbor_patch}" >/dev/null 2>&1; then
git apply "${harbor_patch}"
echo "Applied Harbor Hermes custom endpoint patch."
elif git apply --reverse --check "${harbor_patch}" >/dev/null 2>&1; then
echo "Harbor Hermes custom endpoint patch is already applied."
else
cat >&2 <<EOF
Could not apply Harbor patch cleanly.
This usually means Harbor changed upstream or already has a different version
of the Hermes custom endpoint fix. Inspect:
${harbor_patch}
${harbor_dir}/src/harbor/agents/installed/hermes.py
EOF
exit 1
fi
)
fi
}
curl_models() {
curl -fsS --max-time 8 "${local_base_url}/models" >/dev/null
}
local_gateway_healthy() {
curl_models >/dev/null 2>&1
}
docker_gateway_healthy() {
docker run --rm curlimages/curl:latest \
-fsS --max-time 10 "${docker_base_url}/models" >/dev/null 2>&1
}
stop_pid_file_process() {
local pid_file="$1"
if [[ -f "${pid_file}" ]]; then
local pid
pid="$(cat "${pid_file}" 2>/dev/null || true)"
if [[ -n "${pid}" ]]; then
kill "${pid}" >/dev/null 2>&1 || true
fi
rm -f "${pid_file}"
fi
}
start_tunnel_once() {
require_env AUTOSCALER_SSH_TARGET
require_env AUTOSCALER_SSH_KEY
require_env AUTOSCALER_REMOTE_GATEWAY
stop_pid_file_process "${tunnel_pid_file}"
ssh -i "${AUTOSCALER_SSH_KEY}" \
-o ExitOnForwardFailure=yes \
-o ServerAliveInterval=30 \
-o ServerAliveCountMax=3 \
-N \
-L "${local_port}:${AUTOSCALER_REMOTE_GATEWAY}" \
"${AUTOSCALER_SSH_TARGET}" &
echo "$!" > "${tunnel_pid_file}"
for _ in {1..15}; do
if local_gateway_healthy; then
return 0
fi
sleep 1
done
echo "Tunnel started but ${local_base_url}/models did not become healthy." >&2
return 1
}
ensure_autoscaler_gateway() {
if [[ "${NO_TUNNEL:-0}" != "1" ]] && ! local_gateway_healthy; then
start_tunnel_once
fi
if ! docker_gateway_healthy; then
cat >&2 <<EOF
Docker could not reach the autoscaler at ${docker_base_url}.
On macOS/Windows, host.docker.internal should work. On Linux you may need to
run Harbor with host networking or set DOCKER_BASE_URL to a container-reachable
gateway URL.
EOF
exit 1
fi
}
start_tunnel_supervisor() {
if [[ "${NO_TUNNEL:-0}" == "1" ]]; then
return 0
fi
require_env AUTOSCALER_SSH_TARGET
require_env AUTOSCALER_SSH_KEY
require_env AUTOSCALER_REMOTE_GATEWAY
stop_pid_file_process "${tunnel_supervisor_pid_file}"
(
while true; do
if ! local_gateway_healthy; then
echo "Autoscaler tunnel unhealthy; restarting..." >&2
start_tunnel_once || true
fi
sleep "${health_interval}"
done
) &
echo "$!" > "${tunnel_supervisor_pid_file}"
}
cleanup() {
stop_pid_file_process "${tunnel_supervisor_pid_file}"
stop_pid_file_process "${tunnel_pid_file}"
}
trap cleanup EXIT
ensure_autoscaler_gateway
start_tunnel_supervisor
ensure_harbor_checkout
args=(
--dataset terminal-bench@2.0
--agent hermes
--model "openai/${hermes_model}"
--n-concurrent "${n_concurrent}"
--job-name "${job_name}"
-y
)
if [[ -n "${include_task_name}" ]]; then
args+=(--include-task-name "${include_task_name}")
else
if [[ -n "${n_tasks}" ]]; then
args+=(--n-tasks "${n_tasks}")
fi
if [[ -n "${exclude_task_name}" ]]; then
args+=(--exclude-task-name "${exclude_task_name}")
fi
fi
cd "${harbor_dir}"
OPENAI_API_KEY="${api_key}" \
OPENAI_BASE_URL="${docker_base_url}" \
uv run --no-dev harbor run "${args[@]}"

View File

@@ -353,6 +353,39 @@ class TestRuntimeMode:
assert any("coding agent" in b for b in blocks)
assert any("Workspace" in b for b in blocks)
def test_coding_instructions_append_their_own_block(self, tmp_path):
_git_init(tmp_path)
cfg = {
"agent": {
"coding_context": "on",
"coding_instructions": "Clean the diff before commit.",
}
}
mode = cc.resolve_runtime_mode(platform="cli", cwd=tmp_path, config=cfg)
blocks = mode.system_blocks()
# The brief stays block 0 (byte-stable, cache-keyed independently); the
# operator instructions ride a separate trailing block.
assert blocks[0] == cc.CODING_AGENT_GUIDANCE
assert any("Clean the diff before commit." in b for b in blocks[1:])
def test_coding_instructions_accept_a_list(self, tmp_path):
_git_init(tmp_path)
cfg = {
"agent": {
"coding_context": "on",
"coding_instructions": ["No tsc/lint on UI.", "Clean the diff."],
}
}
mode = cc.resolve_runtime_mode(platform="cli", cwd=tmp_path, config=cfg)
instr_block = mode.system_blocks()[-1]
assert "No tsc/lint on UI." in instr_block
assert "Clean the diff." in instr_block
def test_no_instructions_block_when_unset(self, tmp_path):
_git_init(tmp_path)
mode = cc.resolve_runtime_mode(platform="cli", cwd=tmp_path, config={"agent": {"coding_context": "on"}})
assert not any("Operator instructions" in b for b in mode.system_blocks())
def test_toolset_selection_gated_on_focus(self, tmp_path):
_git_init(tmp_path)
focus = cc.resolve_runtime_mode(platform="cli", cwd=tmp_path, config={"agent": {"coding_context": "focus"}})

View File

@@ -42,33 +42,6 @@ def test_nous_portal_tags_returns_fresh_list():
assert "client=test-mutation" not in b
def test_conversation_tag_format():
"""The conversation tag carries the session id verbatim."""
from agent.portal_tags import conversation_tag
assert conversation_tag("abc-123") == "conversation=abc-123"
def test_nous_portal_tags_appends_conversation_when_session_id_given():
"""A session id adds a third, high-cardinality conversation tag."""
from agent.portal_tags import conversation_tag, nous_portal_tags
tags = nous_portal_tags(session_id="sess-42")
assert "product=hermes-agent" in tags
assert conversation_tag("sess-42") in tags
assert len(tags) == 3
def test_nous_portal_tags_omits_conversation_without_session_id():
"""Base tag set stays at two tags when no session id is available."""
from agent.portal_tags import nous_portal_tags
for empty in (None, ""):
tags = nous_portal_tags(session_id=empty)
assert len(tags) == 2
assert not any(t.startswith("conversation=") for t in tags)
def test_auxiliary_client_nous_extra_body_uses_helper():
"""auxiliary_client.NOUS_EXTRA_BODY must match the canonical helper output."""
from agent.auxiliary_client import NOUS_EXTRA_BODY

View File

@@ -97,6 +97,24 @@ class TestParseResponse:
)
assert r is None
def test_pre_verify_continue_canonical(self):
r = shell_hooks._parse_response(
"pre_verify", '{"action": "continue", "message": "run checks"}',
)
assert r == {"action": "continue", "message": "run checks"}
def test_pre_verify_block_is_continue_claude_style(self):
# Claude-Code Stop hooks: block the stop == keep going; reason → message.
r = shell_hooks._parse_response(
"pre_verify", '{"decision": "block", "reason": "run the formatter"}',
)
assert r == {"action": "continue", "message": "run the formatter"}
def test_pre_verify_without_message_is_noop(self):
# A continue with nothing to tell the model lets the turn finish.
assert shell_hooks._parse_response("pre_verify", '{"action": "continue"}') is None
assert shell_hooks._parse_response("pre_verify", '{"decision": "allow"}') is None
def test_block_action_without_message_uses_default(self):
"""Block is honored even when message/reason is absent."""
r = shell_hooks._parse_response("pre_tool_call", '{"action": "block"}')

View File

@@ -215,6 +215,7 @@ def test_nudge_after_unverified_edit_with_known_command(tmp_path, monkeypatch):
assert "fresh passing verification evidence" in nudge
assert "`pnpm run test`" in nudge
assert changed in nudge
assert "creative UI/visual work" in nudge
def test_nudge_includes_failed_output_summary(tmp_path, monkeypatch):
@@ -249,6 +250,23 @@ def test_no_suite_nudge_requests_temp_script(tmp_path, monkeypatch):
assert tempfile.gettempdir() in nudge
assert "ad-hoc verification" in nudge
assert "suite green" in nudge
assert "creative UI/visual work" in nudge
def test_verify_guidance_can_be_disabled(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
_node_project(tmp_path)
changed = str(tmp_path / "src" / "app.ts")
from agent import verify_hooks
monkeypatch.setattr(verify_hooks, "coding_verify_guidance", lambda: None)
nudge = build_verify_on_stop_nudge(session_id="s1", changed_paths=[changed])
assert nudge is not None
assert "fresh passing verification evidence" in nudge
assert "creative UI/visual work" not in nudge
def test_ad_hoc_pass_satisfies_no_suite_stop_loop(tmp_path, monkeypatch):

View File

@@ -0,0 +1,53 @@
"""Unit tests for the verification-loop policy (agent/verify_hooks.py).
The `pre_verify` user-hook aggregation lives in `hermes_cli.plugins`
(`get_pre_verify_continue_message`) and is tested in
`tests/hermes_cli/test_plugins.py`, alongside `get_pre_tool_call_block_message`.
"""
from __future__ import annotations
from agent import verify_hooks
class TestMaxVerifyNudges:
def test_default_when_unset(self):
assert (
verify_hooks.max_verify_nudges({})
== verify_hooks.DEFAULT_MAX_VERIFY_NUDGES
)
assert (
verify_hooks.max_verify_nudges({"agent": {}})
== verify_hooks.DEFAULT_MAX_VERIFY_NUDGES
)
def test_reads_and_coerces(self):
assert verify_hooks.max_verify_nudges({"agent": {"max_verify_nudges": 5}}) == 5
assert verify_hooks.max_verify_nudges({"agent": {"max_verify_nudges": "2"}}) == 2
assert verify_hooks.max_verify_nudges({"agent": {"max_verify_nudges": -1}}) == 0
def test_bad_value_falls_back(self):
assert (
verify_hooks.max_verify_nudges({"agent": {"max_verify_nudges": "x"}})
== verify_hooks.DEFAULT_MAX_VERIFY_NUDGES
)
class TestCodingVerifyGuidance:
def test_enabled_by_default(self):
assert (
verify_hooks.coding_verify_guidance({})
== verify_hooks.CODING_VERIFY_GUIDANCE
)
assert (
verify_hooks.coding_verify_guidance({"agent": {}})
== verify_hooks.CODING_VERIFY_GUIDANCE
)
def test_reads_truthy_config(self):
cfg = {"agent": {"verify_guidance": "yes"}}
assert verify_hooks.coding_verify_guidance(cfg) == verify_hooks.CODING_VERIFY_GUIDANCE
def test_opt_out_via_config(self):
off = {"agent": {"verify_guidance": False}}
assert verify_hooks.coding_verify_guidance(off) is None

View File

@@ -18,6 +18,7 @@ from hermes_cli.plugins import (
get_plugin_command_handler,
get_plugin_commands,
get_pre_tool_call_block_message,
get_pre_verify_continue_message,
has_middleware,
resolve_plugin_command_result,
)
@@ -858,6 +859,73 @@ class TestPreToolCallBlocking:
assert get_pre_tool_call_block_message("terminal", {}) == "first blocker"
class TestGetPreVerifyContinueMessage:
"""`pre_verify` directive aggregation — mirrors the pre_tool_call block path."""
def test_continue_canonical(self, monkeypatch):
monkeypatch.setattr(
"hermes_cli.plugins.invoke_hook",
lambda hook_name, **kwargs: [{"action": "continue", "message": "run checks"}],
)
assert get_pre_verify_continue_message(session_id="s") == "run checks"
def test_claude_block_means_continue(self, monkeypatch):
# Claude-Code Stop: "block" the stop == keep going; reason → message.
monkeypatch.setattr(
"hermes_cli.plugins.invoke_hook",
lambda hook_name, **kwargs: [{"decision": "block", "reason": "run the formatter"}],
)
assert get_pre_verify_continue_message() == "run the formatter"
def test_first_actionable_directive_wins(self, monkeypatch):
monkeypatch.setattr(
"hermes_cli.plugins.invoke_hook",
lambda hook_name, **kwargs: [
"noise", # not a dict
{"action": "continue"}, # no message → skipped
{"action": "continue", "message": "second"},
{"action": "continue", "message": "third"},
],
)
assert get_pre_verify_continue_message() == "second"
def test_message_is_trimmed(self, monkeypatch):
monkeypatch.setattr(
"hermes_cli.plugins.invoke_hook",
lambda hook_name, **kwargs: [{"action": "continue", "message": " tidy up "}],
)
assert get_pre_verify_continue_message() == "tidy up"
def test_invalid_returns_ignored(self, monkeypatch):
monkeypatch.setattr(
"hermes_cli.plugins.invoke_hook",
lambda hook_name, **kwargs: [
{"action": "allow"}, # wrong action
{"context": "noise"}, # not a directive
{"action": "continue", "message": " "}, # blank message
{"action": "continue", "message": 42}, # message not str
],
)
assert get_pre_verify_continue_message() is None
def test_none_when_no_hooks(self, monkeypatch):
monkeypatch.setattr("hermes_cli.plugins.invoke_hook", lambda hook_name, **kwargs: [])
assert get_pre_verify_continue_message() is None
def test_forwards_scope_signals_to_hooks(self, monkeypatch):
seen = {}
def capture(hook_name, **kwargs):
seen.update(kwargs)
return []
monkeypatch.setattr("hermes_cli.plugins.invoke_hook", capture)
get_pre_verify_continue_message(coding=True, attempt=2, changed_paths=["a.py"])
assert seen["coding"] is True
assert seen["attempt"] == 2
assert seen["changed_paths"] == ["a.py"]
class TestThreadToolWhitelist:
"""Tests for the thread-local tool whitelist used by background review forks."""

View File

@@ -414,12 +414,6 @@ class TestNousProfile:
body = p.build_extra_body()
assert body["tags"] == nous_portal_tags()
def test_tags_include_conversation_when_session_id(self):
from agent.portal_tags import conversation_tag
p = get_provider_profile("nous")
body = p.build_extra_body(session_id="sess-99")
assert conversation_tag("sess-99") in body["tags"]
def test_auth_type(self):
p = get_provider_profile("nous")
assert p.auth_type == "oauth_device_code"

View File

@@ -382,6 +382,7 @@ def register(ctx):
| [`post_tool_call`](#post_tool_call) | After any tool returns | ignored |
| [`pre_llm_call`](#pre_llm_call) | Once per turn, before the tool-calling loop | `{"context": str}` to prepend context to the user message |
| [`post_llm_call`](#post_llm_call) | Once per turn, after the tool-calling loop | ignored |
| [`pre_verify`](#pre_verify) | Once per turn when the agent edited code, before it verifies/finishes | `{"action": "continue", "message": str}` to keep going |
| [`on_session_start`](#on_session_start) | New session created (first turn only) | ignored |
| [`on_session_end`](#on_session_end) | Session ends | ignored |
| [`on_session_finalize`](#on_session_finalize) | CLI/gateway tears down an active session (flush, save, stats) | ignored |
@@ -652,6 +653,71 @@ def register(ctx):
---
### `pre_verify`
Fires **once per turn when the agent edited code**, just before it finishes (after the built-in verify-on-stop guard). This is a user/plugin policy gate: a callback can keep the agent going — run a check, defer it, tidy the diff — instead of letting it stop.
Hermes' shipped verification guidance is not a default `pre_verify` hook. It is appended to the evidence-based verify-on-stop nudge when edited code lacks fresh verification evidence, so it does not create a second default continuation path. Set `agent.verify_guidance: false` to keep that built-in evidence nudge terse.
**Callback signature:**
```python
def my_callback(session_id: str, platform: str, model: str, coding: bool,
attempt: int, final_response: str, changed_paths: list, **kwargs):
```
| Parameter | Type | Description |
|-----------|------|-------------|
| `session_id` | `str` | Unique identifier for the current session |
| `platform` | `str` | Where the session is running (`"cli"`, `"telegram"`, …) |
| `model` | `str` | The model identifier |
| `coding` | `bool` | Whether the turn is in the coding posture (in a code workspace) — scope your hook on this |
| `attempt` | `int` | How many times this turn has already been nudged (0 on the first) — self-throttle on this |
| `final_response` | `str` | The answer the agent is about to deliver |
| `changed_paths` | `list` | Files the agent edited this turn (sorted, always non-empty here) |
Scope a hook to the coding context by checking `coding` and make it one-shot with `attempt` (shell hooks read both from `.extra`), the same way a `pre_tool_call` hook scopes on `tool_name` — so you can register several `pre_verify` hooks, each firing only where it should.
**Fires:** In `agent/conversation_loop.py`, at the point the agent would accept a final answer, immediately after the verify-on-stop check — but only when the agent edited code this turn and at least one `pre_verify` hook is registered.
**Return value — keep the agent going:**
```python
return {"action": "continue", "message": "Run the formatter on your changes, then finish."}
```
The `message` is appended as a synthetic user turn and the loop runs again. The Claude-Code Stop shape (`{"decision": "block", "reason": "..."}`, where blocking the stop means *keep going*) is accepted too. A directive with no message — or any other return — lets the turn finish.
**Bounded:** consecutive continue directives in one turn are capped by `agent.max_verify_nudges` (default 3), so a hook that always says continue can never trap the loop. The attempted answer is kept in history but not surfaced to the user while the agent is being nudged.
**Make it idempotent:** the hook re-fires after each nudge, so gate on `attempt` (`if attempt: return None`) — otherwise it just nudges until the bound is hit.
**Use cases:** defer tests/lints during creative iteration, require green checks for certain paths, block "done" until a changelog entry exists, run a project-specific verification checklist.
**Example — defer checks on creative UI work, scoped + one-shot:**
```python
UI = (".tsx", ".jsx", ".css", ".scss")
def defer_ui_checks(coding, attempt, changed_paths, **kwargs):
if attempt or not coding:
return None # one-shot, coding only
if not all(p.endswith(UI) for p in changed_paths):
return None # only pure-UI edits
return {
"action": "continue",
"message": "This is UI work — don't run tests/lints yet; ask the user to "
"eyeball it first, and clean the diff before any commit.",
}
def register(ctx):
ctx.register_hook("pre_verify", defer_ui_checks)
```
For standing guidance that should shape the built-in missing-evidence nudge, use `agent.verify_guidance`. For broader coding posture rules that don't need to *gate* verification, prefer `agent.coding_instructions` in `config.yaml` — it rides the coding brief and costs no extra turn.
---
### `on_session_start`
Fires **once** when a brand-new session is created. Does **not** fire on session continuation (when the user sends a second message in an existing session).
@@ -1284,6 +1350,10 @@ Each time the event fires, Hermes spawns a subprocess for every matching hook (m
// Inject context for pre_llm_call:
{"context": "Today is Friday, 2026-04-17"}
// Keep the agent going at the verify gate (pre_verify); both shapes accepted:
{"action": "continue", "message": "Run the formatter, then finish."}
{"decision": "block", "reason": "Run the formatter, then finish."}
// Silent no-op — any empty / non-matching output is fine:
```