@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/loop.mjs ADDED
@@ -0,0 +1,864 @@
1
+ // The orchestrator loop: events in, `step` in the middle, effects out over the
2
+ // SDK and the adapters (docs/ORCHESTRATOR-IR.md §4–§5, §7).
3
+ //
4
+ // This is the only module that performs I/O for the loop. It holds the single
5
+ // state value, the vault, the SDK client, the adapters, and an injected clock.
6
+
7
+ import { mkdirSync, writeFileSync, existsSync, readdirSync, readFileSync, renameSync, unlinkSync } from 'node:fs';
8
+ import { basename, join, dirname } from 'node:path';
9
+ import { step, leaseId } from './reducer.mjs';
10
+ import * as events from './events.mjs';
11
+ import { save, retainWorld, releaseWorld } from './orchestration.mjs';
12
+ import { INSTRUCTION_ARGUMENTS, NAME_ARGUMENTS, activationEnv, activationCwd, approvalPolicy, sandboxArguments, limitArguments, materializePersona, materializeSkills, modelArguments, turnCounter } from './activation.mjs';
13
+ import { workerRoute } from './worker-route.mjs';
14
+ import { hermesAccess, hermesApproval, untrustedServerVerdict, hermesToolEnv, memorySnapshot, mirrorSession, mirrorState, recordIncident, sealMirror } from './hermes-cli.mjs';
15
+ import { hermesSessionKey } from './ir.mjs';
16
+ import { surfaceKeyString } from './ir.mjs';
17
+ import { BREAKER_FAILURES, BREAKER_MINUTES, MAX_ATTEMPTS, historyRetention, jobGate } from './cron.mjs';
18
+ import { startWebhookListener } from './webhook.mjs';
19
+ import { startBridge } from './mcp/bridge.mjs';
20
+ import { socketPathFor } from './mcp/server.mjs';
21
+ import { mountOrchestratorMcp } from './mcp/mount.mjs';
22
+ import { performRemoveProfileDir } from './operator.mjs';
23
+ import { harnessHomes } from './mcp/verbs.mjs';
24
+ import { checkMonitor, runJobScript } from './gates.mjs';
25
+ import { startBoard } from './board.mjs';
26
+
27
+ /**
28
+ * Where a worker keeps its transcripts inside its config home (`<profile>/<harness>`), as discovery and
29
+ * materialization take them (`HarnessHomes`): the worker sessions the Hermes mirror reads and writes.
30
+ */
31
+ export const WORKER_STORE = Object.freeze({
32
+ codex: (home) => ({ codex: join(home, 'sessions') }),
33
+ 'claude-code': (home) => ({ claude_code: join(home, 'projects') }),
34
+ });
35
+
36
+ /** The tick the loop drives itself on when no clock was injected (§4.3: "every 60 s"). */
37
+ export const TICK_MS = 60_000;
38
+ const ADAPTER_RETRY_MS = 30_000;
39
+
40
+ export class Orchestrator {
41
+ /**
42
+ * @param {object} o
43
+ * @param {import('./index.d.ts').OrchestratorState} o.state
44
+ * @param {Map<string,string>} o.vault
45
+ * @param {import('@volter/supercode-harness-sdk').SupercodeHarnessClient} o.client
46
+ * @param {Record<string, object>} o.adapters platform -> adapter
47
+ * @param {() => string} [o.clock] RFC 3339 now; injected for tests (which then drive `tick` themselves)
48
+ * @param {(profile, worker, env) => object|undefined} [o.launchFor] launch override per worker (tests)
49
+ * @param {(level, message) => void} [o.log]
50
+ */
51
+ constructor({ state, vault = new Map(), client, adapters = {}, clock = null, launchFor = null, executeJob = null, log = () => {} }) {
52
+ this.state = state;
53
+ this.executeJob = executeJob;
54
+ this.hostedTasks = new Set();
55
+ state.runtime ??= {};
56
+ state.runtime.hostedJobs = !!executeJob;
57
+ state.runtime.hostedFires = {};
58
+ this.vault = vault;
59
+ this.client = client;
60
+ this.adapters = adapters;
61
+ this.clock = clock ?? (() => new Date().toISOString());
62
+ // an injected clock owns the tick: the test dispatches `events.tick` itself
63
+ this.autoTick = !clock;
64
+ this.timer = null;
65
+ this.webhooks = null;
66
+ this.bridge = null; // §7: the model-facing MCP door's loop side
67
+ this.launchFor = launchFor;
68
+ this.retired = new Map(); // runtimes a newer start displaced, by id, until their close names them
69
+ this.log = log;
70
+ this.runtimes = new Map(); // leaseId -> ManagedRuntime
71
+ this.queue = Promise.resolve();
72
+ this.effectLog = [];
73
+ this.support = null; // memoized `supportReport()`: the registry does not move under a run
74
+ }
75
+
76
+ async start() {
77
+ retainWorld();
78
+ for (const [platform, adapter] of Object.entries(this.adapters)) await this.connectAdapter(platform, adapter);
79
+ this.webhooks = await startWebhookListener({
80
+ state: this.state, vault: this.vault, port: webhookPort(this.state),
81
+ dispatch: (event) => this.dispatchLogged(event), clock: () => this.clock(), log: this.log,
82
+ });
83
+ this.bridge = await startBridge({ orch: this, socketPath: socketPathFor(this.state.root), log: this.log });
84
+ // the queue already logs a failed event; a voided dispatch must not also
85
+ // reject unhandled, which Node treats as fatal and which stopped the daemon
86
+ if (this.autoTick) { this.timer = setInterval(() => { this.tick().catch(() => undefined); }, TICK_MS); this.timer.unref?.(); }
87
+ // the home's board, dispatched as a Hermes gateway dispatches it (docs/plans/hermes-compat.md row 14)
88
+ if (this.autoTick) this.board = await startBoard({ root: this.state.root, config: this.state.profiles.default?.residue?.config ?? {}, log: this.log });
89
+ }
90
+
91
+ /**
92
+ * One adapter's connect. A platform that cannot connect (its service down, a refused token) is that
93
+ * adapter's state, not the daemon's: it is reported `down` with the cause, the other adapters and the
94
+ * daemon's own doors keep serving, and the connect is tried again until it holds or the daemon stops.
95
+ */
96
+ async connectAdapter(platform, adapter) {
97
+ let status;
98
+ try {
99
+ status = await adapter.connect((msg) => this.dispatchLogged(events.inbound({ ...msg, at: msg.at ?? this.clock() })));
100
+ if (status?.state === 'down' && status.configured === false) {
101
+ // not set up (a missing package or credential): no retry can fix it
102
+ this.log('error', `${platform}: not configured, not retried: ${status.error}`);
103
+ await this.dispatchLogged(events.adapterStatus({ platform, state: 'down', error: status.error, configured: false, at: this.clock() }));
104
+ return;
105
+ }
106
+ } catch (error) {
107
+ this.log('error', `${platform}: connect failed, retrying in ${ADAPTER_RETRY_MS / 1000}s: ${error instanceof Error ? error.message : String(error)}`);
108
+ await this.dispatchLogged(events.adapterStatus({ platform, state: 'down', error: error instanceof Error ? error.message : String(error), at: this.clock() }));
109
+ this.adapterRetries ??= new Map();
110
+ const timer = setTimeout(() => { this.adapterRetries?.delete(platform); void this.connectAdapter(platform, adapter); }, ADAPTER_RETRY_MS);
111
+ timer.unref?.();
112
+ this.adapterRetries.set(platform, timer);
113
+ return;
114
+ }
115
+ // connected: recording that is the queue's (a failed save is logged there),
116
+ // never a reason to connect the same adapter again
117
+ await this.dispatchLogged(events.adapterStatus({ platform, state: status?.state ?? 'up', at: this.clock() }));
118
+ }
119
+
120
+ /** The port the webhook listener bound (0 in config means "any free port"). */
121
+ get webhookPort() {
122
+ return this.webhooks?.port ?? null;
123
+ }
124
+
125
+ /** One clock tick: expiry, due cron jobs, obligation retry, breaker windows (§4.3). */
126
+ tick(at = this.clock()) {
127
+ return this.dispatch(events.tick(at, cronInactivitySeconds(), cronMaxParallel())).then(
128
+ (result) => { this.tickerHeartbeat(true); return result; },
129
+ (error) => { this.tickerHeartbeat(false); throw error; },
130
+ );
131
+ }
132
+
133
+ /**
134
+ * Hermes's ticker liveness (`cron/jobs.py` `record_ticker_heartbeat`), in each profile's cron store: the
135
+ * epoch of every tick in `ticker_heartbeat`, and of every tick that did not fail in `ticker_last_success`.
136
+ * `hermes cron status` reads both, so a scheduler alive but failing every tick is told from one firing jobs.
137
+ * Written whole and renamed into place, as Hermes writes them; a failed write never stops the tick.
138
+ */
139
+ tickerHeartbeat(success) {
140
+ const epoch = String(Date.now() / 1000);
141
+ for (const profile of Object.values(this.state.profiles ?? {})) {
142
+ if (!profile?.dir) continue;
143
+ for (const name of success ? ['ticker_heartbeat', 'ticker_last_success'] : ['ticker_heartbeat']) {
144
+ try {
145
+ const dir = join(profile.dir, 'cron');
146
+ mkdirSync(dir, { recursive: true });
147
+ const tmp = join(dir, `.hb_${process.pid}_${name}`);
148
+ writeFileSync(tmp, epoch);
149
+ renameSync(tmp, join(dir, name));
150
+ } catch (error) {
151
+ this.log('warn', `profile ${profile.name}: ${name} not written: ${error.message}`);
152
+ }
153
+ }
154
+ }
155
+ }
156
+
157
+ async stop() {
158
+ if (this.timer) { clearInterval(this.timer); this.timer = null; }
159
+ this.board?.stop(); this.board = null;
160
+ // Before any runtime closes: each binding records the runtime to resume (§4.7), while adapters can still deliver.
161
+ await this.dispatch(events.stopping(this.clock())).catch((error) => this.log('error', `stopping: ${error.message}`));
162
+ for (const timer of this.adapterRetries?.values() ?? []) clearTimeout(timer);
163
+ this.adapterRetries?.clear();
164
+ if (this.webhooks) { await this.webhooks.close(); this.webhooks = null; }
165
+ if (this.bridge) { await this.bridge.close(); this.bridge = null; }
166
+ this.gateAbort?.abort();
167
+ await this.drainHosted();
168
+ for (const rt of this.runtimes.values()) { try { await rt.close(); } catch { /* already gone */ } }
169
+ this.runtimes.clear();
170
+ for (const adapter of Object.values(this.adapters)) await adapter.close?.();
171
+ releaseWorld();
172
+ }
173
+
174
+ /** Events are applied one at a time, in arrival order; effects run before the next event. */
175
+ dispatch(event) {
176
+ const run = this.queue.then(() => this.apply(event));
177
+ this.queue = run.catch((error) => this.log('error', `dispatch: ${error.message}`));
178
+ return run;
179
+ }
180
+
181
+ /**
182
+ * Queue a follow-up event from inside an effect handler WITHOUT awaiting it.
183
+ * An effect runs on the queue; awaiting `dispatch` there would wait on the
184
+ * event currently being applied, i.e. on itself.
185
+ */
186
+ enqueue(event) {
187
+ void this.dispatchLogged(event);
188
+ }
189
+
190
+ /**
191
+ * `dispatch` for a caller that drops the promise (a timer, an adapter's
192
+ * inbound callback, the webhook listener). The queue already logged the
193
+ * failure; an unhandled rejection is fatal to Node and stopped the daemon.
194
+ */
195
+ dispatchLogged(event) {
196
+ return this.dispatch(event).catch(() => undefined);
197
+ }
198
+
199
+ async apply(event) {
200
+ const effects = step(this.state, event);
201
+ for (const effect of effects) {
202
+ this.effectLog.push(effect);
203
+ await this.perform(effect);
204
+ }
205
+ return effects;
206
+ }
207
+
208
+ async perform(effect) {
209
+ switch (effect.type) {
210
+ case 'execute_job': return this.executeHostedJob(effect);
211
+ case 'run_gate': return this.runGate(effect);
212
+ case 'runtime_start': return this.startRuntime(effect);
213
+ case 'runtime_reattach': return this.reattachRuntime(effect);
214
+ case 'send_input': return this.sendInput(effect);
215
+ case 'steer_input': return this.steerInput(effect);
216
+ case 'respond': return this.respond(effect);
217
+ case 'runtime_close': return this.closeRuntime(effect);
218
+ case 'deliver': return this.deliver(effect);
219
+ case 'write_local': return this.writeLocal(effect);
220
+ // ORC-13 §4.6: `profiles.delete` moves the folder aside; it never
221
+ // unlinks one. `performRemoveProfileDir` is shared with the cold path.
222
+ case 'remove_profile_dir': {
223
+ const moved = performRemoveProfileDir(this.state.root, effect, this.clock());
224
+ this.log('info', moved ? `profile ${effect.profile}: folder moved to ${moved}` : `profile ${effect.profile}: no folder to move`);
225
+ return;
226
+ }
227
+ case 'record_incident': {
228
+ const dir = this.state.profiles[effect.profile]?.dir;
229
+ const got = dir ? recordIncident(dir, effect) : { id: null, why: 'no such profile' };
230
+ if (!got.id) this.log('warn', `incident for ${effect.job_id} not recorded: ${got.why}`);
231
+ return;
232
+ }
233
+ case 'pairing_issue': {
234
+ let got;
235
+ try { got = hermesAccess(this.state.profiles[effect.profile].dir, 'issue', effect); } catch (error) { this.log('warn', `pairing: ${error.message}`); return; }
236
+ if (got?.silent) return;
237
+ await this.dispatch({ type: 'pairing_issued', profile: effect.profile, key: effect.key, code: got?.code ?? null, at: this.clock() });
238
+ return;
239
+ }
240
+ case 'access_write': {
241
+ const dir = this.state.profiles[effect.profile]?.dir;
242
+ try {
243
+ const got = hermesAccess(dir, effect.op, effect);
244
+ if (effect.op === 'approve') {
245
+ if (got) await this.dispatch({ type: 'operator', verb: 'access_granted', profile: effect.profile, args: { platform: got.platform, user_id: got.user_id }, at: this.clock() });
246
+ else this.log('warn', `pairing code ${effect.code}: not pending in Hermes's pairing store (wrong, expired, or locked out)`);
247
+ }
248
+ } catch (error) {
249
+ this.log('warn', `access ${effect.op}: ${error.message}`);
250
+ }
251
+ return;
252
+ }
253
+ case 'save': await save(this.state, this.state.root); return;
254
+ case 'log': this.log(effect.level, effect.message); return;
255
+ default: this.log('warn', `unknown effect ${effect.type}`);
256
+ }
257
+ }
258
+
259
+ executeHostedJob({ profile: name, job_id, fire_id }) {
260
+ const profile = this.state.profiles[name];
261
+ const job = structuredClone(profile.jobs[job_id]);
262
+ const fire = structuredClone(profile.fires.find((f) => f.id === fire_id));
263
+ // Host work never blocks the state queue; completion returns as an event.
264
+ const task = Promise.resolve().then(() => this.executeJob({ profile: name, job, fire }))
265
+ .then((receipt) => {
266
+ if (!receipt || typeof receipt.output !== 'string' || receipt.accepted !== true)
267
+ throw new Error('The host did not confirm this scheduled task');
268
+ return this.dispatch({ type: 'hosted_result', profile: name, fire_id, receipt, at: this.clock() });
269
+ }).catch((error) => this.dispatch({ type: 'hosted_result', profile: name, fire_id, error: error.message, at: this.clock() }))
270
+ .catch((error) => this.log('error', `Hosted completion could not be saved: ${error.message}`))
271
+ .finally(() => this.hostedTasks.delete(task));
272
+ this.hostedTasks.add(task);
273
+ }
274
+
275
+ /**
276
+ * A fire's gate, off the queue (a script may run for Hermes's hour): the
277
+ * monitor source first, then the pre-run script unless the monitor already
278
+ * decided the run (Hermes's order in `run_job`). The answer returns as
279
+ * `gate_result`.
280
+ */
281
+ runGate({ profile: name, job_id, fire_id }) {
282
+ const profile = this.state.profiles[name];
283
+ const job = profile?.jobs[job_id] ? structuredClone(profile.jobs[job_id]) : null;
284
+ const gate = jobGate(job);
285
+ // stop() aborts a running gate's script rather than wait out its hour
286
+ if (!this.gateAbort || this.gateAbort.signal.aborted) this.gateAbort = new AbortController();
287
+ const signal = this.gateAbort.signal;
288
+ const secrets = [...this.vault.values()];
289
+ const task = (async () => {
290
+ if (!gate) return { script: { ok: false, output: 'the job no longer has a gate' } };
291
+ const workdir = job.workdir ? activationCwd(profile, job.workdir) : null;
292
+ let monitor = null;
293
+ if (gate.monitor_script || gate.monitor_url) {
294
+ monitor = await checkMonitor(profile, job, gate, { workdir, signal, secrets });
295
+ if (!monitor.ok || !monitor.changed) return { monitor };
296
+ }
297
+ // Hermes runs a no_agent script in the job's workdir, and an agent job's
298
+ // pre-run script from the scripts folder
299
+ const script = gate.script ? await runJobScript(profile, gate.script, gate.no_agent ? workdir : null, { signal, secrets }) : null;
300
+ return { monitor, script };
301
+ })()
302
+ .catch((error) => ({ script: { ok: false, output: `Script execution failed: ${error.message}` } }))
303
+ .then((answer) => (signal.aborted ? undefined : this.dispatchLogged(events.gateResult({ profile: name, fire_id, at: this.clock(), ...answer }))))
304
+ .finally(() => this.hostedTasks.delete(task));
305
+ this.hostedTasks.add(task);
306
+ }
307
+
308
+ /** Wait for admitted host tasks; useful for deterministic tests and graceful shutdown. */
309
+ async drainHosted() { await Promise.allSettled([...this.hostedTasks]); }
310
+
311
+ async startRuntime({ profile: profileName, keyString, worker, resume_runtime_id = null }) {
312
+ const profile = this.state.profiles[profileName];
313
+ const lid = leaseId(profileName, keyString);
314
+ const cwd = activationCwd(profile, worker.cwd);
315
+ materializePersona(profile);
316
+ materializeSkills(profile);
317
+ let launch;
318
+ try {
319
+ // the home's own `model:` is the worker's model and route (docs/plans/hermes-compat.md row 6)
320
+ const route = workerRoute(profile, worker, this.vault);
321
+ // a worker of any harness but Hermes gets Hermes's CLI against its profile's home (row 7)
322
+ const tools = worker.harness === 'hermes' ? {} : hermesToolEnv(profile.dir);
323
+ const env = { ...tools, ...activationEnv({ root: this.state.root, profile, surfaceKey: keyString, vault: this.vault }), ...route.env };
324
+ // the home's memory, as Hermes's agent carries it into a session (row 13)
325
+ const memory = INSTRUCTION_ARGUMENTS[worker.harness] ? await memorySnapshot(profile.dir).catch((error) => { this.log('warn', `memory ${keyString}: ${error.message}`); return ''; }) : '';
326
+ const instructions = memory ? INSTRUCTION_ARGUMENTS[worker.harness](memory) : [];
327
+ const naming = NAME_ARGUMENTS[worker.harness]?.(basename(this.state.root)) ?? [];
328
+ launch = await this.resolveLaunch(profile, { ...worker, model: route.model }, env, [...route.args, ...instructions, ...naming]);
329
+ } catch (error) {
330
+ this.enqueue(events.worker({ profile: profileName, keyString, runtime_id: null, ev: { kind: 'error', message: `start failed: ${error.message}` }, at: this.clock() }));
331
+ return;
332
+ }
333
+ // §7: the model-facing door. One MCP server per worker session, bound to
334
+ // this conversation, mounted through whatever door this harness publishes.
335
+ const mount = mountOrchestratorMcp({
336
+ root: this.state.root, profile, harness: worker.harness, surfaceKey: keyString, launch,
337
+ socketPath: this.bridge?.path ?? socketPathFor(this.state.root), vault: this.vault,
338
+ });
339
+ this.log('info', `mcp mount (${worker.harness}, ${mount.door}): ${mount.ran}`);
340
+ if (resume_runtime_id) resume_runtime_id = await this.continueFromHermes(profile, keyString, worker, resume_runtime_id, cwd);
341
+ // read before the runtime exists: a runtime emits from the moment it is made
342
+ const steer = Boolean((await this.descriptorFor(worker.harness).catch(() => null))?.runtime?.capabilities?.steer);
343
+ let runtime;
344
+ try {
345
+ const policy = approvalPolicy(profile, worker.harness);
346
+ const params = { harness: worker.harness, cwd, launch: mount.launch, ...(mount.mcp_servers ? { mcp_servers: mount.mcp_servers } : {}), ...(policy ? { approval_policy: policy } : {}) };
347
+ if (resume_runtime_id && (await this.descriptorFor(worker.harness).catch(() => null))?.runtime?.capabilities?.resume_session) {
348
+ try {
349
+ runtime = await this.client.resumeManagedRuntime({ ...params, runtime_id: resume_runtime_id });
350
+ this.log('info', `resume ${keyString}: reopened ${resume_runtime_id}`);
351
+ } catch (error) {
352
+ this.log('warn', `resume ${keyString}: ${resume_runtime_id} did not reopen (${error.message}); starting fresh`);
353
+ }
354
+ }
355
+ runtime ??= await this.client.startManagedRuntime(params);
356
+ } catch (error) {
357
+ this.enqueue(events.worker({ profile: profileName, keyString, runtime_id: null, ev: { kind: 'error', message: `start failed: ${error.message}` }, at: this.clock() }));
358
+ return;
359
+ }
360
+ // `started` is queued before the runtime can be heard (steer was read
361
+ // before the start), so none of its events arrives ahead of its lease
362
+ const previous = this.runtimes.get(lid);
363
+ if (previous && previous !== runtime) this.retired.set(previous.handle.runtime_id, previous);
364
+ this.runtimes.set(lid, runtime);
365
+ this.enqueue(events.worker({
366
+ profile: profileName, keyString, runtime_id: runtime.handle.runtime_id,
367
+ ev: { kind: 'started', runtime_id: runtime.handle.runtime_id, session_id: runtime.handle.runtime_id, connection: runtime.connection, harness: worker.harness, steer },
368
+ at: this.clock(),
369
+ }));
370
+ this.watch(profileName, keyString, runtime);
371
+ }
372
+
373
+ /**
374
+ * §4.7's first half, one binding at a time: the daemon came back and this
375
+ * surface held a lease when it went away. Where the worker's own door can
376
+ * adopt a process that is still running (`attach_existing_process`) the
377
+ * conversation is taken over as it stands; where it cannot — every harness
378
+ * whose runtime dies with its parent — the binding's last turn is marked
379
+ * interrupted and the surface is told once (`binding_interrupted`).
380
+ */
381
+ async reattachRuntime({ profile: profileName, keyString, runtime_id, harness }) {
382
+ const interrupted = (why) => {
383
+ this.log('info', `reattach ${keyString}: ${why}`);
384
+ this.enqueue(events.operator({ verb: 'binding_interrupted', profile: profileName, args: { keyString, runtime_id }, at: this.clock() }));
385
+ };
386
+ if (!runtime_id || !harness) return interrupted('no runtime recorded on the binding');
387
+ let capable = false;
388
+ try {
389
+ capable = Boolean((await this.descriptorFor(harness))?.runtime?.capabilities?.attach_existing_process);
390
+ } catch (error) {
391
+ return interrupted(`capabilities unavailable: ${error.message}`);
392
+ }
393
+ if (!capable) return interrupted(`${harness} cannot attach to an existing process`);
394
+ let runtime;
395
+ try {
396
+ runtime = await this.client.attachManagedRuntime({ harness, runtime_id });
397
+ } catch (error) {
398
+ return interrupted(`attach_existing failed: ${error.message}`);
399
+ }
400
+ this.runtimes.set(leaseId(profileName, keyString), runtime);
401
+ this.watch(profileName, keyString, runtime);
402
+ const held = this.state.profiles[profileName]?.bindings?.[keyString];
403
+ this.enqueue(events.worker({
404
+ profile: profileName, keyString, runtime_id: runtime.handle.runtime_id,
405
+ ev: { kind: 'started', runtime_id: runtime.handle.runtime_id, session_id: held?.worker?.session_id || runtime.handle.runtime_id, connection: runtime.connection, harness, reattach_of: runtime_id },
406
+ at: this.clock(),
407
+ }));
408
+ this.log('info', `reattach ${keyString}: attached to ${runtime.handle.runtime_id}`);
409
+ }
410
+
411
+ /** Every runtime the loop holds reports through the same three channels. */
412
+ watch(profileName, keyString, runtime) {
413
+ runtime.on('event', (ev) => this.onWorkerEvent(profileName, keyString, runtime, ev));
414
+ runtime.on('closed', (ev) => this.onWorkerEvent(profileName, keyString, runtime, ev));
415
+ runtime.on('runtimeError', (ev) => this.onWorkerEvent(profileName, keyString, runtime, ev));
416
+ }
417
+
418
+ /** The registry's row for one harness; the report is fetched at most once. */
419
+ async descriptorFor(harness) {
420
+ if (!this.support) {
421
+ this.support = this.client.supportReport().catch((error) => { this.support = null; throw error; });
422
+ }
423
+ const report = await this.support;
424
+ return report.harnesses.find((h) => h.id === harness) ?? null;
425
+ }
426
+
427
+ /** The registry's default launch with the activation env merged in, unless a test overrides it. */
428
+ async resolveLaunch(profile, worker, env, routeArgs = []) {
429
+ if (this.launchFor) {
430
+ const override = this.launchFor(profile, worker, env);
431
+ if (override) return { ...override, arguments: [...(override.arguments ?? []), ...modelArguments(worker), ...routeArgs, ...sandboxArguments(profile, worker.harness), ...limitArguments(profile, worker, this.log)] };
432
+ }
433
+ const base = (await this.descriptorFor(worker.harness))?.runtime?.default_launch;
434
+ if (!base) {
435
+ // the backend's own launch takes no env and no flags: a model set here could not reach it
436
+ if (worker.model) throw new Error(`${worker.harness} publishes no launch to carry model ${worker.model}`);
437
+ return undefined;
438
+ }
439
+ return { program: base.program, arguments: [...(base.arguments ?? []), ...modelArguments(worker), ...routeArgs, ...sandboxArguments(profile, worker.harness), ...limitArguments(profile, worker, this.log)], env: { ...(base.env ?? {}), ...env } };
440
+ }
441
+
442
+ /**
443
+ * Hermes's `agent.max_turns` for a worker that takes no turn cap at launch (Claude Code takes `--max-turns`,
444
+ * activation.mjs `TURN_ARGUMENTS`): the orchestrator counts the turn's model round trips, Hermes's own unit,
445
+ * by the event the worker reports for each (activation.mjs `ROUND_TRIP_EVENTS`), and interrupts the turn past
446
+ * the cap. A turn that runs away (a denial it retries, a loop) ends here instead of on the spend limit, and
447
+ * fails: its final names the cap (activation.mjs `turnCounter`).
448
+ */
449
+ holdTurnLimit(profileName, keyString, runtime, ev) {
450
+ const id = runtime.handle?.runtime_id ?? keyString;
451
+ this.turnCounters ??= new Map();
452
+ // a turn's start reads the profile's cap afresh; a worker that reports no start is counted from its first call
453
+ if (ev.type === 'turn_started' || !this.turnCounters.has(id)) {
454
+ const profile = this.state.profiles[profileName];
455
+ this.turnCounters.set(id, turnCounter(profile, profile?.bindings?.[keyString]?.worker?.harness ?? profile?.worker?.harness));
456
+ if (ev.type === 'turn_started') return;
457
+ }
458
+ const counter = this.turnCounters.get(id);
459
+ if (!counter?.count(ev)) return;
460
+ this.log('warn', `${keyString}: ${counter.cap + 1} model calls in one turn passes agent.max_turns ${counter.cap}; the turn is interrupted`);
461
+ void Promise.resolve(runtime.interrupt?.()).catch((error) => this.log('warn', `${keyString}: interrupt failed: ${error.message}`));
462
+ }
463
+
464
+ onWorkerEvent(profileName, keyString, runtime, ev) {
465
+ const at = this.clock();
466
+ // every event the runtime reports is the worker being alive, including the
467
+ // tool calls, reasoning and turn starts the reducer does not otherwise read
468
+ let w = normalizeWorkerEvent(ev) ?? { kind: 'activity' };
469
+ this.holdTurnLimit(profileName, keyString, runtime, ev);
470
+ if (w.kind === 'final') w = this.turnCounters?.get(runtime.handle?.runtime_id ?? keyString)?.settle(w) ?? w;
471
+ if (w.kind === 'ended' || w.kind === 'error') this.turnCounters?.delete(runtime.handle?.runtime_id ?? keyString);
472
+ if (w.kind === 'request') {
473
+ // Hermes's approval rule decides first (hermes-cli.mjs `hermesApproval`); the reducer applies its verdict
474
+ const profile = this.state.profiles[profileName];
475
+ const b = profile?.bindings?.[keyString];
476
+ const context = b?.recurrence || b?.residue?.webhook ? 'cron' : 'conversation';
477
+ // a tool of one of the profile's untrusted MCP servers is gated as Hermes's trust gate gates it
478
+ const gated = untrustedServerVerdict(profile, w.tool, context, { mcp: w.mcp && !String(w.tool ?? '').startsWith('mcp__orchestrator__') });
479
+ if (gated) { this.enqueue(events.worker({ profile: profileName, keyString, runtime_id: runtime.handle.runtime_id, ev: { ...w, hermes: gated }, at })); return; }
480
+ void hermesApproval(profile.dir, { command: w.command ?? null, context })
481
+ .catch((error) => { this.log('warn', `approval ${keyString}: ${error.message}`); return null; })
482
+ .then((hermes) => this.enqueue(events.worker({ profile: profileName, keyString, runtime_id: runtime.handle.runtime_id, ev: { ...w, hermes }, at })));
483
+ return;
484
+ }
485
+ this.enqueue(events.worker({ profile: profileName, keyString, runtime_id: runtime.handle.runtime_id, ev: w, at }));
486
+ if (w.kind === 'final') this.mirrorTurn(profileName, keyString, w);
487
+ }
488
+
489
+ /**
490
+ * After a worker's turn, its transcript into the home's Hermes session store (docs/plans/hermes-compat.md
491
+ * row 11), under the routing entry's session id, so the conversation opens under Hermes. A binding whose
492
+ * worker has not reported its session yet is mirrored on a later turn. A session Hermes wrote to since
493
+ * the last mirror is never rewritten: its turns carry over at the worker's next start.
494
+ */
495
+ mirrorTurn(profileName, keyString, final = null) {
496
+ const profile = this.state.profiles[profileName];
497
+ const b = profile?.bindings?.[keyString];
498
+ const w = b?.worker;
499
+ if (!w?.session_id || !WORKER_STORE[w.harness]) return;
500
+ const run = (this.mirrors ??= new Map());
501
+ const previous = run.get(keyString) ?? Promise.resolve();
502
+ const next = previous.then(async () => {
503
+ const locator = await this.workerLocator(profile, keyString, w);
504
+ if (!locator) { this.log('warn', `mirror ${keyString}: ${w.harness} session ${w.session_id} is not in ${join(profile.dir, w.harness)}`); return; }
505
+ const loaded = await this.client.loadWindow(locator, { message_limit: 1_000_000 });
506
+ // a fire's session ends with its run, as Hermes's scheduler ends it (`cron_complete`, or
507
+ // `cron_incomplete_no_output` for a run that said nothing)
508
+ const said = (loaded.session.messages ?? []).some((m) => m.role === 'assistant' && (typeof m.content === 'string' ? m.content.trim() : (m.content ?? []).length));
509
+ const end = b.recurrence && final ? (said ? 'cron_complete' : 'cron_incomplete_no_output') : null;
510
+ const got = await mirrorSession(profile.dir, {
511
+ id: w.session_id, sessionKey: hermesSessionKey(profileName, b.key), binding: b, session: loaded.session,
512
+ locator: locator.storage?.path ?? null, parent: this.continued?.get(keyString) ?? null, end,
513
+ });
514
+ if (got.diverged != null) this.log('warn', `mirror ${keyString}: Hermes continued ${w.session_id} while its worker was live; its turns carry over at the worker's next start, and this worker's turns since stay in its own store`);
515
+ else if (got.mirrored === null) this.log('warn', `mirror ${keyString}: ${got.why}`);
516
+ }).catch((error) => this.log('warn', `mirror ${keyString}: ${error.message}`));
517
+ run.set(keyString, next);
518
+ }
519
+
520
+ /** The worker's own session, found by its id in the profile's worker home. */
521
+ async workerLocator(profile, keyString, w) {
522
+ const cached = this.locators?.get(keyString);
523
+ if (cached?.session_id === w.session_id) return cached;
524
+ const { sessions } = await this.client.discover({
525
+ harnesses: [w.harness], homes: WORKER_STORE[w.harness](join(profile.dir, w.harness)),
526
+ include_child_sessions: true, root_session_id: w.session_id, limit: 8,
527
+ });
528
+ const found = sessions.find((d) => d.locator.session_id === w.session_id)?.locator ?? null;
529
+ if (found) (this.locators ??= new Map()).set(keyString, found);
530
+ return found;
531
+ }
532
+
533
+ /**
534
+ * The session a worker resumes (docs/plans/hermes-compat.md row 11, Hermes to worker). Where Hermes
535
+ * carried the conversation past the worker (a mirror resumed under Hermes, or a conversation Hermes
536
+ * held when this home was its), its transcript is handed to the worker's harness and materialized in
537
+ * the worker's home, the old mirror sealed, and that new session resumed; otherwise `sessionId`.
538
+ */
539
+ async continueFromHermes(profile, keyString, worker, sessionId, cwd) {
540
+ const store = WORKER_STORE[worker.harness];
541
+ if (!store) return sessionId;
542
+ const state = await mirrorState(profile.dir, sessionId);
543
+ if (!state?.ahead) return sessionId;
544
+ try {
545
+ const hermes = { harness: 'hermes', session_id: sessionId, storage: { kind: 'file', path: join(profile.dir, 'state.db') } };
546
+ const { artifact } = await this.client.handoffSession({ locator: hermes, target_harness: worker.harness, cwd });
547
+ const { locator } = await this.client.materializeSession({ artifact, cwd, homes: store(join(profile.dir, worker.harness)) });
548
+ await sealMirror(profile.dir, sessionId, locator.session_id);
549
+ (this.locators ??= new Map()).set(keyString, locator);
550
+ (this.continued ??= new Map()).set(keyString, sessionId);
551
+ this.log('info', `resume ${keyString}: Hermes carried ${sessionId} on (${state.messages} messages); it goes on as ${worker.harness} ${locator.session_id}`);
552
+ return locator.session_id;
553
+ } catch (error) {
554
+ this.log('warn', `resume ${keyString}: Hermes carried ${sessionId} on, and its turns could not be handed to ${worker.harness} (${error.message}); resuming the worker's own session without them`);
555
+ return sessionId;
556
+ }
557
+ }
558
+
559
+ /** A line for a turn in flight, through the harness's own `steer` door; refused, it waits for the turn's end. */
560
+ async steerInput({ profile, keyString, text }) {
561
+ const runtime = this.runtimes.get(leaseId(profile, keyString));
562
+ const requeue = (why) => {
563
+ this.log('info', `steer ${keyString}: ${why}; the line waits for the turn's end`);
564
+ this.enqueue(events.worker({ profile, keyString, runtime_id: runtime?.handle?.runtime_id ?? null, ev: { kind: 'steer_refused', text }, at: this.clock() }));
565
+ };
566
+ if (!runtime) return requeue('no runtime');
567
+ try { await runtime.steer(text); } catch (error) { requeue(error.message); }
568
+ }
569
+
570
+ async sendInput({ profile, keyString, text, context_from = null, skills = [], job_id = null }) {
571
+ const runtime = this.runtimes.get(leaseId(profile, keyString));
572
+ if (!runtime) { this.log('warn', `send_input with no runtime for ${keyString}`); return; }
573
+ const owner = this.state.profiles[profile];
574
+ const dir = owner?.dir;
575
+ let input = context_from && dir ? contextFromPrefix(dir, job_id, context_from) + text : text;
576
+ if (skills?.length && owner) input = await this.skillsPrefix(owner, skills, owner.jobs?.[job_id]?.workdir ?? null, input) ?? input;
577
+ try {
578
+ await runtime.sendInput(input);
579
+ } catch (error) {
580
+ this.enqueue(events.worker({ profile, keyString, runtime_id: runtime.handle.runtime_id, ev: { kind: 'error', message: `send failed: ${error.message}` }, at: this.clock() }));
581
+ }
582
+ }
583
+
584
+ /**
585
+ * Hermes `cron/scheduler.py:4733-4812`, transcribed: a job's `skills` are
586
+ * invoked, not merely available. Each named skill is found through the
587
+ * worker harness's own loader (`harness.v1.skills.list`, the roots that
588
+ * harness reads) and its `SKILL.md` is loaded ahead of the prompt with
589
+ * Hermes's framing; a skill that is not there is named in a notice the
590
+ * model is told to open with, never dropped silently.
591
+ */
592
+ async skillsPrefix(profile, list, workdir, input) {
593
+ const names = (Array.isArray(list) ? list : [list]).map((n) => String(n ?? '').trim()).filter(Boolean);
594
+ if (!names.length) return null;
595
+ let rows = [];
596
+ try {
597
+ rows = await this.client.listSkills({ harness: profile.worker.harness, cwd: activationCwd(profile, workdir), homes: harnessHomes(profile) }) ?? [];
598
+ } catch (error) {
599
+ this.log('warn', `skills for ${profile.name}: ${error.message}`);
600
+ }
601
+ const parts = [];
602
+ const skipped = [];
603
+ for (const name of names) {
604
+ const row = rows.find((r) => r.name === name && r.enabled !== false);
605
+ const file = row && join(row.location, 'SKILL.md');
606
+ // Hermes's template variable for the skill's own folder, so its
607
+ // scripts/ and references/ resolve (`agent/skill_preprocessing.py`)
608
+ const content = file && existsSync(file) ? readFileSync(file, 'utf8').replaceAll('${HERMES_SKILL_DIR}', row.location).trim() : '';
609
+ if (!content) { skipped.push(name); continue; }
610
+ if (parts.length) parts.push('');
611
+ parts.push(`[IMPORTANT: The user has invoked the "${name}" skill, indicating they want you to follow its instructions. The full skill content is loaded below.]`, '', content);
612
+ }
613
+ if (skipped.length) {
614
+ parts.unshift(`[IMPORTANT: The following skill(s) were listed for this job but could not be found and were skipped: ${skipped.join(', ')}. Start your response with a brief notice so the user is aware, e.g.: '⚠️ Skill(s) not found and skipped: ${skipped.join(', ')}']`);
615
+ this.log('warn', `skills for ${profile.name}: not found: ${skipped.join(', ')}`);
616
+ }
617
+ // Hermes ends the scaffold with its instruction marker (`append_user_instruction`)
618
+ parts.push('');
619
+ return `${parts.join('\n')}\nThe user has provided the following instruction alongside the skill invocation: ${input}`;
620
+ }
621
+
622
+ async respond({ profile, keyString, request_id, response }) {
623
+ const runtime = this.runtimes.get(leaseId(profile, keyString));
624
+ if (!runtime) return;
625
+ try { await runtime.respond(JSON.parse(request_id), response); }
626
+ catch { try { await runtime.respond(request_id, response); } catch (error) { this.log('warn', `respond failed: ${error.message}`); } }
627
+ }
628
+
629
+ async closeRuntime({ profile, keyString, runtime_id = null }) {
630
+ const lid = leaseId(profile, keyString);
631
+ // a close naming a runtime closes only that one, never a newer one on the same surface
632
+ if (runtime_id && this.retired.has(runtime_id)) {
633
+ const old = this.retired.get(runtime_id);
634
+ this.retired.delete(runtime_id);
635
+ this.turnCounters?.delete(runtime_id);
636
+ try { await old.close(); } catch { /* already gone */ }
637
+ return;
638
+ }
639
+ const runtime = this.runtimes.get(lid);
640
+ if (runtime_id && runtime && runtime.handle.runtime_id !== runtime_id) return;
641
+ this.runtimes.delete(lid);
642
+ if (runtime) this.turnCounters?.delete(runtime.handle.runtime_id);
643
+ if (runtime) { try { await runtime.close(); } catch { /* already gone */ } }
644
+ }
645
+
646
+ /**
647
+ * One send attempt, through the per-adapter breaker (§2.8). An open breaker
648
+ * leaves the obligation `pending` and its `attempts` untouched — the tick
649
+ * re-emits it once `until` passes. Three consecutive failures on one adapter
650
+ * open it; any success closes it.
651
+ */
652
+ async deliver({ profile: profileName, obligation_id }) {
653
+ const profile = this.state.profiles[profileName];
654
+ const o = profile.obligations.find((x) => x.id === obligation_id);
655
+ if (!o || o.state !== 'pending') return;
656
+ const now = this.clock();
657
+ const rt = this.state.runtime;
658
+ rt.adapters ??= {};
659
+ const adapter = this.adapters[o.target.platform];
660
+ // a platform no adapter serves gets no entry: the entries are what the
661
+ // delivery preflight reads as connected
662
+ if (!adapter) {
663
+ o.attempts += 1;
664
+ o.updated_at = String(Date.parse(now) / 1000);
665
+ o.state = 'failed';
666
+ o.last_error = `no adapter for ${o.target.platform}`;
667
+ this.recordDelivery(profile, o, o.last_error);
668
+ await save(this.state, this.state.root);
669
+ return;
670
+ }
671
+ const entry = (rt.adapters[o.target.platform] ??= { state: 'up', breaker: { failures: 0 } });
672
+ entry.breaker ??= { failures: 0 };
673
+ if (entry.breaker.until && Date.parse(now) < Date.parse(entry.breaker.until)) {
674
+ this.log('warn', `deliver ${o.id}: ${o.target.platform} breaker open until ${entry.breaker.until}`);
675
+ return;
676
+ }
677
+ o.attempts += 1;
678
+ o.updated_at = String(Date.parse(now) / 1000);
679
+ try {
680
+ // the whole OutboundContent (§2.1): reply_to and attachments reach the adapter, not only text
681
+ const receipt = await adapter.send(o.target, { ...o.content, text: o.content.text, at: now });
682
+ if (receipt?.message_id) o.posted = { message_id: String(receipt.message_id) }; // §2.6 `posted`: the platform's handle for what was sent (ORC-1 F4)
683
+ o.state = 'sent';
684
+ o.delivered_at = o.updated_at;
685
+ o.last_error = null;
686
+ entry.breaker = { failures: 0 };
687
+ this.recordDelivery(profile, o, null);
688
+ } catch (error) {
689
+ o.last_error = error.message;
690
+ entry.breaker.failures += 1;
691
+ if (entry.breaker.failures >= BREAKER_FAILURES) {
692
+ entry.breaker.until = new Date(Date.parse(now) + BREAKER_MINUTES * 60_000).toISOString();
693
+ this.log('warn', `${o.target.platform}: ${entry.breaker.failures} consecutive send failures, breaker open until ${entry.breaker.until}`);
694
+ }
695
+ if (o.attempts >= MAX_ATTEMPTS) { o.state = 'failed'; this.recordDelivery(profile, o, error.message); }
696
+ }
697
+ await save(this.state, this.state.root);
698
+ }
699
+
700
+ /**
701
+ * Hermes's `last_delivery_error` on the job (`cron/scheduler.py`,
702
+ * `mark_job_run`): the reason a fire's answer never arrived. Each run
703
+ * resets it (`finishFire`); a failed delivery writes it and is logged.
704
+ */
705
+ recordDelivery(profile, o, error) {
706
+ const fire = o.source?.kind === 'fire' ? profile.fires.find((f) => f.id === o.source.fire_id) : null;
707
+ const job = fire ? profile.jobs[fire.job_id] : null;
708
+ if (job) (job.residue ??= {}).last_delivery_error = error;
709
+ if (!error) return;
710
+ const what = job ? `Cron job ${job.id}` : o.source?.kind === 'webhook' ? `Webhook ${o.source.name}` : 'A reply';
711
+ this.log('warn', `${what}: its answer could not be delivered to ${o.target.platform} after ${o.attempts} attempt(s): ${error}`);
712
+ }
713
+
714
+ /** `deliver: local` — the fire's answer as a file under the profile folder (§4.3). */
715
+ writeLocal({ profile: profileName, path, text }) {
716
+ const profile = this.state.profiles[profileName];
717
+ if (!profile) { this.log('warn', `write_local for unknown profile ${profileName}`); return; }
718
+ const full = join(profile.dir, path);
719
+ mkdirSync(dirname(full), { recursive: true });
720
+ writeFileSync(full, text.endsWith('\n') ? text : `${text}\n`);
721
+ // Hermes `_prune_job_output`: a job keeps its newest `cron.output_retention`
722
+ // run files (50), never the whole history; zero or less keeps everything
723
+ if (/^cron\/output\/[^/]+\/[^/]+\.md$/.test(path)) {
724
+ const keep = historyRetention(profile).outputs;
725
+ if (keep > 0) {
726
+ const dir = dirname(full);
727
+ try {
728
+ for (const stale of readdirSync(dir).filter((f) => f.endsWith('.md')).sort().reverse().slice(keep)) {
729
+ try { unlinkSync(join(dir, stale)); } catch { /* pruning never breaks saving */ }
730
+ }
731
+ } catch { /* as in Hermes: pruning failures are swallowed */ }
732
+ }
733
+ }
734
+ }
735
+ }
736
+
737
+ /**
738
+ * Hermes `cron/scheduler.py:4823-4895`, transcribed: for each job id in
739
+ * `context_from` ("self" is this job), the newest `cron/output/<id>/*.md` is
740
+ * prepended, capped at 8,000 characters, with Hermes's own framing. A job with
741
+ * no output yet is skipped silently, as there.
742
+ */
743
+ export function contextFromPrefix(profileDir, jobId, refs) {
744
+ let prefix = '';
745
+ for (const ref of refs ?? []) {
746
+ const isSelf = String(ref).trim().toLowerCase() === 'self' || ref === jobId;
747
+ const id = isSelf ? jobId : String(ref).trim();
748
+ if (!id || /[\/\\]/.test(id) || id === '.' || id === '..') continue;
749
+ const dir = join(profileDir, 'cron', 'output', id);
750
+ if (!existsSync(dir)) continue;
751
+ const files = readdirSync(dir).filter((f) => f.endsWith('.md')).sort();
752
+ if (!files.length) continue;
753
+ let latest = readFileSync(join(dir, files[files.length - 1]), 'utf8').trim();
754
+ if (!latest) continue;
755
+ if (latest.length > 8000) latest = `${latest.slice(0, 8000)}\n\n[... output truncated ...]`;
756
+ prefix += isSelf
757
+ ? `## Your previous run's output\nThe following is this job's most recent output from its previous run. Use it for continuity: avoid repeating what was already reported, and continue where the last run left off.\n\n\`\`\`\n${latest}\n\`\`\`\n\n`
758
+ : `## Output from job '${id}'\nThe following is the most recent output from a preceding cron job. Use it as context for your analysis.\n\n\`\`\`\n${latest}\n\`\`\`\n\n`;
759
+ }
760
+ return prefix;
761
+ }
762
+
763
+ /**
764
+ * Hermes's `HERMES_CRON_MAX_PARALLEL` (the daemon's environment), which wins
765
+ * over each profile's `cron.max_parallel_jobs`: a positive cap, or null.
766
+ */
767
+ export function cronMaxParallel(raw = process.env.HERMES_CRON_MAX_PARALLEL) {
768
+ const text = String(raw ?? '').trim();
769
+ if (!text || !/^[+-]?\d+$/.test(text)) return null;
770
+ const n = Number(text);
771
+ return n > 0 ? n : null;
772
+ }
773
+
774
+ /**
775
+ * Hermes `cron/scheduler.py` `_cron_inactivity_seconds`, transcribed: the
776
+ * daemon's `HERMES_CRON_TIMEOUT` in seconds; unset or unreadable is 600, 0 is
777
+ * no limit.
778
+ */
779
+ export function cronInactivitySeconds(raw = process.env.HERMES_CRON_TIMEOUT) {
780
+ const text = String(raw ?? '').trim();
781
+ if (!text) return 600;
782
+ // Python's float(): digit underscores, nan and inf are numbers; hex is not
783
+ if (!/^[+-]?(((\d(_?\d)*)?\.?\d(_?\d)*|\d(_?\d)*\.)([eE][+-]?\d+)?|nan|inf(inity)?)$/i.test(text)) return 600;
784
+ const n = Number(text.replace(/_/g, '').replace(/^([+-]?)inf(inity)?$/i, '$1Infinity'));
785
+ // Hermes uses the limit only when it is > 0; anything else is no limit
786
+ return Number.isFinite(n) && n > 0 ? n : 0;
787
+ }
788
+
789
+ /** `gateway.webhook_port` out of config.yaml's Hermes residue; 0 (any free port) when unset. */
790
+ export function webhookPort(state) {
791
+ for (const profile of Object.values(state.profiles)) {
792
+ const port = profile.residue?.config?.gateway?.webhook_port;
793
+ if (Number.isInteger(port)) return port;
794
+ }
795
+ return 0;
796
+ }
797
+
798
+ /**
799
+ * Claude Code's `can_use_tool` reaches the runtime as a native control request, not a normalized
800
+ * `request`; unrelayed, the turn sits blocked until the runtime's own timeout denies it. Its id is the
801
+ * CLI's `request_id` (a string, which is what the runtime's respond door takes) and its answers are
802
+ * the two the CLI accepts.
803
+ */
804
+ function claudePermissionRequest(payload) {
805
+ const request = payload?.request;
806
+ if (request?.subtype !== 'can_use_tool' || typeof payload.request_id !== 'string') return null;
807
+ const input = request.input ?? {};
808
+ const detail = typeof input.command === 'string' ? input.command
809
+ : typeof input.file_path === 'string' ? input.file_path
810
+ : JSON.stringify(input).slice(0, 200);
811
+ return {
812
+ kind: 'request',
813
+ id: JSON.stringify(payload.request_id),
814
+ command: typeof input.command === 'string' ? input.command : null,
815
+ tool: typeof request.tool_name === 'string' ? request.tool_name : null,
816
+ subject: `${request.display_name ?? request.tool_name ?? 'a tool'}: ${detail}`,
817
+ options: [
818
+ { name: 'Allow', kind: 'allow_once', response: { behavior: 'allow', updatedInput: input } },
819
+ { name: 'Deny', kind: 'reject_once', response: { behavior: 'deny', message: 'denied in the channel' } },
820
+ ],
821
+ };
822
+ }
823
+
824
+ /** The shell command a worker asks to run (Codex's `params.command`, a string or an argv), for Hermes's rule. */
825
+ function requestCommand(payload) {
826
+ const raw = payload?.params?.command ?? payload?.command ?? payload?.params?.input?.command ?? null;
827
+ if (Array.isArray(raw)) return raw.map(String).join(' ');
828
+ return typeof raw === 'string' ? raw : null;
829
+ }
830
+
831
+ /** An MCP tool a worker asks to call, in Claude Code's `mcp__<server>__<tool>` form, and whether the ask is an MCP one. */
832
+ function requestTool(ev) {
833
+ const params = ev.payload?.params ?? ev.payload ?? {};
834
+ const server = params.serverName ?? params.server_name ?? params.server ?? null;
835
+ const tool = params.toolName ?? params.tool_name ?? params.tool ?? null;
836
+ const mcp = /mcp|elicitation/i.test(String(ev.requestKind ?? ev.kind ?? '')) || Boolean(server);
837
+ return { ...(server && tool ? { tool: `mcp__${server}__${tool}` } : {}), ...(mcp ? { mcp: true } : {}) };
838
+ }
839
+
840
+ /** SDK normalized runtime event → the reducer's worker-event vocabulary (§3). */
841
+ export function normalizeWorkerEvent(ev) {
842
+ // A helper the session started speaks inside its own tool call (Claude Code marks each of its
843
+ // frames with the parent_tool_use_id that started it): its words are the session working, not
844
+ // the session talking, and reach the conversation only through what the session itself says.
845
+ if ((ev.type === 'output_delta' || ev.type === 'message') && ev.payload?.parent_tool_use_id) return null;
846
+ switch (ev.type) {
847
+ // A streamed chunk names the message it belongs to where the worker says (Codex: `item/agentMessage/delta`'s
848
+ // `itemId`, measured), so the turn's text can start a new paragraph where a new message starts.
849
+ case 'output_delta': return ev.text ? { kind: 'text', delta: ev.text, item: ev.payload?.params?.itemId ?? null } : null;
850
+ // A whole assistant message (not a streamed chunk): the turn's text keeps it as its own paragraph.
851
+ case 'message': return ev.role === 'assistant' && ev.text ? { kind: 'text', delta: ev.text, block: true } : null;
852
+ case 'turn_completed': return { kind: 'final', text: null, outcome: ev.outcome ?? 'completed', message: ev.message ?? null };
853
+ case 'native':
854
+ if (ev.kind === 'supercode/acp_request_completed') return { kind: 'final', text: null };
855
+ if (ev.kind === 'control_request') return claudePermissionRequest(ev.payload);
856
+ return null;
857
+ case 'request': return { kind: 'request', id: JSON.stringify(ev.requestId), command: requestCommand(ev.payload), ...requestTool(ev), subject: ev.requestKind ?? ev.kind, options: ev.options ?? [] };
858
+ case 'error': return ev.terminal ? { kind: 'error', message: ev.message } : null;
859
+ case 'closed': return { kind: 'ended', reason: ev.message ?? ev.kind };
860
+ default: return null;
861
+ }
862
+ }
863
+
864
+ export { surfaceKeyString };