@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.
@@ -0,0 +1,730 @@
1
+ #!/usr/bin/env node
2
+ // The orchestrator daemon entry (ORC-7).
3
+ //
4
+ // node bin/orchestrator.mjs --root <home> [--once]
5
+ // node bin/orchestrator.mjs access <verb> [args] [--root <home>] [--profile <name>]
6
+ //
7
+ // `supercode orchestrator start` spawns the daemon form; the daemon itself
8
+ // writes the lease (`<home>/orchestrator.lock`) naming this process and
9
+ // removes it on the way out, so a service-managed daemon reports the same
10
+ // lease a foreground one does. `supercode orchestrator stop` sends SIGTERM.
11
+ // A lock left behind by a process that is gone is the mark of a daemon that
12
+ // died: this one dispatches `restart` (§4.7) before it accepts anything.
13
+ //
14
+ // The `access` form is the COLD path of §4.6's operator verbs: it loads the
15
+ // home, applies the reducer's own event, and saves through `save()`. It is
16
+ // the only writer of the folder — `supercode orchestrator pair|access ...`
17
+ // spawns this rather than encoding the YAML a second time.
18
+ //
19
+ // It is deliberately thin: load the home strictly through the IR, build the
20
+ // `Orchestrator` (ORC-4/ORC-5) over a `SupercodeHarnessClient` and the adapters
21
+ // the profiles' `platforms:` blocks name, `start()` it, and stay up until
22
+ // SIGTERM — at which point `stop()` closes the tick, the webhook listener,
23
+ // every runtime and every adapter. The loop owns its own clock and its own
24
+ // `/webhooks/<name>` listener; nothing here schedules or serves anything. The
25
+ // only policy in this file is which adapter class serves which platform:
26
+ // `loopback`, the `api_server`-compatible HTTP surface, and the Chat SDK
27
+ // sidecar a block selects with `extra.adapter: chat-sdk` (§7, ORC-9).
28
+ //
29
+ // Beside the daemon it carries the two migration verbs (ORC-12):
30
+ //
31
+ // node bin/orchestrator.mjs import --from hermes|openclaw [--home <path>] [--into <root>]
32
+ // node bin/orchestrator.mjs export --to hermes|openclaw [--from <root>] [--into <dest>]
33
+ //
34
+ // `import` is a compile followed by a `save`; `export` is the codec, and it
35
+ // prints the tier of every file it wrote. A refused write (the session half,
36
+ // behind the UNI-22 stability gate) prints its reason and exits 2 — a partial
37
+ // migration is never reported as a whole one.
38
+ //
39
+ // And it carries the COLD PATH of the operator door (ORC-13, §4.6):
40
+ //
41
+ // node bin/orchestrator.mjs <op> --root <home> [--profile <p>] --json '<args>'
42
+ //
43
+ // where `<op>` is one of `operator.mjs`'s `OPERATOR_OPS`. It loads the home,
44
+ // applies the verb through the very same `applyOperator` the daemon's socket
45
+ // uses, performs the effects (`save`, `remove_profile_dir`) and prints one
46
+ // `{ok, result}` line — the identical answer the socket gives, so `supercode
47
+ // jobs create --harness orchestrator` writes the same bytes whether the daemon
48
+ // is up or down. It exits 0 on `ok`, 1 on a refusal.
49
+ //
50
+ // Everything it prints is one JSON object per line on stdout.
51
+
52
+ import { existsSync, mkdirSync, rmdirSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs';
53
+ import { basename, dirname, join, resolve } from 'node:path';
54
+ import { spawn } from 'node:child_process';
55
+ import { fileURLToPath } from 'node:url';
56
+ import { homedir } from 'node:os';
57
+ import { LOCK_FILE, leaseIsLive, readLease, thisHost } from '../lease.mjs';
58
+ import { socketPathFor } from '../mcp/server.mjs';
59
+ import { GATEWAY_KIND, holdGatewayLock } from '../hermes-locks.mjs';
60
+ import { adoptHome } from '../hermes-home.mjs';
61
+ import { hermesAccess } from '../hermes-cli.mjs';
62
+
63
+ import {
64
+ load, save, step, importFrom, exportTo, closeWorld, Orchestrator, LoopbackAdapter,
65
+ ChatSdkAdapter, ApiServerAdapter, Rh2Adapter,
66
+ TICK_MS, accessPolicy, applyOperator, performRemoveProfileDir, OPERATOR_OPS,
67
+ } from '../index.mjs';
68
+ import * as events from '../events.mjs';
69
+ import { httpJobExecutor } from '../hosted.mjs';
70
+ import { SupercodeHarnessClient } from '@volter/supercode-harness-sdk';
71
+
72
+ /// Adapter selector -> constructor. A `platforms:` block picks its adapter by
73
+ /// `extra.adapter` when it has one and by the block's own name otherwise, so
74
+ /// `platforms.slack.extra.adapter: chat-sdk` and `platforms.api_server` both
75
+ /// resolve here. §7's build order is loopback, then the api_server-compatible
76
+ /// HTTP surface, then the Chat SDK sidecar (ORC-9); Claude Code's channel
77
+ /// plugins are NOT an adapter family (ORC-1 F4) and have no entry. A block
78
+ /// whose selector is missing here is reported and left unconnected, never faked.
79
+ const ADAPTERS = {
80
+ loopback: (platform, profile) => new LoopbackAdapter({ platform, profile }),
81
+ 'chat-sdk': (platform, profile, ctx) => new ChatSdkAdapter({ platform, profile, profileDir: ctx.state.profiles[profile]?.dir ?? null, channel: ctx.channel, vault: ctx.vault, log: ctx.log }),
82
+ api_server: (platform, profile, ctx) => new ApiServerAdapter({ platform, profile, channel: ctx.channel, vault: ctx.vault, state: ctx.state, log: ctx.log }),
83
+ rh2: (platform, profile, ctx) => new Rh2Adapter({ platform, profile, channel: ctx.channel, vault: ctx.vault, log: ctx.log }),
84
+ };
85
+
86
+ /// The selector a `platforms:` block resolves through: its `extra.adapter`
87
+ /// when it names one (the loader prefixes `extra:` keys with `extra.`), else
88
+ /// the block's own name.
89
+ function adapterSelector(platform, channel) {
90
+ const named = channel?.extra?.['extra.adapter'] ?? channel?.extra?.adapter;
91
+ if (typeof named === 'string' && named) return named;
92
+ // a Hermes-shaped slack/telegram/discord block is served by the Chat SDK
93
+ // sidecar, with Hermes's own variables and ingress (HERMES_PLATFORMS)
94
+ return ['slack', 'telegram', 'discord'].includes(platform) ? 'chat-sdk' : platform;
95
+ }
96
+
97
+ function parseArgs(argv) {
98
+ const args = { root: null, once: false, version: false };
99
+ for (let i = 0; i < argv.length; i += 1) {
100
+ const arg = argv[i];
101
+ const take = () => {
102
+ const value = argv[i + 1];
103
+ i += 1;
104
+ return value;
105
+ };
106
+ if (arg === '--executor-url') args.executorUrl = take();
107
+ else if (arg === '--executor-token-file') args.executorTokenFile = take();
108
+ else if (arg === '--root') args.root = take();
109
+ else if (arg.startsWith('--root=')) args.root = arg.slice('--root='.length);
110
+ else if (arg === '--once') args.once = true;
111
+ else if (arg === '--version') args.version = true;
112
+ else throw new Error(`unknown argument \`${arg}\``);
113
+ }
114
+ return args;
115
+ }
116
+
117
+ function say(event, fields) {
118
+ process.stdout.write(`${JSON.stringify({ event, ...fields })}\n`);
119
+ }
120
+
121
+ function packageVersion() {
122
+ const here = dirname(fileURLToPath(import.meta.url));
123
+ return JSON.parse(readFileSync(join(here, '..', 'package.json'), 'utf8')).version;
124
+ }
125
+
126
+ function defaultRoot() {
127
+ return process.env.SUPERCODE_ORCHESTRATOR_HOME || join(homedir(), '.supercode/orchestrator');
128
+ }
129
+
130
+ // --------------------------------------------------------------- migration
131
+
132
+ /**
133
+ * Each gateway harness's own home resolution, mirrored exactly (the same
134
+ * semantics `crates/interchange/src/catalog.rs` mirrors), so an inherited
135
+ * environment means the same thing here as to the harness itself.
136
+ */
137
+ const HOMES = {
138
+ hermes: () => process.env.HERMES_HOME || join(homedir(), '.hermes'),
139
+ openclaw: () => process.env.OPENCLAW_STATE_DIR
140
+ || (process.env.OPENCLAW_HOME ? join(process.env.OPENCLAW_HOME, '.openclaw') : null)
141
+ || join(homedir(), '.openclaw'),
142
+ };
143
+
144
+ function parseMigrationArgs(argv, verb) {
145
+ const args = { harness: null, home: null, into: null };
146
+ const harnessFlag = verb === 'import' ? '--from' : '--to';
147
+ const homeFlag = verb === 'import' ? '--home' : '--from';
148
+ for (let i = 0; i < argv.length; i += 1) {
149
+ const arg = argv[i];
150
+ const take = () => { const value = argv[i + 1]; i += 1; return value; };
151
+ if (arg === harnessFlag) args.harness = take();
152
+ else if (arg.startsWith(`${harnessFlag}=`)) args.harness = arg.slice(harnessFlag.length + 1);
153
+ else if (arg === homeFlag) args.home = take();
154
+ else if (arg.startsWith(`${homeFlag}=`)) args.home = arg.slice(homeFlag.length + 1);
155
+ else if (arg === '--into') args.into = take();
156
+ else if (arg.startsWith('--into=')) args.into = arg.slice('--into='.length);
157
+ else throw new Error(`unknown argument \`${arg}\``);
158
+ }
159
+ if (!args.harness) throw new Error(`\`${verb}\` needs ${harnessFlag} ${Object.keys(HOMES).join('|')}`);
160
+ if (!HOMES[args.harness]) {
161
+ throw new Error(`unknown harness \`${args.harness}\`; ${verb} supports: ${Object.keys(HOMES).join(', ')}`);
162
+ }
163
+ return args;
164
+ }
165
+
166
+ function profileCounts(state) {
167
+ return Object.entries(state.profiles).map(([name, profile]) => profileReport(name, profile));
168
+ }
169
+
170
+ /**
171
+ * `import --from <harness>`: that harness's home becomes our folder, through
172
+ * `world.import` — a compile and a save composed in the harness process, so
173
+ * the credentials land in our `.env` without crossing the wire, and the
174
+ * files the world does not model (`MEMORY.md`, `skills/`, an agent's
175
+ * transcripts) are carried by path.
176
+ */
177
+ async function runImport(argv) {
178
+ const args = parseMigrationArgs(argv, 'import');
179
+ const home = resolve(args.home ?? HOMES[args.harness]());
180
+ const into = resolve(args.into ?? defaultRoot());
181
+ const imported = await importFrom(args.harness, home, into);
182
+ say('imported', {
183
+ from: args.harness,
184
+ home,
185
+ into,
186
+ profiles: profileCounts(imported.orchestration),
187
+ carried: imported.carried,
188
+ secrets: [...imported.vault_keys].sort(),
189
+ });
190
+ return 0;
191
+ }
192
+
193
+ /**
194
+ * `export --to <harness>`: our folder becomes that harness's home, through
195
+ * `world.export`. Our `state.db` is never a Hermes session store — it carries
196
+ * the world's `bindings`, not Hermes's `sessions` — so the codec's own UNI-22
197
+ * gate refuses the session half, and a refused half is exit 2, never a whole
198
+ * migration.
199
+ */
200
+ async function runExport(argv) {
201
+ const args = parseMigrationArgs(argv, 'export');
202
+ const root = resolve(args.home ?? defaultRoot());
203
+ const into = resolve(args.into ?? join(root, `export-${args.harness}`));
204
+ const report = await exportTo(args.harness, root, into);
205
+ say('exported', {
206
+ to: args.harness,
207
+ root,
208
+ into,
209
+ written: report.written,
210
+ notes: report.notes ?? [],
211
+ refused: report.refused,
212
+ });
213
+ for (const refusal of report.refused) say('refused', { file: refusal.file, reason: refusal.reason });
214
+ return report.refused.length ? 2 : 0;
215
+ }
216
+
217
+ // --------------------------------------------------------------- operator
218
+
219
+ /**
220
+ * `<op> --root <home> [--profile <p>] [--json '<args>']` — the cold path of
221
+ * the operator door.
222
+ *
223
+ * One code path with the daemon: `applyOperator` is the same function the
224
+ * socket calls, and the effects it produces are performed here exactly as the
225
+ * loop performs them (`save` writes the folder through `save()`;
226
+ * `remove_profile_dir` moves a deleted profile's folder into
227
+ * `profiles/.trash/`). Nothing else runs — no adapters, no tick, no worker.
228
+ */
229
+ /** One JSON line to a live daemon's operator door and its one-line answer. */
230
+ async function overSocket(path, request) {
231
+ const { createConnection } = await import('node:net');
232
+ return new Promise((resolve) => {
233
+ const socket = createConnection(path);
234
+ let buffer = '';
235
+ const done = (answer) => { socket.destroy(); resolve(answer); };
236
+ socket.setTimeout(30_000, () => done({ ok: false, error: `the daemon's door at ${path} did not answer` }));
237
+ socket.once('connect', () => socket.write(`${JSON.stringify(request)}\n`));
238
+ socket.on('data', (chunk) => {
239
+ buffer += chunk;
240
+ const nl = buffer.indexOf('\n');
241
+ if (nl < 0) return;
242
+ try { done(JSON.parse(buffer.slice(0, nl))); } catch { done({ ok: false, error: 'the daemon answered with no JSON' }); }
243
+ });
244
+ socket.once('error', (error) => done({ ok: false, error: `the daemon's lease is live but its door refused: ${error.message}` }));
245
+ });
246
+ }
247
+
248
+ function parseOperatorArgs(argv) {
249
+ const args = { root: null, profile: 'default', json: '{}', at: null };
250
+ for (let i = 0; i < argv.length; i += 1) {
251
+ const arg = argv[i];
252
+ const take = () => { const value = argv[i + 1]; i += 1; return value; };
253
+ if (arg === '--root') args.root = take();
254
+ else if (arg.startsWith('--root=')) args.root = arg.slice('--root='.length);
255
+ else if (arg === '--profile') args.profile = take();
256
+ else if (arg.startsWith('--profile=')) args.profile = arg.slice('--profile='.length);
257
+ else if (arg === '--json') args.json = take();
258
+ else if (arg.startsWith('--json=')) args.json = arg.slice('--json='.length);
259
+ // An injected clock, so a receipt is reproducible. Never a default.
260
+ else if (arg === '--at') args.at = take();
261
+ else if (arg.startsWith('--at=')) args.at = arg.slice('--at='.length);
262
+ else throw new Error(`unknown argument \`${arg}\``);
263
+ }
264
+ return args;
265
+ }
266
+
267
+ async function runOperator(op, argv) {
268
+ const args = parseOperatorArgs(argv);
269
+ const root = resolve(args.root ?? defaultRoot());
270
+ let payload;
271
+ try {
272
+ payload = JSON.parse(args.json || '{}');
273
+ } catch (error) {
274
+ process.stdout.write(`${JSON.stringify({ ok: false, error: `--json is not JSON: ${error.message}` })}\n`);
275
+ return 1;
276
+ }
277
+ // A live daemon holds the home's state in memory and re-saves it: a write made beside it would be lost on its next
278
+ // save. While its lease is live the verb goes over its socket, as supercode's own client sends it (ORC-13).
279
+ const lease = readLease(root);
280
+ if (lease && leaseIsLive(lease)) {
281
+ const answer = await overSocket(socketPathFor(root), { op, args: payload, profile: args.profile });
282
+ process.stdout.write(`${JSON.stringify({ ...answer, root, door: 'live', context_profile: args.profile })}\n`);
283
+ return answer.ok ? 0 : 1;
284
+ }
285
+ let state;
286
+ let vault;
287
+ try {
288
+ ({ state, vault } = await load(root));
289
+ } catch (error) {
290
+ process.stdout.write(`${JSON.stringify({ ok: false, error: `${root}: ${String(error.message ?? error)}` })}\n`);
291
+ return 1;
292
+ }
293
+ const now = args.at ?? new Date().toISOString();
294
+ const logs = [];
295
+ const dispatch = async (event) => {
296
+ const effects = step(state, event);
297
+ for (const effect of effects) {
298
+ if (effect.type === 'save') await save(state, root);
299
+ else if (effect.type === 'remove_profile_dir') performRemoveProfileDir(root, effect, now);
300
+ else if (effect.type === 'log') logs.push(`${effect.level}: ${effect.message}`);
301
+ // Every other effect needs a running loop (a worker, an adapter). The
302
+ // cold path performs none of them and says so rather than pretending.
303
+ else logs.push(`skipped: ${effect.type} needs the daemon`);
304
+ }
305
+ return effects;
306
+ };
307
+ const answer = await applyOperator(state, op, payload, args.profile, now, dispatch);
308
+ // `root`/`door` ride BESIDE the result, never inside it: `profiles.create`
309
+ // answers with a `profile` record of its own, and the door must not clobber
310
+ // the verb's own row.
311
+ const line = answer.ok
312
+ ? { ok: true, result: answer.result, root, door: 'cold', context_profile: args.profile }
313
+ : { ok: false, error: answer.error, root, door: 'cold', context_profile: args.profile };
314
+ if (logs.length) line.log = logs;
315
+ process.stdout.write(`${JSON.stringify(line)}\n`);
316
+ return answer.ok ? 0 : 1;
317
+ }
318
+
319
+ /**
320
+ * One adapter per (platform, owning profile) named by an enabled `platforms:`
321
+ * block. A platform whose adapter does not exist yet is listed as
322
+ * `unsupported`: the daemon never pretends a channel is connected.
323
+ */
324
+ function buildAdapters(state, vault = new Map(), log = () => {}) {
325
+ const adapters = {};
326
+ const report = [];
327
+ for (const [profileName, profile] of Object.entries(state.profiles)) {
328
+ for (const [platform, channel] of Object.entries(profile.channels)) {
329
+ if (!channel.enabled) {
330
+ report.push({ platform, profile: profileName, state: 'disabled' });
331
+ continue;
332
+ }
333
+ if (adapters[platform]) {
334
+ report.push({ platform, profile: profileName, state: 'already owned by another profile' });
335
+ continue;
336
+ }
337
+ const selector = adapterSelector(platform, channel);
338
+ const make = ADAPTERS[selector];
339
+ if (!make) {
340
+ report.push({ platform, profile: profileName, state: `unsupported (no adapter \`${selector}\` for this platform yet)` });
341
+ continue;
342
+ }
343
+ adapters[platform] = make(platform, profileName, { channel, state, vault, log });
344
+ report.push({ platform, profile: profileName, adapter: selector, state: 'connecting' });
345
+ }
346
+ }
347
+ return { adapters, report };
348
+ }
349
+
350
+ function profileReport(name, profile) {
351
+ return {
352
+ profile: name,
353
+ dir: profile.dir,
354
+ worker: profile.worker ? profile.worker.harness : null,
355
+ model: profile.worker?.model ?? null,
356
+ jobs: Object.keys(profile.jobs).length,
357
+ routes: profile.routes.length,
358
+ subscriptions: Object.keys(profile.subscriptions).length,
359
+ bindings: Object.keys(profile.bindings).length,
360
+ fires: profile.fires.length,
361
+ obligations: profile.obligations.length,
362
+ };
363
+ }
364
+
365
+ // ---------------------------------------------------------------- the lease
366
+
367
+ /** The lease file, shared with `supercode orchestrator status|stop` (Rust `Lease`). */
368
+ const readLock = readLease;
369
+
370
+ function writeLock(root, lease) {
371
+ writeFileSync(join(root, LOCK_FILE), `${JSON.stringify(lease, null, 2)}\n`, { flag: 'wx', mode: 0o600 });
372
+ }
373
+
374
+ /** Only ever removes a lease this process holds. */
375
+ function clearLock(root) {
376
+ const held = readLock(root);
377
+ if (held && held.pid !== process.pid) return;
378
+ try { unlinkSync(join(root, LOCK_FILE)); } catch { /* already gone */ }
379
+ }
380
+
381
+ // ---------------------------------------------------------------- access verbs
382
+
383
+ /** `<verb> <positional...>` -> the operator event §4.6 defines for it. */
384
+
385
+ function parseAccessArgs(argv) {
386
+ const args = { root: null, profile: 'default', rest: [] };
387
+ for (let i = 0; i < argv.length; i += 1) {
388
+ const arg = argv[i];
389
+ if (arg === '--root') args.root = argv[(i += 1)];
390
+ else if (arg.startsWith('--root=')) args.root = arg.slice('--root='.length);
391
+ else if (arg === '--profile') args.profile = argv[(i += 1)];
392
+ else if (arg.startsWith('--profile=')) args.profile = arg.slice('--profile='.length);
393
+ else if (arg.startsWith('--')) throw new Error(`unknown argument \`${arg}\``);
394
+ else args.rest.push(arg);
395
+ }
396
+ return args;
397
+ }
398
+
399
+ /** Pending pairings as Hermes's pairing store lists them (codes are stored hashed; a row names who asked). */
400
+ function pairingRows(profile) {
401
+ return hermesAccess(profile.dir, 'pending').map((p) => ({ ...p, policy: accessPolicy(profile, p.platform) }));
402
+ }
403
+
404
+ function accessReport(root, name, profile, extra = {}) {
405
+ return {
406
+ root,
407
+ profile: name,
408
+ policy: profile.access.policy,
409
+ default_policy: 'allowlist',
410
+ allowlist: profile.access.allowlist,
411
+ admins: profile.access.admins,
412
+ pending: pairingRows(profile),
413
+ ...extra,
414
+ };
415
+ }
416
+
417
+ /** What each access verb asks of Hermes's own store and writers (docs/plans/hermes-compat.md row 8). */
418
+ const ACCESS_OPS = {
419
+ 'pair-approve': (profile, rest) => ['approve', { code: rest[0], platforms: [...new Set([...Object.keys(profile.channels ?? {}), ...hermesAccess(profile.dir, 'pending').map((p) => p.platform)])] }],
420
+ allow: (profile, rest) => ['allow', { platform: rest[0], user_id: rest[1] }],
421
+ revoke: (profile, rest) => ['revoke', { platform: rest[0], user_id: rest[1] }],
422
+ 'admin-add': (profile, rest) => ['admin_add', { platform: rest[0], user_id: rest[1] }],
423
+ 'admin-remove': (profile, rest) => ['admin_remove', { platform: rest[0], user_id: rest[1] }],
424
+ };
425
+
426
+ /**
427
+ * `access <verb> ...`: the COLD path. Access lives in Hermes's stores, so each verb is performed through
428
+ * Hermes's own pairing store and writers, then the home is loaded again to report what stands.
429
+ */
430
+ async function accessMain(argv) {
431
+ let parsed;
432
+ try {
433
+ parsed = parseAccessArgs(argv);
434
+ } catch (error) {
435
+ say('error', { message: error.message });
436
+ process.exit(2);
437
+ }
438
+ const [verb, ...rest] = parsed.rest;
439
+ const root = resolve(parsed.root ?? defaultRoot());
440
+ let state;
441
+ let vault;
442
+ try {
443
+ ({ state, vault } = await load(root));
444
+ } catch (error) {
445
+ say('error', { root, message: String(error.message ?? error), name: error.name ?? 'Error' });
446
+ process.exit(1);
447
+ }
448
+ const profile = state.profiles[parsed.profile];
449
+ if (!profile) {
450
+ say('error', { root, message: `unknown profile \`${parsed.profile}\` (have: ${Object.keys(state.profiles).join(', ')})` });
451
+ process.exit(1);
452
+ }
453
+ if (verb === 'pair-list') {
454
+ say('access', accessReport(root, parsed.profile, profile, { verb }));
455
+ return;
456
+ }
457
+ const make = ACCESS_OPS[verb];
458
+ if (!make) {
459
+ say('error', { root, message: `unknown access verb \`${verb ?? ''}\` (have: pair-list, ${Object.keys(ACCESS_OPS).join(', ')})` });
460
+ process.exit(2);
461
+ }
462
+ const [op, args] = make(profile, rest);
463
+ if ((op === 'approve' && !args.code) || (op !== 'approve' && (!args.platform || !args.user_id))) {
464
+ say('error', { root, profile: parsed.profile, verb, message: op === 'approve' ? 'pair-approve takes a code' : `${verb} takes a platform and a user id` });
465
+ process.exit(2);
466
+ }
467
+ let got;
468
+ try {
469
+ got = hermesAccess(profile.dir, op, args);
470
+ } catch (error) {
471
+ say('error', { root, profile: parsed.profile, verb, message: error.message });
472
+ process.exit(1);
473
+ }
474
+ if (op === 'approve' && !got) {
475
+ say('error', { root, profile: parsed.profile, verb, message: `pairing code ${args.code} is not pending in Hermes's pairing store (wrong, expired, or locked out)` });
476
+ process.exit(1);
477
+ }
478
+ const fresh = (await load(root)).state.profiles[parsed.profile];
479
+ say('access', accessReport(root, parsed.profile, fresh, { verb, changed: true, ...(op === 'approve' ? { approved: got } : {}) }));
480
+ }
481
+
482
+ /**
483
+ * The agent-setup applier (plan §7), one home per call:
484
+ *
485
+ * apply plan|apply|adopt|resolve|provision --home-id <id> (--hermes <home> | --orchestrator <root> [--profile <p>])
486
+ * [--spec <file.json>] [--param name=value ...] [--plan-hash <h>] [--reviewed] [--keys a,b]
487
+ * [--python <py> --source <dir>] [--container <name>] (--container: HERMES_HOME is the container's path)
488
+ *
489
+ * Prints one {ok, result} line. `plan` never changes anything; `apply` refuses a
490
+ * stale --plan-hash; `delete` runs only with --reviewed; a home with no apply
491
+ * state is plan-only until `provision` or `adopt`.
492
+ */
493
+ async function runApply(argv) {
494
+ const [verb, ...rest] = argv;
495
+ const opts = {};
496
+ const params = {};
497
+ for (let i = 0; i < rest.length; i += 1) {
498
+ const flag = rest[i];
499
+ if (flag === '--reviewed') { opts.reviewed = true; continue; }
500
+ if (flag === '--param') {
501
+ const pair = String(rest[i + 1] ?? '');
502
+ const eq = pair.indexOf('=');
503
+ if (eq < 1) throw new Error(`apply: --param takes name=value, got ${JSON.stringify(pair)}`);
504
+ params[pair.slice(0, eq)] = pair.slice(eq + 1);
505
+ i += 1;
506
+ continue;
507
+ }
508
+ if (!flag.startsWith('--') || rest[i + 1] === undefined) throw new Error(`apply: unexpected argument ${JSON.stringify(flag)}`);
509
+ opts[flag.slice(2)] = rest[i + 1];
510
+ i += 1;
511
+ }
512
+ const applier = await import('../apply/index.mjs');
513
+ const doors = await import('../apply/doors.mjs');
514
+ if (!opts['home-id']) throw new Error('apply: --home-id is required (the runtime names each home)');
515
+ if (verb === 'provision') {
516
+ const state = applier.provision({ homeId: opts['home-id'] });
517
+ process.stdout.write(`${JSON.stringify({ ok: true, result: { provisioned: opts['home-id'], generation: state.generation } })}\n`);
518
+ return 0;
519
+ }
520
+ let door;
521
+ if (opts.hermes && opts.container) door = doors.hermesDoor({ home: opts.hermes, runner: doors.containerRunner({ container: opts.container }) });
522
+ else if (opts.hermes) door = doors.hermesDoor({ home: resolve(opts.hermes), ...doors.locateHermes({ python: opts.python ?? null, source: opts.source ?? null }) });
523
+ else if (opts.orchestrator) door = await doors.orchestratorDoor({ root: resolve(opts.orchestrator), profile: opts.profile ?? 'default', load });
524
+ else throw new Error('apply: name the home with --hermes <HERMES_HOME> or --orchestrator <root>');
525
+ const spec = opts.spec ? JSON.parse(readFileSync(resolve(opts.spec), 'utf8')) : null;
526
+ let result;
527
+ if (verb === 'plan') result = await applier.plan({ door, homeId: opts['home-id'], spec, params });
528
+ else if (verb === 'apply') result = await applier.apply({ door, homeId: opts['home-id'], spec, params, planHash: opts['plan-hash'] ?? null, reviewedPlan: Boolean(opts.reviewed) });
529
+ else if (verb === 'adopt') result = await applier.adopt({ door, homeId: opts['home-id'], keys: String(opts.keys ?? '').split(',').filter(Boolean) });
530
+ else if (verb === 'resolve') result = await applier.resolve({ door, homeId: opts['home-id'], keys: String(opts.keys ?? '').split(',').filter(Boolean) });
531
+ else throw new Error(`apply: ${JSON.stringify(verb)} is not plan | apply | adopt | resolve | provision`);
532
+ process.stdout.write(`${JSON.stringify({ ok: true, result })}\n`);
533
+ return 0;
534
+ }
535
+
536
+ async function main() {
537
+ const argv = process.argv.slice(2);
538
+ if (OPERATOR_OPS.includes(argv[0])) {
539
+ try {
540
+ const code = await runOperator(argv[0], argv.slice(1));
541
+ await closeWorld();
542
+ process.exit(code);
543
+ } catch (error) {
544
+ process.stdout.write(`${JSON.stringify({ ok: false, error: String(error.message ?? error) })}\n`);
545
+ process.exit(1);
546
+ }
547
+ }
548
+ if (argv[0] === 'import' || argv[0] === 'export') {
549
+ try {
550
+ const code = await (argv[0] === 'import' ? runImport(argv.slice(1)) : runExport(argv.slice(1)));
551
+ await closeWorld();
552
+ process.exit(code);
553
+ } catch (error) {
554
+ say('error', { verb: argv[0], message: String(error.message ?? error), name: error.name ?? 'Error' });
555
+ await closeWorld();
556
+ process.exit(1);
557
+ }
558
+ }
559
+ if (argv[0] === 'access') {
560
+ await accessMain(argv.slice(1));
561
+ return;
562
+ }
563
+ if (argv[0] === 'apply') {
564
+ try {
565
+ const code = await runApply(argv.slice(1));
566
+ await closeWorld();
567
+ process.exit(code);
568
+ } catch (error) {
569
+ process.stdout.write(`${JSON.stringify({ ok: false, error: String(error.message ?? error) })}\n`);
570
+ await closeWorld();
571
+ process.exit(1);
572
+ }
573
+ }
574
+ let args;
575
+ try {
576
+ args = parseArgs(argv);
577
+ } catch (error) {
578
+ say('error', { message: error.message });
579
+ process.exit(2);
580
+ }
581
+ if (args.version) {
582
+ say('version', { version: packageVersion() });
583
+ return;
584
+ }
585
+ // The daemon runs as `hermes-gateway`: its home is a Hermes home, and Hermes calls a lock holder live only
586
+ // when its command line is a gateway's (hermes-locks.mjs). A launcher that did not name it so is re-run as it.
587
+ if (!args.once && basename(process.argv0) !== GATEWAY_KIND) {
588
+ const child = spawn(process.execPath, process.argv.slice(1), { argv0: GATEWAY_KIND, stdio: 'inherit', env: process.env });
589
+ for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP']) process.on(signal, () => child.kill(signal));
590
+ child.on('exit', (code, signal) => process.exit(code ?? (signal ? 1 : 0)));
591
+ return;
592
+ }
593
+ const root = resolve(args.root ?? defaultRoot());
594
+ let crashed = false;
595
+ let gatewayLock = null;
596
+ if (!args.once) {
597
+ // Serialize lease replacement before loading state. An acquisition guard
598
+ // left by a crash fails closed; inspect it before removing it manually.
599
+ const guard = join(root, 'orchestrator.acquire');
600
+ mkdirSync(guard);
601
+ try {
602
+ const previous = readLock(root);
603
+ if (previous && leaseIsLive(previous)) {
604
+ say('error', { root, message: `already serving ${root} (pid ${previous.pid})` });
605
+ throw new Error(`already serving ${root}`);
606
+ }
607
+ crashed = Boolean(previous);
608
+ if (previous) unlinkSync(join(root, LOCK_FILE));
609
+ writeLock(root, { pid: process.pid, started_at: new Date().toISOString(), root, ...thisHost(), executor_url: args.executorUrl ?? null });
610
+ } finally {
611
+ rmdirSync(guard);
612
+ }
613
+ // One runtime per home: Hermes's own gateway lock, held for as long as this daemon lives, so a Hermes
614
+ // gateway on this home refuses to start, and this daemon refuses a home a Hermes gateway serves.
615
+ try {
616
+ gatewayLock = await holdGatewayLock(root);
617
+ // the home in both forms before it is loaded: a Hermes home is adopted as it stands
618
+ for (const change of adoptHome(root)) say('log', { level: 'info', message: `home: ${change}` });
619
+ } catch (error) {
620
+ say('error', { root, message: error.message });
621
+ gatewayLock?.release();
622
+ clearLock(root);
623
+ process.exit(1);
624
+ }
625
+ }
626
+ let state;
627
+ let vault;
628
+ try {
629
+ ({ state, vault } = await load(root));
630
+ } catch (error) {
631
+ // A home that does not load is a refused start, never a partial one.
632
+ say('error', { root, message: String(error.message ?? error), name: error.name ?? 'Error' });
633
+ process.exit(1);
634
+ }
635
+ // Under Hermes each named profile is a home of its own (`<root>/profiles/<name>`) with its own gateway; this daemon
636
+ // serves them all, so it holds each profile's gateway lock too: `hermes -p <name> cron status` reads the profile's
637
+ // scheduler as running, and a Hermes gateway started on the profile refuses, as it does on the root.
638
+ const profileLocks = [];
639
+ if (gatewayLock) {
640
+ for (const profile of Object.values(state.profiles)) {
641
+ if (!profile?.dir || resolve(profile.dir) === root) continue;
642
+ try {
643
+ profileLocks.push(await holdGatewayLock(profile.dir));
644
+ } catch (error) {
645
+ say('error', { root, message: error.message });
646
+ for (const lock of profileLocks) lock.release();
647
+ gatewayLock.release();
648
+ clearLock(root);
649
+ process.exit(1);
650
+ }
651
+ }
652
+ }
653
+
654
+ const { adapters, report } = buildAdapters(state, vault, (level, message) => say('log', { level, message }));
655
+ // `SUPERCODE_BIN` is set by `supercode orchestrator start` to the very
656
+ // binary that spawned this daemon, so the worker doors it opens are that
657
+ // build's, not whatever `supercode` a PATH lookup happens to find.
658
+ const client = new SupercodeHarnessClient(process.env.SUPERCODE_BIN ? { command: process.env.SUPERCODE_BIN } : {});
659
+ const orchestrator = new Orchestrator({
660
+ executeJob: args.executorUrl ? httpJobExecutor({ url: args.executorUrl, tokenFile: args.executorTokenFile, root }) : null,
661
+ state,
662
+ vault,
663
+ client,
664
+ adapters,
665
+ log: (level, message) => say('log', { level, message }),
666
+ });
667
+
668
+ say('loaded', {
669
+ root,
670
+ pid: process.pid,
671
+ version: packageVersion(),
672
+ started_at: new Date().toISOString(),
673
+ profiles: Object.entries(state.profiles).map(([name, profile]) => profileReport(name, profile)),
674
+ adapters: report,
675
+ });
676
+
677
+ if (args.once) {
678
+ // `--once` proves the home loads and the loop builds without connecting
679
+ // anything: the check `setup` and a service unit's dry run want.
680
+ say('ready', { root, pid: process.pid, connected: [], once: true });
681
+ return;
682
+ }
683
+
684
+
685
+ await orchestrator.start();
686
+ // A daemon that came BACK replays pending obligations, re-attaches or
687
+ // interrupts the bindings that held a lease, and clears the stale ones
688
+ // before it accepts anything new (§4.7). A clean stop removes its own lock,
689
+ // so an ordinary start finds none and replays nothing.
690
+ if (crashed) await orchestrator.dispatch(events.restart(new Date().toISOString()));
691
+ say('ready', {
692
+ root,
693
+ pid: process.pid,
694
+ // an adapter that refused to come up is `down`, never counted as connected
695
+ connected: Object.entries(state.runtime.adapters ?? {}).filter(([, a]) => a.state === 'up').map(([p]) => p),
696
+ down: Object.entries(state.runtime.adapters ?? {}).filter(([, a]) => a.state !== 'up').map(([p]) => p),
697
+ webhook_port: orchestrator.webhookPort,
698
+ tick_ms: TICK_MS,
699
+ restarted: crashed,
700
+ });
701
+
702
+ // The loop's own tick timer is `unref`'d, and the webhook listener may bind
703
+ // nothing a profile uses; this timer is what keeps the daemon alive on
704
+ // purpose, so "up" always means a process that will still be here next tick.
705
+ const keepalive = setInterval(() => {}, TICK_MS);
706
+
707
+ let stopping = false;
708
+ const stop = async (signal) => {
709
+ if (stopping) return;
710
+ stopping = true;
711
+ clearInterval(keepalive);
712
+ say('stopping', { root, pid: process.pid, signal });
713
+ try {
714
+ await orchestrator.stop();
715
+ await client.close?.();
716
+ await closeWorld();
717
+ } catch (error) {
718
+ say('log', { level: 'error', message: `stop: ${String(error.message ?? error)}` });
719
+ }
720
+ for (const lock of profileLocks) lock.release();
721
+ gatewayLock?.release();
722
+ clearLock(root);
723
+ say('stopped', { root, pid: process.pid });
724
+ process.exit(0);
725
+ };
726
+ process.on('SIGTERM', () => void stop('SIGTERM'));
727
+ process.on('SIGINT', () => void stop('SIGINT'));
728
+ }
729
+
730
+ await main();