@volter/supercode-orchestrator 0.4.23 → 0.4.25

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/board/cli.mjs CHANGED
@@ -466,7 +466,7 @@ export async function runWorkflow(argv) {
466
466
  if (args.reason) for (const [t] of results) withBoard((b) => engine.comment(b, t, callerName(), `UNBLOCK: ${args.reason}`));
467
467
  say(args, results.map(([t, s]) => `Unblocked ${t} → ${s}`).join('\n'), { ok: true, results }); return 0;
468
468
  }
469
- case 'request-review': need(1, 'a card id'); withBoard((b) => engine.requestReview(b, id, { summary: args.summary ?? null, reviewer: args.reviewer ?? null, metadata: metadataOf(args.metadata) ?? null, force: Boolean(args.force) })); say(args, `${id} → review`, { ok: true }); return 0;
469
+ case 'request-review': need(1, 'a card id'); withBoard((b) => engine.requestReview(b, id, { summary: args.summary ?? null, reviewer: args.reviewer ?? null, metadata: metadataOf(args.metadata) ?? null, force: Boolean(args.force), expectedRunId: workerRun(b.db, id, sid) })); say(args, `${id} → review`, { ok: true }); return 0;
470
470
  case 'request-changes': need(2, 'a card id and the reason'); withBoard((b) => engine.requestChanges(b, id, { reason: rest.slice(1).join(' '), reviewer: args.reviewer ?? callerName() })); say(args, `${id} sent back for changes`, { ok: true }); return 0;
471
471
  case 'reopen': {
472
472
  // a done card back to work; the cards that built on it are invalidated with it (Hermes's reopen)
@@ -909,6 +909,17 @@ function importBoard(root, archive, as) {
909
909
  // ------------------------------------------------------------------ a card's own session
910
910
 
911
911
  /** The card whose latest session is `sessionId` (the caller works it), or null. */
912
+ /**
913
+ * The run the caller works on card `id`, as Hermes's `_worker_run_id_for` names it: a one-shot worker's
914
+ * `HERMES_KANBAN_TASK`/`HERMES_KANBAN_RUN_ID`, or the run the board recorded for the caller's session. Null for anyone else.
915
+ */
916
+ function workerRun(db, id, sessionId) {
917
+ if (process.env.HERMES_KANBAN_TASK === id && Number.isInteger(Number(process.env.HERMES_KANBAN_RUN_ID)) && process.env.HERMES_KANBAN_RUN_ID) return Number(process.env.HERMES_KANBAN_RUN_ID);
918
+ if (!sessionId) return null;
919
+ const hit = db.prepare('SELECT id FROM task_runs WHERE task_id = ? AND metadata LIKE ? ORDER BY id DESC LIMIT 1').get(id, `%"session_id":"${sessionId}"%`);
920
+ return hit ? Number(hit.id) : null;
921
+ }
922
+
912
923
  function ownCard(db, sessionId) {
913
924
  const hit = db.prepare('SELECT task_id FROM task_runs WHERE metadata LIKE ? ORDER BY id DESC LIMIT 1').get(`%"session_id":"${sessionId}"%`);
914
925
  if (!hit) return null;
@@ -911,13 +911,17 @@ function readMailbox(board, address, report) {
911
911
  // ------------------------------------------------------------------ goals and triage
912
912
 
913
913
  /**
914
- * Hermes's goal loop for a pane session: a goal card whose session sits idle while the card is still running is told
915
- * to keep going, up to the card's turn budget; when the budget is spent the card is blocked for review. Whether the
916
- * work is done is the session's call through its card (`request-review`), not an auxiliary judge's.
914
+ * The board's heartbeat for a pane session: a running card's session never sits idle without deciding. Each tick, a
915
+ * session that is idle while its card is still running is asked to decide: `request-review` when the work is done,
916
+ * `block` with its kind when it cannot go on (waiting on a person, a capability, another card), or keep working. A
917
+ * goal card (Hermes's goal loop) is told to keep going. A session is asked once it has been idle a whole tick; one
918
+ * seen working since the last ask answered it, and its count starts again. After the turn budget (`goal_max_turns`, else
919
+ * `kanban.goal_max_turns`) of asks in a row with no work and no decision (a goal card: of turns), the card is blocked `needs_input` and its creator is told.
920
+ * Whether the work is done is the session's call through its card, not an auxiliary judge's.
917
921
  */
918
922
  function pursueGoals(board, fleet, settings, report) {
919
923
  if (!fleet.ok) return;
920
- const cards = board.db.prepare("SELECT id FROM tasks WHERE status = 'running' AND goal_mode = 1").all();
924
+ const cards = board.db.prepare("SELECT id FROM tasks WHERE status = 'running'").all();
921
925
  for (const { id } of cards) {
922
926
  const nudge = tx(board, (db) => {
923
927
  const task = getTask(db, id);
@@ -925,22 +929,37 @@ function pursueGoals(board, fleet, settings, report) {
925
929
  const session = runRow?.metadata?.supercode;
926
930
  if (!session?.session_id || session.surface !== 'pane') return null;
927
931
  const live = fleet.sessions.find((x) => x.address?.endsWith(`:${session.session_id}`));
928
- if (live?.status !== 'idle') return null;
932
+ if (!live) return null;
933
+ // a session seen working since the last ask answered it by working: its count starts again (a goal card keeps
934
+ // Hermes's budget of turns); one idle for less than a whole tick is between turns, not idle
935
+ if (live.status !== 'idle') {
936
+ if (session.idle_since || (session.goal_nudged_at && !session.worked_since_ask)) setRunSession(db, runRow.id, { ...session, idle_since: null, worked_since_ask: true });
937
+ return null;
938
+ }
939
+ if (!session.idle_since) { setRunSession(db, runRow.id, { ...session, idle_since: now() }); return null; }
940
+ if (now() - session.idle_since < settings.interval) return null;
929
941
  if (session.goal_nudged_at && now() - session.goal_nudged_at < settings.interval * 2) return null;
930
- const turns = Number(session.goal_turns ?? 0);
942
+ const turns = session.worked_since_ask && !task.goal_mode ? 0 : Number(session.goal_turns ?? 0);
931
943
  const budget = task.goal_max_turns ?? settings.goalMaxTurns;
932
944
  if (turns >= budget) {
933
945
  db.prepare("UPDATE tasks SET status = 'blocked', claim_lock = NULL, claim_expires = NULL WHERE id = ?").run(id);
934
- const ended = endRun(db, id, { outcome: 'blocked', summary: `goal turn budget (${budget}) spent` });
935
- event(db, id, 'blocked', { reason: `goal turn budget (${budget}) spent; review it`, kind: 'needs_input', source_status: 'ready' }, ended);
946
+ const ended = endRun(db, id, { outcome: 'blocked', summary: `idle ${budget} times without deciding done or blocked` });
947
+ event(db, id, 'blocked', { reason: `its session went idle ${budget} times without deciding done or blocked; review it`, kind: 'needs_input', source_status: 'running' }, ended);
936
948
  return null;
937
949
  }
938
- setRunSession(db, runRow.id, { ...session, goal_turns: turns + 1, goal_nudged_at: now() });
939
- event(db, id, 'goal_continued', { turn: turns + 1, budget }, runRow.id);
940
- return { address: live.address, text: `Card ${id} (${task.title}) is not done yet: keep working toward it (goal turn ${turns + 1} of ${budget}). When it is done, \`request-review\` it; if you are stuck, \`block\` it with its kind.` };
950
+ setRunSession(db, runRow.id, { ...session, goal_turns: turns + 1, goal_nudged_at: now(), worked_since_ask: false });
951
+ event(db, id, task.goal_mode ? 'goal_continued' : 'decision_asked', { turn: turns + 1, budget }, runRow.id);
952
+ const local = normalMachine(task.machine) === localMachine();
953
+ const where = local ? `--root ${board.root}${board.slug !== 'default' ? ` --board ${board.slug}` : ''}` : `--at ${boardAddress(board.label)}`;
954
+ const decide = `When it is done, \`supercode workflow request-review ${id} --summary "<what>" ${where}\`; if you cannot go on, \`supercode workflow block ${id} "<why>" --kind <needs_input|capability|transient|dependency> ${where}\`.`;
955
+ const text = task.goal_mode
956
+ ? `Card ${id} (${task.title}) is not done yet: keep working toward it (goal turn ${turns + 1} of ${budget}). ${decide}`
957
+ : `Card ${id} (${task.title}) is still running and you are idle: decide (${turns + 1} of ${budget}). If there is more to do, keep working. ${decide} `
958
+ + `A session that stays idle without deciding has its card blocked for its creator after ${budget}.`;
959
+ return { address: live.address, text, kind: task.goal_mode ? 'goal_continued' : 'decision_asked' };
941
960
  });
942
961
  if (nudge) {
943
- try { send(boardAddress(board.label), board.label, nudge.address, nudge.text, `board-goal-${id}-${now()}`); report.notified.push([id, 'goal_continued', nudge.address]); }
962
+ try { send(boardAddress(board.label), board.label, nudge.address, nudge.text, `board-goal-${id}-${now()}`); report.notified.push([id, nudge.kind, nudge.address]); }
944
963
  catch (error) { report.undelivered.push([id, nudge.address, String(error.message).slice(-200)]); }
945
964
  }
946
965
  }
package/board/engine.mjs CHANGED
@@ -7,7 +7,7 @@
7
7
  //
8
8
  // Each function takes an open board (`store.mjs`) and runs in one write transaction. Nothing here launches,
9
9
  // observes or sends: the dispatcher (`dispatch.mjs`) does, and records what it did through these.
10
- import { spawnSync } from 'node:child_process';
10
+ import { spawn, spawnSync } from 'node:child_process';
11
11
  import { existsSync, rmSync } from 'node:fs';
12
12
  import { join, resolve } from 'node:path';
13
13
  import { DatabaseSync } from 'node:sqlite';
@@ -60,6 +60,15 @@ export function parseWorkspace(word = 'scratch') {
60
60
 
61
61
  // ------------------------------------------------------------------ runs
62
62
 
63
+ /**
64
+ * Hermes's `_terminate_reclaimed_worker`: SIGTERM to the worker's process group, SIGKILL if it is still alive five
65
+ * seconds later. The wait runs in a detached shell, so the board's write lock is never held through it.
66
+ */
67
+ function terminate(pid) {
68
+ try { process.kill(-pid, 'SIGTERM'); } catch { try { process.kill(pid, 'SIGTERM'); } catch { return; } }
69
+ spawn('/bin/sh', ['-c', 'for i in 1 2 3 4 5 6 7 8 9 10; do kill -0 "$0" 2>/dev/null || exit 0; sleep 0.5; done; kill -KILL -"$0" 2>/dev/null || kill -KILL "$0" 2>/dev/null', String(pid)], { detached: true, stdio: 'ignore' }).unref();
70
+ }
71
+
63
72
  /** Close the card's open run; answers its id, or null when none was open. */
64
73
  export function endRun(db, taskId, { outcome, status = null, summary = null, error = null, metadata = undefined }) {
65
74
  const row = one(db, 'SELECT current_run_id FROM tasks WHERE id = ?', taskId);
@@ -70,9 +79,8 @@ export function endRun(db, taskId, { outcome, status = null, summary = null, err
70
79
  // a run taken back (reclaim, archive, a reopened ancestor) stops its one-shot worker on this machine, as Hermes
71
80
  // terminates a reclaimed worker (a board's one-shot workers run where the board is); a pane session is a person-visible session and is never killed from the board
72
81
  const worker = outcome === 'reclaimed' ? json(was, {})?.supercode : null;
73
- if (worker?.surface === 'headless' && worker.pid) {
74
- try { process.kill(-worker.pid, 'SIGTERM'); } catch { try { process.kill(worker.pid, 'SIGTERM'); } catch { /* gone */ } }
75
- }
82
+ const lock = one(db, 'SELECT claim_lock FROM task_runs WHERE id = ?', runId)?.claim_lock ?? one(db, 'SELECT claim_lock FROM tasks WHERE id = ?', taskId)?.claim_lock;
83
+ if (worker?.surface === 'headless' && worker.pid && String(lock ?? '').startsWith(`${claimer().split(':')[0]}:`)) terminate(Number(worker.pid));
76
84
  run(db, `UPDATE task_runs SET status = ?, outcome = ?, summary = ?, error = ?, metadata = ?, ended_at = ?, claim_lock = NULL, claim_expires = NULL, worker_pid = NULL
77
85
  WHERE id = ? AND ended_at IS NULL`, status ?? outcome, outcome, summary, error, keep, now(), runId);
78
86
  run(db, 'UPDATE tasks SET current_run_id = NULL WHERE id = ?', taskId);
@@ -424,13 +432,15 @@ export function unblock(board, id) {
424
432
  }
425
433
 
426
434
  /** Implementation done, awaiting review (not a block): the review lane, optionally under a reviewer profile. */
427
- export function requestReview(board, id, { summary = null, metadata = null, reviewer = null, force = false } = {}) {
435
+ export function requestReview(board, id, { summary = null, metadata = null, reviewer = null, force = false, expectedRunId = null } = {}) {
428
436
  return tx(board, (db) => {
429
437
  const task = must(db, id);
430
438
  if (!parentsDone(db, id)) throw new Error(`${id} waits on parents that are not done`);
431
439
  if (!['running', 'ready'].includes(task.status)) throw new Error(`${id} is ${task.status}; only a running or ready card goes to review`);
432
440
  // a live claim is its worker's to hand off; anyone else says so on purpose (Hermes's guard)
433
- if (task.status === 'running' && task.claim_lock && !force) throw new Error(`${id} is running under a live claim; its worker requests review, or pass --force to override on purpose`);
441
+ // (Hermes's expected_run_id: the worker names the run it holds; a superseded run changes nothing)
442
+ if (expectedRunId !== null && Number(task.current_run_id) !== Number(expectedRunId)) throw new Error(`run ${expectedRunId} of ${id} is no longer its current run; nothing was changed`);
443
+ if (task.status === 'running' && task.claim_lock && !force && expectedRunId === null) throw new Error(`${id} is running under a live claim; its worker requests review, or pass --force to override on purpose`);
434
444
  // a re-review goes back to the reviewer who asked for the changes
435
445
  if (!reviewer) {
436
446
  const changed = one(db, "SELECT id FROM task_runs WHERE task_id = ? AND outcome = 'changes_requested' ORDER BY id DESC LIMIT 1", id);
package/board/tools.mjs CHANGED
@@ -97,7 +97,7 @@ export async function boardTool(root, name, args = {}, env = {}) {
97
97
  }
98
98
  case 'kanban_request_review': {
99
99
  const stale = ownRun(); if (stale) return error(stale);
100
- engine.requestReview(board, tid, { summary: args.summary ? redact(String(args.summary)) : null, metadata: args.metadata ? redact(args.metadata) : null, reviewer: args.reviewer ?? null, force: own === tid });
100
+ engine.requestReview(board, tid, { summary: args.summary ? redact(String(args.summary)) : null, metadata: args.metadata ? redact(args.metadata) : null, reviewer: args.reviewer ?? null, expectedRunId: own === tid && runId ? runId : null });
101
101
  return ok({ task_id: tid, status: 'review' });
102
102
  }
103
103
  case 'kanban_request_changes': {
package/kanban-worker.mjs CHANGED
@@ -2,9 +2,11 @@
2
2
  // starts a headless profile's worker as Hermes's dispatcher starts its own: `-p <assignee> --cli --accept-hooks
3
3
  // [--skills <s>]… [-m <model> [--provider <p>]] [--reasoning <e>] chat -q "work kanban task <id>"`, detached, in the
4
4
  // task's workspace, its output to the task's log, the task in its environment (`KANBAN_ENV`), and watches the pid.
5
- // This is that process: it runs the assignee profile's own worker for the one query, with Hermes's task protocol,
6
- // the task's skills and the home's memory in its system prompt and the board's tools on its door, prints the answer
7
- // to the log and exits, as `hermes chat -q` does. A profile whose worker is Hermes runs Hermes itself.
5
+ // This is that process: it runs the assignee profile's own worker for the one query, with the task protocol and the
6
+ // task's skills in its system prompt and the board's tools on its door, prints the answer to the log and exits, as
7
+ // `hermes chat -q` does. It runs no Hermes code: the worker is a session supercode starts and records (its id on the
8
+ // run), its memory is its harness's own, its requests are answered by supercode's unattended rule. A profile whose
9
+ // worker is Hermes runs Hermes itself, as its harness.
8
10
  import { spawn } from 'node:child_process';
9
11
  import { existsSync, statSync } from 'node:fs';
10
12
  import { basename, dirname, join, resolve } from 'node:path';
@@ -12,10 +14,12 @@ import { SupercodeHarnessClient } from '@volter/supercode-harness-sdk';
12
14
  import { load } from './orchestration.mjs';
13
15
  import { INSTRUCTION_ARGUMENTS, activationCwd, activationEnv, sandboxArguments, approvalPolicy, limitArguments, materializePersona, materializeSkills, modelArguments, turnCounter, userHomeArguments, userHomePersona } from './activation.mjs';
14
16
  import { workerRoute } from './worker-route.mjs';
15
- import { KANBAN_ENV, untrustedServerVerdict, hermesApproval, hermesInstall, hermesToolEnv, memorySnapshot, mirrorSession } from './hermes-cli.mjs';
17
+ import { KANBAN_ENV, untrustedServerVerdict, hermesInstall } from './hermes-cli.mjs';
16
18
  import { pinnedSkills, taskProtocol } from './board/worker-prompt.mjs';
17
19
  import { mountOrchestratorMcp } from './mcp/mount.mjs';
18
- import { normalizeWorkerEvent, WORKER_STORE } from './loop.mjs';
20
+ import { normalizeWorkerEvent } from './loop.mjs';
21
+ import { DEFAULT_BOARD, json, openBoard, tx } from './board/store.mjs';
22
+ import { setRunSession } from './board/engine.mjs';
19
23
  import { permissionResponse } from './reducer.mjs';
20
24
 
21
25
  /** Hermes's worker argv, as `_default_spawn` builds it. */
@@ -63,11 +67,9 @@ export async function runKanbanWorker(argv, env = process.env) {
63
67
  materializeSkills(profile);
64
68
  const route = workerRoute(profile, worker, vault);
65
69
  const surfaceKey = `kanban|task|${taskId}`;
66
- const runEnv = { ...hermesToolEnv(profile.dir), ...activationEnv({ root, profile, surfaceKey, vault }), ...route.env, ...task };
67
- // Hermes's system prompt for a dispatched worker: the home's memory, the task protocol, the task's skills
68
- const memory = await memorySnapshot(profile.dir).catch(() => '');
69
- // the task protocol and the pinned skills, supercode's own (board/worker-prompt.mjs)
70
- const instructions = [userHomePersona(profile), memory, taskProtocol(taskId), pinnedSkills(profile.dir, args.skills)].filter(Boolean).join('\n\n');
70
+ const runEnv = { HERMES_HOME: profile.dir, ...activationEnv({ root, profile, surfaceKey, vault }), ...route.env, ...task };
71
+ // the persona, the task protocol and the pinned skills, supercode's own (board/worker-prompt.mjs)
72
+ const instructions = [userHomePersona(profile), taskProtocol(taskId), pinnedSkills(profile.dir, args.skills)].filter(Boolean).join('\n\n');
71
73
 
72
74
  const client = new SupercodeHarnessClient(env.SUPERCODE_BIN ? { command: env.SUPERCODE_BIN } : {});
73
75
  try {
@@ -90,6 +92,7 @@ async function runTurn({ client, profile, harness, worker, route, root, vault, s
90
92
  const mount = mountOrchestratorMcp({ root, profile, harness, surfaceKey, launch, vault, task });
91
93
  const policy = approvalPolicy(profile, harness);
92
94
  const runtime = await client.startManagedRuntime({ harness, cwd, launch: mount.launch, ...(mount.mcp_servers ? { mcp_servers: mount.mcp_servers } : {}), ...(policy ? { approval_policy: policy } : {}) });
95
+ try { recordSession(root, task, harness, runtime.handle.runtime_id); } catch (error) { say(`hermes-worker: recording the session on its run: ${error.message}`); }
93
96
  let text = '';
94
97
  // `agent.max_turns` for a worker that takes no cap at launch, held as the daemon holds it (loop.mjs `holdTurnLimit`)
95
98
  const counter = turnCounter(profile, harness);
@@ -104,13 +107,12 @@ async function runTurn({ client, profile, harness, worker, route, root, vault, s
104
107
  if (w.kind === 'final' && counter) w = counter.settle(w);
105
108
  if (w.kind === 'text') text += w.block && text ? `\n\n${w.delta}` : w.delta;
106
109
  else if (w.kind === 'request') {
107
- // Hermes's approval rule for a single-query worker (`approvals.single_query_mode` for a dangerous command)
110
+ // nobody attends a dispatched worker: an untrusted server's tool is denied, anything else is answered by the
111
+ // profile's `worker.permission.unattended` (deny unless it says approve)
108
112
  const gated = untrustedServerVerdict(profile, w.tool, 'single_query', { mcp: w.mcp && !String(w.tool ?? '').startsWith('mcp__orchestrator__') });
109
- void (gated ? Promise.resolve(gated) : hermesApproval(profile.dir, { command: w.command ?? null, context: 'single_query' })).catch(() => null).then((hermes) => {
110
- const approve = hermes ? hermes.verdict === 'allow' : profile.worker?.permission?.unattended === 'approve';
111
- if (!hermes || hermes.verdict !== 'allow') say(`hermes-worker: ${approve ? 'approved' : 'denied'} a permission request with nobody attending (${hermes ? `Hermes: ${hermes.why}` : 'worker.permission.unattended'}): ${w.subject}`);
112
- runtime.respond(JSON.parse(w.id), permissionResponse(w.options, approve, hermes?.message ?? null)).catch(() => {});
113
- });
113
+ const approve = gated ? gated.verdict === 'allow' : profile.worker?.permission?.unattended === 'approve';
114
+ say(`hermes-worker: ${approve ? 'approved' : 'denied'} a permission request with nobody attending (${gated ? gated.why : 'worker.permission.unattended'}): ${w.subject}`);
115
+ runtime.respond(JSON.parse(w.id), permissionResponse(w.options, approve, gated?.message ?? (approve ? null : 'Denied: nobody is attending this run to approve it (worker.permission.unattended). Find an alternative approach that avoids this action.'))).catch(() => {});
114
116
  } else if (w.kind === 'final') {
115
117
  if (w.outcome === 'error') say(`hermes-worker: the turn failed: ${w.message ?? 'no reason given'}`);
116
118
  done(w.outcome === 'error' ? 1 : 0);
@@ -125,20 +127,27 @@ async function runTurn({ client, profile, harness, worker, route, root, vault, s
125
127
  runtime.sendInput(args.query).catch((error) => { say(`hermes-worker: ${error.message}`); done(1); });
126
128
  });
127
129
  if (text) say(text);
128
- // the run's transcript in the home's session store, as a Hermes worker's is: tagged `kanban`
129
- await mirrorRun(client, profile, harness, runtime.handle.runtime_id).catch((error) => say(`hermes-worker: mirror: ${error.message}`));
130
130
  await runtime.close().catch(() => {});
131
131
  return outcome;
132
132
  }
133
133
 
134
- async function mirrorRun(client, profile, harness, sessionId) {
135
- const store = WORKER_STORE[harness];
136
- if (!store) return;
137
- const { sessions } = await client.discover({ harnesses: [harness], homes: store(join(profile.dir, harness)), include_child_sessions: true, root_session_id: sessionId, limit: 8 });
138
- const locator = sessions.find((d) => d.locator.session_id === sessionId)?.locator;
139
- if (!locator) return;
140
- const loaded = await client.loadWindow(locator, { message_limit: 1_000_000 });
141
- await mirrorSession(profile.dir, { id: sessionId, sessionKey: null, binding: { key: { platform: 'kanban' }, worker: { harness } }, session: loaded.session, locator: locator.storage?.path ?? null });
134
+ /**
135
+ * The worker's session on its run, where supercode keeps it (`metadata.supercode.session_id`, beside the pid the
136
+ * dispatcher recorded): the harness's own transcript, found and resumed by supercode like any session.
137
+ */
138
+ function recordSession(root, task, harness, sessionId) {
139
+ if (!task.HERMES_KANBAN_RUN_ID || !sessionId) return;
140
+ const board = openBoard(root, task.HERMES_KANBAN_BOARD || DEFAULT_BOARD);
141
+ try {
142
+ tx(board, (db) => {
143
+ const row = db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(Number(task.HERMES_KANBAN_RUN_ID));
144
+ if (!row) return;
145
+ const meta = json(row.metadata, {}) ?? {};
146
+ setRunSession(db, Number(task.HERMES_KANBAN_RUN_ID), { ...(meta.supercode ?? {}), session_id: sessionId, harness });
147
+ });
148
+ } finally {
149
+ board.close();
150
+ }
142
151
  }
143
152
 
144
153
  /** A profile whose worker is Hermes: the dispatcher's own command, run as Hermes would have run it. */
package/mcp/server.mjs CHANGED
@@ -48,8 +48,12 @@ export function bindingFromEnv(env = process.env) {
48
48
  /** The profile's folder: the root is the `default` profile, `profiles/<name>` the rest (a Hermes home's layout). */
49
49
  const profileDir = (binding) => (binding.profile === 'default' ? binding.root : join(binding.root, 'profiles', binding.profile));
50
50
 
51
- /** The tools this door publishes: the orchestrator's, and the board's where a dispatch gave it a task. */
52
- const published = (binding) => (binding.kanban ? [...TOOLS, ...KANBAN_TOOLS] : TOOLS);
51
+ // the tools Hermes's own handlers answer (mcp/verbs.mjs): a worker the board dispatched is supercode's session, and a
52
+ // profile that wants Hermes's session search, memory and skills tools makes Hermes its worker's harness
53
+ const HERMES_ANSWERED = new Set(['session_search', 'memory', 'skills_list', 'skill_view']);
54
+
55
+ /** The tools this door publishes: the orchestrator's, and for a worker the board dispatched, the board's without Hermes's. */
56
+ const published = (binding) => (binding.kanban ? [...TOOLS.filter((t) => !HERMES_ANSWERED.has(t.name)), ...KANBAN_TOOLS] : TOOLS);
53
57
 
54
58
  /**
55
59
  * A board tool, answered by supercode's own board (board/tools.mjs) on the home's board file: the task, its run and
package/mcp/verbs.mjs CHANGED
@@ -217,7 +217,8 @@ export async function runVerb(orch, { verb, args = {}, profile: profileName, sur
217
217
  const prompt = typeof args.prompt === 'string' && args.prompt.trim();
218
218
  const skilled = Array.isArray(args.skills) && args.skills.some((x) => String(x).trim());
219
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);
220
+ // a board-dispatched worker's schedules are parsed by supercode alone (its session runs no Hermes code)
221
+ const schedule = parseSchedule(args.schedule, now, String(surface ?? '').startsWith('kanban|') ? null : profile.dir);
221
222
  // Hermes's tool contract (cron/jobs.py `_normalize_workdir`, measured in
222
223
  // ORC-8's week): the workdir is trimmed, `~`-expanded, refused when
223
224
  // relative (jobs run detached from any shell cwd), symlink-RESOLVED, and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-orchestrator",
3
- "version": "0.4.23",
3
+ "version": "0.4.25",
4
4
  "type": "module",
5
5
  "description": "The orchestrator runtime over the Volter Harness ontology: one typed operational model whose folder is its serialization, read and written through the harness orchestration doors (docs/ORCHESTRATOR-IR.md)",
6
6
  "exports": {