@volter/supercode-orchestrator 0.4.159 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -5
- package/adapters/chat-sdk.mjs +4 -1
- package/adapters/rh2.mjs +18 -20
- package/agent-home.mjs +35 -0
- package/agent-profiles.mjs +53 -0
- package/agents.mjs +443 -0
- package/bin/agent-door.mjs +53 -0
- package/bin/runtime-door.mjs +23 -0
- package/bin/runtime.mjs +59 -4
- package/board/agent-declarations.mjs +29 -0
- package/board/ci.mjs +6 -1
- package/board/cli.mjs +158 -31
- package/board/dispatch.mjs +304 -124
- package/board/engine.mjs +30 -10
- package/board/event-fleet.mjs +59 -0
- package/board/event-relay.mjs +45 -0
- package/board/event-stream.mjs +104 -0
- package/board/incidents.mjs +57 -0
- package/board/published.mjs +86 -0
- package/board/runtime-door.mjs +12 -0
- package/board/store.mjs +39 -6
- package/board/tools.mjs +1 -0
- package/board/wake.mjs +17 -56
- package/board/workflow.mjs +28 -7
- package/board/workspace.mjs +18 -15
- package/commands.mjs +2 -2
- package/conversations.mjs +108 -0
- package/cron.mjs +30 -4
- package/gates.mjs +3 -2
- package/hermes-cli.mjs +19 -2
- package/index.d.ts +43 -2
- package/index.mjs +4 -0
- package/installed-apps.mjs +52 -0
- package/ir.mjs +3 -3
- package/kanban-worker.mjs +16 -6
- package/loop.mjs +211 -21
- package/mcp/bridge.mjs +13 -2
- package/mcp/tools.mjs +17 -0
- package/mcp/verbs.mjs +29 -7
- package/ontology.d.ts +246 -2
- package/operator.mjs +6 -1
- package/package.json +2 -2
- package/reducer.mjs +133 -28
- package/webhook.mjs +8 -4
package/reducer.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import { parseCommand, frame, REPLY } from './commands.mjs';
|
|
|
11
11
|
import { cronTick, gateResult, fireFinal, fireFailed, finishHostedFire, homeZone, isSynthetic, nextRunAt, scheduleError, unattendedRequest } from './cron.mjs';
|
|
12
12
|
import { handleWebhook } from './webhook.mjs';
|
|
13
13
|
import { modelError } from './activation.mjs';
|
|
14
|
+
import { addParticipant, agentSlot, budgetRefusal, conversationId, ensureConversation, isAgent, profileOfAgent, receivingAgents, recordUsage } from './conversations.mjs';
|
|
14
15
|
|
|
15
16
|
export function keyOf(event) {
|
|
16
17
|
return surfaceKeyString(event.key);
|
|
@@ -150,7 +151,8 @@ function handleInbound(state, ev, effects) {
|
|
|
150
151
|
|
|
151
152
|
// 1. access
|
|
152
153
|
const policy = accessPolicy(owner, ev.key.platform);
|
|
153
|
-
const
|
|
154
|
+
const principal = ev.identity?.classification==='proven'?ev.identity.principal?.id:null;
|
|
155
|
+
const allowed = policy === 'open' || (principal && (owner.access.allowlist[ev.key.platform] ?? []).includes(`principal:${principal}`)) || (owner.access.allowlist[ev.key.platform] ?? []).includes(ev.sender);
|
|
154
156
|
if (!allowed) {
|
|
155
157
|
// Hermes's gateway: in a DM whose platform pairs, a stranger is offered a code from Hermes's own
|
|
156
158
|
// pairing store (its codes, expiry, rate limit and lockout; docs/plans/hermes-compat.md row 8);
|
|
@@ -163,16 +165,31 @@ function handleInbound(state, ev, effects) {
|
|
|
163
165
|
return;
|
|
164
166
|
}
|
|
165
167
|
|
|
166
|
-
// 2. route
|
|
167
|
-
const
|
|
168
|
-
const
|
|
169
|
-
const keyString = keyOf(ev);
|
|
168
|
+
// 2. route: to an agent (G1); a named profile's own agent bears its name
|
|
169
|
+
const routedAgent = resolveRoute(owner.routes, ev.key, ev.guild_id) ?? owner.name;
|
|
170
|
+
const routedProfile = profileOf(state, profileOfAgent(state, routedAgent) ?? owner.name);
|
|
170
171
|
|
|
171
|
-
// 3. command
|
|
172
|
+
// 3. command: the routed agent's
|
|
172
173
|
const cmd = parseCommand(ev.text);
|
|
173
|
-
if (cmd) { handleCommand(state,
|
|
174
|
+
if (cmd) { handleCommand(state, routedProfile, ev, agentSlot(routedProfile.name, routedAgent, keyOf(ev)), cmd, effects); return; }
|
|
175
|
+
|
|
176
|
+
// 4. the conversation (G5, created on first sight); its speaker policy picks who receives the line (G1)
|
|
177
|
+
const conversation = ensureConversation(state, ev.key, routedAgent, now);
|
|
178
|
+
addParticipant(conversation, `${ev.key.platform}:${ev.sender}`, 'to', now);
|
|
179
|
+
const agents = receivingAgents(state, conversation, ev, routedAgent);
|
|
180
|
+
if (agents.length) conversation.last_speaker = agents[agents.length - 1];
|
|
181
|
+
for (const agent of agents) inboundToAgent(state, agent, conversation, ev, effects);
|
|
182
|
+
effects.push({ type: 'save' });
|
|
183
|
+
}
|
|
174
184
|
|
|
175
|
-
|
|
185
|
+
/** One receiving agent's binding for the conversation: get-or-create, then frame and send (§4.1 steps 4–5). */
|
|
186
|
+
function inboundToAgent(state, agent, conversation, ev, effects) {
|
|
187
|
+
const now = ev.at;
|
|
188
|
+
const profile = profileOf(state, profileOfAgent(state, agent) ?? ev.profile);
|
|
189
|
+
const keyString = agentSlot(profile.name, agent, keyOf(ev));
|
|
190
|
+
addParticipant(conversation, agent, 'to', now);
|
|
191
|
+
|
|
192
|
+
// get-or-create the (conversation, agent) binding
|
|
176
193
|
let b = liveBinding(profile, keyString);
|
|
177
194
|
if (b) {
|
|
178
195
|
const reason = expired(b, sessionReset(profile), now);
|
|
@@ -180,13 +197,13 @@ function handleInbound(state, ev, effects) {
|
|
|
180
197
|
}
|
|
181
198
|
if (!b) {
|
|
182
199
|
b = {
|
|
183
|
-
key: ev.key,
|
|
200
|
+
key: ev.key, conversation: conversation.id, agent,
|
|
184
201
|
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,
|
|
202
|
+
started_at: now, last_activity_at: now, ended_at: null, end_reason: null, recurrence: null, residue: {},
|
|
186
203
|
};
|
|
187
204
|
profile.bindings[keyString] = b;
|
|
188
205
|
if (!profile.worker) { reply(state, profile, ev.key, `Profile ${profile.name} has no worker configured.`, now, effects); return; }
|
|
189
|
-
startWorker(state, profile, keyString, effects);
|
|
206
|
+
if (!startWorker(state, profile, keyString, effects, now)) return;
|
|
190
207
|
} else {
|
|
191
208
|
// A live binding whose lease is gone — a restart, a worker that died — must
|
|
192
209
|
// get its worker back on the next message rather than queue it forever
|
|
@@ -199,15 +216,17 @@ function handleInbound(state, ev, effects) {
|
|
|
199
216
|
// The conversation's session comes back, not a blank one; one that will not reopen starts fresh (loop).
|
|
200
217
|
b.residue.resume_runtime_id ??= b.residue.runtime_id ?? b.worker.session_id;
|
|
201
218
|
}
|
|
202
|
-
startWorker(state, profile, keyString, effects);
|
|
219
|
+
if (!startWorker(state, profile, keyString, effects, now)) return;
|
|
203
220
|
}
|
|
204
221
|
}
|
|
205
222
|
b.last_activity_at = now;
|
|
223
|
+
b.conversation ??= conversation.id;
|
|
224
|
+
b.agent ??= agent;
|
|
206
225
|
|
|
207
226
|
// 5. frame and send, or queue behind the running turn
|
|
208
227
|
const rt = ensureRuntime(state);
|
|
209
228
|
const lid = leaseId(profile.name, keyString);
|
|
210
|
-
const text = frame(profile.name, ev.key, ev.sender, ev.sender_name, now, ev.text);
|
|
229
|
+
const text = frame(profile.name, ev.key, ev.sender, ev.sender_name, now, ev.text, ev.identity);
|
|
211
230
|
const inFlight = Boolean(rt.turns[lid]);
|
|
212
231
|
const leased = Boolean(rt.leases[lid]);
|
|
213
232
|
if (inFlight && leased && rt.leases[lid].steer) {
|
|
@@ -220,7 +239,6 @@ function handleInbound(state, ev, effects) {
|
|
|
220
239
|
rt.turns[lid] = { started_at: now, buffer: '' };
|
|
221
240
|
effects.push({ type: 'send_input', profile: profile.name, keyString, text });
|
|
222
241
|
}
|
|
223
|
-
effects.push({ type: 'save' });
|
|
224
242
|
}
|
|
225
243
|
|
|
226
244
|
/**
|
|
@@ -228,13 +246,24 @@ function handleInbound(state, ev, effects) {
|
|
|
228
246
|
* a burst of messages before the lease exists queues behind ONE start (the
|
|
229
247
|
* `started`/`error` worker events clear the mark).
|
|
230
248
|
*/
|
|
231
|
-
export function startWorker(state, profile, keyString, effects) {
|
|
249
|
+
export function startWorker(state, profile, keyString, effects, now = null) {
|
|
232
250
|
const rt = ensureRuntime(state);
|
|
251
|
+
const binding = profile.bindings[keyString];
|
|
252
|
+
const agent = binding?.agent ?? profile.name;
|
|
253
|
+
// G3: a start or resume over the agent's budget is refused with the reason; nothing is paused or ended
|
|
254
|
+
const session = binding?.worker?.session_id ? `${binding.worker.harness}:${binding.worker.session_id}` : null;
|
|
255
|
+
const refused = now ? budgetRefusal(state, profile, agent, now, session) : null;
|
|
256
|
+
if (refused) {
|
|
257
|
+
if (binding) reply(state, profile, binding.key, `Not started: ${refused}.`, now, effects);
|
|
258
|
+
effects.push({ type: 'budget_refused', profile: profile.name, agent, reason: refused });
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
233
261
|
rt.starting[leaseId(profile.name, keyString)] = true;
|
|
234
262
|
// A binding whose runtime went away with the daemon reopens that same session where its harness can, so the
|
|
235
263
|
// conversation it held is not lost; the loop starts fresh when the harness cannot.
|
|
236
|
-
const resume =
|
|
237
|
-
effects.push({ type: 'runtime_start', profile: profile.name, keyString, worker: profile.worker, prompt: null, resume_runtime_id: resume });
|
|
264
|
+
const resume = binding?.residue?.resume_runtime_id ?? null;
|
|
265
|
+
effects.push({ type: 'runtime_start', profile: profile.name, keyString, worker: profile.worker, prompt: null, resume_runtime_id: resume, agent });
|
|
266
|
+
return true;
|
|
238
267
|
}
|
|
239
268
|
|
|
240
269
|
|
|
@@ -270,7 +299,7 @@ function handleCommand(state, profile, ev, keyString, cmd, effects) {
|
|
|
270
299
|
break;
|
|
271
300
|
}
|
|
272
301
|
case 'whoami': {
|
|
273
|
-
reply(state, profile, ev.key, `${ev.sender}${ev.sender_name ? ` (${ev.sender_name})` : ''} on ${renderSurfaceKey(profile.name, ev.key)}`, now, effects);
|
|
302
|
+
reply(state, profile, ev.key, `${ev.identity?.label??'unresolved'}${ev.identity?.principal?` (${ev.identity.principal.id}; proven)`:''}; ${ev.sender}${ev.sender_name ? ` (${ev.sender_name})` : ''} on ${renderSurfaceKey(profile.name, ev.key)}`, now, effects);
|
|
274
303
|
break;
|
|
275
304
|
}
|
|
276
305
|
case 'model': {
|
|
@@ -322,18 +351,80 @@ export function doHandoff(state, profile, keyString, to, note, now, effects) {
|
|
|
322
351
|
const b = liveBinding(profile, keyString);
|
|
323
352
|
if (!b) return { ok: false, message: 'No live session to hand off.' };
|
|
324
353
|
if (!to) return { ok: false, message: 'Cannot hand off: no platform named.' };
|
|
325
|
-
|
|
354
|
+
// G2: a Handoff record (session → target conversation), never a field on the binding
|
|
326
355
|
const target = homeChannel(profile, to);
|
|
356
|
+
const agent = b.agent ?? profile.name;
|
|
357
|
+
const record = { session: `${b.worker.harness}:${b.worker.session_id ?? ''}`, to: target ? conversationId(target) : `platform:${to}`, by: agent, state: target ? 'pending' : 'failed', at: now };
|
|
327
358
|
if (target) {
|
|
359
|
+
addParticipant(ensureConversation(state, target, agent, now), agent, 'to', now);
|
|
328
360
|
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
361
|
} else {
|
|
330
|
-
|
|
331
|
-
b.handoff.error = `no home chat on ${to}`;
|
|
362
|
+
record.error = `no home chat on ${to}`;
|
|
332
363
|
}
|
|
364
|
+
(state.handoffs ??= []).push(record);
|
|
333
365
|
effects.push({ type: 'save' });
|
|
334
|
-
return
|
|
366
|
+
return record.state === 'pending'
|
|
335
367
|
? { ok: true, message: `Handed off to ${to}.` }
|
|
336
|
-
: { ok: false, message: `Cannot hand off: ${
|
|
368
|
+
: { ok: false, message: `Cannot hand off: ${record.error}.` };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** A transfer's line reaches its receiver: queued behind a running turn, or sent now. */
|
|
372
|
+
function handOver(rt, profileName, keyString, line, now, effects) {
|
|
373
|
+
const lid = leaseId(profileName, keyString);
|
|
374
|
+
if (rt.turns[lid] || !rt.leases[lid]) (rt.queues[lid] ??= []).push(line);
|
|
375
|
+
else { rt.turns[lid] = { started_at: now, buffer: '' }; effects.push(queuedInput(profileName, keyString, line)); }
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** The handovers a giver's turn held back go out when that turn ends. */
|
|
379
|
+
function releaseHandovers(rt, lid, now, effects) {
|
|
380
|
+
for (const held of rt.handovers?.[lid] ?? []) handOver(rt, held.profile, held.keyString, held.line, now, effects);
|
|
381
|
+
if (rt.handovers) delete rt.handovers[lid];
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* G2: the conversation passes from one agent to another. The giver's binding ends with `transfer` (it stays a
|
|
386
|
+
* participant); the receiver gets or continues its binding, and its next turn is given the conversation's history
|
|
387
|
+
* as the transfer says. Refused unless the receiver is an agent of this home.
|
|
388
|
+
*/
|
|
389
|
+
export function doTransfer(state, profile, keyString, args, now, effects) {
|
|
390
|
+
const from = liveBinding(profile, keyString);
|
|
391
|
+
if (!from) return { ok: false, message: 'No live conversation to transfer.' };
|
|
392
|
+
const giver = from.agent ?? profile.name;
|
|
393
|
+
const conversation = state.conversations?.[from.conversation] ?? ensureConversation(state, from.key, giver, now);
|
|
394
|
+
const history = ['full', 'filtered', 'none'].includes(args.history) ? args.history : 'full';
|
|
395
|
+
const record = { conversation: conversation.id, from: giver, to: String(args.to ?? ''), by: args.by ?? giver, reason: String(args.reason ?? ''), history,
|
|
396
|
+
...(history === 'filtered' ? { filter: args.filter ?? { last: 20 } } : {}), state: 'pending', at: now };
|
|
397
|
+
(state.transfers ??= []).push(record);
|
|
398
|
+
const refuse = (error) => { record.state = 'refused'; record.error = error; effects.push({ type: 'save' }); return { ok: false, message: `Transfer refused: ${error}.` }; };
|
|
399
|
+
if (!isAgent(state, record.to)) return refuse(`${record.to || 'nobody'} is not an agent of this home (its agents: ${[...new Set([...Object.keys(state.profiles ?? {}), ...Object.keys(state.agents ?? {})])].join(', ')})`);
|
|
400
|
+
if (record.to === giver) return refuse('the conversation is already this agent\'s');
|
|
401
|
+
const toProfile = profileOf(state, profileOfAgent(state, record.to));
|
|
402
|
+
if (!toProfile.worker) return refuse(`${record.to}'s profile has no worker`);
|
|
403
|
+
endBinding(state, profile.name, keyString, 'transfer', now, effects);
|
|
404
|
+
addParticipant(conversation, record.to, 'to', now);
|
|
405
|
+
conversation.holder = record.to;
|
|
406
|
+
const slot = agentSlot(toProfile.name, record.to, surfaceKeyString(from.key));
|
|
407
|
+
let b = liveBinding(toProfile, slot);
|
|
408
|
+
if (!b) {
|
|
409
|
+
b = { key: from.key, conversation: conversation.id, agent: record.to, worker: { harness: toProfile.worker.harness, session_id: null, locator: null },
|
|
410
|
+
started_at: now, last_activity_at: now, ended_at: null, end_reason: null, recurrence: null, residue: {} };
|
|
411
|
+
toProfile.bindings[slot] = b;
|
|
412
|
+
}
|
|
413
|
+
const rt = ensureRuntime(state);
|
|
414
|
+
const lid = leaseId(toProfile.name, slot);
|
|
415
|
+
if (!rt.leases[lid] && !rt.starting[lid] && !startWorker(state, toProfile, slot, effects, now)) return refuse(`${record.to} may not start`);
|
|
416
|
+
const text = `${giver} transferred this conversation to you${record.reason ? `: ${record.reason}` : '.'}`;
|
|
417
|
+
const handover = history === 'none' || !from.worker.session_id ? null
|
|
418
|
+
: { harness: from.worker.harness, session_id: from.worker.session_id, mode: history, filter: record.filter ?? null, from: giver };
|
|
419
|
+
const line = { text, at: now, ...(handover ? { history: handover } : {}) };
|
|
420
|
+
// The giver is mid-turn (it called the tool): its reply of that turn is part of the conversation, so the receiver
|
|
421
|
+
// is handed it when that turn ends, its history read then.
|
|
422
|
+
const giverLid = leaseId(profile.name, keyString);
|
|
423
|
+
if (rt.turns[giverLid]) (rt.handovers ??= {})[giverLid] = [...(rt.handovers?.[giverLid] ?? []), { profile: toProfile.name, keyString: slot, line }];
|
|
424
|
+
else handOver(rt, toProfile.name, slot, line, now, effects);
|
|
425
|
+
record.state = 'done';
|
|
426
|
+
effects.push({ type: 'save' });
|
|
427
|
+
return { ok: true, message: `Transferred to ${record.to}.` };
|
|
337
428
|
}
|
|
338
429
|
|
|
339
430
|
// ---------------------------------------------------------------- worker events
|
|
@@ -355,6 +446,9 @@ function handleWorker(state, ev, effects) {
|
|
|
355
446
|
// any word from the worker is activity; a fire's inactivity limit counts from the last (§4.3)
|
|
356
447
|
rt.activity[lid] = now;
|
|
357
448
|
switch (w.kind) {
|
|
449
|
+
case 'session':
|
|
450
|
+
if(binding&&binding.worker.session_id!==w.session_id){binding.worker.session_id=w.session_id;effects.push({type:'save'});}
|
|
451
|
+
break;
|
|
358
452
|
case 'activity':
|
|
359
453
|
break;
|
|
360
454
|
case 'started': {
|
|
@@ -440,12 +534,15 @@ function handleWorker(state, ev, effects) {
|
|
|
440
534
|
if (failed) reply(state, profile, binding.key, `Turn failed: ${message}`, now, effects);
|
|
441
535
|
else if (text && !t?.replied) reply(state, profile, binding.key, text, now, effects);
|
|
442
536
|
}
|
|
537
|
+
releaseHandovers(rt, lid, now, effects);
|
|
443
538
|
const q = rt.queues[lid] ?? [];
|
|
444
539
|
if (q.length && rt.leases[lid]) {
|
|
445
540
|
const next = q.shift();
|
|
446
541
|
rt.turns[lid] = { started_at: now, buffer: '' };
|
|
447
542
|
effects.push(queuedInput(profile.name, ev.keyString, next));
|
|
448
543
|
}
|
|
544
|
+
// G3: the session's usage is read again from the harness's own records after every turn
|
|
545
|
+
if (binding?.worker?.session_id) effects.push({ type: 'usage_refresh', session: { harness: binding.worker.harness, session_id: binding.worker.session_id }, agent: binding.agent ?? profile.name });
|
|
449
546
|
effects.push({ type: 'save' });
|
|
450
547
|
break;
|
|
451
548
|
}
|
|
@@ -467,6 +564,7 @@ function handleWorker(state, ev, effects) {
|
|
|
467
564
|
case 'error': {
|
|
468
565
|
delete rt.turns[lid];
|
|
469
566
|
delete rt.starting[lid];
|
|
567
|
+
releaseHandovers(rt, lid, now, effects);
|
|
470
568
|
const synthetic = binding ? fireFailed(state, profile, binding, w.message ?? w.reason ?? 'worker exited', now, effects) : false;
|
|
471
569
|
if (binding) {
|
|
472
570
|
endBinding(state, profile.name, ev.keyString, 'error', now, effects);
|
|
@@ -502,7 +600,7 @@ export function permissionResponse(options, allow, reason = null) {
|
|
|
502
600
|
*/
|
|
503
601
|
function queuedInput(profile, keyString, next) {
|
|
504
602
|
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 } : {}) };
|
|
603
|
+
return { type: 'send_input', profile, keyString, text: next.text, ...(framed ? { context_from: next.context_from ?? null, skills: next.skills ?? [], job_id: next.job_id } : {}), ...(next.history ? { history: next.history } : {}) };
|
|
506
604
|
}
|
|
507
605
|
|
|
508
606
|
/** An admin's numeric reply resolves the oldest pending request on that surface. */
|
|
@@ -510,7 +608,7 @@ function tryResolveRequest(state, ev, effects) {
|
|
|
510
608
|
const rt = ensureRuntime(state);
|
|
511
609
|
const keyString = keyOf(ev);
|
|
512
610
|
const owner = profileOf(state, ev.profile);
|
|
513
|
-
const isAdmin = (owner.access.admins[ev.key.platform] ?? []).includes(ev.sender) || accessPolicy(owner, ev.key.platform) === 'open';
|
|
611
|
+
const isAdmin = (ev.identity?.classification==='proven' && (owner.access.admins[ev.key.platform] ?? []).includes(`principal:${ev.identity.principal.id}`)) || (owner.access.admins[ev.key.platform] ?? []).includes(ev.sender) || accessPolicy(owner, ev.key.platform) === 'open';
|
|
514
612
|
const m = /^\s*(\d+)\s*$/.exec(ev.text);
|
|
515
613
|
if (!m || !isAdmin) return false;
|
|
516
614
|
const entry = Object.entries(rt.pending_requests).find(([, p]) => p.keyString === keyString);
|
|
@@ -588,7 +686,7 @@ function handleRestart(state, ev, effects) {
|
|
|
588
686
|
if (!b.residue?.reopen_at_start) continue;
|
|
589
687
|
delete b.residue.reopen_at_start;
|
|
590
688
|
live += 1;
|
|
591
|
-
startWorker(state, profile, ks, effects);
|
|
689
|
+
startWorker(state, profile, ks, effects, ev.at);
|
|
592
690
|
continue;
|
|
593
691
|
}
|
|
594
692
|
live += 1;
|
|
@@ -626,7 +724,7 @@ function handleOperator(state, ev, effects) {
|
|
|
626
724
|
switch (ev.verb) {
|
|
627
725
|
case 'pair_approve': {
|
|
628
726
|
// Hermes's pairing store holds the code (hashed); the loop asks it, and a grant comes back as `access_granted`
|
|
629
|
-
effects.push({ type: 'access_write', profile: profile.name, op: 'approve', code: ev.args.code, platforms: Object.keys(profile.channels ?? {}) });
|
|
727
|
+
effects.push({ type: 'access_write', profile: profile.name, op: 'approve', code: ev.args.code, principal: ev.args.principal ?? null, platforms: Object.keys(profile.channels ?? {}) });
|
|
630
728
|
break;
|
|
631
729
|
}
|
|
632
730
|
// what Hermes's store granted, mirrored into the loaded access
|
|
@@ -676,7 +774,7 @@ function handleOperator(state, ev, effects) {
|
|
|
676
774
|
}
|
|
677
775
|
// the attach is over; what queued behind it gets a worker now (resumed where the harness can)
|
|
678
776
|
delete rt.starting[lid];
|
|
679
|
-
if (rt.queues[lid]?.length && profile.worker) startWorker(state, profile, ev.args.keyString, effects);
|
|
777
|
+
if (rt.queues[lid]?.length && profile.worker) startWorker(state, profile, ev.args.keyString, effects, ev.at);
|
|
680
778
|
effects.push({ type: 'save' });
|
|
681
779
|
break;
|
|
682
780
|
}
|
|
@@ -750,6 +848,11 @@ function handleOperator(state, ev, effects) {
|
|
|
750
848
|
if (!outcome.ok) effects.push({ type: 'log', level: 'warn', message: `session_handoff: ${outcome.message}` });
|
|
751
849
|
break;
|
|
752
850
|
}
|
|
851
|
+
case 'conversation_transfer': {
|
|
852
|
+
const outcome = doTransfer(state, profile, ev.args.keyString, ev.args, ev.at, effects);
|
|
853
|
+
if (!outcome.ok) effects.push({ type: 'log', level: 'warn', message: `conversation_transfer: ${outcome.message}` });
|
|
854
|
+
break;
|
|
855
|
+
}
|
|
753
856
|
// Profile verbs (§4.6): a profile IS a folder, so `create` adds the record
|
|
754
857
|
// and lets `save` write the folder, and `delete` removes the record and
|
|
755
858
|
// asks the loop to move the folder aside — the reducer performs no I/O and
|
|
@@ -795,6 +898,8 @@ export function step(state, event) {
|
|
|
795
898
|
case 'adapter': handleAdapter(state, event); break;
|
|
796
899
|
case 'operator': handleOperator(state, event, effects); break;
|
|
797
900
|
case 'pairing_issued': handlePairingIssued(state, event, effects); break;
|
|
901
|
+
// G3: a session's usage as its harness recorded it, recomputed whole
|
|
902
|
+
case 'usage': recordUsage(state, event.session, event.agent, event.usage); effects.push({ type: 'save' }); break;
|
|
798
903
|
default: throw new Error(`unknown event type ${event.type}`);
|
|
799
904
|
}
|
|
800
905
|
return effects;
|
package/webhook.mjs
CHANGED
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
// and `node:http` at call time instead.
|
|
11
11
|
|
|
12
12
|
import { surfaceKeyString, sessionReset } from './ir.mjs';
|
|
13
|
-
import { endBinding, ensureRuntime, expired, leaseId, liveBinding, newObligation } from './reducer.mjs';
|
|
13
|
+
import { endBinding, ensureRuntime, expired, leaseId, liveBinding, newObligation, reply } from './reducer.mjs';
|
|
14
|
+
import { budgetRefusal } from './conversations.mjs';
|
|
14
15
|
import { compactStamp, resolveTarget } from './cron.mjs';
|
|
15
16
|
import * as events from './events.mjs';
|
|
16
17
|
|
|
@@ -150,14 +151,17 @@ export function handleWebhook(state, ev, effects) {
|
|
|
150
151
|
if (reason) { endBinding(state, profile.name, keyString, reason, now, effects); b = null; }
|
|
151
152
|
}
|
|
152
153
|
if (!b) {
|
|
154
|
+
// G3: an agent over its budget starts nothing; the refusal is told, never a pause
|
|
155
|
+
const refused = budgetRefusal(state, profile, profile.name, now);
|
|
156
|
+
if (refused) { effects.push({ type: 'budget_refused', profile: profile.name, agent: profile.name, reason: refused }); return; }
|
|
153
157
|
b = {
|
|
154
|
-
key,
|
|
158
|
+
key, agent: profile.name,
|
|
155
159
|
worker: { harness: profile.worker.harness, session_id: `webhook_${ev.name}_${compactStamp(now)}`, locator: null },
|
|
156
160
|
started_at: now, last_activity_at: now, ended_at: null, end_reason: null,
|
|
157
|
-
|
|
161
|
+
recurrence: null, residue: { webhook: ev.name },
|
|
158
162
|
};
|
|
159
163
|
profile.bindings[keyString] = b;
|
|
160
|
-
effects.push({ type: 'runtime_start', profile: profile.name, keyString, worker: profile.worker, prompt: null });
|
|
164
|
+
effects.push({ type: 'runtime_start', profile: profile.name, keyString, worker: profile.worker, prompt: null, agent: profile.name });
|
|
161
165
|
}
|
|
162
166
|
b.residue.webhook = ev.name;
|
|
163
167
|
b.last_activity_at = now;
|