fix(plugins): re-export probe symbols + declare plugin deps in dashboard

- Hermes / OpenClaw plugin index.ts now re-export `probeHermesBinary` /
  `probeOpenClawBinary` and their status types so the dashboard's
  `runtime-provider-probes.ts` façade can import them via the public
  package entry instead of deep paths.
- Dashboard `package.json` adds `@fusion-plugin-examples/hermes-runtime`,
  `…/openclaw-runtime`, `…/paperclip-runtime` as workspace deps so
  pnpm symlinks them into `packages/dashboard/node_modules/`. Without
  these, the new probe imports failed with "Cannot find module" during
  `pnpm typecheck`.

This clears 6 of the 9 outstanding typecheck errors. The remaining 3 are
in the in-flight Hermes plugin rewrite (runtime-adapter still imports
from a deleted `./pi-module.js`; the new `index.ts` calls a factory
with the wrong arg type) and should be resolved by the same change set
that landed the rewrite.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Fusion
2026-04-27 20:22:18 -07:00
committed by gsxdsm
parent 5159cc2a68
commit 6e65786bc5
82 changed files with 8481 additions and 2833 deletions

View File

@@ -0,0 +1,460 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ChildProcess } from "node:child_process";
import { EventEmitter } from "node:events";
// ── Mock node:child_process before imports that use it ─────────────────────
const { mockSpawn } = vi.hoisted(() => ({ mockSpawn: vi.fn() }));
vi.mock("node:child_process", () => ({ spawn: mockSpawn }));
import {
buildHermesArgs,
invokeHermesCli,
listHermesProfiles,
parseHermesOutput,
resolveCliSettings,
} from "../cli-spawn.js";
import type { HermesCliSettings } from "../cli-spawn.js";
// ── Helpers ────────────────────────────────────────────────────────────────
function defaultSettings(overrides: Partial<HermesCliSettings> = {}): HermesCliSettings {
return {
binaryPath: "hermes",
maxTurns: 12,
yolo: false,
cliTimeoutMs: 5_000,
...overrides,
};
}
/**
* Create a fake ChildProcess-like EventEmitter with controllable
* stdout/stderr streams and a kill spy.
*/
function makeFakeChild(): {
child: ChildProcess;
emitStdout: (data: string) => void;
emitStderr: (data: string) => void;
emitError: (err: Error) => void;
emitClose: (code: number | null) => void;
kill: ReturnType<typeof vi.fn>;
} {
const main = new EventEmitter() as ChildProcess;
const stdoutEmitter = new EventEmitter();
const stderrEmitter = new EventEmitter();
(main as any).stdout = stdoutEmitter;
(main as any).stderr = stderrEmitter;
const kill = vi.fn().mockReturnValue(true);
(main as any).kill = kill;
return {
child: main,
emitStdout: (data) => stdoutEmitter.emit("data", Buffer.from(data)),
emitStderr: (data) => stderrEmitter.emit("data", Buffer.from(data)),
emitError: (err) => main.emit("error", err),
emitClose: (code) => main.emit("close", code),
kill,
};
}
/** Build a stdout string that hermes would produce for a given body + session id. */
function fakeHermesOutput(body: string, sessionId = "20260427_120000_abcd12"): string {
return `${body}\nsession_id: ${sessionId}\n`;
}
// ── resolveCliSettings ──────────────────────────────────────────────────────
describe("resolveCliSettings", () => {
const origEnv = { ...process.env };
afterEach(() => {
process.env = { ...origEnv };
});
it("returns defaults when settings and env are empty", () => {
delete process.env.HERMES_BIN;
delete process.env.HERMES_MODEL_ID;
delete process.env.HERMES_PROVIDER;
delete process.env.HERMES_MAX_TURNS;
delete process.env.HERMES_YOLO;
delete process.env.HERMES_CLI_TIMEOUT_MS;
expect(resolveCliSettings()).toEqual({
binaryPath: "hermes",
model: undefined,
provider: undefined,
maxTurns: 12,
yolo: false,
cliTimeoutMs: 300_000,
});
});
it("prefers settings over env vars", () => {
process.env.HERMES_BIN = "/usr/bin/hermes";
process.env.HERMES_MODEL_ID = "env-model";
process.env.HERMES_PROVIDER = "openrouter";
expect(
resolveCliSettings({
binaryPath: "/custom/hermes",
model: "gpt-4o",
provider: "openai-codex",
maxTurns: 5,
yolo: true,
cliTimeoutMs: 10_000,
}),
).toEqual({
binaryPath: "/custom/hermes",
model: "gpt-4o",
provider: "openai-codex",
maxTurns: 5,
yolo: true,
cliTimeoutMs: 10_000,
});
});
it("falls back to env vars when settings omit values", () => {
process.env.HERMES_BIN = "/env/hermes";
process.env.HERMES_MODEL_ID = "env-model";
process.env.HERMES_PROVIDER = "gemini";
process.env.HERMES_MAX_TURNS = "7";
process.env.HERMES_YOLO = "true";
process.env.HERMES_CLI_TIMEOUT_MS = "60000";
expect(resolveCliSettings({})).toEqual({
binaryPath: "/env/hermes",
model: "env-model",
provider: "gemini",
maxTurns: 7,
yolo: true,
cliTimeoutMs: 60_000,
});
});
});
// ── buildHermesArgs ────────────────────────────────────────────────────────
describe("buildHermesArgs", () => {
it("builds minimal args without resume or optional flags", () => {
const args = buildHermesArgs("hello world", defaultSettings());
expect(args).toEqual(["chat", "-q", "hello world", "-Q", "--source", "tool", "--max-turns", "12"]);
});
it("adds --resume when sessionId provided", () => {
const args = buildHermesArgs("hello", defaultSettings(), "20260427_120000_abcd12");
expect(args).toContain("--resume");
expect(args[args.indexOf("--resume") + 1]).toBe("20260427_120000_abcd12");
});
it("adds -m and --provider when configured", () => {
const args = buildHermesArgs(
"hello",
defaultSettings({ model: "claude-sonnet-4-5", provider: "anthropic" }),
);
expect(args).toContain("-m");
expect(args[args.indexOf("-m") + 1]).toBe("claude-sonnet-4-5");
expect(args).toContain("--provider");
expect(args[args.indexOf("--provider") + 1]).toBe("anthropic");
});
it("adds --yolo when yolo is true", () => {
const args = buildHermesArgs("hello", defaultSettings({ yolo: true }));
expect(args).toContain("--yolo");
});
it("does not add --yolo when yolo is false", () => {
const args = buildHermesArgs("hello", defaultSettings({ yolo: false }));
expect(args).not.toContain("--yolo");
});
it("uses configured maxTurns", () => {
const args = buildHermesArgs("hello", defaultSettings({ maxTurns: 5 }));
expect(args[args.indexOf("--max-turns") + 1]).toBe("5");
});
it("first call has no --resume arg", () => {
const args = buildHermesArgs("hi", defaultSettings());
expect(args).not.toContain("--resume");
});
});
// ── parseHermesOutput ──────────────────────────────────────────────────────
describe("parseHermesOutput", () => {
it("extracts session_id and body from clean output", () => {
const stdout = "This is the response\nsession_id: 20260427_120000_abcd12\n";
const result = parseHermesOutput(stdout, "");
expect(result.sessionId).toBe("20260427_120000_abcd12");
expect(result.body).toBe("This is the response");
});
it("strips ANSI escape codes from body", () => {
const stdout = "\x1b[32mGreen text\x1b[0m\nsession_id: 20260427_120000_abcd12\n";
const result = parseHermesOutput(stdout, "");
expect(result.body).toBe("Green text");
expect(result.body).not.toContain("\x1b");
});
it("strips preamble chrome lines", () => {
const stdout = [
"╭─ Hermes ─╮",
"↻ Resumed session foo",
" ┊ preparing memory…",
"Query: what is 2+2?",
"4",
"╰─────────╯",
"session_id: 20260427_120000_abcd12",
].join("\n") + "\n";
const result = parseHermesOutput(stdout, "");
expect(result.body).toBe("4");
expect(result.body).not.toContain("Hermes");
expect(result.body).not.toContain("Resumed");
expect(result.body).not.toContain("preparing");
expect(result.body).not.toContain("Query:");
expect(result.body).not.toContain("╭");
expect(result.body).not.toContain("╰");
});
it("normalizes CRLF to LF", () => {
const stdout = "line1\r\nline2\r\nsession_id: 20260427_120000_abcd12\r\n";
const result = parseHermesOutput(stdout, "");
expect(result.body).toBe("line1\nline2");
});
it("throws when session_id line is missing", () => {
expect(() => parseHermesOutput("some output without id", "stderr text")).toThrow(
/missing session_id/,
);
});
});
// ── invokeHermesCli ────────────────────────────────────────────────────────
describe("invokeHermesCli", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("first call passes correct args and no --resume", async () => {
const { child, emitStdout, emitClose } = makeFakeChild();
mockSpawn.mockReturnValue(child);
const PROMPT = "what is typescript?";
const promise = invokeHermesCli(PROMPT, defaultSettings());
emitStdout(fakeHermesOutput("TypeScript is a language."));
emitClose(0);
const result = await promise;
expect(mockSpawn).toHaveBeenCalledOnce();
const [bin, args] = mockSpawn.mock.calls[0]!;
expect(bin).toBe("hermes");
expect(args).toEqual(["chat", "-q", PROMPT, "-Q", "--source", "tool", "--max-turns", "12"]);
expect(result.body).toBe("TypeScript is a language.");
expect(result.sessionId).toBe("20260427_120000_abcd12");
});
it("subsequent call with sessionId passes --resume", async () => {
const { child, emitStdout, emitClose } = makeFakeChild();
mockSpawn.mockReturnValue(child);
const promise = invokeHermesCli("hello again", defaultSettings(), "20260427_120000_abcd12");
emitStdout(fakeHermesOutput("Hi there!"));
emitClose(0);
await promise;
const args: string[] = mockSpawn.mock.calls[0]![1];
expect(args).toContain("--resume");
expect(args[args.indexOf("--resume") + 1]).toBe("20260427_120000_abcd12");
});
it("captures session id from stdout", async () => {
const { child, emitStdout, emitClose } = makeFakeChild();
mockSpawn.mockReturnValue(child);
const promise = invokeHermesCli("hi", defaultSettings());
emitStdout("Hello!\nsession_id: 20260427_120000_abcd12\n");
emitClose(0);
const result = await promise;
expect(result.sessionId).toBe("20260427_120000_abcd12");
});
it("rejects on non-zero exit code and surfaces stderr", async () => {
const { child, emitStdout, emitStderr, emitClose } = makeFakeChild();
mockSpawn.mockReturnValue(child);
const promise = invokeHermesCli("hi", defaultSettings());
emitStdout("partial output");
emitStderr("fatal error from hermes");
emitClose(1);
await expect(promise).rejects.toThrow(/exited with code 1/);
});
it("rejects when session_id is missing from stdout on exit 0", async () => {
const { child, emitStdout, emitClose } = makeFakeChild();
mockSpawn.mockReturnValue(child);
const promise = invokeHermesCli("hi", defaultSettings());
emitStdout("Some output without session id line");
emitClose(0);
await expect(promise).rejects.toThrow(/missing session_id/);
});
it("includes -m, --provider, --max-turns, --yolo when settings configured", async () => {
const { child, emitStdout, emitClose } = makeFakeChild();
mockSpawn.mockReturnValue(child);
const settings = defaultSettings({
model: "claude-sonnet-4-5",
provider: "anthropic",
maxTurns: 20,
yolo: true,
});
const promise = invokeHermesCli("test", settings);
emitStdout(fakeHermesOutput("ok"));
emitClose(0);
await promise;
const args: string[] = mockSpawn.mock.calls[0]![1];
expect(args).toContain("-m");
expect(args[args.indexOf("-m") + 1]).toBe("claude-sonnet-4-5");
expect(args).toContain("--provider");
expect(args[args.indexOf("--provider") + 1]).toBe("anthropic");
expect(args[args.indexOf("--max-turns") + 1]).toBe("20");
expect(args).toContain("--yolo");
});
it("rejects on ENOENT with not-found message", async () => {
const { child, emitError } = makeFakeChild();
mockSpawn.mockReturnValue(child);
const promise = invokeHermesCli("hi", defaultSettings());
const err = Object.assign(new Error("not found"), { code: "ENOENT" });
emitError(err);
await expect(promise).rejects.toThrow(/binary not found/);
});
it("AbortSignal triggers child.kill and rejects", async () => {
const { child, kill, emitStdout } = makeFakeChild();
mockSpawn.mockReturnValue(child);
const ac = new AbortController();
const promise = invokeHermesCli("hi", defaultSettings(), undefined, ac.signal);
// Abort before any output arrives.
ac.abort();
emitStdout("irrelevant");
await expect(promise).rejects.toThrow(/aborted/);
expect(kill).toHaveBeenCalledWith("SIGTERM");
});
});
// ── listHermesProfiles ─────────────────────────────────────────────────────
describe("listHermesProfiles", () => {
beforeEach(() => {
vi.clearAllMocks();
});
const SAMPLE_OUTPUT = [
" Profile Model Gateway Alias",
" ─────────────── ───────────────────────── ─────────── ────────────",
" ◆default MiniMax-M2.7 stopped —",
].join("\n") + "\n";
it("happy path: parses single default profile", async () => {
const { child, emitStdout, emitClose } = makeFakeChild();
mockSpawn.mockReturnValue(child);
const promise = listHermesProfiles();
emitStdout(SAMPLE_OUTPUT);
emitClose(0);
const profiles = await promise;
expect(profiles).toHaveLength(1);
expect(profiles[0]).toMatchObject({
name: "default",
model: "MiniMax-M2.7",
gateway: "stopped",
alias: undefined,
isDefault: true,
});
});
it("happy path: multi-profile ordered correctly", async () => {
const { child, emitStdout, emitClose } = makeFakeChild();
mockSpawn.mockReturnValue(child);
const multiOutput = [
" Profile Model Gateway Alias",
" ─────────────── ───────────────────────── ─────────── ────────────",
" ◆default MiniMax-M2.7 stopped —",
" work claude-sonnet-4-5 running work-hermes",
].join("\n") + "\n";
const promise = listHermesProfiles();
emitStdout(multiOutput);
emitClose(0);
const profiles = await promise;
expect(profiles).toHaveLength(2);
expect(profiles[0]!.name).toBe("default");
expect(profiles[0]!.isDefault).toBe(true);
expect(profiles[1]!.name).toBe("work");
expect(profiles[1]!.isDefault).toBe(false);
expect(profiles[1]!.model).toBe("claude-sonnet-4-5");
expect(profiles[1]!.alias).toBe("work-hermes");
});
it("ENOENT rejects with binary-not-found error", async () => {
const { child, emitError } = makeFakeChild();
mockSpawn.mockReturnValue(child);
const promise = listHermesProfiles({ binaryPath: "/no/such/hermes" });
const err = Object.assign(new Error("not found"), { code: "ENOENT" });
emitError(err);
await expect(promise).rejects.toThrow(/hermes profile list failed.*binary not found/);
});
it("non-zero exit rejects with exit code error", async () => {
const { child, emitStdout, emitStderr, emitClose } = makeFakeChild();
mockSpawn.mockReturnValue(child);
const promise = listHermesProfiles();
emitStdout("");
emitStderr("unknown subcommand");
emitClose(2);
await expect(promise).rejects.toThrow(/hermes profile list failed.*exited with code 2/);
});
it("spawns with 'profile list' args", async () => {
const { child, emitStdout, emitClose } = makeFakeChild();
mockSpawn.mockReturnValue(child);
const promise = listHermesProfiles({ binaryPath: "/custom/hermes" });
emitStdout(SAMPLE_OUTPUT);
emitClose(0);
await promise;
expect(mockSpawn).toHaveBeenCalledOnce();
const [bin, args] = mockSpawn.mock.calls[0]!;
expect(bin).toBe("/custom/hermes");
expect(args).toEqual(["profile", "list"]);
});
});

View File

@@ -1,221 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
createStreamSession,
describeStreamModel,
resolveModelConfig,
streamPrompt,
} from "../pi-module.js";
const { mockGetModel, mockStreamSimple } = vi.hoisted(() => ({
mockGetModel: vi.fn(),
mockStreamSimple: vi.fn(),
}));
vi.mock("@mariozechner/pi-ai", () => ({
getModel: mockGetModel,
streamSimple: mockStreamSimple,
}));
function createFakeStream(events: unknown[], finalMessage: unknown) {
return {
async *[Symbol.asyncIterator]() {
for (const event of events) {
yield event;
}
},
result: vi.fn().mockResolvedValue(finalMessage),
};
}
describe("hermes pi-ai stream client", () => {
const originalEnv = { ...process.env };
beforeEach(() => {
vi.clearAllMocks();
process.env = { ...originalEnv };
delete process.env.HERMES_PROVIDER;
delete process.env.HERMES_MODEL_ID;
delete process.env.HERMES_API_KEY;
delete process.env.HERMES_THINKING_LEVEL;
mockGetModel.mockReturnValue({ provider: "anthropic", id: "claude-sonnet-4-5" });
});
afterEach(() => {
process.env = { ...originalEnv };
});
it("resolveModelConfig prefers settings over env and env over defaults", () => {
process.env.HERMES_PROVIDER = "openai";
process.env.HERMES_MODEL_ID = "gpt-5";
process.env.HERMES_API_KEY = "env-key";
process.env.HERMES_THINKING_LEVEL = "medium";
expect(resolveModelConfig()).toEqual({
provider: "openai",
modelId: "gpt-5",
apiKey: "env-key",
thinkingLevel: "medium",
});
expect(
resolveModelConfig({ provider: "anthropic", modelId: "claude", apiKey: "plugin-key", thinkingLevel: "high" }),
).toEqual({
provider: "anthropic",
modelId: "claude",
apiKey: "plugin-key",
thinkingLevel: "high",
});
delete process.env.HERMES_PROVIDER;
delete process.env.HERMES_MODEL_ID;
delete process.env.HERMES_API_KEY;
delete process.env.HERMES_THINKING_LEVEL;
expect(resolveModelConfig()).toEqual({
provider: "anthropic",
modelId: "claude-sonnet-4-5",
apiKey: undefined,
thinkingLevel: undefined,
});
});
it("createStreamSession resolves model and initializes session state", () => {
const onText = vi.fn();
const onThinking = vi.fn();
const onToolStart = vi.fn();
const onToolEnd = vi.fn();
const session = createStreamSession({
provider: "anthropic",
modelId: "claude-sonnet-4-5",
apiKey: "key",
thinkingLevel: "high",
systemPrompt: "You are Hermes",
callbacks: { onText, onThinking, onToolStart, onToolEnd },
});
expect(mockGetModel).toHaveBeenCalledWith("anthropic", "claude-sonnet-4-5");
expect(session.model).toEqual({ provider: "anthropic", id: "claude-sonnet-4-5" });
expect(session.systemPrompt).toBe("You are Hermes");
expect(session.messages).toEqual([]);
expect(session.apiKey).toBe("key");
expect(session.thinkingLevel).toBe("high");
expect(session.callbacks).toEqual({ onText, onThinking, onToolStart, onToolEnd });
expect(session.lastModelDescription).toBe("anthropic/claude-sonnet-4-5");
expect(session.sessionId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i);
const second = createStreamSession({
provider: "anthropic",
modelId: "claude-sonnet-4-5",
systemPrompt: "You are Hermes",
});
expect(second.sessionId).not.toBe(session.sessionId);
});
it("streamPrompt streams deltas, handles tool calls, stores usage, and appends assistant text only", async () => {
const onText = vi.fn();
const onThinking = vi.fn();
const onToolStart = vi.fn();
const onToolEnd = vi.fn();
const session = createStreamSession({
provider: "anthropic",
modelId: "claude-sonnet-4-5",
apiKey: "api-key",
thinkingLevel: "medium",
systemPrompt: "system",
callbacks: { onText, onThinking, onToolStart, onToolEnd },
});
session.messages.push({ role: "user", content: "hello" });
const doneMessage = {
content: [
{ type: "text", text: "Hello" },
{ type: "thinking", thinking: "internal" },
{ type: "toolCall", id: "t1", name: "bash", arguments: { cmd: "ls" } },
{ type: "text", text: " world" },
],
usage: { input: 1, output: 2 },
};
mockStreamSimple.mockReturnValue(
createFakeStream(
[
{ type: "text_delta", delta: "Hello" },
{ type: "thinking_delta", delta: "thinking" },
{ type: "toolcall_end", toolCall: { name: "bash", arguments: { cmd: "ls" } } },
{ type: "text_delta", delta: " world" },
{ type: "done", message: doneMessage },
],
doneMessage,
),
);
await streamPrompt(session, { role: "user", content: "ignored" } as any);
expect(mockStreamSimple).toHaveBeenCalledWith(
session.model,
{
systemPrompt: "system",
messages: [{ role: "user", content: "hello" }],
},
{
sessionId: session.sessionId,
apiKey: "api-key",
reasoning: "medium",
},
);
expect(onText).toHaveBeenNthCalledWith(1, "Hello");
expect(onText).toHaveBeenNthCalledWith(2, " world");
expect(onThinking).toHaveBeenCalledWith("thinking");
expect(onToolStart).toHaveBeenCalledWith("bash", { cmd: "ls" });
expect(onToolEnd).toHaveBeenCalledWith("bash", false, { cmd: "ls" });
expect(session.usage).toEqual({ input: 1, output: 2 });
expect(session.messages).toEqual([
{ role: "user", content: "hello" },
{ role: "assistant", content: "Hello world" },
]);
expect(describeStreamModel(session)).toBe("anthropic/claude-sonnet-4-5");
});
it("streamPrompt omits optional apiKey/reasoning when unset", async () => {
const session = createStreamSession({
provider: "anthropic",
modelId: "claude-sonnet-4-5",
systemPrompt: "system",
});
session.messages.push({ role: "user", content: "hello" });
const doneMessage = { content: [{ type: "text", text: "ok" }], usage: { input: 1, output: 1 } };
mockStreamSimple.mockReturnValue(createFakeStream([{ type: "done", message: doneMessage }], doneMessage));
await streamPrompt(session, { role: "user", content: "ignored" } as any);
expect(mockStreamSimple).toHaveBeenCalledWith(
session.model,
{ systemPrompt: "system", messages: [{ role: "user", content: "hello" }] },
{ sessionId: session.sessionId },
);
});
it("streamPrompt throws on error event", async () => {
const session = createStreamSession({
provider: "anthropic",
modelId: "claude-sonnet-4-5",
systemPrompt: "system",
});
const errorMessage = {
type: "error",
error: {
errorMessage: "boom",
},
};
mockStreamSimple.mockReturnValue(createFakeStream([errorMessage], { content: [], usage: {} }));
await expect(streamPrompt(session, { role: "user", content: "ignored" } as any)).rejects.toThrow("boom");
expect(session.messages).toEqual([]);
});
});

View File

@@ -1,47 +1,59 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { mockResolveModelConfig } = vi.hoisted(() => ({
mockResolveModelConfig: vi.fn().mockReturnValue({
provider: "anthropic",
modelId: "claude-sonnet-4-5",
apiKey: undefined,
thinkingLevel: undefined,
const { mockResolveCli } = vi.hoisted(() => ({
mockResolveCli: vi.fn().mockReturnValue({
binaryPath: "hermes",
model: undefined,
provider: undefined,
maxTurns: 12,
yolo: false,
cliTimeoutMs: 300_000,
}),
}));
vi.mock("../pi-module.js", () => ({
resolveModelConfig: mockResolveModelConfig,
}));
vi.mock("../cli-spawn.js", async () => {
const actual = await vi.importActual<typeof import("../cli-spawn.js")>(
"../cli-spawn.js",
);
return {
...actual,
resolveCliSettings: mockResolveCli,
};
});
import plugin, { hermesRuntimeMetadata, hermesRuntimeFactory, HERMES_RUNTIME_ID } from "../index.js";
import plugin, {
hermesRuntimeMetadata,
hermesRuntimeFactory,
HERMES_RUNTIME_ID,
} from "../index.js";
import { HermesRuntimeAdapter } from "../runtime-adapter.js";
function createMockContext(settings: Record<string, unknown> = {}) {
return {
pluginId: "fusion-plugin-hermes-runtime",
settings,
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
emitEvent: vi.fn(),
taskStore: {
getTask: vi.fn(),
},
taskStore: { getTask: vi.fn() },
};
}
describe("hermes-runtime plugin", () => {
beforeEach(() => {
vi.clearAllMocks();
mockResolveCli.mockReturnValue({
binaryPath: "hermes",
model: undefined,
provider: undefined,
maxTurns: 12,
yolo: false,
cliTimeoutMs: 300_000,
});
});
it("has expected manifest identity", () => {
expect(plugin.manifest.id).toBe("fusion-plugin-hermes-runtime");
expect(plugin.manifest.name).toBe("Hermes Runtime Plugin");
expect(plugin.manifest.version).toBe("0.1.0");
expect(plugin.state).toBe("installed");
});
@@ -49,50 +61,35 @@ describe("hermes-runtime plugin", () => {
expect(HERMES_RUNTIME_ID).toBe("hermes");
expect(plugin.runtime?.metadata.runtimeId).toBe("hermes");
expect(plugin.runtime?.metadata.name).toBe("Hermes Runtime");
expect(plugin.runtime?.metadata.description).toContain("pi-ai direct streaming");
expect(plugin.runtime?.metadata.description).toContain("hermes");
expect(plugin.manifest.runtime).toEqual(hermesRuntimeMetadata);
});
it("onLoad resolves model config and logs selected provider/model without api key", async () => {
const ctx = createMockContext({ provider: "openai", modelId: "gpt-5", apiKey: "secret" });
mockResolveModelConfig.mockReturnValue({
provider: "openai",
modelId: "gpt-5",
apiKey: "secret",
thinkingLevel: "medium",
it("onLoad logs selected binary path & model and emits loaded event", async () => {
mockResolveCli.mockReturnValue({
binaryPath: "/opt/homebrew/bin/hermes",
model: "claude-sonnet-4-5",
provider: "anthropic",
maxTurns: 12,
yolo: false,
cliTimeoutMs: 300_000,
});
await plugin.hooks.onLoad?.(ctx as any);
expect(mockResolveModelConfig).toHaveBeenCalledWith(ctx.settings);
expect(ctx.logger.info).toHaveBeenCalledWith("Hermes Runtime Plugin loaded — using openai/gpt-5");
expect(ctx.logger.info.mock.calls[0][0]).not.toContain("secret");
const ctx = createMockContext({ binaryPath: "/opt/homebrew/bin/hermes" });
await plugin.hooks!.onLoad!(ctx as any);
expect(mockResolveCli).toHaveBeenCalledWith(ctx.settings);
expect(ctx.logger.info).toHaveBeenCalledWith(
expect.stringContaining("/opt/homebrew/bin/hermes"),
);
expect(ctx.emitEvent).toHaveBeenCalledWith("hermes-runtime:loaded", {
runtimeId: "hermes",
version: "0.1.0",
version: plugin.manifest.version,
});
});
it("runtime factory resolves settings and returns HermesRuntimeAdapter", async () => {
const ctx = createMockContext({ provider: "openai", modelId: "gpt-5" });
mockResolveModelConfig.mockReturnValue({
provider: "openai",
modelId: "gpt-5",
apiKey: "api-key",
thinkingLevel: "high",
});
it("factory returns a HermesRuntimeAdapter", async () => {
const ctx = createMockContext({ binaryPath: "hermes" });
const runtime = (await hermesRuntimeFactory(ctx as any)) as HermesRuntimeAdapter;
expect(mockResolveModelConfig).toHaveBeenCalledWith(ctx.settings);
expect(runtime).toBeInstanceOf(HermesRuntimeAdapter);
expect(runtime.id).toBe("hermes");
expect(runtime.name).toBe("Hermes Runtime");
expect((runtime as any).config).toEqual({
provider: "openai",
modelId: "gpt-5",
apiKey: "api-key",
thinkingLevel: "high",
});
});
});

View File

@@ -1,96 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { FusionPlugin } from "@fusion/plugin-sdk";
import plugin from "../index.js";
import type { AgentRuntime } from "../types.js";
const {
mockResolveModelConfig,
mockCreateStreamSession,
mockStreamPrompt,
mockDescribeStreamModel,
} = vi.hoisted(() => ({
mockResolveModelConfig: vi.fn().mockReturnValue({
provider: "anthropic",
modelId: "claude-sonnet-4-5",
apiKey: undefined,
thinkingLevel: undefined,
}),
mockCreateStreamSession: vi.fn().mockReturnValue({ messages: [], dispose: vi.fn() }),
mockStreamPrompt: vi.fn().mockResolvedValue(undefined),
mockDescribeStreamModel: vi.fn().mockReturnValue("anthropic/claude-sonnet-4-5"),
}));
vi.mock("../pi-module.js", () => ({
resolveModelConfig: mockResolveModelConfig,
createStreamSession: mockCreateStreamSession,
streamPrompt: mockStreamPrompt,
describeStreamModel: mockDescribeStreamModel,
}));
function isAgentRuntime(value: unknown): value is AgentRuntime {
return (
typeof value === "object" &&
value !== null &&
"id" in value &&
"name" in value &&
typeof (value as AgentRuntime).createSession === "function" &&
typeof (value as AgentRuntime).promptWithFallback === "function" &&
typeof (value as AgentRuntime).describeModel === "function"
);
}
function createMockContext() {
return {
pluginId: "fusion-plugin-hermes-runtime",
settings: { provider: "anthropic", modelId: "claude-sonnet-4-5" },
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
emitEvent: vi.fn(),
taskStore: { getTask: vi.fn() },
};
}
describe("Hermes runtime plugin integration", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("exports a valid Fusion plugin manifest", () => {
const fusionPlugin = plugin as FusionPlugin;
expect(fusionPlugin).toBeDefined();
expect(fusionPlugin.manifest.id).toBe("fusion-plugin-hermes-runtime");
});
it("runtime factory returns an AgentRuntime-compatible Hermes adapter", async () => {
const runtime = (await plugin.runtime!.factory(createMockContext() as any)) as AgentRuntime;
expect(runtime.id).toBe("hermes");
expect(runtime.name).toBe("Hermes Runtime");
expect(isAgentRuntime(runtime)).toBe(true);
const created = await runtime.createSession({ cwd: "/tmp", systemPrompt: "helpful" });
expect(created.sessionFile).toBeUndefined();
await runtime.promptWithFallback(created.session, "Hello integration");
expect(mockStreamPrompt).toHaveBeenCalled();
expect(runtime.describeModel(created.session)).toBe("anthropic/claude-sonnet-4-5");
});
it("onLoad emits hermes-runtime:loaded with runtime metadata", async () => {
const ctx = createMockContext();
await plugin.hooks.onLoad?.(ctx as any);
expect(mockResolveModelConfig).toHaveBeenCalledWith(ctx.settings);
expect(ctx.emitEvent).toHaveBeenCalledWith("hermes-runtime:loaded", {
runtimeId: "hermes",
version: "0.1.0",
});
});
});

View File

@@ -0,0 +1,156 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { EventEmitter } from "node:events";
import type { ChildProcess } from "node:child_process";
// ── Mock node:child_process ────────────────────────────────────────────────
const { mockSpawn } = vi.hoisted(() => ({ mockSpawn: vi.fn() }));
vi.mock("node:child_process", () => ({ spawn: mockSpawn }));
import { probeHermesBinary } from "../probe.js";
// ── Helpers ────────────────────────────────────────────────────────────────
/** Yields to the microtask and I/O queue so awaited code can continue. */
function flushAsync(): Promise<void> {
return new Promise((resolve) => setImmediate(resolve));
}
function makeFakeChild(): {
child: ChildProcess;
emitStdout: (data: string) => void;
emitStderr: (data: string) => void;
emitError: (err: NodeJS.ErrnoException) => void;
emitClose: (code: number | null) => void;
} {
const main = new EventEmitter() as ChildProcess;
const stdoutEmitter = new EventEmitter();
const stderrEmitter = new EventEmitter();
(main as any).stdout = stdoutEmitter;
(main as any).stderr = stderrEmitter;
(main as any).kill = vi.fn();
return {
child: main,
emitStdout: (data) => stdoutEmitter.emit("data", Buffer.from(data)),
emitStderr: (data) => stderrEmitter.emit("data", Buffer.from(data)),
emitError: (err) => main.emit("error", err),
emitClose: (code) => main.emit("close", code),
};
}
// ── Tests ──────────────────────────────────────────────────────────────────
describe("probeHermesBinary", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns available: false with not-found reason on ENOENT", async () => {
// probeHermesBinary first awaits tryResolveBinaryPath (which/where spawn),
// then spawns the --version process. We must drive them sequentially.
const whichChild = makeFakeChild();
const versionChild = makeFakeChild();
mockSpawn
.mockReturnValueOnce(whichChild.child) // which hermes
.mockReturnValueOnce(versionChild.child); // hermes --version
const promise = probeHermesBinary({ timeoutMs: 500 });
// Settle the `which` call — which causes tryResolveBinaryPath to resolve.
whichChild.emitClose(1);
// Yield so the awaited tryResolveBinaryPath continuation runs and spawns
// the version child before we emit its error.
await flushAsync();
const enoent = Object.assign(new Error("ENOENT"), { code: "ENOENT" });
versionChild.emitError(enoent);
const result = await promise;
expect(result.available).toBe(false);
expect(result.reason).toMatch(/not found on PATH/);
expect(result.probeDurationMs).toBeGreaterThanOrEqual(0);
});
it("returns available: true with parsed version on success", async () => {
const whichChild = makeFakeChild();
const versionChild = makeFakeChild();
mockSpawn
.mockReturnValueOnce(whichChild.child)
.mockReturnValueOnce(versionChild.child);
const promise = probeHermesBinary({ binaryPath: "hermes", timeoutMs: 500 });
whichChild.emitStdout("/usr/local/bin/hermes\n");
whichChild.emitClose(0);
await flushAsync();
versionChild.emitStdout("Hermes Agent v1.2.3\n");
versionChild.emitClose(0);
const result = await promise;
expect(result.available).toBe(true);
expect(result.version).toBe("Hermes Agent v1.2.3");
expect(result.binaryPath).toBe("/usr/local/bin/hermes");
expect(result.reason).toBeUndefined();
expect(result.probeDurationMs).toBeGreaterThanOrEqual(0);
});
it("returns available: false when version exits non-zero", async () => {
const whichChild = makeFakeChild();
const versionChild = makeFakeChild();
mockSpawn
.mockReturnValueOnce(whichChild.child)
.mockReturnValueOnce(versionChild.child);
const promise = probeHermesBinary({ timeoutMs: 500 });
whichChild.emitClose(1);
await flushAsync();
versionChild.emitStderr("error: something went wrong");
versionChild.emitClose(2);
const result = await promise;
expect(result.available).toBe(false);
expect(result.reason).toContain("error: something went wrong");
});
it("uses custom binaryPath when provided", async () => {
const whichChild = makeFakeChild();
const versionChild = makeFakeChild();
mockSpawn
.mockReturnValueOnce(whichChild.child)
.mockReturnValueOnce(versionChild.child);
const promise = probeHermesBinary({ binaryPath: "/opt/bin/hermes", timeoutMs: 500 });
whichChild.emitClose(1);
await flushAsync();
versionChild.emitStdout("Hermes Agent v2.0.0\n");
versionChild.emitClose(0);
const result = await promise;
expect(result.available).toBe(true);
expect(result.version).toBe("Hermes Agent v2.0.0");
// Verify the version spawn used our custom path.
const versionSpawnArgs = mockSpawn.mock.calls[1]!;
expect(versionSpawnArgs[0]).toBe("/opt/bin/hermes");
expect(versionSpawnArgs[1]).toEqual(["--version"]);
});
});

View File

@@ -1,109 +1,157 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { HermesRuntimeAdapter } from "../runtime-adapter.js";
const {
mockCreateStreamSession,
mockStreamPrompt,
mockDescribeStreamModel,
} = vi.hoisted(() => ({
mockCreateStreamSession: vi.fn(),
mockStreamPrompt: vi.fn(),
mockDescribeStreamModel: vi.fn(),
const { mockInvoke } = vi.hoisted(() => ({
mockInvoke: vi.fn(),
}));
vi.mock("../pi-module.js", () => ({
createStreamSession: mockCreateStreamSession,
streamPrompt: mockStreamPrompt,
describeStreamModel: mockDescribeStreamModel,
}));
vi.mock("../cli-spawn.js", async () => {
const actual = await vi.importActual<typeof import("../cli-spawn.js")>(
"../cli-spawn.js",
);
return {
...actual,
invokeHermesCli: mockInvoke,
};
});
describe("HermesRuntimeAdapter", () => {
beforeEach(() => {
vi.clearAllMocks();
beforeEach(() => {
vi.clearAllMocks();
mockInvoke.mockResolvedValue({
body: "hello from hermes",
sessionId: "20260427_120000_abc123",
});
});
it("has stable runtime identity", () => {
const adapter = new HermesRuntimeAdapter({ provider: "anthropic", modelId: "claude-sonnet-4-5" });
describe("HermesRuntimeAdapter — identity", () => {
it("has stable id/name", () => {
const adapter = new HermesRuntimeAdapter({});
expect(adapter.id).toBe("hermes");
expect(adapter.name).toBe("Hermes Runtime");
});
});
it("createSession passes model config/systemPrompt/callbacks and returns undefined sessionFile", async () => {
const adapter = new HermesRuntimeAdapter({
provider: "openai",
modelId: "gpt-5",
apiKey: "secret",
thinkingLevel: "high",
});
const session = { messages: [], dispose: vi.fn() };
mockCreateStreamSession.mockReturnValue(session);
describe("HermesRuntimeAdapter — createSession", () => {
it("returns a session with empty sessionId and undefined sessionFile", async () => {
const adapter = new HermesRuntimeAdapter({});
const onText = vi.fn();
const onThinking = vi.fn();
const onToolStart = vi.fn();
const onToolEnd = vi.fn();
const result = await adapter.createSession({
cwd: "/tmp/project",
systemPrompt: "You are Hermes",
tools: "coding",
customTools: [{ name: "ignored" }],
sessionManager: { foo: "bar" },
skillSelection: { all: true },
skills: ["bash"],
cwd: "/repo",
systemPrompt: "be helpful",
onText,
onThinking,
onToolStart,
onToolEnd,
});
expect(mockCreateStreamSession).toHaveBeenCalledWith({
provider: "openai",
modelId: "gpt-5",
apiKey: "secret",
thinkingLevel: "high",
systemPrompt: "You are Hermes",
callbacks: {
onText,
onThinking,
onToolStart,
onToolEnd,
},
});
expect(result).toEqual({ session, sessionFile: undefined });
expect(JSON.stringify(mockCreateStreamSession.mock.calls[0][0])).not.toContain("/tmp/project");
expect(JSON.stringify(mockCreateStreamSession.mock.calls[0][0])).not.toContain("coding");
});
it("promptWithFallback appends user message then delegates to streamPrompt", async () => {
const adapter = new HermesRuntimeAdapter({ provider: "anthropic", modelId: "claude-sonnet-4-5" });
const session = { messages: [], dispose: vi.fn() } as any;
await adapter.promptWithFallback(session, "Hello from Hermes");
expect(session.messages).toEqual([{ role: "user", content: "Hello from Hermes" }]);
expect(mockStreamPrompt).toHaveBeenCalledWith(session, {
role: "user",
content: "Hello from Hermes",
});
});
it("describeModel delegates to describeStreamModel", () => {
const adapter = new HermesRuntimeAdapter({ provider: "anthropic", modelId: "claude-sonnet-4-5" });
const session = { messages: [], dispose: vi.fn() } as any;
mockDescribeStreamModel.mockReturnValue("anthropic/claude-sonnet-4-5");
expect(adapter.describeModel(session)).toBe("anthropic/claude-sonnet-4-5");
expect(mockDescribeStreamModel).toHaveBeenCalledWith(session);
});
it("dispose is a no-op when missing and calls dispose when present", async () => {
const adapter = new HermesRuntimeAdapter({ provider: "anthropic", modelId: "claude-sonnet-4-5" });
const dispose = vi.fn();
await expect(adapter.dispose({ messages: [], dispose } as any)).resolves.toBeUndefined();
await expect(adapter.dispose({ messages: [] } as any)).resolves.toBeUndefined();
expect(dispose).toHaveBeenCalledTimes(1);
expect(result.sessionFile).toBeUndefined();
expect(result.session.sessionId).toBe("");
expect(result.session.systemPrompt).toBe("be helpful");
expect(result.session.callbacks.onText).toBe(onText);
});
});
describe("HermesRuntimeAdapter — promptWithFallback", () => {
it("invokes hermes CLI with no resume on first call", async () => {
const adapter = new HermesRuntimeAdapter({ model: "claude-sonnet-4-5" });
const onText = vi.fn();
const { session } = await adapter.createSession({
cwd: "/repo",
systemPrompt: "sys",
onText,
});
await adapter.promptWithFallback(session, "first prompt");
expect(mockInvoke).toHaveBeenCalledTimes(1);
const [prompt, settings, resumeId] = mockInvoke.mock.calls[0];
expect(prompt).toBe("first prompt");
expect(settings.model).toBe("claude-sonnet-4-5");
expect(resumeId).toBeUndefined();
expect(onText).toHaveBeenCalledWith("hello from hermes");
// Session id is captured for next call
expect(session.sessionId).toBe("20260427_120000_abc123");
});
it("passes captured session id as --resume on subsequent calls", async () => {
const adapter = new HermesRuntimeAdapter({});
const { session } = await adapter.createSession({
cwd: "/repo",
systemPrompt: "sys",
});
await adapter.promptWithFallback(session, "p1");
await adapter.promptWithFallback(session, "p2");
const [, , resume2] = mockInvoke.mock.calls[1];
expect(resume2).toBe("20260427_120000_abc123");
});
it("propagates CLI errors", async () => {
mockInvoke.mockRejectedValueOnce(new Error("hermes: missing session_id"));
const adapter = new HermesRuntimeAdapter({});
const { session } = await adapter.createSession({
cwd: "/repo",
systemPrompt: "sys",
});
await expect(adapter.promptWithFallback(session, "p")).rejects.toThrow(
/missing session_id/,
);
});
it("does NOT call onText when body is empty", async () => {
mockInvoke.mockResolvedValueOnce({
body: "",
sessionId: "20260427_120000_def456",
});
const adapter = new HermesRuntimeAdapter({});
const onText = vi.fn();
const { session } = await adapter.createSession({
cwd: "/repo",
systemPrompt: "sys",
onText,
});
await adapter.promptWithFallback(session, "p");
expect(onText).not.toHaveBeenCalled();
});
});
describe("HermesRuntimeAdapter — describeModel", () => {
it("returns hermes/<provider>/<model> when both set", async () => {
const adapter = new HermesRuntimeAdapter({
provider: "anthropic",
model: "claude-sonnet-4-5",
});
const { session } = await adapter.createSession({
cwd: "/repo",
systemPrompt: "sys",
});
expect(adapter.describeModel(session)).toBe(
"hermes/anthropic/claude-sonnet-4-5",
);
});
it("returns hermes/<model> when only model set", async () => {
const adapter = new HermesRuntimeAdapter({ model: "MiniMax-M2.7" });
const { session } = await adapter.createSession({
cwd: "/repo",
systemPrompt: "sys",
});
expect(adapter.describeModel(session)).toBe("hermes/MiniMax-M2.7");
});
it("returns plain 'hermes' when neither set", async () => {
const adapter = new HermesRuntimeAdapter({});
const { session } = await adapter.createSession({
cwd: "/repo",
systemPrompt: "sys",
});
expect(adapter.describeModel(session)).toBe("hermes");
});
});
describe("HermesRuntimeAdapter — dispose", () => {
it("is a no-op", async () => {
const adapter = new HermesRuntimeAdapter({});
const { session } = await adapter.createSession({
cwd: "/repo",
systemPrompt: "sys",
});
await expect(adapter.dispose!(session)).resolves.toBeUndefined();
});
});

View File

@@ -0,0 +1,467 @@
/**
* Hermes CLI spawn module.
*
* Drives the local `hermes` binary as a subprocess instead of using the
* @mariozechner/pi-ai SDK. Session continuity is maintained by capturing
* the `session_id:` line from stdout and passing `--resume <id>` on
* subsequent invocations.
*
* There is NO per-token streaming on this surface — `hermes chat -q`
* buffers output in prompt_toolkit. The full response is delivered in
* one chunk once the process exits.
*/
import { spawn, spawnSync } from "node:child_process";
import { sep as PATH_SEP } from "node:path";
/**
* On Windows, `spawn("hermes", ...)` won't find `hermes.cmd`/`.bat` shims —
* Node doesn't honor PATHEXT. We resolve via `where` (which does) and spawn
* the absolute path instead. No-op on POSIX. Cached per process.
*/
const resolvedBinaryCache = new Map<string, string>();
function resolveBinaryForSpawn(binary: string): string {
if (process.platform !== "win32") return binary;
if (binary.includes(PATH_SEP) || binary.includes("/") || /\.[a-z]{2,4}$/i.test(binary)) {
return binary;
}
const cached = resolvedBinaryCache.get(binary);
if (cached) return cached;
try {
const result = spawnSync("where", [binary], { encoding: "utf-8" });
if (result.status === 0) {
const first = (result.stdout ?? "").trim().split(/\r?\n/)[0];
if (first?.length) {
resolvedBinaryCache.set(binary, first);
return first;
}
}
} catch {
// fall through
}
return binary;
}
/** ANSI escape code stripping regex. */
// eslint-disable-next-line no-control-regex -- ANSI escapes are control chars by definition
const ANSI_RE = /\x1b\[[0-9;]*[A-Za-z]/g;
/** Pattern to locate the session id appended by hermes near end of stdout. */
const SESSION_ID_RE = /^session_id:\s+([0-9]{8}_[0-9]{6}_[0-9a-f]{6})\s*$/m;
/** Lines that are part of the hermes TUI chrome and should be stripped from the body. */
const CHROME_LINE_RES = [
/^\s*┊\s/, // memory/status sidebar lines
/^↻ Resumed session /, // resume banner
/^╭─.*╮\s*$/, // top box border
/^╰─.*╯\s*$/, // bottom box border
/^Query:\s*/, // query echo line
];
// ── Profile listing ───────────────────────────────────────────────────────────
/**
* Summary of a single Hermes profile as returned by `hermes profile list`.
*/
export interface HermesProfileSummary {
/** Profile name, e.g. "default". */
name: string;
/** Model configured for this profile, if any. */
model?: string;
/** Gateway status string, e.g. "stopped". */
gateway?: string;
/** Alias wrapper script name, if set. */
alias?: string;
/** True when this profile has the `◆` sticky-default marker. */
isDefault: boolean;
}
/** Regex matching lines made entirely of `─` box-drawing chars (the rule row). */
const PROFILE_RULE_RE = /^[\s─]+$/;
/** Em-dash placeholder used by `hermes profile list` for empty values. */
const EM_DASH = "—";
/**
* Parse the stdout of `hermes profile list` into an array of profile summaries.
*
* The output looks like:
*
* ```
* Profile Model Gateway Alias
* ─────────────── ───────────────────────── ─────────── ────────────
* ◆default MiniMax-M2.7 stopped —
* ```
*
* Columns are whitespace-aligned (variable-width). The function splits on runs
* of two-or-more spaces to handle variable column widths robustly.
*/
function parseProfileListOutput(raw: string): HermesProfileSummary[] {
const lines = raw.replace(ANSI_RE, "").replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
const profiles: HermesProfileSummary[] = [];
for (const line of lines) {
// Skip blank lines.
if (line.trim() === "") continue;
// Skip the header line (starts with " Profile").
if (/^\s*Profile\b/.test(line)) continue;
// Skip rule lines (all box-drawing dashes / spaces).
if (PROFILE_RULE_RE.test(line)) continue;
// Split on 2+ spaces to separate columns. Trim leading space first.
const stripped = line.replace(/^\s+/, "");
const columns = stripped.split(/\s{2,}/);
const rawName = columns[0] ?? "";
const isDefault = rawName.startsWith("◆");
const name = rawName.replace(/^◆/, "").trim();
if (!name) continue;
const toUndef = (v: string | undefined): string | undefined =>
v === undefined || v.trim() === "" || v.trim() === EM_DASH ? undefined : v.trim();
profiles.push({
name,
model: toUndef(columns[1]),
gateway: toUndef(columns[2]),
alias: toUndef(columns[3]),
isDefault,
});
}
return profiles;
}
/**
* Resolve the HERMES_HOME directory for a named profile.
*
* Mirrors hermes's own logic:
* - "default" → ~/.hermes
* - other name → ~/.hermes/profiles/<name>
*
* This is used to set HERMES_HOME when spawning hermes with a specific profile.
*/
function hermesProfileHome(profileName: string): string {
const base = process.env.HERMES_HOME ?? `${process.env.HOME ?? "~"}/.hermes`;
if (profileName === "default" || profileName === "") return base;
return `${base}/profiles/${profileName}`;
}
/**
* List all Hermes profiles by running `hermes profile list`.
*
* @param opts.binaryPath - Path to the hermes binary (default: "hermes").
* @param opts.timeoutMs - Maximum wait time in ms (default: 5000).
* @returns Array of profile summaries, ordered as hermes returns them.
* @throws When hermes is not found (ENOENT) or exits non-zero.
*/
export async function listHermesProfiles(opts?: {
binaryPath?: string;
timeoutMs?: number;
}): Promise<HermesProfileSummary[]> {
const binary = resolveBinaryForSpawn(opts?.binaryPath ?? "hermes");
const timeoutMs = opts?.timeoutMs ?? 5_000;
return new Promise<HermesProfileSummary[]>((resolve, reject) => {
let settled = false;
const child = spawn(binary, ["profile", "list"], {
stdio: ["ignore", "pipe", "pipe"],
env: { ...process.env },
});
const timer = setTimeout(() => {
if (settled) return;
settled = true;
try { child.kill("SIGKILL"); } catch { /* already gone */ }
reject(new Error(`hermes profile list failed: timed out after ${timeoutMs}ms`));
}, timeoutMs);
let stdout = "";
let stderr = "";
child.stdout?.on("data", (chunk: Buffer) => { stdout += chunk.toString("utf-8"); });
child.stderr?.on("data", (chunk: Buffer) => { stderr += chunk.toString("utf-8"); });
child.on("error", (err: NodeJS.ErrnoException) => {
if (settled) return;
settled = true;
clearTimeout(timer);
const isNotFound = err.code === "ENOENT";
reject(
new Error(
isNotFound
? `hermes profile list failed: binary not found at "${opts?.binaryPath ?? "hermes"}"`
: `hermes profile list failed: ${err.message}`,
),
);
});
child.on("close", (code: number | null) => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (code !== 0) {
const combined = [stdout, stderr].filter(Boolean).join("\n");
reject(new Error(`hermes profile list failed: process exited with code ${String(code)}.\n${combined}`));
return;
}
resolve(parseProfileListOutput(stdout));
});
});
}
// ── CLI settings + invocation ─────────────────────────────────────────────────
/**
* Settings resolved from plugin ctx.settings + env-var fallbacks.
*/
export interface HermesCliSettings {
/** Path to the hermes binary. Default: "hermes" (rely on PATH). */
binaryPath: string;
/** Model identifier, e.g. "claude-sonnet-4-5". */
model?: string;
/** Provider identifier, e.g. "anthropic". */
provider?: string;
/** Maximum agent turns per invocation. Default: 12. */
maxTurns: number;
/** Pass --yolo to hermes (skip confirmations). Default: false. */
yolo: boolean;
/** Hard kill timeout in milliseconds. Default: 300000 (5 min). */
cliTimeoutMs: number;
/**
* Hermes profile name to activate when spawning the CLI.
* Implemented by setting HERMES_HOME to the profile directory in the
* subprocess environment — hermes has no `--profile` CLI flag on `chat`.
* Empty string / undefined = use the current sticky-default profile.
*/
profile?: string;
}
/** Result of a single hermes CLI invocation. */
export interface HermesCliResult {
/** Parsed assistant response text. */
body: string;
/** The session id captured from stdout (used for --resume on next call). */
sessionId: string;
}
/**
* Resolve HermesCliSettings from a plugin settings record and environment
* variable fallbacks.
*/
export function resolveCliSettings(settings?: Record<string, unknown>): HermesCliSettings {
const str = (v: unknown): string | undefined =>
typeof v === "string" && v.trim().length > 0 ? v.trim() : undefined;
const num = (v: unknown, envKey: string, fallback: number): number => {
// Accept numeric values directly.
if (typeof v === "number" && Number.isFinite(v) && v > 0) return v;
const raw = str(v) ?? str(process.env[envKey]);
if (raw !== undefined) {
const parsed = Number(raw);
if (Number.isFinite(parsed) && parsed > 0) return parsed;
}
return fallback;
};
const bool = (v: unknown, envKey: string, fallback: boolean): boolean => {
if (typeof v === "boolean") return v;
const raw = str(v) ?? str(process.env[envKey]);
if (raw !== undefined) return raw === "1" || raw.toLowerCase() === "true";
return fallback;
};
return {
binaryPath: str(settings?.binaryPath) ?? str(process.env.HERMES_BIN) ?? "hermes",
model: str(settings?.model) ?? str(process.env.HERMES_MODEL_ID),
provider: str(settings?.provider) ?? str(process.env.HERMES_PROVIDER),
maxTurns: num(settings?.maxTurns, "HERMES_MAX_TURNS", 12),
yolo: bool(settings?.yolo, "HERMES_YOLO", false),
cliTimeoutMs: num(settings?.cliTimeoutMs, "HERMES_CLI_TIMEOUT_MS", 300_000),
profile: str(settings?.profile) ?? str(process.env.HERMES_PROFILE),
};
}
/**
* Strip ANSI escape codes and normalize CRLF to LF.
*/
function cleanText(raw: string): string {
return raw.replace(ANSI_RE, "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
}
/**
* Remove hermes TUI chrome lines from the captured body.
*/
function stripChrome(text: string): string {
const lines = text.split("\n");
const filtered = lines.filter((line) => !CHROME_LINE_RES.some((re) => re.test(line)));
return filtered.join("\n").trim();
}
/**
* Parse the raw stdout from `hermes chat -q ... -Q`.
*
* Returns `{ body, sessionId }` on success or throws with a descriptive error.
*/
export function parseHermesOutput(rawStdout: string, rawStderr: string): HermesCliResult {
const cleaned = cleanText(rawStdout);
const match = SESSION_ID_RE.exec(cleaned);
if (!match) {
const combined = [rawStdout, rawStderr].filter(Boolean).join("\n");
throw new Error(`hermes: missing session_id in output.\n${combined}`);
}
const sessionId = match[1]!;
// Body is everything before the session_id line.
const sessionIdLineStart = cleaned.lastIndexOf("\nsession_id:");
const bodyRaw = sessionIdLineStart >= 0 ? cleaned.slice(0, sessionIdLineStart) : cleaned;
const body = stripChrome(bodyRaw);
return { body, sessionId };
}
/**
* Build the argv array for a `hermes chat` invocation.
*/
export function buildHermesArgs(
prompt: string,
settings: HermesCliSettings,
resumeSessionId?: string,
): string[] {
const args: string[] = ["chat", "-q", prompt, "-Q", "--source", "tool"];
if (resumeSessionId) {
args.push("--resume", resumeSessionId);
}
if (settings.model) {
args.push("-m", settings.model);
}
if (settings.provider) {
args.push("--provider", settings.provider);
}
args.push("--max-turns", String(settings.maxTurns));
if (settings.yolo) {
args.push("--yolo");
}
return args;
}
/**
* Invoke the hermes CLI for a single prompt/response turn.
*
* @param prompt - The user prompt to send.
* @param settings - Resolved CLI settings.
* @param resumeSessionId - Hermes session id from a prior call, if continuing.
* @param signal - Optional AbortSignal; will SIGTERM the subprocess on abort.
* @returns Parsed response body and the new/existing session id.
*/
export async function invokeHermesCli(
prompt: string,
settings: HermesCliSettings,
resumeSessionId?: string,
signal?: AbortSignal,
): Promise<HermesCliResult> {
const args = buildHermesArgs(prompt, settings, resumeSessionId);
const binary = resolveBinaryForSpawn(settings.binaryPath);
return new Promise<HermesCliResult>((resolve, reject) => {
let settled = false;
const spawnEnv: NodeJS.ProcessEnv = { ...process.env, PYTHONUNBUFFERED: "1" };
if (settings.profile) {
spawnEnv.HERMES_HOME = hermesProfileHome(settings.profile);
}
const child = spawn(binary, args, {
stdio: ["ignore", "pipe", "pipe"],
env: spawnEnv,
});
const hardKillTimer = setTimeout(() => {
if (settled) return;
settled = true;
try {
child.kill("SIGKILL");
} catch {
// already gone
}
reject(new Error(`hermes: process timed out after ${settings.cliTimeoutMs}ms`));
}, settings.cliTimeoutMs);
// Forward AbortSignal.
const onAbort = (): void => {
if (settled) return;
settled = true;
clearTimeout(hardKillTimer);
try {
child.kill("SIGTERM");
} catch {
// already gone
}
reject(new Error("hermes: invocation aborted"));
};
if (signal) {
if (signal.aborted) {
onAbort();
return;
}
signal.addEventListener("abort", onAbort, { once: true });
}
let stdout = "";
let stderr = "";
child.stdout?.on("data", (chunk: Buffer) => {
stdout += chunk.toString("utf-8");
});
child.stderr?.on("data", (chunk: Buffer) => {
stderr += chunk.toString("utf-8");
});
child.on("error", (err: NodeJS.ErrnoException) => {
if (settled) return;
settled = true;
clearTimeout(hardKillTimer);
signal?.removeEventListener("abort", onAbort);
const isNotFound = err.code === "ENOENT";
reject(
new Error(
isNotFound
? `hermes: binary not found at "${settings.binaryPath}". Install hermes or set binaryPath/HERMES_BIN.`
: `hermes: spawn error — ${err.message}`,
),
);
});
child.on("close", (code: number | null) => {
if (settled) return;
settled = true;
clearTimeout(hardKillTimer);
signal?.removeEventListener("abort", onAbort);
if (code !== 0) {
const combined = [stdout, stderr].filter(Boolean).join("\n");
reject(new Error(`hermes: process exited with code ${String(code)}.\n${combined}`));
return;
}
try {
resolve(parseHermesOutput(stdout, stderr));
} catch (parseErr) {
reject(parseErr);
}
});
});
}

View File

@@ -1,12 +1,14 @@
/**
* Hermes Runtime Plugin
*
* Provides an executable Hermes runtime adapter for Fusion's plugin runtime
* discovery and session execution pipeline.
* Provides an executable Hermes runtime adapter that drives the local `hermes`
* CLI as a subprocess. Discovered by Fusion's plugin runtime registry; the
* settings configured in the dashboard's "Runtimes → Hermes" page flow through
* `ctx.settings` into the CLI invocation.
*/
import { definePlugin } from "@fusion/plugin-sdk";
import { resolveModelConfig } from "./pi-module.js";
import { resolveCliSettings } from "./cli-spawn.js";
import { HermesRuntimeAdapter } from "./runtime-adapter.js";
import type {
FusionPlugin,
@@ -17,26 +19,19 @@ import type {
// ── Hermes Runtime Metadata ───────────────────────────────────────────────────
const HERMES_RUNTIME_ID = "hermes";
const HERMES_RUNTIME_VERSION = "0.1.0";
const HERMES_RUNTIME_VERSION = "0.2.0";
const hermesRuntimeMetadata: PluginRuntimeManifestMetadata = {
runtimeId: HERMES_RUNTIME_ID,
name: "Hermes Runtime",
description: "Hermes raw-model runtime using pi-ai direct streaming",
description: "Drives the local `hermes` CLI (NousResearch/hermes-agent)",
version: HERMES_RUNTIME_VERSION,
};
// ── Hermes Runtime Factory ────────────────────────────────────────────────────
const hermesRuntimeFactory: PluginRuntimeFactory = async (ctx) => {
const config = resolveModelConfig(ctx.settings);
return new HermesRuntimeAdapter({
provider: config.provider,
modelId: config.modelId,
apiKey: config.apiKey,
thinkingLevel: config.thinkingLevel,
});
return new HermesRuntimeAdapter(ctx.settings as Record<string, unknown> | undefined);
};
// ── Plugin Definition ─────────────────────────────────────────────────────────
@@ -45,24 +40,27 @@ const plugin: FusionPlugin = definePlugin({
manifest: {
id: "fusion-plugin-hermes-runtime",
name: "Hermes Runtime Plugin",
version: "0.1.0",
description: "Hermes AI runtime plugin for Fusion - provides AI agent execution runtime capabilities",
version: HERMES_RUNTIME_VERSION,
description:
"Drives the local `hermes` CLI for Fusion agents — captures session ids and resumes via --resume.",
author: "Fusion Team",
homepage: "https://github.com/gsxdsm/fusion",
homepage: "https://github.com/NousResearch/hermes-agent",
runtime: hermesRuntimeMetadata,
},
state: "installed",
hooks: {
onLoad: (ctx) => {
const config = resolveModelConfig(ctx.settings);
ctx.logger.info(`Hermes Runtime Plugin loaded — using ${config.provider}/${config.modelId}`);
const settings = resolveCliSettings(ctx.settings);
ctx.logger.info(
`Hermes Runtime Plugin loaded — binary=${settings.binaryPath} model=${settings.model ?? "(default)"}`,
);
ctx.emitEvent("hermes-runtime:loaded", {
runtimeId: HERMES_RUNTIME_ID,
version: HERMES_RUNTIME_VERSION,
});
},
onUnload: () => {
// No context available during unload
// No persistent state to clean up — each prompt spawns a fresh subprocess.
},
},
runtime: {
@@ -73,6 +71,19 @@ const plugin: FusionPlugin = definePlugin({
export default plugin;
// ── Exports for Testing ───────────────────────────────────────────────────────
// ── Public exports ────────────────────────────────────────────────────────────
export { hermesRuntimeMetadata, hermesRuntimeFactory, HERMES_RUNTIME_ID };
export { HermesRuntimeAdapter } from "./runtime-adapter.js";
export {
resolveCliSettings,
invokeHermesCli,
buildHermesArgs,
parseHermesOutput,
listHermesProfiles,
} from "./cli-spawn.js";
export type { HermesCliSettings, HermesCliResult, HermesProfileSummary } from "./cli-spawn.js";
// Probe re-export for the dashboard's runtime-provider-probes façade.
export { probeHermesBinary } from "./probe.js";
export type { HermesBinaryStatus } from "./probe.js";

View File

@@ -1,134 +0,0 @@
import { randomUUID } from "node:crypto";
import {
getModel,
streamSimple,
type Api,
type AssistantMessageEvent,
type Context,
type Message,
type Model,
type SimpleStreamOptions,
type ThinkingLevel,
} from "@mariozechner/pi-ai";
import type {
HermesCallbacks,
HermesStreamSession,
ResolvedModelConfig,
} from "./types.js";
const DEFAULT_PROVIDER = "anthropic";
const DEFAULT_MODEL_ID = "claude-sonnet-4-5";
function resolveStringSetting(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
}
export function resolveModelConfig(settings?: Record<string, unknown>): ResolvedModelConfig {
const provider =
resolveStringSetting(settings?.provider) ?? resolveStringSetting(process.env.HERMES_PROVIDER) ?? DEFAULT_PROVIDER;
const modelId =
resolveStringSetting(settings?.modelId) ?? resolveStringSetting(process.env.HERMES_MODEL_ID) ?? DEFAULT_MODEL_ID;
const apiKey = resolveStringSetting(settings?.apiKey) ?? resolveStringSetting(process.env.HERMES_API_KEY);
const thinkingLevel =
resolveStringSetting(settings?.thinkingLevel) ?? resolveStringSetting(process.env.HERMES_THINKING_LEVEL) ?? undefined;
return { provider, modelId, apiKey, thinkingLevel };
}
export function createStreamSession(options: {
provider: string;
modelId: string;
apiKey?: string;
thinkingLevel?: string;
systemPrompt: string;
callbacks?: HermesCallbacks;
}): HermesStreamSession {
const model = getModel(options.provider as never, options.modelId as never) as Model<Api>;
return {
model,
systemPrompt: options.systemPrompt,
messages: [],
apiKey: options.apiKey,
thinkingLevel: options.thinkingLevel,
sessionId: randomUUID(),
lastModelDescription: `${model.provider}/${model.id}`,
callbacks: options.callbacks ?? {},
usage: undefined,
dispose: () => undefined,
};
}
export async function streamPrompt(session: HermesStreamSession, _userMessage: Message): Promise<void> {
const context: Context = {
systemPrompt: session.systemPrompt,
messages: [...(session.messages as Message[])],
};
const options: SimpleStreamOptions = {
sessionId: session.sessionId,
};
if (session.apiKey) {
options.apiKey = session.apiKey;
}
if (session.thinkingLevel) {
options.reasoning = session.thinkingLevel as ThinkingLevel;
}
const stream = streamSimple(session.model as Model<Api>, context, options);
let fullText = "";
for await (const event of stream) {
handleStreamEvent(event, session, (delta) => {
fullText += delta;
});
}
const finalMessage = await stream.result();
const responseText =
finalMessage.content
.filter((content) => content.type === "text")
.map((content) => content.text)
.join("") || fullText;
session.messages.push({ role: "assistant", content: responseText });
session.lastModelDescription = `${(session.model as Model<Api>).provider}/${(session.model as Model<Api>).id}`;
}
function handleStreamEvent(
event: AssistantMessageEvent,
session: HermesStreamSession,
onTextDelta: (delta: string) => void,
): void {
if (event.type === "text_delta") {
session.callbacks.onText?.(event.delta);
onTextDelta(event.delta);
return;
}
if (event.type === "thinking_delta") {
session.callbacks.onThinking?.(event.delta);
return;
}
if (event.type === "toolcall_end") {
session.callbacks.onToolStart?.(event.toolCall.name, event.toolCall.arguments);
session.callbacks.onToolEnd?.(event.toolCall.name, false, event.toolCall.arguments);
return;
}
if (event.type === "error") {
const errorMessage = event.error.errorMessage ?? "Hermes stream failed";
throw new Error(errorMessage);
}
if (event.type === "done") {
session.usage = event.message.usage;
}
}
export function describeStreamModel(session: HermesStreamSession): string {
return session.lastModelDescription;
}

View File

@@ -0,0 +1,146 @@
/**
* Hermes binary probe helper.
*
* Mirrors the probeClaudeCli pattern from packages/dashboard/src/claude-cli-probe.ts.
* Never throws — all failures are captured as `available: false` with a reason.
*/
import { spawn } from "node:child_process";
/** Default probe timeout in milliseconds. */
const DEFAULT_PROBE_TIMEOUT_MS = 2000;
/**
* Result of probing for the hermes binary.
*/
export interface HermesBinaryStatus {
/** True if the binary was found and ran to completion successfully. */
available: boolean;
/** Absolute path resolved via `which`/`where`, if found. */
binaryPath?: string;
/** Version string from `hermes --version` stdout, if available. */
version?: string;
/** Human-readable failure reason when `available === false`. */
reason?: string;
/** Wall-clock duration of the probe in milliseconds. */
probeDurationMs: number;
}
/**
* Probe for the hermes binary.
*
* Runs `<binaryPath> --version` with a short timeout. Use this from
* the dashboard status endpoint to check binary presence without crashing.
*
* @param opts.binaryPath - Override the binary path (default: "hermes").
* @param opts.timeoutMs - Override probe timeout in ms (default: 2000).
*/
export async function probeHermesBinary(opts?: {
binaryPath?: string;
timeoutMs?: number;
}): Promise<HermesBinaryStatus> {
const startedAt = Date.now();
const binary =
typeof opts?.binaryPath === "string" && opts.binaryPath.trim().length > 0
? opts.binaryPath.trim()
: "hermes";
const timeoutMs = opts?.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;
const resolvedPath = await tryResolveBinaryPath(binary);
return new Promise<HermesBinaryStatus>((resolvePromise) => {
const finish = (result: Omit<HermesBinaryStatus, "probeDurationMs">): void => {
resolvePromise({ ...result, probeDurationMs: Date.now() - startedAt });
};
let settled = false;
const child = spawn(resolvedPath ?? binary, ["--version"], {
stdio: ["ignore", "pipe", "pipe"],
});
const timer = setTimeout(() => {
if (settled) return;
settled = true;
try {
child.kill("SIGKILL");
} catch {
// Process already gone.
}
finish({
available: false,
binaryPath: resolvedPath,
reason: `Probe timed out after ${timeoutMs}ms`,
});
}, timeoutMs);
let stdout = "";
let stderr = "";
child.stdout?.on("data", (chunk: Buffer) => {
stdout += chunk.toString("utf-8");
});
child.stderr?.on("data", (chunk: Buffer) => {
stderr += chunk.toString("utf-8");
});
child.on("error", (err: NodeJS.ErrnoException) => {
if (settled) return;
settled = true;
clearTimeout(timer);
const isNotFound = err.code === "ENOENT";
finish({
available: false,
binaryPath: resolvedPath,
reason: isNotFound
? `\`${binary}\` not found on PATH`
: err.message,
});
});
child.on("close", (code: number | null) => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (code === 0) {
finish({
available: true,
version: stdout.trim() || undefined,
binaryPath: resolvedPath,
});
} else {
finish({
available: false,
binaryPath: resolvedPath,
reason:
stderr.trim() || `hermes --version exited with code ${String(code)}`,
});
}
});
});
}
/**
* Best-effort path resolution via `which` (POSIX) or `where` (Windows).
* Returns undefined on failure — the spawn above is the actual authority.
*/
async function tryResolveBinaryPath(binary: string): Promise<string | undefined> {
return new Promise((resolvePromise) => {
const which = process.platform === "win32" ? "where" : "which";
const child = spawn(which, [binary], { stdio: ["ignore", "pipe", "ignore"] });
let out = "";
child.stdout?.on("data", (chunk: Buffer) => {
out += chunk.toString("utf-8");
});
child.on("error", () => resolvePromise(undefined));
child.on("close", (code: number | null) => {
if (code === 0) {
const first = out.trim().split(/\r?\n/)[0];
resolvePromise(first?.length ? first : undefined);
} else {
resolvePromise(undefined);
}
});
});
}

View File

@@ -1,57 +1,84 @@
/**
* Hermes Runtime Adapter — drives the local `hermes` CLI as a subprocess.
*
* Each call to `promptWithFallback` invokes `hermes chat -q ... -Q --source tool`
* and captures the resulting `session_id:` line. Subsequent calls on the same
* session pass `--resume <id>` to continue the conversation.
*/
import { invokeHermesCli, resolveCliSettings } from "./cli-spawn.js";
import type { HermesCliSettings } from "./cli-spawn.js";
import type {
AgentRuntime,
AgentRuntimeOptions,
AgentSession,
AgentSessionResult,
HermesModelConfig,
HermesStreamSession,
} from "./types.js";
import { createStreamSession, describeStreamModel, streamPrompt } from "./pi-module.js";
export class HermesRuntimeAdapter implements AgentRuntime {
readonly id = "hermes";
readonly name = "Hermes Runtime";
constructor(
private readonly config: HermesModelConfig = {
provider: "anthropic",
modelId: "claude-sonnet-4-5",
},
) {}
private readonly settings: HermesCliSettings;
constructor(settings?: Record<string, unknown> | HermesCliSettings) {
this.settings = resolveCliSettings(
settings as Record<string, unknown> | undefined,
);
}
async createSession(options: AgentRuntimeOptions): Promise<AgentSessionResult> {
const session = createStreamSession({
provider: this.config.provider,
modelId: this.config.modelId,
apiKey: this.config.apiKey,
thinkingLevel: this.config.thinkingLevel,
const session: HermesStreamSession = {
model: undefined,
systemPrompt: options.systemPrompt,
messages: [],
apiKey: undefined,
thinkingLevel: undefined,
sessionId: "",
lastModelDescription: this.describeFromSettings(),
callbacks: {
onText: options.onText,
onThinking: options.onThinking,
onToolStart: options.onToolStart,
onToolEnd: options.onToolEnd,
},
});
return {
session,
sessionFile: undefined,
dispose: () => undefined,
};
return { session, sessionFile: undefined };
}
async promptWithFallback(session: AgentSession, prompt: string, _options?: unknown): Promise<void> {
const userMessage = { role: "user", content: prompt };
session.messages.push(userMessage);
await streamPrompt(session, userMessage as any);
async promptWithFallback(
session: AgentSession,
prompt: string,
_options?: unknown,
): Promise<void> {
const resumeId = session.sessionId || undefined;
const result = await invokeHermesCli(prompt, this.settings, resumeId);
session.sessionId = result.sessionId;
session.lastModelDescription = this.describeFromSettings();
if (result.body) {
session.callbacks.onText?.(result.body);
}
}
describeModel(session: AgentSession): string {
return describeStreamModel(session);
return session.lastModelDescription || this.describeFromSettings();
}
async dispose(session: AgentSession): Promise<void> {
if (typeof session.dispose === "function") {
session.dispose();
}
async dispose(_session: AgentSession): Promise<void> {
// No persistent resources to release — the hermes CLI process exits per turn.
}
private describeFromSettings(): string {
const provider = this.settings.provider;
const model = this.settings.model;
if (provider && model) return `hermes/${provider}/${model}`;
if (model) return `hermes/${model}`;
if (provider) return `hermes/${provider}`;
return "hermes";
}
}