Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions changelog.d/hermes-chat-command-discovery.md
Original file line number Diff line number Diff line change
@@ -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.
37 changes: 36 additions & 1 deletion docs/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
23 changes: 18 additions & 5 deletions scripts/chat-size-regression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Frame[]> => {
if (routed) return [...wire];
assert.ok(page);
Expand Down Expand Up @@ -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);
Expand All @@ -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"));
Expand All @@ -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;
}
Expand Down
9 changes: 9 additions & 0 deletions server/codex.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.";
Expand Down
46 changes: 30 additions & 16 deletions server/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
}

/**
Expand Down Expand Up @@ -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<string, { answers: string[]; shown: string[] | null }>();
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 };
Expand All @@ -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 !== "");
Expand Down
16 changes: 15 additions & 1 deletion server/commands.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -14,6 +15,7 @@ const BUILTINS: Record<string, readonly string[]> = {
// 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. */
Expand Down Expand Up @@ -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"));
Expand Down
2 changes: 0 additions & 2 deletions server/conventions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,6 @@ describe("herdr socket", () => {
const HERDR_RPC_ALLOWED: Record<string, string> = {
"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",
};
Expand All @@ -200,7 +199,6 @@ describe("herdr socket", () => {
// paneProcessInfo(paneId) (the better outcome), merges, or closes.
const HERDR_RPC_PENDING: Record<string, string> = {
"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/", () => {
Expand Down
Loading