@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/reducer.mjs ADDED
@@ -0,0 +1,792 @@
1
+ // The loop as a pure reducer (docs/ORCHESTRATOR-IR.md §4).
2
+ //
3
+ // step(state, event) -> Effect[]
4
+ //
5
+ // Mutates `state` in place (it is the orchestrator's single value) and emits
6
+ // effects (§5). It never performs I/O and imports no node module: the test
7
+ // suite asserts that statically. Time comes from the event's `at`.
8
+
9
+ import { surfaceKeyString, renderSurfaceKey, resolveRoute, sessionReset, homeChannel } from './ir.mjs';
10
+ import { parseCommand, frame, REPLY } from './commands.mjs';
11
+ import { cronTick, gateResult, fireFinal, fireFailed, finishHostedFire, homeZone, isSynthetic, nextRunAt, scheduleError, unattendedRequest } from './cron.mjs';
12
+ import { handleWebhook } from './webhook.mjs';
13
+ import { modelError } from './activation.mjs';
14
+
15
+ export function keyOf(event) {
16
+ return surfaceKeyString(event.key);
17
+ }
18
+
19
+ function profileOf(state, name) {
20
+ const p = state.profiles[name];
21
+ if (!p) throw new Error(`unknown profile ${name}`);
22
+ return p;
23
+ }
24
+
25
+ export function ensureRuntime(state) {
26
+ const r = state.runtime;
27
+ r.leases ??= {}; r.queues ??= {}; r.adapters ??= {}; r.pending_requests ??= {}; r.turns ??= {}; r.starting ??= {}; r.activity ??= {}; r.gates ??= {};
28
+ return r;
29
+ }
30
+
31
+ function leaseId(profile, keyString) {
32
+ return `${profile} ${keyString}`;
33
+ }
34
+
35
+ // ---------------------------------------------------------------- expiry
36
+
37
+ function minutesBetween(a, b) {
38
+ return (Date.parse(b) - Date.parse(a)) / 60000;
39
+ }
40
+
41
+ /** A daily boundary at local `hour` lies in (a, b] (Hermes resets at a local hour). */
42
+ function crossedDailyBoundary(a, b, hour) {
43
+ const start = new Date(a);
44
+ const boundary = new Date(start.getFullYear(), start.getMonth(), start.getDate(), hour, 0, 0, 0);
45
+ if (boundary <= start) boundary.setDate(boundary.getDate() + 1);
46
+ return boundary.getTime() <= Date.parse(b);
47
+ }
48
+
49
+ /** Whether a session has reset, by Hermes's `session_reset` (`sessionReset`): its end reason, or null. */
50
+ export function expired(binding, reset, now) {
51
+ if (binding.ended_at) return binding.end_reason ?? 'reset';
52
+ const idle = reset.mode === 'idle' || reset.mode === 'both';
53
+ const daily = reset.mode === 'daily' || reset.mode === 'both';
54
+ if (idle && minutesBetween(binding.last_activity_at, now) >= reset.idle_minutes) return 'idle';
55
+ if (daily && crossedDailyBoundary(binding.last_activity_at, now, reset.at_hour)) return 'daily';
56
+ return null;
57
+ }
58
+
59
+ export function liveBinding(profile, keyString) {
60
+ const b = profile.bindings[keyString];
61
+ return b && !b.ended_at ? b : null;
62
+ }
63
+
64
+ // ---------------------------------------------------------------- access
65
+
66
+ /**
67
+ * Hermes's `_get_unauthorized_dm_behavior`, transcribed: a platform's own `unauthorized_dm_behavior`,
68
+ * email ignored, a global override, the platform's `dm_policy`, then `ignore` where an allowlist is
69
+ * configured and `pair` on an open gateway.
70
+ */
71
+ export function dmBehavior(profile, platform) {
72
+ const extra = profile.channels?.[platform]?.extra ?? {};
73
+ const own = extra['extra.unauthorized_dm_behavior'] ?? extra.unauthorized_dm_behavior;
74
+ if (own) return String(own);
75
+ if (platform === 'email') return 'ignore';
76
+ const global = profile.residue?.config?.unauthorized_dm_behavior;
77
+ if (global && global !== 'pair') return String(global);
78
+ const policy = String(extra['extra.dm_policy'] ?? extra.dm_policy ?? '').trim().toLowerCase();
79
+ if (policy === 'pairing') return 'pair';
80
+ if (policy === 'allowlist' || policy === 'disabled') return 'ignore';
81
+ return (profile.access.allowlist[platform] ?? []).length ? 'ignore' : 'pair';
82
+ }
83
+
84
+ /** §4.1 step 1: a platform with no `policy` entry is `allowlist`, never open. */
85
+ export function accessPolicy(profile, platform) {
86
+ return profile.access.policy[platform] ?? 'allowlist';
87
+ }
88
+
89
+
90
+ export function endBinding(state, profileName, keyString, reason, now, effects) {
91
+ const profile = state.profiles[profileName];
92
+ const b = profile.bindings[keyString];
93
+ if (!b || b.ended_at) return;
94
+ b.ended_at = now;
95
+ b.end_reason = reason;
96
+ if (b.residue) delete b.residue.runtime_id; // the lease is gone with the binding (§4.7)
97
+ // ended bindings keep their history under a started_at-qualified slot;
98
+ // two sessions ended within one clock instant get distinct slots
99
+ delete profile.bindings[keyString];
100
+ let slot = `${keyString}#${b.started_at}`;
101
+ for (let n = 2; profile.bindings[slot]; n++) slot = `${keyString}#${b.started_at}#${n}`;
102
+ profile.bindings[slot] = b;
103
+ const rt = ensureRuntime(state);
104
+ const lid = leaseId(profileName, keyString);
105
+ if (rt.leases[lid]) { effects.push({ type: 'runtime_close', profile: profileName, keyString, lease: rt.leases[lid] }); delete rt.leases[lid]; }
106
+ delete rt.turns[lid];
107
+ delete rt.queues[lid];
108
+ delete rt.activity[lid];
109
+ }
110
+
111
+ // ---------------------------------------------------------------- obligations
112
+
113
+ let obligationCounter = 0;
114
+ export function newObligation(profile, key, text, source, now) {
115
+ // The stamp is a second and the counter is per PROCESS, so a daemon that
116
+ // came back inside the same second as the last one would otherwise mint an
117
+ // id the store already holds (`delivery_obligations.obligation_id` is
118
+ // unique). The profile's own rows are the authority on what is taken.
119
+ const stamp = now.replace(/[^0-9]/g, '').slice(0, 14);
120
+ let id = `obl-${stamp}-${(++obligationCounter).toString(36)}`;
121
+ while (profile.obligations.some((o) => o.id === id)) id = `obl-${stamp}-${(++obligationCounter).toString(36)}`;
122
+ const o = {
123
+ id,
124
+ target: { platform: key.platform, kind: key.kind, chat_id: key.chat_id ?? '', thread_id: key.thread_id ?? null },
125
+ session_key: renderSurfaceKey(profile.name, key),
126
+ content: { text },
127
+ state: 'pending', attempts: 0, last_error: null,
128
+ created_at: String(Date.parse(now) / 1000), updated_at: String(Date.parse(now) / 1000), delivered_at: null,
129
+ source, residue: {},
130
+ };
131
+ profile.obligations.push(o);
132
+ return o;
133
+ }
134
+
135
+ export function reply(state, profile, key, text, now, effects, source = { kind: 'turn' }) {
136
+ const o = newObligation(profile, key, text, source, now);
137
+ effects.push({ type: 'deliver', profile: profile.name, obligation_id: o.id });
138
+ return o;
139
+ }
140
+
141
+ // ---------------------------------------------------------------- inbound
142
+
143
+ function handleInbound(state, ev, effects) {
144
+ const owner = profileOf(state, ev.profile);
145
+ const now = ev.at;
146
+
147
+ // 0. our own echo (§3 `is_me`): a platform that replays what we just posted
148
+ // is not a turn. Answering it would loop the conversation against itself.
149
+ if (ev.is_me) return;
150
+
151
+ // 1. access
152
+ const policy = accessPolicy(owner, ev.key.platform);
153
+ const allowed = policy === 'open' || (owner.access.allowlist[ev.key.platform] ?? []).includes(ev.sender);
154
+ if (!allowed) {
155
+ // Hermes's gateway: in a DM whose platform pairs, a stranger is offered a code from Hermes's own
156
+ // pairing store (its codes, expiry, rate limit and lockout; docs/plans/hermes-compat.md row 8);
157
+ // anywhere else, and wherever the owner restricted access, the message is dropped silently
158
+ if (ev.key.kind === 'dm' && dmBehavior(owner, ev.key.platform) === 'pair') {
159
+ effects.push({ type: 'pairing_issue', profile: owner.name, platform: ev.key.platform, user_id: ev.sender, user_name: ev.sender_name ?? '', key: ev.key });
160
+ } else {
161
+ effects.push({ type: 'log', level: 'info', message: `unauthorized ${ev.key.platform} sender ${ev.sender} ignored` });
162
+ }
163
+ return;
164
+ }
165
+
166
+ // 2. route
167
+ const routed = resolveRoute(owner.routes, ev.key, ev.guild_id) ?? owner.name;
168
+ const profile = profileOf(state, routed);
169
+ const keyString = keyOf(ev);
170
+
171
+ // 3. command
172
+ const cmd = parseCommand(ev.text);
173
+ if (cmd) { handleCommand(state, profile, ev, keyString, cmd, effects); return; }
174
+
175
+ // 4. get-or-create
176
+ let b = liveBinding(profile, keyString);
177
+ if (b) {
178
+ const reason = expired(b, sessionReset(profile), now);
179
+ if (reason) { endBinding(state, profile.name, keyString, reason, now, effects); b = null; }
180
+ }
181
+ if (!b) {
182
+ b = {
183
+ key: ev.key,
184
+ worker: { harness: profile.worker?.harness ?? 'unknown', session_id: null, locator: null },
185
+ started_at: now, last_activity_at: now, ended_at: null, end_reason: null, handoff: null, recurrence: null, residue: {},
186
+ };
187
+ profile.bindings[keyString] = b;
188
+ if (!profile.worker) { reply(state, profile, ev.key, `Profile ${profile.name} has no worker configured.`, now, effects); return; }
189
+ startWorker(state, profile, keyString, effects);
190
+ } else {
191
+ // A live binding whose lease is gone — a restart, a worker that died — must
192
+ // get its worker back on the next message rather than queue it forever
193
+ // (ORC-8 finding: "a resent message after a restart is never answered").
194
+ const rt0 = ensureRuntime(state);
195
+ const lid0 = leaseId(profile.name, keyString);
196
+ if (!rt0.leases[lid0] && !rt0.turns[lid0] && !rt0.starting[lid0] && profile.worker) {
197
+ if (b.worker.session_id) {
198
+ b.residue.resumed_from = b.worker.session_id;
199
+ // The conversation's session comes back, not a blank one; one that will not reopen starts fresh (loop).
200
+ b.residue.resume_runtime_id ??= b.residue.runtime_id ?? b.worker.session_id;
201
+ }
202
+ startWorker(state, profile, keyString, effects);
203
+ }
204
+ }
205
+ b.last_activity_at = now;
206
+
207
+ // 5. frame and send, or queue behind the running turn
208
+ const rt = ensureRuntime(state);
209
+ const lid = leaseId(profile.name, keyString);
210
+ const text = frame(profile.name, ev.key, ev.sender, ev.sender_name, now, ev.text);
211
+ const inFlight = Boolean(rt.turns[lid]);
212
+ const leased = Boolean(rt.leases[lid]);
213
+ if (inFlight && leased && rt.leases[lid].steer) {
214
+ // A worker that takes input mid-turn (the harness's `steer`) hears the line now, inside the turn it is running:
215
+ // a person steering a long piece of work is not held until the work is done. Its reply is that turn's.
216
+ effects.push({ type: 'steer_input', profile: profile.name, keyString, text });
217
+ } else if (inFlight || !leased) {
218
+ (rt.queues[lid] ??= []).push({ text, at: now });
219
+ } else {
220
+ rt.turns[lid] = { started_at: now, buffer: '' };
221
+ effects.push({ type: 'send_input', profile: profile.name, keyString, text });
222
+ }
223
+ effects.push({ type: 'save' });
224
+ }
225
+
226
+ /**
227
+ * Emit one `runtime_start` for a surface and remember that it is starting, so
228
+ * a burst of messages before the lease exists queues behind ONE start (the
229
+ * `started`/`error` worker events clear the mark).
230
+ */
231
+ function startWorker(state, profile, keyString, effects) {
232
+ const rt = ensureRuntime(state);
233
+ rt.starting[leaseId(profile.name, keyString)] = true;
234
+ // A binding whose runtime went away with the daemon reopens that same session where its harness can, so the
235
+ // conversation it held is not lost; the loop starts fresh when the harness cannot.
236
+ const resume = profile.bindings[keyString]?.residue?.resume_runtime_id ?? null;
237
+ effects.push({ type: 'runtime_start', profile: profile.name, keyString, worker: profile.worker, prompt: null, resume_runtime_id: resume });
238
+ }
239
+
240
+
241
+ // ---------------------------------------------------------------- commands
242
+
243
+ function handleCommand(state, profile, ev, keyString, cmd, effects) {
244
+ const now = ev.at;
245
+ switch (cmd.name) {
246
+ case 'new':
247
+ case 'reset': {
248
+ endBinding(state, profile.name, keyString, cmd.name, now, effects);
249
+ reply(state, profile, ev.key, cmd.name === 'new' ? REPLY.newSession : REPLY.reset, now, effects);
250
+ break;
251
+ }
252
+ case 'sethome': {
253
+ // Hermes's `/sethome`: the platform's `home_channel` in its `platforms:` block
254
+ const channel = (profile.channels[ev.key.platform] ??= { platform: ev.key.platform, enabled: true, credentials: {}, extra: {} });
255
+ channel.extra = { ...(channel.extra ?? {}), home_channel: { platform: ev.key.platform, chat_id: ev.key.chat_id ?? '', name: ev.sender_name ?? ev.key.chat_id ?? '', ...(ev.key.thread_id ? { thread_id: ev.key.thread_id } : {}) } };
256
+ effects.push({ type: 'save' });
257
+ reply(state, profile, ev.key, REPLY.homeSet, now, effects);
258
+ break;
259
+ }
260
+ case 'status': {
261
+ const b = liveBinding(profile, keyString);
262
+ const lines = [
263
+ `profile: ${profile.name}`,
264
+ `worker: ${profile.worker ? profile.worker.harness + (profile.worker.model ? ` (${profile.worker.model})` : '') : 'none'}`,
265
+ `session: ${b ? b.worker.session_id || 'starting' : 'none'}`,
266
+ `session reset: ${(({ mode, at_hour, idle_minutes }) => (mode === 'none' ? 'none' : [mode !== 'daily' ? `idle ${idle_minutes} min` : null, mode !== 'idle' ? `daily at ${at_hour}:00` : null].filter(Boolean).join(', ')))(sessionReset(profile))}`,
267
+ `queued: ${(ensureRuntime(state).queues[leaseId(profile.name, keyString)] ?? []).length}`,
268
+ ];
269
+ reply(state, profile, ev.key, lines.join('\n'), now, effects);
270
+ break;
271
+ }
272
+ case 'whoami': {
273
+ reply(state, profile, ev.key, `${ev.sender}${ev.sender_name ? ` (${ev.sender_name})` : ''} on ${renderSurfaceKey(profile.name, ev.key)}`, now, effects);
274
+ break;
275
+ }
276
+ case 'model': {
277
+ if (!profile.worker) { reply(state, profile, ev.key, `Profile ${profile.name} has no worker configured.`, now, effects); break; }
278
+ // refused here, not at the next start, where it would end every conversation
279
+ const unusable = modelError(profile.worker.harness, cmd.args);
280
+ if (unusable) { reply(state, profile, ev.key, `Model not changed: ${unusable}.`, now, effects); break; }
281
+ const b = liveBinding(profile, keyString);
282
+ if (b) b.residue.model_override = cmd.args;
283
+ profile.worker.model = cmd.args || null;
284
+ reply(state, profile, ev.key, REPLY.modelApplied(cmd.args || 'default'), now, effects);
285
+ effects.push({ type: 'save' });
286
+ break;
287
+ }
288
+ case 'sessions': {
289
+ const rows = Object.values(profile.bindings).filter((b) => surfaceKeyString(b.key) === keyString).sort((a, b) => (a.started_at < b.started_at ? 1 : -1)).slice(0, 10);
290
+ const text = rows.length ? rows.map((b, i) => `${i + 1}. ${b.worker.session_id || '(starting)'} · ${b.started_at}${b.ended_at ? ` · ended (${b.end_reason})` : ' · live'}`).join('\n') : 'No sessions on this surface.';
291
+ reply(state, profile, ev.key, text, now, effects);
292
+ break;
293
+ }
294
+ case 'handoff': {
295
+ const outcome = doHandoff(state, profile, keyString, cmd.args || null, null, now, effects);
296
+ reply(state, profile, ev.key, outcome.message, now, effects);
297
+ break;
298
+ }
299
+ case 'bg': {
300
+ const id = `bg-${now.replace(/[^0-9]/g, '').slice(0, 14)}`;
301
+ profile.jobs[id] = {
302
+ id, schedule: { kind: 'once', run_at: now }, prompt: cmd.args, workdir: null, model: null, skills: [], context_from: null,
303
+ deliver: { kind: 'origin' }, failure_deliver: { kind: 'origin' },
304
+ origin: { platform: ev.key.platform, chat_type: ev.key.kind, chat_id: ev.key.chat_id ?? null, thread_id: ev.key.thread_id ?? null },
305
+ attach_to_session: null, repeat: null, enabled: true, next_run_at: now, last_run_at: null, last_status: null, created_at: now, residue: {},
306
+ };
307
+ reply(state, profile, ev.key, REPLY.bgQueued(id), now, effects);
308
+ effects.push({ type: 'save' });
309
+ break;
310
+ }
311
+ default:
312
+ break;
313
+ }
314
+ }
315
+
316
+ /**
317
+ * `/handoff <platform>` and the `session_handoff` tool share this (§4.5): mark
318
+ * the live binding pending, announce it on the profile's home chat on that
319
+ * platform, or fail by name when there is none. Returns what to tell the caller.
320
+ */
321
+ export function doHandoff(state, profile, keyString, to, note, now, effects) {
322
+ const b = liveBinding(profile, keyString);
323
+ if (!b) return { ok: false, message: 'No live session to hand off.' };
324
+ if (!to) return { ok: false, message: 'Cannot hand off: no platform named.' };
325
+ b.handoff = { to, state: 'pending', error: null };
326
+ const target = homeChannel(profile, to);
327
+ if (target) {
328
+ reply(state, profile, target, `Continuing session ${b.worker.session_id} from ${b.key.platform}. Reply here to pick it up.${note ? `\n${note}` : ''}`, now, effects);
329
+ } else {
330
+ b.handoff.state = 'failed';
331
+ b.handoff.error = `no home chat on ${to}`;
332
+ }
333
+ effects.push({ type: 'save' });
334
+ return b.handoff.state === 'pending'
335
+ ? { ok: true, message: `Handed off to ${to}.` }
336
+ : { ok: false, message: `Cannot hand off: ${b.handoff.error}.` };
337
+ }
338
+
339
+ // ---------------------------------------------------------------- worker events
340
+
341
+ function handleWorker(state, ev, effects) {
342
+ const profile = profileOf(state, ev.profile);
343
+ const rt = ensureRuntime(state);
344
+ const lid = leaseId(profile.name, ev.keyString);
345
+ const binding = liveBinding(profile, ev.keyString);
346
+ const now = ev.at;
347
+ const w = ev.ev;
348
+ // A runtime that no longer serves this surface (the one a timed-out fire's
349
+ // binding closed, while the next fire starts on the same key) is not heard:
350
+ // its late `closed` would otherwise fail the new fire.
351
+ if (w.kind !== 'started' && ev.runtime_id) {
352
+ const current = rt.leases[lid]?.runtime_id ?? binding?.residue?.runtime_id ?? null;
353
+ if (current !== ev.runtime_id) return;
354
+ }
355
+ // any word from the worker is activity; a fire's inactivity limit counts from the last (§4.3)
356
+ rt.activity[lid] = now;
357
+ switch (w.kind) {
358
+ case 'activity':
359
+ break;
360
+ case 'started': {
361
+ delete rt.starting[lid];
362
+ // A runtime whose binding ended while it was starting (/new, /reset or
363
+ // idle expiry during boot) is closed: nothing will use it.
364
+ // A reattach whose binding was replaced while it attached (a cron fire,
365
+ // /new then a message) is not that binding's runtime either.
366
+ if (!binding || (w.reattach_of && binding.residue?.runtime_id !== w.reattach_of)) {
367
+ effects.push({ type: 'runtime_close', profile: profile.name, keyString: ev.keyString, runtime_id: w.runtime_id });
368
+ break;
369
+ }
370
+ // Starts run one at a time, so a `started` is always the newest runtime
371
+ // on this surface (the loop has already made it the one it sends to).
372
+ // It takes the lease; the runtime it displaces is closed by name.
373
+ if (rt.leases[lid] && rt.leases[lid].runtime_id !== w.runtime_id) {
374
+ effects.push({ type: 'runtime_close', profile: profile.name, keyString: ev.keyString, runtime_id: rt.leases[lid].runtime_id });
375
+ }
376
+ delete rt.starting[lid];
377
+ rt.leases[lid] = { runtime_id: w.runtime_id, connection: w.connection ?? null, since: now, steer: w.steer === true };
378
+ if (binding) {
379
+ binding.worker.session_id = w.session_id ?? w.runtime_id;
380
+ binding.worker.harness = w.harness ?? binding.worker.harness;
381
+ // §2.8 never persists the lease table, so the binding carries the one
382
+ // fact a restart needs from it: which runtime was serving this surface.
383
+ binding.residue.runtime_id = w.runtime_id;
384
+ delete binding.residue.interrupted_at;
385
+ }
386
+ // the first queued message goes out now that the lease exists
387
+ const q = rt.queues[lid] ?? [];
388
+ if (q.length && !rt.turns[lid]) {
389
+ const next = q.shift();
390
+ rt.turns[lid] = { started_at: now, buffer: '' };
391
+ effects.push(queuedInput(profile.name, ev.keyString, next));
392
+ }
393
+ effects.push({ type: 'save' });
394
+ break;
395
+ }
396
+ case 'steer_refused': {
397
+ // The harness would not take the line mid-turn: it waits for the turn's end like any other.
398
+ (rt.queues[lid] ??= []).push({ text: w.text, at: now });
399
+ break;
400
+ }
401
+ case 'text': {
402
+ const t = rt.turns[lid] ??= { started_at: now, buffer: '' };
403
+ // A conversation hears a working turn as it goes: a whole message is delivered as it arrives. Once the turn has
404
+ // spoken through the tool, a message waits for the next, since the last one is narration of that reply (§4.2
405
+ // `final`). A fire's session delivers once.
406
+ if (w.block && binding && !isSynthetic(binding)) {
407
+ if (t.buffer.trim()) reply(state, profile, binding.key, t.buffer.trim(), now, effects);
408
+ t.buffer = '';
409
+ if (!t.replied && (w.delta ?? '').trim()) {
410
+ reply(state, profile, binding.key, w.delta.trim(), now, effects);
411
+ effects.push({ type: 'save' });
412
+ break;
413
+ }
414
+ effects.push({ type: 'save' });
415
+ }
416
+ // Whole messages are paragraphs; streamed chunks continue the text they belong to, and a chunk of another
417
+ // message starts a paragraph (a fire's report read "…the raw results.1) Shell output…" without it).
418
+ const newMessage = !w.block && w.item && t.item && w.item !== t.item;
419
+ if ((w.block || newMessage) && t.buffer && !t.buffer.endsWith('\n')) t.buffer += '\n\n';
420
+ if (w.item) t.item = w.item;
421
+ t.buffer += w.delta ?? '';
422
+ // the turn's last message alone: what a fire delivers, as Hermes delivers a fire's final response
423
+ t.last = w.block || newMessage || t.last === undefined ? (w.delta ?? '') : t.last + (w.delta ?? '');
424
+ break;
425
+ }
426
+ case 'final': {
427
+ const t = rt.turns[lid];
428
+ const text = (w.text ?? t?.buffer ?? '').trim();
429
+ delete rt.turns[lid];
430
+ if (binding) binding.last_activity_at = now;
431
+ // a turn that ended in error (§4.2; the SDK's `turn_completed` with
432
+ // `outcome: error`) FAILS a fire and tells a chat surface once — it is
433
+ // never mistaken for an empty answer (ORC-8 finding)
434
+ const failed = w.outcome === 'error';
435
+ const message = w.message ?? 'the worker reported an error';
436
+ // a fire's (or a webhook turn's) session delivers per the job/subscription target
437
+ const report = (w.text ?? t?.last ?? t?.buffer ?? '').trim();
438
+ const synthetic = binding ? (failed ? fireFailed(state, profile, binding, message, now, effects) : fireFinal(state, profile, binding, report, now, effects)) : false;
439
+ if (!synthetic && binding) {
440
+ if (failed) reply(state, profile, binding.key, `Turn failed: ${message}`, now, effects);
441
+ else if (text && !t?.replied) reply(state, profile, binding.key, text, now, effects);
442
+ }
443
+ const q = rt.queues[lid] ?? [];
444
+ if (q.length && rt.leases[lid]) {
445
+ const next = q.shift();
446
+ rt.turns[lid] = { started_at: now, buffer: '' };
447
+ effects.push(queuedInput(profile.name, ev.keyString, next));
448
+ }
449
+ effects.push({ type: 'save' });
450
+ break;
451
+ }
452
+ case 'request': {
453
+ if (!binding) break;
454
+ // Hermes's approval rule allowed it (a safe command, any other tool): it runs without asking, as under Hermes
455
+ if (w.hermes?.verdict === 'allow') {
456
+ effects.push({ type: 'respond', profile: profile.name, keyString: ev.keyString, request_id: String(w.id), response: permissionResponse(w.options, true) });
457
+ break;
458
+ }
459
+ // a cron fire or a webhook turn has nobody to ask: answered now (§4.2)
460
+ if (unattendedRequest(state, profile, binding, ev.keyString, w, now, effects)) break;
461
+ rt.pending_requests[String(w.id)] = { profile: profile.name, keyString: ev.keyString, runtime_id: rt.leases[lid]?.runtime_id ?? null, asked_at: now, options: w.options ?? [] };
462
+ const options = (w.options ?? []).map((o, i) => `${i + 1}. ${o.name}`).join('\n');
463
+ reply(state, profile, binding.key, `Permission needed: ${w.subject ?? 'the worker wants to act'}\nReply with a number:\n${options}`, now, effects);
464
+ break;
465
+ }
466
+ case 'ended':
467
+ case 'error': {
468
+ delete rt.turns[lid];
469
+ delete rt.starting[lid];
470
+ const synthetic = binding ? fireFailed(state, profile, binding, w.message ?? w.reason ?? 'worker exited', now, effects) : false;
471
+ if (binding) {
472
+ endBinding(state, profile.name, ev.keyString, 'error', now, effects);
473
+ if (!synthetic) reply(state, profile, binding.key, `Session ended: ${w.message ?? w.reason ?? 'worker exited'}.`, now, effects);
474
+ } else {
475
+ delete rt.leases[lid];
476
+ }
477
+ effects.push({ type: 'save' });
478
+ break;
479
+ }
480
+ default:
481
+ break;
482
+ }
483
+ }
484
+
485
+ /**
486
+ * The worker's own option for a decision nobody typed (the timeout's default,
487
+ * an unattended surface's policy): the first whose kind or name reads as
488
+ * allow/approve, or as reject/deny; `cancelled` when the worker offered neither.
489
+ */
490
+ export function permissionResponse(options, allow, reason = null) {
491
+ const pattern = allow ? /allow|approve/i : /reject|deny/i;
492
+ const answer = (options ?? []).find((o) => pattern.test(o.kind ?? '') || pattern.test(o.name ?? '')) ?? null;
493
+ const response = answer ? (answer.response ?? answer) : { outcome: { outcome: 'cancelled' } };
494
+ // a denial that carries words (Claude Code's `message`) says why, so the worker can find another way
495
+ return !allow && reason && typeof response.message === 'string' ? { ...response, message: reason } : response;
496
+ }
497
+
498
+ /**
499
+ * A queued line as the worker receives it. A fire's line keeps what frames it
500
+ * (`context_from`, the job's `skills`) whichever flush sends it; the loop reads
501
+ * those from disk at send time.
502
+ */
503
+ function queuedInput(profile, keyString, next) {
504
+ const framed = next.job_id && (next.context_from || next.skills?.length);
505
+ return { type: 'send_input', profile, keyString, text: next.text, ...(framed ? { context_from: next.context_from ?? null, skills: next.skills ?? [], job_id: next.job_id } : {}) };
506
+ }
507
+
508
+ /** An admin's numeric reply resolves the oldest pending request on that surface. */
509
+ function tryResolveRequest(state, ev, effects) {
510
+ const rt = ensureRuntime(state);
511
+ const keyString = keyOf(ev);
512
+ const owner = profileOf(state, ev.profile);
513
+ const isAdmin = (owner.access.admins[ev.key.platform] ?? []).includes(ev.sender) || accessPolicy(owner, ev.key.platform) === 'open';
514
+ const m = /^\s*(\d+)\s*$/.exec(ev.text);
515
+ if (!m || !isAdmin) return false;
516
+ const entry = Object.entries(rt.pending_requests).find(([, p]) => p.keyString === keyString);
517
+ if (!entry) return false;
518
+ const [id, p] = entry;
519
+ const choice = p.options[Number(m[1]) - 1];
520
+ if (!choice) return false;
521
+ delete rt.pending_requests[id];
522
+ effects.push({ type: 'respond', profile: p.profile, keyString, request_id: id, response: choice.response ?? choice });
523
+ return true;
524
+ }
525
+
526
+ // ---------------------------------------------------------------- tick (expiry half, then §4.3's cron half)
527
+
528
+ function handleTick(state, ev, effects) {
529
+ for (const profile of Object.values(state.profiles)) {
530
+ for (const [ks, b] of Object.entries(profile.bindings)) {
531
+ if (b.ended_at || ks.includes('#')) continue;
532
+ const reason = expired(b, sessionReset(profile), ev.at);
533
+ if (reason) endBinding(state, profile.name, ks, reason, ev.at, effects);
534
+ }
535
+ }
536
+ // unanswered permission requests take the worker's default (deny unless it says allow) after its timeout
537
+ const rt = ensureRuntime(state);
538
+ for (const [id, p] of Object.entries(rt.pending_requests)) {
539
+ const worker = state.profiles[p.profile]?.worker;
540
+ const timeout = worker?.permission?.timeout_seconds ?? 300;
541
+ if ((Date.parse(ev.at) - Date.parse(p.asked_at)) / 1000 >= timeout) {
542
+ delete rt.pending_requests[id];
543
+ effects.push({ type: 'respond', profile: p.profile, keyString: p.keyString, request_id: id, response: permissionResponse(p.options, worker?.permission?.default === 'allow') });
544
+ }
545
+ }
546
+ cronTick(state, ev, effects); // §4.3: due jobs, obligation retry, breakers
547
+ }
548
+
549
+ // ---------------------------------------------------------------- restart / adapter / operator
550
+
551
+ // §4.7 on a clean stop: the daemon closes every runtime, so each binding that held one keeps it as the runtime to
552
+ // resume, as a crash's pass marks it, and the next line reopens the same session. A turn cut off mid-way is told so
553
+ // on its surface; an idle binding is not.
554
+ function handleStopping(state, ev, effects) {
555
+ const rt = ensureRuntime(state);
556
+ for (const profile of Object.values(state.profiles)) {
557
+ for (const [ks, b] of Object.entries(profile.bindings)) {
558
+ if (b.ended_at || ks.includes('#') || !b.residue?.runtime_id) continue;
559
+ b.residue.resume_runtime_id = b.residue.runtime_id;
560
+ delete b.residue.runtime_id;
561
+ if (rt.turns[leaseId(profile.name, ks)] && !b.recurrence && b.key.platform !== 'webhook' && b.key.platform !== 'cron') {
562
+ b.residue.interrupted_at = ev.at;
563
+ reply(state, profile, b.key, REPLY.interrupted, ev.at, effects);
564
+ }
565
+ }
566
+ }
567
+ effects.push({ type: 'save' });
568
+ }
569
+
570
+ function handleRestart(state, ev, effects) {
571
+ const rt = ensureRuntime(state);
572
+ rt.leases = {}; rt.turns = {}; rt.queues = {}; rt.pending_requests = {};
573
+ let live = 0;
574
+ const replays = [];
575
+ for (const profile of Object.values(state.profiles)) {
576
+ // §4.7: every binding that held a lease when the daemon went away. The
577
+ // lease table itself is never persisted (§2.8); `residue.runtime_id`,
578
+ // written when the lease started, is the one fact that survived. The loop
579
+ // attaches where the worker's door allows it and marks the rest
580
+ // interrupted (`binding_interrupted` below).
581
+ for (const [ks, b] of Object.entries(profile.bindings)) {
582
+ if (b.ended_at || ks.includes('#') || !b.residue?.runtime_id) continue;
583
+ live += 1;
584
+ // a message arriving while the attach is in flight queues behind it,
585
+ // rather than starting a fresh runtime that would replace the session
586
+ ensureRuntime(state).starting[leaseId(profile.name, ks)] = true;
587
+ effects.push({ type: 'runtime_reattach', profile: profile.name, keyString: ks, runtime_id: b.residue.runtime_id, harness: b.worker.harness });
588
+ }
589
+ for (const o of profile.obligations) if (o.state === 'pending') replays.push({ type: 'deliver', profile: profile.name, obligation_id: o.id });
590
+ }
591
+ // every reattach runs before any replayed delivery: the loop stops an
592
+ // event's effects at the first that throws (a save), and a reattach left
593
+ // unrun would leave its surface marked starting with nothing to clear it
594
+ effects.push(...replays);
595
+ effects.push({ type: 'log', level: 'info', message: `restart at ${ev.at}: leases cleared, ${live} binding(s) to re-attach, pending obligations replayed` });
596
+ }
597
+
598
+ function handleAdapter(state, ev) {
599
+ const rt = ensureRuntime(state);
600
+ const a = rt.adapters[ev.platform] ??= { state: 'down', breaker: { failures: 0 } };
601
+ a.state = ev.state;
602
+ a.configured = ev.configured !== false;
603
+ if (ev.state === 'up') a.breaker = { failures: 0 };
604
+ }
605
+
606
+ /** Hermes's pairing store issued a code for a stranger: tell them, as Hermes's gateway does. */
607
+ export function handlePairingIssued(state, ev, effects) {
608
+ const owner = profileOf(state, ev.profile);
609
+ const text = ev.code ? REPLY.notPaired(ev.code, ev.key.platform, owner.name) : REPLY.pairingBusy;
610
+ reply(state, owner, ev.key, text, ev.at, effects);
611
+ }
612
+
613
+ function handleOperator(state, ev, effects) {
614
+ const profile = profileOf(state, ev.profile);
615
+ switch (ev.verb) {
616
+ case 'pair_approve': {
617
+ // Hermes's pairing store holds the code (hashed); the loop asks it, and a grant comes back as `access_granted`
618
+ effects.push({ type: 'access_write', profile: profile.name, op: 'approve', code: ev.args.code, platforms: Object.keys(profile.channels ?? {}) });
619
+ break;
620
+ }
621
+ // what Hermes's store granted, mirrored into the loaded access
622
+ case 'access_granted': {
623
+ const { platform, user_id: userId } = ev.args;
624
+ profile.access.allowlist[platform] = [...new Set([...(profile.access.allowlist[platform] ?? []), userId])].sort();
625
+ break;
626
+ }
627
+ // §2.4's access verbs: the operator CLI's `pair approve`, `access
628
+ // allow|revoke` and `access admin add|remove` are these four events.
629
+ case 'access_allow':
630
+ case 'access_revoke':
631
+ case 'admin_add':
632
+ case 'admin_remove': {
633
+ const { platform, user_id: userId } = ev.args;
634
+ if (typeof platform !== 'string' || !platform || typeof userId !== 'string' || !userId) {
635
+ effects.push({ type: 'log', level: 'warn', message: `${ev.verb}: platform and user_id are required` });
636
+ break;
637
+ }
638
+ const list = ev.verb.startsWith('admin') ? 'admins' : 'allowlist';
639
+ const users = new Set(profile.access[list][platform] ?? []);
640
+ if (ev.verb === 'access_allow' || ev.verb === 'admin_add') users.add(userId); else users.delete(userId);
641
+ if (users.size) profile.access[list][platform] = [...users].sort();
642
+ else delete profile.access[list][platform];
643
+ // written where Hermes keeps it: `.env` allowlists, `config.yaml` admins (row 8)
644
+ effects.push({ type: 'access_write', profile: profile.name, op: ev.verb.replace('access_', ''), platform, user_id: userId });
645
+ break;
646
+ }
647
+ // §4.7's other half: a live binding the loop could not re-attach to. The
648
+ // surface is told once, in one line, on the loop's next pass.
649
+ case 'binding_interrupted': {
650
+ const rt = ensureRuntime(state);
651
+ const lid = leaseId(profile.name, ev.args.keyString);
652
+ const b = liveBinding(profile, ev.args.keyString);
653
+ // only the binding the attach was for: a fire or /new may have replaced it meanwhile
654
+ if (!b || (ev.args.runtime_id && b.residue?.runtime_id !== ev.args.runtime_id)) {
655
+ if (!b) delete rt.starting[lid];
656
+ effects.push({ type: 'log', level: 'info', message: `binding_interrupted: ${ev.args.keyString} no longer holds ${ev.args.runtime_id ?? 'that runtime'}` });
657
+ break;
658
+ }
659
+ b.residue.resume_runtime_id = b.residue.runtime_id;
660
+ delete b.residue.runtime_id;
661
+ b.residue.interrupted_at = ev.at;
662
+ // cron fires and webhook turns have no reader on their synthetic surface
663
+ if (!b.recurrence && b.key.platform !== 'webhook' && b.key.platform !== 'cron') {
664
+ reply(state, profile, b.key, REPLY.interrupted, ev.at, effects);
665
+ }
666
+ // the attach is over; what queued behind it gets a worker now (resumed where the harness can)
667
+ delete rt.starting[lid];
668
+ if (rt.queues[lid]?.length && profile.worker) startWorker(state, profile, ev.args.keyString, effects);
669
+ effects.push({ type: 'save' });
670
+ break;
671
+ }
672
+ case 'sessions_new':
673
+ case 'sessions_reset': {
674
+ const keyString = ev.args.keyString;
675
+ endBinding(state, profile.name, keyString, ev.verb === 'sessions_new' ? 'new' : 'reset', ev.at, effects);
676
+ effects.push({ type: 'save' });
677
+ break;
678
+ }
679
+ // Job verbs (§4.6): the same events the MCP tools, the daemon socket
680
+ // (ORC-13) and the operator CLI use. The caller builds a complete Job
681
+ // record (§2.6); the reducer only owns the state change.
682
+ case 'jobs_create': {
683
+ const job = ev.args.job;
684
+ if (!job || typeof job.id !== 'string' || !job.id) { effects.push({ type: 'log', level: 'warn', message: 'jobs_create: a job with an id is required' }); break; }
685
+ if (profile.jobs[job.id]) { effects.push({ type: 'log', level: 'warn', message: `jobs_create: job ${job.id} already exists` }); break; }
686
+ // a schedule nothing can evaluate would sit due on every tick (§4.3)
687
+ const broken = scheduleError(job.schedule) ?? modelError(profile.worker?.harness, job.model);
688
+ if (broken) { effects.push({ type: 'log', level: 'warn', message: `jobs_create: job ${job.id}: ${broken}` }); break; }
689
+ profile.jobs[job.id] = job;
690
+ effects.push({ type: 'save' });
691
+ break;
692
+ }
693
+ case 'jobs_update': {
694
+ const job = profile.jobs[ev.args.id];
695
+ if (!job) { effects.push({ type: 'log', level: 'warn', message: `jobs_update: no job ${ev.args.id}` }); break; }
696
+ const broken = (ev.args.patch?.schedule ? scheduleError(ev.args.patch.schedule) : null) ?? modelError(profile.worker?.harness, ev.args.patch?.model);
697
+ if (broken) { effects.push({ type: 'log', level: 'warn', message: `jobs_update: job ${ev.args.id}: ${broken}` }); break; }
698
+ Object.assign(job, ev.args.patch ?? {});
699
+ effects.push({ type: 'save' });
700
+ break;
701
+ }
702
+ case 'jobs_pause':
703
+ case 'jobs_resume': {
704
+ const job = profile.jobs[ev.args.id];
705
+ if (!job) { effects.push({ type: 'log', level: 'warn', message: `${ev.verb}: no job ${ev.args.id}` }); break; }
706
+ const broken = ev.verb === 'jobs_resume' ? scheduleError(job.schedule) : null;
707
+ if (broken) { effects.push({ type: 'log', level: 'warn', message: `jobs_resume: job ${ev.args.id} cannot run: ${broken}` }); break; }
708
+ job.enabled = ev.verb === 'jobs_resume';
709
+ if (job.enabled && !job.next_run_at) job.next_run_at = nextRunAt(job.schedule, ev.at, homeZone(profile));
710
+ effects.push({ type: 'save' });
711
+ break;
712
+ }
713
+ case 'jobs_run': {
714
+ const job = profile.jobs[ev.args.id];
715
+ if (!job) { effects.push({ type: 'log', level: 'warn', message: `jobs_run: no job ${ev.args.id}` }); break; }
716
+ job.next_run_at = ev.at; // the next tick claims it (§4.3)
717
+ effects.push({ type: 'save' });
718
+ break;
719
+ }
720
+ case 'jobs_delete': {
721
+ if (!profile.jobs[ev.args.id]) { effects.push({ type: 'log', level: 'warn', message: `jobs_delete: no job ${ev.args.id}` }); break; }
722
+ delete profile.jobs[ev.args.id];
723
+ effects.push({ type: 'save' });
724
+ break;
725
+ }
726
+ case 'send_message': {
727
+ const key = ev.args.key;
728
+ if (!key || typeof key.platform !== 'string' || typeof ev.args.text !== 'string') { effects.push({ type: 'log', level: 'warn', message: 'send_message: key and text are required' }); break; }
729
+ reply(state, profile, key, ev.args.text, ev.at, effects);
730
+ // A turn that has spoken on its own surface through the tool has said what it meant there: what is left of
731
+ // its text at the end is narration about that, and is not delivered a second time.
732
+ const own = ensureRuntime(state).turns[leaseId(profile.name, surfaceKeyString(key))];
733
+ if (own) own.replied = true;
734
+ effects.push({ type: 'save' });
735
+ break;
736
+ }
737
+ case 'session_handoff': {
738
+ const outcome = doHandoff(state, profile, ev.args.keyString, ev.args.to ?? null, ev.args.note ?? null, ev.at, effects);
739
+ if (!outcome.ok) effects.push({ type: 'log', level: 'warn', message: `session_handoff: ${outcome.message}` });
740
+ break;
741
+ }
742
+ // Profile verbs (§4.6): a profile IS a folder, so `create` adds the record
743
+ // and lets `save` write the folder, and `delete` removes the record and
744
+ // asks the loop to move the folder aside — the reducer performs no I/O and
745
+ // nothing here ever unlinks anything.
746
+ case 'profiles_create': {
747
+ const made = ev.args.profile;
748
+ if (!made || typeof made.name !== 'string' || !made.name) { effects.push({ type: 'log', level: 'warn', message: 'profiles_create: a profile record with a name is required' }); break; }
749
+ if (state.profiles[made.name]) { effects.push({ type: 'log', level: 'warn', message: `profiles_create: profile ${made.name} already exists` }); break; }
750
+ state.profiles[made.name] = made;
751
+ effects.push({ type: 'save' });
752
+ break;
753
+ }
754
+ case 'profiles_delete': {
755
+ const name = ev.args.name;
756
+ if (name === 'default') { effects.push({ type: 'log', level: 'warn', message: 'profiles_delete: the root folder IS the default profile; it is never removed' }); break; }
757
+ const doomed = state.profiles[name];
758
+ if (!doomed) { effects.push({ type: 'log', level: 'warn', message: `profiles_delete: no profile ${name}` }); break; }
759
+ delete state.profiles[name];
760
+ effects.push({ type: 'remove_profile_dir', profile: name, dir: doomed.dir });
761
+ effects.push({ type: 'save' });
762
+ break;
763
+ }
764
+ default:
765
+ effects.push({ type: 'log', level: 'warn', message: `unknown operator verb ${ev.verb}` });
766
+ }
767
+ }
768
+
769
+ // ---------------------------------------------------------------- step
770
+
771
+ export function step(state, event) {
772
+ const effects = [];
773
+ switch (event.type) {
774
+ case 'inbound':
775
+ if (!tryResolveRequest(state, event, effects)) handleInbound(state, event, effects);
776
+ break;
777
+ case 'worker': handleWorker(state, event, effects); break;
778
+ case 'tick': handleTick(state, event, effects); break;
779
+ case 'hosted_result': finishHostedFire(state, event, effects); break;
780
+ case 'gate_result': gateResult(state, event, effects); break;
781
+ case 'webhook': handleWebhook(state, event, effects); break;
782
+ case 'restart': handleRestart(state, event, effects); break;
783
+ case 'stopping': handleStopping(state, event, effects); break;
784
+ case 'adapter': handleAdapter(state, event); break;
785
+ case 'operator': handleOperator(state, event, effects); break;
786
+ case 'pairing_issued': handlePairingIssued(state, event, effects); break;
787
+ default: throw new Error(`unknown event type ${event.type}`);
788
+ }
789
+ return effects;
790
+ }
791
+
792
+ export { leaseId };