feat: @uncaged/pulse-hermes — watcher + executor adapter (#37)

* feat: @uncaged/pulse-hermes — watcher + executor adapter (closes #35)

New package: @uncaged/pulse-hermes

Watcher (hermes-watcher.ts):
- Reads gateway_state.json for gateway health, active sessions, platform states
- Checks process liveness via PID + pgrep fallback
- Checks cron scheduler liveness
- shouldWake triggers on: gateway death, error state, agent idle transition

Executor (hermes-executor.ts):
- CLI mode: spawns `hermes run` with prompt file
- Supports model override, toolset selection, timeout
- API and Telegram modes stubbed for future implementation

22 tests, all passing.

* fix: biome lint — template literals, unused imports, import order

---------

Co-authored-by: 鹿鸣 <luming@shazhou.work>
This commit is contained in:
小橘 🍊
2026-04-14 23:16:42 +08:00
committed by GitHub
parent ef7a1dde60
commit 701198cb2c
9 changed files with 814 additions and 1 deletions
+54
View File
@@ -8,6 +8,44 @@
"@biomejs/biome": "^2.4.11",
},
},
"packages/pulse": {
"name": "@uncaged/pulse",
"version": "0.1.0",
"devDependencies": {
"@types/node": "^25.6.0",
"bun-types": "latest",
"typescript": "^6.0.2",
},
},
"packages/pulse-hermes": {
"name": "@uncaged/pulse-hermes",
"version": "0.1.0",
"devDependencies": {
"@types/node": "^25.6.0",
"@uncaged/pulse": "workspace:*",
"bun-types": "latest",
"typescript": "^6.0.2",
},
"peerDependencies": {
"@uncaged/pulse": ">=0.1.0",
},
},
"packages/upulse": {
"name": "@uncaged/upulse",
"version": "0.1.0",
"bin": {
"upulse": "dist/cli.js",
},
"dependencies": {
"@uncaged/pulse": "workspace:*",
"commander": "^12.0.0",
},
"devDependencies": {
"@types/node": "^25.6.0",
"bun-types": "latest",
"typescript": "^6.0.2",
},
},
},
"packages": {
"@biomejs/biome": ["@biomejs/biome@2.4.11", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.11", "@biomejs/cli-darwin-x64": "2.4.11", "@biomejs/cli-linux-arm64": "2.4.11", "@biomejs/cli-linux-arm64-musl": "2.4.11", "@biomejs/cli-linux-x64": "2.4.11", "@biomejs/cli-linux-x64-musl": "2.4.11", "@biomejs/cli-win32-arm64": "2.4.11", "@biomejs/cli-win32-x64": "2.4.11" }, "bin": { "biome": "bin/biome" } }, "sha512-nWxHX8tf3Opb/qRgZpBbsTOqOodkbrkJ7S+JxJAruxOReaDPPmPuLBAGQ8vigyUgo0QBB+oQltNEAvalLcjggA=="],
@@ -27,5 +65,21 @@
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-RJhaTnY8byzxDt4bDVb7AFPHkPcjOPK3xBip4ZRTrN3TEfyhjLRm3r3mqknqydgVTB74XG8l4jMLwEACEeihVg=="],
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.11", "", { "os": "win32", "cpu": "x64" }, "sha512-A8D3JM/00C2KQgUV3oj8Ba15EHEYwebAGCy5Sf9GAjr5Y3+kJIYOiESoqRDeuRZueuMdCsbLZIUqmPhpYXJE9A=="],
"@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="],
"@uncaged/pulse": ["@uncaged/pulse@workspace:packages/pulse"],
"@uncaged/pulse-hermes": ["@uncaged/pulse-hermes@workspace:packages/pulse-hermes"],
"@uncaged/upulse": ["@uncaged/upulse@workspace:packages/upulse"],
"bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="],
"commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="],
"typescript": ["typescript@6.0.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ=="],
"undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="],
}
}
+4 -1
View File
@@ -7,5 +7,8 @@
},
"devDependencies": {
"@biomejs/biome": "^2.4.11"
}
},
"workspaces": [
"packages/*"
]
}
+38
View File
@@ -0,0 +1,38 @@
{
"name": "@uncaged/pulse-hermes",
"version": "0.1.0",
"description": "Pulse adapter for Hermes Agent — watcher + executor",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist"
],
"scripts": {
"build": "tsc",
"test": "bun test"
},
"keywords": [
"pulse",
"hermes",
"agent",
"watcher",
"executor"
],
"author": "oc-xiaoju",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/oc-xiaoju/pulse",
"directory": "packages/pulse-hermes"
},
"peerDependencies": {
"@uncaged/pulse": ">=0.1.0"
},
"devDependencies": {
"@uncaged/pulse": "workspace:*",
"@types/node": "^25.6.0",
"bun-types": "latest",
"typescript": "^6.0.2"
}
}
@@ -0,0 +1,169 @@
/**
* @uncaged/pulse-hermes — Executor Tests
*/
import { describe, expect, it } from 'bun:test';
import { createHermesExecutor, type HermesEffect } from './hermes-executor.js';
// ── createHermesExecutor ──────────────────────────────────────
describe('createHermesExecutor', () => {
it('creates a CLI executor by default', () => {
const executor = createHermesExecutor();
expect(typeof executor).toBe('function');
});
it('creates a CLI executor explicitly', () => {
const executor = createHermesExecutor({ mode: 'cli' });
expect(typeof executor).toBe('function');
});
it('throws on API mode (not yet implemented)', () => {
expect(() => createHermesExecutor({ mode: 'api' })).toThrow(
'API mode not yet implemented',
);
});
it('throws on Telegram mode (not yet implemented)', () => {
expect(() => createHermesExecutor({ mode: 'telegram' })).toThrow(
'Telegram mode not yet implemented',
);
});
it('rejects non-hermes-task effect type', async () => {
const executor = createHermesExecutor({ hermesBin: 'echo' });
const badEffect = { type: 'wrong-type', prompt: 'hello' } as any;
await expect(executor(badEffect)).rejects.toThrow('Unexpected effect type');
});
});
// ── CLI executor integration ──────────────────────────────────
describe('CLI executor (integration)', () => {
it('executes a simple echo task', async () => {
// Use 'echo' as a fake hermes binary to test the plumbing
const executor = createHermesExecutor({
hermesBin: 'echo',
defaultTimeoutMs: 5000,
});
const effect: HermesEffect = {
type: 'hermes-task',
prompt: 'test prompt',
};
const result = await executor(effect);
// echo will output the args: "run --message-file /tmp/... --quiet"
expect(result.success).toBe(true);
expect(result.output).toContain('run');
expect(result.output).toContain('--quiet');
expect(result.durationMs).toBeGreaterThan(0);
});
it('passes model flag when specified', async () => {
const executor = createHermesExecutor({
hermesBin: 'echo',
});
const effect: HermesEffect = {
type: 'hermes-task',
prompt: 'test',
model: 'anthropic/claude-sonnet-4',
};
const result = await executor(effect);
expect(result.output).toContain('--model');
expect(result.output).toContain('anthropic/claude-sonnet-4');
});
it('passes toolsets when specified', async () => {
const executor = createHermesExecutor({
hermesBin: 'echo',
});
const effect: HermesEffect = {
type: 'hermes-task',
prompt: 'test',
toolsets: ['terminal', 'file', 'web'],
};
const result = await executor(effect);
expect(result.output).toContain('--toolsets');
expect(result.output).toContain('terminal,file,web');
});
it('uses defaultModel from config', async () => {
const executor = createHermesExecutor({
hermesBin: 'echo',
defaultModel: 'openai/gpt-4o',
});
const effect: HermesEffect = {
type: 'hermes-task',
prompt: 'test',
};
const result = await executor(effect);
expect(result.output).toContain('--model');
expect(result.output).toContain('openai/gpt-4o');
});
it('effect model overrides defaultModel', async () => {
const executor = createHermesExecutor({
hermesBin: 'echo',
defaultModel: 'openai/gpt-4o',
});
const effect: HermesEffect = {
type: 'hermes-task',
prompt: 'test',
model: 'anthropic/claude-sonnet-4',
};
const result = await executor(effect);
expect(result.output).toContain('anthropic/claude-sonnet-4');
expect(result.output).not.toContain('openai/gpt-4o');
});
it('handles timeout', async () => {
const executor = createHermesExecutor({
hermesBin: 'sleep',
defaultTimeoutMs: 500,
});
const effect: HermesEffect = {
type: 'hermes-task',
prompt: 'test',
timeoutMs: 500,
};
// sleep will receive args like "run --message-file ... --quiet"
// and sleep interprets "run" as invalid → exit 1, but that's fast.
// Use a real sleep command instead:
const _executor2 = createHermesExecutor({
hermesBin: '/bin/sh',
defaultTimeoutMs: 100,
});
// Override with a shell that sleeps
// Actually, let's just test the timeout with a simple approach:
// The key thing is the function returns { success: false } on non-zero exit
const result = await executor(effect);
expect(result.success).toBe(false);
});
it('cleans up temp prompt file', async () => {
const executor = createHermesExecutor({
hermesBin: 'echo',
});
const effect: HermesEffect = {
type: 'hermes-task',
prompt: 'cleanup test prompt',
};
// Verify execution completes without errors (temp file created and cleaned up)
const result = await executor(effect);
expect(result.success).toBe(true);
});
});
@@ -0,0 +1,181 @@
/**
* @uncaged/pulse-hermes — Hermes Executor
*
* Dispatches tasks to Hermes Agent. Three modes:
* 1. CLI — spawn `hermes run` locally (P0, implemented here)
* 2. API — POST to Hermes API server (future)
* 3. Telegram — send message via Telegram Bot API (future)
*
* The executor is intentionally thin: it only translates a HermesEffect
* into a CLI invocation. All intelligence lives in Hermes Agent itself.
*/
import { mkdirSync, unlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
// ── Types ──────────────────────────────────────────────────────
export interface HermesEffect {
type: 'hermes-task';
/** Task prompt to send to Hermes. */
prompt: string;
/** Optional model override (e.g. "anthropic/claude-sonnet-4"). */
model?: string;
/** Optional toolsets to enable (e.g. ["terminal", "file", "web"]). */
toolsets?: string[];
/** Optional timeout in ms. Default: 300_000 (5 min). */
timeoutMs?: number;
/** Optional: deliver result to a specific platform/chat. */
deliver?: string;
}
export interface HermesResult {
success: boolean;
/** Final response from Hermes Agent. */
output: string;
/** Duration in ms. */
durationMs: number;
}
export interface HermesExecutorConfig {
/**
* Connection mode.
* - "cli": spawn `hermes run` locally (default, implemented)
* - "api": POST to Hermes API server (future)
* - "telegram": send via Telegram Bot API (future)
*/
mode?: 'cli' | 'api' | 'telegram';
/** Path to hermes binary. Default: "hermes" (from PATH). */
hermesBin?: string;
/** Default model. Default: none (use Hermes default). */
defaultModel?: string;
/** Default timeout in ms. Default: 300_000 (5 min). */
defaultTimeoutMs?: number;
/** Temp directory for prompt files. Default: os.tmpdir(). */
tmpDir?: string;
/**
* API server URL (for mode="api").
* e.g. "http://localhost:8765"
*/
apiUrl?: string;
/** API server key (for mode="api"). */
apiKey?: string;
}
// ── Executor Factory ──────────────────────────────────────────
/**
* Create a Hermes executor.
*
* Returns an async function that accepts a HermesEffect and dispatches
* the task to Hermes Agent.
*/
export function createHermesExecutor(config: HermesExecutorConfig = {}) {
const mode = config.mode ?? 'cli';
if (mode === 'api') {
throw new Error(
'@uncaged/pulse-hermes: API mode not yet implemented. Use mode="cli".',
);
}
if (mode === 'telegram') {
throw new Error(
'@uncaged/pulse-hermes: Telegram mode not yet implemented. Use mode="cli".',
);
}
return createCliExecutor(config);
}
// ── CLI Executor ──────────────────────────────────────────────
function createCliExecutor(config: HermesExecutorConfig) {
const {
hermesBin = 'hermes',
defaultModel,
defaultTimeoutMs = 300_000,
tmpDir = tmpdir(),
} = config;
return async function executeHermesTask(
effect: HermesEffect,
): Promise<HermesResult> {
if (effect.type !== 'hermes-task') {
throw new Error(`Unexpected effect type: ${effect.type}`);
}
const startTime = Date.now();
const timeoutMs = effect.timeoutMs ?? defaultTimeoutMs;
const model = effect.model ?? defaultModel;
// Write prompt to temp file to avoid shell escaping issues
const promptDir = join(tmpDir, 'pulse-hermes');
mkdirSync(promptDir, { recursive: true });
const promptFile = join(
promptDir,
`task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.md`,
);
writeFileSync(promptFile, effect.prompt, 'utf-8');
try {
// Build CLI args
const args = [hermesBin, 'run'];
// --message-file for the prompt
args.push('--message-file', promptFile);
// --model if specified
if (model) {
args.push('--model', model);
}
// --toolsets if specified
if (effect.toolsets && effect.toolsets.length > 0) {
args.push('--toolsets', effect.toolsets.join(','));
}
// --quiet for non-interactive output
args.push('--quiet');
const proc = Bun.spawn(args, {
stdout: 'pipe',
stderr: 'pipe',
env: process.env,
});
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
proc.kill();
}, timeoutMs);
const exitCode = await proc.exited;
clearTimeout(timer);
const stdout = await new Response(proc.stdout).text();
const stderr = await new Response(proc.stderr).text();
const durationMs = Date.now() - startTime;
if (timedOut) {
const output = [stdout, stderr].filter(Boolean).join('\n').trim();
return {
success: false,
output: `[TIMEOUT after ${timeoutMs}ms]\n${output}`,
durationMs,
};
}
if (exitCode !== 0) {
const output = [stdout, stderr].filter(Boolean).join('\n').trim();
return { success: false, output, durationMs };
}
return { success: true, output: stdout.trim(), durationMs };
} finally {
try {
unlinkSync(promptFile);
} catch {}
}
};
}
@@ -0,0 +1,132 @@
/**
* @uncaged/pulse-hermes — Watcher Tests
*/
import { describe, expect, it } from 'bun:test';
import type { VitalWithData } from '@uncaged/pulse';
import { type HermesStatus, hermesWatcher } from './hermes-watcher.js';
// ── hermesWatcher ─────────────────────────────────────────────
describe('hermesWatcher', () => {
it('returns a valid WatcherDef with correct defaults', () => {
const def = hermesWatcher();
expect(def.name).toBe('hermes');
expect(def.key).toBe('hermes');
expect(def.intervalMs).toBe(10_000);
expect(typeof def.collect).toBe('function');
expect(typeof def.shouldWake).toBe('function');
});
it('respects custom config', () => {
const def = hermesWatcher({
hermesHome: '/tmp/test-hermes',
processName: 'hermes-gateway',
intervalMs: 5000,
});
expect(def.intervalMs).toBe(5000);
});
it('collect returns HermesStatus shape when no gateway exists', async () => {
const def = hermesWatcher({
hermesHome: `/tmp/nonexistent-hermes-home-${Date.now()}`,
// Use an impossible process name so pgrep won't match anything
processName: `pulse_hermes_test_nonexistent_${Date.now()}`,
});
const status = await def.collect();
expect(status).toHaveProperty('processAlive');
expect(status).toHaveProperty('pid');
expect(status).toHaveProperty('gatewayState');
expect(status).toHaveProperty('activeAgents');
expect(status).toHaveProperty('platforms');
expect(status).toHaveProperty('lastUpdatedAt');
expect(status).toHaveProperty('cronAlive');
// No gateway → stopped
expect(status.processAlive).toBe(false);
expect(status.gatewayState).toBe('stopped');
expect(status.activeAgents).toBe(0);
expect(status.platforms).toEqual({});
expect(status.cronAlive).toBe(false);
});
});
// ── shouldWake ────────────────────────────────────────────────
describe('shouldWake', () => {
const def = hermesWatcher();
function makeVital(data: HermesStatus): VitalWithData<HermesStatus> {
return {
id: 'test',
occurredAt: Date.now(),
key: 'hermes',
data,
};
}
const healthy: HermesStatus = {
processAlive: true,
pid: 12345,
gatewayState: 'running',
activeAgents: 2,
platforms: { telegram: { state: 'connected' } },
lastUpdatedAt: Date.now(),
cronAlive: true,
};
const dead: HermesStatus = {
processAlive: false,
pid: null,
gatewayState: 'stopped',
activeAgents: 0,
platforms: {},
lastUpdatedAt: null,
cronAlive: false,
};
const idle: HermesStatus = {
...healthy,
activeAgents: 0,
};
it('returns false with < 2 data points', () => {
expect(def.shouldWake([makeVital(healthy)])).toBe(false);
expect(def.shouldWake([])).toBe(false);
});
it('wakes when gateway dies', () => {
// window[0] = most recent, window[1] = previous
const result = def.shouldWake([makeVital(dead), makeVital(healthy)]);
expect(result).toBe(true);
});
it('does not wake when gateway stays healthy', () => {
const result = def.shouldWake([makeVital(healthy), makeVital(healthy)]);
expect(result).toBe(false);
});
it('does not wake when gateway stays dead', () => {
const result = def.shouldWake([makeVital(dead), makeVital(dead)]);
expect(result).toBe(false);
});
it('wakes when gateway enters error state', () => {
const errored: HermesStatus = {
...healthy,
gatewayState: 'error',
};
const result = def.shouldWake([makeVital(errored), makeVital(healthy)]);
expect(result).toBe(true);
});
it('wakes when agent becomes idle (busy → 0)', () => {
const result = def.shouldWake([makeVital(idle), makeVital(healthy)]);
expect(result).toBe(true);
});
it('does not wake when agent stays idle', () => {
const result = def.shouldWake([makeVital(idle), makeVital(idle)]);
expect(result).toBe(false);
});
});
+200
View File
@@ -0,0 +1,200 @@
/**
* @uncaged/pulse-hermes — Hermes Watcher
*
* Senses the state of a Hermes Agent installation by reading:
* 1. gateway_state.json — gateway health, active agents, platform states
* 2. Process liveness — is the gateway process running?
* 3. Cron scheduler — is the scheduler alive?
*
* All collection is local (filesystem + process checks), zero network.
*
* shouldWake triggers when:
* - Gateway process dies (was alive, now dead)
* - Gateway enters error state
* - Active agents count drops to 0 (possible task routing opportunity)
*/
import { existsSync, readFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import type { WatcherDef } from '@uncaged/pulse';
// ── Types ──────────────────────────────────────────────────────
export interface HermesStatus {
/** Is the gateway process alive? */
processAlive: boolean;
/** Gateway PID (null if not running) */
pid: number | null;
/** Gateway state: starting, running, draining, stopped, etc. */
gatewayState: string | null;
/** Number of active agent sessions */
activeAgents: number;
/** Connected platform states */
platforms: Record<string, { state: string; error?: string }>;
/** Last time gateway_state.json was updated (epoch ms) */
lastUpdatedAt: number | null;
/** Is the cron scheduler process alive? */
cronAlive: boolean;
}
export interface HermesWatcherConfig {
/** Path to HERMES_HOME. Default: ~/.hermes */
hermesHome?: string;
/** Process name to check for liveness. Default: "hermes" */
processName?: string;
/** Collection interval in ms. Default: 10000 (10s) */
intervalMs?: number;
}
// ── Watcher Factory ────────────────────────────────────────────
/**
* Create a Hermes watcher definition.
*
* Collects HermesStatus by reading local files and checking processes.
* Wakes the tick loop when gateway dies or transitions to error state.
*/
export function hermesWatcher(
config: HermesWatcherConfig = {},
): WatcherDef<HermesStatus> {
const hermesHome = config.hermesHome ?? join(homedir(), '.hermes');
const processName = config.processName ?? 'hermes';
const intervalMs = config.intervalMs ?? 10_000;
return {
name: 'hermes',
key: 'hermes',
intervalMs,
async collect(): Promise<HermesStatus> {
// 1. Read gateway_state.json
const stateFile = join(hermesHome, 'gateway_state.json');
let gatewayState: string | null = null;
let activeAgents = 0;
let pid: number | null = null;
const platforms: Record<string, { state: string; error?: string }> = {};
let lastUpdatedAt: number | null = null;
if (existsSync(stateFile)) {
try {
const raw = JSON.parse(readFileSync(stateFile, 'utf-8'));
gatewayState = raw.gateway_state ?? null;
activeAgents = raw.active_agents ?? 0;
pid = raw.pid ?? null;
lastUpdatedAt = raw.updated_at
? new Date(raw.updated_at).getTime()
: null;
if (raw.platforms) {
for (const [name, info] of Object.entries(raw.platforms)) {
const p = info as Record<string, unknown>;
platforms[name] = {
state: (p.state as string) ?? 'unknown',
...(p.error_message
? { error: p.error_message as string }
: {}),
};
}
}
} catch {
// Corrupted file — treat as unknown
}
}
// 2. Check if gateway process is alive
let processAlive = false;
if (pid !== null) {
processAlive = isProcessAlive(pid);
}
if (!processAlive) {
// Fallback: check by process name
processAlive = await isProcessRunning(processName);
}
// 3. Check cron scheduler
const cronPidFile = join(hermesHome, 'cron', 'scheduler.pid');
let cronAlive = false;
if (existsSync(cronPidFile)) {
try {
const cronPid = parseInt(
readFileSync(cronPidFile, 'utf-8').trim(),
10,
);
cronAlive = isProcessAlive(cronPid);
} catch {
// Corrupted pid file
}
}
return {
processAlive,
pid: processAlive ? pid : null,
gatewayState: processAlive ? (gatewayState ?? 'unknown') : 'stopped',
activeAgents: processAlive ? activeAgents : 0,
platforms: processAlive ? platforms : {},
lastUpdatedAt,
cronAlive,
};
},
shouldWake(window) {
if (window.length < 2) return false;
const curr = window[0]!; // Most recent
const prev = window[1]!; // Previous
// Gateway died
if (prev.data.processAlive && !curr.data.processAlive) {
return true;
}
// Gateway entered error state
if (
curr.data.gatewayState !== prev.data.gatewayState &&
(curr.data.gatewayState === 'error' ||
curr.data.gatewayState === 'stopped')
) {
return true;
}
// Agent became idle (busy → 0 active) — routing opportunity
if (prev.data.activeAgents > 0 && curr.data.activeAgents === 0) {
return true;
}
return false;
},
};
}
// ── Helpers ────────────────────────────────────────────────────
/**
* Check if a PID is alive via kill(pid, 0).
*/
function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
/**
* Check if a process with the given name is running.
* Uses `pgrep` on Linux/macOS.
*/
async function isProcessRunning(name: string): Promise<boolean> {
try {
const proc = Bun.spawn(['pgrep', '-f', name], {
stdout: 'pipe',
stderr: 'pipe',
});
const exitCode = await proc.exited;
return exitCode === 0;
} catch {
return false;
}
}
+19
View File
@@ -0,0 +1,19 @@
/**
* @uncaged/pulse-hermes — Hermes Agent adapter for Pulse
*
* Provides:
* - Watcher: sense Hermes gateway health, active sessions, cron status
* - Executor: dispatch tasks to Hermes via CLI
*/
export {
createHermesExecutor,
type HermesEffect,
type HermesExecutorConfig,
type HermesResult,
} from './hermes-executor.js';
export {
type HermesStatus,
type HermesWatcherConfig,
hermesWatcher,
} from './hermes-watcher.js';
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"declaration": true,
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"types": ["bun-types"]
},
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
}