fix(dashboard): write ChatView visualViewport vars imperatively

The mobile composer/footer slid over the message list when the user
swiped with the keyboard up. Cause: --vv-height / --vv-offset-top were
routed through React state via useMobileKeyboard, so on iOS — which
fires visualViewport scroll/resize on the same frame as its keyboard
animation — the .chat-thread translation lagged by one paint, visible
as the composer momentarily floating over messages.

Now those two vars are written imperatively in a useLayoutEffect
directly to the .chat-thread DOM node on every visualViewport event,
mirroring the working pattern at QuickChatFAB.tsx:1032-1052 (which
already works correctly on mobile). Only --keyboard-overlap (a
structural open/close signal, not per-frame) still flows through
React state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-05-06 20:25:38 -07:00
parent bcfb4a3f62
commit 72691c6fd6
46 changed files with 1901 additions and 35 deletions

View File

@@ -29,6 +29,21 @@ After install, run `hermes login` (or `hermes auth`) to configure a provider. Th
Verify with `hermes --version`.
## Fusion skill auto-install
When the Hermes runtime plugin loads, it attempts to auto-install/mirror Fusion's bundled `fusion` skill into the active Hermes profile skill directory:
- default profile: `${HERMES_HOME:-~/.hermes}/skills/fusion`
- named profile: `${HERMES_HOME:-~/.hermes}/profiles/<profile>/skills/fusion`
The installer is idempotent and self-healing:
- leaves an already-correct install untouched
- replaces prior Fusion installs it can positively identify
- avoids replacing unrelated user-managed directories
If the bundled Fusion skill source is missing or filesystem writes fail, the plugin logs a warning and still starts the Hermes runtime.
## Limitations
Because we drive the CLI's `chat -q` mode:

View File

@@ -0,0 +1,2 @@
export {};
//# sourceMappingURL=cli-spawn.test.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"cli-spawn.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/cli-spawn.test.ts"],"names":[],"mappings":""}

View File

@@ -0,0 +1,353 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
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";
// ── Helpers ────────────────────────────────────────────────────────────────
function defaultSettings(overrides = {}) {
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() {
const main = new EventEmitter();
const stdoutEmitter = new EventEmitter();
const stderrEmitter = new EventEmitter();
main.stdout = stdoutEmitter;
main.stderr = stderrEmitter;
const kill = vi.fn().mockReturnValue(true);
main.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, sessionId = "20260427_120000_abcd12") {
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 = 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 = 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"]);
});
});
//# sourceMappingURL=cli-spawn.test.js.map

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,2 @@
export {};
//# sourceMappingURL=fusion-skill-install.test.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"fusion-skill-install.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/fusion-skill-install.test.ts"],"names":[],"mappings":""}

View File

@@ -0,0 +1,93 @@
import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { getFusionSkillSourceCandidates, installFusionSkillIntoHermesHome, resolveBundledFusionSkillSourceFromCandidates, } from "../fusion-skill-install.js";
const tempDirs = [];
function tempDir(prefix) {
const dir = mkdtempSync(path.join(os.tmpdir(), prefix));
tempDirs.push(dir);
return dir;
}
function makeSkillSource(dir) {
mkdirSync(dir, { recursive: true });
writeFileSync(path.join(dir, "SKILL.md"), "# Fusion Skill\n");
}
afterEach(() => {
delete process.env.HERMES_HOME;
for (const dir of tempDirs.splice(0))
rmSync(dir, { recursive: true, force: true });
});
describe("fusion skill installer", () => {
it("resolves workspace and packaged candidate layouts", () => {
const workspaceCandidates = getFusionSkillSourceCandidates("file:///repo/plugins/fusion-plugin-hermes-runtime/src/index.ts");
expect(workspaceCandidates[0].replace(/\\/g, "/")).toMatch(/\/packages\/cli\/skill\/fusion$/);
const packagedCandidates = getFusionSkillSourceCandidates("file:///repo/packages/cli/dist/plugins/fusion-plugin-hermes-runtime/bundled.js");
expect(packagedCandidates.some((candidate) => candidate.replace(/\\/g, "/").endsWith("/packages/cli/dist/skill/fusion"))).toBe(true);
});
it("installs skill for configured Hermes home", () => {
const home = tempDir("hermes-home-");
const source = path.join(tempDir("fusion-source-"), "fusion");
makeSkillSource(source);
process.env.HERMES_HOME = home;
const result = installFusionSkillIntoHermesHome({ sourceDir: source });
expect(result.outcome).toBe("installed");
expect(result.targetDir).toBe(path.join(home, "skills", "fusion"));
});
it("no-ops when already installed to same source", () => {
const home = tempDir("hermes-home-");
const source = path.join(tempDir("fusion-source-"), "fusion");
makeSkillSource(source);
process.env.HERMES_HOME = home;
expect(installFusionSkillIntoHermesHome({ sourceDir: source }).outcome).toBe("installed");
expect(installFusionSkillIntoHermesHome({ sourceDir: source }).outcome).toBe("already-installed");
});
it("replaces prior fusion directory install safely", () => {
const home = tempDir("hermes-home-");
const source = path.join(tempDir("fusion-source-"), "fusion");
makeSkillSource(source);
process.env.HERMES_HOME = home;
const target = path.join(home, "skills", "fusion");
makeSkillSource(target);
writeFileSync(path.join(target, "old.txt"), "stale");
const result = installFusionSkillIntoHermesHome({ sourceDir: source });
expect(result.outcome).toBe("replaced");
});
it("skips replacement for unrelated existing directory", () => {
const home = tempDir("hermes-home-");
const source = path.join(tempDir("fusion-source-"), "fusion");
makeSkillSource(source);
process.env.HERMES_HOME = home;
const target = path.join(home, "skills", "fusion");
mkdirSync(target, { recursive: true });
writeFileSync(path.join(target, "README.md"), "my custom skill");
const result = installFusionSkillIntoHermesHome({ sourceDir: source });
expect(result.outcome).toBe("skipped");
});
it("warns when source is missing", () => {
const home = tempDir("hermes-home-");
process.env.HERMES_HOME = home;
const result = installFusionSkillIntoHermesHome({ sourceDir: null });
expect(result.outcome).toBe("warning");
});
it("can resolve first existing candidate", () => {
const source = path.join(tempDir("fusion-source-"), "fusion");
makeSkillSource(source);
const resolved = resolveBundledFusionSkillSourceFromCandidates(["/missing", source]);
expect(resolved).toBe(source);
});
it("replaces stale fusion symlink", () => {
const home = tempDir("hermes-home-");
const source = path.join(tempDir("fusion-source-"), "fusion");
const oldSource = path.join(tempDir("fusion-old-source-"), "fusion");
makeSkillSource(source);
makeSkillSource(oldSource);
process.env.HERMES_HOME = home;
const target = path.join(home, "skills", "fusion");
mkdirSync(path.dirname(target), { recursive: true });
symlinkSync(oldSource, target, "dir");
const result = installFusionSkillIntoHermesHome({ sourceDir: source });
expect(result.outcome).toBe("replaced");
});
});
//# sourceMappingURL=fusion-skill-install.test.js.map

File diff suppressed because one or more lines are too long

View File

@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { mockResolveCli } = vi.hoisted(() => ({
const { mockResolveCli, mockInstallFusionSkill } = vi.hoisted(() => ({
mockResolveCli: vi.fn().mockReturnValue({
binaryPath: "hermes",
model: undefined,
@@ -8,6 +8,11 @@ const { mockResolveCli } = vi.hoisted(() => ({
yolo: false,
cliTimeoutMs: 300_000,
}),
mockInstallFusionSkill: vi.fn().mockReturnValue({
outcome: "installed",
sourceDir: "/tmp/source",
targetDir: "/tmp/target",
}),
}));
vi.mock("../cli-spawn.js", async () => {
const actual = await vi.importActual("../cli-spawn.js");
@@ -16,7 +21,14 @@ vi.mock("../cli-spawn.js", async () => {
resolveCliSettings: mockResolveCli,
};
});
import plugin, { hermesRuntimeMetadata, hermesRuntimeFactory, HERMES_RUNTIME_ID, } from "../index.js";
vi.mock("../fusion-skill-install.js", async () => {
const actual = await vi.importActual("../fusion-skill-install.js");
return {
...actual,
installFusionSkillIntoHermesHome: mockInstallFusionSkill,
};
});
import plugin, { HERMES_RUNTIME_ID, hermesRuntimeFactory, hermesRuntimeMetadata } from "../index.js";
import { HermesRuntimeAdapter } from "../runtime-adapter.js";
function createMockContext(settings = {}) {
return {
@@ -37,6 +49,12 @@ describe("hermes-runtime plugin", () => {
maxTurns: 12,
yolo: false,
cliTimeoutMs: 300_000,
profile: undefined,
});
mockInstallFusionSkill.mockReturnValue({
outcome: "installed",
sourceDir: "/tmp/source",
targetDir: "/tmp/target",
});
});
it("has expected manifest identity", () => {
@@ -59,11 +77,28 @@ describe("hermes-runtime plugin", () => {
maxTurns: 12,
yolo: false,
cliTimeoutMs: 300_000,
profile: undefined,
});
const ctx = createMockContext({ binaryPath: "/opt/homebrew/bin/hermes" });
await plugin.hooks.onLoad(ctx);
expect(mockResolveCli).toHaveBeenCalledWith(ctx.settings);
expect(ctx.logger.info).toHaveBeenCalledWith(expect.stringContaining("/opt/homebrew/bin/hermes"));
expect(mockInstallFusionSkill).toHaveBeenCalledWith({ profile: undefined });
expect(ctx.logger.info).toHaveBeenCalledWith(expect.stringContaining("fusionSkill=installed"));
expect(ctx.emitEvent).toHaveBeenCalledWith("hermes-runtime:loaded", {
runtimeId: "hermes",
version: plugin.manifest.version,
});
});
it("onLoad warns but continues when skill install warns", async () => {
mockInstallFusionSkill.mockReturnValue({
outcome: "warning",
sourceDir: null,
targetDir: "/tmp/target",
reason: "missing skill source",
});
const ctx = createMockContext();
await plugin.hooks.onLoad(ctx);
expect(ctx.logger.warn).toHaveBeenCalledWith(expect.stringContaining("auto-install warning"));
expect(ctx.emitEvent).toHaveBeenCalledWith("hermes-runtime:loaded", {
runtimeId: "hermes",
version: plugin.manifest.version,

View File

@@ -1 +1 @@
{"version":3,"file":"index.test.js","sourceRoot":"","sources":["../../src/__tests__/index.test.ts"],"names":[],"mappings":"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"}
{"version":3,"file":"index.test.js","sourceRoot":"","sources":["../../src/__tests__/index.test.ts"],"names":[],"mappings":"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"}

View File

@@ -0,0 +1,2 @@
export {};
//# sourceMappingURL=probe.test.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"probe.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/probe.test.ts"],"names":[],"mappings":""}

View File

@@ -0,0 +1,107 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { EventEmitter } from "node:events";
// ── 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() {
return new Promise((resolve) => setImmediate(resolve));
}
function makeFakeChild() {
const main = new EventEmitter();
const stdoutEmitter = new EventEmitter();
const stderrEmitter = new EventEmitter();
main.stdout = stdoutEmitter;
main.stderr = stderrEmitter;
main.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"]);
});
});
//# sourceMappingURL=probe.test.js.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"probe.test.js","sourceRoot":"","sources":["../../src/__tests__/probe.test.ts"],"names":[],"mappings":"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"}

View File

@@ -51,7 +51,8 @@ describe("HermesRuntimeAdapter — promptWithFallback", () => {
await adapter.promptWithFallback(session, "first prompt");
expect(mockInvoke).toHaveBeenCalledTimes(1);
const [prompt, settings, resumeId] = mockInvoke.mock.calls[0];
expect(prompt).toBe("first prompt");
expect(prompt).toContain("User request:\nfirst prompt");
expect(prompt).toContain("Fusion runtime context:");
expect(settings.model).toBe("claude-sonnet-4-5");
expect(resumeId).toBeUndefined();
expect(onText).toHaveBeenCalledWith("hello from hermes");
@@ -66,7 +67,8 @@ describe("HermesRuntimeAdapter — promptWithFallback", () => {
});
await adapter.promptWithFallback(session, "p1");
await adapter.promptWithFallback(session, "p2");
const [, , resume2] = mockInvoke.mock.calls[1];
const [prompt2, , resume2] = mockInvoke.mock.calls[1];
expect(prompt2).toBe("p2");
expect(resume2).toBe("20260427_120000_abc123");
});
it("propagates CLI errors", async () => {

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,96 @@
/**
* 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.
*/
/**
* 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;
}
/**
* 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 declare function listHermesProfiles(opts?: {
binaryPath?: string;
timeoutMs?: number;
}): Promise<HermesProfileSummary[]>;
/**
* 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 declare function resolveCliSettings(settings?: Record<string, unknown>): HermesCliSettings;
/**
* Parse the raw stdout from `hermes chat -q ... -Q`.
*
* Returns `{ body, sessionId }` on success or throws with a descriptive error.
*/
export declare function parseHermesOutput(rawStdout: string, rawStderr: string): HermesCliResult;
/**
* Build the argv array for a `hermes chat` invocation.
*/
export declare function buildHermesArgs(prompt: string, settings: HermesCliSettings, resumeSessionId?: string): string[];
/**
* 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 declare function invokeHermesCli(prompt: string, settings: HermesCliSettings, resumeSessionId?: string, signal?: AbortSignal): Promise<HermesCliResult>;
//# sourceMappingURL=cli-spawn.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"cli-spawn.d.ts","sourceRoot":"","sources":["../src/cli-spawn.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAqDH;;GAEG;AACH,MAAM,WAAW,oBAAoB;IACnC,oCAAoC;IACpC,IAAI,EAAE,MAAM,CAAC;IACb,iDAAiD;IACjD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,6CAA6C;IAC7C,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,yCAAyC;IACzC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,gEAAgE;IAChE,SAAS,EAAE,OAAO,CAAC;CACpB;AAyED;;;;;;;GAOG;AACH,wBAAsB,kBAAkB,CAAC,IAAI,CAAC,EAAE;IAC9C,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB,GAAG,OAAO,CAAC,oBAAoB,EAAE,CAAC,CAqDlC;AAID;;GAEG;AACH,MAAM,WAAW,iBAAiB;IAChC,mEAAmE;IACnE,UAAU,EAAE,MAAM,CAAC;IACnB,kDAAkD;IAClD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,6CAA6C;IAC7C,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,uDAAuD;IACvD,QAAQ,EAAE,MAAM,CAAC;IACjB,kEAAkE;IAClE,IAAI,EAAE,OAAO,CAAC;IACd,kEAAkE;IAClE,YAAY,EAAE,MAAM,CAAC;IACrB;;;;;OAKG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,gDAAgD;AAChD,MAAM,WAAW,eAAe;IAC9B,sCAAsC;IACtC,IAAI,EAAE,MAAM,CAAC;IACb,4EAA4E;IAC5E,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,iBAAiB,CA+BxF;AAkBD;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,eAAe,CAgBvF;AAED;;GAEG;AACH,wBAAgB,eAAe,CAC7B,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,iBAAiB,EAC3B,eAAe,CAAC,EAAE,MAAM,GACvB,MAAM,EAAE,CAsBV;AAED;;;;;;;;GAQG;AACH,wBAAsB,eAAe,CACnC,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,iBAAiB,EAC3B,eAAe,CAAC,EAAE,MAAM,EACxB,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,eAAe,CAAC,CA8F1B"}

View File

@@ -0,0 +1,362 @@
/**
* 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 os from "node:os";
import path, { 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();
function resolveBinaryForSpawn(binary) {
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
];
/** 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) {
const lines = raw.replace(ANSI_RE, "").replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
const profiles = [];
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) => 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) {
const base = process.env.HERMES_HOME ?? path.join(os.homedir(), ".hermes");
if (profileName === "default" || profileName === "")
return base;
return path.join(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) {
const binary = resolveBinaryForSpawn(opts?.binaryPath ?? "hermes");
const timeoutMs = opts?.timeoutMs ?? 5_000;
return new Promise((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) => { stdout += chunk.toString("utf-8"); });
child.stderr?.on("data", (chunk) => { stderr += chunk.toString("utf-8"); });
child.on("error", (err) => {
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) => {
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));
});
});
}
/**
* Resolve HermesCliSettings from a plugin settings record and environment
* variable fallbacks.
*/
export function resolveCliSettings(settings) {
const str = (v) => typeof v === "string" && v.trim().length > 0 ? v.trim() : undefined;
const num = (v, envKey, fallback) => {
// 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, envKey, fallback) => {
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) {
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) {
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, rawStderr) {
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, settings, resumeSessionId) {
const args = ["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, settings, resumeSessionId, signal) {
const args = buildHermesArgs(prompt, settings, resumeSessionId);
const binary = resolveBinaryForSpawn(settings.binaryPath);
return new Promise((resolve, reject) => {
let settled = false;
const spawnEnv = { ...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 = () => {
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) => {
stdout += chunk.toString("utf-8");
});
child.stderr?.on("data", (chunk) => {
stderr += chunk.toString("utf-8");
});
child.on("error", (err) => {
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) => {
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);
}
});
});
}
//# sourceMappingURL=cli-spawn.js.map

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,17 @@
export declare const FUSION_SKILL_NAME = "fusion";
export type HermesFusionSkillInstallOutcome = "installed" | "already-installed" | "replaced" | "skipped" | "warning";
export interface HermesFusionSkillInstallResult {
outcome: HermesFusionSkillInstallOutcome;
sourceDir: string | null;
targetDir: string;
reason?: string;
}
export declare function resolveHermesHome(profile?: string): string;
export declare function getFusionSkillSourceCandidates(moduleUrl?: string): string[];
export declare function resolveBundledFusionSkillSource(): string | null;
export declare function resolveBundledFusionSkillSourceFromCandidates(candidates: string[]): string | null;
export declare function installFusionSkillIntoHermesHome(options?: {
profile?: string;
sourceDir?: string | null;
}): HermesFusionSkillInstallResult;
//# sourceMappingURL=fusion-skill-install.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"fusion-skill-install.d.ts","sourceRoot":"","sources":["../src/fusion-skill-install.ts"],"names":[],"mappings":"AAeA,eAAO,MAAM,iBAAiB,WAAW,CAAC;AAE1C,MAAM,MAAM,+BAA+B,GACvC,WAAW,GACX,mBAAmB,GACnB,UAAU,GACV,SAAS,GACT,SAAS,CAAC;AAEd,MAAM,WAAW,8BAA8B;IAC7C,OAAO,EAAE,+BAA+B,CAAC;IACzC,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,wBAAgB,iBAAiB,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM,CAI1D;AAED,wBAAgB,8BAA8B,CAAC,SAAS,SAAkB,GAAG,MAAM,EAAE,CAUpF;AAED,wBAAgB,+BAA+B,IAAI,MAAM,GAAG,IAAI,CAQ/D;AAED,wBAAgB,6CAA6C,CAAC,UAAU,EAAE,MAAM,EAAE,GAAG,MAAM,GAAG,IAAI,CAMjG;AAED,wBAAgB,gCAAgC,CAAC,OAAO,GAAE;IACxD,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACtB,GAAG,8BAA8B,CA+EtC"}

View File

@@ -0,0 +1,152 @@
import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, unlinkSync, } from "node:fs";
import { homedir } from "node:os";
import { basename, dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
export const FUSION_SKILL_NAME = "fusion";
export function resolveHermesHome(profile) {
const base = process.env.HERMES_HOME ?? join(homedir(), ".hermes");
if (!profile || profile === "default")
return base;
return join(base, "profiles", profile);
}
export function getFusionSkillSourceCandidates(moduleUrl = import.meta.url) {
const here = fileURLToPath(moduleUrl);
const moduleDir = dirname(here);
return [
resolve(moduleDir, "..", "..", "..", "..", "packages", "cli", "skill", FUSION_SKILL_NAME),
resolve(moduleDir, "..", "..", "..", "skill", FUSION_SKILL_NAME),
resolve(moduleDir, "..", "..", "skill", FUSION_SKILL_NAME),
resolve(moduleDir, "..", "..", "..", "..", "skill", FUSION_SKILL_NAME),
];
}
export function resolveBundledFusionSkillSource() {
const candidates = getFusionSkillSourceCandidates();
for (const candidate of candidates) {
if (existsSync(join(candidate, "SKILL.md")))
return candidate;
}
return null;
}
export function resolveBundledFusionSkillSourceFromCandidates(candidates) {
for (const candidate of candidates) {
if (existsSync(join(candidate, "SKILL.md")))
return candidate;
}
return null;
}
export function installFusionSkillIntoHermesHome(options = {}) {
const sourceDir = options.sourceDir ?? resolveBundledFusionSkillSource();
const targetDir = join(resolveHermesHome(options.profile), "skills", FUSION_SKILL_NAME);
if (!sourceDir) {
return {
outcome: "warning",
sourceDir,
targetDir,
reason: "bundled Fusion skill source directory not found",
};
}
try {
mkdirSync(dirname(targetDir), { recursive: true });
let replaced = false;
if (existsSync(targetDir) || isBrokenSymlink(targetDir)) {
const stat = lstatSync(targetDir);
if (stat.isSymbolicLink()) {
const currentTarget = safeReadlink(targetDir);
if (currentTarget && resolve(dirname(targetDir), currentTarget) === resolve(sourceDir)) {
return { outcome: "already-installed", sourceDir, targetDir };
}
if (!looksLikeFusionSkillTarget(resolve(dirname(targetDir), currentTarget ?? ""))) {
return {
outcome: "skipped",
sourceDir,
targetDir,
reason: "existing symlink does not look like a Fusion skill install",
};
}
unlinkSync(targetDir);
replaced = true;
}
else {
if (!looksLikePriorFusionInstall(targetDir)) {
return {
outcome: "skipped",
sourceDir,
targetDir,
reason: "existing directory does not look like a Fusion skill install",
};
}
rmSync(targetDir, { recursive: true, force: true });
replaced = true;
}
}
try {
symlinkSync(sourceDir, targetDir, "dir");
}
catch (error) {
const symlinkReason = error instanceof Error ? error.message : String(error);
try {
cpSync(sourceDir, targetDir, { recursive: true });
return {
outcome: replaced ? "replaced" : "installed",
sourceDir,
targetDir,
reason: `symlink failed (${symlinkReason}); copied files instead`,
};
}
catch (copyError) {
return {
outcome: "warning",
sourceDir,
targetDir,
reason: copyError instanceof Error ? copyError.message : String(copyError),
};
}
}
return { outcome: replaced ? "replaced" : "installed", sourceDir, targetDir };
}
catch (error) {
return {
outcome: "warning",
sourceDir,
targetDir,
reason: error instanceof Error ? error.message : String(error),
};
}
}
function safeReadlink(path) {
try {
return readlinkSync(path);
}
catch {
return null;
}
}
function isBrokenSymlink(path) {
try {
const stat = lstatSync(path);
return stat.isSymbolicLink() && !existsSync(path);
}
catch {
return false;
}
}
function looksLikePriorFusionInstall(path) {
const skillMd = join(path, "SKILL.md");
if (!existsSync(skillMd))
return false;
try {
const body = readFileSync(skillMd, "utf-8");
return /\bfusion\b/i.test(body) && /\bskill\b/i.test(body);
}
catch {
return false;
}
}
function looksLikeFusionSkillTarget(path) {
if (!path)
return false;
if (basename(path).toLowerCase() === FUSION_SKILL_NAME)
return true;
return existsSync(join(path, "SKILL.md"));
}
//# sourceMappingURL=fusion-skill-install.js.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"fusion-skill-install.js","sourceRoot":"","sources":["../src/fusion-skill-install.ts"],"names":[],"mappings":"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"}

View File

@@ -15,6 +15,7 @@ export default plugin;
export { hermesRuntimeMetadata, hermesRuntimeFactory, HERMES_RUNTIME_ID };
export { HermesRuntimeAdapter } from "./runtime-adapter.js";
export { resolveCliSettings, invokeHermesCli, buildHermesArgs, parseHermesOutput, listHermesProfiles, } from "./cli-spawn.js";
export { installFusionSkillIntoHermesHome, resolveBundledFusionSkillSource, resolveHermesHome, } from "./fusion-skill-install.js";
export type { HermesCliSettings, HermesCliResult, HermesProfileSummary } from "./cli-spawn.js";
export { probeHermesBinary } from "./probe.js";
export type { HermesBinaryStatus } from "./probe.js";

View File

@@ -1 +1 @@
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAKH,OAAO,KAAK,EACV,YAAY,EACZ,oBAAoB,EACpB,6BAA6B,EAC9B,MAAM,oBAAoB,CAAC;AAI5B,QAAA,MAAM,iBAAiB,WAAW,CAAC;AAGnC,QAAA,MAAM,qBAAqB,EAAE,6BAK5B,CAAC;AAIF,QAAA,MAAM,oBAAoB,EAAE,oBAE3B,CAAC;AAIF,QAAA,MAAM,MAAM,EAAE,YA+BZ,CAAC;AAEH,eAAe,MAAM,CAAC;AAItB,OAAO,EAAE,qBAAqB,EAAE,oBAAoB,EAAE,iBAAiB,EAAE,CAAC;AAC1E,OAAO,EAAE,oBAAoB,EAAE,MAAM,sBAAsB,CAAC;AAC5D,OAAO,EACL,kBAAkB,EAClB,eAAe,EACf,eAAe,EACf,iBAAiB,EACjB,kBAAkB,GACnB,MAAM,gBAAgB,CAAC;AACxB,YAAY,EAAE,iBAAiB,EAAE,eAAe,EAAE,oBAAoB,EAAE,MAAM,gBAAgB,CAAC;AAG/F,OAAO,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAC/C,YAAY,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC"}
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAMH,OAAO,KAAK,EACV,YAAY,EACZ,oBAAoB,EACpB,6BAA6B,EAC9B,MAAM,oBAAoB,CAAC;AAI5B,QAAA,MAAM,iBAAiB,WAAW,CAAC;AAGnC,QAAA,MAAM,qBAAqB,EAAE,6BAK5B,CAAC;AAIF,QAAA,MAAM,oBAAoB,EAAE,oBAE3B,CAAC;AAIF,QAAA,MAAM,MAAM,EAAE,YA2CZ,CAAC;AAEH,eAAe,MAAM,CAAC;AAItB,OAAO,EAAE,qBAAqB,EAAE,oBAAoB,EAAE,iBAAiB,EAAE,CAAC;AAC1E,OAAO,EAAE,oBAAoB,EAAE,MAAM,sBAAsB,CAAC;AAC5D,OAAO,EACL,kBAAkB,EAClB,eAAe,EACf,eAAe,EACf,iBAAiB,EACjB,kBAAkB,GACnB,MAAM,gBAAgB,CAAC;AACxB,OAAO,EACL,gCAAgC,EAChC,+BAA+B,EAC/B,iBAAiB,GAClB,MAAM,2BAA2B,CAAC;AACnC,YAAY,EAAE,iBAAiB,EAAE,eAAe,EAAE,oBAAoB,EAAE,MAAM,gBAAgB,CAAC;AAG/F,OAAO,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAC/C,YAAY,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC"}

View File

@@ -8,6 +8,7 @@
*/
import { definePlugin } from "@fusion/plugin-sdk";
import { resolveCliSettings } from "./cli-spawn.js";
import { installFusionSkillIntoHermesHome } from "./fusion-skill-install.js";
import { HermesRuntimeAdapter } from "./runtime-adapter.js";
// ── Hermes Runtime Metadata ───────────────────────────────────────────────────
const HERMES_RUNTIME_ID = "hermes";
@@ -37,7 +38,14 @@ const plugin = definePlugin({
hooks: {
onLoad: (ctx) => {
const settings = resolveCliSettings(ctx.settings);
ctx.logger.info(`Hermes Runtime Plugin loaded — binary=${settings.binaryPath} model=${settings.model ?? "(default)"}`);
const skillInstall = installFusionSkillIntoHermesHome({ profile: settings.profile });
if (skillInstall.outcome === "warning") {
ctx.logger.warn(`Hermes Runtime Plugin: Fusion skill auto-install warning: ${skillInstall.reason ?? "unknown"}`);
}
else if (skillInstall.outcome === "skipped") {
ctx.logger.warn(`Hermes Runtime Plugin: Fusion skill auto-install skipped: ${skillInstall.reason ?? "unknown"}`);
}
ctx.logger.info(`Hermes Runtime Plugin loaded — binary=${settings.binaryPath} model=${settings.model ?? "(default)"} fusionSkill=${skillInstall.outcome}`);
ctx.emitEvent("hermes-runtime:loaded", {
runtimeId: HERMES_RUNTIME_ID,
version: HERMES_RUNTIME_VERSION,
@@ -57,6 +65,7 @@ export default plugin;
export { hermesRuntimeMetadata, hermesRuntimeFactory, HERMES_RUNTIME_ID };
export { HermesRuntimeAdapter } from "./runtime-adapter.js";
export { resolveCliSettings, invokeHermesCli, buildHermesArgs, parseHermesOutput, listHermesProfiles, } from "./cli-spawn.js";
export { installFusionSkillIntoHermesHome, resolveBundledFusionSkillSource, resolveHermesHome, } from "./fusion-skill-install.js";
// Probe re-export for the dashboard's runtime-provider-probes façade.
export { probeHermesBinary } from "./probe.js";
//# sourceMappingURL=index.js.map

View File

@@ -1 +1 @@
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAClD,OAAO,EAAE,kBAAkB,EAAE,MAAM,gBAAgB,CAAC;AACpD,OAAO,EAAE,oBAAoB,EAAE,MAAM,sBAAsB,CAAC;AAO5D,iFAAiF;AAEjF,MAAM,iBAAiB,GAAG,QAAQ,CAAC;AACnC,MAAM,sBAAsB,GAAG,OAAO,CAAC;AAEvC,MAAM,qBAAqB,GAAkC;IAC3D,SAAS,EAAE,iBAAiB;IAC5B,IAAI,EAAE,gBAAgB;IACtB,WAAW,EAAE,2DAA2D;IACxE,OAAO,EAAE,sBAAsB;CAChC,CAAC;AAEF,iFAAiF;AAEjF,MAAM,oBAAoB,GAAyB,KAAK,EAAE,GAAG,EAAE,EAAE;IAC/D,OAAO,IAAI,oBAAoB,CAAC,GAAG,CAAC,QAA+C,CAAC,CAAC;AACvF,CAAC,CAAC;AAEF,iFAAiF;AAEjF,MAAM,MAAM,GAAiB,YAAY,CAAC;IACxC,QAAQ,EAAE;QACR,EAAE,EAAE,8BAA8B;QAClC,IAAI,EAAE,uBAAuB;QAC7B,OAAO,EAAE,sBAAsB;QAC/B,WAAW,EACT,kGAAkG;QACpG,MAAM,EAAE,aAAa;QACrB,QAAQ,EAAE,8CAA8C;QACxD,OAAO,EAAE,qBAAqB;KAC/B;IACD,KAAK,EAAE,WAAW;IAClB,KAAK,EAAE;QACL,MAAM,EAAE,CAAC,GAAG,EAAE,EAAE;YACd,MAAM,QAAQ,GAAG,kBAAkB,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;YAClD,GAAG,CAAC,MAAM,CAAC,IAAI,CACb,yCAAyC,QAAQ,CAAC,UAAU,UAAU,QAAQ,CAAC,KAAK,IAAI,WAAW,EAAE,CACtG,CAAC;YACF,GAAG,CAAC,SAAS,CAAC,uBAAuB,EAAE;gBACrC,SAAS,EAAE,iBAAiB;gBAC5B,OAAO,EAAE,sBAAsB;aAChC,CAAC,CAAC;QACL,CAAC;QACD,QAAQ,EAAE,GAAG,EAAE;YACb,2EAA2E;QAC7E,CAAC;KACF;IACD,OAAO,EAAE;QACP,QAAQ,EAAE,qBAAqB;QAC/B,OAAO,EAAE,oBAAoB;KAC9B;CACF,CAAC,CAAC;AAEH,eAAe,MAAM,CAAC;AAEtB,iFAAiF;AAEjF,OAAO,EAAE,qBAAqB,EAAE,oBAAoB,EAAE,iBAAiB,EAAE,CAAC;AAC1E,OAAO,EAAE,oBAAoB,EAAE,MAAM,sBAAsB,CAAC;AAC5D,OAAO,EACL,kBAAkB,EAClB,eAAe,EACf,eAAe,EACf,iBAAiB,EACjB,kBAAkB,GACnB,MAAM,gBAAgB,CAAC;AAGxB,sEAAsE;AACtE,OAAO,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC"}
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAClD,OAAO,EAAE,kBAAkB,EAAE,MAAM,gBAAgB,CAAC;AACpD,OAAO,EAAE,gCAAgC,EAAE,MAAM,2BAA2B,CAAC;AAC7E,OAAO,EAAE,oBAAoB,EAAE,MAAM,sBAAsB,CAAC;AAO5D,iFAAiF;AAEjF,MAAM,iBAAiB,GAAG,QAAQ,CAAC;AACnC,MAAM,sBAAsB,GAAG,OAAO,CAAC;AAEvC,MAAM,qBAAqB,GAAkC;IAC3D,SAAS,EAAE,iBAAiB;IAC5B,IAAI,EAAE,gBAAgB;IACtB,WAAW,EAAE,2DAA2D;IACxE,OAAO,EAAE,sBAAsB;CAChC,CAAC;AAEF,iFAAiF;AAEjF,MAAM,oBAAoB,GAAyB,KAAK,EAAE,GAAG,EAAE,EAAE;IAC/D,OAAO,IAAI,oBAAoB,CAAC,GAAG,CAAC,QAA+C,CAAC,CAAC;AACvF,CAAC,CAAC;AAEF,iFAAiF;AAEjF,MAAM,MAAM,GAAiB,YAAY,CAAC;IACxC,QAAQ,EAAE;QACR,EAAE,EAAE,8BAA8B;QAClC,IAAI,EAAE,uBAAuB;QAC7B,OAAO,EAAE,sBAAsB;QAC/B,WAAW,EACT,kGAAkG;QACpG,MAAM,EAAE,aAAa;QACrB,QAAQ,EAAE,8CAA8C;QACxD,OAAO,EAAE,qBAAqB;KAC/B;IACD,KAAK,EAAE,WAAW;IAClB,KAAK,EAAE;QACL,MAAM,EAAE,CAAC,GAAG,EAAE,EAAE;YACd,MAAM,QAAQ,GAAG,kBAAkB,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;YAClD,MAAM,YAAY,GAAG,gCAAgC,CAAC,EAAE,OAAO,EAAE,QAAQ,CAAC,OAAO,EAAE,CAAC,CAAC;YAErF,IAAI,YAAY,CAAC,OAAO,KAAK,SAAS,EAAE,CAAC;gBACvC,GAAG,CAAC,MAAM,CAAC,IAAI,CACb,6DAA6D,YAAY,CAAC,MAAM,IAAI,SAAS,EAAE,CAChG,CAAC;YACJ,CAAC;iBAAM,IAAI,YAAY,CAAC,OAAO,KAAK,SAAS,EAAE,CAAC;gBAC9C,GAAG,CAAC,MAAM,CAAC,IAAI,CACb,6DAA6D,YAAY,CAAC,MAAM,IAAI,SAAS,EAAE,CAChG,CAAC;YACJ,CAAC;YAED,GAAG,CAAC,MAAM,CAAC,IAAI,CACb,yCAAyC,QAAQ,CAAC,UAAU,UAAU,QAAQ,CAAC,KAAK,IAAI,WAAW,gBAAgB,YAAY,CAAC,OAAO,EAAE,CAC1I,CAAC;YACF,GAAG,CAAC,SAAS,CAAC,uBAAuB,EAAE;gBACrC,SAAS,EAAE,iBAAiB;gBAC5B,OAAO,EAAE,sBAAsB;aAChC,CAAC,CAAC;QACL,CAAC;QACD,QAAQ,EAAE,GAAG,EAAE;YACb,2EAA2E;QAC7E,CAAC;KACF;IACD,OAAO,EAAE;QACP,QAAQ,EAAE,qBAAqB;QAC/B,OAAO,EAAE,oBAAoB;KAC9B;CACF,CAAC,CAAC;AAEH,eAAe,MAAM,CAAC;AAEtB,iFAAiF;AAEjF,OAAO,EAAE,qBAAqB,EAAE,oBAAoB,EAAE,iBAAiB,EAAE,CAAC;AAC1E,OAAO,EAAE,oBAAoB,EAAE,MAAM,sBAAsB,CAAC;AAC5D,OAAO,EACL,kBAAkB,EAClB,eAAe,EACf,eAAe,EACf,iBAAiB,EACjB,kBAAkB,GACnB,MAAM,gBAAgB,CAAC;AACxB,OAAO,EACL,gCAAgC,EAChC,+BAA+B,EAC/B,iBAAiB,GAClB,MAAM,2BAA2B,CAAC;AAGnC,sEAAsE;AACtE,OAAO,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC"}

View File

@@ -0,0 +1,35 @@
/**
* 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.
*/
/**
* 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 declare function probeHermesBinary(opts?: {
binaryPath?: string;
timeoutMs?: number;
}): Promise<HermesBinaryStatus>;
//# sourceMappingURL=probe.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"probe.d.ts","sourceRoot":"","sources":["../src/probe.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAOH;;GAEG;AACH,MAAM,WAAW,kBAAkB;IACjC,uEAAuE;IACvE,SAAS,EAAE,OAAO,CAAC;IACnB,4DAA4D;IAC5D,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,mEAAmE;IACnE,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,gEAAgE;IAChE,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,wDAAwD;IACxD,eAAe,EAAE,MAAM,CAAC;CACzB;AAED;;;;;;;;GAQG;AACH,wBAAsB,iBAAiB,CAAC,IAAI,CAAC,EAAE;IAC7C,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB,GAAG,OAAO,CAAC,kBAAkB,CAAC,CAiF9B"}

View File

@@ -0,0 +1,118 @@
/**
* 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;
/**
* 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) {
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((resolvePromise) => {
const finish = (result) => {
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) => {
stdout += chunk.toString("utf-8");
});
child.stderr?.on("data", (chunk) => {
stderr += chunk.toString("utf-8");
});
child.on("error", (err) => {
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) => {
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) {
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) => {
out += chunk.toString("utf-8");
});
child.on("error", () => resolvePromise(undefined));
child.on("close", (code) => {
if (code === 0) {
const first = out.trim().split(/\r?\n/)[0];
resolvePromise(first?.length ? first : undefined);
}
else {
resolvePromise(undefined);
}
});
});
}
//# sourceMappingURL=probe.js.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"probe.js","sourceRoot":"","sources":["../src/probe.ts"],"names":[],"mappings":"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"}

View File

@@ -1 +1 @@
{"version":3,"file":"runtime-adapter.d.ts","sourceRoot":"","sources":["../src/runtime-adapter.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAGH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAC;AACxD,OAAO,KAAK,EACV,YAAY,EACZ,mBAAmB,EACnB,YAAY,EACZ,kBAAkB,EAEnB,MAAM,YAAY,CAAC;AAEpB,qBAAa,oBAAqB,YAAW,YAAY;IACvD,QAAQ,CAAC,EAAE,YAAY;IACvB,QAAQ,CAAC,IAAI,oBAAoB;IAEjC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAoB;gBAEjC,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,iBAAiB;IAM5D,aAAa,CAAC,OAAO,EAAE,mBAAmB,GAAG,OAAO,CAAC,kBAAkB,CAAC;IAqBxE,kBAAkB,CACtB,OAAO,EAAE,YAAY,EACrB,MAAM,EAAE,MAAM,EACd,QAAQ,CAAC,EAAE,OAAO,GACjB,OAAO,CAAC,IAAI,CAAC;IAYhB,aAAa,CAAC,OAAO,EAAE,YAAY,GAAG,MAAM;IAItC,OAAO,CAAC,QAAQ,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC;IAIpD,OAAO,CAAC,oBAAoB;CAQ7B"}
{"version":3,"file":"runtime-adapter.d.ts","sourceRoot":"","sources":["../src/runtime-adapter.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAGH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAC;AACxD,OAAO,KAAK,EACV,YAAY,EACZ,mBAAmB,EACnB,YAAY,EACZ,kBAAkB,EAEnB,MAAM,YAAY,CAAC;AAwBpB,qBAAa,oBAAqB,YAAW,YAAY;IACvD,QAAQ,CAAC,EAAE,YAAY;IACvB,QAAQ,CAAC,IAAI,oBAAoB;IAEjC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAoB;gBAEjC,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,iBAAiB;IAM5D,aAAa,CAAC,OAAO,EAAE,mBAAmB,GAAG,OAAO,CAAC,kBAAkB,CAAC;IAuBxE,kBAAkB,CACtB,OAAO,EAAE,YAAY,EACrB,MAAM,EAAE,MAAM,EACd,QAAQ,CAAC,EAAE,OAAO,GACjB,OAAO,CAAC,IAAI,CAAC;IAehB,aAAa,CAAC,OAAO,EAAE,YAAY,GAAG,MAAM;IAItC,OAAO,CAAC,QAAQ,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC;IAIpD,OAAO,CAAC,oBAAoB;CAQ7B"}

View File

@@ -6,6 +6,23 @@
* session pass `--resume <id>` to continue the conversation.
*/
import { invokeHermesCli, resolveCliSettings } from "./cli-spawn.js";
function buildRuntimeContextSection(options) {
const skillNames = Array.isArray(options.skills) ? options.skills.filter((value) => typeof value === "string" && value.trim().length > 0) : [];
const skillSelection = options.skillSelection;
const selectionSkillNames = Array.isArray(skillSelection?.requestedSkillNames)
? skillSelection.requestedSkillNames.filter((value) => typeof value === "string" && value.trim().length > 0)
: [];
const mergedSkills = skillNames.length > 0 ? skillNames : selectionSkillNames;
const lines = [
"Fusion runtime context:",
`- Tool mode: ${options.tools ?? "coding"}`,
];
if (mergedSkills.length > 0) {
lines.push(`- Requested skills: ${mergedSkills.join(", ")}`);
}
lines.push("- If fn_* tools are available in your runtime, use them directly for coordination/memory/task actions.");
return lines.join("\n");
}
export class HermesRuntimeAdapter {
id = "hermes";
name = "Hermes Runtime";
@@ -28,13 +45,18 @@ export class HermesRuntimeAdapter {
onToolStart: options.onToolStart,
onToolEnd: options.onToolEnd,
},
runtimeContext: options.runtimeContext,
fusedSystemPrompt: [options.systemPrompt.trim(), buildRuntimeContextSection(options).trim()].filter((part) => part.length > 0).join("\n\n"),
dispose: () => undefined,
};
return { session, sessionFile: undefined };
}
async promptWithFallback(session, prompt, _options) {
const resumeId = session.sessionId || undefined;
const result = await invokeHermesCli(prompt, this.settings, resumeId);
const promptWithContext = resumeId
? prompt
: `${session.fusedSystemPrompt}\n\nUser request:\n${prompt}`;
const result = await invokeHermesCli(promptWithContext, this.settings, resumeId);
session.sessionId = result.sessionId;
session.lastModelDescription = this.describeFromSettings();
if (result.body) {

View File

@@ -1 +1 @@
{"version":3,"file":"runtime-adapter.js","sourceRoot":"","sources":["../src/runtime-adapter.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,EAAE,eAAe,EAAE,kBAAkB,EAAE,MAAM,gBAAgB,CAAC;AAUrE,MAAM,OAAO,oBAAoB;IACtB,EAAE,GAAG,QAAQ,CAAC;IACd,IAAI,GAAG,gBAAgB,CAAC;IAEhB,QAAQ,CAAoB;IAE7C,YAAY,QAAsD;QAChE,IAAI,CAAC,QAAQ,GAAG,kBAAkB,CAChC,QAA+C,CAChD,CAAC;IACJ,CAAC;IAED,KAAK,CAAC,aAAa,CAAC,OAA4B;QAC9C,MAAM,OAAO,GAAwB;YACnC,KAAK,EAAE,SAAS;YAChB,YAAY,EAAE,OAAO,CAAC,YAAY;YAClC,QAAQ,EAAE,EAAE;YACZ,MAAM,EAAE,SAAS;YACjB,aAAa,EAAE,SAAS;YACxB,SAAS,EAAE,EAAE;YACb,oBAAoB,EAAE,IAAI,CAAC,oBAAoB,EAAE;YACjD,SAAS,EAAE;gBACT,MAAM,EAAE,OAAO,CAAC,MAAM;gBACtB,UAAU,EAAE,OAAO,CAAC,UAAU;gBAC9B,WAAW,EAAE,OAAO,CAAC,WAAW;gBAChC,SAAS,EAAE,OAAO,CAAC,SAAS;aAC7B;YACD,OAAO,EAAE,GAAG,EAAE,CAAC,SAAS;SACzB,CAAC;QAEF,OAAO,EAAE,OAAO,EAAE,WAAW,EAAE,SAAS,EAAE,CAAC;IAC7C,CAAC;IAED,KAAK,CAAC,kBAAkB,CACtB,OAAqB,EACrB,MAAc,EACd,QAAkB;QAElB,MAAM,QAAQ,GAAG,OAAO,CAAC,SAAS,IAAI,SAAS,CAAC;QAChD,MAAM,MAAM,GAAG,MAAM,eAAe,CAAC,MAAM,EAAE,IAAI,CAAC,QAAQ,EAAE,QAAQ,CAAC,CAAC;QAEtE,OAAO,CAAC,SAAS,GAAG,MAAM,CAAC,SAAS,CAAC;QACrC,OAAO,CAAC,oBAAoB,GAAG,IAAI,CAAC,oBAAoB,EAAE,CAAC;QAE3D,IAAI,MAAM,CAAC,IAAI,EAAE,CAAC;YAChB,OAAO,CAAC,SAAS,CAAC,MAAM,EAAE,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;QAC1C,CAAC;IACH,CAAC;IAED,aAAa,CAAC,OAAqB;QACjC,OAAO,OAAO,CAAC,oBAAoB,IAAI,IAAI,CAAC,oBAAoB,EAAE,CAAC;IACrE,CAAC;IAED,KAAK,CAAC,OAAO,CAAC,QAAsB;QAClC,8EAA8E;IAChF,CAAC;IAEO,oBAAoB;QAC1B,MAAM,QAAQ,GAAG,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC;QACxC,MAAM,KAAK,GAAG,IAAI,CAAC,QAAQ,CAAC,KAAK,CAAC;QAClC,IAAI,QAAQ,IAAI,KAAK;YAAE,OAAO,UAAU,QAAQ,IAAI,KAAK,EAAE,CAAC;QAC5D,IAAI,KAAK;YAAE,OAAO,UAAU,KAAK,EAAE,CAAC;QACpC,IAAI,QAAQ;YAAE,OAAO,UAAU,QAAQ,EAAE,CAAC;QAC1C,OAAO,QAAQ,CAAC;IAClB,CAAC;CACF"}
{"version":3,"file":"runtime-adapter.js","sourceRoot":"","sources":["../src/runtime-adapter.ts"],"names":[],"mappings":"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"}

View File

@@ -10,6 +10,12 @@ export interface HermesCallbacks {
onToolStart?: (toolName: string, args?: unknown) => void;
onToolEnd?: (toolName: string, isError: boolean, result?: unknown) => void;
}
export interface HermesRuntimeContext {
sessionPurpose?: string;
toolMode?: "coding" | "readonly";
customToolNames?: string[];
requestedSkillNames?: string[];
}
export interface HermesStreamSession {
model: unknown;
systemPrompt: string;
@@ -20,6 +26,8 @@ export interface HermesStreamSession {
lastModelDescription: string;
callbacks: HermesCallbacks;
usage?: unknown;
runtimeContext?: HermesRuntimeContext;
fusedSystemPrompt: string;
dispose(): void;
}
export type AgentSession = HermesStreamSession;
@@ -45,6 +53,7 @@ export interface AgentRuntimeOptions {
sessionManager?: unknown;
skillSelection?: unknown;
skills?: string[];
runtimeContext?: HermesRuntimeContext;
}
/** Result of creating a session. */
export interface AgentSessionResult {

View File

@@ -1 +1 @@
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}

View File

@@ -0,0 +1,119 @@
import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
getFusionSkillSourceCandidates,
installFusionSkillIntoHermesHome,
resolveBundledFusionSkillSourceFromCandidates,
} from "../fusion-skill-install.js";
const tempDirs: string[] = [];
function tempDir(prefix: string): string {
const dir = mkdtempSync(path.join(os.tmpdir(), prefix));
tempDirs.push(dir);
return dir;
}
function makeSkillSource(dir: string): void {
mkdirSync(dir, { recursive: true });
writeFileSync(path.join(dir, "SKILL.md"), "# Fusion Skill\n");
}
afterEach(() => {
delete process.env.HERMES_HOME;
for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true });
});
describe("fusion skill installer", () => {
it("resolves workspace and packaged candidate layouts", () => {
const workspaceCandidates = getFusionSkillSourceCandidates("file:///repo/plugins/fusion-plugin-hermes-runtime/src/index.ts");
expect(workspaceCandidates[0].replace(/\\/g, "/")).toMatch(/\/packages\/cli\/skill\/fusion$/);
const packagedCandidates = getFusionSkillSourceCandidates("file:///repo/packages/cli/dist/plugins/fusion-plugin-hermes-runtime/bundled.js");
expect(
packagedCandidates.some((candidate) => candidate.replace(/\\/g, "/").endsWith("/packages/cli/dist/skill/fusion")),
).toBe(true);
});
it("installs skill for configured Hermes home", () => {
const home = tempDir("hermes-home-");
const source = path.join(tempDir("fusion-source-"), "fusion");
makeSkillSource(source);
process.env.HERMES_HOME = home;
const result = installFusionSkillIntoHermesHome({ sourceDir: source });
expect(result.outcome).toBe("installed");
expect(result.targetDir).toBe(path.join(home, "skills", "fusion"));
});
it("no-ops when already installed to same source", () => {
const home = tempDir("hermes-home-");
const source = path.join(tempDir("fusion-source-"), "fusion");
makeSkillSource(source);
process.env.HERMES_HOME = home;
expect(installFusionSkillIntoHermesHome({ sourceDir: source }).outcome).toBe("installed");
expect(installFusionSkillIntoHermesHome({ sourceDir: source }).outcome).toBe("already-installed");
});
it("replaces prior fusion directory install safely", () => {
const home = tempDir("hermes-home-");
const source = path.join(tempDir("fusion-source-"), "fusion");
makeSkillSource(source);
process.env.HERMES_HOME = home;
const target = path.join(home, "skills", "fusion");
makeSkillSource(target);
writeFileSync(path.join(target, "old.txt"), "stale");
const result = installFusionSkillIntoHermesHome({ sourceDir: source });
expect(result.outcome).toBe("replaced");
});
it("skips replacement for unrelated existing directory", () => {
const home = tempDir("hermes-home-");
const source = path.join(tempDir("fusion-source-"), "fusion");
makeSkillSource(source);
process.env.HERMES_HOME = home;
const target = path.join(home, "skills", "fusion");
mkdirSync(target, { recursive: true });
writeFileSync(path.join(target, "README.md"), "my custom skill");
const result = installFusionSkillIntoHermesHome({ sourceDir: source });
expect(result.outcome).toBe("skipped");
});
it("warns when source is missing", () => {
const home = tempDir("hermes-home-");
process.env.HERMES_HOME = home;
const result = installFusionSkillIntoHermesHome({ sourceDir: null });
expect(result.outcome).toBe("warning");
});
it("can resolve first existing candidate", () => {
const source = path.join(tempDir("fusion-source-"), "fusion");
makeSkillSource(source);
const resolved = resolveBundledFusionSkillSourceFromCandidates(["/missing", source]);
expect(resolved).toBe(source);
});
it("replaces stale fusion symlink", () => {
const home = tempDir("hermes-home-");
const source = path.join(tempDir("fusion-source-"), "fusion");
const oldSource = path.join(tempDir("fusion-old-source-"), "fusion");
makeSkillSource(source);
makeSkillSource(oldSource);
process.env.HERMES_HOME = home;
const target = path.join(home, "skills", "fusion");
mkdirSync(path.dirname(target), { recursive: true });
symlinkSync(oldSource, target, "dir");
const result = installFusionSkillIntoHermesHome({ sourceDir: source });
expect(result.outcome).toBe("replaced");
});
});

View File

@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { mockResolveCli } = vi.hoisted(() => ({
const { mockResolveCli, mockInstallFusionSkill } = vi.hoisted(() => ({
mockResolveCli: vi.fn().mockReturnValue({
binaryPath: "hermes",
model: undefined,
@@ -9,23 +9,32 @@ const { mockResolveCli } = vi.hoisted(() => ({
yolo: false,
cliTimeoutMs: 300_000,
}),
mockInstallFusionSkill: vi.fn().mockReturnValue({
outcome: "installed",
sourceDir: "/tmp/source",
targetDir: "/tmp/target",
}),
}));
vi.mock("../cli-spawn.js", async () => {
const actual = await vi.importActual<typeof import("../cli-spawn.js")>(
"../cli-spawn.js",
);
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";
vi.mock("../fusion-skill-install.js", async () => {
const actual = await vi.importActual<typeof import("../fusion-skill-install.js")>(
"../fusion-skill-install.js",
);
return {
...actual,
installFusionSkillIntoHermesHome: mockInstallFusionSkill,
};
});
import plugin, { HERMES_RUNTIME_ID, hermesRuntimeFactory, hermesRuntimeMetadata } from "../index.js";
import { HermesRuntimeAdapter } from "../runtime-adapter.js";
function createMockContext(settings: Record<string, unknown> = {}) {
@@ -48,6 +57,12 @@ describe("hermes-runtime plugin", () => {
maxTurns: 12,
yolo: false,
cliTimeoutMs: 300_000,
profile: undefined,
});
mockInstallFusionSkill.mockReturnValue({
outcome: "installed",
sourceDir: "/tmp/source",
targetDir: "/tmp/target",
});
});
@@ -73,13 +88,33 @@ describe("hermes-runtime plugin", () => {
maxTurns: 12,
yolo: false,
cliTimeoutMs: 300_000,
profile: undefined,
});
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(mockInstallFusionSkill).toHaveBeenCalledWith({ profile: undefined });
expect(ctx.logger.info).toHaveBeenCalledWith(expect.stringContaining("fusionSkill=installed"));
expect(ctx.emitEvent).toHaveBeenCalledWith("hermes-runtime:loaded", {
runtimeId: "hermes",
version: plugin.manifest.version,
});
});
it("onLoad warns but continues when skill install warns", async () => {
mockInstallFusionSkill.mockReturnValue({
outcome: "warning",
sourceDir: null,
targetDir: "/tmp/target",
reason: "missing skill source",
});
const ctx = createMockContext();
await plugin.hooks!.onLoad!(ctx as any);
expect(ctx.logger.warn).toHaveBeenCalledWith(expect.stringContaining("auto-install warning"));
expect(ctx.emitEvent).toHaveBeenCalledWith("hermes-runtime:loaded", {
runtimeId: "hermes",
version: plugin.manifest.version,

View File

@@ -0,0 +1,184 @@
import {
cpSync,
existsSync,
lstatSync,
mkdirSync,
readFileSync,
readlinkSync,
rmSync,
symlinkSync,
unlinkSync,
} from "node:fs";
import { homedir } from "node:os";
import { basename, dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
export const FUSION_SKILL_NAME = "fusion";
export type HermesFusionSkillInstallOutcome =
| "installed"
| "already-installed"
| "replaced"
| "skipped"
| "warning";
export interface HermesFusionSkillInstallResult {
outcome: HermesFusionSkillInstallOutcome;
sourceDir: string | null;
targetDir: string;
reason?: string;
}
export function resolveHermesHome(profile?: string): string {
const base = process.env.HERMES_HOME ?? join(homedir(), ".hermes");
if (!profile || profile === "default") return base;
return join(base, "profiles", profile);
}
export function getFusionSkillSourceCandidates(moduleUrl = import.meta.url): string[] {
const here = fileURLToPath(moduleUrl);
const moduleDir = dirname(here);
return [
resolve(moduleDir, "..", "..", "..", "..", "packages", "cli", "skill", FUSION_SKILL_NAME),
resolve(moduleDir, "..", "..", "..", "skill", FUSION_SKILL_NAME),
resolve(moduleDir, "..", "..", "skill", FUSION_SKILL_NAME),
resolve(moduleDir, "..", "..", "..", "..", "skill", FUSION_SKILL_NAME),
];
}
export function resolveBundledFusionSkillSource(): string | null {
const candidates = getFusionSkillSourceCandidates();
for (const candidate of candidates) {
if (existsSync(join(candidate, "SKILL.md"))) return candidate;
}
return null;
}
export function resolveBundledFusionSkillSourceFromCandidates(candidates: string[]): string | null {
for (const candidate of candidates) {
if (existsSync(join(candidate, "SKILL.md"))) return candidate;
}
return null;
}
export function installFusionSkillIntoHermesHome(options: {
profile?: string;
sourceDir?: string | null;
} = {}): HermesFusionSkillInstallResult {
const sourceDir = options.sourceDir ?? resolveBundledFusionSkillSource();
const targetDir = join(resolveHermesHome(options.profile), "skills", FUSION_SKILL_NAME);
if (!sourceDir) {
return {
outcome: "warning",
sourceDir,
targetDir,
reason: "bundled Fusion skill source directory not found",
};
}
try {
mkdirSync(dirname(targetDir), { recursive: true });
let replaced = false;
if (existsSync(targetDir) || isBrokenSymlink(targetDir)) {
const stat = lstatSync(targetDir);
if (stat.isSymbolicLink()) {
const currentTarget = safeReadlink(targetDir);
if (currentTarget && resolve(dirname(targetDir), currentTarget) === resolve(sourceDir)) {
return { outcome: "already-installed", sourceDir, targetDir };
}
if (!looksLikeFusionSkillTarget(resolve(dirname(targetDir), currentTarget ?? ""))) {
return {
outcome: "skipped",
sourceDir,
targetDir,
reason: "existing symlink does not look like a Fusion skill install",
};
}
unlinkSync(targetDir);
replaced = true;
} else {
if (!looksLikePriorFusionInstall(targetDir)) {
return {
outcome: "skipped",
sourceDir,
targetDir,
reason: "existing directory does not look like a Fusion skill install",
};
}
rmSync(targetDir, { recursive: true, force: true });
replaced = true;
}
}
try {
symlinkSync(sourceDir, targetDir, "dir");
} catch (error) {
const symlinkReason = error instanceof Error ? error.message : String(error);
try {
cpSync(sourceDir, targetDir, { recursive: true });
return {
outcome: replaced ? "replaced" : "installed",
sourceDir,
targetDir,
reason: `symlink failed (${symlinkReason}); copied files instead`,
};
} catch (copyError) {
return {
outcome: "warning",
sourceDir,
targetDir,
reason: copyError instanceof Error ? copyError.message : String(copyError),
};
}
}
return { outcome: replaced ? "replaced" : "installed", sourceDir, targetDir };
} catch (error) {
return {
outcome: "warning",
sourceDir,
targetDir,
reason: error instanceof Error ? error.message : String(error),
};
}
}
function safeReadlink(path: string): string | null {
try {
return readlinkSync(path);
} catch {
return null;
}
}
function isBrokenSymlink(path: string): boolean {
try {
const stat = lstatSync(path);
return stat.isSymbolicLink() && !existsSync(path);
} catch {
return false;
}
}
function looksLikePriorFusionInstall(path: string): boolean {
const skillMd = join(path, "SKILL.md");
if (!existsSync(skillMd)) return false;
try {
const body = readFileSync(skillMd, "utf-8");
return /\bfusion\b/i.test(body) && /\bskill\b/i.test(body);
} catch {
return false;
}
}
function looksLikeFusionSkillTarget(path: string): boolean {
if (!path) return false;
if (basename(path).toLowerCase() === FUSION_SKILL_NAME) return true;
return existsSync(join(path, "SKILL.md"));
}

View File

@@ -9,6 +9,7 @@
import { definePlugin } from "@fusion/plugin-sdk";
import { resolveCliSettings } from "./cli-spawn.js";
import { installFusionSkillIntoHermesHome } from "./fusion-skill-install.js";
import { HermesRuntimeAdapter } from "./runtime-adapter.js";
import type {
FusionPlugin,
@@ -51,8 +52,20 @@ const plugin: FusionPlugin = definePlugin({
hooks: {
onLoad: (ctx) => {
const settings = resolveCliSettings(ctx.settings);
const skillInstall = installFusionSkillIntoHermesHome({ profile: settings.profile });
if (skillInstall.outcome === "warning") {
ctx.logger.warn(
`Hermes Runtime Plugin: Fusion skill auto-install warning: ${skillInstall.reason ?? "unknown"}`,
);
} else if (skillInstall.outcome === "skipped") {
ctx.logger.warn(
`Hermes Runtime Plugin: Fusion skill auto-install skipped: ${skillInstall.reason ?? "unknown"}`,
);
}
ctx.logger.info(
`Hermes Runtime Plugin loaded — binary=${settings.binaryPath} model=${settings.model ?? "(default)"}`,
`Hermes Runtime Plugin loaded — binary=${settings.binaryPath} model=${settings.model ?? "(default)"} fusionSkill=${skillInstall.outcome}`,
);
ctx.emitEvent("hermes-runtime:loaded", {
runtimeId: HERMES_RUNTIME_ID,
@@ -82,6 +95,11 @@ export {
parseHermesOutput,
listHermesProfiles,
} from "./cli-spawn.js";
export {
installFusionSkillIntoHermesHome,
resolveBundledFusionSkillSource,
resolveHermesHome,
} from "./fusion-skill-install.js";
export type { HermesCliSettings, HermesCliResult, HermesProfileSummary } from "./cli-spawn.js";
// Probe re-export for the dashboard's runtime-provider-probes façade.