@volter/supercode-orchestrator 0.4.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +532 -0
- package/activation.mjs +270 -0
- package/adapters/api-server.mjs +615 -0
- package/adapters/chat-sdk.mjs +475 -0
- package/adapters/chunks.mjs +14 -0
- package/adapters/loopback.mjs +37 -0
- package/adapters/rh2.mjs +349 -0
- package/apply/doors.d.ts +2 -0
- package/apply/doors.mjs +292 -0
- package/apply/hermes_door.py +129 -0
- package/apply/index.d.ts +4 -0
- package/apply/index.mjs +441 -0
- package/apply/state.mjs +142 -0
- package/bin/hermes-worker.mjs +11 -0
- package/bin/orchestrator.mjs +730 -0
- package/board.mjs +81 -0
- package/commands.mjs +37 -0
- package/cron.mjs +967 -0
- package/events.mjs +107 -0
- package/gates.mjs +281 -0
- package/hermes-cli.mjs +440 -0
- package/hermes-home.mjs +264 -0
- package/hermes-locks.mjs +188 -0
- package/hosted.mjs +81 -0
- package/index.d.ts +508 -0
- package/index.mjs +38 -0
- package/ir.mjs +205 -0
- package/kanban-worker.mjs +155 -0
- package/lease.mjs +51 -0
- package/loop.mjs +864 -0
- package/mcp/bridge.mjs +133 -0
- package/mcp/hermes-kanban-tools.json +477 -0
- package/mcp/hermes-tools.json +288 -0
- package/mcp/mount.mjs +161 -0
- package/mcp/server.mjs +160 -0
- package/mcp/tools.mjs +57 -0
- package/mcp/verbs.mjs +348 -0
- package/ontology.d.ts +557 -0
- package/operator.mjs +426 -0
- package/orchestration.mjs +166 -0
- package/package.json +51 -0
- package/reducer.mjs +792 -0
- package/webhook.mjs +263 -0
- package/worker-route.mjs +122 -0
package/hermes-home.mjs
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// A home is a Hermes home (docs/plans/hermes-compat.md): where the workers have a native form it is the
|
|
2
|
+
// source and Hermes's form shadows it. This module brings a home's files into that shape before the
|
|
3
|
+
// folder is loaded, so a Hermes home is adopted as it stands and a home the orchestrator ran opens
|
|
4
|
+
// under Hermes.
|
|
5
|
+
//
|
|
6
|
+
// profileDirs(root) the root (the default profile) and each `profiles/<name>/`, dot folders skipped
|
|
7
|
+
// adoptPersona(dir) `AGENTS.md` the persona, `SOUL.md` a link to it, `CLAUDE.md` `@AGENTS.md` (row 2)
|
|
8
|
+
// adoptSkills(dir) `.agents/skills/<name>/` the source, Hermes reading it through its own
|
|
9
|
+
// `skills.external_dirs`; the skills Hermes manages stay in `skills/` (row 3)
|
|
10
|
+
// skillSources(dir) every skill a profile has, by name, and where it lives
|
|
11
|
+
// adoptMcp(dir) `.mcp.json` the source, `config.yaml`'s `mcp_servers:` its shadow, an edit on
|
|
12
|
+
// either side carried to the other against the last state written (row 4)
|
|
13
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, renameSync, rmdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
14
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
15
|
+
import { homedir } from 'node:os';
|
|
16
|
+
import { createHash } from 'node:crypto';
|
|
17
|
+
import { parseDocument } from 'yaml';
|
|
18
|
+
|
|
19
|
+
/** Claude Code's bridge to `AGENTS.md`, its own documented form. */
|
|
20
|
+
export const CLAUDE_BRIDGE = '@AGENTS.md\n';
|
|
21
|
+
|
|
22
|
+
/** The root and each named profile's folder, as Hermes and the folder codec both lay them out. */
|
|
23
|
+
export function profileDirs(root) {
|
|
24
|
+
const dirs = [resolve(root)];
|
|
25
|
+
const named = join(root, 'profiles');
|
|
26
|
+
if (existsSync(named)) {
|
|
27
|
+
for (const entry of readdirSync(named, { withFileTypes: true })) {
|
|
28
|
+
if (entry.name.startsWith('.')) continue;
|
|
29
|
+
const dir = join(named, entry.name);
|
|
30
|
+
if (entry.isDirectory() || (entry.isSymbolicLink() && existsSync(dir))) dirs.push(dir);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return dirs;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const kind = (path) => { try { const s = lstatSync(path); return s.isSymbolicLink() ? 'link' : s.isFile() ? 'file' : 'other'; } catch { return 'none'; } };
|
|
37
|
+
|
|
38
|
+
/** Make `path` a symlink to `target` (a name relative to its folder), replacing a file or a stale link. */
|
|
39
|
+
export function linkTo(path, target) {
|
|
40
|
+
if (kind(path) === 'link' && readlinkSync(path) === target) return false;
|
|
41
|
+
rmSync(path, { force: true });
|
|
42
|
+
symlinkSync(target, path);
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The persona's two forms in one folder. `AGENTS.md` is the source; `SOUL.md` is a link to it, so
|
|
48
|
+
* Hermes reads the same file (and never writes its default persona through a dangling link, since
|
|
49
|
+
* `AGENTS.md` exists before any Hermes call); `CLAUDE.md` is Claude Code's `@AGENTS.md`. A Hermes home
|
|
50
|
+
* adopted here keeps its persona: a `SOUL.md` with no `AGENTS.md` becomes `AGENTS.md`. Two different
|
|
51
|
+
* files are two personas, and the adoption refuses rather than choose.
|
|
52
|
+
*/
|
|
53
|
+
export function adoptPersona(dir) {
|
|
54
|
+
const agents = join(dir, 'AGENTS.md');
|
|
55
|
+
const soul = join(dir, 'SOUL.md');
|
|
56
|
+
const claude = join(dir, 'CLAUDE.md');
|
|
57
|
+
const changed = [];
|
|
58
|
+
const soulKind = kind(soul);
|
|
59
|
+
if (kind(agents) === 'none') {
|
|
60
|
+
if (soulKind === 'file') { renameSync(soul, agents); changed.push('SOUL.md -> AGENTS.md'); } else return changed;
|
|
61
|
+
} else if (soulKind === 'file' && readFileSync(soul, 'utf8') !== readFileSync(agents, 'utf8')) {
|
|
62
|
+
throw new Error(`${dir}: SOUL.md and AGENTS.md are two different personas; keep one (AGENTS.md is the source, SOUL.md its link)`);
|
|
63
|
+
}
|
|
64
|
+
if (linkTo(soul, 'AGENTS.md')) changed.push('SOUL.md -> link');
|
|
65
|
+
if (kind(claude) === 'none') { writeFileSync(claude, CLAUDE_BRIDGE); changed.push('CLAUDE.md'); }
|
|
66
|
+
return changed;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The workers' form of the skills, relative to a profile folder; Hermes reads it through `external_dirs`. */
|
|
70
|
+
export const AGENTS_SKILLS = '.agents/skills';
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The skills Hermes manages, by name: its bundled skills (`skills/.bundled_manifest`, `name:hash` lines)
|
|
74
|
+
* and its hub installs (`skills/.hub/lock.json` `installed`). They have no worker-native origin, so they
|
|
75
|
+
* stay where Hermes keeps them and its own updates keep working.
|
|
76
|
+
*/
|
|
77
|
+
export function managedSkills(dir) {
|
|
78
|
+
const names = new Set();
|
|
79
|
+
try {
|
|
80
|
+
for (const line of readFileSync(join(dir, 'skills', '.bundled_manifest'), 'utf8').split('\n')) {
|
|
81
|
+
const name = line.split(':')[0].trim();
|
|
82
|
+
if (name) names.add(name);
|
|
83
|
+
}
|
|
84
|
+
} catch { /* none */ }
|
|
85
|
+
try {
|
|
86
|
+
for (const name of Object.keys(JSON.parse(readFileSync(join(dir, 'skills', '.hub', 'lock.json'), 'utf8')).installed ?? {})) names.add(name);
|
|
87
|
+
} catch { /* none */ }
|
|
88
|
+
return names;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The skill folders under Hermes's `skills/`, flat or in a category: `[{ name, path }]` (path relative to `skills/`). */
|
|
92
|
+
function hermesSkillDirs(dir) {
|
|
93
|
+
const base = join(dir, 'skills');
|
|
94
|
+
const out = [];
|
|
95
|
+
if (!existsSync(base)) return out;
|
|
96
|
+
for (const top of readdirSync(base, { withFileTypes: true })) {
|
|
97
|
+
if (top.name.startsWith('.') || !top.isDirectory()) continue;
|
|
98
|
+
if (existsSync(join(base, top.name, 'SKILL.md'))) { out.push({ name: top.name, path: top.name }); continue; }
|
|
99
|
+
for (const inner of readdirSync(join(base, top.name), { withFileTypes: true })) {
|
|
100
|
+
if (!inner.name.startsWith('.') && inner.isDirectory() && existsSync(join(base, top.name, inner.name, 'SKILL.md'))) {
|
|
101
|
+
out.push({ name: inner.name, path: `${top.name}/${inner.name}` });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Two skill folders with the same files and bytes. */
|
|
109
|
+
function sameTree(a, b) {
|
|
110
|
+
const list = (d) => readdirSync(d, { recursive: true, withFileTypes: true }).filter((e) => e.isFile()).map((e) => relative(d, join(e.parentPath ?? e.path, e.name))).sort();
|
|
111
|
+
const la = list(a);
|
|
112
|
+
const lb = list(b);
|
|
113
|
+
return la.length === lb.length && la.every((f, i) => f === lb[i] && readFileSync(join(a, f)).equals(readFileSync(join(b, f))));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The skills in both forms in one profile folder. Every skill Hermes does not manage lives in
|
|
118
|
+
* `.agents/skills/<name>/`: one an existing home keeps in `skills/` (a skill the agent wrote, a family the
|
|
119
|
+
* repository does not commit) is moved there, and one Hermes's agent writes later is moved on the next
|
|
120
|
+
* load. Hermes reads that folder through its own `skills.external_dirs`, added to the profile's
|
|
121
|
+
* `config.yaml`. A name two different skills share refuses by name, as does a name the workers' form
|
|
122
|
+
* shares with a skill Hermes manages, which Hermes itself could not resolve.
|
|
123
|
+
*/
|
|
124
|
+
export function adoptSkills(dir) {
|
|
125
|
+
const changed = [];
|
|
126
|
+
const managed = managedSkills(dir);
|
|
127
|
+
const target = join(dir, AGENTS_SKILLS);
|
|
128
|
+
for (const { name, path } of hermesSkillDirs(dir)) {
|
|
129
|
+
if (managed.has(name)) continue;
|
|
130
|
+
const from = join(dir, 'skills', path);
|
|
131
|
+
const to = join(target, name);
|
|
132
|
+
if (existsSync(to)) {
|
|
133
|
+
if (!sameTree(from, to)) throw new Error(`${dir}: two different skills are named ${name} (skills/${path} and ${AGENTS_SKILLS}/${name}); rename one`);
|
|
134
|
+
rmSync(from, { recursive: true, force: true });
|
|
135
|
+
changed.push(`skills/${path}: the same skill as ${AGENTS_SKILLS}/${name}, removed`);
|
|
136
|
+
} else {
|
|
137
|
+
mkdirSync(target, { recursive: true });
|
|
138
|
+
renameSync(from, to);
|
|
139
|
+
changed.push(`skills/${path} -> ${AGENTS_SKILLS}/${name}`);
|
|
140
|
+
}
|
|
141
|
+
if (path.includes('/')) {
|
|
142
|
+
// a category left with nothing but its description goes with it
|
|
143
|
+
const category = join(dir, 'skills', path.split('/')[0]);
|
|
144
|
+
const rest = readdirSync(category).filter((f) => f !== 'DESCRIPTION.md');
|
|
145
|
+
if (!rest.length) rmSync(category, { recursive: true, force: true });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (existsSync(target)) {
|
|
149
|
+
for (const entry of readdirSync(target)) {
|
|
150
|
+
if (managed.has(entry)) throw new Error(`${dir}: ${AGENTS_SKILLS}/${entry} has the name of a skill Hermes manages; rename it`);
|
|
151
|
+
}
|
|
152
|
+
if (addExternalDir(dir)) changed.push(`config.yaml: skills.external_dirs += ${AGENTS_SKILLS}`);
|
|
153
|
+
}
|
|
154
|
+
return changed;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Add `.agents/skills` to the profile's `skills.external_dirs`, keeping the rest of `config.yaml` as it is. */
|
|
158
|
+
function addExternalDir(dir) {
|
|
159
|
+
const file = join(dir, 'config.yaml');
|
|
160
|
+
const doc = parseDocument(existsSync(file) ? readFileSync(file, 'utf8') : '');
|
|
161
|
+
const current = doc.getIn(['skills', 'external_dirs'])?.toJSON?.() ?? doc.getIn(['skills', 'external_dirs']) ?? [];
|
|
162
|
+
const list = Array.isArray(current) ? current : [current];
|
|
163
|
+
if (list.includes(AGENTS_SKILLS)) return false;
|
|
164
|
+
doc.setIn(['skills', 'external_dirs'], [...list, AGENTS_SKILLS]);
|
|
165
|
+
writeFileSync(file, String(doc));
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Every skill a profile has, by name: the workers' form first, then the skills Hermes manages. */
|
|
170
|
+
export function skillSources(dir) {
|
|
171
|
+
const out = new Map();
|
|
172
|
+
const target = join(dir, AGENTS_SKILLS);
|
|
173
|
+
if (existsSync(target)) {
|
|
174
|
+
for (const entry of readdirSync(target)) if (existsSync(join(target, entry, 'SKILL.md'))) out.set(entry, join(target, entry));
|
|
175
|
+
}
|
|
176
|
+
const managed = managedSkills(dir);
|
|
177
|
+
for (const { name, path } of hermesSkillDirs(dir)) if (managed.has(name) && !out.has(name)) out.set(name, join(dir, 'skills', path));
|
|
178
|
+
return out;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** A server's fields both forms carry: stdio `command`/`args`/`env`, or remote `url`/`headers`. */
|
|
182
|
+
const MCP_FIELDS = ['command', 'args', 'env', 'url', 'headers'];
|
|
183
|
+
|
|
184
|
+
/** One `.mcp.json` server from a Hermes `mcp_servers` entry (Hermes's other keys stay in `config.yaml`). */
|
|
185
|
+
function nativeServer(entry) {
|
|
186
|
+
const out = {};
|
|
187
|
+
for (const field of MCP_FIELDS) if (entry?.[field] !== undefined && entry[field] !== null) out[field] = entry[field];
|
|
188
|
+
if (out.url) out.type = 'http';
|
|
189
|
+
return out;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const canon = (value) => JSON.stringify(value, (k, v) => (v && typeof v === 'object' && !Array.isArray(v) ? Object.fromEntries(Object.keys(v).sort().map((key) => [key, v[key]])) : v));
|
|
193
|
+
|
|
194
|
+
/** The applier-style base: what this home's two MCP forms held when last written, outside the home. */
|
|
195
|
+
function baseFile(dir, env = process.env) {
|
|
196
|
+
const root = join(env.XDG_STATE_HOME || join(homedir(), '.local', 'state'), 'supercode', 'hermes-home');
|
|
197
|
+
return join(root, `${createHash('sha256').update(resolve(dir)).digest('hex').slice(0, 16)}.json`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* MCP servers in both forms in one profile folder. The source is `.mcp.json` (`mcpServers`, the form
|
|
202
|
+
* Claude Code and the other workers read); the shadow is `config.yaml`'s `mcp_servers:`, where Hermes
|
|
203
|
+
* reads them, each entry keeping any key only Hermes has (a timeout, a sampling block). Hermes can edit
|
|
204
|
+
* its form while it runs the home (`hermes mcp add`), so both are compared with the last state written:
|
|
205
|
+
* an edit on one side is carried to the other, and two different edits refuse by name. The
|
|
206
|
+
* orchestrator's own server is never written to either form; it is mounted per session.
|
|
207
|
+
*/
|
|
208
|
+
export function adoptMcp(dir, env = process.env) {
|
|
209
|
+
const jsonFile = join(dir, '.mcp.json');
|
|
210
|
+
const yamlFile = join(dir, 'config.yaml');
|
|
211
|
+
const doc = parseDocument(existsSync(yamlFile) ? readFileSync(yamlFile, 'utf8') : '');
|
|
212
|
+
const hermes = doc.getIn(['mcp_servers'])?.toJSON?.() ?? {};
|
|
213
|
+
const native = existsSync(jsonFile) ? (JSON.parse(readFileSync(jsonFile, 'utf8')).mcpServers ?? {}) : {};
|
|
214
|
+
const shadow = Object.fromEntries(Object.entries(hermes).map(([name, entry]) => [name, nativeServer(entry)]));
|
|
215
|
+
const source = Object.fromEntries(Object.entries(native).map(([name, entry]) => [name, nativeServer(entry)]));
|
|
216
|
+
const file = baseFile(dir, env);
|
|
217
|
+
let base = null;
|
|
218
|
+
try { base = JSON.parse(readFileSync(file, 'utf8')).mcp ?? null; } catch { /* first sync */ }
|
|
219
|
+
const sourceChanged = base === null ? Object.keys(source).length > 0 : canon(source) !== canon(base);
|
|
220
|
+
const shadowChanged = base === null ? Object.keys(shadow).length > 0 : canon(shadow) !== canon(base);
|
|
221
|
+
let want = source;
|
|
222
|
+
if (sourceChanged && shadowChanged && canon(source) !== canon(shadow)) {
|
|
223
|
+
throw new Error(`${dir}: MCP servers were changed in both .mcp.json and config.yaml mcp_servers since they were last in step; make them agree`);
|
|
224
|
+
}
|
|
225
|
+
if (shadowChanged && !sourceChanged) want = shadow;
|
|
226
|
+
const changed = [];
|
|
227
|
+
if (canon(source) !== canon(want)) {
|
|
228
|
+
const out = existsSync(jsonFile) ? JSON.parse(readFileSync(jsonFile, 'utf8')) : {};
|
|
229
|
+
out.mcpServers = Object.fromEntries(Object.entries(want).map(([name, entry]) => [name, { ...(native[name] ?? {}), ...entry }]));
|
|
230
|
+
writeFileSync(jsonFile, `${JSON.stringify(out, null, 2)}\n`);
|
|
231
|
+
changed.push('.mcp.json: from config.yaml mcp_servers');
|
|
232
|
+
}
|
|
233
|
+
if (canon(shadow) !== canon(want)) {
|
|
234
|
+
const next = {};
|
|
235
|
+
for (const [name, entry] of Object.entries(want)) {
|
|
236
|
+
const kept = { ...(hermes[name] ?? {}) };
|
|
237
|
+
for (const field of MCP_FIELDS) delete kept[field];
|
|
238
|
+
const { type, ...fields } = entry;
|
|
239
|
+
next[name] = { ...kept, ...fields };
|
|
240
|
+
}
|
|
241
|
+
if (Object.keys(next).length) doc.setIn(['mcp_servers'], next); else doc.deleteIn(['mcp_servers']);
|
|
242
|
+
writeFileSync(yamlFile, String(doc));
|
|
243
|
+
changed.push('config.yaml: mcp_servers from .mcp.json');
|
|
244
|
+
}
|
|
245
|
+
if (base === null || canon(base) !== canon(want)) {
|
|
246
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
247
|
+
writeFileSync(file, JSON.stringify({ home: resolve(dir), mcp: want }));
|
|
248
|
+
}
|
|
249
|
+
return changed;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** The profile's MCP servers in the workers' form (`.mcp.json` `mcpServers`), or `{}`. */
|
|
253
|
+
export function mcpServers(dir) {
|
|
254
|
+
try { return JSON.parse(readFileSync(join(dir, '.mcp.json'), 'utf8')).mcpServers ?? {}; } catch { return {}; }
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Bring every profile folder of a home into shape before it is loaded. */
|
|
258
|
+
export function adoptHome(root) {
|
|
259
|
+
const report = [];
|
|
260
|
+
for (const dir of profileDirs(root)) {
|
|
261
|
+
for (const change of [...adoptPersona(dir), ...adoptSkills(dir), ...adoptMcp(dir)]) report.push(`${dir}: ${change}`);
|
|
262
|
+
}
|
|
263
|
+
return report;
|
|
264
|
+
}
|
package/hermes-locks.mjs
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// Hermes's two runtime locks, held the way Hermes holds them, so one home is served by one runtime and
|
|
2
|
+
// one bot token by one gateway (docs/plans/hermes-compat.md, row 1; pinned Hermes gateway/status.py).
|
|
3
|
+
//
|
|
4
|
+
// the home's gateway lock `<home>/gateway.lock`, an exclusive `flock` the live gateway holds, with its
|
|
5
|
+
// pid record as the file's content; Hermes refuses to start while anyone holds it
|
|
6
|
+
// a token's scoped lock `<lock dir>/<scope>-<sha256(identity)[:16]>.lock`, an O_EXCL file whose
|
|
7
|
+
// record names the holder; Hermes's adapter refuses a token a live gateway holds
|
|
8
|
+
//
|
|
9
|
+
// Hermes calls a holder live when its pid exists and its command line is a gateway's (`hermes-gateway`,
|
|
10
|
+
// `hermes gateway run`, `gateway/run.py`). The daemon runs as `hermes-gateway` (bin/orchestrator.mjs), so
|
|
11
|
+
// Hermes reads the orchestrator's locks as a gateway's, which they are.
|
|
12
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
13
|
+
import { createHash } from 'node:crypto';
|
|
14
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, unlinkSync, writeSync } from 'node:fs';
|
|
15
|
+
import { homedir } from 'node:os';
|
|
16
|
+
import { join, resolve } from 'node:path';
|
|
17
|
+
|
|
18
|
+
/** Hermes's gateway kind, the `kind` of every record it writes. */
|
|
19
|
+
export const GATEWAY_KIND = 'hermes-gateway';
|
|
20
|
+
|
|
21
|
+
/** The scope Hermes's adapter locks for each platform, and which credential it locks by. */
|
|
22
|
+
export const TOKEN_SCOPES = Object.freeze({
|
|
23
|
+
discord: { scope: 'discord-bot-token', option: 'botToken' },
|
|
24
|
+
telegram: { scope: 'telegram-bot-token', option: 'botToken' },
|
|
25
|
+
slack: { scope: 'slack-app-token', option: 'appToken' },
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
/** Hermes's `_get_lock_dir`: `HERMES_GATEWAY_LOCK_DIR`, else `$XDG_STATE_HOME/hermes/gateway-locks`. */
|
|
29
|
+
export function tokenLockDir(env = process.env) {
|
|
30
|
+
if (env.HERMES_GATEWAY_LOCK_DIR) return env.HERMES_GATEWAY_LOCK_DIR;
|
|
31
|
+
return join(env.XDG_STATE_HOME || join(homedir(), '.local', 'state'), 'hermes', 'gateway-locks');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const scopeHash = (identity) => createHash('sha256').update(String(identity)).digest('hex').slice(0, 16);
|
|
35
|
+
|
|
36
|
+
/** Hermes's `_gateway_command_subcommand(...) == "run"`: a command line that is a gateway's. */
|
|
37
|
+
export function looksLikeGateway(command) {
|
|
38
|
+
if (!command) return false;
|
|
39
|
+
const tokens = command.split(/\s+/).map((t) => t.replace(/^["']|["']$/g, '').replaceAll('\\', '/').toLowerCase()).filter(Boolean);
|
|
40
|
+
for (const t of tokens) {
|
|
41
|
+
if (t === 'gateway/run.py' || t.endsWith('/gateway/run.py')) return true;
|
|
42
|
+
const base = t.split('/').pop();
|
|
43
|
+
if (base === 'hermes-gateway' || base === 'hermes-gateway.exe') return true;
|
|
44
|
+
}
|
|
45
|
+
const joined = tokens.join(' ');
|
|
46
|
+
if (!(joined.includes('hermes_cli.main') || joined.includes('hermes_cli/main.py') || tokens.some((t) => ['hermes', 'hermes.exe'].includes(t.split('/').pop())))) return false;
|
|
47
|
+
const filtered = [];
|
|
48
|
+
for (let i = 0; i < tokens.length; i += 1) {
|
|
49
|
+
if (tokens[i] === '--profile' || tokens[i] === '-p') { i += 1; continue; }
|
|
50
|
+
if (tokens[i].startsWith('--profile=') || tokens[i].startsWith('-p=')) continue;
|
|
51
|
+
filtered.push(tokens[i]);
|
|
52
|
+
}
|
|
53
|
+
const at = filtered.indexOf('gateway');
|
|
54
|
+
return at >= 0 && (at + 1 >= filtered.length || filtered[at + 1] === 'run');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function commandOf(pid) {
|
|
58
|
+
const run = spawnSync('ps', ['-o', 'command=', '-p', String(pid)], { encoding: 'utf8', timeout: 5000 });
|
|
59
|
+
return run.status === 0 ? run.stdout.trim() || null : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function pidAlive(pid) {
|
|
63
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
64
|
+
try { process.kill(pid, 0); return true; } catch (error) { return error.code === 'EPERM'; }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Linux's `/proc/<pid>/stat` field 22, as Hermes records it; null where there is no /proc. */
|
|
68
|
+
function startTime(pid) {
|
|
69
|
+
try { return Number(readFileSync(`/proc/${pid}/stat`, 'utf8').split(' ')[21]); } catch { return null; }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The record Hermes's `_build_pid_record` writes, for this process serving `home`. */
|
|
73
|
+
export function pidRecord(home) {
|
|
74
|
+
return { pid: process.pid, kind: GATEWAY_KIND, argv: [process.argv0, ...process.argv.slice(1)], start_time: startTime(process.pid), hermes_home: resolve(home) };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Hermes's stale test for a record another process wrote. */
|
|
78
|
+
function holderLive(record) {
|
|
79
|
+
const pid = Number(record?.pid);
|
|
80
|
+
if (!pidAlive(pid)) return false;
|
|
81
|
+
const now = startTime(pid);
|
|
82
|
+
if (record.start_time != null && now != null && now !== record.start_time) return false;
|
|
83
|
+
const command = commandOf(pid);
|
|
84
|
+
return command === null || looksLikeGateway(command);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Take a token's scoped lock (Hermes's `acquire_scoped_lock`). Answers `{ ok: true }`, or
|
|
89
|
+
* `{ ok: false, holder }` naming the live gateway that holds it.
|
|
90
|
+
*/
|
|
91
|
+
export function acquireTokenLock({ scope, identity, home, platform, env = process.env }) {
|
|
92
|
+
const dir = tokenLockDir(env);
|
|
93
|
+
mkdirSync(dir, { recursive: true });
|
|
94
|
+
const file = join(dir, `${scope}-${scopeHash(identity)}.lock`);
|
|
95
|
+
const record = { ...pidRecord(home), scope, identity_hash: scopeHash(identity), metadata: { platform }, updated_at: new Date().toISOString() };
|
|
96
|
+
let existing = null;
|
|
97
|
+
try { existing = JSON.parse(readFileSync(file, 'utf8')); } catch (error) { if (error.code !== 'ENOENT') existing = {}; }
|
|
98
|
+
if (existing) {
|
|
99
|
+
if (Number(existing.pid) === process.pid) { writeRecord(file, record, 'w'); return { ok: true }; }
|
|
100
|
+
if (holderLive(existing)) return { ok: false, holder: existing };
|
|
101
|
+
// stale: claimed by an atomic rename, as Hermes does, so of two racing starters one wins
|
|
102
|
+
try { renameSync(file, `${file}.stale`); rmSync(`${file}.stale`, { force: true }); } catch { /* another racer took it */ }
|
|
103
|
+
}
|
|
104
|
+
try { writeRecord(file, record, 'wx'); } catch (error) {
|
|
105
|
+
if (error.code === 'EEXIST') { let holder = null; try { holder = JSON.parse(readFileSync(file, 'utf8')); } catch { /* unreadable */ } return { ok: false, holder }; }
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
return { ok: true };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function writeRecord(file, record, flag) {
|
|
112
|
+
const fd = openSync(file, flag, 0o644);
|
|
113
|
+
try { writeSync(fd, JSON.stringify(record)); } finally { closeSync(fd); }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Release a token's scoped lock this process holds (Hermes's `release_scoped_lock`). */
|
|
117
|
+
export function releaseTokenLock({ scope, identity, env = process.env }) {
|
|
118
|
+
const file = join(tokenLockDir(env), `${scope}-${scopeHash(identity)}.lock`);
|
|
119
|
+
try { if (Number(JSON.parse(readFileSync(file, 'utf8')).pid) === process.pid) unlinkSync(file); } catch { /* gone */ }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// The flock itself: Node has none, so a small Python child takes it, writes Hermes's record naming
|
|
123
|
+
// this process, and holds it until its stdin closes, which is this process exiting by any path.
|
|
124
|
+
const HOLDER = `
|
|
125
|
+
import fcntl, json, os, sys
|
|
126
|
+
path, record = sys.argv[1], json.loads(sys.argv[2])
|
|
127
|
+
handle = open(path, "a+", encoding="utf-8")
|
|
128
|
+
try:
|
|
129
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
130
|
+
except OSError:
|
|
131
|
+
handle.seek(0)
|
|
132
|
+
sys.stdout.write("held " + handle.read().strip().replace("\\n", " ") + "\\n"); sys.stdout.flush(); sys.exit(3)
|
|
133
|
+
handle.seek(0); handle.truncate(); json.dump(record, handle); handle.flush(); os.fsync(handle.fileno())
|
|
134
|
+
sys.stdout.write("ok\\n"); sys.stdout.flush()
|
|
135
|
+
sys.stdin.read()
|
|
136
|
+
`;
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Hold `<home>/gateway.lock` for this process. Resolves `{ release }`; rejects, naming the holder,
|
|
140
|
+
* when a Hermes gateway (or another orchestrator) already serves the home.
|
|
141
|
+
*/
|
|
142
|
+
export function holdGatewayLock(home, { python = 'python3' } = {}) {
|
|
143
|
+
return holdFileLock(join(resolve(home), 'gateway.lock'), home, { python, what: `${resolve(home)} is served by`, tail: 'one runtime serves a home at a time' });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Hold the board's one dispatcher lock, `<root>/kanban/.dispatcher.lock` (Hermes's
|
|
148
|
+
* `_acquire_singleton_lock`, which every Hermes gateway takes before it dispatches). Resolves
|
|
149
|
+
* `{ release }`; rejects, naming the holder, when another dispatcher holds it.
|
|
150
|
+
*/
|
|
151
|
+
export function holdDispatcherLock(root, { python = 'python3' } = {}) {
|
|
152
|
+
const path = join(resolve(root), 'kanban', '.dispatcher.lock');
|
|
153
|
+
mkdirSync(join(resolve(root), 'kanban'), { recursive: true });
|
|
154
|
+
return holdFileLock(path, root, { python, what: `the board at ${resolve(root)} is dispatched by`, tail: 'one dispatcher serves a board' });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function holdFileLock(path, home, { python, what, tail }) {
|
|
158
|
+
return new Promise((done, fail) => {
|
|
159
|
+
let child;
|
|
160
|
+
try {
|
|
161
|
+
child = spawn(python, ['-c', HOLDER, path, JSON.stringify(pidRecord(home))], { stdio: ['pipe', 'pipe', 'inherit'] });
|
|
162
|
+
} catch (error) { fail(new Error(`cannot hold ${path}: ${error.message}`)); return; }
|
|
163
|
+
let out = '';
|
|
164
|
+
child.on('error', (error) => fail(new Error(`cannot hold ${path} (${python}: ${error.message}); a home is served by one runtime, and this one could not claim it`)));
|
|
165
|
+
child.stdout.on('data', (chunk) => {
|
|
166
|
+
out += chunk;
|
|
167
|
+
if (!out.includes('\n')) return;
|
|
168
|
+
const line = out.split('\n')[0];
|
|
169
|
+
if (line === 'ok') {
|
|
170
|
+
done({ release: () => { try { child.stdin.end(); } catch { /* gone */ } } });
|
|
171
|
+
} else {
|
|
172
|
+
let holder = null;
|
|
173
|
+
try { holder = JSON.parse(line.slice('held '.length)); } catch { /* unreadable record */ }
|
|
174
|
+
fail(new Error(`${what} ${holder?.pid ? `pid ${holder.pid} (${(holder.argv ?? []).join(' ') || holder.kind || 'a gateway'})` : 'another runtime'}; ${tail}`));
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
child.on('exit', (code) => { if (!out.includes('\n')) fail(new Error(`the lock holder for ${path} exited ${code} before answering`)); });
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Whether a Hermes gateway (or anything) holds `<home>/gateway.lock` now, without taking it. */
|
|
182
|
+
export function gatewayLockHeld(home, { python = 'python3' } = {}) {
|
|
183
|
+
const path = join(resolve(home), 'gateway.lock');
|
|
184
|
+
if (!existsSync(path)) return false;
|
|
185
|
+
const probe = spawnSync(python, ['-c', 'import fcntl,sys\nh=open(sys.argv[1],"a+")\ntry:\n fcntl.flock(h.fileno(), fcntl.LOCK_EX|fcntl.LOCK_NB)\nexcept OSError:\n sys.exit(3)\n', path], { timeout: 5000 });
|
|
186
|
+
return probe.status === 3;
|
|
187
|
+
}
|
|
188
|
+
|
package/hosted.mjs
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Host integration: the orchestrator owns time and durable fires; a trusted
|
|
2
|
+
// application executes each task through its existing conversation/action door.
|
|
3
|
+
import { connect } from 'node:net';
|
|
4
|
+
import { readFileSync } from 'node:fs';
|
|
5
|
+
import { join, resolve } from 'node:path';
|
|
6
|
+
import { socketPathFor } from './mcp/server.mjs';
|
|
7
|
+
import { leaseIsHere } from './lease.mjs';
|
|
8
|
+
|
|
9
|
+
export function callOperator({ root, profile = 'default', op, args = {}, timeoutMs = 30_000 }) {
|
|
10
|
+
return new Promise((resolve, reject) => {
|
|
11
|
+
const socket = connect(socketPathFor(root));
|
|
12
|
+
let buffer = '', settled = false;
|
|
13
|
+
const finish = (error, value) => {
|
|
14
|
+
if (settled) return;
|
|
15
|
+
settled = true; socket.destroy();
|
|
16
|
+
error ? reject(error) : resolve(value);
|
|
17
|
+
};
|
|
18
|
+
socket.setTimeout(timeoutMs, () => finish(new Error('Orchestrator request timed out')));
|
|
19
|
+
socket.setEncoding('utf8');
|
|
20
|
+
socket.on('error', (e) => finish(e));
|
|
21
|
+
socket.on('connect', () => socket.write(JSON.stringify({ op, profile, args }) + '\n'));
|
|
22
|
+
socket.on('data', (chunk) => {
|
|
23
|
+
buffer += chunk;
|
|
24
|
+
if (buffer.length > 4 * 1024 * 1024) return finish(new Error('Orchestrator response too large'));
|
|
25
|
+
const newline = buffer.indexOf('\n');
|
|
26
|
+
if (newline < 0) return;
|
|
27
|
+
try {
|
|
28
|
+
const reply = JSON.parse(buffer.slice(0, newline));
|
|
29
|
+
if (reply.ok !== true) throw new Error(reply.error || 'Orchestrator refused the request');
|
|
30
|
+
finish(null, reply.result);
|
|
31
|
+
} catch (error) { finish(error); }
|
|
32
|
+
});
|
|
33
|
+
socket.on('close', () => finish(new Error('Orchestrator closed before confirming the request')));
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function httpJobExecutor({ url, tokenFile, root, timeoutMs = 30_000 }) {
|
|
38
|
+
const endpoint = new URL(url);
|
|
39
|
+
if (endpoint.protocol !== 'http:' || !['127.0.0.1', 'localhost', '[::1]'].includes(endpoint.hostname) || endpoint.username || endpoint.password)
|
|
40
|
+
throw new Error('A hosted executor must use a loopback HTTP endpoint');
|
|
41
|
+
if (!tokenFile) throw new Error('A hosted executor token file is required');
|
|
42
|
+
const token = readFileSync(tokenFile, 'utf8').trim();
|
|
43
|
+
if (token.length < 32) throw new Error('The hosted executor token is invalid');
|
|
44
|
+
return async ({ profile, job, fire }) => {
|
|
45
|
+
const response = await fetch(endpoint, {
|
|
46
|
+
method: 'POST', redirect: 'error', signal: AbortSignal.timeout(timeoutMs),
|
|
47
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
|
48
|
+
body: JSON.stringify({ root, profile, job, fire }),
|
|
49
|
+
});
|
|
50
|
+
if (!response.ok) {
|
|
51
|
+
const refusal = await response.json().catch(() => null);
|
|
52
|
+
throw new Error(typeof refusal?.error === 'string' ? refusal.error.slice(0, 2000) : `Hosted task was not confirmed (HTTP ${response.status})`);
|
|
53
|
+
}
|
|
54
|
+
const receipt = await response.json();
|
|
55
|
+
if (receipt.fire_id !== fire.id || receipt.profile !== profile || receipt.accepted !== true)
|
|
56
|
+
throw new Error('Hosted task receipt did not match this fire');
|
|
57
|
+
return receipt;
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Stop this package's hosted daemon through its native signal handler. The
|
|
62
|
+
* daemon retains its own lease until shutdown completes; this never clears it
|
|
63
|
+
* early or escalates to SIGKILL. A foreign/unhosted owner is refused. */
|
|
64
|
+
export async function stopHostedDaemon({ root, executorUrl, timeoutMs = 30_000 }) {
|
|
65
|
+
root = resolve(root);
|
|
66
|
+
let lease;
|
|
67
|
+
try { lease = JSON.parse(readFileSync(join(root, 'orchestrator.lock'), 'utf8')); }
|
|
68
|
+
catch (error) { if (error.code === 'ENOENT') return { stopped: true, wasRunning: false }; throw error; }
|
|
69
|
+
if (!Number.isInteger(lease.pid) || lease.pid <= 0 || resolve(lease.root ?? '') !== root || typeof lease.executor_url !== 'string' || (executorUrl && lease.executor_url !== executorUrl)) throw new Error('Another executor owns this orchestrator home.');
|
|
70
|
+
// A lease another machine or an earlier boot wrote names no process here: nothing is signalled on its word.
|
|
71
|
+
if (!leaseIsHere(lease)) return { stopped: true, wasRunning: false };
|
|
72
|
+
const alive = () => { try { process.kill(lease.pid, 0); return true; } catch (error) { if (error.code === 'ESRCH') return false; throw error; } };
|
|
73
|
+
if (!alive()) return { stopped: true, wasRunning: false };
|
|
74
|
+
process.kill(lease.pid, 'SIGTERM');
|
|
75
|
+
const deadline = Date.now() + timeoutMs;
|
|
76
|
+
while (alive()) {
|
|
77
|
+
if (Date.now() >= deadline) throw new Error('The orchestrator has not finished stopping. Its lease and data were preserved.');
|
|
78
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
79
|
+
}
|
|
80
|
+
return { stopped: true, wasRunning: true };
|
|
81
|
+
}
|