Skip to content
Open
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
31 changes: 15 additions & 16 deletions apps/memos-local-plugin/bridge/hermes-process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (<token>)* chat (<space>|$)`:
*
* • `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.
* • `(<token>)*` — 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";
Expand All @@ -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`.
Expand All @@ -56,7 +55,7 @@ export const HERMES_CHAT_PROCESS_PATTERN = "hermes(?:\\s+\\S+)*\\s+chat\\b";
* `/proc/<pid>/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);
}

/**
Expand Down
29 changes: 26 additions & 3 deletions apps/memos-local-plugin/tests/unit/bridge/hermes-process.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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", () => {
Expand Down Expand Up @@ -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(
Expand Down
Loading