diff --git a/changelog.d/hermes-chat-command-discovery.md b/changelog.d/hermes-chat-command-discovery.md
new file mode 100644
index 000000000..83af050ae
--- /dev/null
+++ b/changelog.d/hermes-chat-command-discovery.md
@@ -0,0 +1,30 @@
+### Added
+- Hermes Chat suggests `/retry` and eligible individual skills from the selected native process
+ and profile. Project skills require native trust and current scan proof. Ink transcripts can
+ resolve a unique process-owned native runtime session.
+- Recognized single-skill messages show the user request and a skill chip instead of expanded
+ instructions. Native storage and model input stay unchanged; unknown or clipped envelopes
+ remain verbatim.
+- Hermes `/help`, `/status`, `/reload-skills`, `/version` and `/profile` show native informational
+ cards, with key-free dismissal or explicit Ink pager controls. Successful native reloads refresh
+ suggestions and transcript skill matching for the selected PC and pane. `/plan`, `/init` and
+ `/review` retain native execution and transcript persistence. Recognized approval cards retain
+ warnings and require explicit consent. Unknown menus use Terminal; `/diff` and `/context` remain
+ outside the Chat command scope.
+
+### Fixed
+- Hermes `/profile` cards handle redraws that place the command echo inside or after the result;
+ ambiguous earlier results remain in Terminal.
+- Repeated Hermes skill reloads refresh suggestions and transcript chips even when the native
+ result text is identical; dismissing a result leaves its inventory refresh running.
+- Stale Hermes approval or pager text above a new question no longer hides the fallback card,
+ including when a later unrelated box has its own closing border. Active unreadable native panels
+ still require Terminal.
+- Hermes profile discovery reuses gateway start-time evidence across candidate profiles and
+ rechecks process lifetimes before returning the selected profile.
+- Plain text inside Markdown emphasis and links avoids unnecessary delimiter-scanner allocations
+ without changing parsed content.
+- Displaced terminal attachments hold takeover diagnostics until exit can confirm the refusal,
+ rather than painting the diagnostic into the pane.
+- A terminal released while another app held it restores typing when its fresh attach becomes
+ ready, even before the new output stream arrives.
diff --git a/docs/guide.md b/docs/guide.md
index 5eaef09a5..3e22f68f8 100644
--- a/docs/guide.md
+++ b/docs/guide.md
@@ -152,10 +152,45 @@ Every agent herdr runs shows up with its live status, terminal and alerts. The c
| **OpenCode** | Native session in OpenCode 2's own database, resolved through herdr: `~/.local/share/opencode/opencode.db` (`%USERPROFILE%\.local\share\opencode\opencode.db` on Windows), or where the server's `XDG_DATA_HOME` or `OPENCODE_DB` puts it | — use Terminal |
| **Devin CLI** | Native SQLite session, showing the active branch when herdr or the live process explicitly identifies the session; otherwise terminal text | — use Terminal |
| **Grok** | Linux native update log with exact process/session evidence, on the `grok-1.0.50-linux-x86_64` build only; other builds use Terminal. See [Grok setup and limits](grok-chat.md). | — use Terminal |
-| **Hermes** | Native SQLite session store | — use Terminal |
+| **Hermes** | Native SQLite session store; informational commands use terminal-backed result cards; plan/init/review use native transcripts | Clarify questions, recognized native approvals and Ink informational-pager controls; other menus use Terminal |
| **Antigravity (agy)** | **Experimental; macOS untested.** Native ledger, selected by the running process’s held conversation lock. Linux native-tested; Windows uses terminal text. See [Agy behavior and limits](agy-chat.md). | — use Terminal |
| **Anything else** | The terminal's text | — use Terminal |
+Hermes command suggestions include `/retry` and eligible individual skills from the selected
+native process's profile. Resolved panes also offer `/help`, `/status`, `/reload-skills`, `/version`,
+`/profile`, `/plan`, `/review` and `/init`. `/help` shows
+the visible native help output; **Dismiss** hides the card without typing into the terminal.
+Use Terminal to scroll the full help. Classic CLI `/status` is a read-only card with **Dismiss**.
+Ink `/status` shows the native pager's current page with **Previous page**, **Next page**
+(when another page exists), and **Close**. These are live terminal-backed cards, not persisted
+transcript turns. Unknown layouts still require Terminal. Other built-ins remain terminal-only.
+`/reload-skills` shows the native reload result with the same classic dismiss or Ink pager controls.
+After each recognized successful reload, Chat refreshes command suggestions and transcript skill
+matching for that pane and PC without a page reload, even when the result text is unchanged.
+Dismissal does not cancel an in-flight refresh. Added and removed skills appear in the native
+result; failed reloads do not trigger a refresh.
+`/version` shows the native version and installation diagnostics, including any native update-status
+message. Classic CLI uses **Dismiss**; Ink uses its native pager controls. It does not install updates.
+`/profile` shows the selected native profile and home, with classic **Dismiss** or Ink pager controls.
+These cards show terminal output, not stored turns. `/diff` remains outside Chat suggestions and
+dedicated result cards; use `git diff` in Terminal instead.
+`/plan [task]` asks Hermes to write its plan under the active workspace's `.hermes/plans/` directory.
+`/init [notes]` asks Hermes to create or update that workspace's `AGENTS.md`; recognized native
+approval cards retain the warning and offer only the choices Hermes displays. Nothing is approved
+automatically. `/review [instructions]` starts Hermes's independent background review; its native
+completion batch and the primary agent's response appear in the transcript. Plan, init and review
+keep native behavior and persistence; Chat does not write files or manufacture review results.
+`/context` remains outside suggestions: the verified installed Ink fallback runs without the live
+agent and reports "No active agent" rather than the conversation's context usage.
+Discovery reads installed native declarations without executing Python. Unresolved process
+identity exposes no profile resources. Project skills require both
+native project trust and a current safe or caution scan attestation; ambiguous modern skill
+identifiers and unverified app requirements are omitted. Ink transcript lookup accepts only a
+unique native session bound to the pane's gateway PID and process lifetime.
+Recognized single-skill invocations show the actual user request and a skill chip instead of
+the expanded skill document. A bare invocation shows only the chip. This changes presentation,
+not Hermes's stored messages or model input. Unknown, quoted and clipped envelopes remain raw text.
+
GJC chat requires GJC 0.18.9 or later for its broker-bound SDK session CLI, with `gjc`
on the server PC's PATH and its SDK broker already running. An older GJC opens in Terminal.
Git must also be on PATH for the SDK's workspace-scope discovery.
diff --git a/scripts/chat-size-regression.ts b/scripts/chat-size-regression.ts
index f6067d201..af8204c11 100644
--- a/scripts/chat-size-regression.ts
+++ b/scripts/chat-size-regression.ts
@@ -502,7 +502,8 @@ export async function checkInactiveAttachLifecycle(browser: Browser, origin: str
};
listeners.add(check); check();
});
- const routed = queueScenario || scenario === "writes";
+ const routed = queueScenario || scenario === "writes" || scenario === "held";
+ /** Read captured frames from the routed socket or the browser's live recording. */
const recorded = async (): Promise => {
if (routed) return [...wire];
assert.ok(page);
@@ -550,7 +551,7 @@ export async function checkInactiveAttachLifecycle(browser: Browser, origin: str
}
page = await openRecording(browser, contexts, origin, scenario === "held" ? heldPane : first,
{ viewport: { width: 1000, height: 700 } }, { language: "en", defaultView: queueScenario ? "chat" : "terminal", releasePaneAway: true },
- scenario === "mount", queueScenario || scenario === "writes" ? setup : undefined);
+ scenario === "mount", routed ? setup : undefined);
if (scenario === "held") {
await page.getByText("Another app has this pane open.", { exact: false }).waitFor();
await pauseClock(page);
@@ -563,6 +564,8 @@ export async function checkInactiveAttachLifecycle(browser: Browser, origin: str
await page.locator(".terminal-banner", { hasText: "View only while you use another window" }).waitFor();
assert.ok(holder);
await stopHolder(holder);
+ // Input readiness must restore typing even while the new stream is delayed.
+ holdOutput = true;
await page.evaluate(() => {
Object.defineProperty(document, "hasFocus", { configurable: true, value: () => true });
window.dispatchEvent(new Event("focus"));
@@ -574,9 +577,19 @@ export async function checkInactiveAttachLifecycle(browser: Browser, origin: str
"a fresh resume must clear the previous attach's held state");
await page.clock.resume();
await page.locator(".xterm-helper-textarea").focus();
- await page.keyboard.type("z");
- await page.waitForFunction(() => (window as unknown as { frames_: Frame[] }).frames_
- .some((f) => f.dir === "out" && f.type === "input"), undefined, { timeout: 10_000 });
+ const typed = `held-resume-${crypto.randomUUID()}`;
+ await page.keyboard.type(typed);
+ await waitWire("out", "input");
+ const inputDeadline = Date.now() + 10_000;
+ let nativeText = "";
+ while (Date.now() < inputDeadline) {
+ nativeText = (await paneRead({ paneId: heldPane, source: "visible", format: "text" })).text.replace(/\s/g, "");
+ if (nativeText.includes(typed)) break;
+ await Bun.sleep(50);
+ }
+ assert.ok(nativeText.includes(typed), "typing after input-ready reaches the native pane before output is delivered");
+ holdOutput = false;
+ for (const send of heldOutput.splice(0)) send();
console.log("PASS inactive attach lifecycle: held");
continue;
}
diff --git a/server/codex.test.ts b/server/codex.test.ts
index 4a5d0d580..f3f44d6c4 100644
--- a/server/codex.test.ts
+++ b/server/codex.test.ts
@@ -638,6 +638,15 @@ describe("Codex rollout resolution", () => {
expect(matchShortCodexAnswers(`${shortScreen.join("\n\n")}\nOpenAI Codex (v1.0)\nNew session`, [shared[0]!])).toBeNull();
});
+ it("keeps byte-identical rollouts ambiguous and screen matches local to each call", () => {
+ const candidate = { path: "one", text: shortRollout };
+ const screen = shortScreen.join("\n\n");
+ expect(matchShortCodexAnswers(screen, [candidate])).toBe("one");
+ expect(matchShortCodexAnswers(screen, [candidate, { ...candidate, path: "copy" }])).toBeNull();
+ expect(matchShortCodexAnswers("Different pane", [candidate])).toBeNull();
+ expect(matchShortCodexAnswers(screen, [candidate])).toBe("one");
+ });
+
// the review of #284: each case once bound the wrong rollout, or lost the right one
const looked = "I'll look at the file and run the tests.";
const changed = "I'll make the change and verify the result.";
diff --git a/server/codex.ts b/server/codex.ts
index b6553e4e1..e76300eb3 100644
--- a/server/codex.ts
+++ b/server/codex.ts
@@ -831,22 +831,26 @@ export function matchCodexTranscript(screen: string, candidates: { path: string;
return matching.size === 1 ? [...matching][0]! : null;
}
-/**
- * A text cut at its markdown link targets: each stretch of text with the target that follows it
- * ("" after the last). A scan, not a split at /\]\([^)\s]*\)/: that rescans to the end of the
- * text from every "](" no ")" closes, quadratic in a text full of them.
- */
-function cutAtLinkTargets(text: string): { text: string; target: string }[] {
- const pieces: { text: string; target: string }[] = [];
- let start = 0;
+/** Link target boundaries, scanned once even when many openings have no closing parenthesis. */
+function* linkTargets(text: string): Generator<{ at: number; end: number }> {
let search = 0;
for (let at = text.indexOf("](", search); at >= 0; at = text.indexOf("](", search)) {
let end = at + 2;
while (end < text.length && text[end] !== ")" && !/\s/.test(text[end]!)) end++;
// unclosed: no "](" before `end` is closed either, so all of it stays text
if (text[end] !== ")") { search = end; continue; }
+ yield { at, end };
+ search = end + 1;
+ }
+}
+
+/** Each stretch of text with the markdown link target that follows it ("" after the last). */
+function cutAtLinkTargets(text: string): { text: string; target: string }[] {
+ const pieces: { text: string; target: string }[] = [];
+ let start = 0;
+ for (const { at, end } of linkTargets(text)) {
pieces.push({ text: text.slice(start, at), target: text.slice(at + 2, end) });
- start = search = end + 1;
+ start = end + 1;
}
pieces.push({ text: text.slice(start), target: "" });
return pieces;
@@ -858,7 +862,7 @@ function cutAtLinkTargets(text: string): { text: string; target: string }[] {
* own way, so the answer is not one run of text there, and its pieces alone are short common words.
*/
function shownAnswer(text: string): string | null {
- return cutAtLinkTargets(text).length > 1 ? null : normalizeDisplay(text).slice(-160);
+ return !linkTargets(text).next().done ? null : normalizeDisplay(text).slice(-160);
}
/**
@@ -897,12 +901,22 @@ export function matchShortCodexAnswers(screen: string, candidates: { path: strin
if (candidates.some((candidate) => candidate.cut)) return null;
const lastHeader = screen.lastIndexOf("OpenAI Codex (v");
const display = normalizeDisplay(lastHeader >= 0 ? screen.slice(lastHeader) : screen);
- // every turn of what was read, not the newest 100: an answer said long ago is said
- const answers = candidates.map((candidate) => parseCodexTranscript(candidate.text, Infinity).filter((turn) => turn.role === "assistant")
- .flatMap((turn) => turn.parts).flatMap((part) => part.kind === "text" ? [part.text] : []));
+ // Identical rollout bytes can belong to different sessions. Reuse their parsing and
+ // display work only within this call; keep every candidate below to reject ambiguity.
+ const parsed = new Map();
+ const answers = candidates.map((candidate) => {
+ const known = parsed.get(candidate.text);
+ if (known !== undefined) return known;
+ // Every turn of what was read, not the newest 100: an answer said long ago is said.
+ const prose = parseCodexTranscript(candidate.text, Infinity).filter((turn) => turn.role === "assistant")
+ .flatMap((turn) => turn.parts).flatMap((part) => part.kind === "text" ? [part.text] : []);
+ const own = { answers: prose, shown: newestAnswersShown(display, prose) };
+ parsed.set(candidate.text, own);
+ return own;
+ });
let found: { index: number; shown: string[] } | null = null;
for (const [index, own] of answers.entries()) {
- const shown = newestAnswersShown(display, own);
+ const shown = own.shown;
if (shown === null) continue;
if (found !== null) return null;
found = { index, shown };
@@ -911,13 +925,13 @@ export function matchShortCodexAnswers(screen: string, candidates: { path: strin
const { index: only, shown } = found;
for (const [index, other] of answers.entries()) {
if (index === only) continue;
- const said = normalizeDisplay(other.join("\n"));
+ const said = normalizeDisplay(other.answers.join("\n"));
if (shown.some((answer) => said.includes(answer))) return null;
// an answer of the other with links shows as its text around them, the targets Codex's own
// way: when that text is all in a shown answer, in order, the screen may be showing the other
// every such answer of what was read, however short its text or many its links: one left
// unchecked is one the screen may be showing
- for (const text of other) {
+ for (const text of other.answers) {
const pieces = cutAtLinkTargets(text);
if (pieces.length < 2) continue;
const around = pieces.map((piece) => normalizeDisplay(piece.text)).filter((piece) => piece !== "");
diff --git a/server/commands.ts b/server/commands.ts
index fa530b8f7..0ae31c090 100644
--- a/server/commands.ts
+++ b/server/commands.ts
@@ -1,6 +1,7 @@
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { piAgentDir } from "./pi-models.ts";
import { join, relative, sep } from "node:path";
+import { hermesCommands, HERMES_BUILTIN_COMMANDS, type HermesCommandContext } from "./hermes-commands.ts";
import type { SlashCommand } from "../shared/protocol.ts";
@@ -14,6 +15,7 @@ const BUILTINS: Record = {
// chat would walk a reader into a state only the terminal lens can leave. Navigating stays in the
// terminal; the chat's place is the abandoned-turns marker, which says where a /tree left off
pi: ["settings", "model", "thinking", "scoped-models", "login", "logout", "llama", "new", "resume", "name", "session", "fork", "clone", "compact", "import", "copy", "export", "share", "bug", "trust", "reload", "hotkeys", "changelog", "quit"],
+ hermes: HERMES_BUILTIN_COMMANDS.map((c) => c.name),
};
/** pi states what each command opens or does, in its own words, read off its palette. */
@@ -142,8 +144,20 @@ function piTemplates(root: string, source: SlashCommand["source"]): SlashCommand
return result;
}
-export function paneCommands(agent: string | null | undefined, cwd: string | null | undefined, home = process.env.HOME ?? ""): SlashCommand[] {
+/**
+ * Build suggestions from agent built-ins and the native resources supported here; unknown agents return none.
+ * Hermes profile resources require selected-process context, and pi project resources are omitted without trust proof.
+ */
+export function paneCommands(
+ agent: string | null | undefined,
+ cwd: string | null | undefined,
+ home = process.env.HOME ?? "",
+ options: { hermesContext?: HermesCommandContext | null } = {},
+): SlashCommand[] {
if (!agent || !(agent in BUILTINS)) return [];
+ if (agent === "hermes") {
+ return hermesCommands(cwd, options.hermesContext);
+ }
const commands: SlashCommand[] = (BUILTINS[agent] ?? []).map((name) => ({ name, description: (agent === "pi" ? PI_DESCRIPTIONS[name] : undefined) ?? DESCRIPTIONS[name] ?? `Run /${name}`, source: "builtin" }));
if (agent === "claude") {
commands.push(...customCommands(join(home, ".claude", "commands"), "user"));
diff --git a/server/conventions.test.ts b/server/conventions.test.ts
index 4a2a2be85..8b93e6e49 100644
--- a/server/conventions.test.ts
+++ b/server/conventions.test.ts
@@ -191,7 +191,6 @@ describe("herdr socket", () => {
const HERDR_RPC_ALLOWED: Record = {
"server/conversation.ts": "open PRs #869 #912 #923 change it",
"server/gjc-runtime.ts": "open PR #912 changes it",
- "server/hermes.ts": "open PR #910 changes it",
"server/omo.ts": "open PR #923 changes it",
"server/shell-agent.ts": "open PRs #885 #923 change it",
};
@@ -200,7 +199,6 @@ describe("herdr socket", () => {
// paneProcessInfo(paneId) (the better outcome), merges, or closes.
const HERDR_RPC_PENDING: Record = {
"server/gjc-status.ts": "open PR #928 adds a call",
- "server/index.ts": "open PR #859 adds a call",
};
it("calls herdrRpc only inside server/herdr/", () => {
diff --git a/server/hermes-commands.test.ts b/server/hermes-commands.test.ts
new file mode 100644
index 000000000..bc0903eac
--- /dev/null
+++ b/server/hermes-commands.test.ts
@@ -0,0 +1,265 @@
+import { afterEach, describe, expect, it } from "bun:test";
+import { createHash } from "node:crypto";
+import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { discoverHermesSkills, hermesCommands, hermesNativeFeatures, readHermesFile, type HermesCommandContext } from "./hermes-commands.ts";
+import { paneCommands } from "./commands.ts";
+
+const directories: string[] = [];
+/** Allocate an isolated fixture root and register it for afterEach removal. */
+function temp(): string {
+ const path = mkdtempSync(join(tmpdir(), "hermes-discovery-"));
+ directories.push(path);
+ return path;
+}
+afterEach(() => { for (const path of directories.splice(0)) rmSync(path, { recursive: true, force: true }); });
+/** Write a fixture file after creating its parent directories. */
+function put(path: string, content: string): void {
+ mkdirSync(join(path, ".."), { recursive: true });
+ writeFileSync(path, content);
+}
+/** Create a minimal native skill with optional YAML metadata and return its bundle directory. */
+function skill(root: string, name: string, description = name, extra = ""): string {
+ const dir = join(root, name);
+ put(join(dir, "SKILL.md"), `---\nname: ${name}\ndescription: ${description}\n${extra}\n---\nBody\n`);
+ return dir;
+}
+/** Supply modern native discovery facts without reading the user's profile or process environment. */
+function context(home = temp()): HermesCommandContext {
+ return { home, userHome: home, env: {}, legacy: false, scannerVersion: "test-scanner", coreCommands: new Set(["retry", "status", "reset"]), docker: false, s6: false };
+}
+/** Write a native scan attestation for a SKILL.md-only bundle and return the cache file for later mutation. */
+function approve(dir: string, ctx: HermesCommandContext, verdict = "safe"): string {
+ const digest = createHash("sha256").update("SKILL.md\0").update(readFileSync(join(dir, "SKILL.md"))).digest("hex");
+ const source = createHash("sha256").update("project-local\0").digest("hex").slice(0, 16);
+ const path = join(ctx.home, "cache", "project_skill_scans", `${digest}-${source}.json`);
+ put(path, JSON.stringify({ bundle_hash: `sha256:${digest}`, scanner_version: ctx.scannerVersion, source: "project-local", source_url: "", verdict }));
+ return path;
+}
+
+describe("Hermes discovery", () => {
+ it("does not discover resources without selected-process identity", () => {
+ const cwd = temp();
+ skill(join(cwd, ".hermes", "skills"), "unbound");
+ expect(hermesCommands(cwd, null).filter(c => c.source !== "builtin")).toEqual([]);
+ expect(paneCommands("hermes", cwd, undefined, { hermesContext: null }).filter(c => c.source !== "builtin")).toEqual([]);
+ });
+ it("offers native result commands only for a resolved process", () => {
+ expect(hermesCommands(null, null).some(command => command.name === "status")).toBeFalse();
+ const commands = hermesCommands(null, context());
+ expect(commands.find(command => command.name === "help")?.source).toBe("builtin");
+ expect(commands.find(command => command.name === "status")?.source).toBe("builtin");
+ expect(commands.find(command => command.name === "reload-skills")?.source).toBe("builtin");
+ expect(commands.some(command => command.name === "diff")).toBeFalse();
+ });
+
+ it("requires trust and current native scan proof before project precedence applies", () => {
+ const ctx = context(), repo = temp(), external = temp();
+ put(join(repo, ".git", "config"), "");
+ put(join(ctx.home, "config.yaml"), `skills:\n trusted_project_dirs: [${repo}]\n external_dirs: [${external}]\n`);
+ skill(join(ctx.home, "skills"), "deploy", "profile");
+ skill(external, "deploy", "external");
+ const project = skill(join(repo, ".hermes", "skills"), "deploy", "project");
+ expect(discoverHermesSkills(repo, ctx).find(c => c.name === "deploy")?.description).toBe("profile");
+ approve(project, ctx);
+ expect(discoverHermesSkills(repo, ctx).find(c => c.name === "deploy")?.description).toBe("project");
+ put(join(project, "script.sh"), "changed bundle bytes");
+ expect(discoverHermesSkills(repo, ctx).find(c => c.name === "deploy")?.description).toBe("profile");
+ });
+
+ it("rejects dangerous attestations and untrusted project roots", () => {
+ const ctx = context(), repo = temp();
+ put(join(repo, ".git", "config"), "");
+ const dir = skill(join(repo, ".hermes", "skills"), "project");
+ approve(dir, ctx);
+ expect(discoverHermesSkills(repo, ctx)).toEqual([]);
+ put(join(ctx.home, "config.yaml"), `skills:\n trusted_project_dirs: [${repo}]\n`);
+ approve(dir, ctx, "dangerous");
+ expect(discoverHermesSkills(repo, ctx)).toEqual([]);
+ });
+
+ it("reserves native commands, quick commands and bundles", () => {
+ const ctx = context();
+ put(join(ctx.home, "config.yaml"), "quick_commands:\n quick: {type: exec, command: echo test}\n");
+ put(join(ctx.home, "skill-bundles", "pack.yaml"), "name: pack\nskills: [helper]\n");
+ for (const name of ["status", "reset", "quick", "pack", "diff", "context", "available"]) skill(join(ctx.home, "skills"), name);
+ expect(discoverHermesSkills(null, ctx).map(c => c.name)).toEqual(["available"]);
+ });
+
+ it("rejects ambiguous modern identifiers but preserves sorted legacy precedence", () => {
+ const ctx = context();
+ skill(join(ctx.home, "skills", "a"), "duplicate", "first");
+ skill(join(ctx.home, "skills", "b"), "duplicate", "second");
+ expect(discoverHermesSkills(null, ctx)).toEqual([]);
+ expect(discoverHermesSkills(null, { ...ctx, legacy: true }).map(c => c.description)).toEqual(["first"]);
+ });
+
+ it("uses selected environment gates and excludes unknown app requirements", () => {
+ const ctx = context();
+ skill(join(ctx.home, "skills"), "docker", "docker", "environments: [docker]");
+ skill(join(ctx.home, "skills"), "unknown", "unknown", "environments: [future-runtime]");
+ skill(join(ctx.home, "skills"), "app", "app", "requires_apps: [calendar]");
+ expect(discoverHermesSkills(null, ctx).map(c => c.name)).toEqual(["unknown"]);
+ expect(discoverHermesSkills(null, { ...ctx, docker: true }).map(c => c.name)).toEqual(["docker", "unknown"]);
+ });
+
+ it("expands external roots against selected-process variables and isolates profiles", () => {
+ const a = context(), b = context(), external = temp();
+ skill(join(a.home, "skills"), "alpha");
+ skill(join(b.home, "skills"), "beta");
+ skill(external, "external");
+ put(join(a.home, "config.yaml"), 'skills:\n external_dirs: ["${SKILLS_ROOT}"]\n');
+ a.env = { SKILLS_ROOT: external };
+ expect(discoverHermesSkills(null, a).map(c => c.name)).toEqual(["alpha", "external"]);
+ expect(discoverHermesSkills(null, b).map(c => c.name)).toEqual(["beta"]);
+ });
+
+ it("prunes support directories and fails closed on malformed profile configuration", () => {
+ const ctx = context();
+ const parent = skill(join(ctx.home, "skills"), "parent");
+ skill(join(parent, "scripts"), "hidden");
+ skill(join(ctx.home, "skills", ".archive"), "archived");
+ expect(discoverHermesSkills(null, ctx).map(c => c.name)).toEqual(["parent"]);
+ put(join(ctx.home, "config.yaml"), "skills: [invalid: {\n");
+ expect(discoverHermesSkills(null, ctx)).toEqual([]);
+ });
+
+ it("rejects attestations from another scanner or source", () => {
+ const ctx = context(), repo = temp();
+ put(join(repo, ".git", "config"), "");
+ put(join(ctx.home, "config.yaml"), `skills:\n trusted_project_dirs: [${repo}]\n`);
+ const dir = skill(join(repo, ".hermes", "skills"), "project");
+ for (const fields of [{ scanner_version: "obsolete" }, { source: "external" }, { source_url: "https://example.invalid" }, { bundle_hash: "sha256:wrong" }]) {
+ const path = approve(dir, ctx);
+ const attestation = JSON.parse(readFileSync(path, "utf8"));
+ put(path, JSON.stringify({ ...attestation, ...fields }));
+ expect(discoverHermesSkills(repo, ctx)).toEqual([]);
+ }
+ });
+
+ it("bounds every directory entry, including non-skill files", () => {
+ const ctx = context();
+ skill(join(ctx.home, "skills"), "available");
+ for (let i = 0; i < 4097; i++) put(join(ctx.home, "skills", `file-${i}`), "");
+ expect(discoverHermesSkills(null, ctx)).toEqual([]);
+ });
+
+ it("shares a byte budget across profile and external roots", () => {
+ const ctx = context(), external = temp();
+ put(join(ctx.home, "config.yaml"), `skills:\n external_dirs: [${external}]\n`);
+ const body = "x".repeat(1_048_400);
+ for (let i = 0; i < 17; i++) {
+ const root = i < 9 ? join(ctx.home, "skills") : external;
+ const dir = skill(root, `large-${i}`);
+ put(join(dir, "SKILL.md"), `${readFileSync(join(dir, "SKILL.md"), "utf8")}${body}`);
+ }
+ expect(discoverHermesSkills(null, ctx)).toEqual([]);
+ });
+
+ it("rejects oversized and non-regular files before reading their contents", () => {
+ const home = temp(), file = join(home, "large");
+ put(file, "x".repeat(1025));
+ expect(() => readHermesFile(file, 1024)).toThrow();
+ expect(() => readHermesFile(home)).toThrow();
+ });
+
+ it("honors project discovery being disabled even with trust and current attestation", () => {
+ const ctx = context(), repo = temp();
+ put(join(repo, ".git", "config"), "");
+ put(join(ctx.home, "config.yaml"), `skills:\n project_discovery: false\n trusted_project_dirs: [${repo}]\n`);
+ const dir = skill(join(repo, ".hermes", "skills"), "project");
+ approve(dir, ctx);
+ skill(join(ctx.home, "skills"), "local");
+ expect(discoverHermesSkills(repo, ctx).map(command => command.name)).toEqual(["local"]);
+ });
+
+ it("does not reclassify the profile's own skills as quarantined project resources", () => {
+ const repo = temp(), ctx = context(join(repo, ".hermes"));
+ put(join(repo, ".git", "config"), "");
+ put(join(ctx.home, "config.yaml"), `skills:\n trusted_project_dirs: [${repo}]\n`);
+ skill(join(ctx.home, "skills"), "local");
+ expect(discoverHermesSkills(repo, ctx).map(command => command.name)).toEqual(["local"]);
+ });
+
+ it("resolves trust paths from the native cwd and does not decode array-shaped scalar paths", () => {
+ const ctx = context(), repo = temp();
+ ctx.cwd = repo;
+ put(join(repo, ".git", "config"), "");
+ const dir = skill(join(repo, ".hermes", "skills"), "project");
+ approve(dir, ctx);
+ put(join(ctx.home, "config.yaml"), 'skills:\n trusted_project_dirs: ["."]\n');
+ expect(discoverHermesSkills(repo, ctx).map(command => command.name)).toEqual(["project"]);
+ put(join(ctx.home, "config.yaml"), `skills:\n trusted_project_dirs: '${JSON.stringify([repo])}'\n`);
+ expect(discoverHermesSkills(repo, ctx)).toEqual([]);
+ });
+
+ it("reads the legacy active-org marker only from its native mirror directory", () => {
+ const ctx = { ...context(), legacy: true }, root = join(ctx.home, "skills");
+ skill(join(root, "_org", "active"), "visible");
+ skill(join(root, "_org", "foreign"), "hidden");
+ put(join(root, ".active_org"), "active");
+ expect(discoverHermesSkills(null, ctx)).toEqual([]);
+ put(join(root, "_org", ".active_org"), "active");
+ expect(discoverHermesSkills(null, ctx).map(command => command.name)).toEqual(["visible"]);
+ });
+
+ it("keeps non-string platform and app requirements from failing open", () => {
+ const ctx = context(), root = join(ctx.home, "skills");
+ skill(root, "numeric-platform", "hidden", "platforms: [42]");
+ skill(root, "array-looking-platform", "hidden", 'platforms: "[linux]"');
+ skill(root, "numeric-app", "hidden", "requires_apps: [42]");
+ skill(root, "mapping-app", "hidden", "requires_apps: {calendar: true}");
+ skill(root, "available");
+ expect(discoverHermesSkills(null, ctx).map(command => command.name)).toEqual(["available"]);
+ });
+
+ it("resolves relative bundle overrides from the selected process cwd", () => {
+ const ctx = context();
+ ctx.cwd = temp();
+ ctx.env = { HERMES_BUNDLES_DIR: "packs" };
+ put(join(ctx.cwd, "packs", "pack.yaml"), "name: pack\nskills: [helper]\n");
+ put(join(ctx.home, "packs", "wrong.yaml"), "name: available\nskills: [helper]\n");
+ skill(join(ctx.home, "skills"), "pack");
+ skill(join(ctx.home, "skills"), "available");
+ expect(discoverHermesSkills(null, ctx).map(command => command.name)).toEqual(["available"]);
+ expect(discoverHermesSkills(null, { ...ctx, cwd: undefined })).toEqual([]);
+ });
+
+ it("does not reserve invalid bundles with empty skill entries", () => {
+ const ctx = context();
+ put(join(ctx.home, "skill-bundles", "pack.yaml"), 'name: pack\nskills: ["", " "]\n');
+ skill(join(ctx.home, "skills"), "pack");
+ expect(discoverHermesSkills(null, ctx).map(command => command.name)).toEqual(["pack"]);
+ });
+
+ it("reserves aliases declared by the installed native command registry", () => {
+ const source = temp(), ctx = context();
+ put(join(source, "agent", "skill_commands.py"), "for entry in _skill_catalog():\n pass\n");
+ put(join(source, "tools", "skills_guard.py"), 'SCANNER_VERSION = "current-scanner"\n');
+ put(join(source, "hermes_cli", "commands.py"), 'COMMAND_REGISTRY = [\n CommandDef("prompt", "Compose", "Session", aliases=("compose",)),\n CommandDef("new", "New", "Session", aliases=("reset",)),\n]\n');
+ const features = hermesNativeFeatures(source)!;
+ expect(features.legacy).toBeFalse();
+ expect(features.scannerVersion).toBe("current-scanner");
+ skill(join(ctx.home, "skills"), "compose");
+ skill(join(ctx.home, "skills"), "reset");
+ skill(join(ctx.home, "skills"), "available");
+ expect(discoverHermesSkills(null, { ...ctx, ...features }).map(command => command.name)).toEqual(["available"]);
+ });
+
+ it("does not expose a lower-priority skill when an oversized file may own its identifier", () => {
+ const ctx = context(), external = temp();
+ put(join(ctx.home, "config.yaml"), `skills:\n external_dirs: [${external}]\n`);
+ const dir = skill(join(ctx.home, "skills"), "deploy", "profile");
+ put(join(dir, "SKILL.md"), `${readFileSync(join(dir, "SKILL.md"), "utf8")}${"x".repeat(1_048_576)}`);
+ skill(external, "deploy", "external");
+ expect(discoverHermesSkills(null, ctx)).toEqual([]);
+ });
+
+ it("does not expose skills when a bounded bundle read leaves collisions unknown", () => {
+ const ctx = context();
+ put(join(ctx.home, "skill-bundles", "pack.yaml"), `name: pack\nskills: [helper]\n# ${"x".repeat(1_048_576)}`);
+ skill(join(ctx.home, "skills"), "pack");
+ expect(discoverHermesSkills(null, ctx)).toEqual([]);
+ });
+});
diff --git a/server/hermes-commands.ts b/server/hermes-commands.ts
new file mode 100644
index 000000000..07b5d4d2a
--- /dev/null
+++ b/server/hermes-commands.ts
@@ -0,0 +1,386 @@
+import { createHash } from "node:crypto";
+import { closeSync, constants, existsSync, fstatSync, openSync, opendirSync, readSync, realpathSync, statSync, type Dirent } from "node:fs";
+import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
+import type { SlashCommand } from "../shared/protocol.ts";
+
+/** Native /retry creates another persisted assistant turn; informational commands do not. */
+export const HERMES_BUILTIN_COMMANDS = [
+ { name: "retry", description: "Retry the last message (requires a previous message)" },
+] as const;
+
+/** Installed native semantics and selected-process facts, never the bridge's environment. */
+export interface HermesCommandContext {
+ home: string;
+ userHome: string;
+ env: Readonly>;
+ cwd?: string;
+ legacy: boolean;
+ scannerVersion: string | null;
+ coreCommands: ReadonlySet;
+ docker: boolean;
+ s6: boolean;
+}
+
+const MAX_FILE_BYTES = 1_048_576;
+const MAX_SCAN_BYTES = 16_777_216;
+const MAX_ENTRIES = 4096;
+const EXCLUDED: Record = { ".git": true, ".github": true, ".hub": true, ".archive": true, ".curator_backups": true, ".locks": true, ".venv": true, venv: true, node_modules: true, "site-packages": true, __pycache__: true, ".tox": true, ".nox": true, ".pytest_cache": true, ".mypy_cache": true, ".ruff_cache": true };
+const SUPPORT: Record = { references: true, templates: true, assets: true, scripts: true };
+const PYTHON_PLATFORM: Record = { macos: "darwin", windows: "win32" };
+type Mapping = Record;
+
+/** Treat non-object YAML or JSON values as empty metadata, excluding arrays. */
+function mapping(value: unknown): Mapping {
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Mapping : {};
+}
+
+/** Read through one bounded file descriptor, including a check for growth after stat. */
+export function readHermesFile(path: string, limit = MAX_FILE_BYTES): Buffer {
+ const fd = openSync(path, constants.O_RDONLY | constants.O_NONBLOCK);
+ try {
+ const stat = fstatSync(fd);
+ if (!stat.isFile()) throw new Error("Hermes discovery requires a regular file");
+ if (stat.size > limit) throw new RangeError("Hermes discovery file exceeds its bound");
+ const bytes = Buffer.alloc(stat.size + 1);
+ let count = 0;
+ while (count < bytes.length) {
+ const read = readSync(fd, bytes, count, bytes.length - count, null);
+ if (read === 0) break;
+ count += read;
+ }
+ const after = fstatSync(fd);
+ if (count !== stat.size || after.size !== stat.size || after.mtimeMs !== stat.mtimeMs || after.ctimeMs !== stat.ctimeMs) {
+ throw new RangeError("Hermes discovery file changed during read");
+ }
+ return bytes.subarray(0, count);
+ } finally { closeSync(fd); }
+}
+
+interface ScanBudget { entries: number; bytes: number }
+
+/** Charge stable file reads to the shared budget; size or mutation errors invalidate the whole scan. */
+function readBytes(path: string, limit = MAX_FILE_BYTES, budget?: ScanBudget): Buffer {
+ if (budget && budget.bytes > MAX_SCAN_BYTES) throw new Error("Hermes discovery exceeds its byte bound");
+ let bytes: Buffer;
+ try { bytes = readHermesFile(path, budget ? Math.min(limit, MAX_SCAN_BYTES - budget.bytes) : limit); } catch (error) {
+ // Unknown oversized/changing aliases or bundle names could shadow any
+ // later suggestion. Do not turn a bounded read into partial discovery.
+ if (budget && error instanceof RangeError) budget.bytes = MAX_SCAN_BYTES + 1;
+ throw error;
+ }
+ if (budget && (budget.bytes += bytes.length) > MAX_SCAN_BYTES) throw new Error("Hermes discovery exceeds its byte bound");
+ return bytes;
+}
+
+/** Collect directory entries within the shared scan cap and close the directory even on failure. */
+function entries(path: string, budget: ScanBudget): Dirent[] {
+ const directory = opendirSync(path);
+ try {
+ const result: Dirent[] = [];
+ for (let entry = directory.readSync(); entry !== null; entry = directory.readSync()) {
+ if (++budget.entries > MAX_ENTRIES) throw new Error("Hermes discovery exceeds its entry bound");
+ result.push(entry);
+ }
+ return result;
+ } finally { directory.closeSync(); }
+}
+
+/** Parse a bounded UTF-8 YAML file, tolerating a BOM and treating non-mapping roots as empty. */
+function yaml(path: string, budget?: ScanBudget): Mapping {
+ return mapping(Bun.YAML.parse(readBytes(path, MAX_FILE_BYTES, budget).toString("utf8").replace(/^\uFEFF/, "")));
+}
+
+/** Normalize disable-list values, including YAML array strings, to trimmed nonempty strings. */
+function strings(value: unknown): string[] {
+ if (typeof value === "string" && value.trimStart().startsWith("[")) {
+ try { value = Bun.YAML.parse(value); } catch { return []; }
+ }
+ return (Array.isArray(value) ? value : typeof value === "string" ? [value] : [])
+ .filter((item): item is string => typeof item === "string").map(item => item.trim()).filter(Boolean);
+}
+
+/** Unlike disable lists, native path settings do not decode quoted array strings. */
+function configPaths(value: unknown): string[] {
+ const paths = typeof value === "string" ? [value] : Array.isArray(value) ? value : [];
+ return paths.map(path => String(path).trim()).filter(Boolean);
+}
+
+/** Python's Unicode string order, without locale-dependent collation. */
+function compare(a: string, b: string): number {
+ let i = 0, j = 0;
+ while (i < a.length && j < b.length) {
+ const x = a.codePointAt(i)!, y = b.codePointAt(j)!;
+ if (x !== y) return x - y;
+ i += x > 0xffff ? 2 : 1;
+ j += y > 0xffff ? 2 : 1;
+ }
+ return (a.length - i) - (b.length - j);
+}
+
+/** Match native command-name normalization, retaining Unicode letters and numbers only in modern Hermes. */
+function slug(name: string, legacy: boolean): string {
+ const value = name.toLowerCase().replace(/[ _]/g, "-");
+ return (legacy ? value.replace(/[^a-z0-9-]/g, "") : value.replace(/[^\p{L}\p{N}_-]/gu, ""))
+ .replace(/-{2,}/g, "-").replace(/^-+|-+$/g, "");
+}
+
+/** Resolve configured paths using the selected process's environment and home; reject unresolved home or base identity. */
+function expand(path: string, context: HermesCommandContext, base: string | null = context.home): string {
+ let value = path.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g,
+ (original, braced: string, plain: string) => context.env[braced || plain] ?? original);
+ value = value.replace(/^~(?=\/|$)/, context.userHome);
+ if (value.startsWith("~") || (!isAbsolute(value) && base === null)) throw new Error("Unresolved native path identity");
+ return resolve(isAbsolute(value) ? value : join(base!, value));
+}
+
+/** Extract only declarations; never import or execute an installed Python module. */
+export function hermesNativeFeatures(source: string): Pick | null {
+ try {
+ const scanner = readBytes(join(source, "agent", "skill_commands.py")).toString("utf8");
+ const legacy = !/\b_skill_catalog\s*\(/.test(scanner);
+ if (legacy && !scanner.includes("get_project_skills_dirs")) return null;
+ const guard = readBytes(join(source, "tools", "skills_guard.py")).toString("utf8");
+ const scannerVersion = guard.match(/^SCANNER_VERSION\s*=\s*["']([^"']+)["']/m)?.[1] ?? null;
+ const registry = readBytes(join(source, "hermes_cli", "commands.py")).toString("utf8");
+ const coreCommands = new Set();
+ const definitions = [...registry.matchAll(/CommandDef\(\s*["']([^"']+)["']/g)];
+ for (let i = 0; i < definitions.length; i++) {
+ const match = definitions[i]!;
+ coreCommands.add(match[1]!.toLowerCase());
+ const definition = registry.slice(match.index, definitions[i + 1]?.index ?? registry.length);
+ const aliases = definition.match(/\baliases\s*=\s*\(([^)]*)\)/)?.[1];
+ if (aliases) for (const alias of aliases.matchAll(/["']([^"']+)["']/g)) coreCommands.add(alias[1]!.toLowerCase());
+ }
+ return coreCommands.size ? { legacy, scannerVersion, coreCommands } : null;
+ } catch { return null; }
+}
+
+/** Find the nearest canonical Git root within 64 ancestors, excluding the user's home itself. */
+function projectRoot(cwd: string, userHome: string): string | null {
+ const canonicalHome = realpathSync(userHome);
+ let dir = realpathSync(cwd);
+ for (let depth = 0; depth < 64; depth++) {
+ if (existsSync(join(dir, ".git"))) return dir === canonicalHome ? null : dir;
+ const parent = dirname(dir);
+ if (parent === dir) return null;
+ dir = parent;
+ }
+ return null;
+}
+
+interface Root { path: string; tier: number }
+interface Candidate {
+ path: string;
+ root: Root;
+ name: string;
+ aliases: string[];
+ bytes: Buffer;
+ metadata: Mapping;
+ description: string;
+ eligible: boolean;
+}
+
+/** Native project scans attest all file names and bytes, not just SKILL.md. */
+function projectApproved(dir: string, context: HermesCommandContext, budget: ScanBudget, skillBytes: Buffer): boolean {
+ if (!context.scannerVersion) return false;
+ try {
+ const files: { path: string; name: string }[] = [];
+ /** Inventory regular files for bundle hashing without following directory symlinks, with a 64-level depth cap. */
+ const visit = (path: string, depth: number): void => {
+ if (depth > 64) throw new Error("Hermes skill scan exceeds its depth bound");
+ for (const entry of entries(path, budget)) {
+ const full = join(path, entry.name);
+ // pathlib.rglob does not recurse through directory symlinks.
+ if (entry.isDirectory()) visit(full, depth + 1);
+ else if (statSync(full).isFile()) files.push({ path: full, name: relative(dir, full).split("\\").join("/") });
+ }
+ };
+ visit(dir, 0);
+ files.sort((a, b) => compare(a.name, b.name));
+ const hash = createHash("sha256");
+ for (const file of files) {
+ const bytes = readBytes(file.path, MAX_FILE_BYTES, budget);
+ if (file.name === "SKILL.md" && !bytes.equals(skillBytes)) return false;
+ hash.update(file.name).update("\0").update(bytes);
+ }
+ const digest = hash.digest("hex");
+ const sourceId = createHash("sha256").update("project-local\0").digest("hex").slice(0, 16);
+ const attestation = mapping(JSON.parse(readBytes(join(context.home, "cache", "project_skill_scans", `${digest}-${sourceId}.json`), MAX_FILE_BYTES, budget).toString("utf8").replace(/^\uFEFF/, "")));
+ return attestation.bundle_hash === `sha256:${digest}` && attestation.scanner_version === context.scannerVersion
+ && attestation.source === "project-local" && attestation.source_url === ""
+ && (attestation.verdict === "safe" || attestation.verdict === "caution");
+ } catch { return false; }
+}
+
+/** Apply platform and selected-environment gates; modern app requirements remain hidden without plugin-state proof. */
+function visible(metadata: Mapping, context: HermesCommandContext, config: Mapping): boolean {
+ const platforms = (Array.isArray(metadata.platforms) ? metadata.platforms : metadata.platforms ? [metadata.platforms] : []).map(value => String(value).trim());
+ if (platforms.length && !platforms.some(p => process.platform.startsWith(PYTHON_PLATFORM[p.toLowerCase()] ?? p.toLowerCase()))) return false;
+ const environments = (Array.isArray(metadata.environments) ? metadata.environments : metadata.environments ? [metadata.environments] : []).map(value => String(value).trim()).filter(Boolean);
+ if (environments.length && !environments.some(tag => {
+ switch (tag.toLowerCase()) {
+ case "docker": return context.docker;
+ case "s6": return context.s6;
+ case "kanban": return Boolean(context.env.HERMES_KANBAN_TASK || context.env.HERMES_KANBAN_BOARD)
+ || strings(config.toolsets).includes("kanban") || Object.values(mapping(config.platform_toolsets)).some(v => strings(v).includes("kanban"));
+ default: return true;
+ }
+ })) return false;
+ // App declarations are in-memory plugin state. Unknown availability fails closed,
+ // as native skill_matches_apps does; the legacy runtime has no such gate.
+ const apps = metadata.requires_apps;
+ const requiresApps = Array.isArray(apps) ? apps.length > 0 : apps && (typeof apps !== "object" || Object.keys(apps).length > 0);
+ return context.legacy || !requiresApps;
+}
+
+/** Bounded, sorted native traversal, preserving aliases while stopping symlink cycles. */
+function skillFiles(root: string, legacy: boolean, budget: ScanBudget): string[] {
+ const matches: string[] = [];
+ let activeOrg: string | null = null;
+ if (legacy) {
+ try { activeOrg = readBytes(join(root, "_org", ".active_org"), 4096, budget).toString("utf8").trim() || null; } catch { /* no active org */ }
+ }
+ /** Follow native skill roots with ancestor-only cycle detection, pruning support folders and inactive legacy orgs. */
+ const visit = (dir: string, ancestors: Set, depth: number): void => {
+ if (depth > 64) throw new Error("Hermes traversal exceeds its depth bound");
+ const real = realpathSync(dir);
+ if (ancestors.has(real)) return;
+ const next = new Set(ancestors).add(real);
+ const children = entries(dir, budget);
+ const hasSkill = children.some(e => e.name === "SKILL.md" && statSync(join(dir, e.name)).isFile());
+ if (hasSkill) matches.push(join(dir, "SKILL.md"));
+ for (const entry of children) {
+ if (Object.hasOwn(EXCLUDED, entry.name) || (hasSkill && Object.hasOwn(SUPPORT, entry.name))) continue;
+ if (legacy && dir === root && entry.name === "_org" && !activeOrg) continue;
+ if (legacy && dir === join(root, "_org") && entry.name !== activeOrg) continue;
+ const path = join(dir, entry.name);
+ try { if (statSync(path).isDirectory()) visit(path, next, depth + 1); } catch (error) {
+ if (entry.isDirectory()) throw error;
+ // A broken symlink is not a native skill directory.
+ }
+ }
+ };
+ visit(root, new Set(), 0);
+ return matches.sort(compare);
+}
+
+/** Choose a name's highest-priority alias owner; identical copies in one root need a uniquely shallowest path. */
+function uniqueCandidate(index: number, candidates: Candidate[]): boolean {
+ const candidate = candidates[index]!;
+ const owners = candidates.map((entry, i) => ({ entry, i })).filter(({ entry }) => entry.aliases.includes(candidate.name));
+ const tier = Math.min(...owners.map(({ entry }) => entry.root.tier));
+ const contenders = owners.filter(({ entry }) => entry.root.tier === tier);
+ if (contenders.length === 1) return contenders[0]!.i === index;
+ if (!contenders.every(({ entry }) => entry.root.path === contenders[0]!.entry.root.path && entry.bytes.equals(contenders[0]!.entry.bytes))) return false;
+ const ranked = contenders.sort((a, b) => relative(a.entry.root.path, a.entry.path).split(/[\\/]/).length - relative(b.entry.root.path, b.entry.path).split(/[\\/]/).length);
+ /** Count root-relative path components to reject equal-depth copies rather than picking an arbitrary winner. */
+ const depth = ({ entry }: typeof ranked[number]) => relative(entry.root.path, entry.path).split(/[\\/]/).length;
+ return depth(ranked[0]!) !== depth(ranked[1]!) && ranked[0]!.i === index;
+}
+
+/** Eligible installed individual skills; absence of native proof never enables a project skill. */
+export function discoverHermesSkills(cwd: string | null | undefined, context: HermesCommandContext): SlashCommand[] {
+ try {
+ const budget: ScanBudget = { entries: 0, bytes: 0 };
+ const configPath = join(context.home, "config.yaml");
+ const config = existsSync(configPath) ? yaml(configPath, budget) : {};
+ const skills = mapping(config.skills);
+ const disabled = new Set(strings(skills.disabled));
+ const platform = context.env.HERMES_PLATFORM || context.env.HERMES_SESSION_PLATFORM;
+ for (const name of strings(mapping(skills.platform_disabled)[platform ?? ""])) disabled.add(name);
+ disabled.delete("hermes-agent");
+ const reserved = new Set([
+ ...HERMES_BUILTIN_COMMANDS.map(command => command.name),
+ "help", "status", "reload-skills", "version", "profile", "diff", "context", "plan", "review", "init",
+ ...context.coreCommands,
+ ...Object.keys(mapping(config.quick_commands)),
+ ]);
+ const override = context.env.HERMES_BUNDLES_DIR?.replace(/^~(?=\/|$)/, context.userHome);
+ if (override && ((!isAbsolute(override) && !context.cwd) || override.startsWith("~"))) return [];
+ const bundlesDir = override ? resolve(context.cwd ?? context.home, override) : join(context.home, "skill-bundles");
+ if (existsSync(bundlesDir)) {
+ for (const { name: entry } of entries(bundlesDir, budget).sort((a, b) => compare(a.name, b.name))) {
+ if (!/\.ya?ml$/.test(entry)) continue;
+ try {
+ const bundle = yaml(join(bundlesDir, entry), budget);
+ if (!Array.isArray(bundle.skills) || !bundle.skills.some(value => String(value).trim())) continue;
+ reserved.add(slug(String(bundle.name || entry.replace(/\.ya?ml$/, "")), context.legacy));
+ } catch { /* Native skips invalid bundles too. */ }
+ }
+ }
+ let localSkills = join(context.home, "skills");
+ try { localSkills = realpathSync(localSkills); } catch { /* Missing optional local root. */ }
+ const roots: Root[] = [];
+ /** Register canonical directory roots once, excluding native ignored paths and project aliases of profile skills. */
+ const add = (path: string, tier: number) => {
+ try {
+ const canonical = realpathSync(path);
+ if (canonical.split(/[\\/]/).some(part => Object.hasOwn(EXCLUDED, part)) || (tier === 0 && canonical === localSkills)) return;
+ if (statSync(canonical).isDirectory() && !roots.some(root => root.path === canonical)) roots.push({ path: canonical, tier });
+ } catch { /* Missing optional roots are not resources. */ }
+ };
+ if (cwd && skills.project_discovery !== false) {
+ const project = projectRoot(cwd, context.userHome);
+ if (project && configPaths(skills.trusted_project_dirs).some(path => {
+ try { return realpathSync(expand(path, context, context.cwd ?? null)) === project; } catch { return false; }
+ })) {
+ add(join(project, ".hermes", "skills"), 0);
+ add(join(project, ".agents", "skills"), 0);
+ }
+ }
+ add(join(context.home, "skills"), 1);
+ if (!context.legacy && typeof skills.create_dir === "string") add(expand(skills.create_dir, context), 2);
+ for (const path of configPaths(skills.external_dirs)) add(expand(path, context), 3);
+ const candidates: Candidate[] = [];
+ for (const root of roots) {
+ for (const path of skillFiles(root.path, context.legacy, budget)) {
+ try {
+ const bytes = readBytes(path, MAX_FILE_BYTES, budget);
+ if (root.tier === 0 && !projectApproved(dirname(path), context, budget, bytes)) continue;
+ if (candidates.length >= MAX_ENTRIES) return [];
+ const content = bytes.toString("utf8").replace(/^\uFEFF/, "");
+ const match = content.match(/^---\s*\r?\n([\s\S]*?)\r?\n---(?:\s*\r?\n|$)/);
+ const metadata = match ? mapping(Bun.YAML.parse(match[1]!)) : {};
+ const name = String(metadata.name ?? basename(dirname(path)));
+ const body = match ? content.slice(match[0].length) : content;
+ const description = typeof metadata.description === "string" ? metadata.description
+ : body.split(/\r?\n/).map(line => line.trim()).find(line => line && !line.startsWith("#")) ?? `Invoke the ${name} skill`;
+ const aliases = [...new Set([name, basename(dirname(path)), relative(root.path, dirname(path)).split("\\").join("/")])];
+ candidates.push({ path, root, bytes, metadata, name, aliases, description,
+ eligible: (metadata.name === undefined || typeof metadata.name === "string") && visible(metadata, context, config) && !disabled.has(name) });
+ } catch { /* Native skips unreadable or malformed individual skill metadata. */ }
+ }
+ }
+ if (budget.entries > MAX_ENTRIES || budget.bytes > MAX_SCAN_BYTES) return [];
+ const names = new Set(), slugs = new Set();
+ const result: SlashCommand[] = [];
+ for (let i = 0; i < candidates.length; i++) {
+ const candidate = candidates[i]!;
+ if (!candidate.eligible || (context.legacy ? names.has(candidate.name) : !uniqueCandidate(i, candidates))) continue;
+ names.add(candidate.name);
+ const command = slug(candidate.name, context.legacy);
+ if (!command || reserved.has(command) || slugs.has(command)) continue;
+ slugs.add(command);
+ result.push({ name: command, description: candidate.description.trim().slice(0, 120), source: "skill" });
+ }
+ return result.sort((a, b) => compare(a.name, b.name));
+ } catch { return []; }
+}
+
+/** Existing wire interface; unresolved identity yields no profile resources. */
+export function hermesCommands(cwd?: string | null, context?: HermesCommandContext | null): SlashCommand[] {
+ return [
+ ...HERMES_BUILTIN_COMMANDS.map(command => ({ ...command, source: "builtin" as const })),
+ ...(context ? [
+ { name: "help", description: "Show native help (visible terminal portion)", source: "builtin" as const },
+ { name: "status", description: "Show native session status (terminal-backed card)", source: "builtin" as const },
+ { name: "reload-skills", description: "Reload native skills and refresh Chat suggestions", source: "builtin" as const },
+ { name: "version", description: "Show native version and installation diagnostics", source: "builtin" as const },
+ { name: "profile", description: "Show active profile name and home directory", source: "builtin" as const },
+ { name: "plan", description: "Write an implementation plan to .hermes/plans/ without executing tasks", source: "builtin" as const },
+ { name: "review", description: "Spawn a background subagent to review recent conversation work", source: "builtin" as const },
+ { name: "init", description: "Generate or update AGENTS.md instructions (prompts model, requires write approval)", source: "builtin" as const },
+ ] : []),
+ ...(context ? discoverHermesSkills(cwd, context) : []),
+ ];
+}
diff --git a/server/hermes.test.ts b/server/hermes.test.ts
index 77dd9f808..f9ee6ffe3 100644
--- a/server/hermes.test.ts
+++ b/server/hermes.test.ts
@@ -5,6 +5,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import {
+ hermesRuntimeSession,
defaultHermesHome,
forgetHermesTranscriptState,
hermesBreadcrumbSession,
@@ -16,11 +17,192 @@ import {
isHermesProcess,
parseHermesRows,
processHermesHome,
+ paneHermesCommandContext,
type HermesMessageRow,
} from "./hermes.ts";
import { ConversationNotStarted, ConversationUnavailable, HistoryChanged, TRANSCRIPT_WINDOW_BYTES, type RecognizedConversation } from "./conversation.ts";
import type { ConversationPart, ConversationTurn } from "../shared/protocol.ts";
import { toolSummary } from "./transcript-records.ts";
+import { processStartedAt } from "./process-start.ts";
+
+describe("Hermes skill presentation", () => {
+ const header = '[IMPORTANT: The user has invoked the "review" skill, indicating they want you to follow its instructions. The full skill content is loaded below.]';
+ const scaffold = `${header}\n\nPrivate skill instructions\n\n[Skill directory: /skills/review]\nResolve any relative paths in this skill (e.g. \`scripts/foo.js\`, \`templates/config.yaml\`) against that directory, then run them with the terminal tool using the absolute path.`;
+ /** Parse a single native user row through the production skill-envelope recognition path. */
+ const parse = (content: string) => parseHermesRows([{ id: 1, role: "user", content, tool_call_id: null, tool_calls: null, tool_name: null, reasoning: null, timestamp: 1 }])[0]!.parts;
+ it("shows the actual request and skill instead of expanded instructions", () => {
+ expect(parse(`${scaffold}\n\nThe user has provided the following instruction alongside the skill invocation: Review changes\nKeep the API stable`)).toEqual([
+ { kind: "text", text: "Review changes\nKeep the API stable" },
+ { kind: "skill", skill: { name: "review", path: "/skills/review/SKILL.md", evidence: "instructions", status: "loaded" } },
+ ]);
+ });
+ it("shows a bare invocation without inventing request text", () => {
+ expect(parse(scaffold)).toEqual([{ kind: "skill", skill: { name: "review", path: "/skills/review/SKILL.md", evidence: "instructions", status: "loaded" } }]);
+ });
+ it("does not expose native runtime notes as user instructions", () => {
+ expect(parse(`${scaffold}\n\nThe user has provided the following instruction alongside the skill invocation: Review changes\n\n[Runtime note: internal]`)[0]).toEqual({ kind: "text", text: "Review changes" });
+ });
+ it("preserves quoted, unknown and clipped scaffolds verbatim", () => {
+ for (const content of [`Example: ${scaffold}`, `${header}\npartial`, `${header}\n\nUnknown body without the native footer`, `${scaffold}\n\nUnexpected suffix`, `${scaffold}\n… trimmed`, '[IMPORTANT: The user has invoked the "review" skill bundle, unknown format]', `${scaffold}\n\nQuoted envelope:\n${scaffold}`]) {
+ expect(parse(content)).toEqual([{ kind: "text", text: content }]);
+ }
+ });
+ it("preserves instruction markers and incomplete runtime notes inside the user's request", () => {
+ const request = "Review this literal:\n\nThe user has provided the following instruction alongside the skill invocation: nested\n\n[Runtime note: incomplete\nKeep this text";
+ expect(parse(`${scaffold}\n\nThe user has provided the following instruction alongside the skill invocation: ${request}`)[0]).toEqual({ kind: "text", text: request });
+ });
+ it("recognizes native supporting-file scaffolding without discarding the request", () => {
+ const support = '\n\n[This skill has supporting files (paths relative to the skill directory above):]\n- scripts/check.js\n\nLoad any of these with skill_view(name="review", file_path=""), or run scripts directly by absolute path (e.g. `node /skills/review/scripts/foo.js`).';
+ const content = `${scaffold}\n\n[Skill config (from /profile/config.yaml):\n target = app\n]\n\n[Skill setup note: Setup skipped]${support}\n\nThe user has provided the following instruction alongside the skill invocation: Review changes`;
+ expect(parse(content)).toEqual([
+ { kind: "text", text: "Review changes" },
+ { kind: "skill", skill: { name: "review", path: "/skills/review/SKILL.md", evidence: "instructions", status: "loaded" } },
+ ]);
+ });
+});
+
+describe("Hermes Ink session ownership", () => {
+ it("rejects foreign, stale and ambiguous registry sessions", () => {
+ const home = mkdtempSync(join(tmpdir(), "hermes-runtime-"));
+ try {
+ mkdirSync(join(home, "runtime"));
+ const gateway = { pid: process.pid, argv: ["python", "-m", "tui_gateway.entry"] };
+ const entry = { pid: process.pid, surface: "tui", process_start_time: processStartedAt(process.pid)! / 1000, session_id: "owned" };
+ /** Replace this fixture's registry with the ownership evidence for the current assertion. */
+ const save = (entries: unknown[]) => writeFileSync(join(home, "runtime", "active_sessions.json"), JSON.stringify({ entries }));
+ save([entry]);
+ expect(hermesRuntimeSession(home, [gateway])).toBe("owned");
+ expect(hermesRuntimeSession(home, [])).toBeNull();
+ for (const argv of [["node", "-m", "tui_gateway.entry"], ["python", "-c", "pass", "-m", "tui_gateway.entry"], ["python", "script.py", "-m", "tui_gateway.entry"]]) {
+ expect(hermesRuntimeSession(home, [{ ...gateway, argv }])).toBeNull();
+ }
+ for (const fields of [{ pid: -1 }, { surface: "cli" }, { session_id: "../foreign" }]) {
+ save([{ ...entry, ...fields }]);
+ expect(hermesRuntimeSession(home, [gateway])).toBeNull();
+ }
+ save([{ ...entry, process_start_time: entry.process_start_time - 60 }]);
+ expect(hermesRuntimeSession(home, [gateway])).toBeNull();
+ save([entry, { ...entry, session_id: "other" }]);
+ expect(hermesRuntimeSession(home, [gateway])).toBeNull();
+ writeFileSync(join(home, "runtime", "active_sessions.json"), " ".repeat(1_048_577));
+ expect(hermesRuntimeSession(home, [gateway])).toBeNull();
+ } finally { rmSync(home, { recursive: true, force: true }); }
+ });
+});
+
+describe.skipIf(process.platform !== "linux" && process.platform !== "darwin")("Hermes profile registry ownership", () => {
+ let root: string;
+ let source: string;
+ let child: Bun.Subprocess<"pipe", "pipe", "inherit">;
+ let listener: Bun.SocketListener<{ buffer: string }>;
+ let socketBefore: string | undefined;
+
+ /** Write native fixture files without using the user's Hermes installation. */
+ function put(path: string, content: string): void {
+ mkdirSync(join(path, ".."), { recursive: true });
+ writeFileSync(path, content);
+ }
+
+ /** Save the real registry shape consumed by both profile and transcript lookup. */
+ function registry(home: string, entries: unknown[]): void {
+ put(join(home, "runtime", "active_sessions.json"), JSON.stringify({ entries }));
+ }
+
+ /** Bind fixture evidence to the owned child's measured start, never a guessed session. */
+ function ownedEntry(session = "owned") {
+ const startedAt = processStartedAt(child.pid);
+ expect(startedAt).not.toBeNull();
+ return { pid: child.pid, surface: "tui", process_start_time: startedAt! / 1000, session_id: session };
+ }
+
+ beforeEach(async () => {
+ root = mkdtempSync(join(tmpdir(), "hermes-profile-registry-"));
+ source = join(root, "source");
+ put(join(source, "agent", "skill_commands.py"), "for entry in _skill_catalog():\n pass\n");
+ put(join(source, "tools", "skills_guard.py"), 'SCANNER_VERSION = "fixture-scanner"\n');
+ put(join(source, "hermes_cli", "commands.py"), 'COMMAND_REGISTRY = [CommandDef("status", "Status", "Session")]\n');
+ put(join(root, "active_profile"), "not-session-evidence");
+ child = Bun.spawn([process.execPath, "-e", "console.log('ready'); for await (const _ of Bun.stdin.stream()) {}"], {
+ env: { ...process.env, HOME: root, HERMES_HOME: root, PYTHONPATH: source },
+ stdin: "pipe", stdout: "pipe",
+ });
+ const reader = child.stdout.getReader();
+ const ready = await reader.read();
+ reader.releaseLock();
+ expect(new TextDecoder().decode(ready.value)).toBe("ready\n");
+ socketBefore = process.env["HERDR_SOCKET"];
+ const socket = join(root, "herdr.sock");
+ process.env["HERDR_SOCKET"] = socket;
+ listener = Bun.listen<{ buffer: string }>({
+ unix: socket,
+ socket: {
+ /** Accumulate the real RPC client's newline-delimited request. */
+ open(socket) { socket.data = { buffer: "" }; },
+ /** Return only synthetic pane metadata; process lifetimes remain real. */
+ data(socket, chunk) {
+ socket.data.buffer += chunk.toString();
+ if (!socket.data.buffer.includes("\n")) return;
+ const request = JSON.parse(socket.data.buffer) as { id: string; method: string; params: { pane_id: string } };
+ socket.end(`${JSON.stringify({ id: request.id, result: { process_info: { foreground_processes: [
+ { pid: process.pid, argv: ["hermes", "--profile", "not-session-evidence"], cwd: root },
+ { pid: child.pid, argv: ["python", "-m", "tui_gateway.entry"], cwd: root },
+ ] } } })}\n`);
+ },
+ },
+ });
+ });
+
+ afterEach(async () => {
+ child.kill();
+ await child.exited;
+ listener.stop(true);
+ if (socketBefore === undefined) delete process.env["HERDR_SOCKET"];
+ else process.env["HERDR_SOCKET"] = socketBefore;
+ rmSync(root, { recursive: true, force: true });
+ });
+
+ it("selects the lifetime-bound registry among all 64 named profiles", async () => {
+ const entry = ownedEntry();
+ for (let i = 0; i < 64; i++) {
+ registry(join(root, "profiles", `profile-${i}`), [{ ...entry, process_start_time: entry.process_start_time - 60 }]);
+ }
+ const selected = join(root, "profiles", "profile-63");
+ registry(selected, [entry, entry]);
+ expect(await paneHermesCommandContext("fixture-pane")).toMatchObject({ home: selected, scannerVersion: "fixture-scanner" });
+ expect(hermesRuntimeSession(selected, [{ pid: child.pid, argv: ["python", "-m", "tui_gateway.entry"] }])).toBe("owned");
+ });
+
+ it("does not select a profile for a foreign PID or a reused PID's old start", async () => {
+ const entry = ownedEntry();
+ registry(join(root, "profiles", "foreign"), [{ ...entry, pid: process.pid }]);
+ registry(join(root, "profiles", "reused"), [{ ...entry, process_start_time: entry.process_start_time - 60 }]);
+ expect(await paneHermesCommandContext("fixture-pane")).toBeNull();
+ const gateway = { pid: child.pid, argv: ["python", "-m", "tui_gateway.entry"] };
+ expect(hermesRuntimeSession(join(root, "profiles", "foreign"), [gateway])).toBeNull();
+ expect(hermesRuntimeSession(join(root, "profiles", "reused"), [gateway])).toBeNull();
+ });
+
+ it("rejects ambiguous registry sessions and multiple matching profiles", async () => {
+ const entry = ownedEntry();
+ const first = join(root, "profiles", "first"), second = join(root, "profiles", "second");
+ registry(first, [entry, { ...entry, session_id: "another" }]);
+ expect(await paneHermesCommandContext("fixture-pane")).toBeNull();
+ expect(hermesRuntimeSession(first, [{ pid: child.pid, argv: ["python", "-m", "tui_gateway.entry"] }])).toBeNull();
+ registry(first, [entry]);
+ registry(second, [entry]);
+ expect(await paneHermesCommandContext("fixture-pane")).toBeNull();
+ });
+
+ it("standalone transcript lookup rejects a registry after its gateway exits", async () => {
+ registry(root, [ownedEntry()]);
+ const gateway = { pid: child.pid, argv: ["python", "-m", "tui_gateway.entry"] };
+ expect(hermesRuntimeSession(root, [gateway])).toBe("owned");
+ child.kill();
+ await child.exited;
+ expect(hermesRuntimeSession(root, [gateway])).toBeNull();
+ expect(await paneHermesCommandContext("fixture-pane")).toBeNull();
+ });
+});
describe("hermes paths", () => {
it("computes default hermes home and db path", () => {
@@ -44,7 +226,7 @@ describe("hermes process identification", () => {
});
describe("a Hermes process's own store", () => {
- it.skipIf(process.platform !== "linux" && process.platform !== "darwin")("reads HERMES_HOME from the process environment and caches it by process identity", async () => {
+ it.skipIf(process.platform !== "linux" && process.platform !== "darwin")("reads HERMES_HOME only while the cached process identity is alive", async () => {
const home = mkdtempSync(join(tmpdir(), "hermes-process-home-"));
const child = Bun.spawn([process.execPath, "-e", "console.log('ready'); for await (const _ of Bun.stdin.stream()) {}"], {
env: { ...process.env, HERMES_HOME: home },
@@ -57,13 +239,33 @@ describe("a Hermes process's own store", () => {
expect(await processHermesHome(child.pid, argv)).toBe(home);
child.kill();
await child.exited;
- expect(await processHermesHome(child.pid, argv)).toBe(home);
+ expect(await processHermesHome(child.pid, argv)).toBeNull();
} finally {
child.kill();
await child.exited;
rmSync(home, { recursive: true, force: true });
}
});
+ it.skipIf(process.platform !== "linux" && process.platform !== "darwin")("does not pin transient non-existent home directory in cache", async () => {
+ const parentDir = mkdtempSync(join(tmpdir(), "hermes-proc-test-"));
+ const home = join(parentDir, "later-created-home");
+ const child = Bun.spawn([process.execPath, "-e", "console.log('ready'); for await (const _ of Bun.stdin.stream()) {}"], {
+ env: { ...process.env, HERMES_HOME: home },
+ stdin: "pipe",
+ stdout: "pipe",
+ });
+ const argv = ["hermes", "--profile", "custom"];
+ try {
+ await child.stdout.getReader().read();
+ expect(await processHermesHome(child.pid, argv)).toBeNull();
+ mkdirSync(home);
+ expect(await processHermesHome(child.pid, argv)).toBe(home);
+ } finally {
+ child.kill();
+ await child.exited;
+ rmSync(parentDir, { recursive: true, force: true });
+ }
+ });
it("reads the last HERMES_HOME assignment from a macOS process line", () => {
expect(hermesHomeInPsLine("python hermes HERMES_HOME=/tmp/arg HOME=/Users/alice HERMES_HOME=/Users/alice/Hermes Profiles/work TERM=xterm"))
diff --git a/server/hermes.ts b/server/hermes.ts
index 9ff994eb0..2e4b90433 100644
--- a/server/hermes.ts
+++ b/server/hermes.ts
@@ -1,16 +1,48 @@
import { Database } from "bun:sqlite";
-import { execFileSync } from "node:child_process";
+import { execFile, execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
-import { existsSync, readFileSync, readlinkSync, realpathSync, statSync } from "node:fs";
-import { isAbsolute, join } from "node:path";
-import { readFile, stat } from "node:fs/promises";
+import { constants, existsSync, opendirSync, readlinkSync, realpathSync, statSync } from "node:fs";
+import { dirname, isAbsolute, join } from "node:path";
+import { open, stat } from "node:fs/promises";
+import { promisify } from "node:util";
import type { ConversationMetadata, ConversationPart, ConversationTurn, HerdrPane } from "../shared/protocol.ts";
-import { herdrRpc } from "./herdr/client.ts";
+import { agentGet, paneProcessInfo } from "./herdr/client.ts";
import { answerVersion, ConversationNotStarted, ConversationUnavailable, type ConversationPage, HistoryChanged, type RecognizedConversation, TRANSCRIPT_WINDOW_BYTES } from "./conversation.ts";
import { toolSummary } from "./transcript-records.ts";
import { TOOL_OUTPUT_CHARS, trimOutput } from "./tool-output.ts";
import { processStartedAt } from "./process-start.ts";
+import { hermesNativeFeatures, readHermesFile, type HermesCommandContext } from "./hermes-commands.ts";
+
+/** Recognize only the native single-skill scaffold, not quoted or incomplete examples. */
+function hermesSkillParts(text: string): ConversationPart[] | null {
+ const header = /^\[IMPORTANT: The user has invoked the "([^"\r\n<>]{1,200})" skill, indicating they want you to follow its instructions\. The full skill content is loaded below\.\]\n\n/.exec(text);
+ if (!header || text.endsWith("\n… trimmed")) return null;
+ const footers = [...text.matchAll(/\n\n\[Skill directory: ([^\r\n\]]+)\]\nResolve any relative paths in this skill \(e\.g\. `scripts\/foo\.js`, `templates\/config\.yaml`\) against that directory, then run them with the terminal tool using the absolute path\./g)];
+ // More than one footer can be a quoted envelope inside the body or request.
+ // Without persisted provenance, keep that ambiguous message verbatim.
+ if (footers.length !== 1) return null;
+ const footer = footers[0]!;
+ let suffix = text.slice(footer.index! + footer[0].length);
+ const config = /^\n\n\[Skill config \(from [^\r\n]+\):\n(?: [^\r\n]*\n)+\]/.exec(suffix);
+ if (config) suffix = suffix.slice(config[0].length);
+ const setup = /^\n\n\[Skill setup note: [^\r\n]*\]/.exec(suffix);
+ if (setup) suffix = suffix.slice(setup[0].length);
+ const support = /^\n\n\[This skill has supporting files \(paths relative to the skill directory above\):\]\n(?:- [^\r\n]+\n)+\nLoad any of these with skill_view\(name="[^"\r\n]+", file_path=""\), or run scripts directly by absolute path \(e\.g\. `node [^\r\n]+\/scripts\/foo\.js`\)\./.exec(suffix);
+ if (support) suffix = suffix.slice(support[0].length);
+ const marker = "\n\nThe user has provided the following instruction alongside the skill invocation: ";
+ let request = "";
+ if (suffix.startsWith(marker)) request = suffix.slice(marker.length);
+ else if (suffix && !/^\n\n\[Runtime note: [\s\S]*\]$/.test(suffix)) return null;
+ // Only a complete trailing native note is scaffolding; a quoted marker or
+ // an incomplete note in the user's request must not clip the rest of it.
+ request = request.replace(/\n\n\[Runtime note: [^\[\]]*\]$/, "");
+ return [
+ ...(request ? [{ kind: "text" as const, text: request }] : []),
+ { kind: "skill", skill: { name: header[1]!, evidence: "instructions", status: "loaded",
+ path: `${footer[1]!.replace(/[\\/]$/, "")}/SKILL.md` } },
+ ];
+}
export interface HermesMessageRow {
id: number;
@@ -82,9 +114,15 @@ export function hermesDbPath(hermesHome = defaultHermesHome()): string {
}
/** What processHermesHome found, by pid and argv, and when. */
-const processHomes = new Map();
+const processHomes = new Map();
const PROCESS_HOME_TTL_MS = 30_000;
+/** Recheck a PID's start time so exited or reused processes cannot supply identity. */
+function sameProcessLifetime(pid: number, startedAt: number): boolean {
+ const current = processStartedAt(pid);
+ return current !== null && Math.abs(current - startedAt) <= (process.platform === "darwin" ? 1000 : 0);
+}
+
/** The HERMES_HOME in one macOS `ps -E -o command=` line. */
export function hermesHomeInPsLine(text: string): string | null {
return [...text.matchAll(/(?:^|\s)HERMES_HOME=(.*?)(?=\s+[A-Za-z_][A-Za-z0-9_]*=|\s*$)/g)].at(-1)?.[1] || null;
@@ -92,32 +130,35 @@ export function hermesHomeInPsLine(text: string): string | null {
/** The HERMES_HOME a process started with, cached because a process's environment does not change. */
export async function processHermesHome(pid: number, argv: readonly string[] = []): Promise {
+ if (!Number.isSafeInteger(pid) || pid <= 0) return null;
+ const startedAt = processStartedAt(pid);
+ if (startedAt === null) return null;
const key = `${pid}\0${argv.join("\0")}`;
const known = processHomes.get(key);
- if (known && Date.now() - known.at < PROCESS_HOME_TTL_MS) return known.home;
- let home: string | null = null;
+ if (known && Date.now() - known.at < PROCESS_HOME_TTL_MS
+ && Math.abs(known.startedAt - startedAt) <= (process.platform === "darwin" ? 1000 : 0)) return known.home;
try {
- if (process.platform === "linux") {
- home = (await readFile(`/proc/${pid}/environ`, "utf8")).split("\0").find((entry) => entry.startsWith("HERMES_HOME="))?.slice(12) || null;
- } else if (process.platform === "darwin") {
- const child = Bun.spawn(["/bin/ps", "-E", "-ww", "-p", String(pid), "-o", "command="], { stdout: "pipe", stderr: "ignore" });
- const timer = setTimeout(() => child.kill(), 3000);
- try {
- const text = await new Response(child.stdout).text();
- await child.exited;
- home = hermesHomeInPsLine(text);
- } finally { clearTimeout(timer); }
+ const env = await hermesProcessEnvironment(pid);
+ if (!env || !sameProcessLifetime(pid, startedAt)) return null;
+ const explicit = env.HERMES_HOME || null;
+ if (!explicit) {
+ processHomes.delete(key);
+ processHomes.set(key, { home: null, startedAt, at: Date.now() });
+ if (processHomes.size > 256) processHomes.delete(processHomes.keys().next().value!);
+ return null;
}
- if (home === null || !isAbsolute(home) || !(await stat(home)).isDirectory()) home = null;
- } catch { home = null; }
- processHomes.delete(key);
- processHomes.set(key, { home, at: Date.now() });
- if (processHomes.size > 256) processHomes.delete(processHomes.keys().next().value!);
- return home;
+ if (!isAbsolute(explicit)) return null;
+ const isDir = (await stat(explicit).catch(() => null))?.isDirectory();
+ if (!isDir || !sameProcessLifetime(pid, startedAt)) return null;
+ processHomes.delete(key);
+ processHomes.set(key, { home: explicit, startedAt, at: Date.now() });
+ if (processHomes.size > 256) processHomes.delete(processHomes.keys().next().value!);
+ return explicit;
+ } catch { return null; }
}
/** Recognizes the Hermes executable, including Python launching its entrypoint. */
-export function isHermesProcess(entry: { name?: string; argv0?: string; argv?: readonly string[] }): boolean {
+export function isHermesProcess(entry: { name?: string; argv0?: string | null; argv?: readonly string[] | null }): boolean {
const binary = (entry.name ?? entry.argv0 ?? entry.argv?.[0] ?? "").toLowerCase();
if (binary === "hermes" || binary === "hermes.exe" || binary.endsWith("/hermes") || binary.endsWith("\\hermes.exe")) return true;
if (entry.argv && entry.argv.length > 1) {
@@ -132,6 +173,173 @@ export function isHermesProcess(entry: { name?: string; argv0?: string; argv?: r
return false;
}
+interface HermesProcess { pid: number; name?: string; argv0?: string | null; argv?: string[] | null; cwd?: string | null }
+
+/** Recognize Python's gateway module launch, excluding script and inline-code arguments. */
+function isTuiGateway(process: HermesProcess): boolean {
+ const args = process.argv ?? [];
+ const executable = (args[0] ?? "").replaceAll("\\", "/").split("/").at(-1)!;
+ if (!/^(?:python(?:\d+(?:\.\d+)*)?|pypy\d*|py)(?:\.exe)?$/i.test(executable)) return false;
+ for (let i = 1; i < args.length; i++) {
+ const arg = args[i]!;
+ if (arg === "-m") return args[i + 1] === "tui_gateway.entry";
+ if (arg === "-c" || arg === "--" || !arg.startsWith("-")) return false;
+ if (arg === "-X" || arg === "-W") i++;
+ }
+ return false;
+}
+
+/** Run bounded asynchronous process queries without blocking the server's event loop. */
+const run = promisify(execFile);
+
+/** Read the process's own environment on supported hosts; keep credentials server-side. */
+async function hermesProcessEnvironment(pid: number): Promise | null> {
+ if (!Number.isSafeInteger(pid) || pid <= 0) return null;
+ try {
+ if (process.platform === "linux") {
+ const file = await open(`/proc/${pid}/environ`, constants.O_RDONLY | constants.O_NONBLOCK);
+ let data: string;
+ try {
+ const buffer = Buffer.alloc(1_048_577);
+ let count = 0;
+ while (count < buffer.length) {
+ const { bytesRead } = await file.read(buffer, count, buffer.length - count, null);
+ if (bytesRead === 0) break;
+ count += bytesRead;
+ }
+ if (count === buffer.length) return null;
+ data = buffer.subarray(0, count).toString("utf8");
+ } finally { await file.close(); }
+ return Object.fromEntries(data.split("\0").filter(entry => entry.includes("=")).map(entry => {
+ const separator = entry.indexOf("=");
+ return [entry.slice(0, separator), entry.slice(separator + 1)];
+ }));
+ }
+ if (process.platform === "darwin") {
+ const options = { encoding: "utf8" as const, timeout: 1500, killSignal: "SIGKILL" as const, maxBuffer: 1_048_576, windowsHide: true };
+ const [cmdResult, envResult] = await Promise.all([
+ run("/bin/ps", ["-ww", "-p", String(pid), "-o", "command="], options),
+ run("/bin/ps", ["-E", "-ww", "-p", String(pid), "-o", "command="], options),
+ ]);
+ const command = cmdResult.stdout.trimEnd();
+ const text = envResult.stdout;
+ if (!command || !text.startsWith(`${command} `)) return null;
+ const environment = text.slice(command.length);
+ return Object.fromEntries([...environment.matchAll(/(?:^|\s)([A-Za-z_][A-Za-z0-9_]*)=(.*?)(?=\s+[A-Za-z_][A-Za-z0-9_]*=|\s*$)/g)].map(match => [match[1]!, match[2]!]));
+ }
+ } catch { /* Unreadable process identity is not a default profile. */ }
+ return null;
+}
+
+/** Match one registry against captured gateway starts; callers must revalidate lifetimes before use. */
+function hermesRuntimeSessionAt(home: string, gatewayStarts: ReadonlyMap): string | null {
+ try {
+ const file = join(home, "runtime", "active_sessions.json");
+ const registry = JSON.parse(readHermesFile(file).toString("utf8")) as { entries?: unknown };
+ if (!Array.isArray(registry.entries)) return null;
+ const sessions = new Set();
+ for (const [pid, startedAt] of gatewayStarts) {
+ for (const entry of registry.entries) {
+ if (entry === null || typeof entry !== "object") continue;
+ if (entry.pid !== pid || entry.surface !== "tui" || typeof entry.process_start_time !== "number"
+ || !Number.isFinite(entry.process_start_time) || Math.abs(entry.process_start_time * 1000 - startedAt) > (process.platform === "darwin" ? 1000 : 10)) continue;
+ if (typeof entry.session_id === "string" && /^[\w:.-]{1,200}$/.test(entry.session_id)) sessions.add(entry.session_id);
+ }
+ }
+ return sessions.size === 1 ? sessions.values().next().value! : null;
+ } catch { return null; }
+}
+
+/** Bind a standalone transcript lookup to a gateway's start and recheck it after reading the registry. */
+export function hermesRuntimeSession(home: string, processes: readonly HermesProcess[]): string | null {
+ const gatewayStarts = new Map();
+ for (const gateway of processes) {
+ if (!isTuiGateway(gateway) || !Number.isSafeInteger(gateway.pid) || gateway.pid <= 0) continue;
+ const startedAt = processStartedAt(gateway.pid);
+ if (startedAt !== null) gatewayStarts.set(gateway.pid, startedAt);
+ }
+ const session = hermesRuntimeSessionAt(home, gatewayStarts);
+ for (const [pid, startedAt] of gatewayStarts) {
+ if (!sameProcessLifetime(pid, startedAt)) return null;
+ }
+ return session;
+}
+
+/** Resolve a proven profile using one gateway-start snapshot, then recheck native and launcher lifetimes. */
+export async function paneHermesCommandContext(paneId: string, configuredHome?: string): Promise {
+ const info = await paneProcessInfo(paneId).catch(() => null);
+ const processes = info?.foreground_processes ?? [];
+ const launchers = processes.filter(isHermesProcess);
+ const gateways = processes.filter(isTuiGateway);
+ if (launchers.length !== 1 || gateways.length > 1) return null;
+ const launcher = launchers[0]!;
+ const native = gateways[0] ?? launcher;
+ if (!Number.isSafeInteger(native.pid) || native.pid <= 0) return null;
+ const startedAt = processStartedAt(native.pid);
+ const launcherStartedAt = native === launcher ? startedAt : processStartedAt(launcher.pid);
+ const env = await hermesProcessEnvironment(native.pid);
+ if (startedAt === null || launcherStartedAt === null || !env || !env.HOME || !isAbsolute(env.HOME)) return null;
+ if (process.platform === "linux") {
+ try {
+ const nativeRoot = statSync(`/proc/${native.pid}/root`), bridgeRoot = statSync("/");
+ if (nativeRoot.dev !== bridgeRoot.dev || nativeRoot.ino !== bridgeRoot.ino
+ || readlinkSync(`/proc/${native.pid}/ns/mnt`) !== readlinkSync("/proc/self/ns/mnt")) return null;
+ } catch { return null; }
+ }
+ let home = configuredHome ?? env.HERMES_HOME;
+ const root = env.HERMES_HOME || join(env.HOME, ".hermes");
+ if (!isAbsolute(root)) return null;
+ // /proc exposes exec-time environment, so an in-process profile override may
+ // require a lifetime-bound breadcrumb rather than today's active_profile file.
+ if (!configuredHome && (!home || existsSync(join(root, "active_profile")) || launcher.argv?.some(arg => arg === "--profile" || arg === "-p" || arg.startsWith("--profile=")))) {
+ const candidates = [root];
+ try {
+ const profiles = opendirSync(join(root, "profiles"));
+ try {
+ let count = 0;
+ for (let entry = profiles.readSync(); entry !== null; entry = profiles.readSync()) {
+ if (++count > 64) return null;
+ if (entry.isDirectory()) candidates.push(join(root, "profiles", entry.name));
+ }
+ } finally { profiles.closeSync(); }
+ } catch { /* No named profiles. */ }
+ const terminal = hermesTerminalId(launcher.pid);
+ const gatewayStarts = new Map();
+ if (gateways.length === 1) gatewayStarts.set(native.pid, startedAt);
+ const matches = candidates.filter(candidate => hermesRuntimeSessionAt(candidate, gatewayStarts)
+ || (terminal && launcher.cwd && hermesBreadcrumbSession(candidate, terminal, launcher.cwd, launcherStartedAt)));
+ home = matches.length === 1 ? matches[0] : undefined;
+ }
+ if (!home || !isAbsolute(home)) return null;
+ try {
+ home = realpathSync(home);
+ if (!statSync(home).isDirectory()) return null;
+ } catch { return null; }
+ const sources: string[] = [];
+ for (const process of processes) for (const arg of process.argv ?? []) {
+ if (/[\\/]ui-tui[\\/]dist[\\/]entry\.js$/.test(arg)) sources.push(dirname(dirname(dirname(arg))));
+ }
+ if (env.PYTHONPATH) sources.push(...env.PYTHONPATH.split(process.platform === "win32" ? ";" : ":").filter(isAbsolute));
+ for (const arg of launcher.argv ?? []) {
+ if (/[\\/]hermes(?:\.exe)?$/.test(arg)) {
+ try {
+ const path = realpathSync(arg);
+ sources.push(dirname(path), dirname(dirname(dirname(path))));
+ } catch { /* Missing launcher path. */ }
+ }
+ }
+ for (const source of new Set(sources)) {
+ const features = hermesNativeFeatures(source);
+ if (!features) continue;
+ if (!sameProcessLifetime(native.pid, startedAt) || !sameProcessLifetime(launcher.pid, launcherStartedAt)) return null;
+ const filesystem = process.platform === "linux" ? `/proc/${native.pid}/root` : "/";
+ return { ...features, home, userHome: env.HOME, env, cwd: native.cwd ?? launcher.cwd ?? undefined,
+ docker: existsSync(join(filesystem, ".dockerenv")) || existsSync(join(filesystem, "run", ".containerenv")),
+ s6: existsSync(join(filesystem, "run", "s6")) || existsSync(join(filesystem, "package", "admin", "s6-overlay")) };
+ }
+ return null;
+}
+
/** Resolves process terminal identity to Hermes breadcrumb filename convention. */
export function hermesTerminalId(pid: number): string | null {
if (!Number.isSafeInteger(pid) || pid <= 0) return null;
@@ -160,7 +368,7 @@ export function hermesBreadcrumbSession(home: string, terminalId: string, cwd: s
const marker = join(home, "terminal-sessions", terminalId);
const markerStat = statSync(marker);
if (!markerStat.isFile() || markerStat.size > 8192) return null;
- const data = JSON.parse(readFileSync(marker, "utf8")) as { session_id?: unknown; cwd?: unknown; ts?: unknown };
+ const data = JSON.parse(readHermesFile(marker, 8192).toString("utf8")) as { session_id?: unknown; cwd?: unknown; ts?: unknown };
if (typeof data.session_id !== "string" || data.session_id.trim().length === 0) return null;
if (typeof data.cwd !== "string" || realpathSync(data.cwd) !== realpathSync(cwd)) return null;
if (typeof data.ts !== "number" || !Number.isFinite(data.ts) || data.ts * 1000 < startedAt - 1000) return null;
@@ -185,21 +393,18 @@ export async function hermesTranscriptForPane(
: null;
if (sessionId === null) {
- const info = await herdrRpc<{ agent: { agent_session?: { value?: unknown } } }>(
- "agent.get",
- { target: paneId },
- ).catch(() => null);
- if (typeof info?.agent?.agent_session?.value === "string" && info.agent.agent_session.value.length > 0) {
- sessionId = info.agent.agent_session.value;
+ const info = await agentGet(paneId).catch(() => null);
+ if (typeof info?.agent_session?.value === "string" && info.agent_session.value.length > 0) {
+ sessionId = info.agent_session.value;
}
}
- let hermesProcess: { pid: number; name?: string; argv0?: string; argv?: string[] } | undefined;
+ let hermesProcess: HermesProcess | undefined;
+ let processes: HermesProcess[] = [];
if (configuredHome === undefined || sessionId === null) {
- const processInfo = await herdrRpc<{
- process_info?: { foreground_processes?: { pid: number; name?: string; argv0?: string; argv?: string[] }[] };
- }>("pane.process_info", { pane_id: paneId }).catch(() => null);
- hermesProcess = (processInfo?.process_info?.foreground_processes ?? []).find(isHermesProcess);
+ const processInfo = await paneProcessInfo(paneId).catch(() => null);
+ processes = processInfo?.foreground_processes ?? [];
+ hermesProcess = processes.find(isTuiGateway) ?? processes.find(isHermesProcess);
}
const processHome = configuredHome === undefined && hermesProcess !== undefined
@@ -214,6 +419,7 @@ export async function hermesTranscriptForPane(
sessionId = hermesBreadcrumbSession(hermesHome, termId, cwd, startedAt);
}
}
+ if (sessionId === null) sessionId = hermesRuntimeSession(hermesHome, processes);
if (sessionId === null) throw new ConversationUnavailable("no_session_path");
const dbPath = hermesDbPath(hermesHome);
@@ -644,7 +850,7 @@ export function parseHermesRows(rows: readonly HermesMessageRow[], identity?: st
turns.push({
role: "user",
ts,
- parts: [{ kind: "text", text }],
+ parts: hermesSkillParts(text) ?? [{ kind: "text", text }],
});
} else if (row.role === "assistant") {
let turn = turns[turns.length - 1];
diff --git a/server/index.ts b/server/index.ts
index b03408fd6..9237368c9 100644
--- a/server/index.ts
+++ b/server/index.ts
@@ -22,6 +22,7 @@ import { compressResponse } from "./compress.ts";
import { isMouseReport, sameAttachment } from "./input-guard.ts";
import { startStatusCollector } from "./collector.ts";
import { claudePanePid, claudePaneSession, conversationImage, ConversationUnavailable, forgetPaneTranscriptState, HistoryChanged, paneConversation, paneRunsOmo, toolOutput } from "./conversation.ts";
+import { paneHermesCommandContext } from "./hermes.ts";
import { OPENCODE_TOOL_REF } from "./opencode.ts";
import { DevinHistoryChanged } from "./devin.ts";
import { ConversationMonitor } from "./conversation-monitor.ts";
@@ -408,6 +409,11 @@ export interface ServerInstance {
stop: () => void;
}
+/**
+ * Start the HTTP/WebSocket bridge and its status, conversation and attachment services using optional injected dependencies.
+ * Return the bound address, readiness/alert handles and a stop handle that releases observers and attachments before closing Bun.
+ * Tests must pass an isolated stateDir; omitted options use the installation's environment and persistent state.
+ */
export function createServer(
options: {
port?: number;
@@ -580,11 +586,13 @@ export function createServer(
* the input box (Claude Code 2.1.295, #404). A space after the mention closes them first; a
* payload already in bracketed-paste markers gets it inside them.
*/
- const closeMention = (text: string): string => {
+ const closeMention = (text: string, hermes = false): string => {
const open = text.startsWith("\u001b[200~") ? "\u001b[200~" : "";
const close = open && text.endsWith("\u001b[201~") ? "\u001b[201~" : "";
const body = text.slice(open.length, text.length - close.length);
- return /(?:^|\s)@\S+$/.test(body) ? `${open}${body} ${close}` : text;
+ // A bare Hermes command otherwise leaves its completion open for the committing Enter.
+ const command = hermes && /^\/(?:help|status|reload[-_]skills|version|v|profile)\s*$/.test(body);
+ return command || /(?:^|\s)@\S+$/.test(body) ? `${open}${body.trimEnd()} ${close}` : text;
};
/**
@@ -595,6 +603,8 @@ export function createServer(
async function submitText(paneId: string, text: string, payload: string, arrivedAt: number, fromTerminal = false, authorize: () => void = () => {},
clearDraft?: string, confirmation?: { draft: string; identity: PendingIdentity }): Promise {
let clearedDraft = false;
+ let hermes = false;
+ /** Recheck this send's live authorization and deadline before typing any remaining input. */
const inTime = (reserve = 0): void => {
authorize();
if (Date.now() - arrivedAt > (options.submitDeadlineMs ?? SUBMIT_DEADLINE_MS) - reserve) {
@@ -613,6 +623,7 @@ export function createServer(
if (error instanceof HerdrError && error.code === "pane_not_found") return undefined;
throw error;
});
+ hermes = pane?.agent === "hermes";
inTime();
const identity = pane ? pendingIdentity(pane) : null;
const sameClaude = (current: PendingIdentity | null): boolean => current?.agent === "claude"
@@ -686,7 +697,7 @@ export function createServer(
if (clearDraft !== undefined && !claudeInputEmpty(...reads)) throw new InputDraftError(null);
}
try {
- await agentPrompt(paneId, closeMention(text));
+ await agentPrompt(paneId, closeMention(text, hermes));
noteSubmitted(paneId, text);
return;
} catch (error) {
@@ -700,7 +711,7 @@ export function createServer(
// a mirrored pane's browser never learned the program's paste mode, so `payload` came as bare
// lines: several of them are shaped here as the same block typed into the mirror is. herdr is
// asked only for such a block, so a one-line message never waits on it.
- const shaped = await mirrorInput(closeMention(payload), async () => await terminalAttach() ? null : (await paneContext(paneId)).agent);
+ const shaped = await mirrorInput(closeMention(payload, !fromTerminal && hermes), async () => await terminalAttach() ? null : (await paneContext(paneId)).agent);
inTime();
await paneSendText(paneId, shaped);
await Bun.sleep(options.submitDelayMs ?? SUBMIT_DELAY_MS);
@@ -1168,6 +1179,10 @@ export function createServer(
return created;
}
+ /**
+ * Wait for the previous attach to retire, resolve the pane's terminal, then register one PTY or mirrored attachment.
+ * Observer-first and mirrored attaches use the pane's grid; PTY failures remove the placeholder, while retries retain its clients.
+ */
async function spawnAttachment(paneId: string, cols: number, rows: number, forObserver: boolean): Promise {
await retiringAttachments.get(paneId);
const mirrored = !(await terminalAttach());
@@ -1266,6 +1281,10 @@ export function createServer(
};
/** the terminal attached to: a pane keeps its id across a server handoff, its terminal does not */
let attachedTerminal = terminalId;
+ /**
+ * Start an attach attempt with the shared grid and only a still-authorized explicit takeover request.
+ * Hold setup/refusal output until readiness is known, then handle exit through bounded retries or terminal handoff lookup.
+ */
const start = (): PtySession => {
let takingOver = mayTakeOver(takeover) ? takeover : null;
takeover = null;
@@ -1284,12 +1303,13 @@ export function createServer(
const data = outputTail.flush(taken);
if (data) forward(data);
};
+ /** Forward pane bytes while holding a possible refusal tail only until its bounded flush deadline. */
const publish = (data: string): void => {
clearTimeout(tailTimer);
const visible = outputTail.push(data);
if (visible) forward(visible);
- // A pane may print the same bytes without exiting: never keep its text indefinitely.
- if (outputTail.pending) tailTimer = setTimeout(flushTail, holdFor);
+ // A complete refusal gets the exit budget; partial matches must not delay ordinary pane text.
+ if (outputTail.pending) tailTimer = setTimeout(flushTail, isTakeoverExit(output) ? ATTACH_REFUSAL_EXIT_MS : holdFor);
};
/** ended before its exit came: what it still prints is no attach's */
let retired = false;
@@ -1664,10 +1684,13 @@ export function createServer(
port: options.port ?? (envPort ? Number(envPort) : DEFAULT_PORT),
hostname,
+ /**
+ * Wait for Portal cleanup, enforce access/origin/read-only gates, then dispatch bridge, machine, WS and API routes before public files.
+ * Compress eligible responses; successful WebSocket upgrades return no HTTP response.
+ */
async fetch(request, bunServer) {
const startup = portalStartupResult ?? await portalStartup;
if (!startup.ok) return compressResponse(request, errorResponse(startup.error));
- // the route's answer, gzipped for a browser that takes it (server/compress.ts)
return compressResponse(request, await (async (): Promise => {
const url = new URL(request.url);
let { pathname } = url;
@@ -2305,7 +2328,8 @@ export function createServer(
if (pathname === "/api/pane/commands") {
// herdr names an omo pane `pi` while omo waits: pi's commands and templates are not omo's
const agent = context.agent === "pi" && await paneRunsOmo(paneId) ? "omo" : context.agent;
- return jsonResponse({ commands: paneCommands(agent, context.cwd) });
+ const hermesContext = agent === "hermes" ? await paneHermesCommandContext(paneId, options.hermesHome) : undefined;
+ return jsonResponse({ commands: paneCommands(agent, context.cwd, undefined, { hermesContext }) });
}
const limitRaw = url.searchParams.get("limit");
const limit = limitRaw === null ? 20 : Number(limitRaw);
diff --git a/server/portal.test.ts b/server/portal.test.ts
index 9f381d8fd..8b198d094 100644
--- a/server/portal.test.ts
+++ b/server/portal.test.ts
@@ -483,12 +483,14 @@ describe("running Portal", () => {
const portalDir = join(stateDir, "portal");
mkdirSync(portalDir, { recursive: true, mode: 0o700 });
const identityPath = join(portalDir, "identity.json");
- const unrelated = Bun.spawn(["sh", "-c", "while :; do sleep 0.1; done", "sh", "expose", "--identity-path", identityPath], {
+ const readyPath = join(stateDir, "unrelated-ready");
+ const unrelated = Bun.spawn(["sh", "-c", 'printf ready > "$1"; while :; do sleep 0.1; done', "sh", readyPath, "expose", "--identity-path", identityPath], {
stdin: "ignore", stdout: "ignore", stderr: "ignore",
});
writeFileSync(join(portalDir, "state.json"), JSON.stringify({ enabled: true, relay: RELAY, port: 7317, pid: unrelated.pid }));
const service = new PortalService({ stateDir, fetch: relay() });
try {
+ await waitFor(() => existsSync(readyPath), "unrelated process executed its command");
await service.resume({ port: 7317, tokenSet: false, serveOnly: false });
expect(alive(unrelated.pid)).toBe(true);
} finally {
@@ -503,16 +505,15 @@ describe("running Portal", () => {
mkdirSync(portalDir, { recursive: true, mode: 0o700 });
const identityPath = join(portalDir, "identity.json");
const scriptPath = join(portalDir, "portal");
- writeFileSync(scriptPath, "await new Promise(() => {});\n");
+ const readyPath = join(stateDir, "interpreter-ready");
+ writeFileSync(scriptPath, `require("node:fs").writeFileSync(${JSON.stringify(readyPath)}, "ready");\nawait Promise.withResolvers().promise;\n`);
const unrelated = Bun.spawn([process.execPath, scriptPath, "expose", "--identity-path", identityPath], {
stdin: "ignore", stdout: "ignore", stderr: "ignore",
});
writeFileSync(join(portalDir, "state.json"), JSON.stringify({ enabled: true, relay: RELAY, port: 7317, pid: unrelated.pid }));
const service = new PortalService({ stateDir, fetch: relay() });
- const internals = service as unknown as { processArgv(pid: number): Promise };
try {
- await waitFor(() => alive(unrelated.pid), "unrelated interpreter started");
- expect((await internals.processArgv(unrelated.pid))?.slice(1, 3)).toEqual([scriptPath, "expose"]);
+ await waitFor(() => existsSync(readyPath), "unrelated interpreter executed its script");
await service.resume({ port: 7317, tokenSet: false, serveOnly: false });
expect(alive(unrelated.pid)).toBe(true);
} finally {
diff --git a/server/prompt.test.ts b/server/prompt.test.ts
index 792eb2fda..137cae76b 100644
--- a/server/prompt.test.ts
+++ b/server/prompt.test.ts
@@ -33,6 +33,403 @@ const claudeBackgroundApproval = (selected: number, footer = CLAUDE_BACKGROUND_A
].join("\n");
};
+// Sanitized shapes captured from Hermes Ink's native help panel and status pager.
+const hermesHelpOutput = "│ TUI │\n│ /logs Show recent gateway log lines │\n│ Hotkeys │\n│ Ctrl+C copy selection / clear draft / interrupt / exit │\n│ Tab apply completion │\n╰──────────────────────────────────╯\n─ ready │ test model │ 1 session\n❯";
+/** Render an Ink pager with optional final-page and wrapped hints, plus an excluded backdrop. */
+function hermesStatusOutput(body = "Model: test-model\nTokens: 0", end = false, wrapped = false): string {
+ const hint = end ? "end · ↑↓/jk · b/PgUp back · g top · Esc/q close (9 lines)" : "↑↓/jk line · Enter/Space/PgDn page · b/PgUp back · g/G top/bottom · Esc/q close (5/9)";
+ const hints = wrapped ? [hint.slice(0, 48), hint.slice(48)] : [hint];
+ return ["╔" + "═".repeat(110) + "╗", ...["", "Status", "", ...body.split("\n"), "", ...hints, ""].map(line => `║ ${line.padEnd(106)} ║ backdrop must not enter the card`), "╚" + "═".repeat(110) + "╝"].join("\n");
+}
+const hermesClassicStatus = "Hermes CLI Status\nSession ID: test-session\nPath: /tmp/test-home\nModel: test-model (custom)\nTokens: 0\nAgent Running: No";
+const hermesClassicHelp = "/reload-skills - Re-scan skills\n── Exit ──\n/quit - Exit the CLI\nTip: /help skills lists skill commands · /help filters · Ctrl+P opens the command palette\nMulti-line: Ctrl+J, Alt+Enter, or \\\\+Enter for a new line\nDraft editor: Ctrl+G (Alt+G in VSCode/Cursor)\nPaste image: Alt+V (or /paste)";
+/** Place classic command output above a status bar and a composer that already contains a draft. */
+const hermesClassicOutput = (body: string) => `${body}\n⚕ test-model │ ctx -- │ 2s\n────────────────────────────────\n❯ Research this topic\n────────────────────────────────`;
+/** Render classic command output above its native composer, empty unless a draft is supplied. */
+const hermesClassicReadyOutput = (body: string, draft = "") => `${body}\n⚕ test-model │ ctx -- │ 2s\n────────────────────────────────\n❯${draft ? " " + draft : ""}\n────────────────────────────────`;
+
+describe("Hermes native informational output", () => {
+ test("extracts the status overlay without its underlying transcript", () => {
+ const prompt = parseInteractivePrompt("hermes", hermesStatusOutput())!;
+ expect(prompt.body).toBe("Model: test-model\nTokens: 0");
+ expect(labels(prompt)).toEqual(["Previous page", "Next page", "Close"]);
+ expect(answerKeys(prompt, { option_index: 1 })).toEqual([{ keys: ["pagedown"] }]);
+ });
+ test("reads wrapped footer text and offers no next-page action at the end", () => {
+ const prompt = parseInteractivePrompt("hermes", hermesStatusOutput("Agent Running: No", true, true))!;
+ expect(prompt.body).toBe("Agent Running: No");
+ expect(labels(prompt)).toEqual(["Previous page", "Close"]);
+ expect(answerKeys(prompt, { option_index: 1 })).toEqual([{ keys: ["esc"] }]);
+ });
+ test("reads a native pager beside a transcript scrollbar and inline footer without accepting new output", () => {
+ const rows = hermesStatusOutput("Model: test-model\nTokens: 0", true).split("\n");
+ rows[0] += " old transcript backdrop │";
+ rows[rows.length - 1] += "pr910-native-fictional/work";
+ const screen = rows.join("\n");
+ const prompt = parseInteractivePrompt("hermes", screen)!;
+ expect(prompt.body).toBe("Model: test-model\nTokens: 0");
+ expect(labels(prompt)).toEqual(["Previous page", "Close"]);
+ expect(answerKeys(prompt, { option_index: 1 })).toEqual([{ keys: ["esc"] }]);
+ expect(parseInteractivePrompt("hermes", screen + "\nNew assistant response")).toBeNull();
+ expect(parseInteractivePrompt("hermes", rows.slice(0, -1).join("\n"))).toBeNull();
+ });
+ test("requires a complete active status overlay", () => {
+ for (const screen of [hermesStatusOutput().replace("Status", "Logs"), hermesStatusOutput().split("\n").slice(0, -1).join("\n"), hermesStatusOutput() + "\nnew response\nmore output\n❯"]) {
+ expect(parseInteractivePrompt("hermes", screen)).toBeNull();
+ }
+ });
+ test("rejects short stale pager tails, mismatched borders and content after the native hint", () => {
+ const screen = hermesStatusOutput();
+ for (const tail of ["\nDone.", "\nDone.\n❯"]) {
+ expect(parseInteractivePrompt("hermes", screen + tail)).toBeNull();
+ }
+ expect(parseInteractivePrompt("hermes", screen.replace("╚══", "╚═"))).toBeNull();
+ const rows = screen.split("\n");
+ rows.splice(rows.length - 1, 0, "║ Unexpected new output ║");
+ expect(parseInteractivePrompt("hermes", rows.join("\n"))).toBeNull();
+ });
+
+ test("keeps native reload failure separate from successful reload output", () => {
+ for (const body of ["❌ Skills reload failed: unreadable directory", "Skills reload failed: unreadable directory"]) {
+ const prompt = parseInteractivePrompt("hermes", hermesStatusOutput(body, true).replace("Status", "Reload Skills"))!;
+ expect(prompt.title).toBe("Reload Skills failed");
+ expect(prompt.body).toBe(body);
+ expect(answerKeys(prompt, { option_index: 1 })).toEqual([{ keys: ["esc"] }]);
+ }
+ });
+
+ test("rejects result chrome followed by a newer response and excludes context pagers", () => {
+ const classic = hermesClassicReadyOutput("⚙️ /profile\nProfile: default\nHome: /tmp/selected-home");
+ const ink = "· /profile\n·\nProfile: default\nHome: /tmp/selected-home\n─ ready │ test model\n❯";
+ for (const screen of [classic, ink]) {
+ expect(parseInteractivePrompt("hermes", screen + "\nNew assistant response", null, true, [], "/profile")).toBeNull();
+ }
+ expect(parseInteractivePrompt("hermes", hermesStatusOutput("Context details", true).replace("Status", "Context"), null, true, [], "/context")).toBeNull();
+ });
+
+ test("does not reinterpret another command's arguments as a help or reload result", () => {
+ const screen = hermesClassicOutput(hermesClassicHelp);
+ for (const sent of ["/status extra", "/reload-skills extra", "/version extra", "/help skills"]) {
+ expect(parseInteractivePrompt("hermes", screen, null, true, [], sent)).toBeNull();
+ }
+ });
+
+ test("shows visible help only after an explicit help send with an empty native input", () => {
+ expect(parseInteractivePrompt("hermes", hermesHelpOutput)).toBeNull();
+ const prompt = parseInteractivePrompt("hermes", hermesHelpOutput, null, true, [], "/help")!;
+ expect(prompt.body).toContain("/logs Show recent gateway log lines");
+ expect(prompt.body).toContain("Tab apply completion");
+ expect(answerKeys(prompt, { option_index: 0 })).toEqual([]);
+ expect(parseInteractivePrompt("hermes", hermesHelpOutput + "typed draft", null, true, [], "/help")).toBeNull();
+ expect(parseInteractivePrompt("hermes", hermesHelpOutput, null, true, [], "/help", true)).toBeNull();
+ });
+ test("reads visible Ink help beside the native scrollbar while retaining send and tail guards", () => {
+ const screen = hermesHelpOutput.split("\n").map(line => `${line} ┃`).join("\n");
+ const prompt = parseInteractivePrompt("hermes", screen, null, true, [], "/help")!;
+ expect(prompt.body).toContain("/logs Show recent gateway log lines");
+ expect(prompt.body).toContain("Tab apply completion");
+ expect(answerKeys(prompt, { option_index: 0 })).toEqual([]);
+ expect(parseInteractivePrompt("hermes", screen)).toBeNull();
+ expect(parseInteractivePrompt("hermes", screen + "\nNew assistant response", null, true, [], "/help")).toBeNull();
+ });
+ test("shows classic status without previous conversation or input chrome", () => {
+ const screen = hermesClassicOutput(`Earlier user message\n${hermesClassicStatus}`);
+ expect(parseInteractivePrompt("hermes", screen)).toBeNull();
+ const prompt = parseInteractivePrompt("hermes", screen, null, true, [], "/status")!;
+ expect(prompt.body).toBe(hermesClassicStatus);
+ expect(answerKeys(prompt, { option_index: 0 })).toEqual([]);
+ expect(parseInteractivePrompt("hermes", screen.replace("Agent Running: No", "partial"), null, true, [], "/status")).toBeNull();
+ });
+ test("shows only attested classic help output and rejects stale output followed by another response", () => {
+ const screen = hermesClassicOutput(hermesClassicHelp);
+ expect(parseInteractivePrompt("hermes", screen, null, true, [], "/help")?.body).toBe(hermesClassicHelp);
+ expect(parseInteractivePrompt("hermes", hermesClassicOutput(`${hermesClassicHelp}\nNew assistant answer`), null, true, [], "/help")).toBeNull();
+ });
+ test("recognizes classic reload completion, additions, removals and no-change results", () => {
+ for (const body of ["No new skills detected.\n📚 54 skill(s) available", "➕ Added Skills:\n- new-skill: New description\n➖ Removed Skills:\n- old-skill\n📚 54 skill(s) available"]) {
+ const prompt = parseInteractivePrompt("hermes", hermesClassicOutput(`⚙️ /reload-skills\n${body}`), null, true, [], "/reload-skills")!;
+ expect(prompt.title).toBe("Reload Skills");
+ expect(prompt.body).toBe(body);
+ expect(answerKeys(prompt, { option_index: 0 })).toEqual([]);
+ }
+ });
+ test("does not mistake a prior completion or a failed reload for success", () => {
+ const loading = "No new skills detected.\n📚 54 skill(s) available\n⚙️ /reload-skills\n🔄 Reloading skills...";
+ expect(parseInteractivePrompt("hermes", hermesClassicOutput(loading), null, true, [], "/reload-skills")).toBeNull();
+ const prompt = parseInteractivePrompt("hermes", hermesClassicOutput("⚙️ /reload_skills\n❌ Skills reload failed: unreadable directory"), null, true, [], "/reload_skills")!;
+ expect(prompt.title).toBe("Reload Skills failed");
+ expect(prompt.body).toBe("❌ Skills reload failed: unreadable directory");
+ expect(answerKeys(prompt, { option_index: 0 })).toEqual([]);
+ });
+ test("uses native pager navigation for Ink reload results", () => {
+ const screen = hermesStatusOutput("Reloading skills...\nAdded skills:\n- new-skill\n55 skill(s) available", true).replace("Status", "Reload Skills");
+ const prompt = parseInteractivePrompt("hermes", screen)!;
+ expect(prompt.title).toBe("Reload Skills");
+ expect(prompt.body).toContain("55 skill(s) available");
+ expect(answerKeys(prompt, { option_index: 1 })).toEqual([{ keys: ["esc"] }]);
+ });
+ test("extracts classic version diagnostics despite an interleaved native command echo", () => {
+ const body = "Hermes Agent v0.21.0 (2026.8.31) · upstream abc12345 · local def67890 (+3 carried commits)\nInstall directory: /tmp/hermes-install\n\n⚙️ /version\nInstall method: git\nPython: 3.11.15\nOpenAI SDK: 2.24.0\nUp to date";
+ const screen = hermesClassicOutput(`Previous conversation\n${body}`);
+ const prompt = parseInteractivePrompt("hermes", screen, null, true, [], "/version")!;
+ expect(prompt.title).toBe("Version");
+ expect(prompt.body).toBe(body.replace("⚙️ /version\n", ""));
+ expect(answerKeys(prompt, { option_index: 0 })).toEqual([]);
+ expect(parseInteractivePrompt("hermes", screen)).toBeNull();
+ expect(parseInteractivePrompt("hermes", screen, null, true, [], "/version", true)).toBeNull();
+ expect(parseInteractivePrompt("hermes", hermesClassicOutput(body.replace("OpenAI SDK: 2.24.0\n", "")), null, true, [], "/version")).toBeNull();
+ expect(parseInteractivePrompt("hermes", hermesClassicOutput(`${body}\nNew assistant response`), null, true, [], "/version")).toBeNull();
+ expect(parseInteractivePrompt("hermes", screen, null, true, [], "/v")?.body).toBe(prompt.body);
+ });
+ test("shows the native Ink version pager and its explicit close key", () => {
+ const body = "Hermes Agent v0.21.0 (2026.8.31)\nInstall directory: /tmp/hermes-install\nPython: 3.11.15\nOpenAI SDK: Not installed";
+ const prompt = parseInteractivePrompt("hermes", hermesStatusOutput(body, true).replace("Status", "Version"))!;
+ expect(prompt.title).toBe("Version");
+ expect(prompt.body).toBe(body);
+ expect(answerKeys(prompt, { option_index: 1 })).toEqual([{ keys: ["esc"] }]);
+ });
+ test("extracts classic profile and rejects partial or stale output while allowing placeholder draft input", () => {
+ const body = "Profile: default\nHome: /tmp/selected-home";
+ const screen = hermesClassicReadyOutput(`⚙️ /profile\n${body}`);
+ const prompt = parseInteractivePrompt("hermes", screen, null, true, [], "/profile")!;
+ expect(parseInteractivePrompt("hermes", screen.replace("⚕", "☤"), null, true, [], "/profile")?.body).toBe(body);
+ expect(prompt.title).toBe("Profile");
+ expect(prompt.body).toBe(body);
+ expect(labels(prompt)).toEqual(["Dismiss"]);
+ expect(answerKeys(prompt, { option_index: 0 })).toEqual([]);
+
+ // partial profile
+ expect(parseInteractivePrompt("hermes", hermesClassicReadyOutput("⚙️ /profile\nProfile: default"), null, true, [], "/profile")).toBeNull();
+ expect(parseInteractivePrompt("hermes", hermesClassicReadyOutput("⚙️ /profile\nHome: /tmp/selected-home"), null, true, [], "/profile")).toBeNull();
+
+ // stale profile followed by assistant response or wrong ticket
+ expect(parseInteractivePrompt("hermes", hermesClassicReadyOutput(`⚙️ /profile\n${body}\nNew assistant response`), null, true, [], "/profile")).toBeNull();
+ expect(parseInteractivePrompt("hermes", screen, null, true, [], "/status")).toBeNull();
+
+ // nonempty native input line (e.g. placeholder) displays result with key-free Dismiss
+ const nonemptyPrompt = parseInteractivePrompt("hermes", hermesClassicReadyOutput(`⚙️ /profile\n${body}`, "What changed in this repo recently?"), null, true, [], "/profile")!;
+ expect(nonemptyPrompt.title).toBe("Profile");
+ expect(nonemptyPrompt.body).toBe(body);
+ expect(labels(nonemptyPrompt)).toEqual(["Dismiss"]);
+ expect(answerKeys(nonemptyPrompt, { option_index: 0 })).toEqual([]);
+ });
+ test("reads profile output when buffered command echo follows or splits the result", () => {
+ const body = "Profile: default\nHome: /tmp/selected-home";
+ for (const output of [
+ `${body}\n\n⚙️ /profile`,
+ "Profile: default\n⚙️ /profile\nHome: /tmp/selected-home",
+ ]) {
+ const screen = hermesClassicReadyOutput(`⚙️ /version\nUp to date\n\n${output}`);
+ const prompt = parseInteractivePrompt("hermes", screen, null, true, [], "/profile")!;
+ expect(prompt?.body).toBe(body);
+ expect(answerKeys(prompt, { option_index: 0 })).toEqual([]);
+ expect(parseInteractivePrompt("hermes", screen, null, true, [], "/status")).toBeNull();
+ expect(parseInteractivePrompt("hermes", hermesClassicReadyOutput(`${output}\nNew assistant response`), null, true, [], "/profile")).toBeNull();
+ }
+ expect(parseInteractivePrompt("hermes", hermesClassicReadyOutput(`⚙️ /profile\n${body}\n⚙️ /profile`), null, true, [], "/profile")).toBeNull();
+ });
+ test("leaves excluded Hermes diff output in Terminal on each native surface", () => {
+ const classic = hermesClassicReadyOutput("⚙️ /diff\nNo changes.");
+ const ink = "· /diff\n· No changes.\n─ ready │ test model │ 1 session\n❯";
+ const pager = hermesStatusOutput("No changes.", true).replace("Status", "Diff");
+ for (const screen of [classic, ink, pager]) {
+ expect(parseInteractivePrompt("hermes", screen, null, true, [], "/diff")).toBeNull();
+ }
+ });
+ test("parses classic review dispatch note", () => {
+ const screen = hermesClassicReadyOutput("⚙️ /review\n⚖ Review subagent dispatched...");
+ const prompt = parseInteractivePrompt("hermes", screen, null, true, [], "/review")!;
+ expect(prompt.title).toBe("Review");
+ expect(prompt.body).toBe("⚖ Review subagent dispatched...");
+ });
+ test("parses short Ink output for profile and review", () => {
+ const inkProfile = "· /profile\n·\nProfile: default\nHome: /tmp/selected-home\n─ ready │ test model │ 1 session\n❯";
+ const pProfile = parseInteractivePrompt("hermes", inkProfile, null, true, [], "/profile")!;
+ expect(pProfile.title).toBe("Profile");
+ expect(pProfile.body).toBe("Profile: default\nHome: /tmp/selected-home");
+ expect(labels(pProfile)).toEqual(["Dismiss"]);
+ expect(answerKeys(pProfile, { option_index: 0 })).toEqual([]);
+
+ const inkChrome = "· /profile ┃\n· ┃\nProfile: default ┃\nHome: /tmp/selected-home ┃\n─ ready │ test model │ 1 session ┃\n❯ ┃";
+ expect(parseInteractivePrompt("hermes", inkChrome, null, true, [], "/profile")?.body).toBe("Profile: default\nHome: /tmp/selected-home");
+
+ const pDraft = parseInteractivePrompt("hermes", inkProfile.replace("❯", "❯ typed draft"), null, true, [], "/profile")!;
+ expect(pDraft.body).toBe("Profile: default\nHome: /tmp/selected-home");
+ expect(answerKeys(pDraft, { option_index: 0 })).toEqual([]);
+
+ const inkReview = "· /review\n⚖ Review subagent dispatched...\n─ ready │ test model │ 1 session\n❯";
+ const pReview = parseInteractivePrompt("hermes", inkReview, null, true, [], "/review")!;
+ expect(pReview.title).toBe("Review");
+ expect(pReview.body).toBe("⚖ Review subagent dispatched...");
+ });
+ test("recognizes native Profile and Review pagers", () => {
+ const p1 = parseInteractivePrompt("hermes", hermesStatusOutput("Profile: default\nHome: /tmp/selected-home", true).replace("Status", "Profile"))!;
+ expect(p1.title).toBe("Profile");
+ expect(p1.body).toBe("Profile: default\nHome: /tmp/selected-home");
+ expect(labels(p1)).toEqual(["Previous page", "Close"]);
+ expect(answerKeys(p1, { option_index: 1 })).toEqual([{ keys: ["esc"] }]);
+
+ const p3 = parseInteractivePrompt("hermes", hermesStatusOutput("Review results:\n- Issue 1 fixed", true).replace("Status", "Review"))!;
+ expect(p3.title).toBe("Review");
+ expect(p3.body).toBe("Review results:\n- Issue 1 fixed");
+ expect(labels(p3)).toEqual(["Previous page", "Close"]);
+ });
+});
+
+/** Render a classic approval with independently mutable cursor, countdown, warning and tool progress. */
+function hermesClassicApproval(at = 0, command = "echo '...' > AGENTS.md", detail = "Write to protected agent-instruction files...approval always required", choices = ["Allow once", "Deny"], seconds = 240, spinner = "⠋", toolProgress?: string): string {
+ const content = ["⚠️ Dangerous Command", "", ...command.split("\n"), "", ...choices.map((label, index) => `${at === index ? "❯" : " "} ${index + 1}. ${label}`), "", ...detail.split("\n")];
+ const postModal = toolProgress !== undefined ? [` ${toolProgress}`] : [];
+ return [`╭${"─".repeat(74)}╮`, ...content.map((line) => `│ ${line.padEnd(72)} │`), `╰${"─".repeat(74)}╯`, "", ...postModal, ` ↑/↓ to select, Enter to confirm (${seconds}s)`, ` ⚕ smoke-model │ ctx -- │ [░░░░░░░░░░] -- │ 5s │ ${spinner} 0s`, "────────────────────────────────", "⚠ ❯", "────────────────────────────────"].join("\n");
+}
+
+/** Render an Ink approval with its in-panel key hint and a mutable background spinner. */
+function hermesTuiApproval(at = 0, command = "echo '...' > AGENTS.md", warning = "warning", choices = ["Allow once", "Deny"], spinner = "⠋"): string {
+ const content = [`⚠ Approval required · ${warning}`, ...command.split("\n"), "", ...choices.map((label, index) => `${at === index ? "▸" : " "} ${index + 1}. ${label}`), `↑/↓ select · Enter confirm · 1-${choices.length} quick pick · Esc/Ctrl+C deny`];
+ return [`╔${"═".repeat(98)}╗`, ...content.map((line) => `║ ${line.padEnd(96)} ║`), `╚${"═".repeat(98)}╝`, "", `─ ~/app │ smoke-model │ ${spinner} approval needed ─`].join("\n");
+}
+
+describe("Hermes native approval prompts", () => {
+ test("parses classic approval with command, warning and options", () => {
+ const screen = hermesClassicApproval();
+ const prompt = parseInteractivePrompt("hermes", screen)!;
+ expect(prompt.kind).toBe("approval");
+ expect(prompt.title).toBe("⚠️ Dangerous Command");
+ expect(prompt.body).toContain("echo '...' > AGENTS.md");
+ expect(prompt.body).toContain("Write to protected agent-instruction files...approval always required");
+ expect(labels(prompt)).toEqual(["Allow once", "Deny"]);
+
+ // navigation keys
+ expect(answerKeys(prompt, { option_index: 0 })).toEqual([{ keys: ["enter"] }]);
+
+ // navigation keys when cursor moved to Deny
+ const pAt1 = parseInteractivePrompt("hermes", hermesClassicApproval(1))!;
+ expect(answerKeys(pAt1, { option_index: 1 })).toEqual([{ keys: ["enter"] }]);
+ });
+
+ test("classic approval identity is unaffected by spinner or countdown changes but invalidates on changes to command, warning or options", () => {
+ const prompt = parseInteractivePrompt("hermes", hermesClassicApproval())!;
+
+ // spinner / timeout tick does not invalidate
+ const ticked = parseInteractivePrompt("hermes", hermesClassicApproval(0, undefined, undefined, undefined, 239, "⠙"))!;
+ expect(ticked.id).toBe(prompt.id);
+
+ // changed command invalidates
+ const otherCmd = parseInteractivePrompt("hermes", hermesClassicApproval(0, "echo '...' > OTHER.md"))!;
+ expect(otherCmd.id).not.toBe(prompt.id);
+
+ // changed warning invalidates
+ const otherWarning = parseInteractivePrompt("hermes", hermesClassicApproval(0, undefined, "Different warning text"))!;
+ expect(otherWarning.id).not.toBe(prompt.id);
+
+ // changed options invalidate
+ const otherOptions = parseInteractivePrompt("hermes", hermesClassicApproval(0, undefined, undefined, ["Allow once", "Allow for this session", "Deny"]))!;
+ expect(otherOptions.id).not.toBe(prompt.id);
+ });
+
+ test("parses Ink approval modal with title, warning, and stable identity across spinner ticks", () => {
+ const screen = hermesTuiApproval();
+ const prompt = parseInteractivePrompt("hermes", screen)!;
+ expect(prompt.kind).toBe("approval");
+ expect(prompt.title).toBe("⚠ Approval required · warning");
+ expect(prompt.body).toBe("echo '...' > AGENTS.md");
+ expect(labels(prompt)).toEqual(["Allow once", "Deny"]);
+
+ // navigation keys
+ expect(answerKeys(prompt, { option_index: 0 })).toEqual([{ keys: ["enter"] }]);
+
+
+ // spinner tick preserves id
+ const ticked = parseInteractivePrompt("hermes", hermesTuiApproval(0, undefined, undefined, undefined, "⠙"))!;
+ expect(ticked.id).toBe(prompt.id);
+
+ // changed command, warning or options invalidate
+ expect(parseInteractivePrompt("hermes", hermesTuiApproval(0, "echo '...' > OTHER.md"))!.id).not.toBe(prompt.id);
+ expect(parseInteractivePrompt("hermes", hermesTuiApproval(0, undefined, "other warning"))!.id).not.toBe(prompt.id);
+ expect(parseInteractivePrompt("hermes", hermesTuiApproval(0, undefined, undefined, ["Allow once", "Allow this session", "Deny"]))!.id).not.toBe(prompt.id);
+ });
+
+ test("rejects truncated approval text, stale trails and broken borders", () => {
+ expect(parseInteractivePrompt("hermes", hermesClassicApproval(0, "echo '...' > AGENTS.md\n… (command truncated)"))).toBeNull();
+ expect(parseInteractivePrompt("hermes", `${hermesClassicApproval()}\nDone.`)).toBeNull();
+ expect(parseInteractivePrompt("hermes", `${hermesTuiApproval()}\nDone.`)).toBeNull();
+ });
+
+ test("parses classic approval with native tool progress row and keeps stable ID across timer and spinner ticks", () => {
+ const progress = "✍️ /tmp/hermes-five-smoke-BJYKAj/fixture-repo/AGENTS.md (52062s · ↓ 24 tok)";
+ const screen = hermesClassicApproval(0, "", "Write to protected agent-instruction file(s): AGENTS.md. These files steer future agent behavior; approval is always required (not bypassed by auto-approve).", ["Allow once", "Deny"], 247, "⠋", progress);
+ const prompt = parseInteractivePrompt("hermes", screen)!;
+ expect(prompt.kind).toBe("approval");
+ expect(prompt.title).toBe("⚠️ Dangerous Command");
+ expect(prompt.body).toContain("");
+ expect(labels(prompt)).toEqual(["Allow once", "Deny"]);
+
+ // Timer / token counter tick preserves approval ID
+ const tickedProgress = "✍️ /tmp/hermes-five-smoke-BJYKAj/fixture-repo/AGENTS.md (52063s · ↓ 25 tok)";
+ const ticked = parseInteractivePrompt("hermes", hermesClassicApproval(0, "", "Write to protected agent-instruction file(s): AGENTS.md. These files steer future agent behavior; approval is always required (not bypassed by auto-approve).", ["Allow once", "Deny"], 246, "⠙", tickedProgress))!;
+ expect(ticked.id).toBe(prompt.id);
+ for (const timer of ["( 17.1s · ↓ 24 tok)", "01m441s · ↓ 24 tok)"]) {
+ const repainted = parseInteractivePrompt("hermes", screen.replace("(52062s · ↓ 24 tok)", timer));
+ expect(repainted?.id).toBe(prompt.id);
+ }
+
+ // Changed target rejection: tool progress target mismatched from modal
+ const otherProgress = "✍️ /tmp/hermes-five-smoke-BJYKAj/fixture-repo/OTHER.md (52062s · ↓ 24 tok)";
+ expect(parseInteractivePrompt("hermes", hermesClassicApproval(0, "", "Write to protected agent-instruction file(s): AGENTS.md. These files steer future agent behavior; approval is always required (not bypassed by auto-approve).", ["Allow once", "Deny"], 247, "⠋", otherProgress))).toBeNull();
+
+ // Arbitrary conversation tail rejection
+ const arbitraryScreen = hermesClassicApproval(0, "", "Write to protected agent-instruction file(s): AGENTS.md.", ["Allow once", "Deny"], 247, "⠋", "I am currently generating the instructions for you.");
+ expect(parseInteractivePrompt("hermes", arbitraryScreen)).toBeNull();
+ });
+
+ test("parses Ink approval modal with trailing native background ruling after border", () => {
+ const rawInkScreen = [
+ " ╔════════════════════════════════════════════════════════════════════════════════════════════════════════════════════╗",
+ " ║ ⚠ approval required · Write to protected agent-tistruction file(s): AGENTS.md. These files steer future agent ║ ┃",
+ " ║ behavior; approval is always required (not bypassed by auto-approve). ║",
+ " ║ ║",
+ " ║ ▸ 1. Allow once ║",
+ " ║ 2. Deny ║",
+ " ║ ↑/↓ select · Enter confirm · 1-2 quick pick · Esc/Ctrl+C deny ║",
+ " ╚════════════════════════════════════════════════════════════════════════════════════════════════════════════════════╝",
+ "",
+ "",
+ "",
+ " ─ (⌐■_■) contemplating… · 1m 11s │ test model │ 16/256k │ 3m 27s ─ Hermes deterministic assistant response",
+ ].join("\n");
+ const prompt = parseInteractivePrompt("hermes", rawInkScreen)!;
+ expect(prompt.kind).toBe("approval");
+ expect(prompt.body).toContain("");
+ expect(prompt.body).toContain("behavior; approval is always required (not bypassed by auto-approve).");
+ expect(labels(prompt)).toEqual(["Allow once", "Deny"]);
+ const repainted = parseInteractivePrompt("hermes", rawInkScreen.replace("⚠ approval required", "⚠ Mpprdval required"))!;
+ expect(repainted.kind).toBe("approval");
+ expect(labels(repainted)).toEqual(["Allow once", "Deny"]);
+ });
+
+ test("preserves blank lines inside approved shell commands and their identities", () => {
+ const command = "cat > AGENTS.md <<'EOF'\nfirst\n\nsecond\nEOF";
+ for (const render of [hermesClassicApproval, hermesTuiApproval]) {
+ const prompt = parseInteractivePrompt("hermes", render(0, command))!;
+ expect(prompt.body).toContain(command);
+ const changed = parseInteractivePrompt("hermes", render(0, command.replace("first\n\nsecond", "first\nsecond")))!;
+ expect(changed.id).not.toBe(prompt.id);
+ expect(parseInteractivePrompt("hermes", render().replace(/([╰╚])[─═]/, "$1"))).toBeNull();
+ }
+ });
+
+ test("displays the full native progress target and rejects basename suffix matches", () => {
+ const firstScreen = hermesClassicApproval(0, "", undefined, undefined, 240, "⠋", "✍️ /tmp/first/AGENTS.md (17s · ↓ 24 tok)");
+ const prompt = parseInteractivePrompt("hermes", firstScreen)!;
+ expect(prompt.body).toContain("Target: /tmp/first/AGENTS.md");
+ const other = parseInteractivePrompt("hermes", firstScreen.replace("/tmp/first/AGENTS.md", "/tmp/second/AGENTS.md"))!;
+ expect(other.id).not.toBe(prompt.id);
+ expect(other.body).toContain("Target: /tmp/second/AGENTS.md");
+ const mismatched = hermesClassicApproval(0, "", "Write to OTHER-AGENTS.md", undefined, 240, "⠋", "✍️ /tmp/AGENTS.md (17s · ↓ 24 tok)");
+ expect(parseInteractivePrompt("hermes", mismatched)).toBeNull();
+ });
+});
+
describe("interactive prompt parsing", () => {
test("invalidates approvals when their command changes, including text beyond the display cap", () => {
const screen = (command: string, secondSelected = false) => `
@@ -2968,6 +3365,278 @@ describe("an answer and the menu it was made for", () => {
return { status: response.status, code: body.error?.code };
}
+ test("pages Hermes status and rejects an answer for the previous page", async () => {
+ await withPane("hermes", "idle", hermesStatusOutput(), async pane => {
+ const first = (await card())!;
+ pane.onSent = key => { if (key === "pagedown") pane.screen = hermesStatusOutput("Tokens: 30", true); if (key === "esc") pane.screen = "─ ready\n❯"; };
+ expect((await answer(first.id, { option_index: 1 })).status).toBe(200);
+ expect(pane.sent).toEqual(["pagedown"]);
+ expect((await answer(first.id, { option_index: 2 })).status).toBe(409);
+ const next = (await card())!;
+ expect(next.body).toBe("Tokens: 30");
+ expect((await answer(next.id, { option_index: 1 })).status).toBe(200);
+ expect(pane.sent).toEqual(["pagedown", "esc"]);
+ expect(await card()).toBeNull();
+ });
+ });
+
+ test("leaves unchanged Hermes pager actionable after navigation and closes with escape", async () => {
+ const profileScreen = hermesStatusOutput("Profile: default\nHome: /tmp/selected-home", true).replace("Status", "Profile");
+ await withPane("hermes", "idle", profileScreen, async pane => {
+ const first = (await card())!;
+ expect(labels(first)).toEqual(["Previous page", "Close"]);
+ pane.onSent = key => { if (key === "esc") pane.screen = "─ ready\n❯"; };
+
+ // Navigating at boundary leaves native pager screen unchanged
+ expect((await answer(first.id, { option_index: 0 })).status).toBe(200);
+ expect(pane.sent).toEqual(["pageup"]);
+ expect((await answer(first.id, { option_index: 1 })).status).toBe(409);
+
+ // Pager card remains actionable on unchanged screen
+ const current = (await card())!;
+ expect(current).not.toBeNull();
+ expect(current.id).not.toBe(first.id);
+ expect(labels(current)).toEqual(["Previous page", "Close"]);
+
+ // Subsequent close sends Escape and dismisses pager
+ pane.sent = [];
+ expect((await answer(current.id, { option_index: 1 })).status).toBe(200);
+ expect(pane.sent).toEqual(["esc"]);
+ expect(await card()).toBeNull();
+ });
+ });
+
+ test("sends no Hermes pager key after another prompt replaces it", async () => {
+ await withPane("hermes", "idle", hermesStatusOutput(), async pane => {
+ const first = (await card())!;
+ pane.screen = "Other native prompt\n❯ typed draft";
+ expect((await answer(first.id, { option_index: 1 })).status).toBe(409);
+ expect(pane.sent).toEqual([]);
+ });
+ });
+
+ test("dismisses help without touching native input, and rearms on a new help send", async () => {
+ await withPane("hermes", "idle", hermesHelpOutput, async pane => {
+ noteSubmitted("p_1", "/help");
+ const first = (await card())!;
+ expect((await answer(first.id, { option_index: 0 })).status).toBe(200);
+ expect(pane.sent).toEqual([]);
+ expect(await card()).toBeNull();
+ noteSubmitted("p_1", "/help");
+ const next = (await card())!;
+ expect(next.id).not.toBe(first.id);
+ expect((await answer(first.id, { option_index: 0 })).status).toBe(409);
+ await answer(next.id, { option_index: 0 });
+ });
+ });
+
+ test("dismisses classic status without sending any terminal key", async () => {
+ await withPane("hermes", "idle", hermesClassicOutput(hermesClassicStatus), async pane => {
+ noteSubmitted("p_1", "/status");
+ const first = (await card())!;
+ expect(first.body).toBe(hermesClassicStatus);
+ expect((await answer(first.id, { option_index: 0 })).status).toBe(200);
+ expect(pane.sent).toEqual([]);
+ expect(await card()).toBeNull();
+ });
+ });
+
+ test("dismisses a reload result without sending keys or accepting a stale replacement", async () => {
+ await withPane("hermes", "idle", hermesClassicOutput("No new skills detected.\n📚 54 skill(s) available"), async pane => {
+ noteSubmitted("p_1", "/reload-skills");
+ const first = (await card())!;
+ pane.screen = hermesClassicOutput("➕ Added Skills:\n- new-skill\n📚 55 skill(s) available");
+ expect((await answer(first.id, { option_index: 0 })).status).toBe(409);
+ const next = (await card())!;
+ expect((await answer(next.id, { option_index: 0 })).status).toBe(200);
+ expect(pane.sent).toEqual([]);
+ expect(await card()).toBeNull();
+ });
+ });
+
+ test("waits for Hermes approval cursor settlement and handles allow and deny keys", async () => {
+ await withPane("hermes", "blocked", hermesClassicApproval(0), async pane => {
+ promptWaitEnded("p_1");
+ const shown = (await card())!;
+ expect(shown.kind).toBe("approval");
+ expect(labels(shown)).toEqual(["Allow once", "Deny"]);
+
+ // Deny: sends down, waits for cursor settle at row 1, then sends enter
+ pane.onSent = key => {
+ if (key === "down") {
+ pane.screen = hermesClassicApproval(1, undefined, undefined, undefined, 239, "⠙");
+ }
+ };
+ expect((await answer(shown.id, { option_index: 1 })).status).toBe(200);
+ expect(pane.sent).toEqual(["down", "enter"]);
+ // Lingering screen after commit is withheld by hermesAnswered without generic fallback
+ expect(await card()).toBeNull();
+
+ // A different new approval remains actionable
+ pane.screen = hermesClassicApproval(0, "echo '...' > OTHER.md");
+ const next = (await card())!;
+ expect(next.kind).toBe("approval");
+ expect(next.body).toContain("OTHER.md");
+ pane.sent = [];
+ expect((await answer(next.id, { option_index: 0 })).status).toBe(200);
+ expect(pane.sent).toEqual(["enter"]);
+ expect(await card()).toBeNull();
+
+ // Once native modal actually disappears, new identical asking remains answerable
+ pane.screen = "hermes> done\n";
+ await card();
+ pane.screen = hermesClassicApproval(0);
+ const reasked = (await card())!;
+ expect(reasked.kind).toBe("approval");
+ pane.sent = [];
+ expect((await answer(reasked.id, { option_index: 0 })).status).toBe(200);
+ expect(pane.sent).toEqual(["enter"]);
+ });
+
+ // Allow once: sends enter directly without moving
+ await withPane("hermes", "blocked", hermesClassicApproval(0), async pane => {
+ promptWaitEnded("p_1");
+ const shown = (await card())!;
+ expect((await answer(shown.id, { option_index: 0 })).status).toBe(200);
+ expect(pane.sent).toEqual(["enter"]);
+ expect(await card()).toBeNull();
+ });
+
+ // Stale target rejection: if prompt changes before cursor settles
+ await withPane("hermes", "blocked", hermesClassicApproval(0), async pane => {
+ promptWaitEnded("p_1");
+ const shown = (await card())!;
+ pane.onSent = key => {
+ if (key === "down") {
+ pane.screen = hermesClassicApproval(1, "echo '...' > OTHER.md");
+ }
+ };
+ expect((await answer(shown.id, { option_index: 1 })).status).toBe(409);
+ });
+ });
+
+ test("leaves malformed native approvals and excluded pagers in Terminal, never generic fallback", async () => {
+ const screens = [
+ hermesClassicApproval(0, "echo safe\n… (command truncated)"),
+ hermesClassicApproval(0, undefined, ""),
+ hermesClassicApproval().replace("╰──", "╰─"),
+ hermesTuiApproval().split("\n").filter(line => !line.startsWith("╚")).join("\n"),
+ hermesClassicApproval().replace("Dangerous Command", "Unreadable caption"),
+ hermesClassicApproval().replace("(240s)", "(unreadable countdown)"),
+ hermesClassicApproval().replace("Enter to confirm (240s)", "Enter to confirm\n (unreadable countdown)"),
+ hermesTuiApproval().replace("⚠ Approval required · warning", "Unreadable caption"),
+ hermesTuiApproval().replace("quick pick", "unreadable hint"),
+ hermesTuiApproval().replace(/^║.*echo.*$/m, "unreadable command row").replace("quick pick", "unreadable hint"),
+ hermesTuiApproval(0, "echo safe\n… (command truncated)"),
+ hermesStatusOutput().split("\n").slice(0, -1).join("\n"),
+ hermesStatusOutput().replace("╚══", "╚═"),
+ hermesStatusOutput().replace("Status", "Logs"),
+ hermesStatusOutput().replace("Esc/q close", "unreadable close hint"),
+ hermesStatusOutput().replace(/^║.*Model:.*$/m, "unreadable status row").replace("Esc/q close", "unreadable close hint"),
+ hermesStatusOutput(undefined, true).replace(/^║.*Model:.*$/m, "unreadable status row").replace("Esc/q close", "unreadable close hint"),
+ hermesStatusOutput(undefined, false, true).replace(/^║.*Model:.*$/m, "unreadable status row").replace("Esc/q close", "unreadable close hint"),
+ hermesStatusOutput("No changes.", true).replace("Status", "Diff"),
+ hermesStatusOutput("Context details", true).replace("Status", "Context"),
+ ];
+ for (const screen of screens) {
+ await withPane("hermes", "blocked", screen, async pane => {
+ promptWaitEnded("p_1");
+ expect(parseInteractivePrompt("hermes", screen)).toBeNull();
+ expect(await card()).toBeNull();
+ expect(pane.sent).toEqual([]);
+ });
+ }
+ });
+
+ test("does not let nearby quoted native chrome hide an unrelated bottom question", async () => {
+ const quotes = [
+ "The old log said Dangerous Command before continuing.",
+ "Quoted warning: ⚠ Approval required · write permission",
+ "Old footer: ↑/↓ to select, Enter to confirm (240s)",
+ "Old footer: ↑/↓ select · Enter confirm · 1-2 quick pick · Esc/Ctrl+C deny",
+ "║ Status ║",
+ "║ Diff ║",
+ "║ Context ║",
+ "Old pager: Esc/q close (5/9)",
+ hermesClassicApproval(),
+ hermesTuiApproval(),
+ hermesStatusOutput(),
+ hermesStatusOutput("No changes.", true).replace("Status", "Diff"),
+ hermesStatusOutput("Context details", true).replace("Status", "Context"),
+ ];
+ for (const quote of quotes) {
+ await withPane("hermes", "blocked", `${quote}\nContinue with the unrelated task? (y/n)`, async pane => {
+ promptWaitEnded("p_1");
+ const shown = (await card())!;
+ expect(shown.fallback).toBe(true);
+ expect(labels(shown)).toContain("Enter");
+ expect(pane.sent).toEqual([]);
+ });
+ }
+ });
+
+ test("does not associate old native prose with an unrelated later box", async () => {
+ const screens = [
+ "The old log said Dangerous Command.\nAnother task now needs input.\n╭────────────────────────────╮\n│ Continue deployment? (y/n) │\n╰────────────────────────────╯\n─ ─",
+ "Quoted warning: ⚠ Approval required · write permission\nAnother task now needs input.\n╔════════════════════════════╗\n║ Continue deployment? (y/n) ║\n╚════════════════════════════╝\n─ ─",
+ ];
+ for (const screen of screens) {
+ await withPane("hermes", "blocked", screen, async pane => {
+ promptWaitEnded("p_1");
+ const shown = (await card())!;
+ expect(shown.fallback).toBe(true);
+ expect(labels(shown)).toContain("Enter");
+ expect(pane.sent).toEqual([]);
+ });
+ }
+ });
+
+ test("does not revive an answered approval through an incomplete redraw", async () => {
+ await withPane("hermes", "blocked", hermesTuiApproval(), async pane => {
+ promptWaitEnded("p_1");
+ const shown = (await card())!;
+ expect((await answer(shown.id, { option_index: 0 })).status).toBe(200);
+ expect(pane.sent).toEqual(["enter"]);
+ pane.screen = hermesTuiApproval().replace("╚══", "╚═");
+ expect(await card()).toBeNull();
+ pane.screen = hermesTuiApproval();
+ expect(await card()).toBeNull();
+ expect((await answer(shown.id, { option_index: 0 })).status).toBe(409);
+ expect(pane.sent).toEqual(["enter"]);
+ });
+ });
+
+ test("rechecks Ink approval before every later move and before a direct Enter", async () => {
+ const choices = ["Allow once", "Allow this session", "Always allow", "Deny"];
+ await withPane("hermes", "blocked", hermesTuiApproval(0, undefined, undefined, choices), async pane => {
+ promptWaitEnded("p_1");
+ const shown = (await card())!;
+ pane.onSent = key => { if (key === "down") pane.screen = hermesTuiApproval(1, undefined, "changed warning", choices); };
+ expect((await answer(shown.id, { option_index: 3 })).status).toBe(409);
+ expect(pane.sent).toEqual(["down"]);
+ });
+ await withPane("hermes", "blocked", hermesTuiApproval(), async pane => {
+ promptWaitEnded("p_1");
+ const shown = (await card())!;
+ let reads = 0;
+ pane.onRead = () => { if (++reads === 2) pane.screen = hermesTuiApproval(0, "write OTHER.md"); };
+ expect((await answer(shown.id, { option_index: 0 })).status).toBe(409);
+ expect(pane.sent).toEqual([]);
+ });
+ });
+
+ test("preserves generic fallback for unrelated Hermes waits and other agents", async () => {
+ for (const [agent, screen] of [["hermes", "Continue? (y/n)"], ["unknown-agent", "Choose a mode\n1. Safe\n2. Other\nEnter a number"]]) {
+ await withPane(agent!, "blocked", screen!, async pane => {
+ promptWaitEnded("p_1");
+ const shown = (await card())!;
+ expect(shown.fallback).toBe(true);
+ expect(shown.options.length).toBeGreaterThan(1);
+ expect(pane.sent).toEqual([]);
+ });
+ }
+ });
+
const omoRule = "─".repeat(100);
const omoWidget = (box = "❯") => `? Question pending (1 unanswered) · 30m
QA — Which one?
diff --git a/server/prompt.ts b/server/prompt.ts
index ab378efa5..b218280e9 100644
--- a/server/prompt.ts
+++ b/server/prompt.ts
@@ -121,6 +121,10 @@ type Responder =
| "pi-model"
| "hermes-question"
| "hermes-input"
+ | "hermes-classic-approval"
+ | "hermes-tui-approval"
+ | "hermes-pager"
+ | "hermes-output"
| "fallback-menu"
| "fallback-gjc-menu"
| "fallback-keys";
@@ -1875,6 +1879,7 @@ function withoutClaudeTasks(shown: string[], hintEnds: (lines: string[], end: nu
return end < shown.length && hintEnds(shown, end) ? shown.slice(0, end) : shown;
}
+/** Confirm each responder's native hint still owns the screen tail, ignoring only known chrome. */
function promptTailIsActive(prompt: ParsedPrompt, screen: string): boolean {
const cleanLines = screen.replace(ANSI_RE, "").split(/\r?\n/).map(cleanLine);
const visible = cleanLines.filter((line) => line && !isDivider(line));
@@ -1889,6 +1894,9 @@ function promptTailIsActive(prompt: ParsedPrompt, screen: string): boolean {
// Hermes keeps its status line (and maybe the hint's wrapped tail) under the hint; an answered
// question leaves the screen, so a hint among the last few lines is the live one
if (prompt.responder === "hermes-question" || prompt.responder === "hermes-input") return shown.slice(-4).some((line) => HERMES_HINT_RE.test(line));
+ if (prompt.responder === "hermes-classic-approval" || prompt.responder === "hermes-tui-approval") return true;
+ if (prompt.responder === "hermes-pager") return true; // its complete, bottom-most native overlay is checked by the parser
+ if (prompt.responder === "hermes-output") return true; // read-only output admitted only after this pane's explicit command
if (prompt.responder === "codex-menu") return ends(CODEX_CONTINUE_HINT_RE);
if (prompt.responder === "codex-question") return ends(CODEX_ASK_HINT_RE);
if (prompt.responder === "codex-async-question") return cleanLines.slice(-4).some((line) => CODEX_ASYNC_ASK_HINT_RE.test(line)) || ends(CODEX_ASYNC_ASK_HINT_RE);
@@ -2230,6 +2238,379 @@ const HERMES_ASK_RE = /^ask \d+ questions?$/;
const HERMES_ROW_RE = /^(▸ )?(\[[ x]\] )?(\d+)\. (.+)$/;
const HERMES_OTHER = "Other (type your answer)";
+const HERMES_PAGER_HINT_RE = /^(?:↑↓\/jkline·Enter\/Space\/PgDnpage·b\/PgUpback·g\/Gtop\/bottom·Esc\/qclose\(\d+\/\d+\)|end·↑↓\/jk·b\/PgUpback·gtop·Esc\/qclose\(\d+lines\))$/;
+
+/** Ink's informational pagers are read-only; each button sends one explicit native pager key. */
+function parseHermesPager(screen: string): ParsedPrompt | null {
+ const raw = screen.replace(ANSI_RE, "").split(/\r?\n/).map(line => line.replace(/\s+[┃│]\s*$/, ""));
+ const top = findLastIndex(raw, line => /^\s*╔═+╗/.test(line));
+ if (top < 0) return null;
+ const bottom = raw.findIndex((line, index) => index > top && /^\s*╚═+╝/.test(line));
+ if (bottom < 0) return null;
+ const topBorder = raw[top]!.match(/^(\s*╔═+╗)/);
+ const bottomBorder = raw[bottom]!.match(/^(\s*╚═+╝)/);
+ // Backdrop can remain beside an overlay; newer output below it invalidates the card.
+ if (!topBorder || !bottomBorder || topBorder[1]!.trim().length !== bottomBorder[1]!.trim().length
+ || !hermesTail(raw, bottom, false)) return null;
+ const rows = raw.slice(top + 1, bottom);
+ if (!rows.every(line => /^\s*║.*║/.test(line))) return null;
+ const inner = rows.map(line => line.slice(line.indexOf("║") + 1, line.lastIndexOf("║")));
+ const lines = inner.map(line => line.trim());
+ const title = lines.findIndex(Boolean);
+ if (title < 0 || !["Status", "Reload Skills", "Version", "Profile", "Review"].includes(lines[title]!)) return null;
+ const hint = lines.findIndex((line, index) => index > title && /^(?:↑|end)/.test(line) && HERMES_PAGER_HINT_RE.test(lines.slice(index).join("").replace(/\s+/g, "")));
+ if (hint < 0) return null;
+ const bodyRows = inner.slice(title + 1, hint).map(line => line.trim());
+ while (bodyRows.length > 0 && !bodyRows[0]!.trim()) bodyRows.shift();
+ while (bodyRows.length > 0 && !bodyRows.at(-1)!.trim()) bodyRows.pop();
+ const body = bodyRows.join("\n").trim();
+ const end = lines[hint]!.startsWith("end");
+ const outputTitle = lines[title] === "Reload Skills" && /(?:^|\n)(?:❌\s*)?Skills reload failed: .+/.test(body) ? "Reload Skills failed" : lines[title]!;
+ const options = end ? ["Previous page", "Close"] : ["Previous page", "Next page", "Close"];
+ return finishPrompt("hermes", {
+ kind: "menu", title: outputTitle, question: "Native terminal output", body,
+ options: options.map(label => ({ label, description: null })), multi_select: false, custom_option_index: null,
+ }, { responder: "hermes-pager", menuLabels: options, selectedIndex: 0, checkedOptionIndices: [], customMenuIndex: null, rejectWithEscapeIndex: null,
+ optionSteps: (end ? ["pageup", KEY.escape] : ["pageup", "pagedown", KEY.escape]).map(key => keySteps([key])) });
+}
+
+/** Build a read-only command-result card whose dismissal never sends a native terminal key. */
+function hermesOutputPrompt(title: string, body: string): ParsedPrompt {
+ return finishPrompt("hermes", {
+ kind: "menu", title, question: "Visible terminal output; use Terminal to scroll the full output.",
+ body, options: [{ label: "Dismiss", description: null }], multi_select: false, custom_option_index: null,
+ }, { responder: "hermes-output", menuLabels: ["Dismiss"], selectedIndex: 0, checkedOptionIndices: [], customMenuIndex: null, rejectWithEscapeIndex: null, optionSteps: [[]] });
+}
+
+/** Help is an Ink transcript panel, not a pager or a SQLite turn. Show only its visible portion. */
+function parseHermesHelp(screen: string, sent: string | null): ParsedPrompt | null {
+ if (sent?.trim() !== "/help") return null;
+ const raw = screen.replace(ANSI_RE, "").split(/\r?\n/);
+ const lines = raw.map(line => cleanLine(line.replace(/\s+┃\s*$/, "")));
+ const title = findLastIndex(lines, line => line === "(^_^)? Commands");
+ const hotkeys = findLastIndex(lines, line => line === "Hotkeys");
+ let start = title >= 0 ? title : hotkeys;
+ if (title < 0) while (start > 0 && /^\s*│/.test(raw[start - 1]!)) start -= 1;
+ if (start < 0) return null;
+ const end = raw.findIndex((line, index) => index > start && /^╰[─━]+(?:╯(?:\s*┃)?|\s*┃)$/.test(cleanLine(line)));
+ if (end < 0 || !lines.slice(start + 1, end).some(line => /\/help|Ctrl|ctrl|Enter|Tab/.test(line))) return null;
+ const tail = lines.slice(end + 1).filter(Boolean);
+ if (!tail.some(line => /^─?\s*ready\b/.test(line)) || !tail.every(line => /^─?\s*ready\b|^❯(?:\s+Try ".*")?$/.test(line))) return null;
+ return hermesOutputPrompt("Help", lines.slice(title >= 0 ? start + 1 : start, end).filter(line => !isDivider(line)).map(line => line.replace(/\s*┃$/, "")).join("\n").trim());
+}
+
+/** Ink short system messages (profile, review) above its ready footer. */
+function parseHermesInkOutput(screen: string, sent: string | null): ParsedPrompt | null {
+ const trimmedSent = sent?.trim() ?? "";
+ const baseCommand = trimmedSent.split(/\s+/)[0] ?? "";
+ if (baseCommand !== "/profile" && baseCommand !== "/review") return null;
+ const rawLines = screen.replace(ANSI_RE, "").split(/\r?\n/);
+ const unchromed = rawLines.map(line => line.replace(/\s*[┃│]$/, ""));
+ const lines = unchromed.map(cleanLine);
+ const input = findLastIndex(lines, line => /^❯(?:\s.*)?$/.test(line));
+ if (input < 0 || lines.slice(input + 1).some(Boolean)) return null;
+ const footer = findLastIndex(lines.slice(0, input), line => /^─?\s*ready\b/.test(line));
+ if (footer < 0 || lines.slice(footer + 1, input).some(Boolean)) return null;
+ const output = lines.slice(0, footer);
+ const unchromedOutput = unchromed.slice(0, footer);
+ const echo = findLastIndex(output, line => /^·\s*\/[\w-]+(?:\s.*)?$/.test(line));
+ if (echo < 0) return null;
+ const echoCmd = output[echo]!.replace(/^·\s*/, "").trim();
+ if (echoCmd.replace(/\s+/g, " ") !== trimmedSent.replace(/\s+/g, " ")) return null;
+
+ if (baseCommand === "/profile") {
+ let dotBlank = echo + 1;
+ while (dotBlank < output.length && !output[dotBlank]) dotBlank += 1;
+ if (dotBlank >= output.length || !/^·\s*$/.test(output[dotBlank]!)) return null;
+ const profileIndex = output.findIndex((line, index) => index > dotBlank && /^(?:·\s*)?Profile:\s*\S+/.test(line));
+ const homeIndex = output.findIndex((line, index) => index > profileIndex && /^(?:·\s*)?Home:\s*\S+/.test(line));
+ if (profileIndex < 0 || homeIndex < 0 || homeIndex !== profileIndex + 1) return null;
+ if (output.slice(dotBlank + 1, profileIndex).some(Boolean)) return null;
+ if (output.slice(homeIndex + 1).some(Boolean)) return null;
+ return hermesOutputPrompt("Profile", output.slice(profileIndex, homeIndex + 1).map(l => l.replace(/^·\s*/, "")).join("\n").trim());
+ }
+
+ if (baseCommand === "/review") {
+ let reviewStart = echo + 1;
+ while (reviewStart < output.length && !output[reviewStart]) reviewStart += 1;
+ if (reviewStart >= output.length) return null;
+ const firstLine = output[reviewStart]!.replace(/^·\s*/, "");
+ if (!/^⚖\s*Review subagent dispatched\.\.\./.test(firstLine)) return null;
+ let reviewEnd = reviewStart;
+ while (reviewEnd + 1 < output.length && output[reviewEnd + 1]) reviewEnd += 1;
+ if (output.slice(reviewEnd + 1).some(Boolean)) return null;
+ const reviewLines = unchromedOutput.slice(reviewStart, reviewEnd + 1).map(l => l.replace(/^·\s*/, "").trimEnd());
+ return hermesOutputPrompt("Review", reviewLines.join("\n").trim());
+ }
+
+ return null;
+}
+
+/** Classic Hermes prints command results above its status bar and bordered input box. */
+function parseHermesClassicOutput(screen: string, sent: string | null): ParsedPrompt | null {
+ const trimmedSent = sent?.trim() ?? "";
+ const command = trimmedSent.replace(/^\/reload_skills$/, "/reload-skills").replace(/^\/v$/, "/version");
+ const baseCommand = command.split(/\s+/)[0] ?? "";
+ if (!["/help", "/status", "/reload-skills", "/version"].includes(command)
+ && baseCommand !== "/profile" && baseCommand !== "/review") return null;
+ const rawLines = screen.replace(ANSI_RE, "").split(/\r?\n/);
+ const lines = rawLines.map(cleanLine);
+ const input = findLastIndex(lines, line => /^(?:[\w.-]+\s+)?❯(?:\s.*)?$/.test(line));
+ if (input < 0 || lines.slice(input + 1).some(line => line && !isDivider(line))) return null;
+ const footer = findLastIndex(lines.slice(0, input), line => /^[⚕☤]\s.+│/.test(line));
+ if (footer < 0 || lines.slice(footer + 1, input).some(line => line && !isDivider(line))) return null;
+ const output = lines.slice(0, footer);
+
+ if (baseCommand === "/profile") {
+ const echo = findLastIndex(output, line => /^(?:⚙️?\s*)?\/profile(?:\s+\S+)?$/.test(line));
+ if (echo < 0) return null;
+ const echoCmd = output[echo]!.replace(/^(?:⚙️?\s*)?/, "").trim();
+ if (echoCmd.replace(/\s+/g, " ") !== command.replace(/\s+/g, " ")) return null;
+ const profileIndex = findLastIndex(output, line => /^Profile:\s*\S+/.test(line));
+ if (profileIndex < 0) return null;
+ let homeIndex = profileIndex + 1;
+ while (homeIndex < output.length && (!output[homeIndex] || homeIndex === echo)) homeIndex += 1;
+ if (homeIndex >= output.length || !/^Home:\s*\S+/.test(output[homeIndex]!)) return null;
+ // Native redraws can place the echo after profile output. A preceding profile
+ // echo makes that ordering ambiguous with an earlier completed invocation.
+ if (echo > profileIndex && output.some((line, index) => index < profileIndex && /^(?:⚙️?\s*)?\/profile(?:\s+\S+)?$/.test(line))) return null;
+ for (let index = Math.min(echo, profileIndex); index < output.length; index += 1) {
+ if (index !== echo && index !== profileIndex && index !== homeIndex && output[index]) return null;
+ }
+ return hermesOutputPrompt("Profile", `${output[profileIndex]}\n${output[homeIndex]}`);
+ }
+
+ if (baseCommand === "/review") {
+ const echo = findLastIndex(output, line => /^(?:⚙️?\s*)?\/review(?:\s+.*)?$/.test(line));
+ if (echo < 0) return null;
+ const echoCmd = output[echo]!.replace(/^(?:⚙️?\s*)?/, "").trim();
+ if (echoCmd.replace(/\s+/g, " ") !== command.replace(/\s+/g, " ")) return null;
+ let reviewStart = echo + 1;
+ while (reviewStart < output.length && !output[reviewStart]) reviewStart += 1;
+ if (reviewStart >= output.length || !/^⚖\s*Review subagent dispatched\.\.\./.test(output[reviewStart]!)) return null;
+ let reviewEnd = reviewStart;
+ while (reviewEnd + 1 < output.length && output[reviewEnd + 1]) reviewEnd += 1;
+ if (output.slice(reviewEnd + 1).some(Boolean)) return null;
+ return hermesOutputPrompt("Review", output.slice(reviewStart, reviewEnd + 1).join("\n").trim());
+ }
+
+ let start: number, end: number;
+ if (command === "/status") {
+ start = findLastIndex(output, line => line === "Hermes CLI Status");
+ end = findLastIndex(output, line => /^Agent Running: (?:Yes|No)$/.test(line));
+ if (start < 0 || end <= start || !output.slice(start, end).some(line => /^Session ID: /.test(line))
+ || !output.slice(start, end).some(line => /^Model: /.test(line)) || !output.slice(start, end).some(line => /^Tokens: /.test(line))) return null;
+ } else if (command === "/reload-skills") {
+ end = findLastIndex(output, line => /^(?:📚\s*)?\d+ skill\(s\) available$|^❌ Skills reload failed: .+/.test(line));
+ const echo = findLastIndex(output, line => /^(?:⚙️?\s*)?\/reload[-_]skills$/.test(line));
+ start = output.findIndex((line, index) => index > echo && /^(?:🔄 Reloading skills\.\.\.|No new skills detected\.|➕ Added Skills:|➖ Removed Skills:|❌ Skills reload failed:)/.test(line));
+ if (end < 0 || start < 0 || start > end) return null;
+ } else if (command === "/version") {
+ start = findLastIndex(output, line => /^Hermes Agent v\d+\.\d+\.\d+\S* \([\d.\-]+\)/.test(line));
+ end = findLastIndex(output, line => /^OpenAI SDK: .+|^Update available(?::| —)|^Up to date$/.test(line));
+ if (start < 0 || end <= start) return null;
+ const body = output.slice(start + 1, end + 1).filter(Boolean);
+ if (!body.some(line => /^Install directory: .+/.test(line)) || !body.some(line => /^Python: .+/.test(line))
+ || !body.some(line => /^OpenAI SDK: .+/.test(line))
+ || !body.every(line => /^(?:Install directory: |Install method: |Python: |OpenAI SDK: |Update available(?::| —)|Up to date$|(?:⚙️?\s*)?\/(?:version|v)$)/.test(line))) return null;
+ } else {
+ const tip = findLastIndex(output, line => /^Tip: \/help skills lists skill commands/.test(line));
+ end = findLastIndex(output, line => /^(?:Paste image: Alt\+V \(or \/paste\)|Attach image: \/image )/.test(line));
+ start = output.findIndex(line => /^\/[\w-]+\s+-\s|^── .+ ──$/.test(line));
+ if (tip < 0 || start < 0 || start >= tip || end < tip) return null;
+ }
+ if (output.slice(end + 1).some(Boolean)) return null;
+ return hermesOutputPrompt(
+ command === "/help" ? "Help" : command === "/status" ? "Status" : command === "/version" ? "Version"
+ : /^❌ Skills reload failed:/.test(output[end]!) ? "Reload Skills failed" : "Reload Skills",
+ output.slice(start, end + 1).filter(line => command !== "/version" || !/^(?:⚙️?\s*)?\/(?:version|v)$/.test(line)).join("\n").trim()
+ );
+}
+
+/** Hermes v0.21: cli.py's rounded prompt_toolkit panels and ui-tui's double-border prompts.
+ * Read the entire panel, never a logged summary or a command preview hiding its tail. */
+const HERMES_CLASSIC_APPROVAL_HINT = /^↑\/↓ to select, Enter to confirm\s+\([1-9]\d*s\)$/;
+const HERMES_TUI_APPROVAL_HINT = /^↑\/↓ select · Enter confirm · 1-(\d) quick pick · (?:Esc\/)?Ctrl\+C deny$/;
+const HERMES_TRUNCATED_RE = /…|\(command truncated|\(description truncated|\(detail truncated/;
+const HERMES_TOOL_PROGRESS_RE = /^(?:✍️?|[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]|[\u2800-\u28ff]|⚡|⚙️)\s+(\S.*?)(?:\s+\(?\s*(?:\d+m\s*)?\d+(?:\.\d+)?s(?:\s*·\s*↓\s*\d+\s*tok)?\))?$/;
+
+/** Withhold fallback only when native approval or pager chrome still owns the screen tail.
+ * Borders and contents may be unreadable, but unrelated output below them ends ownership. */
+function hermesNeedsTerminal(screen: string): boolean {
+ const lines = screen.replace(ANSI_RE, "").split(/\r?\n/);
+ for (let index = 0; index < lines.length; index += 1) {
+ const line = lines[index]!;
+ const classicHint = /↑\/↓ to select, Enter to confirm/.test(line);
+ const classic = /Dangerous Command/.test(line) || classicHint;
+ const native = classic || /⚠️?[^\n]* required ·|↑\/↓ select · Enter confirm · 1-|Esc\/q\s+close\s*\(/.test(line)
+ || /^\s*║\s*(?:Status|Reload Skills|Version|Profile|Review|Diff|Context)\s*║/.test(line)
+ // Pager navigation remains native chrome when its body or Close caption is damaged.
+ || /^\s*║\s*(?:↑↓\/jk line · Enter\/Space\/PgDn page ·|end · ↑↓\/jk · b\/PgUp back ·)/.test(line);
+ if (!native) continue;
+ // Classic's hint is outside its panel; check its composer rather than treating it as a row.
+ if (classicHint && hermesTail(lines, index - 1, true, true)) return true;
+ // A border belongs to this fragment only while panel rows remain contiguous.
+ let bottom = -1;
+ for (let next = index + 1; next < lines.length; next += 1) {
+ const following = lines[next]!;
+ if (/^\s*[╰╚]/.test(following)) { bottom = next; break; }
+ if (following.trim() && !/^\s*[│║]/.test(following)) break;
+ }
+ let end = bottom >= 0 ? bottom : index;
+ if (bottom < 0) {
+ // Without a bottom border, require the remaining panel rows to reach native chrome.
+ for (let next = index + 1; next < lines.length; next += 1) {
+ const following = lines[next]!;
+ if (following.trim() && !/^\s*[│║]/.test(following)) break;
+ end = next;
+ }
+ }
+ if (hermesTail(lines, end, classic, true) || hermesTail(lines, end, false)) return true;
+ }
+ return false;
+}
+
+/** Only native chrome may follow a panel. Suppression may accept a damaged classic hint,
+ * but actionable approval parsing always requires its complete countdown footer. */
+function hermesTail(lines: string[], end: number, classic: boolean, unreadable = false): boolean {
+ const tail = lines.slice(end + 1).map((line) => line.replace(ANSI_RE, "").trim().replace(/^[│║]\s?/, "").replace(/\s?[│║]$/, "").trim()).filter(Boolean);
+ if (!classic) return tail.length <= 2 && tail.every((line) => /^─(?:\s|─)/.test(line));
+ const hint = HERMES_CLASSIC_APPROVAL_HINT;
+ let hintEnd = -1;
+ for (let span = 1; span <= Math.min(4, tail.length); span += 1) {
+ if (hint.test(tail.slice(0, span).join(" "))) { hintEnd = span; break; }
+ }
+ if (hintEnd < 0 && tail.length > 0 && HERMES_TOOL_PROGRESS_RE.test(tail[0]!)) {
+ for (let span = 1; span <= Math.min(4, tail.length - 1); span += 1) {
+ if (hint.test(tail.slice(1, 1 + span).join(" "))) { hintEnd = 1 + span; break; }
+ }
+ }
+ if (hintEnd < 0 && unreadable) {
+ const start = HERMES_TOOL_PROGRESS_RE.test(tail[0] ?? "") ? 1 : 0;
+ for (let span = 1; span <= Math.min(4, tail.length - start); span += 1) {
+ const candidate = tail.slice(start, start + span).join(" ");
+ if (/^↑\/↓ to select, Enter to confirm\b/.test(candidate)
+ && tail.slice(start + span).every(line => /^─+$|^[⚕☤]\s|^⚠\s*[❯›>]/.test(line))) {
+ hintEnd = start + span;
+ break;
+ }
+ }
+ }
+ if (hintEnd < 0) return false;
+ const rest = tail.slice(hintEnd);
+ let composer = false;
+ for (const line of rest) {
+ if (/^─+$/.test(line) || /^[⚕☤]\s/.test(line)) continue;
+ const input = line.match(/^⚠\s*[❯›>]\s*(.*)$/);
+ if (!input || composer) return false;
+ composer = true;
+ }
+ return rest.length <= 6 && composer;
+}
+
+/** Recognizes and parses classic CLI and default TUI Hermes approval panels. */
+function parseHermesPrompt(screen: string): ParsedPrompt | null {
+ const raw = screen.replace(ANSI_RE, "").split(/\r?\n/).map((line) => line.replace(/([╭╔╰╚║│].*[╮╗╰╝║│])\s*[┃│]\s*$/, "$1"));
+ const start = findLastIndex(raw, (line) => /^\s*[╭╔][─═]+[╮╗]\s*$/.test(line));
+ if (start < 0) return null;
+ const classic = raw[start]!.trim().startsWith("╭");
+ const end = raw.findIndex((line, index) => index > start && (classic ? /^\s*╰─+╯\s*$/ : /^\s*╚═+╝\s*$/).test(line));
+ if (end < 0 || raw[start]!.trim().length !== raw[end]!.trim().length
+ || raw.slice(start + 1, end).some((line) => !(classic ? /^\s*│.*│\s*$/ : /^\s*║.*║\s*$/).test(line))) return null;
+ const panel = raw.slice(start + 1, end).map((line) => line.replace(ANSI_RE, "").trim().replace(/^[│║]\s?/, "").replace(/\s?[│║]$/, "").trim());
+ // Remove only the renderer's border padding. Leading whitespace in a multiline shell
+ // command can be meaningful and must not collide with an otherwise identical command.
+ const content = raw.slice(start + 1, end).map((line) => line.replace(/^\s*[│║] ?/, "").replace(/ ?[│║]\s*$/, "").trimEnd());
+ const title = panel[0] ?? "";
+ // The native footer and allow/deny rows below identify an approval; repainting can damage its decorative caption.
+ const approval = classic ? /^⚠️?\s+Dangerous Command$/i.test(title) : /^⚠️?\s*\S.* required · \S/i.test(title);
+ if (!approval) return null;
+ if (!hermesTail(raw, end, classic)) return null;
+ if (panel.some((line) => HERMES_TRUNCATED_RE.test(line))) return null;
+ let footer = panel.length;
+ let hint: string | null = null;
+ if (!classic) {
+ for (let span = 1; span <= Math.min(5, panel.length); span += 1) {
+ const candidate = normalizeText(panel.slice(-span).join(" "));
+ if (HERMES_TUI_APPROVAL_HINT.test(candidate)) {
+ footer = panel.length - span;
+ hint = candidate;
+ break;
+ }
+ }
+ if (hint === null) return null;
+ }
+ const rowShape = /^(❯|▸)?\s*(\d+)\.?\s+(.+)$/;
+ const first = panel.findIndex((line) => rowShape.test(line));
+ let rows: { label: string; description: string | null; selected: boolean }[] = [];
+ let last = first;
+ if (first < 1) return null;
+ for (let index = first; index < footer; index += 1) {
+ const match = panel[index]!.match(rowShape);
+ if (!match) {
+ if (!panel[index]) break;
+ const previous = rows.at(-1);
+ if (!previous || !/^\s*[│║] {4,}\S/.test(raw[start + 1 + index]!)) break;
+ previous.label += ` ${panel[index]}`;
+ last = index;
+ continue;
+ }
+ if (Number(match[2]) !== rows.length + 1) return null;
+ rows.push({ label: match[3]!, description: null, selected: Boolean(match[1]) });
+ last = index;
+ }
+ const allowed = classic
+ ? ["Allow once", "Allow for this session", "Add to permanent allowlist", "Deny", "Show full command"]
+ : ["Allow once", "Allow this session", "Always allow", "Deny"];
+ if (rows[0]?.label !== allowed[0] || !rows.some((row) => row.label === "Deny") || rows.some((row) => !allowed.includes(row.label))) return null;
+ const order = rows.map((row) => allowed.indexOf(row.label));
+ if (order.some((value, index) => index > 0 && value <= order[index - 1]!)) return null;
+ if (!classic && Number(HERMES_TUI_APPROVAL_HINT.exec(hint ?? "")?.[1]) !== rows.length) return null;
+ // Classic can omit the description entirely when vertical space runs out.
+ if (classic && !panel.slice(last + 1, footer).some(Boolean)) return null;
+
+ const bodyRows = [...content.slice(1, first), ...content.slice(last + 1, footer)];
+ while (bodyRows.length > 0 && !bodyRows[0]!.trim()) bodyRows.shift();
+ while (bodyRows.length > 0 && !bodyRows.at(-1)!.trim()) bodyRows.pop();
+ let body = bodyRows.join("\n");
+
+ let stableTarget: string | undefined;
+ if (classic) {
+ const tailLines = raw.slice(end + 1).map((line) => line.replace(ANSI_RE, "").trim().replace(/^[│║]\s?/, "").replace(/\s?[│║]$/, "").trim()).filter(Boolean);
+ if (tailLines.length > 0 && !HERMES_CLASSIC_APPROVAL_HINT.test(tailLines[0]!)) {
+ const match = tailLines[0]!.match(HERMES_TOOL_PROGRESS_RE);
+ if (match) {
+ stableTarget = match[1]!.trim();
+ const targetFile = stableTarget.split("/").pop() ?? stableTarget;
+ const targets: string[] = body.match(/[^\s<>"'`,;:()]+/g) ?? [];
+ if (!targets.includes(targetFile) && !targets.includes(stableTarget)) return null;
+ if (!body.includes(stableTarget)) body += `\n\nTarget: ${stableTarget}`;
+ }
+ }
+ }
+
+ return hermesPrompt(title, body, rows, classic ? "hermes-classic-approval" : "hermes-tui-approval", stableTarget);
+}
+
+/** Finalizes an approval prompt model from parsed Hermes dialog rows and options. */
+function hermesPrompt(
+ title: string,
+ body: string,
+ rows: { label: string; description: string | null; selected: boolean }[],
+ responder: Responder,
+ target?: string,
+): ParsedPrompt | null {
+ if (!body || body.length > 12_000 || rows.length < 2 || rows.length > 5 || rows.filter((row) => row.selected).length !== 1) return null;
+ return finishPrompt("hermes", {
+ kind: "approval", title, question: title, body,
+ options: rows.map(({ label, description }) => ({ label, description })), multi_select: false, custom_option_index: null,
+ }, {
+ responder, menuLabels: rows.map((row) => row.label), selectedIndex: rows.findIndex((row) => row.selected),
+ checkedOptionIndices: [], customMenuIndex: null, rejectWithEscapeIndex: null,
+ }, target ? { call: target } : {});
+}
+
+/** Parse the native clarify question and choices only when its input footer owns the screen tail. */
function parseHermesClarify(screen: string): ParsedPrompt | null {
const lines = screen.replace(ANSI_RE, "").split(/\r?\n/).map((line) => line.trimEnd());
const hintIndex = findLastIndex(lines, (line) => HERMES_HINT_RE.test(line.trim()));
@@ -2304,6 +2685,7 @@ function parseHermesClarify(screen: string): ParsedPrompt | null {
});
}
+/** Try the agent's native readers and admit only prompts whose terminal tail remains active. */
function parsePrompt(agent: string, screen: string, omoAsk: OmoAsk | null = null, omoTrusted = true, omoOpen: OmoAsk[] = [], sent: string | null = null, working = false): ParsedPrompt | null {
const omo = () => {
const forms = (ask: OmoAsk | null, trusted: boolean) => [parseOmoQuestion(screen, ask, trusted), parseOmoTyping(screen, ask, trusted), parseOmoReview(screen, ask, trusted)];
@@ -2323,7 +2705,7 @@ function parsePrompt(agent: string, screen: string, omoAsk: OmoAsk | null = null
: agent === "pi"
? [parsePiModel(screen), parsePiDialog(screen), ...omo()]
: agent === "hermes"
- ? [parseHermesClarify(screen)]
+ ? [parseHermesPrompt(screen), parseHermesClarify(screen), parseHermesPager(screen), working ? null : parseHermesHelp(screen, sent), working ? null : parseHermesInkOutput(screen, sent), working ? null : parseHermesClassicOutput(screen, sent)]
: agent === "omo" || agent === ""
? omo()
: [];
@@ -2769,6 +3151,7 @@ function askingEnded(paneId: string): void {
* question.
*/
export function promptWaitEnded(paneId: string): void {
+ hermesAnswered.delete(paneId);
if (!askings.get(paneId)?.queued) askingEnded(paneId);
}
@@ -2781,6 +3164,10 @@ function contentId(prompt: InteractivePrompt | null): string | undefined {
const formsAnswered = new Map();
const FORM_SETTLE_MS = 3_000;
+/** A committed Hermes panel can linger through a delayed redraw. It is not another asking
+ * until the screen changes or the collector observes the agent back at work. */
+const hermesAnswered = new Map();
+
/** Each pane's rollout, resolved for its collapsed queue: a poll every 2s would otherwise redo it. */
const queueRollouts = new Map();
const QUEUE_ROLLOUT_MS = 15_000;
@@ -3049,6 +3436,7 @@ export async function claudeHeldIsGrey(paneId: string, prompt: InteractivePrompt
.then((read) => same(claudeGreyInput(read.text)), () => false);
}
+/** Read a fresh pane snapshot and prompt asking, withholding fallback for unsafe native screens. */
async function readPrompt(paneId: string, codexHome?: string): Promise<{ agent: string; status: string; prompt: InteractivePrompt | null; pane: HerdrPane; panes: HerdrPane[] }> {
const { panes } = await sessionSnapshot();
// a closed pane's wait has ended too
@@ -3057,10 +3445,12 @@ async function readPrompt(paneId: string, codexHome?: string): Promise<{ agent:
for (const known of answerTurns.keys()) if (!panes.some((candidate) => candidate.pane_id === known)) answerTurns.delete(known);
for (const known of heldCandidates.keys()) if (!panes.some((candidate) => candidate.pane_id === known)) heldCandidates.delete(known);
for (const known of chatSent.keys()) if (!panes.some((candidate) => candidate.pane_id === known)) chatSent.delete(known);
+ for (const known of hermesAnswered.keys()) if (!panes.some((candidate) => candidate.pane_id === known)) hermesAnswered.delete(known);
const pane = panes.find((candidate) => candidate.pane_id === paneId);
if (!pane) throw new HerdrError("pane_not_found", `pane ${paneId} not found`);
const agent = pane.agent ?? "";
const status = pane.agent_status;
+ if (status === "working") hermesAnswered.delete(paneId);
// counted right before the screen is read, after the snapshot: what matters is whether an
// answer ran while the screen was being read
const turns = answerTurns.get(paneId) ?? 0;
@@ -3068,7 +3458,7 @@ async function readPrompt(paneId: string, codexHome?: string): Promise<{ agent:
// an omo form just answered has closed, while herdr still reports the wait for a moment: that
// is no screen to answer, and its fallback card would flash up after the form's last answer
const settling = Date.now() - (formsAnswered.get(paneId) ?? 0) < FORM_SETTLE_MS;
- if (known.prompt !== null || status !== "blocked" || !agent || settling) {
+ if (known.prompt !== null || known.suppressed || status !== "blocked" || !agent || settling) {
fallbackLogged.delete(paneId);
return { agent, status, prompt: asked(paneId, known.prompt, turns), pane, panes };
}
@@ -3110,13 +3500,14 @@ async function omoAsksFor(paneId: string, cwd: string, panes: HerdrPane[]): Prom
catch { return []; }
}
+/** Resolve supported native prompts and withhold answered or unreadable Hermes panels from fallback. */
async function readKnownPrompt(
paneId: string,
pane: { cwd?: string | null; agent_status?: string },
agent: string,
codexHome?: string,
panes: HerdrPane[] = [],
-): Promise<{ prompt: InteractivePrompt | null; screen?: string }> {
+): Promise<{ prompt: InteractivePrompt | null; screen?: string; suppressed?: boolean }> {
if (!["claude", "omp", "codex", "omo", "pi", "gjc", "hermes", ""].includes(agent)) return { prompt: null };
const screen = await liveScreen(paneId);
// omo's form reads its text from the session's call, the screen showing where the form stands
@@ -3125,7 +3516,15 @@ async function readKnownPrompt(
// a pane herdr names claude, or not at all, is omo's only on evidence: herdr reports it waiting
// on the user, or the session's pending call is the form on screen
const omoTrusted = (agent !== "claude" && agent !== "") || pane.agent_status === "blocked";
- const prompt = parseInteractivePrompt(agent, screen, omoAsks[0] ?? null, omoTrusted, omoAsks, agent === "claude" ? heldCandidate(paneId) : null, pane.agent_status === "working");
+ const prompt = parseInteractivePrompt(agent, screen, omoAsks[0] ?? null, omoTrusted, omoAsks, agent === "claude" ? heldCandidate(paneId) : agent === "hermes" ? lastChatSubmitted(paneId) : null, pane.agent_status === "working");
+ if (agent === "hermes") {
+ if (pane.agent_status === "working") hermesAnswered.delete(paneId);
+ else if (hermesAnswered.has(paneId)) {
+ if (contentId(prompt) === hermesAnswered.get(paneId) || (prompt === null && hermesNeedsTerminal(screen))) return { prompt: null, screen, suppressed: true };
+ hermesAnswered.delete(paneId);
+ }
+ if (prompt === null && hermesNeedsTerminal(screen)) return { prompt: null, screen, suppressed: true };
+ }
if (prompt && await claudeHeldIsGrey(paneId, prompt)) return { prompt: null, screen };
if (!prompt && agent === "gjc") {
const fallback = parseFallbackPrompt(agent, screen);
@@ -3254,6 +3653,7 @@ export interface PromptRequestOptions {
codexHome?: string;
}
+/** Serve prompt reads and serialized answers, rechecking the asking and native cursor before commit. */
export async function handlePromptRequest(request: Request, url: URL, options: PromptRequestOptions = {}): Promise {
if (url.pathname !== "/api/pane/prompt" && url.pathname !== "/api/pane/prompt/answer") return null;
// the answer being handled, once its body was read: what the log line says even when the handling throws
@@ -3311,6 +3711,12 @@ export async function handlePromptRequest(request: Request, url: URL, options: P
if (error instanceof InvalidAnswer) return badRequest("invalid_answer", error.message);
throw error;
}
+ if (parsedByPublicPrompt.get(prompt)?.responder === "hermes-output") {
+ // Dismissing a read-only result must not send Escape into the native input box.
+ chatSent.delete(body.pane_id);
+ askingEnded(body.pane_id);
+ return jsonResponse({ ok: true });
+ }
// The asking this answer is for, held to its last key: once anything says it ended (the
// agent back at work), no key of this answer goes out any more, whatever the screen shows.
const asking = askings.get(body.pane_id)!;
@@ -3373,7 +3779,7 @@ export async function handlePromptRequest(request: Request, url: URL, options: P
// Claude's unnumbered rows and pi's models are read with no number to aim at: looked at
// again before the Enter even when it needs no move. So is Claude's model list, picked
// with a letter: typed after the list has gone, it would stand in the agent's own prompt
- let moved = responder === "claude-confirm" || responder === "pi-model" || responder === "omo-question" || responder === "claude-model" || responder === "claude-effort" || responder === "codex-model" || responder === "fallback-gjc-menu";
+ let moved = responder.startsWith("hermes-") || responder === "claude-confirm" || responder === "pi-model" || responder === "omo-question" || responder === "claude-model" || responder === "claude-effort" || responder === "codex-model" || responder === "fallback-gjc-menu";
// Claude's and Codex's model lists: a window on a list, picked with a letter; Claude's effort
// slider the same way, its levels side by side
const list = responder === "claude-model" || responder === "claude-effort" || responder === "codex-model";
@@ -3435,7 +3841,7 @@ export async function handlePromptRequest(request: Request, url: URL, options: P
// keys to the agent's own prompt, where an arrow walks the prompt's history: each move
// after the first goes only once the list shows the one before it
// GJC's selector the same: dismissed under the answer, its composer would take the moves
- if (move && walked && (list || responder === "fallback-gjc-menu") && !(await onRow())) return promptChanged();
+ if (move && walked && (list || responder.startsWith("hermes-") || responder === "fallback-gjc-menu") && !(await onRow())) return promptChanged();
if (!move && !committed) {
// An answer is only as good as the menu and the cursor it moves from, and both are
// as old as the read above by the time its moves are done: the menu answered in the
@@ -3483,8 +3889,12 @@ export async function handlePromptRequest(request: Request, url: URL, options: P
if (queueOpened && !answered) await closeOpenQuestion(body.pane_id).catch(() => undefined);
// answered, or as good as: the same prompt on the screen after this is asked anew
if (committed) askingEnded(body.pane_id);
+ const responder = parsedByPublicPrompt.get(target)?.responder;
+ if (committed && (responder === "hermes-question" || responder === "hermes-input" || responder === "hermes-classic-approval" || responder === "hermes-tui-approval")) {
+ hermesAnswered.set(body.pane_id, contentId(target)!);
+ }
// sent or dropped from its card: the same words in the box after this are a new message
- if (committed && parsedByPublicPrompt.get(target)?.responder === "claude-held") heldCandidates.delete(body.pane_id);
+ if (committed && responder === "claude-held") heldCandidates.delete(body.pane_id);
}
// answered from the chat, the queue closes again: the next question waits collapsed, and
// the main prompt (where a message typed in the chat goes) has the input back
diff --git a/src/components/ChatView.tsx b/src/components/ChatView.tsx
index e644af5c9..8067ea83d 100644
--- a/src/components/ChatView.tsx
+++ b/src/components/ChatView.tsx
@@ -721,7 +721,7 @@ export const ChatView = memo(function ChatView({ paneId, cwd = null, onOpenDiff,
const { settings } = useSettings();
const machineId = useMachineId();
const diffContext = useMemo(() => onOpenDiff ? { openDiff: onOpenDiff, cwd } : null, [onOpenDiff, cwd]);
- const skillMatcher = useSkillMatcher(paneId);
+ const skillMatcher = useSkillMatcher(paneId, agent, cwd, connected);
// polls pause while the page is hidden and pick up at once when it is back
const visible = usePageVisible();
/** the answer last laid out: an unchanged poll (a 304) hands back this very object */
diff --git a/src/components/Composer.tsx b/src/components/Composer.tsx
index 9cdf35db3..027117f53 100644
--- a/src/components/Composer.tsx
+++ b/src/components/Composer.tsx
@@ -53,7 +53,7 @@ import { composerUsage, formatPercent, formatResetIn, HIGH_PERCENT, meterPercent
import { AgentMark } from "./AgentMark.tsx";
import { BackgroundTasks } from "./BackgroundTasks.tsx";
import { MicButton, VoiceRecordingPill, useDictation } from "./VoiceInput.tsx";
-import { cachedPaneCommands } from "../lib/paneCommandCache.ts";
+import { cachedPaneCommands, cancelPaneCommandRequests, invalidatePaneCommands } from "../lib/paneCommandCache.ts";
import { useT } from "../lib/i18n.ts";
import { modalOpen } from "../lib/focus.ts";
@@ -72,6 +72,8 @@ export interface ComposerProps {
/** the pane's turn ended on work still running in the background: its word is BG, not DONE (lib/status.ts paneStatus) */
backgroundWait?: boolean;
metadata?: ConversationMetadata | null;
+ /** A successful native reload's prompt ID; null means no visible completion, not invalidation. */
+ skillsReloaded?: string | null;
/** replaces the placeholder: how a message answers the agent's waiting prompt */
answerHint?: string | null;
/** what the agent suggests typing next (Claude's grey input text): the placeholder, taken with Tab */
@@ -120,6 +122,7 @@ export const SOURCE_LABEL: Record = {
plugin: "Plugins",
};
+
let attachmentSequence = 0;
type Attachment = {
@@ -169,6 +172,7 @@ function composerHeightLimit(): number {
return Math.min(COMPOSER_HEIGHT_MAX, Math.floor(viewport / 2));
}
+
/**
* What is left of the context, as a ring filled by what is used (as Codex's app shows it):
* red when little is left. The number is on hover, and on a tap beside the ring (a touch
@@ -240,6 +244,7 @@ export function Composer({
plan = null,
backgroundWait = false,
metadata,
+ skillsReloaded = null,
answerHint = null,
suggestion = null,
restored = null,
@@ -292,6 +297,14 @@ export function Composer({
/** the restored text the box was filled with: only that fill is ever cleared, never the user's own typing */
const filledFrom = useRef(null);
const [commands, setCommands] = useState([]);
+ // Each new completion refreshes, even with identical text. Dismissal leaves its read alive.
+ const [commandReload, setCommandReload] = useState(() => ({ paneId, machineId, agent, cwd, id: skillsReloaded, shown: skillsReloaded, sequence: 0 }));
+ if (commandReload.paneId !== paneId || commandReload.machineId !== machineId || commandReload.agent !== agent || commandReload.cwd !== cwd) {
+ setCommandReload({ paneId, machineId, agent, cwd, id: skillsReloaded, shown: skillsReloaded, sequence: 0 });
+ } else if (commandReload.shown !== skillsReloaded) {
+ setCommandReload({ ...commandReload, shown: skillsReloaded,
+ ...(skillsReloaded !== null ? { id: skillsReloaded, sequence: commandReload.sequence + 1 } : {}) });
+ }
const [files, setFiles] = useState([]);
const [slashUsage, setSlashUsage] = useState>(readSlashUsage);
// the highlight lives in the refs below: a move redraws the menu, also when it lands on an index it held before
@@ -353,9 +366,15 @@ export function Composer({
filledFrom.current = null;
}, [restored]);
- useEffect(() => {
+ useLayoutEffect(() => {
+ // Another agent or directory must not retain the previous inventory while its read waits.
+ setCommands([]);
+ if (!connected) {
+ invalidatePaneCommands(paneId, machineId);
+ return;
+ }
let live = true;
- void cachedPaneCommands(paneId, machineId, fetchPaneCommands)
+ void cachedPaneCommands(paneId, machineId, fetchPaneCommands, agent, cwd, commandReload.id !== null)
.then((next) => {
if (live) setCommands(next);
})
@@ -364,8 +383,10 @@ export function Composer({
});
return () => {
live = false;
+ // A read from before a disconnect or scope change cannot refill the shared cache.
+ cancelPaneCommandRequests(paneId, machineId);
};
- }, [paneId]);
+ }, [paneId, machineId, agent, cwd, commandReload.sequence, connected, fetchPaneCommands]);
useEffect(() => {
const request = ++fileRequest.current;
diff --git a/src/components/PaneTerminal.tsx b/src/components/PaneTerminal.tsx
index 2c4f74f62..00063881f 100644
--- a/src/components/PaneTerminal.tsx
+++ b/src/components/PaneTerminal.tsx
@@ -144,6 +144,11 @@ function storedDirectTyping(): boolean {
/** The last refused Send now note per pane, kept across that pane's mounts (see queueError). */
const heldRowNotes = new Map();
+/**
+ * Own one xterm renderer and the selected PC/pane's attach or read-only watch while drawing its terminal and chat lenses.
+ * Reset the renderer only on pane changes, acknowledge parsed output, and keep disconnected input in a user-reviewed draft.
+ * The active pane alone may take focus; observe and mirrored grids adopt server geometry instead of resizing the shared terminal.
+ */
export function PaneTerminal({
paneId,
restoreError = null,
@@ -1056,6 +1061,10 @@ export function PaneTerminal({
setChatInvalidation((current) => current + 1);
}
if (paneRef.current) setInputReady(socket.canInput(paneRef.current));
+ if (message.type === "input-ready" && message.ready !== false) {
+ // Fresh attaches do not send attach-resumed; output may arrive later.
+ term.options.disableStdin = observeRef.current || secretRef.current !== null || heldRef.current;
+ }
if (message.type === "pty-data") {
if (message.pane_id !== paneRef.current || releasedRef.current) return;
// A chat showing herdr's scrollback has no native file to watch: output invalidates it.
@@ -2216,6 +2225,10 @@ export function PaneTerminal({
// a question in Codex's queue leaves the composer alone: Codex keeps working, and a
// message ("stop, don't touch prod") must reach it, not become the answer; its card answers it
// a fallback card is answered with its own buttons: what the user types still goes to the agent
+ // The backend reserves this title for successful native reloads, including results without a count.
+ const skillsReloaded = agent === "hermes" && chatPrompt?.pane === paneId && chatPrompt.value.agent === "hermes"
+ && chatPrompt.value.title === "Reload Skills"
+ ? chatPrompt.value.id : null;
const answering = chatView && chatPrompt !== null && chatPrompt.pane === paneId && !chatPrompt.value.queued && !chatPrompt.value.fallback ? chatPrompt.value : null;
// ...and while it is open in the terminal it holds the input: nothing is sent into it
const heldByOpenQueue = chatView && chatPrompt !== null && chatPrompt.pane === paneId && chatPrompt.value.queued === "open";
@@ -2757,6 +2770,7 @@ export function PaneTerminal({
plan={plan}
backgroundWait={backgroundWait}
metadata={chatMetadata?.pane === paneId ? chatMetadata.value : null}
+ skillsReloaded={skillsReloaded}
connected={connected && !held}
answerHint={answering === null ? null
: pendingAnswer?.promptId === answering.id ? t("Confirm your answer in the card above, or type another…") : answerHint(answering)}
diff --git a/src/lib/markdown.ts b/src/lib/markdown.ts
index ff1ede363..bfe434e6d 100644
--- a/src/lib/markdown.ts
+++ b/src/lib/markdown.ts
@@ -106,6 +106,9 @@ const MAX_INLINE_DEPTH = 16;
* once, for every opener before it.
*/
export function* inlineMarks(source: string): Generator {
+ const opener = /[`[<*_~]|\\\(|file:\/\/\/|https?:\/\/|www\./g;
+ let start = opener.exec(source)?.index;
+ if (start === undefined) return;
// Underscores inside identifiers are literal: MAC_QA_CHAT_OK must survive
// rendering exactly as it appears in the terminal and native transcript.
// a bare or http(s) URL is a link too; it stops at the first non-ASCII character,
@@ -113,8 +116,6 @@ export function* inlineMarks(source: string): Generator {
const marker = /(`[^`\n]+`|\\\(.+?\\\)|\[[^\]\n]+\]\([^\s)]+\)|]+>|file:\/\/\/[!#-;=?-_a-~]+|https?:\/\/[!-;=?-~]+|(? number) => {
let found = -1;
@@ -136,8 +137,10 @@ export function* inlineMarks(source: string): Generator {
const strongClose = next(/(?<=\S)__(?![\p{L}\p{N}\p{M}_])/gu);
let from = 0;
for (;;) {
- opener.lastIndex = from;
- const start = opener.exec(source)?.index;
+ if (from > 0) {
+ opener.lastIndex = from;
+ start = opener.exec(source)?.index;
+ }
if (start === undefined) return;
from = start + 1;
let pattern: RegExp | null = marker;
diff --git a/src/lib/paneCommandCache.test.ts b/src/lib/paneCommandCache.test.ts
new file mode 100644
index 000000000..4572b8d3d
--- /dev/null
+++ b/src/lib/paneCommandCache.test.ts
@@ -0,0 +1,79 @@
+import { expect, test } from "bun:test";
+import type { SlashCommand } from "../../shared/protocol.ts";
+import { cachedPaneCommands, cancelPaneCommandRequests, invalidatePaneCommands, paneCommandRevision, subscribePaneCommands } from "./paneCommandCache.ts";
+
+/** Build a one-skill inventory whose name distinguishes fresh results from stale reads. */
+const commands = (name: string): SlashCommand[] => [{ name, description: "Fictional skill", source: "skill" }];
+/** Fail any fetch attempted when the test expects the current inventory to come from cache. */
+const unexpectedRead = async (): Promise => { throw new Error("the current inventory should be cached"); };
+
+test("a late pre-reload read cannot replace the shared refreshed inventory", async () => {
+ const old = Promise.withResolvers();
+ const pending = cachedPaneCommands("reload", "local", () => old.promise, "hermes", "/project");
+ const fresh = commands("new-skill");
+ await cachedPaneCommands("reload", "local", async () => fresh, "hermes", "/project", true);
+ old.resolve(commands("removed-skill"));
+ await pending;
+ expect(await cachedPaneCommands("reload", "local", unexpectedRead, "hermes", "/project")).toEqual(fresh);
+});
+
+test("an abandoned read cannot supply commands after reconnect", async () => {
+ const old = Promise.withResolvers();
+ const pending = cachedPaneCommands("reconnect", "local", () => old.promise, "hermes", "/project");
+ cancelPaneCommandRequests("reconnect", "local");
+ old.resolve(commands("disconnected-skill"));
+ await pending;
+ const fresh = commands("reconnected-skill");
+ expect(await cachedPaneCommands("reconnect", "local", async () => fresh, "hermes", "/project")).toEqual(fresh);
+});
+
+test("agent and cwd transitions do not reuse another native scope's commands", async () => {
+ const first = commands("hermes-first");
+ await cachedPaneCommands("scope", "local", async () => first, "hermes", "/first");
+ const otherAgent = commands("claude-first");
+ expect(await cachedPaneCommands("scope", "local", async () => otherAgent, "claude", "/first")).toEqual(otherAgent);
+ const otherCwd = commands("claude-second");
+ expect(await cachedPaneCommands("scope", "local", async () => otherCwd, "claude", "/second")).toEqual(otherCwd);
+});
+
+test("a late abandoned scope cannot replace commands when that scope is revisited", async () => {
+ const old = Promise.withResolvers();
+ const pending = cachedPaneCommands("revisit", "local", () => old.promise, "hermes", "/first");
+ const second = commands("second-project");
+ await cachedPaneCommands("revisit", "local", async () => second, "hermes", "/second");
+ old.resolve(commands("stale-first-project"));
+ await pending;
+ expect(await cachedPaneCommands("revisit", "local", unexpectedRead, "hermes", "/second")).toEqual(second);
+ const fresh = commands("current-first-project");
+ expect(await cachedPaneCommands("revisit", "local", async () => fresh, "hermes", "/first")).toEqual(fresh);
+});
+
+test("equal pane IDs on different PCs and different panes on one PC retain separate inventories", async () => {
+ const local = commands("local-skill");
+ const remote = commands("remote-skill");
+ const sibling = commands("sibling-skill");
+ await cachedPaneCommands("shared-id", "local", async () => local, "hermes", "/project");
+ await cachedPaneCommands("shared-id", "remote", async () => remote, "hermes", "/project");
+ await cachedPaneCommands("sibling", "local", async () => sibling, "hermes", "/project");
+ invalidatePaneCommands("shared-id", "local");
+ expect(await cachedPaneCommands("shared-id", "remote", unexpectedRead, "hermes", "/project")).toEqual(remote);
+ expect(await cachedPaneCommands("sibling", "local", unexpectedRead, "hermes", "/project")).toEqual(sibling);
+});
+
+test("native reload invalidation lets a skill matcher read the new inventory without affecting another pane", async () => {
+ await cachedPaneCommands("matcher", "local", async () => commands("removed-skill"), "hermes", "/project");
+ const fresh = commands("added-skill");
+ let refreshed: Promise | undefined;
+ const unsubscribe = subscribePaneCommands("matcher", "local", () => {
+ refreshed = cachedPaneCommands("matcher", "local", async () => fresh, "hermes", "/project");
+ });
+ try {
+ const before = paneCommandRevision("matcher", "local");
+ invalidatePaneCommands("other-matcher", "local");
+ expect(paneCommandRevision("matcher", "local")).toBe(before);
+ expect(refreshed).toBeUndefined();
+ invalidatePaneCommands("matcher", "local");
+ expect(await refreshed).toEqual(fresh);
+ expect(await cachedPaneCommands("matcher", "local", unexpectedRead, "hermes", "/project")).toEqual(fresh);
+ } finally { unsubscribe(); }
+});
diff --git a/src/lib/paneCommandCache.ts b/src/lib/paneCommandCache.ts
index c12565258..257f28394 100644
--- a/src/lib/paneCommandCache.ts
+++ b/src/lib/paneCommandCache.ts
@@ -2,14 +2,63 @@ import { paneStorageId } from "../../shared/machines.ts";
import type { SlashCommand } from "../../shared/protocol.ts";
const COMMAND_CACHE_MS = 60_000;
-const cache = new Map();
+const cache = new Map();
+const requests = new Map();
+const revisions = new Map();
+const listeners = new Map void>>();
-/** A pane's slash commands, asked for at most once a minute per PC and pane: the composer's menu and the chat's skill chips read the same answer. */
-export async function cachedPaneCommands(paneId: string, machineId: string, fetchCommands: (pane: string) => Promise): Promise {
+/** Read the PC-scoped pane's invalidation counter; a pane never invalidated starts at zero. */
+export function paneCommandRevision(paneId: string, machineId: string): number {
+ return revisions.get(paneStorageId(machineId, paneId)) ?? 0;
+}
+
+/** Listen for synchronous inventory invalidations on one PC/pane pair; return a subscription cleanup. */
+export function subscribePaneCommands(paneId: string, machineId: string, listener: () => void): () => void {
+ const key = paneStorageId(machineId, paneId);
+ let subscribers = listeners.get(key);
+ if (!subscribers) listeners.set(key, subscribers = new Set());
+ subscribers.add(listener);
+ return () => {
+ subscribers.delete(listener);
+ if (subscribers.size === 0) listeners.delete(key);
+ };
+}
+
+/** A read abandoned on disconnect or scope change must not refill the cache. */
+export function cancelPaneCommandRequests(paneId: string, machineId: string): void {
+ requests.delete(paneStorageId(machineId, paneId));
+}
+
+/** A successful native reload invalidates both suggestions and transcript skill matching. */
+export function invalidatePaneCommands(paneId: string, machineId: string): void {
+ const key = paneStorageId(machineId, paneId);
+ cache.delete(key);
+ requests.delete(key);
+ revisions.set(key, (revisions.get(key) ?? 0) + 1);
+ const subscribers = listeners.get(key);
+ if (subscribers) for (const listener of subscribers) listener();
+}
+
+/** The menu and skill chips share an inventory scoped to the selected PC, pane, agent and cwd. */
+export async function cachedPaneCommands(
+ paneId: string,
+ machineId: string,
+ fetchCommands: (pane: string) => Promise,
+ agent: string | null,
+ cwd: string | null,
+ refresh = false,
+): Promise {
+ if (refresh) invalidatePaneCommands(paneId, machineId);
const key = paneStorageId(machineId, paneId);
const cached = cache.get(key);
- if (cached && Date.now() - cached.loadedAt < COMMAND_CACHE_MS) return cached.commands;
- const commands = await fetchCommands(paneId);
- cache.set(key, { loadedAt: Date.now(), commands });
- return commands;
+ if (cached && Date.now() - cached.loadedAt < COMMAND_CACHE_MS && cached.agent === agent && cached.cwd === cwd) return cached.commands;
+ const request = {};
+ requests.set(key, request);
+ try {
+ const commands = await fetchCommands(paneId);
+ if (requests.get(key) === request) cache.set(key, { loadedAt: Date.now(), commands, agent, cwd });
+ return commands;
+ } finally {
+ if (requests.get(key) === request) requests.delete(key);
+ }
}
diff --git a/src/lib/useSkillMatcher.ts b/src/lib/useSkillMatcher.ts
index 45c8e310c..8eab37437 100644
--- a/src/lib/useSkillMatcher.ts
+++ b/src/lib/useSkillMatcher.ts
@@ -1,22 +1,26 @@
-import { useEffect, useState } from "react";
+import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
import { useMachineApi, useMachineId } from "./machineContext.tsx";
-import { cachedPaneCommands } from "./paneCommandCache.ts";
+import { cachedPaneCommands, paneCommandRevision, subscribePaneCommands } from "./paneCommandCache.ts";
import { createSkillMatcher, type SkillMatcher } from "./skillMentions.ts";
/** The pane's skill names as a matcher, from its command list; null until it loads, or when it fails. */
-export function useSkillMatcher(paneId: string): SkillMatcher | null {
+export function useSkillMatcher(paneId: string, agent: string | null, cwd: string | null, connected: boolean): SkillMatcher | null {
const { fetchPaneCommands } = useMachineApi();
const machineId = useMachineId();
- const [loaded, setLoaded] = useState<{ key: string; matcher: SkillMatcher | null } | null>(null);
- const key = `${machineId}:${paneId}`;
+ const subscribe = useCallback((listener: () => void) => subscribePaneCommands(paneId, machineId, listener), [paneId, machineId]);
+ const snapshot = useCallback(() => paneCommandRevision(paneId, machineId), [paneId, machineId]);
+ const revision = useSyncExternalStore(subscribe, snapshot, snapshot);
+ const [loaded, setLoaded] = useState<{ paneId: string; machineId: string; agent: string | null; cwd: string | null; revision: number; matcher: SkillMatcher | null } | null>(null);
useEffect(() => {
+ if (!connected) return;
let live = true;
- cachedPaneCommands(paneId, machineId, fetchPaneCommands).then(
- (commands) => { if (live) setLoaded({ key, matcher: createSkillMatcher(commands) }); },
- () => { if (live) setLoaded({ key, matcher: null }); },
+ const scope = { paneId, machineId, agent, cwd, revision };
+ cachedPaneCommands(paneId, machineId, fetchPaneCommands, agent, cwd).then(
+ (commands) => { if (live) setLoaded({ ...scope, matcher: createSkillMatcher(commands) }); },
+ () => { if (live) setLoaded({ ...scope, matcher: null }); },
);
return () => { live = false; };
- }, [fetchPaneCommands, paneId, machineId, key]);
- return loaded?.key === key ? loaded.matcher : null;
+ }, [fetchPaneCommands, paneId, machineId, agent, cwd, revision, connected]);
+ return connected && loaded?.paneId === paneId && loaded.machineId === machineId && loaded.agent === agent && loaded.cwd === cwd && loaded.revision === revision ? loaded.matcher : null;
}