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; }