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

@@ -0,0 +1,15 @@
---
"@runfusion/fusion": patch
---
Fix ChatView composer sliding over messages on mobile when the user
swipes with the keyboard up. The visualViewport `--vv-height` and
`--vv-offset-top` CSS vars were being routed through React state, so
on iOS — which fires visualViewport scroll/resize on the same frame
as its keyboard animation — the thread translation lagged by one
paint. The composer briefly appeared to slide over the message list
during a pan. The vars are now written imperatively in a
`useLayoutEffect` directly to the `.chat-thread` element on every
visualViewport event, mirroring the working pattern in
`QuickChatFAB.tsx:1032-1052`. Only `--keyboard-overlap` (a structural
open/close signal) still goes through React state.

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Auto-install the bundled Fusion skill when the Hermes runtime plugin loads, including profile-aware Hermes skill-path resolution and safe idempotent replacement behavior. Hermes runtime startup now continues with warnings if skill mirroring fails.

View File

@@ -248,6 +248,19 @@ Expected outcome: Plugin is removed by ID.
3. Confirm plugin source path and dependencies are valid. 3. Confirm plugin source path and dependencies are valid.
4. If needed, uninstall and reinstall the plugin. 4. If needed, uninstall and reinstall the plugin.
### Hermes runtime plugin loaded but Fusion skill is missing in Hermes
The Hermes runtime plugin auto-installs the bundled Fusion skill on plugin load.
1. Confirm the plugin is `started`.
2. Check the Hermes skill path:
- default profile: `${HERMES_HOME:-~/.hermes}/skills/fusion`
- named profile: `${HERMES_HOME:-~/.hermes}/profiles/<profile>/skills/fusion`
3. Review plugin warnings; startup continues even if skill mirroring fails.
4. Reload/disable+enable the plugin to re-run the self-healing install attempt.
Manual post-install skill setup is typically not required for Hermes runtime anymore.
### Plugin installed but features are not visible ### Plugin installed but features are not visible
1. Confirm plugin state is `started`. 1. Confirm plugin state is `started`.

View File

@@ -1,6 +1,6 @@
// ChatView.css is imported eagerly from App.tsx to avoid a flash of // ChatView.css is imported eagerly from App.tsx to avoid a flash of
// unstyled content when the lazy chunk loads. Do not re-import here. // unstyled content when the lazy chunk loads. Do not re-import here.
import { memo, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react"; import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
import type { Components } from "react-markdown"; import type { Components } from "react-markdown";
@@ -793,19 +793,52 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
} }
}, []); }, []);
const { keyboardOverlap, viewportHeight, viewportOffsetTop, keyboardOpen } = useMobileKeyboard({ const { keyboardOverlap, keyboardOpen } = useMobileKeyboard({
enabled: isMobile && !!activeSession, enabled: isMobile && !!activeSession,
}); });
// Only structural per-open/close vars go through React state. The
// high-frequency --vv-height / --vv-offset-top vars are written
// imperatively below to avoid a one-frame lag during iOS keyboard
// pan that causes the composer to slide over the messages list.
const threadKeyboardStyle: CSSProperties = const threadKeyboardStyle: CSSProperties =
keyboardOpen keyboardOpen
? ({ ? ({ "--keyboard-overlap": `${keyboardOverlap}px` } as CSSProperties)
"--keyboard-overlap": `${keyboardOverlap}px`,
"--vv-offset-top": `${viewportOffsetTop}px`,
...(viewportHeight !== null ? { "--vv-height": `${viewportHeight}px` } : {}),
} as CSSProperties)
: {}; : {};
const threadRef = useRef<HTMLDivElement>(null);
// Mirror visualViewport metrics onto the .chat-thread element as CSS
// vars synchronously, bypassing React state. iOS fires
// visualViewport scroll/resize events on the same frame as its own
// keyboard / pan animation; deferring writes through React state
// makes the thread (and its bottom-pinned composer) lag by one paint
// — visible as the composer momentarily sliding over messages while
// the user pans. Mirrors QuickChatFAB.tsx:1032-1052 which uses the
// same approach and works correctly on mobile.
useLayoutEffect(() => {
if (!keyboardOpen) return;
if (typeof window === "undefined" || !window.visualViewport) return;
const thread = threadRef.current;
if (!thread) return;
const vv = window.visualViewport;
const apply = () => {
thread.style.setProperty("--vv-height", `${vv.height}px`);
thread.style.setProperty("--vv-offset-top", `${vv.offsetTop || 0}px`);
};
apply();
vv.addEventListener("resize", apply);
vv.addEventListener("scroll", apply);
return () => {
vv.removeEventListener("resize", apply);
vv.removeEventListener("scroll", apply);
thread.style.removeProperty("--vv-height");
thread.style.removeProperty("--vv-offset-top");
};
}, [keyboardOpen]);
const filteredSkills = useMemo(() => { const filteredSkills = useMemo(() => {
const normalizedFilter = skillFilter.trim().toLowerCase(); const normalizedFilter = skillFilter.trim().toLowerCase();
const matchingSkills = normalizedFilter const matchingSkills = normalizedFilter
@@ -1704,7 +1737,7 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
)} )}
{/* Thread */} {/* Thread */}
<div className="chat-thread" style={threadKeyboardStyle}> <div className="chat-thread" ref={threadRef} style={threadKeyboardStyle}>
{/* Header - always rendered in desktop/tablet, only rendered in mobile when viewing a thread */} {/* Header - always rendered in desktop/tablet, only rendered in mobile when viewing a thread */}
{(hasThreadInView || !isMobile) && ( {(hasThreadInView || !isMobile) && (
<div className="chat-thread-header"> <div className="chat-thread-header">

View File

@@ -29,6 +29,21 @@ After install, run `hermes login` (or `hermes auth`) to configure a provider. Th
Verify with `hermes --version`. 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 ## Limitations
Because we drive the CLI's `chat -q` mode: 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"; import { beforeEach, describe, expect, it, vi } from "vitest";
const { mockResolveCli } = vi.hoisted(() => ({ const { mockResolveCli, mockInstallFusionSkill } = vi.hoisted(() => ({
mockResolveCli: vi.fn().mockReturnValue({ mockResolveCli: vi.fn().mockReturnValue({
binaryPath: "hermes", binaryPath: "hermes",
model: undefined, model: undefined,
@@ -8,6 +8,11 @@ const { mockResolveCli } = vi.hoisted(() => ({
yolo: false, yolo: false,
cliTimeoutMs: 300_000, cliTimeoutMs: 300_000,
}), }),
mockInstallFusionSkill: vi.fn().mockReturnValue({
outcome: "installed",
sourceDir: "/tmp/source",
targetDir: "/tmp/target",
}),
})); }));
vi.mock("../cli-spawn.js", async () => { vi.mock("../cli-spawn.js", async () => {
const actual = await vi.importActual("../cli-spawn.js"); const actual = await vi.importActual("../cli-spawn.js");
@@ -16,7 +21,14 @@ vi.mock("../cli-spawn.js", async () => {
resolveCliSettings: mockResolveCli, 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"; import { HermesRuntimeAdapter } from "../runtime-adapter.js";
function createMockContext(settings = {}) { function createMockContext(settings = {}) {
return { return {
@@ -37,6 +49,12 @@ describe("hermes-runtime plugin", () => {
maxTurns: 12, maxTurns: 12,
yolo: false, yolo: false,
cliTimeoutMs: 300_000, cliTimeoutMs: 300_000,
profile: undefined,
});
mockInstallFusionSkill.mockReturnValue({
outcome: "installed",
sourceDir: "/tmp/source",
targetDir: "/tmp/target",
}); });
}); });
it("has expected manifest identity", () => { it("has expected manifest identity", () => {
@@ -59,11 +77,28 @@ describe("hermes-runtime plugin", () => {
maxTurns: 12, maxTurns: 12,
yolo: false, yolo: false,
cliTimeoutMs: 300_000, cliTimeoutMs: 300_000,
profile: undefined,
}); });
const ctx = createMockContext({ binaryPath: "/opt/homebrew/bin/hermes" }); const ctx = createMockContext({ binaryPath: "/opt/homebrew/bin/hermes" });
await plugin.hooks.onLoad(ctx); await plugin.hooks.onLoad(ctx);
expect(mockResolveCli).toHaveBeenCalledWith(ctx.settings); 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", { expect(ctx.emitEvent).toHaveBeenCalledWith("hermes-runtime:loaded", {
runtimeId: "hermes", runtimeId: "hermes",
version: plugin.manifest.version, 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"); await adapter.promptWithFallback(session, "first prompt");
expect(mockInvoke).toHaveBeenCalledTimes(1); expect(mockInvoke).toHaveBeenCalledTimes(1);
const [prompt, settings, resumeId] = mockInvoke.mock.calls[0]; 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(settings.model).toBe("claude-sonnet-4-5");
expect(resumeId).toBeUndefined(); expect(resumeId).toBeUndefined();
expect(onText).toHaveBeenCalledWith("hello from hermes"); expect(onText).toHaveBeenCalledWith("hello from hermes");
@@ -66,7 +67,8 @@ describe("HermesRuntimeAdapter — promptWithFallback", () => {
}); });
await adapter.promptWithFallback(session, "p1"); await adapter.promptWithFallback(session, "p1");
await adapter.promptWithFallback(session, "p2"); 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"); expect(resume2).toBe("20260427_120000_abc123");
}); });
it("propagates CLI errors", async () => { 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 { hermesRuntimeMetadata, hermesRuntimeFactory, HERMES_RUNTIME_ID };
export { HermesRuntimeAdapter } from "./runtime-adapter.js"; export { HermesRuntimeAdapter } from "./runtime-adapter.js";
export { resolveCliSettings, invokeHermesCli, buildHermesArgs, parseHermesOutput, listHermesProfiles, } from "./cli-spawn.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 type { HermesCliSettings, HermesCliResult, HermesProfileSummary } from "./cli-spawn.js";
export { probeHermesBinary } from "./probe.js"; export { probeHermesBinary } from "./probe.js";
export type { HermesBinaryStatus } 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 { definePlugin } from "@fusion/plugin-sdk";
import { resolveCliSettings } from "./cli-spawn.js"; import { resolveCliSettings } from "./cli-spawn.js";
import { installFusionSkillIntoHermesHome } from "./fusion-skill-install.js";
import { HermesRuntimeAdapter } from "./runtime-adapter.js"; import { HermesRuntimeAdapter } from "./runtime-adapter.js";
// ── Hermes Runtime Metadata ─────────────────────────────────────────────────── // ── Hermes Runtime Metadata ───────────────────────────────────────────────────
const HERMES_RUNTIME_ID = "hermes"; const HERMES_RUNTIME_ID = "hermes";
@@ -37,7 +38,14 @@ const plugin = definePlugin({
hooks: { hooks: {
onLoad: (ctx) => { onLoad: (ctx) => {
const settings = resolveCliSettings(ctx.settings); 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", { ctx.emitEvent("hermes-runtime:loaded", {
runtimeId: HERMES_RUNTIME_ID, runtimeId: HERMES_RUNTIME_ID,
version: HERMES_RUNTIME_VERSION, version: HERMES_RUNTIME_VERSION,
@@ -57,6 +65,7 @@ export default plugin;
export { hermesRuntimeMetadata, hermesRuntimeFactory, HERMES_RUNTIME_ID }; export { hermesRuntimeMetadata, hermesRuntimeFactory, HERMES_RUNTIME_ID };
export { HermesRuntimeAdapter } from "./runtime-adapter.js"; export { HermesRuntimeAdapter } from "./runtime-adapter.js";
export { resolveCliSettings, invokeHermesCli, buildHermesArgs, parseHermesOutput, listHermesProfiles, } from "./cli-spawn.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. // Probe re-export for the dashboard's runtime-provider-probes façade.
export { probeHermesBinary } from "./probe.js"; export { probeHermesBinary } from "./probe.js";
//# sourceMappingURL=index.js.map //# 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. * session pass `--resume <id>` to continue the conversation.
*/ */
import { invokeHermesCli, resolveCliSettings } from "./cli-spawn.js"; 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 { export class HermesRuntimeAdapter {
id = "hermes"; id = "hermes";
name = "Hermes Runtime"; name = "Hermes Runtime";
@@ -28,13 +45,18 @@ export class HermesRuntimeAdapter {
onToolStart: options.onToolStart, onToolStart: options.onToolStart,
onToolEnd: options.onToolEnd, onToolEnd: options.onToolEnd,
}, },
runtimeContext: options.runtimeContext,
fusedSystemPrompt: [options.systemPrompt.trim(), buildRuntimeContextSection(options).trim()].filter((part) => part.length > 0).join("\n\n"),
dispose: () => undefined, dispose: () => undefined,
}; };
return { session, sessionFile: undefined }; return { session, sessionFile: undefined };
} }
async promptWithFallback(session, prompt, _options) { async promptWithFallback(session, prompt, _options) {
const resumeId = session.sessionId || undefined; 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.sessionId = result.sessionId;
session.lastModelDescription = this.describeFromSettings(); session.lastModelDescription = this.describeFromSettings();
if (result.body) { 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":"AAAA;;;;;;GAMG;AAEH,OAAO,EAAE,eAAe,EAAE,kBAAkB,EAAE,MAAM,gBAAgB,CAAC;AAUrE,SAAS,0BAA0B,CAAC,OAA4B;IAC9D,MAAM,UAAU,GAAG,KAAK,CAAC,OAAO,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,KAAK,EAAmB,EAAE,CAAC,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,CAAC,IAAI,EAAE,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;IAChK,MAAM,cAAc,GAAG,OAAO,CAAC,cAA+D,CAAC;IAC/F,MAAM,mBAAmB,GAAG,KAAK,CAAC,OAAO,CAAC,cAAc,EAAE,mBAAmB,CAAC;QAC5E,CAAC,CAAC,cAAc,CAAC,mBAAmB,CAAC,MAAM,CAAC,CAAC,KAAK,EAAmB,EAAE,CAAC,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,CAAC,IAAI,EAAE,CAAC,MAAM,GAAG,CAAC,CAAC;QAC7H,CAAC,CAAC,EAAE,CAAC;IACP,MAAM,YAAY,GAAG,UAAU,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,mBAAmB,CAAC;IAE9E,MAAM,KAAK,GAAa;QACtB,yBAAyB;QACzB,gBAAgB,OAAO,CAAC,KAAK,IAAI,QAAQ,EAAE;KAC5C,CAAC;IAEF,IAAI,YAAY,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QAC5B,KAAK,CAAC,IAAI,CAAC,uBAAuB,YAAY,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;IAC/D,CAAC;IAED,KAAK,CAAC,IAAI,CAAC,wGAAwG,CAAC,CAAC;IAErH,OAAO,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AAC1B,CAAC;AAED,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,cAAc,EAAE,OAAO,CAAC,cAAc;YACtC,iBAAiB,EAAE,CAAC,OAAO,CAAC,YAAY,CAAC,IAAI,EAAE,EAAE,0BAA0B,CAAC,OAAO,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC;YAC3I,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,iBAAiB,GAAG,QAAQ;YAChC,CAAC,CAAC,MAAM;YACR,CAAC,CAAC,GAAG,OAAO,CAAC,iBAAiB,sBAAsB,MAAM,EAAE,CAAC;QAC/D,MAAM,MAAM,GAAG,MAAM,eAAe,CAAC,iBAAiB,EAAE,IAAI,CAAC,QAAQ,EAAE,QAAQ,CAAC,CAAC;QAEjF,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"}

View File

@@ -10,6 +10,12 @@ export interface HermesCallbacks {
onToolStart?: (toolName: string, args?: unknown) => void; onToolStart?: (toolName: string, args?: unknown) => void;
onToolEnd?: (toolName: string, isError: boolean, result?: 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 { export interface HermesStreamSession {
model: unknown; model: unknown;
systemPrompt: string; systemPrompt: string;
@@ -20,6 +26,8 @@ export interface HermesStreamSession {
lastModelDescription: string; lastModelDescription: string;
callbacks: HermesCallbacks; callbacks: HermesCallbacks;
usage?: unknown; usage?: unknown;
runtimeContext?: HermesRuntimeContext;
fusedSystemPrompt: string;
dispose(): void; dispose(): void;
} }
export type AgentSession = HermesStreamSession; export type AgentSession = HermesStreamSession;
@@ -45,6 +53,7 @@ export interface AgentRuntimeOptions {
sessionManager?: unknown; sessionManager?: unknown;
skillSelection?: unknown; skillSelection?: unknown;
skills?: string[]; skills?: string[];
runtimeContext?: HermesRuntimeContext;
} }
/** Result of creating a session. */ /** Result of creating a session. */
export interface AgentSessionResult { 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"; import { beforeEach, describe, expect, it, vi } from "vitest";
const { mockResolveCli } = vi.hoisted(() => ({ const { mockResolveCli, mockInstallFusionSkill } = vi.hoisted(() => ({
mockResolveCli: vi.fn().mockReturnValue({ mockResolveCli: vi.fn().mockReturnValue({
binaryPath: "hermes", binaryPath: "hermes",
model: undefined, model: undefined,
@@ -9,23 +9,32 @@ const { mockResolveCli } = vi.hoisted(() => ({
yolo: false, yolo: false,
cliTimeoutMs: 300_000, cliTimeoutMs: 300_000,
}), }),
mockInstallFusionSkill: vi.fn().mockReturnValue({
outcome: "installed",
sourceDir: "/tmp/source",
targetDir: "/tmp/target",
}),
})); }));
vi.mock("../cli-spawn.js", async () => { vi.mock("../cli-spawn.js", async () => {
const actual = await vi.importActual<typeof import("../cli-spawn.js")>( const actual = await vi.importActual<typeof import("../cli-spawn.js")>("../cli-spawn.js");
"../cli-spawn.js",
);
return { return {
...actual, ...actual,
resolveCliSettings: mockResolveCli, resolveCliSettings: mockResolveCli,
}; };
}); });
import plugin, { vi.mock("../fusion-skill-install.js", async () => {
hermesRuntimeMetadata, const actual = await vi.importActual<typeof import("../fusion-skill-install.js")>(
hermesRuntimeFactory, "../fusion-skill-install.js",
HERMES_RUNTIME_ID, );
} from "../index.js"; return {
...actual,
installFusionSkillIntoHermesHome: mockInstallFusionSkill,
};
});
import plugin, { HERMES_RUNTIME_ID, hermesRuntimeFactory, hermesRuntimeMetadata } from "../index.js";
import { HermesRuntimeAdapter } from "../runtime-adapter.js"; import { HermesRuntimeAdapter } from "../runtime-adapter.js";
function createMockContext(settings: Record<string, unknown> = {}) { function createMockContext(settings: Record<string, unknown> = {}) {
@@ -48,6 +57,12 @@ describe("hermes-runtime plugin", () => {
maxTurns: 12, maxTurns: 12,
yolo: false, yolo: false,
cliTimeoutMs: 300_000, 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, maxTurns: 12,
yolo: false, yolo: false,
cliTimeoutMs: 300_000, cliTimeoutMs: 300_000,
profile: undefined,
}); });
const ctx = createMockContext({ binaryPath: "/opt/homebrew/bin/hermes" }); const ctx = createMockContext({ binaryPath: "/opt/homebrew/bin/hermes" });
await plugin.hooks!.onLoad!(ctx as any); await plugin.hooks!.onLoad!(ctx as any);
expect(mockResolveCli).toHaveBeenCalledWith(ctx.settings); expect(mockResolveCli).toHaveBeenCalledWith(ctx.settings);
expect(ctx.logger.info).toHaveBeenCalledWith( expect(mockInstallFusionSkill).toHaveBeenCalledWith({ profile: undefined });
expect.stringContaining("/opt/homebrew/bin/hermes"), 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", { expect(ctx.emitEvent).toHaveBeenCalledWith("hermes-runtime:loaded", {
runtimeId: "hermes", runtimeId: "hermes",
version: plugin.manifest.version, 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 { definePlugin } from "@fusion/plugin-sdk";
import { resolveCliSettings } from "./cli-spawn.js"; import { resolveCliSettings } from "./cli-spawn.js";
import { installFusionSkillIntoHermesHome } from "./fusion-skill-install.js";
import { HermesRuntimeAdapter } from "./runtime-adapter.js"; import { HermesRuntimeAdapter } from "./runtime-adapter.js";
import type { import type {
FusionPlugin, FusionPlugin,
@@ -51,8 +52,20 @@ const plugin: FusionPlugin = definePlugin({
hooks: { hooks: {
onLoad: (ctx) => { onLoad: (ctx) => {
const settings = resolveCliSettings(ctx.settings); 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( 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", { ctx.emitEvent("hermes-runtime:loaded", {
runtimeId: HERMES_RUNTIME_ID, runtimeId: HERMES_RUNTIME_ID,
@@ -82,6 +95,11 @@ export {
parseHermesOutput, parseHermesOutput,
listHermesProfiles, listHermesProfiles,
} from "./cli-spawn.js"; } from "./cli-spawn.js";
export {
installFusionSkillIntoHermesHome,
resolveBundledFusionSkillSource,
resolveHermesHome,
} from "./fusion-skill-install.js";
export type { HermesCliSettings, HermesCliResult, HermesProfileSummary } 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. // Probe re-export for the dashboard's runtime-provider-probes façade.