@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/mcp/tools.mjs ADDED
@@ -0,0 +1,57 @@
1
+ // The orchestrator's model-facing tool table (docs/ORCHESTRATOR-IR.md §7; docs/plans/hermes-compat.md row 13).
2
+ //
3
+ // The worker's model sees Hermes's own tools: `hermes-tools.json` is the pinned Hermes's (v2026.8.31)
4
+ // registry schemas, name, description and parameters, as its model receives them (`registry.get_schema`,
5
+ // and `SEND_MESSAGE_SCHEMA` for `send_message`, which Hermes registers per platform). One word differs:
6
+ // `cronjob.script` names "the home's scripts/" where Hermes prints its own home's path. Regenerated from
7
+ // the pin when Hermes moves, never edited by hand.
8
+ //
9
+ // What serves each: `cronjob` and `send_message` are the orchestrator's (its jobs and deliveries are in
10
+ // its state); `session_search`, `memory`, `skills_list` and `skill_view` are Hermes's own handlers
11
+ // against the profile's home (`hermes-cli.mjs` `hermesTool`), since the sessions, memories and skills
12
+ // they read are that home's. `session_handoff` is the orchestrator's `/handoff` as a tool; Hermes has no
13
+ // tool for it.
14
+ //
15
+ // The table is shared by `server.mjs` (which publishes it over MCP) and `bridge.mjs` (which dispatches it
16
+ // against the orchestrator's state), so a verb cannot be published without an implementation.
17
+
18
+ import { readFileSync } from 'node:fs';
19
+
20
+ /** MCP protocol version this server speaks — the same revision `crates/harness/src/mcp.rs` names. */
21
+ export const PROTOCOL_VERSION = '2025-06-18';
22
+
23
+ /** The name the worker's harness registers these tools under. */
24
+ export const SERVER_NAME = 'orchestrator';
25
+
26
+ const HERMES_TOOLS = JSON.parse(readFileSync(new URL('./hermes-tools.json', import.meta.url), 'utf8'));
27
+
28
+ export const TOOLS = Object.freeze([
29
+ ...HERMES_TOOLS,
30
+ {
31
+ name: 'session_handoff',
32
+ // The `/handoff <platform>` chat command (§4.5), as a tool.
33
+ description:
34
+ 'Hand this session off to another platform: marks the binding pending and posts a pickup notice to the '
35
+ + "profile's home chat on that platform.",
36
+ inputSchema: {
37
+ type: 'object',
38
+ properties: {
39
+ to: { type: 'string', description: 'The platform to hand off to, e.g. "telegram".' },
40
+ note: { type: 'string', description: 'Optional line to include in the pickup notice.' },
41
+ },
42
+ required: ['to'],
43
+ },
44
+ },
45
+ ]);
46
+
47
+ /**
48
+ * The board's tools, for a worker a dispatch spawned for a task (docs/plans/hermes-compat.md row 14):
49
+ * Hermes's fourteen `kanban_*`, from the pin's registry as `hermes-tools.json` is, published only where
50
+ * the task is in the door's environment (`HERMES_KANBAN_TASK`), as Hermes's own gate publishes them.
51
+ */
52
+ export const KANBAN_TOOLS = Object.freeze(JSON.parse(readFileSync(new URL('./hermes-kanban-tools.json', import.meta.url), 'utf8')));
53
+
54
+ /** The tools Hermes's own handlers serve, against the profile's home. */
55
+ export const HERMES_SERVED = Object.freeze(['session_search', 'memory', 'skills_list', 'skill_view']);
56
+
57
+ export const TOOL_NAMES = Object.freeze(TOOLS.map((t) => t.name));
package/mcp/verbs.mjs ADDED
@@ -0,0 +1,348 @@
1
+ // What each model-facing tool DOES, against the orchestrator's one state value
2
+ // (docs/ORCHESTRATOR-IR.md §7). `mcp/tools.mjs` publishes the table; this
3
+ // module is the other half — a verb cannot be published without landing here.
4
+ //
5
+ // Every verb runs inside the loop: it mutates the state value and returns the
6
+ // effects (§5) the loop then performs on its own queue, so a tool call is
7
+ // ordered against inbound messages and ticks like anything else. It performs
8
+ // no I/O of its own except Hermes's: the tools Hermes's own handlers serve
9
+ // (`session_search`, `memory`, `skills_list`, `skill_view`) and its schedule
10
+ // parser, each against the profile's home.
11
+ //
12
+ // Every result names what it did in `ran`.
13
+
14
+ import { homeChannel } from '../ir.mjs';
15
+ import { join } from 'node:path';
16
+ import { existsSync, realpathSync, statSync } from 'node:fs';
17
+ import { homedir } from 'node:os';
18
+ import { decodeContextFrom, parseTarget, renderTarget, resolveRoute, surfaceKeyString } from '../ir.mjs';
19
+ import { liveBinding } from '../reducer.mjs';
20
+ import * as events from '../events.mjs';
21
+ import { compactStamp, gateFields, homeZone, nextRunAt, parseCron, payloadError, scheduleError, scriptPathError } from '../cron.mjs';
22
+ import { applyOperator, hermesJobArgs } from '../operator.mjs';
23
+ import { hermesSchedule, hermesTool } from '../hermes-cli.mjs';
24
+ import { modelError } from '../activation.mjs';
25
+
26
+ /**
27
+ * cron/jobs.py `_normalize_workdir`, transcribed: returns the absolute,
28
+ * symlink-resolved path string, or null when unset/blank; throws Hermes's own
29
+ * messages on relative, missing, or non-directory input.
30
+ */
31
+ export function normalizeWorkdir(workdir) {
32
+ if (workdir === undefined || workdir === null) return null;
33
+ const raw = String(workdir).trim();
34
+ if (!raw) return null;
35
+ const expanded = raw === '~' || raw.startsWith('~/') ? join(homedir(), raw.slice(1)) : raw;
36
+ if (!expanded.startsWith('/')) {
37
+ throw new Error(`Cron workdir must be an absolute path (got '${raw}'). Cron jobs run detached from any shell cwd, so relative paths are ambiguous.`);
38
+ }
39
+ if (!existsSync(expanded)) throw new Error(`Cron workdir does not exist: ${expanded}`);
40
+ const resolved = realpathSync(expanded);
41
+ if (!statSync(resolved).isDirectory()) throw new Error(`Cron workdir is not a directory: ${resolved}`);
42
+ return resolved;
43
+ }
44
+
45
+ /** Inverse of `surfaceKeyString` (`platform|kind|chat_id|thread_id|participant_id`). */
46
+ export function keyFromString(text) {
47
+ if (!text) return null;
48
+ const p = String(text).split('|');
49
+ if (!p[0] || !p[1]) return null;
50
+ const key = { platform: p[0], kind: p[1] };
51
+ if (p[2]) key.chat_id = p[2];
52
+ if (p[3]) key.thread_id = p[3];
53
+ if (p[4]) key.participant_id = p[4];
54
+ return key;
55
+ }
56
+
57
+ /**
58
+ * Which profile a surface belongs to, by the same rule `handleInbound` uses
59
+ * (§4.1 step 2): the profile that owns the adapter resolves its routes, and an
60
+ * unrouted key stays with the owner. This is what makes another profile's
61
+ * surface refusable rather than merely undocumented.
62
+ */
63
+ export function routedProfile(state, key) {
64
+ const profiles = Object.values(state.profiles);
65
+ const owner = profiles.find((p) => p.channels && p.channels[key.platform]) ?? state.profiles.default ?? profiles[0];
66
+ if (!owner) return null;
67
+ return resolveRoute(owner.routes, key) ?? owner.name;
68
+ }
69
+
70
+ const UNIT_MINUTES = { m: 1, min: 1, mins: 1, minute: 1, minutes: 1, h: 60, hr: 60, hour: 60, hours: 60, d: 1440, day: 1440, days: 1440 };
71
+
72
+ /**
73
+ * Hermes's `schedule` string, or the IR's typed `Schedule` (§2.6), to the typed
74
+ * form. Four of Hermes's five documented forms are read here; any other string
75
+ * (natural day/time, "every monday 9am") is Hermes's own parser's
76
+ * (`cron.jobs.parse_schedule`, through the pinned Hermes against `home`), whose
77
+ * answer is one of the same three kinds. A cron without a zone runs on the
78
+ * home's clock (`homeZone`), as under Hermes.
79
+ */
80
+ export function parseSchedule(spec, now, home = null) {
81
+ // a model that serializes the typed form as a JSON string (measured in
82
+ // ORC-8's paid run: `"{\"kind\": \"interval\", \"minutes\": 120}"`) means the
83
+ // typed form; parse it rather than refuse the call
84
+ if (typeof spec === 'string' && /^\s*\{/.test(spec)) {
85
+ try { spec = JSON.parse(spec); } catch { /* not JSON: fall through to the string forms */ }
86
+ }
87
+ if (spec && typeof spec === 'object') {
88
+ switch (spec.kind) {
89
+ case 'once':
90
+ if (!Number.isFinite(Date.parse(spec.run_at))) throw new Error('schedule.run_at must be an instant');
91
+ return { kind: 'once', run_at: new Date(Date.parse(spec.run_at)).toISOString() };
92
+ case 'interval':
93
+ if (!(spec.minutes > 0)) throw new Error('schedule.minutes must be a positive number');
94
+ return { kind: 'interval', minutes: Number(spec.minutes) };
95
+ case 'cron': {
96
+ // the expression AND the zone: a zone `Intl` does not know is refused
97
+ // here, by name, not discovered after the job's first run (§4.3)
98
+ const cron = { kind: 'cron', expr: String(spec.expr ?? ''), ...(spec.tz ? { tz: String(spec.tz) } : {}) };
99
+ const broken = scheduleError(cron);
100
+ if (broken) throw new Error(broken);
101
+ return cron;
102
+ }
103
+ default:
104
+ throw new Error(`schedule.kind must be once | interval | cron, got ${JSON.stringify(spec.kind)}`);
105
+ }
106
+ }
107
+ const raw = String(spec ?? '').trim();
108
+ const t = raw.toLowerCase();
109
+ const once = /^in\s+(\d+)\s*([a-z]+)$/.exec(t);
110
+ if (once) {
111
+ const minutes = Number(once[1]) * (UNIT_MINUTES[once[2]] ?? NaN);
112
+ if (!Number.isFinite(minutes)) throw new Error(`schedule ${JSON.stringify(raw)}: unknown unit ${JSON.stringify(once[2])}`);
113
+ return { kind: 'once', run_at: new Date(Date.parse(now) + minutes * 60000).toISOString() };
114
+ }
115
+ const every = /^(?:every\s+)?(\d*)\s*([a-z]+)$/.exec(t);
116
+ if (every && UNIT_MINUTES[every[2]] !== undefined) {
117
+ return { kind: 'interval', minutes: (every[1] ? Number(every[1]) : 1) * UNIT_MINUTES[every[2]] };
118
+ }
119
+ if (raw.split(/\s+/).filter(Boolean).length === 5) {
120
+ parseCron(raw);
121
+ return { kind: 'cron', expr: raw };
122
+ }
123
+ const at = Date.parse(raw);
124
+ if (Number.isFinite(at)) return { kind: 'once', run_at: new Date(at).toISOString() };
125
+ const hermes = home && raw ? hermesSchedule(home, raw) : null;
126
+ if (hermes) return parseSchedule({ kind: hermes.kind, run_at: hermes.run_at, minutes: hermes.minutes, expr: hermes.expr }, now);
127
+ throw new Error(
128
+ `schedule ${JSON.stringify(raw)} is not a schedule: use '30m' / 'every 2h', 'in 30m', five-field cron `
129
+ + "('0 9 * * *'), an ISO timestamp, or the typed {kind: once|interval|cron} form. Day/time phrasing "
130
+ + "such as 'every monday 9am' is read by Hermes's parser, and none is installed here — write it as cron.",
131
+ );
132
+ }
133
+
134
+ /** A chat key out of Hermes's `deliver`/`target` grammar, resolved against this profile. */
135
+ function targetKey(profile, word, origin) {
136
+ const parsed = parseTarget('mcp/send_message', 'target', word);
137
+ if (!parsed || parsed.kind === 'origin') {
138
+ if (!origin) throw new Error('this session has no surface to answer; name a target');
139
+ return { ...origin };
140
+ }
141
+ if (parsed.kind === 'home') {
142
+ const home = homeChannel(profile);
143
+ if (!home) throw new Error(`profile ${profile.name} has no home channel (/sethome)`);
144
+ return home;
145
+ }
146
+ if (parsed.kind === 'local') throw new Error("send_message does not write files; use a cron job's deliver: 'local'");
147
+ if (!parsed.chat_id) {
148
+ const home = homeChannel(profile, parsed.platform);
149
+ if (home) return home;
150
+ throw new Error(`no home chat on ${parsed.platform}; name it as ${parsed.platform}:<chat_id>`);
151
+ }
152
+ const key = { platform: parsed.platform, kind: 'dm', chat_id: parsed.chat_id };
153
+ if (parsed.thread_id) key.thread_id = parsed.thread_id;
154
+ return key;
155
+ }
156
+
157
+ /** Where `send_message` can reach from this profile, in its target grammar: each home chat, each live conversation. */
158
+ function sendTargets(state, profile) {
159
+ const rows = new Map();
160
+ for (const [platform, channel] of Object.entries(profile.channels ?? {})) {
161
+ const home = channel?.extra?.home_channel;
162
+ if (home?.chat_id) rows.set(platform, { target: platform, platform, chat_id: String(home.chat_id), name: home.name ?? null, home: true });
163
+ }
164
+ for (const b of Object.values(profile.bindings)) {
165
+ const k = b.key;
166
+ if (b.ended_at || !k?.chat_id || ['cron', 'webhook', 'local'].includes(k.platform)) continue;
167
+ const target = [k.platform, k.chat_id, k.thread_id].filter(Boolean).join(':');
168
+ if (!rows.has(target) && routedProfile(state, k) === profile.name) rows.set(target, { target, platform: k.platform, chat_id: k.chat_id, kind: k.kind, home: false });
169
+ }
170
+ return [...rows.values()];
171
+ }
172
+
173
+ function jobId(profile, now) {
174
+ const stem = `job_${compactStamp(now)}`;
175
+ if (!profile.jobs[stem]) return stem;
176
+ for (let n = 2; ; n++) if (!profile.jobs[`${stem}_${n}`]) return `${stem}_${n}`;
177
+ }
178
+
179
+ /** The per-profile harness home the loop activates a worker with (`activationEnv`). */
180
+ export function harnessHomes(profile) {
181
+ const harness = profile.worker?.harness;
182
+ if (!harness) return undefined;
183
+ const home = join(profile.dir, harness);
184
+ // `HarnessHomes`/`SkillHomes` key each harness by its snake_case id; hermes
185
+ // is keyed by its state.db path, whose parent is the HERMES_HOME.
186
+ return { [harness.replace(/-/g, '_')]: harness === 'hermes' ? join(home, 'state.db') : home };
187
+ }
188
+
189
+ // ---------------------------------------------------------------- the verbs
190
+
191
+ /**
192
+ * @param {object} orch the running Orchestrator
193
+ * @param {object} call { verb, args, profile, surface }
194
+ * @returns {Promise<{result: object, effects: object[]}>}
195
+ */
196
+ export async function runVerb(orch, { verb, args = {}, profile: profileName, surface }) {
197
+ const state = orch.state;
198
+ const profile = state.profiles[profileName];
199
+ if (!profile) throw new Error(`unknown profile ${profileName}`);
200
+ const now = orch.clock();
201
+ const binding = surface ? profile.bindings[surface] ?? null : null;
202
+ const key = binding?.key ?? keyFromString(surface);
203
+ const effects = [];
204
+
205
+ switch (verb) {
206
+ case 'cronjob': {
207
+ // Hermes's one cron tool (tools/cronjob_tools.py `cronjob`), its actions on the orchestrator's jobs
208
+ const action = String(args.action ?? '').trim().toLowerCase();
209
+ if (action === 'create') {
210
+ args = hermesJobArgs(args);
211
+ // the model's door: Hermes's path rule for scripts, then its payload rule
212
+ for (const key of ['script', 'monitor_script']) { const bad = scriptPathError(args[key]); if (bad) throw new Error(bad); }
213
+ const gate = gateFields(args);
214
+ const empty = payloadError(args, gate);
215
+ if (empty) throw new Error(empty);
216
+ // Hermes's tool, stricter than create_job: an agent job needs a prompt or skills
217
+ const prompt = typeof args.prompt === 'string' && args.prompt.trim();
218
+ const skilled = Array.isArray(args.skills) && args.skills.some((x) => String(x).trim());
219
+ if (!gate.no_agent && !prompt && !skilled) throw new Error('an agent job needs a prompt or skills (a script alone needs no_agent)');
220
+ const schedule = parseSchedule(args.schedule, now, profile.dir);
221
+ // Hermes's tool contract (cron/jobs.py `_normalize_workdir`, measured in
222
+ // ORC-8's week): the workdir is trimmed, `~`-expanded, refused when
223
+ // relative (jobs run detached from any shell cwd), symlink-RESOLVED, and
224
+ // must be an existing directory. The same model call must store the same
225
+ // string here, or the two worlds' jobs.json disagree on `/tmp` vs
226
+ // `/private/tmp`. Only an operator-authored job (config/jobs.json) may be
227
+ // profile-relative.
228
+ const workdir = normalizeWorkdir(args.workdir);
229
+ const unusable = modelError(profile.worker?.harness, args.model ? String(args.model) : null);
230
+ if (unusable) throw new Error(unusable);
231
+ const id = jobId(profile, now);
232
+ const job = {
233
+ id,
234
+ schedule,
235
+ prompt: typeof args.prompt === 'string' ? args.prompt : '',
236
+ workdir,
237
+ model: args.model ? String(args.model) : null,
238
+ skills: Array.isArray(args.skills) ? args.skills.map(String) : [],
239
+ context_from: decodeContextFrom('cronjob', 'context_from', args.context_from),
240
+ deliver: parseTarget('mcp/cronjob', 'deliver', args.deliver) ?? { kind: 'origin' },
241
+ failure_deliver: parseTarget('mcp/cronjob', 'failure_deliver', args.failure_deliver),
242
+ // `origin` is captured from the binding, never from an argument: a job
243
+ // created in this conversation delivers back to this conversation.
244
+ origin: key ? { platform: key.platform, chat_type: key.kind ?? null, chat_id: key.chat_id ?? null, thread_id: key.thread_id ?? null } : null,
245
+ attach_to_session: typeof args.attach_to_session === 'boolean' ? args.attach_to_session : null,
246
+ // Hermes's `repeat`: an integer is "run N times", "forever"/absent is unlimited (`cron/jobs.py:812-830`)
247
+ repeat: Number.isInteger(args.repeat) && args.repeat > 0 ? { times: args.repeat, completed: 0 } : null,
248
+ enabled: true,
249
+ next_run_at: nextRunAt(schedule, now, homeZone(profile)),
250
+ last_run_at: null,
251
+ last_status: null,
252
+ created_at: now,
253
+ // Hermes's `name` has no IR field; it rides the residue channel verbatim (§1 rule 6).
254
+ residue: { ...(args.name ? { name: String(args.name) } : {}), ...(Array.isArray(args.enabled_toolsets) && args.enabled_toolsets.length ? { enabled_toolsets: args.enabled_toolsets.map(String) } : {}), ...gate },
255
+ };
256
+ await orch.dispatch(events.operator({ verb: 'jobs_create', profile: profile.name, args: { job }, at: now }));
257
+ return {
258
+ effects,
259
+ result: {
260
+ ran: `created cron job ${id} (${describeSchedule(schedule)}) in profile ${profile.name}, delivering to ${renderTarget(job.deliver)}`,
261
+ job_id: id,
262
+ next_run_at: job.next_run_at,
263
+ origin: job.origin,
264
+ },
265
+ };
266
+ }
267
+ if (action === 'list') {
268
+ const jobs = Object.values(profile.jobs).map((j) => ({
269
+ job_id: j.id,
270
+ name: j.residue?.name ?? null,
271
+ schedule: describeSchedule(j.schedule),
272
+ enabled: j.enabled,
273
+ deliver: renderTarget(j.deliver),
274
+ next_run_at: j.next_run_at,
275
+ last_status: j.last_status,
276
+ prompt: j.prompt,
277
+ }));
278
+ return { effects, result: { ran: `listed ${jobs.length} cron job(s) in profile ${profile.name}`, jobs } };
279
+ }
280
+ const op = { update: 'jobs.update', pause: 'jobs.pause', resume: 'jobs.resume', run: 'jobs.run', remove: 'jobs.delete' }[action];
281
+ if (!op) throw new Error(`action must be one of create, list, update, pause, resume, remove, run (got ${JSON.stringify(args.action ?? '')})`);
282
+ const id = String(args.job_id ?? '').trim();
283
+ if (!id) throw new Error(`job_id is required for ${action}; list first — never guess job IDs`);
284
+ if (!profile.jobs[id]) throw new Error(`no job ${JSON.stringify(id)} in profile ${profile.name}; list first`);
285
+ if (action === 'run' && typeof args.prompt === 'string' && args.prompt.trim()) {
286
+ throw new Error("run's transient prompt is not served here; run the job as it stands, or update its prompt");
287
+ }
288
+ const opArgs = action === 'update' ? { ...hermesJobArgs(args, profile.jobs[id]), id } : { id };
289
+ if (action === 'update') {
290
+ delete opArgs.action; delete opArgs.job_id;
291
+ if (typeof opArgs.workdir === 'string' && opArgs.workdir.trim()) opArgs.workdir = normalizeWorkdir(opArgs.workdir);
292
+ for (const key of ['script', 'monitor_script']) { const bad = opArgs[key] ? scriptPathError(opArgs[key]) : null; if (bad) throw new Error(bad); }
293
+ }
294
+ const answer = await applyOperator(state, op, opArgs, profile.name, now, (event) => orch.dispatch(event));
295
+ if (!answer.ok) throw new Error(answer.error);
296
+ return { effects, result: answer.result };
297
+ }
298
+
299
+ case 'send_message': {
300
+ // Hermes's send_message: `send` (the default) and `list`; reactions are not a door these adapters have
301
+ const action = String(args.action ?? 'send').trim().toLowerCase() || 'send';
302
+ if (action === 'list') {
303
+ const targets = sendTargets(state, profile);
304
+ return { effects, result: { ran: `listed ${targets.length} target(s) for profile ${profile.name}`, targets } };
305
+ }
306
+ if (action === 'react' || action === 'unreact') throw new Error(`send_message action ${action} is not served by this orchestrator's platform adapters`);
307
+ if (action !== 'send') throw new Error(`action must be one of send, list, react, unreact (got ${JSON.stringify(args.action)})`);
308
+ if (typeof args.message !== 'string' || !args.message) throw new Error('message is required');
309
+ const target = targetKey(profile, args.target, key);
310
+ const owner = routedProfile(state, target);
311
+ if (owner && owner !== profile.name) {
312
+ throw new Error(`${surfaceKeyString(target)} belongs to profile ${owner}, not ${profile.name}; a session may only message its own profile's surfaces`);
313
+ }
314
+ const fx = await orch.dispatch(events.operator({ verb: 'send_message', profile: profile.name, args: { key: target, text: args.message }, at: now }));
315
+ const obligationId = fx.find((e) => e.type === 'deliver')?.obligation_id ?? null;
316
+ return { effects, result: { ran: `queued a message to ${surfaceKeyString(target)}`, obligation_id: obligationId } };
317
+ }
318
+
319
+ case 'session_search':
320
+ case 'memory':
321
+ case 'skills_list':
322
+ case 'skill_view': {
323
+ // Hermes's own handler against this profile's home: its sessions (the workers' mirrored beside
324
+ // Hermes's), its memories, its skills; answered in Hermes's words
325
+ const verbatim = await hermesTool(profile.dir, verb, args, { sessionId: binding?.worker?.session_id ?? null });
326
+ return { effects, result: { ran: `${verb} in profile ${profile.name}`, verbatim: typeof verbatim === 'string' ? verbatim : JSON.stringify(verbatim) } };
327
+ }
328
+
329
+ case 'session_handoff': {
330
+ const to = args.to ? String(args.to) : '';
331
+ if (!to) throw new Error('to is required');
332
+ const b = surface ? liveBinding(profile, surface) : null;
333
+ if (!b) throw new Error('No live session to hand off.');
334
+ await orch.dispatch(events.operator({ verb: 'session_handoff', profile: profile.name, args: { keyString: surface, to, note: args.note ? String(args.note) : null }, at: now }));
335
+ if (b.handoff?.state !== 'pending') return { effects, error: `Cannot hand off: ${b.handoff?.error ?? 'unknown reason'}.` };
336
+ return { effects, result: { ran: `handed off to ${to}; the binding is pending pickup`, session_id: b.worker.session_id } };
337
+ }
338
+
339
+ default:
340
+ throw new Error(`unknown verb ${verb}`);
341
+ }
342
+ }
343
+
344
+ function describeSchedule(s) {
345
+ if (s.kind === 'once') return `once at ${s.run_at}`;
346
+ if (s.kind === 'interval') return `every ${s.minutes} min`;
347
+ return `cron ${s.expr}${s.tz ? ` ${s.tz}` : ''}`;
348
+ }