/** * Hermes chat process detection. * * The viewer daemon (`bridge.cts --daemon`) wants to know whether the * user has a `hermes chat` session attached to *some* bridge stdio * process so it can upgrade its own `"disconnected"` status to * `"reconnecting"` instead of leaving the viewer permanently red. * * Implementation: shell out to `pgrep -f `. The regex has to * cover all three CLI invocation shapes the Hermes CLI supports: * * • no flags — `hermes chat` * • flags after the subcommand — `hermes chat --skills memory-routing` * • global flags before it — `hermes --skills memory-routing chat` * * The third shape is the bug reported in #1915: the previous literal * pattern `"hermes chat"` requires the two tokens to be contiguous and * therefore misses any invocation with a global flag (`--skills`, * `-m`, `--provider`, …) between them. * * The command grammar is `hermes ()* chat (|$)`: * * • `hermes` — the binary basename. * • `()*` — complete argv-style tokens before the subcommand. * • `chat` — a complete token, not `chatter` or `chat-server`. * * `pgrep -f` on Linux uses glibc's POSIX ERE engine, so its pattern uses * POSIX character classes and capturing groups only. JavaScript does not * implement POSIX character classes, so the test helper declares the same * grammar with `\s`, `\S`, and non-capturing groups instead. */ // eslint-disable-next-line @typescript-eslint/no-require-imports import * as childProcess from "node:child_process"; /** * pgrep `-f` pattern used by `isHermesChatRunning`. * * Exported separately so callers — and tests — can introspect the exact * string we hand to `pgrep` and confirm we have not silently regressed * back to a literal substring match. */ export const HERMES_CHAT_PROCESS_PATTERN = "hermes([[:space:]]+[^[:space:]]+)*[[:space:]]+chat([[:space:]]|$)"; // Keep this semantically aligned with HERMES_CHAT_PROCESS_PATTERN. POSIX ERE // has no non-capturing groups, while JavaScript can avoid unused captures. const HERMES_CHAT_JS_PATTERN = /hermes(?:\s+\S+)*\s+chat(?:\s|$)/; /** * JS-side equivalent of `pgrep -f HERMES_CHAT_PROCESS_PATTERN`. * * Used by unit tests to verify the regex matches every documented * Hermes invocation shape and rejects unrelated command lines. Kept * deliberately stateless — callers should pass the full * `/proc//cmdline`-style command-line string. */ export function matchesHermesChatCommandLine(commandLine: string): boolean { return HERMES_CHAT_JS_PATTERN.test(commandLine); } /** * Shape of the `execFileSync`-compatible helper that * `isHermesChatRunning` shells out through. Carved out as a named type * so tests can pass a `vi.fn()` without depending on Node's overloaded * `ExecFileSyncOptions` union. */ export type ExecFileSyncLike = ( file: string, args: readonly string[], options: { encoding: "utf8"; timeout: number }, ) => string; const defaultExecFileSync: ExecFileSyncLike = (file, args, options) => childProcess.execFileSync(file, [...args], options) as unknown as string; /** * Returns `true` when `pgrep -f` finds at least one process whose full * command line matches `HERMES_CHAT_PROCESS_PATTERN`. * * `pgrep` exits non-zero when there is no match, when it cannot be * found, or on permission errors — all of which we collapse into * `false` because the caller only uses the boolean to decide whether * to upgrade `"disconnected"` to `"reconnecting"`. Surfacing the * difference would just turn a UI hint into a noisy crash path. * * `execFile` is overridable so the unit test can inject a stub instead * of mocking `node:child_process` globally — a Node ESM namespace is * frozen at import time, so spy-based mocking is brittle. Injection is * the same dependency pattern the rest of the bridge uses (see * `startStdioServer`'s `stdin` / `stdout` options). */ export function isHermesChatRunning( execFile: ExecFileSyncLike = defaultExecFileSync, ): boolean { try { const out = execFile("pgrep", ["-f", HERMES_CHAT_PROCESS_PATTERN], { encoding: "utf8", timeout: 1000, }); return out.trim().length > 0; } catch { return false; } }