mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-07 18:43:04 +08:00
Compare commits
13 Commits
jai/conv
...
bb/main-ve
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d19cfbb78 | ||
|
|
150e023fe2 | ||
|
|
8a48d54193 | ||
|
|
e167ed7bb1 | ||
|
|
0ed0c2d39f | ||
|
|
c147270a1c | ||
|
|
880f5837a1 | ||
|
|
f3ce17bf9e | ||
|
|
b29bb6ef9d | ||
|
|
025c8f0604 | ||
|
|
a81c5922a2 | ||
|
|
821d9f709f | ||
|
|
a10113658b |
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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})"
|
||||
|
||||
@@ -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()]
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
69
agent/verify_hooks.py
Normal 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",
|
||||
]
|
||||
105
apps/desktop/electron/fs-ipc.cjs
Normal file
105
apps/desktop/electron/fs-ipc.cjs
Normal 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 }
|
||||
49
apps/desktop/electron/fs-ipc.test.cjs
Normal file
49
apps/desktop/electron/fs-ipc.test.cjs
Normal 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`
|
||||
)
|
||||
}
|
||||
})
|
||||
96
apps/desktop/electron/git-ipc.cjs
Normal file
96
apps/desktop/electron/git-ipc.cjs
Normal 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 }
|
||||
61
apps/desktop/electron/git-ipc.test.cjs
Normal file
61
apps/desktop/electron/git-ipc.test.cjs
Normal 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'])
|
||||
})
|
||||
27
apps/desktop/electron/logs-ipc.cjs
Normal file
27
apps/desktop/electron/logs-ipc.cjs
Normal 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 }
|
||||
44
apps/desktop/electron/logs-ipc.test.cjs
Normal file
44
apps/desktop/electron/logs-ipc.test.cjs
Normal 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')
|
||||
})
|
||||
@@ -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).
|
||||
|
||||
55
apps/desktop/electron/project-dir-ipc.cjs
Normal file
55
apps/desktop/electron/project-dir-ipc.cjs
Normal 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 }
|
||||
63
apps/desktop/electron/project-dir-ipc.test.cjs
Normal file
63
apps/desktop/electron/project-dir-ipc.test.cjs
Normal 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')
|
||||
})
|
||||
89
apps/desktop/electron/terminal-ipc.cjs
Normal file
89
apps/desktop/electron/terminal-ipc.cjs
Normal 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 }
|
||||
69
apps/desktop/electron/terminal-ipc.test.cjs
Normal file
69
apps/desktop/electron/terminal-ipc.test.cjs
Normal 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/
|
||||
)
|
||||
})
|
||||
14
apps/desktop/electron/uninstall-ipc.cjs
Normal file
14
apps/desktop/electron/uninstall-ipc.cjs
Normal 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 }
|
||||
51
apps/desktop/electron/uninstall-ipc.test.cjs
Normal file
51
apps/desktop/electron/uninstall-ipc.test.cjs
Normal 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', ''])
|
||||
})
|
||||
41
apps/desktop/electron/updates-ipc.cjs
Normal file
41
apps/desktop/electron/updates-ipc.cjs
Normal 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 }
|
||||
76
apps/desktop/electron/updates-ipc.test.cjs
Normal file
76
apps/desktop/electron/updates-ipc.test.cjs
Normal 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')
|
||||
})
|
||||
17
apps/desktop/electron/version-ipc.cjs
Normal file
17
apps/desktop/electron/version-ipc.cjs
Normal 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 }
|
||||
41
apps/desktop/electron/version-ipc.test.cjs
Normal file
41
apps/desktop/electron/version-ipc.test.cjs
Normal 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)
|
||||
})
|
||||
19
apps/desktop/electron/vscode-theme-ipc.cjs
Normal file
19
apps/desktop/electron/vscode-theme-ipc.cjs
Normal 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 }
|
||||
30
apps/desktop/electron/vscode-theme-ipc.test.cjs
Normal file
30
apps/desktop/electron/vscode-theme-ipc.test.cjs
Normal 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')
|
||||
}
|
||||
})
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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[@]}"
|
||||
@@ -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"}})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"}')
|
||||
|
||||
@@ -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):
|
||||
|
||||
53
tests/agent/test_verify_hooks.py
Normal file
53
tests/agent/test_verify_hooks.py
Normal 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
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
```
|
||||
|
||||
|
||||
Reference in New Issue
Block a user