diff --git a/apps/memos-local-plugin/bridge/hermes-process.ts b/apps/memos-local-plugin/bridge/hermes-process.ts index 6c2384f96..b796e2d90 100644 --- a/apps/memos-local-plugin/bridge/hermes-process.ts +++ b/apps/memos-local-plugin/bridge/hermes-process.ts @@ -18,22 +18,16 @@ * therefore misses any invocation with a global flag (`--skills`, * `-m`, `--provider`, …) between them. * - * The current pattern is `hermes(?:\s+\S+)*\s+chat\b`: + * The command grammar is `hermes ()* chat (|$)`: * - * • `hermes` — the binary basename. - * • `(?:\s+\S+)*` — any complete argv-style tokens between the - * binary and the subcommand. - * • `\s+chat\b` — a standalone `chat` token, so it does *not* - * match `chatter`, `chat-server`, `--chat-log`, or a flag value - * like `--profile=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 ERE engine, which supports - * `\s`/`\b` as GNU extensions. JavaScript's `RegExp` supports the same - * tokens natively, so this module also exports - * `matchesHermesChatCommandLine()` for unit tests — exercising the - * pattern as a JS regex is a faithful proxy for the pgrep-side - * behaviour without requiring a real Hermes binary or a fork of the - * pgrep process in CI. + * `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"; @@ -45,7 +39,12 @@ import * as childProcess from "node:child_process"; * 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(?:\\s+\\S+)*\\s+chat\\b"; +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`. @@ -56,7 +55,7 @@ export const HERMES_CHAT_PROCESS_PATTERN = "hermes(?:\\s+\\S+)*\\s+chat\\b"; * `/proc//cmdline`-style command-line string. */ export function matchesHermesChatCommandLine(commandLine: string): boolean { - return new RegExp(HERMES_CHAT_PROCESS_PATTERN).test(commandLine); + return HERMES_CHAT_JS_PATTERN.test(commandLine); } /** diff --git a/apps/memos-local-plugin/tests/unit/bridge/hermes-process.test.ts b/apps/memos-local-plugin/tests/unit/bridge/hermes-process.test.ts index 2f900c7b0..5b5ed7c33 100644 --- a/apps/memos-local-plugin/tests/unit/bridge/hermes-process.test.ts +++ b/apps/memos-local-plugin/tests/unit/bridge/hermes-process.test.ts @@ -7,9 +7,11 @@ * subcommand (`hermes --skills memory-routing chat`) was silently * missed and the viewer was stuck on `"disconnected"`. * - * The pattern under test is `hermes(?:\s+\S+)*\s+chat\b` — these cases - * lock in the exact shape of the fix. + * The pgrep pattern uses POSIX character classes while the JS helper + * uses equivalent `\s` / `\S` tokens. These cases lock in both the + * shared command grammar and the exact wire format passed to pgrep. */ +import { spawnSync } from "node:child_process"; import { describe, expect, it, vi } from "vitest"; import { @@ -23,8 +25,23 @@ describe("HERMES_CHAT_PROCESS_PATTERN", () => { // If this string ever changes, audit `bridge.cts` callers and the // issue description before adjusting — the constant is the only // surface that fixes the substring-detection bug. - expect(HERMES_CHAT_PROCESS_PATTERN).toBe("hermes(?:\\s+\\S+)*\\s+chat\\b"); + expect(HERMES_CHAT_PROCESS_PATTERN).toBe( + "hermes([[:space:]]+[^[:space:]]+)*[[:space:]]+chat([[:space:]]|$)", + ); }); + + it.skipIf(process.platform !== "linux")( + "compiles under the glibc ERE engine used by pgrep", + () => { + const result = spawnSync("pgrep", ["-f", HERMES_CHAT_PROCESS_PATTERN], { + encoding: "utf8", + timeout: 2000, + }); + + expect(result.error).toBeUndefined(); + expect([0, 1]).toContain(result.status); + }, + ); }); describe("matchesHermesChatCommandLine", () => { @@ -78,6 +95,12 @@ describe("matchesHermesChatCommandLine", () => { ).toBe(false); }); + it("does not match `hermes chat-server` (chat must be a complete token)", () => { + expect( + matchesHermesChatCommandLine("/usr/local/bin/hermes chat-server"), + ).toBe(false); + }); + it("does not match `hermes --chat-log=... status` (chat must be the subcommand token)", () => { expect( matchesHermesChatCommandLine(