689 lines
23 KiB
TypeScript
689 lines
23 KiB
TypeScript
/**
|
|
* Bridge entry point (CommonJS).
|
|
*
|
|
* Started by non-TypeScript hosts (e.g. the Hermes Python client) via:
|
|
*
|
|
* node_modules/.bin/tsx bridge.cts --agent=hermes --no-viewer
|
|
*
|
|
* The `.cts` extension is intentional: it lets the file be required
|
|
* from CommonJS environments that spawn Node with `require("...")`
|
|
* semantics. Internally we re-export the ESM implementation via
|
|
* `import()`.
|
|
*
|
|
* Viewer lifecycle
|
|
* ================
|
|
* Each agent owns its own HTTP port:
|
|
*
|
|
* - openclaw → :18799
|
|
* - hermes → :18800
|
|
*
|
|
* The viewer port is read from the agent's `~/.<agent>/memos-plugin/
|
|
* config.yaml::viewer.port`. We just call `startHttpServer` once;
|
|
* if the port is already in use we surface the EADDRINUSE error to
|
|
* stderr and keep running stdio-RPC headless (capture / retrieval
|
|
* still work). There's no port-sharing or auto-promotion logic —
|
|
* each agent has its own bookmarkable URL.
|
|
*/
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
const path = require("node:path") as typeof import("node:path");
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
const fs = require("node:fs") as typeof import("node:fs");
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
const childProcess = require("node:child_process") as typeof import("node:child_process");
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
const url = require("node:url") as typeof import("node:url");
|
|
|
|
const BRIDGE_STATUS_HEARTBEAT_MS = 5_000;
|
|
const BRIDGE_STATUS_STALE_MS = 20_000;
|
|
const BRIDGE_STATUS_FILE = "bridge-status.json";
|
|
|
|
interface BridgeArgs {
|
|
daemon: boolean;
|
|
noViewer: boolean;
|
|
tcpPort?: number;
|
|
agent: "openclaw" | "hermes";
|
|
}
|
|
|
|
type BridgeStatus = "connected" | "reconnecting" | "disconnected" | "unknown";
|
|
|
|
interface BridgeStatusSnapshot {
|
|
status: BridgeStatus;
|
|
lastOkAt: number | null;
|
|
lastErrorAt: number | null;
|
|
lastError: string | null;
|
|
}
|
|
|
|
function parseArgs(argv: readonly string[]): BridgeArgs {
|
|
const args: BridgeArgs = { daemon: false, noViewer: false, agent: "openclaw" };
|
|
for (const raw of argv) {
|
|
if (raw === "--daemon") args.daemon = true;
|
|
else if (raw === "--no-viewer") args.noViewer = true;
|
|
else if (raw.startsWith("--tcp=")) args.tcpPort = Number(raw.slice(6));
|
|
else if (raw === "--agent=hermes") args.agent = "hermes";
|
|
else if (raw === "--agent=openclaw") args.agent = "openclaw";
|
|
}
|
|
return args;
|
|
}
|
|
|
|
// ─── PID file singleton guard ───────────────────────────────────────────
|
|
// Prevents bridge process accumulation: each new bridge that wants to
|
|
// own the viewer port kills the previous holder via its PID file.
|
|
// `--no-viewer` (headless) bridges skip this PID file entirely — they don't
|
|
// need the port and should coexist with the daemon that owns it.
|
|
|
|
const PID_FILENAME = "bridge.pid";
|
|
|
|
function pidFilePath(agent: string): string {
|
|
const agentHome = agent === "hermes" ? ".hermes" : ".openclaw";
|
|
return path.join(
|
|
process.env.HOME ?? "/tmp",
|
|
agentHome,
|
|
"memos-plugin",
|
|
"daemon",
|
|
PID_FILENAME,
|
|
);
|
|
}
|
|
|
|
function readPidFile(pidPath: string): number | null {
|
|
try {
|
|
const raw = fs.readFileSync(pidPath, "utf8").trim();
|
|
const pid = parseInt(raw, 10);
|
|
if (isNaN(pid) || pid <= 0) return null;
|
|
process.kill(pid, 0); // throws if not alive
|
|
return pid;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function writePidFile(pidPath: string): void {
|
|
fs.mkdirSync(path.dirname(pidPath), { recursive: true });
|
|
fs.writeFileSync(pidPath, String(process.pid), "utf8");
|
|
}
|
|
|
|
function removePidFile(pidPath: string): void {
|
|
try {
|
|
const content = fs.readFileSync(pidPath, "utf8").trim();
|
|
if (content === String(process.pid)) fs.unlinkSync(pidPath);
|
|
} catch {
|
|
/* best-effort; another bridge may have overwritten */
|
|
}
|
|
}
|
|
|
|
function killExistingBridge(pidPath: string, timeoutMs = 5000): void {
|
|
const existingPid = readPidFile(pidPath);
|
|
if (existingPid === null || existingPid === process.pid) return;
|
|
|
|
process.stderr.write(
|
|
`bridge: killing stale bridge pid=${existingPid} before startup\n`,
|
|
);
|
|
try {
|
|
process.kill(existingPid, "SIGTERM");
|
|
} catch {
|
|
return; // already dead
|
|
}
|
|
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
process.kill(existingPid, 0);
|
|
} catch {
|
|
return; // gone
|
|
}
|
|
childProcess.spawnSync("sleep", ["0.5"]);
|
|
}
|
|
try {
|
|
process.kill(existingPid, "SIGKILL");
|
|
} catch {
|
|
/* already dead */
|
|
}
|
|
}
|
|
|
|
async function main(): Promise<void> {
|
|
const args = parseArgs(process.argv.slice(2));
|
|
|
|
// ─── Singleton: kill previous bridge that owns the viewer port ───
|
|
const pidPath = pidFilePath(args.agent);
|
|
const ownsViewerPort = args.daemon || !args.noViewer;
|
|
const removeOwnedPidFile = () => {
|
|
if (ownsViewerPort) removePidFile(pidPath);
|
|
};
|
|
if (ownsViewerPort) {
|
|
killExistingBridge(pidPath);
|
|
writePidFile(pidPath);
|
|
}
|
|
|
|
// Lazy-import ESM core. Using dynamic import so this file remains
|
|
// CommonJS and stays `require`-able.
|
|
const { bootstrapMemoryCoreFull } = (await importEsm(
|
|
runtimeModule("core/pipeline/index.ts", "dist/core/pipeline/index.js")
|
|
)) as typeof import("./core/pipeline/index.js");
|
|
const { startStdioServer, waitForShutdown } = (await importEsm(
|
|
runtimeModule("bridge/stdio.ts", "dist/bridge/stdio.js")
|
|
)) as typeof import("./bridge/stdio.js");
|
|
const { memoryBuffer, rootLogger } = (await importEsm(
|
|
runtimeModule("core/logger/index.ts", "dist/core/logger/index.js")
|
|
)) as typeof import("./core/logger/index.js");
|
|
const { startHttpServer } = (await importEsm(
|
|
runtimeModule("server/http.ts", "dist/server/http.js")
|
|
)) as typeof import("./server/http.js");
|
|
|
|
const rootDir = pluginRoot();
|
|
const pkgVersion = require(path.join(rootDir, "package.json")).version;
|
|
|
|
// ─── Host LLM bridge (reverse RPC, lazy-bound to stdio) ────────
|
|
// We need to register the bridge BEFORE bootstrap creates the
|
|
// LlmClients (so the very first `shouldFallback()` check sees a
|
|
// non-null bridge), but `stdio` itself doesn't exist until later
|
|
// in this function. The trick: hand a placeholder closure to
|
|
// bootstrap that defers actual stdio access to the time of the
|
|
// first fallback call. In stdio mode we start the server before
|
|
// `core.init()` so startup recovery can also use host fallback.
|
|
//
|
|
// Routing through `bootstrapMemoryCoreFull({ hostLlmBridge })`
|
|
// (instead of having `bridge.cts` call `registerHostLlmBridge`
|
|
// directly) avoids a subtle ESM module-identity issue: the static
|
|
// `import` chain inside `core/llm/client.ts` and the dynamic
|
|
// `await import(...)` here resolve to the same file URL but Node
|
|
// can occasionally treat them as different module instances with
|
|
// independent `currentBridge` slots. Registering inside bootstrap
|
|
// forces both ends to share the same module instance.
|
|
let stdio: import("./bridge/stdio.js").StdioServerHandle | null = null;
|
|
const lazyHostLlmBridge: import("./core/llm/host-bridge.js").HostLlmBridge =
|
|
{
|
|
id: `stdio.host.${args.agent}.v1`,
|
|
async complete(input) {
|
|
if (!stdio) {
|
|
throw new Error(
|
|
"host LLM bridge invoked before stdio server was ready",
|
|
);
|
|
}
|
|
const result = (await stdio.serverRequest(
|
|
"host.llm.complete",
|
|
{
|
|
messages: input.messages,
|
|
model: input.model,
|
|
temperature: input.temperature,
|
|
maxTokens: input.maxTokens,
|
|
timeoutMs: input.timeoutMs,
|
|
},
|
|
{ timeoutMs: (input.timeoutMs ?? 60_000) + 5_000 },
|
|
)) as {
|
|
text?: string;
|
|
model?: string;
|
|
usage?: {
|
|
promptTokens?: number;
|
|
completionTokens?: number;
|
|
totalTokens?: number;
|
|
};
|
|
durationMs?: number;
|
|
};
|
|
return {
|
|
text: typeof result?.text === "string" ? result.text : "",
|
|
model:
|
|
typeof result?.model === "string"
|
|
? result.model
|
|
: input.model ?? "",
|
|
usage: result?.usage,
|
|
durationMs:
|
|
typeof result?.durationMs === "number" ? result.durationMs : 0,
|
|
};
|
|
},
|
|
};
|
|
|
|
const { Telemetry } = (await importEsm(
|
|
runtimeModule("core/telemetry/index.ts", "dist/core/telemetry/index.js")
|
|
)) as typeof import("./core/telemetry/index.js");
|
|
|
|
const { core, config, home } = await bootstrapMemoryCoreFull({
|
|
agent: args.agent,
|
|
namespace: { agentKind: args.agent, profileId: "default" },
|
|
pkgVersion,
|
|
hostLlmBridge: args.daemon ? null : lazyHostLlmBridge,
|
|
});
|
|
|
|
const telemetry = new Telemetry(
|
|
config.telemetry ?? {},
|
|
home.root,
|
|
pkgVersion,
|
|
rootLogger.child({ channel: "core.telemetry" }),
|
|
rootDir,
|
|
);
|
|
(core as { bindTelemetry?: (t: InstanceType<typeof Telemetry>) => void }).bindTelemetry?.(telemetry);
|
|
telemetry.trackPluginStarted(args.agent);
|
|
|
|
const bridgeStatus =
|
|
args.agent === "hermes"
|
|
? createBridgeStatusTracker(
|
|
path.join(home.root, BRIDGE_STATUS_FILE),
|
|
args.daemon,
|
|
)
|
|
: null;
|
|
|
|
// Process-level error reporting. Without these handlers a crash in
|
|
// a background task (capture / reward / L2 inducer) silently kills
|
|
// the bridge process and never surfaces in ARMS — making "0
|
|
// plugin_error events" actively misleading. Both handlers are
|
|
// best-effort and re-emit (or `process.exit(1)`) so we don't
|
|
// alter the existing crash semantics, only add observability.
|
|
// Only registered for `bridge.cts` (the dedicated process); the
|
|
// OpenClaw adapter runs inside the host process and must not steal
|
|
// its global error hooks.
|
|
process.on("uncaughtException", (err) => {
|
|
try {
|
|
telemetry.trackError("uncaught_exception", classifyErrorCode(err));
|
|
} catch {
|
|
/* swallow — telemetry must never widen the crash */
|
|
}
|
|
process.stderr.write(
|
|
`bridge: uncaughtException: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`,
|
|
);
|
|
// Mirror Node's default behaviour so existing supervisors that
|
|
// expect non-zero exit on crash keep working.
|
|
process.exit(1);
|
|
});
|
|
process.on("unhandledRejection", (reason) => {
|
|
try {
|
|
telemetry.trackError("unhandled_rejection", classifyErrorCode(reason));
|
|
} catch {
|
|
/* swallow — telemetry must never widen the crash */
|
|
}
|
|
process.stderr.write(
|
|
`bridge: unhandledRejection: ${reason instanceof Error ? reason.stack ?? reason.message : String(reason)}\n`,
|
|
);
|
|
// Don't exit: per-promise rejections are usually recoverable
|
|
// (failed flush, dropped SSE client). The default Node 20+
|
|
// behaviour is to exit, but for a long-running bridge that
|
|
// would be too aggressive — surface to telemetry + stderr and
|
|
// continue.
|
|
});
|
|
|
|
// Per-agent fixed viewer port.
|
|
const AGENT_DEFAULT_PORTS = { openclaw: 18799, hermes: 18800 } as const;
|
|
const viewerPort = AGENT_DEFAULT_PORTS[args.agent];
|
|
|
|
let bridgeHeartbeat:
|
|
| ReturnType<NonNullable<typeof bridgeStatus>["startHeartbeat"]>
|
|
| undefined;
|
|
|
|
// In stdio mode the host fallback path is a reverse JSON-RPC request
|
|
// over the same pipe as normal bridge traffic. `core.init()` may
|
|
// recover dirty episodes and run reflection/reward/L2/skill work; if
|
|
// that work hits a broken primary skill-evolver model, the LLM facade
|
|
// can fall back to host before init returns. Start stdio first so that
|
|
// fallback has a transport instead of tripping the lazy bridge guard.
|
|
if (!args.daemon) {
|
|
stdio = startStdioServer({ core });
|
|
bridgeStatus?.markConnected();
|
|
bridgeHeartbeat = bridgeStatus?.startHeartbeat();
|
|
void stdio.done.then(() => {
|
|
bridgeHeartbeat?.stop();
|
|
bridgeStatus?.markDisconnected("Hermes chat disconnected");
|
|
});
|
|
}
|
|
|
|
try {
|
|
await core.init();
|
|
} catch (err) {
|
|
bridgeHeartbeat?.stop();
|
|
if (stdio) {
|
|
try {
|
|
await stdio.close();
|
|
} catch {
|
|
/* best-effort */
|
|
}
|
|
}
|
|
throw err;
|
|
}
|
|
|
|
// ─── Daemon mode ──────────────────────────────────────────────
|
|
// When started with `--daemon`, skip stdio and run as a pure HTTP
|
|
// viewer daemon. Used by install.sh (post-install) and admin/restart
|
|
// (self-restart) to keep the Memory Viewer always available.
|
|
if (args.daemon) {
|
|
// Daemon mode is the target of `POST /api/v1/admin/restart`,
|
|
// which re-spawns the bridge after a short sleep. On busy
|
|
// machines the previous bridge's listening socket can take a
|
|
// moment longer than expected to release, so we retry the bind
|
|
// a few times before giving up. Without this the user sees
|
|
// "重启超时" in the viewer because the new daemon raced its
|
|
// predecessor and lost.
|
|
let viewer: import("./server/types.js").ServerHandle | null = null;
|
|
const maxBindAttempts = 10;
|
|
for (let attempt = 1; attempt <= maxBindAttempts; attempt++) {
|
|
try {
|
|
viewer = await startHttpServer(
|
|
{
|
|
core,
|
|
home,
|
|
logTail: () => memoryBuffer().tail({ limit: 200 }),
|
|
bridgeStatus: bridgeStatus ? () => bridgeStatus.snapshot() : undefined,
|
|
telemetry,
|
|
},
|
|
{
|
|
port: viewerPort,
|
|
host: config.viewer.bindHost,
|
|
staticRoot: path.resolve(rootDir, "viewer/dist"),
|
|
agent: args.agent,
|
|
},
|
|
);
|
|
process.stderr.write(
|
|
`bridge: daemon viewer live at ${viewer.url} (agent=${args.agent})\n`,
|
|
);
|
|
break;
|
|
} catch (err) {
|
|
const e = err as NodeJS.ErrnoException;
|
|
if (e?.code === "EADDRINUSE" && attempt < maxBindAttempts) {
|
|
process.stderr.write(
|
|
`bridge: daemon port :${viewerPort} busy (attempt ${attempt}/${maxBindAttempts}), retrying in 1s...\n`,
|
|
);
|
|
await new Promise((r) => setTimeout(r, 1000));
|
|
continue;
|
|
}
|
|
if (e?.code === "EADDRINUSE") {
|
|
process.stderr.write(
|
|
`bridge: daemon port :${viewerPort} still in use after ${maxBindAttempts}s — exiting.\n`,
|
|
);
|
|
await core.shutdown();
|
|
process.exit(1);
|
|
}
|
|
process.stderr.write(
|
|
`bridge: daemon viewer failed: ${(err as Error)?.message ?? String(err)}\n`,
|
|
);
|
|
await core.shutdown();
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
const shutdownDaemon = async (sig: string) => {
|
|
process.stderr.write(`bridge: daemon received ${sig}, shutting down\n`);
|
|
removeOwnedPidFile();
|
|
try { await viewer!.close(); } catch { /* best-effort */ }
|
|
await core.shutdown();
|
|
process.exit(0);
|
|
};
|
|
process.on("SIGINT", () => void shutdownDaemon("SIGINT"));
|
|
process.on("SIGTERM", () => void shutdownDaemon("SIGTERM"));
|
|
// Process stays alive via the HTTP server's ref'd socket.
|
|
return;
|
|
}
|
|
|
|
// ─── Normal (stdio) mode ──────────────────────────────────────
|
|
// The stdio handle was started before `core.init()` above so host
|
|
// fallback is available during startup recovery.
|
|
const activeStdio = stdio;
|
|
if (!activeStdio) {
|
|
throw new Error("internal bridge error: stdio server was not started");
|
|
}
|
|
|
|
// Try to bind the viewer port unless the caller requested a pure stdio
|
|
// bridge. Hermes chat uses --no-viewer; the standalone --daemon process is
|
|
// the single owner of :18800.
|
|
let viewer: import("./server/types.js").ServerHandle | null = null;
|
|
if (args.noViewer) {
|
|
process.stderr.write(
|
|
`bridge: stdio mode running without viewer (agent=${args.agent})\n`,
|
|
);
|
|
} else {
|
|
try {
|
|
viewer = await startHttpServer(
|
|
{
|
|
core,
|
|
home,
|
|
logTail: () => memoryBuffer().tail({ limit: 200 }),
|
|
bridgeStatus: bridgeStatus ? () => bridgeStatus.snapshot() : undefined,
|
|
telemetry,
|
|
},
|
|
{
|
|
port: viewerPort,
|
|
host: config.viewer.bindHost,
|
|
staticRoot: path.resolve(rootDir, "viewer/dist"),
|
|
agent: args.agent,
|
|
},
|
|
);
|
|
process.stderr.write(
|
|
`bridge: viewer live at ${viewer.url} (agent=${args.agent})\n`,
|
|
);
|
|
} catch (err) {
|
|
const e = err as NodeJS.ErrnoException;
|
|
if (e?.code === "EADDRINUSE") {
|
|
process.stderr.write(
|
|
`bridge: viewer port :${viewerPort} is already in use — ` +
|
|
`${args.agent} will run headless (stdio only). ` +
|
|
`Free the port to expose the viewer.\n`,
|
|
);
|
|
} else {
|
|
process.stderr.write(
|
|
`bridge: viewer failed to start: ${e?.message ?? String(err)}\n`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
const shutdown = async (sig: string) => {
|
|
process.stderr.write(`bridge: received ${sig}, shutting down\n`);
|
|
removeOwnedPidFile();
|
|
if (viewer) {
|
|
try {
|
|
await viewer.close();
|
|
} catch {
|
|
/* best-effort */
|
|
}
|
|
}
|
|
await waitForShutdown(core, activeStdio);
|
|
process.exit(0);
|
|
};
|
|
|
|
process.on("SIGINT", () => void shutdown("SIGINT"));
|
|
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
|
|
|
// Keep the process alive until stdin ends (client disconnects).
|
|
await activeStdio.done;
|
|
|
|
// If a viewer is running, keep the process alive as a daemon so the
|
|
// memory panel stays accessible between `hermes chat` sessions.
|
|
if (viewer && !viewer.closed) {
|
|
process.stderr.write(
|
|
`bridge: stdin closed but viewer is still serving at ${viewer.url} — ` +
|
|
`staying alive as daemon. Send SIGTERM to stop.\n`,
|
|
);
|
|
const keepalive = setInterval(() => {
|
|
if (viewer!.closed) {
|
|
clearInterval(keepalive);
|
|
removeOwnedPidFile();
|
|
void core.shutdown().then(() => process.exit(0));
|
|
}
|
|
}, 5_000);
|
|
(keepalive as unknown as { unref?: () => void }).unref?.();
|
|
return;
|
|
}
|
|
|
|
// No viewer (headless bridge) — clean exit.
|
|
removeOwnedPidFile();
|
|
await core.shutdown();
|
|
process.exit(0);
|
|
}
|
|
|
|
function pluginRoot(): string {
|
|
// Source entry: <root>/bridge.cts. Built entry: <root>/dist/bridge.cjs.
|
|
if (fs.existsSync(path.join(__dirname, "package.json"))) return __dirname;
|
|
const parent = path.resolve(__dirname, "..");
|
|
if (fs.existsSync(path.join(parent, "package.json"))) return parent;
|
|
return __dirname;
|
|
}
|
|
|
|
function runtimeModule(sourceRel: string, distRel: string): string {
|
|
const root = pluginRoot();
|
|
const distAbs = path.resolve(root, distRel);
|
|
const sourceAbs = path.resolve(root, sourceRel);
|
|
return pathToEsmUrl(fs.existsSync(distAbs) ? distAbs : sourceAbs);
|
|
}
|
|
|
|
function pathToEsmUrl(abs: string): string {
|
|
return url.pathToFileURL(abs).href;
|
|
}
|
|
|
|
const importEsm = new Function(
|
|
"specifier",
|
|
"return import(specifier)",
|
|
) as (specifier: string) => Promise<unknown>;
|
|
|
|
/**
|
|
* Best-effort error classification for ARMS `plugin_error.error_type`.
|
|
*
|
|
* Priority order:
|
|
* 1. `MemosError.code` and Node `errno` (`ENOENT`, `EADDRINUSE`, …)
|
|
* — both surface as a `code` string property.
|
|
* 2. The constructor name when it's something more specific than
|
|
* the generic `Error` (e.g. `TypeError`, `SyntaxError`).
|
|
* 3. `unknown` as a sentinel.
|
|
*
|
|
* Never returns the message — those can carry user paths or query
|
|
* fragments and would defeat the redaction the rest of the telemetry
|
|
* pipeline guarantees.
|
|
*/
|
|
function classifyErrorCode(err: unknown): string {
|
|
if (err && typeof err === "object" && "code" in err) {
|
|
const code = (err as { code: unknown }).code;
|
|
if (typeof code === "string" && code.length > 0) return code;
|
|
}
|
|
if (err instanceof Error && err.name && err.name !== "Error") {
|
|
return err.name;
|
|
}
|
|
return "unknown";
|
|
}
|
|
|
|
function createBridgeStatusTracker(statusFile: string, daemon: boolean): {
|
|
snapshot(): BridgeStatusSnapshot;
|
|
markConnected(): void;
|
|
markDisconnected(message: string): void;
|
|
startHeartbeat(): { stop(): void };
|
|
} {
|
|
let snapshot: BridgeStatusSnapshot = daemon
|
|
? {
|
|
status: "disconnected",
|
|
lastOkAt: null,
|
|
lastErrorAt: Date.now(),
|
|
lastError: "Hermes chat is not connected",
|
|
}
|
|
: {
|
|
status: "unknown",
|
|
lastOkAt: null,
|
|
lastErrorAt: null,
|
|
lastError: null,
|
|
};
|
|
|
|
function writeStatus(next: BridgeStatusSnapshot): void {
|
|
snapshot = next;
|
|
try {
|
|
fs.mkdirSync(path.dirname(statusFile), { recursive: true });
|
|
fs.writeFileSync(statusFile, JSON.stringify(next), "utf8");
|
|
} catch {
|
|
// Status display must never affect chat capture.
|
|
}
|
|
}
|
|
|
|
function readStatus(): BridgeStatusSnapshot | null {
|
|
try {
|
|
const parsed = JSON.parse(fs.readFileSync(statusFile, "utf8")) as Partial<BridgeStatusSnapshot>;
|
|
if (
|
|
parsed.status === "connected" ||
|
|
parsed.status === "reconnecting" ||
|
|
parsed.status === "disconnected" ||
|
|
parsed.status === "unknown"
|
|
) {
|
|
return {
|
|
status: parsed.status,
|
|
lastOkAt: typeof parsed.lastOkAt === "number" ? parsed.lastOkAt : null,
|
|
lastErrorAt: typeof parsed.lastErrorAt === "number" ? parsed.lastErrorAt : null,
|
|
lastError: typeof parsed.lastError === "string" ? parsed.lastError : null,
|
|
};
|
|
}
|
|
} catch {
|
|
// Missing or corrupt status files are treated as disconnected.
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function applyStaleRule(raw: BridgeStatusSnapshot): BridgeStatusSnapshot {
|
|
if (raw.status === "disconnected" && daemon && isHermesChatRunning()) {
|
|
return {
|
|
status: "reconnecting",
|
|
lastOkAt: raw.lastOkAt,
|
|
lastErrorAt: raw.lastErrorAt,
|
|
lastError: "Hermes chat is running; waiting for memory bridge",
|
|
};
|
|
}
|
|
if (
|
|
raw.status === "connected" &&
|
|
raw.lastOkAt != null &&
|
|
Date.now() - raw.lastOkAt > BRIDGE_STATUS_STALE_MS
|
|
) {
|
|
return {
|
|
status: "disconnected",
|
|
lastOkAt: raw.lastOkAt,
|
|
lastErrorAt: Date.now(),
|
|
lastError: "Hermes bridge heartbeat is stale",
|
|
};
|
|
}
|
|
return raw;
|
|
}
|
|
|
|
function markConnected(): void {
|
|
writeStatus({
|
|
status: "connected",
|
|
lastOkAt: Date.now(),
|
|
lastErrorAt: snapshot.lastErrorAt,
|
|
lastError: snapshot.lastError,
|
|
});
|
|
}
|
|
|
|
function markDisconnected(message: string): void {
|
|
writeStatus({
|
|
status: "disconnected",
|
|
lastOkAt: snapshot.lastOkAt,
|
|
lastErrorAt: Date.now(),
|
|
lastError: message,
|
|
});
|
|
}
|
|
|
|
return {
|
|
snapshot() {
|
|
return { ...applyStaleRule(readStatus() ?? snapshot) };
|
|
},
|
|
markConnected,
|
|
markDisconnected,
|
|
startHeartbeat() {
|
|
const timer = setInterval(() => {
|
|
markConnected();
|
|
}, BRIDGE_STATUS_HEARTBEAT_MS);
|
|
(timer as unknown as { unref?: () => void }).unref?.();
|
|
return {
|
|
stop() {
|
|
clearInterval(timer);
|
|
},
|
|
};
|
|
},
|
|
};
|
|
}
|
|
|
|
function isHermesChatRunning(): boolean {
|
|
try {
|
|
const out = childProcess.execFileSync("pgrep", ["-f", "hermes chat"], {
|
|
encoding: "utf8",
|
|
timeout: 1000,
|
|
});
|
|
return out.trim().length > 0;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
void main().catch((err) => {
|
|
const detail = err instanceof Error ? err.stack ?? err.message : String(err);
|
|
process.stderr.write(
|
|
`bridge: fatal: ${detail}\n`,
|
|
);
|
|
process.exit(1);
|
|
});
|