@volter/supercode-orchestrator 0.5.69 → 0.5.70

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
@@ -2,21 +2,30 @@
2
2
  // format, and its dispatcher. Every verb of `hermes kanban` is here under its own name, with its own flags and its own
3
3
  // output lines, so a caller of Hermes's CLI calls this one unchanged; supercode's own verbs (`move`, `adopt`, `close`,
4
4
  // `serve`, `--at`) are added beside them. The Rust CLI hands every verb but `load` to `bin/orchestrator.mjs workflow`,
5
- // which lands here. The home is `--root`, else `SUPERCODE_ORCHESTRATOR_HOME`, else `HERMES_HOME`; the board is
5
+ // which lands here. The home is `--root`, else `SUPERCODE_ORCHESTRATOR_HOME`; the board is
6
6
  // `--board <slug>`, else `HERMES_KANBAN_BOARD`, else the home's current board (`kanban/current`), else `default`.
7
+ import { machineCall, closeDoors } from './doors.mjs';
8
+ import { createHash } from 'node:crypto';
9
+ import { DatabaseSync } from 'node:sqlite';
10
+ import { homeOf, parse } from './args.mjs';
11
+ import { BOARD_OPERATION_VERBS, boardHome, boardOwnerSocket, CALLER_FACTS } from '@volter/teams/board-owner';
12
+ import { current, isCallersCall, ownCall, within } from './call.mjs';
7
13
  import { taskStatusOf } from './published.mjs';
8
- import { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
14
+ import { createServer } from 'node:net';
15
+ import { chmodSync, existsSync, mkdirSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, copyFileSync, readFileSync, appendFileSync, writeFileSync } from 'node:fs';
16
+ import { notRuntime, ownHome } from './owner-files.mjs';
9
17
  import { spawnSync } from 'node:child_process';
18
+ import { holdLock } from './lock-holder.mjs';
19
+ import { takeEpoch } from './fence.mjs';
20
+ import { boardTool } from './tools.mjs';
10
21
  import { recordSlow, takeSteps } from '@volter/supercode-harness-sdk/slow-log';
11
22
  import { basename, dirname, join, resolve } from 'node:path';
12
23
  import { fileURLToPath } from 'node:url';
13
24
  import { homedir, tmpdir, userInfo } from 'node:os';
14
- import { read } from '../orchestration.mjs';
15
- import { actorOf, view, backingPaths, layOut, backingOf, boardExists, boardPath, DEFAULT_BOARD, event, json, listBoards, logsDir, now, openBoard, setActor, setPrincipal, principalOf, isCaller, tx } from './store.mjs';
25
+ import { actorOf, publishMarked, view, backingPaths, layOut, backingOf, boardExists, boardPath, DEFAULT_BOARD, event, json, listBoards, logsDir, now, openBoard, setActor, setPrincipal, principalOf, isCaller, tx } from './store.mjs';
16
26
  import { isLocalWorkspace } from './workspace.mjs';
17
27
  import { openZtrackBoard } from './ztrack.mjs';
18
28
  import * as engine from './engine.mjs';
19
- import { runtimeDoor } from './runtime-door.mjs';
20
29
 
21
30
  /** The messages on this machine the mailbox still has to deliver (`supercode message waiting`), by message id. */
22
31
  function mailWaiting() {
@@ -38,22 +47,31 @@ function mailExpiries() {
38
47
  * refused its credential, the publication it is retrying and the ones it abandoned. Null when no daemon answers or the
39
48
  * home is not registered: diagnostics then say nothing of publishing.
40
49
  */
41
- function boardPublishing(root) {
42
- const read = runtimeDoor(null, 'harness.v1.boards.list', {}, { timeout: 15_000 });
43
- if (read.code !== 0) return null;
50
+ async function boardPublishing(root) {
51
+ let listed;
52
+ try { listed = await machineCall(null, 'harness.v1.boards.list', {}, { timeoutMs: 15_000 }); } catch { return null; }
44
53
  let here; try { here = realpathSync(root); } catch { here = resolve(root); }
45
- try { const listed = JSON.parse(read.stdout); const board = listed.boards?.find((b) => b.root === here); return board ? { ...board, refused: listed.refused ?? null } : null; } catch { return null; }
54
+ const board = listed?.boards?.find((b) => b.root === here);
55
+ return board ? { ...board, refused: listed.refused ?? null } : null;
46
56
  }
47
- import { requestAdoption, alertManagers, boardAddress, boardLabel, boardSettings, boardCaller, callerSession, commandEnv, reconcileCallerLaunch, closeRequested, lanesOf, localMachine, observeFleet, paused, plan, startCard, stopDispatching, supercodeBin, tick, deliver, startDelivery } from './dispatch.mjs';
57
+ import { dispatchingOf, knowLocalMachine, requestAdoption, alertManagers, boardAddress, boardLabel, boardSettings, boardCaller, callerSession, commandEnv, reconcileCallerLaunch, closeRequested, lanesOf, localMachine, observeFleet, paused, plan, startCard, stopDispatching, supercodeBin, tick, deliver, startDelivery } from './dispatch.mjs';
48
58
  import { watchBoard } from './wake.mjs';
49
59
  import { streamBoardPublications } from './event-stream.mjs';
50
- import { completionOwnerOf, isLatestReviewer, loadWorkflow, requestSessionClose, scopeOf, statusOf, useWorkflow, workflowEntry, workflowFor } from './workflow.mjs';
60
+ import { completionOwnerOf, followHome, isLatestReviewer, loadWorkflow, readHome, requestSessionClose, scopeOf, statusOf, useWorkflow, workflowEntry, workflowFor } from './workflow.mjs';
51
61
  import { evaluate } from './expr.mjs';
52
62
  import { agentDoor,agentScope,activeAgentAssignments,reassignAgent,agentUsage,localAgentRecord,agentRunForSession } from '../agents.mjs';
53
63
 
54
64
  // Text a verb stores, read from --file/--body-file PATH (`-` for stdin) when given: a shell never expands what it
55
65
  // never sees, as it does `$(...)` and `$VAR` in an argument.
56
- const textFrom = (file, inline) => file === undefined ? inline : readFileSync(file === '-' ? 0 : String(file), 'utf8');
66
+ const textFrom = (file, inline) => {
67
+ if (file === undefined) return inline;
68
+ if (file === '-') { const sent = current().stdin; if (sent === null) throw new Error('- names standard input, and the caller sent none'); return sent; }
69
+ return readFileSync(callerPath(String(file)), 'utf8');
70
+ };
71
+
72
+ /** A path a caller names, read against its working directory; never the owner's own runtime files (owner-files.mjs). */
73
+ const callerPath = (named) => notRuntime(resolve(current().cwd, named));
74
+ const SAFE_SESSION = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/;
57
75
  const HELP = `supercode workflow <verb> [--root <home>] [--board <slug>] [--json]
58
76
 
59
77
  The board's verbs, with their flags:
@@ -80,7 +98,7 @@ The board's verbs, with their flags:
80
98
  diagnostics [--task] [--severity] stats assignees log <id> [--tail N] tail <id> watch [--assignee] [--kinds]
81
99
  notify-subscribe <id> --platform P --chat-id C [--thread-id] notify-list [<id>] notify-unsubscribe <id> --platform P --chat-id C
82
100
  capacity (each cap's count against its max, and why each card waits; docs/adr/0009-board-capacity.md)
83
- dispatch [--dry-run] [--max N] [--failure-limit N] daemon [--interval S] [--max N] [--failure-limit N] [--pidfile F] [--verbose] gc [--event-retention-days] [--log-retention-days] repair
101
+ dispatching [on|off] [--reason R] dispatch [--dry-run] [--max N] [--failure-limit N] daemon [--interval S] [--max N] [--failure-limit N] [--pidfile F] [--verbose] gc [--event-retention-days] [--log-retention-days] repair
84
102
  pause [reason] · resume (the board's emergency stop: new starts stop, running sessions go on)
85
103
 
86
104
  And:
@@ -119,38 +137,7 @@ And:
119
137
  workflow declares who may block, pause or finish a card; request-state mails its manager without changing state.
120
138
  `;
121
139
 
122
- const MULTI = new Set(['parent', 'skill', 'worker', 'child', 'ids', 'rm', 'tag', 'pr', 'pr-head', 'reviewed-sha', 'finding']);
123
- const FLAGS = new Set(['json', 'triage', 'no-notify', 'no-start', 'all', 'force', 'help', 'mine', 'archived', 'reclaim', 'dry-run',
124
- 'once', 'goal', 'no-goal', 'off', 'switch', 'delete', 'no-attachments', 'include-logs', 'verbose', 'commit']);
125
- // flags that take every following word up to the next flag (Hermes's nargs='+')
126
- const GREEDY = new Set(['ids', 'rm']);
127
-
128
- function parse(argv) {
129
- const out = { _: [] };
130
- for (let i = 0; i < argv.length; i += 1) {
131
- const a = argv[i];
132
- if (!a.startsWith('--') || a === '--') { out._.push(a); continue; }
133
- const [key, inline] = a.slice(2).split(/=(.*)/s);
134
- if (FLAGS.has(key)) { out[key] = true; continue; }
135
- if (GREEDY.has(key)) {
136
- const values = inline !== undefined ? [inline] : [];
137
- while (i + 1 < argv.length && !argv[i + 1].startsWith('--')) values.push(argv[++i]);
138
- (out[key] ??= []).push(...values);
139
- continue;
140
- }
141
- const value = inline ?? argv[++i];
142
- if (value === undefined) throw new Error(`--${key} needs a value`);
143
- if (MULTI.has(key)) (out[key] ??= []).push(value);
144
- else out[key] = value;
145
- }
146
- return out;
147
- }
148
140
 
149
- function homeOf(args) {
150
- const root = args.root ?? process.env.SUPERCODE_ORCHESTRATOR_HOME ?? process.env.HERMES_HOME;
151
- if (!root) throw new Error('which home? --root <home> (or SUPERCODE_ORCHESTRATOR_HOME)');
152
- return resolve(root);
153
- }
154
141
 
155
142
  const SLUG = /^[a-z0-9][a-z0-9_-]{0,63}$/;
156
143
 
@@ -164,7 +151,7 @@ const currentFile = (root) => join(root, 'kanban', 'current');
164
151
 
165
152
  /** The board a verb acts on, as Hermes resolves it. */
166
153
  function boardOf(root, args) {
167
- for (const want of [args.board, process.env.HERMES_KANBAN_BOARD, existsSync(currentFile(root)) ? readFileSync(currentFile(root), 'utf8').trim() : null]) {
154
+ for (const want of [args.board, current().facts.HERMES_KANBAN_BOARD, existsSync(currentFile(root)) ? readFileSync(currentFile(root), 'utf8').trim() : null]) {
168
155
  if (!want) continue;
169
156
  const slug = normalSlug(want);
170
157
  if (slug === DEFAULT_BOARD || boardExists(root, slug) || existsSync(metaPath(root, slug))) return slug;
@@ -224,9 +211,10 @@ function counts(root, slug) {
224
211
 
225
212
  /** The home's lanes (profiles' workers) and board settings (the root config's `kanban:` block), as the IR reads them. */
226
213
  async function laneState(root) {
227
- const orchestration = await read(root);
214
+ // the home as the owner holds it (workflow.mjs readHome), read again only after a file it is decoded from changed
215
+ const orchestration = await readHome(root);
228
216
  // the board's settings are its workflow's params (the instance's, under the home's own `kanban:` keys)
229
- const entry = useWorkflow(root, workflowEntry(orchestration));
217
+ const entry = useWorkflow(root, Object.assign(workflowEntry(orchestration), { orchestration }));
230
218
  const settings = boardSettings(entry.params);
231
219
  const tick = Number(evaluate(entry.workflow.dispatch.tick, { params: entry.params }));
232
220
  if (tick > 0) settings.interval = tick;
@@ -237,43 +225,43 @@ async function laneState(root) {
237
225
  // The calling session's own address, as its actor is named: no fleet scan (a `message list` costs seconds).
238
226
  // the address the board recorded for the calling session when it launched it (a verb mailed from another machine
239
227
  // runs on the board's machine as its sender, whose address names the sender's machine)
240
- let recordedCaller = null;
241
228
  function callerAddress() {
242
229
  const id = callerSession();
243
230
  if (!id) return null;
244
- return recordedCaller ?? boardCaller()?.address ?? `sc:${localMachine()}:${id === process.env.CODEX_THREAD_ID ? 'codex' : 'claude-code'}:${id}`;
231
+ return current().recorded ?? boardCaller()?.address ?? `sc:${localMachine()}:${id === process.env.CODEX_THREAD_ID ? 'codex' : 'claude-code'}:${id}`;
245
232
  }
246
233
 
247
- const callerName = () => process.env.HERMES_PROFILE || process.env.USER || 'user';
234
+ const callerName = () => current().facts.HERMES_PROFILE || current().facts.USER || 'user';
248
235
 
249
236
  /**
250
237
  * C4: who the caller is as a principal, as a card's `created_by` records it: its agent (a relaunch keeps it), the
251
238
  * automation running it, its own plain session, or the person its Teams login names. Never the OS user; null when
252
239
  * nothing names one.
253
240
  */
254
- function callerPrincipal(root, sid, { teams = false } = {}) {
255
- if (process.env.SUPERCODE_AGENT_ID) return `agent:${process.env.SUPERCODE_AGENT_ID}`;
256
- if (process.env.SUPERCODE_AUTOMATION) return `automation:${process.env.SUPERCODE_AUTOMATION}`;
241
+ async function callerPrincipal(root, sid, { teams = false } = {}) {
242
+ const facts = current().facts;
243
+ if (facts.SUPERCODE_AGENT_ID) return `agent:${facts.SUPERCODE_AGENT_ID}`;
244
+ if (facts.SUPERCODE_AUTOMATION) return `automation:${facts.SUPERCODE_AUTOMATION}`;
257
245
  if (sid) {
258
246
  const harness = boardCaller()?.harness ?? (sid === process.env.CODEX_THREAD_ID ? 'codex' : 'claude-code');
259
247
  const owner = agentRunForSession(root, [`${harness}:${sid}`]);
260
248
  if (owner) return `agent:${owner.agent_id}`;
261
249
  // a session an agent declared elsewhere owns (a manager's main session) is that agent's, as Teams records it
262
250
  let declared = null;
263
- try { declared = agentDoor('session', [`${harness}:${sid}`], { context: process.env.SUPERCODE_CONTEXT }).data?.agent?.id ?? null; } catch { /* no Teams: the session */ }
251
+ try { declared = (await agentDoor('session', [`${harness}:${sid}`], { context: current().facts.SUPERCODE_CONTEXT })).data?.agent?.id ?? null; } catch { /* no Teams: the session */ }
264
252
  return declared ? `agent:${declared}` : `session:${callerAddress()}`;
265
253
  }
266
254
  if (!teams) return null;
267
- const scope = agentScope(process.env.SUPERCODE_CONTEXT);
255
+ const scope = agentScope(current().facts.SUPERCODE_CONTEXT);
268
256
  return !scope.offline && scope.principal ? `person:${scope.principal}` : null;
269
257
  }
270
258
 
271
259
  /** C4: a requester named by an outside id; a proven link makes them the card's sponsor, anything less is recorded unverified. */
272
- function requestedSponsor(args) {
260
+ async function requestedSponsor(args) {
273
261
  if (args.sponsor) return { sponsor: args.sponsor, sponsorEvidence: args.request ? { request: args.request, by: principalOf() } : null };
274
262
  if (!args['requested-by']) return {};
275
263
  let resolved = null;
276
- try { resolved = agentDoor('resolveIdentity', [args['requested-by']], { context: process.env.SUPERCODE_CONTEXT }).data; } catch { /* no Teams: unverified */ }
264
+ try { resolved = (await agentDoor('resolveIdentity', [args['requested-by']], { context: current().facts.SUPERCODE_CONTEXT })).data; } catch { /* no Teams: unverified */ }
277
265
  if (resolved?.classification === 'proven' && resolved.principal?.kind === 'user') {
278
266
  const link = resolved.links?.find((l) => l.classification === 'proven');
279
267
  return { sponsor: resolved.principal.id, sponsorEvidence: { observation: args['requested-by'], link: link?.id ?? null, proof: link?.proof?.kind ?? null, request: args.request ?? null, by: principalOf() } };
@@ -283,7 +271,7 @@ function requestedSponsor(args) {
283
271
 
284
272
  const tagsOf = (card) => { const tags = Array.isArray(card.tags) ? card.tags : json(card.tags, null); return Array.isArray(tags) ? tags : card.tenant ? [card.tenant] : []; };
285
273
 
286
- const say = (args, human, value) => { process.stdout.write(args.json ? `${JSON.stringify(value, null, 2)}\n` : `${human}\n`); };
274
+ const say = (args, human, value) => { current().out(args.json ? `${JSON.stringify(value, null, 2)}\n` : `${human}\n`); };
287
275
 
288
276
  // Hermes's own output lines, so a reader of `hermes kanban list` reads this one
289
277
  const ICONS = { todo: '◻', ready: '▶', running: '●', scheduled: '⏱', blocked: '⊘', done: '✓', archived: '—' };
@@ -357,27 +345,14 @@ function releaseBehind() {
357
345
  if (older < 0) return { key: `${running}>${latest}`, text: `orchestrator ${running} is behind the published ${latest}; action required: ${fix}` };
358
346
  return null;
359
347
  }
360
- function sayReleaseBehind() {
361
- const behind = releaseBehind();
362
- if (behind) process.stderr.write(`board: error: ${behind.text}\n`);
363
- }
364
348
 
365
- /**
366
- * The verbs a session on another machine may not call on a board (through its machine's board door): running the
367
- * board itself (its dispatcher, its init, its repair and housekeeping, adoption) and verbs that hold the call open (a
368
- * watch, a tail, an attach, the event stream). They are run on the board's machine. A verb the door started reads this
369
- * one list and refuses before it does anything.
370
- */
371
- export const NOT_FROM_ANOTHER_MACHINE = new Set(['serve', 'daemon', 'dispatch', 'init', 'adopt', 'boards', 'gc', 'repair', 'migrate', 'watch', 'tail', 'attach', 'events']);
372
-
373
- export async function runWorkflow(argv) {
349
+ /** One verb in full, in the call it runs in (call.mjs): a caller's in the owner, or the process's own here. */
350
+ export async function runVerb(argv) {
351
+ await knowLocalMachine();
374
352
  const args = parse(argv);
375
353
  const [verb, ...rest] = args._;
376
- if (!verb || verb === 'help' || args.help) { process.stdout.write(HELP); return 0; }
354
+ if (!verb || verb === 'help' || args.help) { current().out(HELP); return 0; }
377
355
  const root = homeOf(args);
378
- if (process.env.SUPERCODE_BOARD_CALL === '1' && NOT_FROM_ANOTHER_MACHINE.has(verb)) throw new Error(`${verb} is run on the board's own machine, not called from another`);
379
- // the dispatcher's own verbs run every round; its serve says it once when it starts
380
- if (!['dispatch', 'events'].includes(verb)) sayReleaseBehind();
381
356
  if (verb === 'events') return streamBoardPublications({root,after:args.after,once:args.once});
382
357
  // C4: one task store per Org. A named board without a store of its own is a saved view over it: its tag selects
383
358
  // the cards it shows, and a card filed through it carries that tag.
@@ -396,11 +371,13 @@ export async function runWorkflow(argv) {
396
371
  await loadWorkflow(root).catch((error) => { if (verb !== 'init' && verb !== 'boards') throw error; });
397
372
  const sid = listing && !args.mine ? null : callerSession();
398
373
  // what this command records is the calling session's act (its address, as its subscriptions name it)
399
- recordedCaller = sid && boardExists(root, slug) ? withBoard((b) => b.db.prepare("SELECT json_extract(metadata, '$.supercode.address') AS address FROM task_runs WHERE json_extract(metadata, '$.supercode.session_id') = ? ORDER BY id DESC LIMIT 1").get(sid)?.address) ?? null : null;
374
+ current().recorded = sid && boardExists(root, slug) ? withBoard((b) => b.db.prepare("SELECT json_extract(metadata, '$.supercode.address') AS address FROM task_runs WHERE json_extract(metadata, '$.supercode.session_id') = ? ORDER BY id DESC LIMIT 1").get(sid)?.address) ?? null : null;
400
375
  if (sid) setActor(callerAddress());
401
- setPrincipal(callerPrincipal(root, sid, { teams: !sid && !READS.has(verb) }));
376
+ setPrincipal(await callerPrincipal(root, sid, { teams: !sid && !READS.has(verb) }));
402
377
  if (sid && !READS.has(verb) && boardExists(root, slug)) {
403
- withBoard((b) => { reconcileCallerLaunch(b, sid); const own = ownCard(b.db, sid); if (own) authorize(b.db, verb, rest, args, own); });
378
+ // binding the caller's launch is a write: only the owner, answering the caller's own verb, makes it (a local read
379
+ // never writes)
380
+ withBoard((b) => { if (isCallersCall()) reconcileCallerLaunch(b, sid); const own = ownCard(b.db, sid); if (own) authorize(b.db, verb, rest, args, own); });
404
381
  }
405
382
  // the board's workflow (the IR's): what each verb does, and who may send it, is its
406
383
  // every verb reads the board through its workflow (a list settles cards too), so every verb loads it
@@ -423,7 +400,11 @@ export async function runWorkflow(argv) {
423
400
  const source = openBoard(root, slug);
424
401
  try {
425
402
  const migrated = openZtrackBoard(preparing, { create: true, document: args.document, seed: { db: source.db, mapping }, init: layOut, root, slug });
426
- migrated.close();
403
+ // the board's own state goes with it (its dispatching switch, its hooks cursor); its epoch is this process's
404
+ // (the migration holds the home's lock), which the target's first transaction stores
405
+ const state = (() => { try { return source.db.prepare("SELECT key, value FROM supercode_board_state WHERE key != 'owner_epoch'").all(); } catch { return []; } })();
406
+ try { migrated.tx((db) => { db.exec('CREATE TABLE IF NOT EXISTS supercode_board_state (key TEXT PRIMARY KEY, value TEXT)'); for (const row of state) db.prepare('INSERT INTO supercode_board_state (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value').run(row.key, row.value); }); }
407
+ finally { migrated.close(); }
427
408
  } finally { source.close(); }
428
409
  renameSync(`${preparing}.board.db`, `${paths.ztrack}.board.db`);
429
410
  renameSync(preparing, paths.ztrack);
@@ -481,6 +462,8 @@ export async function runWorkflow(argv) {
481
462
  let workspace = args.workspace;
482
463
  if ((workspace === 'dir' || workspace === 'worktree') && meta.default_workdir) workspace = `${workspace}:${meta.default_workdir}`;
483
464
  // --goal / --no-goal are the create's words; without either, the workflow's initial decides
465
+ // a person filing for themselves sponsors it; an agent filing a person's request names them (proven) or records them unverified
466
+ const sponsorship = args.sponsor || args['requested-by'] ? await requestedSponsor(args) : principalOf()?.startsWith('person:') ? { sponsor: principalOf().slice('person:'.length) } : {};
484
467
  const created = withBoard((b) => engine.createCard(b, {
485
468
  title: rest.join(' '), body: textFrom(args['body-file'], args.body), assignee: args.assignee === 'none' ? null : args.assignee, machine: args.machine,
486
469
  workspace, branch: args.branch, parents: args.parent, priority: args.priority, tenant: args.tenant,
@@ -490,8 +473,7 @@ export async function runWorkflow(argv) {
490
473
  every: args.every ?? null, sessionId: callerSession(), subtaskOf: args['subtask-of'] ?? null, initialStatus: args['initial-status'] ?? null,
491
474
  project: args.project ?? meta.project_id ?? null, goal: args.goal ? { maxTurns: args['goal-max-turns'] ? Number(args['goal-max-turns']) : null } : args['no-goal'] ? false : null,
492
475
  tags: [...(viewTag ? [viewTag] : []), ...(args.tag ?? [])],
493
- // a person filing for themselves sponsors it; an agent filing a person's request names them (proven) or records them unverified
494
- ...(args.sponsor || args['requested-by'] ? requestedSponsor(args) : principalOf()?.startsWith('person:') ? { sponsor: principalOf().slice('person:'.length) } : {}),
476
+ ...sponsorship,
495
477
  }), true);
496
478
  await withBoardAsync(b=>engine.runEffects(b,created.effects??[]));
497
479
  let answer = { id: created.id, existing: created.existing };
@@ -566,8 +548,8 @@ export async function runWorkflow(argv) {
566
548
  return 0;
567
549
  }
568
550
  const others = boardsOf(root, { includeArchived: false }).length - 1;
569
- if (others > 0) process.stdout.write(`Board: ${slug} (${others} other board${others === 1 ? '' : 's'} — \`supercode workflow boards list\`)\n\n`);
570
- process.stdout.write(cards.length ? `${cards.map(line).join('\n')}\n` : '(no matching tasks)\n');
551
+ if (others > 0) current().out(`Board: ${slug} (${others} other board${others === 1 ? '' : 's'} — \`supercode workflow boards list\`)\n\n`);
552
+ current().out(cards.length ? `${cards.map(line).join('\n')}\n` : '(no matching tasks)\n');
571
553
  return 0;
572
554
  }
573
555
 
@@ -640,19 +622,19 @@ export async function runWorkflow(argv) {
640
622
  }
641
623
  }
642
624
  if (card.subscriptions.length) { out.push('', 'Notices:'); for (const s of card.subscriptions) out.push(` → ${s.chat_id} (${s.platform === 'supercode' ? s.delivery_metadata?.role ?? 'creator' : s.platform})`); }
643
- process.stdout.write(`${out.join('\n')}\n`);
625
+ current().out(`${out.join('\n')}\n`);
644
626
  return 0;
645
627
  }
646
628
 
647
629
  // G3: usage from each run's own harness records, summed on agents and on their sponsors
648
630
  case 'usage': {
649
- const byAgent = agentUsage(root), context = process.env.SUPERCODE_CONTEXT;
650
- const sponsorOf = (agent) => { try { return agentDoor('get', [agent], { context }).data.sponsor ?? null; } catch { for (const row of activeAgentAssignments(root).filter((a) => a.agent_id === agent)) { const local = localAgentRecord(root, row.org, agent); if (local) return local.identity.sponsor ?? null; } return null; } };
631
+ const byAgent = agentUsage(root), context = current().facts.SUPERCODE_CONTEXT;
632
+ const sponsorOf = async (agent) => { try { return (await agentDoor('get', [agent], { context })).data.sponsor ?? null; } catch { for (const row of activeAgentAssignments(root).filter((a) => a.agent_id === agent)) { const local = localAgentRecord(root, row.org, agent); if (local) return local.identity.sponsor ?? null; } return null; } };
651
633
  const sum = (records) => { const out = {}; for (const u of records) { const m = out[u.model] ??= { input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_write_tokens: 0, cost: 0 }; m.input_tokens += u.input_tokens ?? 0; m.output_tokens += u.output_tokens ?? 0; m.cache_read_tokens += u.cache_read_tokens ?? 0; m.cache_write_tokens += u.cache_write_tokens ?? 0; m.cost += u.cost?.amount ?? 0; if (u.cost?.amount != null) { m.cost_recorded = true; m.currency = u.cost.currency ?? m.currency ?? null; } else m.cost_unrecorded = true; } return out; };
652
634
  // a harness that keeps no price records tokens only: its cost reads as unrecorded, never as zero
653
635
  const line = (byModel) => Object.entries(byModel).map(([m, t]) => `${m} in ${t.input_tokens} out ${t.output_tokens} cache read ${t.cache_read_tokens} write ${t.cache_write_tokens} cost ${t.cost_recorded ? `${t.cost.toFixed(4)} ${t.currency ?? ''}${t.cost_unrecorded ? ' (some sessions unrecorded)' : ''}`.trim() : 'unrecorded'}`).join('; ');
654
636
  const agents = [...byAgent.keys()].filter((a) => !args.agent || a === args.agent);
655
- const rows = agents.map((agent) => ({ agent, sponsor: args.sponsor || !args.agent ? sponsorOf(agent) : undefined, sessions: new Set(byAgent.get(agent).map((u) => u.session)).size, by_model: sum(byAgent.get(agent)) }))
637
+ const rows = (await Promise.all(agents.map(async (agent) => ({ agent, sponsor: args.sponsor || !args.agent ? await sponsorOf(agent) : undefined, sessions: new Set(byAgent.get(agent).map((u) => u.session)).size, by_model: sum(byAgent.get(agent)) }))))
656
638
  .filter((row) => !args.sponsor || row.sponsor === args.sponsor);
657
639
  const total = sum(rows.flatMap((row) => byAgent.get(row.agent)));
658
640
  const sponsors = [...new Set(rows.map((row) => row.sponsor).filter(Boolean))].map((sponsor) => ({ sponsor, agents: rows.filter((row) => row.sponsor === sponsor).length, by_model: sum(rows.filter((row) => row.sponsor === sponsor).flatMap((row) => byAgent.get(row.agent))) }));
@@ -661,13 +643,13 @@ export async function runWorkflow(argv) {
661
643
  return 0;
662
644
  }
663
645
 
664
- case 'context': need(1, 'a card id'); process.stdout.write(`${withBoard((b) => view(b, (db) => engine.cardContext(db, id)))}\n`); return 0;
646
+ case 'context': need(1, 'a card id'); current().out(`${withBoard((b) => view(b, (db) => engine.cardContext(db, id)))}\n`); return 0;
665
647
 
666
648
  case 'runs': {
667
649
  need(1, 'a card id');
668
650
  const got = withBoard((b) => engine.runs(b.db, id, { stateType: args['state-type'] ?? null, stateName: args['state-name'] ?? null }));
669
651
  if (args.json) { say(args, '', got); return 0; }
670
- process.stdout.write(got.length ? `${got.map((r) => `#${r.id} ${(r.outcome || r.status).padEnd(14)} @${r.profile || '-'} ${ts(r.started_at)}${r.ended_at ? ` → ${ts(r.ended_at)}` : ' (active)'}${r.metadata?.supercode?.address ? ` ${r.metadata.supercode.address}` : ''}${r.metadata?.supercode?.counted === false ? ` not counted: profile differs (${r.metadata.supercode.agent_profile_mismatch.differences.map((d) => d.field).join(', ')})` : ''}${r.summary ? `\n → ${r.summary.split('\n')[0].slice(0, 160)}` : ''}`).join('\n')}\n` : `(no runs for ${id})\n`);
652
+ current().out(got.length ? `${got.map((r) => `#${r.id} ${(r.outcome || r.status).padEnd(14)} @${r.profile || '-'} ${ts(r.started_at)}${r.ended_at ? ` → ${ts(r.ended_at)}` : ' (active)'}${r.metadata?.supercode?.address ? ` ${r.metadata.supercode.address}` : ''}${r.metadata?.supercode?.counted === false ? ` not counted: profile differs (${r.metadata.supercode.agent_profile_mismatch.differences.map((d) => d.field).join(', ')})` : ''}${r.summary ? `\n → ${r.summary.split('\n')[0].slice(0, 160)}` : ''}`).join('\n')}\n` : `(no runs for ${id})\n`);
671
653
  return 0;
672
654
  }
673
655
 
@@ -685,14 +667,14 @@ export async function runWorkflow(argv) {
685
667
  case 'assign': case 'reassign': if (args.agent) {
686
668
  // C7: a card role's assignment moves to another agent; the sessions it ran keep their identity
687
669
  need(1, 'a card id');
688
- const context=process.env.SUPERCODE_CONTEXT,scope=agentDoor('scope',[],{context});
670
+ const context=current().facts.SUPERCODE_CONTEXT,scope=agentScope(context);
689
671
  const task=withBoard(b=>engine.getTask(b.db,id));
690
672
  if(!task)throw new Error(`no card ${id}`);
691
673
  withBoard(b=>{if(!rolesFor(b.db,id,sid,workflowFor(root).params).includes('manager'))throw new Error('Only the manager moves a card assignment between agents.');});
692
674
  const role=args.role??'implementer';
693
675
  const assignment=activeAgentAssignments(root).find(item=>item.org===scope.org&&item.kind==='card'&&item.target===id&&item.role===role);
694
676
  if(!assignment)throw new Error('declare the card role before reassigning its assignment');
695
- const changed=reassignAgent(root,scope.org,assignment.id,args.agent,{context});
677
+ const changed=await reassignAgent(root,scope.org,assignment.id,args.agent,{context});
696
678
  withBoard(b=>tx(b,db=>event(db,id,'agent_assignment_reassigned',{assignment:assignment.id,role,from:assignment.agent_id,to:changed.agent_id})));
697
679
  say(args,`Reassigned ${id}/${role} to agent ${changed.agent_id}; sessions keep their identity`,{ok:true,assignment:changed});return 0;
698
680
  } need(2, 'a card id and a profile (or none)'); await withBoardAsync((b) => engine.set(b, id, 'assignee', rest[1] === 'none' ? null : rest[1])); say(args, `Assigned ${id} to ${rest[1] === 'none' ? '(nobody)' : rest[1]}`, { ok: true }); return 0;
@@ -753,7 +735,7 @@ export async function runWorkflow(argv) {
753
735
  }
754
736
  case 'attach': {
755
737
  need(2, 'a card id and a file');
756
- const source = resolve(rest[1]);
738
+ const source = callerPath(rest[1]);
757
739
  if (!existsSync(source) || !statSync(source).isFile()) throw new Error(`no file ${rest[1]}`);
758
740
  const name = basename(args.name ?? source).replace(/[^A-Za-z0-9._ -]/g, '_') || 'attachment';
759
741
  const dir = join(slug === DEFAULT_BOARD ? join(root, 'kanban', 'attachments') : join(boardDir(root, slug), 'attachments'), id);
@@ -768,7 +750,7 @@ export async function runWorkflow(argv) {
768
750
  need(1, 'a card id');
769
751
  const got = withBoard((b) => engine.attachments(b.db, id));
770
752
  if (args.json) { say(args, '', got); return 0; }
771
- process.stdout.write(got.length ? `${got.map((a) => `#${a.id} ${a.filename} ${a.size} bytes ${ts(a.created_at)} ${a.stored_path}`).join('\n')}\n` : `(no attachments on ${id})\n`);
753
+ current().out(got.length ? `${got.map((a) => `#${a.id} ${a.filename} ${a.size} bytes ${ts(a.created_at)} ${a.stored_path}`).join('\n')}\n` : `(no attachments on ${id})\n`);
772
754
  return 0;
773
755
  }
774
756
  case 'attach-rm': {
@@ -892,7 +874,7 @@ export async function runWorkflow(argv) {
892
874
  case 'diagnostics': case 'diag': {
893
875
  const { settings } = await laneState(root).catch(() => ({ settings: boardSettings({}) }));
894
876
  const fleet = observeFleet(root);
895
- const publishing = boardPublishing(root);
877
+ const publishing = await boardPublishing(root);
896
878
  const expiredMail = mailExpiries();
897
879
  const waitingMail = mailWaiting();
898
880
  const order = ['warning', 'error', 'critical'];
@@ -903,14 +885,14 @@ export async function runWorkflow(argv) {
903
885
  .filter((d) => !args.severity || order.indexOf(d.severity) >= order.indexOf(args.severity));
904
886
  });
905
887
  if (args.json) { say(args, '', found); return 0; }
906
- process.stdout.write(found.length ? `${found.map((d) => `${d.severity.padEnd(8)} ${d.task} ${d.kind}: ${d.title}${d.actions[0] ? `\n → ${d.actions[0]}` : ''}`).join('\n')}\n` : '(no active diagnostics)\n');
888
+ current().out(found.length ? `${found.map((d) => `${d.severity.padEnd(8)} ${d.task} ${d.kind}: ${d.title}${d.actions[0] ? `\n → ${d.actions[0]}` : ''}`).join('\n')}\n` : '(no active diagnostics)\n');
907
889
  return 0;
908
890
  }
909
891
  case 'stats': {
910
892
  const s = withBoard((b) => engine.stats(b.db));
911
893
  if (args.json) { say(args, '', s); return 0; }
912
894
  const fmt = (o) => Object.entries(o).sort().map(([k, v]) => `${k}=${v}`).join(', ') || '(none)';
913
- process.stdout.write(`By status: ${fmt(s.by_status)}\nBy assignee:\n${Object.entries(s.by_assignee).map(([a, c]) => ` ${a.padEnd(20)} ${fmt(c)}`).join('\n') || ' (none)'}\nBy machine:\n${Object.entries(s.by_machine).map(([m, c]) => ` ${m.padEnd(20)} ${fmt(c)}`).join('\n') || ' (none)'}\nOldest ready: ${s.oldest_ready_age_seconds === null ? '-' : `${Math.floor(s.oldest_ready_age_seconds / 60)}m`}\n`);
895
+ current().out(`By status: ${fmt(s.by_status)}\nBy assignee:\n${Object.entries(s.by_assignee).map(([a, c]) => ` ${a.padEnd(20)} ${fmt(c)}`).join('\n') || ' (none)'}\nBy machine:\n${Object.entries(s.by_machine).map(([m, c]) => ` ${m.padEnd(20)} ${fmt(c)}`).join('\n') || ' (none)'}\nOldest ready: ${s.oldest_ready_age_seconds === null ? '-' : `${Math.floor(s.oldest_ready_age_seconds / 60)}m`}\n`);
914
896
  return 0;
915
897
  }
916
898
  case 'assignees': {
@@ -918,7 +900,7 @@ export async function runWorkflow(argv) {
918
900
  const c = withBoard((b) => engine.stats(b.db).by_assignee);
919
901
  const data = [...new Set([...profiles, ...Object.keys(c)])].sort().map((name) => ({ name, on_disk: profiles.includes(name), surface: lanes[name]?.surface ?? null, counts: c[name] ?? {} }));
920
902
  if (args.json) { say(args, '', data); return 0; }
921
- process.stdout.write(`${'NAME'.padEnd(20)} ${'ON DISK'.padEnd(8)} ${'LANE'.padEnd(9)} COUNTS\n${data.map((e) => `${e.name.padEnd(20)} ${(e.on_disk ? 'yes' : 'no').padEnd(8)} ${(e.surface ?? '-').padEnd(9)} ${Object.entries(e.counts).sort().map(([k, v]) => `${k}=${v}`).join(', ') || '(idle)'}`).join('\n')}\n`);
903
+ current().out(`${'NAME'.padEnd(20)} ${'ON DISK'.padEnd(8)} ${'LANE'.padEnd(9)} COUNTS\n${data.map((e) => `${e.name.padEnd(20)} ${(e.on_disk ? 'yes' : 'no').padEnd(8)} ${(e.surface ?? '-').padEnd(9)} ${Object.entries(e.counts).sort().map(([k, v]) => `${k}=${v}`).join(', ') || '(idle)'}`).join('\n')}\n`);
922
904
  return 0;
923
905
  }
924
906
  case 'log': {
@@ -927,12 +909,12 @@ export async function runWorkflow(argv) {
927
909
  if (!existsSync(file)) {
928
910
  // a pane session keeps no log file: its transcript is the harness's own, named by the run
929
911
  const s = withBoard((b) => view(b, (db) => engine.lastSession(db, id)));
930
- process.stdout.write(s ? `(${id} runs as session ${s.address}; its transcript is that session's: \`supercode sessions show ${s.session_id}\`)\n` : `(no log for ${id})\n`);
912
+ current().out(s ? `(${id} runs as session ${s.address}; its transcript is that session's: \`supercode sessions show ${s.session_id}\`)\n` : `(no log for ${id})\n`);
931
913
  return 0;
932
914
  }
933
915
  const text = readFileSync(file, 'utf8');
934
916
  const tail = args.tail ? Number(args.tail) : null;
935
- process.stdout.write(tail ? text.slice(-tail) : text);
917
+ current().out(tail ? text.slice(-tail) : text);
936
918
  return 0;
937
919
  }
938
920
  case 'tail': case 'watch': {
@@ -951,7 +933,7 @@ export async function runWorkflow(argv) {
951
933
  if (kinds && !kinds.has(e.kind)) continue;
952
934
  if (args.assignee && e.assignee !== args.assignee) continue;
953
935
  if (args.tenant && e.tenant !== args.tenant) continue;
954
- process.stdout.write(`[${ts(e.created_at)}] ${verb === 'watch' ? `${e.task_id} ` : ''}${e.kind}${e.payload ? ` ${e.payload}` : ''}\n`);
936
+ current().out(`[${ts(e.created_at)}] ${verb === 'watch' ? `${e.task_id} ` : ''}${e.kind}${e.payload ? ` ${e.payload}` : ''}\n`);
955
937
  }
956
938
  await new Promise((done) => setTimeout(done, interval));
957
939
  }
@@ -987,7 +969,7 @@ export async function runWorkflow(argv) {
987
969
  case 'notify-list': {
988
970
  const subs = withBoard((b) => engine.subscriptions(b.db, id ?? null));
989
971
  if (args.json) { say(args, '', subs); return 0; }
990
- process.stdout.write(subs.length ? `${subs.map((s) => `${s.task_id} ${s.platform}:${s.chat_id}${s.thread_id ? `#${s.thread_id}` : ''} ${s.platform === 'supercode' ? s.delivery_metadata?.role ?? 'creator' : s.delivery_mode} cursor=${s.last_event_id}`).join('\n')}\n` : '(no subscriptions)\n');
972
+ current().out(subs.length ? `${subs.map((s) => `${s.task_id} ${s.platform}:${s.chat_id}${s.thread_id ? `#${s.thread_id}` : ''} ${s.platform === 'supercode' ? s.delivery_metadata?.role ?? 'creator' : s.delivery_mode} cursor=${s.last_event_id}`).join('\n')}\n` : '(no subscriptions)\n');
991
973
  return 0;
992
974
  }
993
975
 
@@ -1005,7 +987,7 @@ export async function runWorkflow(argv) {
1005
987
  }
1006
988
  const reports = await tick({ root, lanes, settings, params });
1007
989
  if (args.json) say(args, '', reports);
1008
- else for (const r of reports) process.stdout.write(`${summary(r)}\n`);
990
+ else for (const r of reports) current().out(`${summary(r)}\n`);
1009
991
  return reports.some((r) => r.error) ? 1 : 0;
1010
992
  }
1011
993
  case 'capacity': {
@@ -1024,7 +1006,7 @@ export async function runWorkflow(argv) {
1024
1006
  }
1025
1007
  lines.push(out.would.length ? `would start: ${out.would.join(' ')}` : 'would start: nothing');
1026
1008
  for (const w of out.waiting) lines.push(`${w.id} ${w.why}`);
1027
- process.stdout.write(`${lines.join('\n')}\n`);
1009
+ current().out(`${lines.join('\n')}\n`);
1028
1010
  return 0;
1029
1011
  }
1030
1012
  case 'deliver': {
@@ -1032,7 +1014,7 @@ export async function runWorkflow(argv) {
1032
1014
  // starts it after each round in a process of its own; never part of a round)
1033
1015
  const reports = await deliver({ root, slugs: args.board ? [slug] : null });
1034
1016
  if (args.json) say(args, '', reports);
1035
- else for (const r of reports) process.stdout.write(`${r.board}: ${r.error ? `error: ${r.error}` : `${r.effects_waiting ?? 0} effect(s) waiting, ${r.effects_failed ?? 0} failed for good, ${r.notified.length} notified`}\n`);
1017
+ else for (const r of reports) current().out(`${r.board}: ${r.error ? `error: ${r.error}` : `${r.effects_waiting ?? 0} effect(s) waiting, ${r.effects_failed ?? 0} failed for good, ${r.notified.length} notified`}\n`);
1036
1018
  return reports.some((r) => r.error) ? 1 : 0;
1037
1019
  }
1038
1020
  case 'daemon': case 'serve': return serve(root, args);
@@ -1087,6 +1069,34 @@ export async function runWorkflow(argv) {
1087
1069
  await withBoardAsync((b) => engine.act(b, id, 'cancel', { payload: { reason: args.reason ?? rest.slice(1).join(' ') } }));
1088
1070
  say(args, `${id} cancelled`, { ok: true }); return 0;
1089
1071
  }
1072
+ case 'run-session': {
1073
+ // A headless worker records its harness session on its run (kanban-worker.mjs), through the owner like every board
1074
+ // write. Only the run the owner launched for this worker: the launch's secret (in the worker's environment alone,
1075
+ // its hash kept on the run), its card and its run are checked at the write, against the card's current, unended run.
1076
+ need(2, 'a run id and a session id');
1077
+ // the secret is the worker's own environment (SUPERCODE_RUN_LAUNCH, sent as a caller fact), never an argument
1078
+ const runId = Number(id), harness = args.harness ?? null, launch = current().facts.SUPERCODE_RUN_LAUNCH ?? '', card = typeof args.task === 'string' ? args.task : '';
1079
+ if (!Number.isSafeInteger(runId) || !SAFE_SESSION.test(rest[1]) || (harness !== null && !SAFE_SESSION.test(harness)) || !launch || !card) throw new Error('run-session takes a run id, a session id, --harness and --task, from a worker the owner launched');
1080
+ const refused = withBoard((b) => tx(b, (db) => {
1081
+ const row = db.prepare('SELECT r.task_id, r.ended_at, r.metadata, t.current_run_id FROM task_runs r JOIN tasks t ON t.id = r.task_id WHERE r.id = ?').get(runId);
1082
+ const saved = json(row?.metadata, {})?.supercode ?? {};
1083
+ if (!row || row.task_id !== card || row.ended_at != null || Number(row.current_run_id) !== runId || saved.surface !== 'headless' || !saved.launch_secret
1084
+ || saved.launch_secret !== createHash('sha256').update(launch).digest('hex') || (saved.session_id && saved.session_id !== rest[1])) return `run ${runId} is not a launch this worker holds; nothing was recorded`;
1085
+ engine.setRunSession(db, runId, { ...saved, session_id: rest[1], harness });
1086
+ return null;
1087
+ }));
1088
+ if (refused) throw new Error(refused);
1089
+ say(args, `run ${runId}: session ${rest[1]}`, { ok: true }); return 0;
1090
+ }
1091
+ case 'dispatching': {
1092
+ // the board's dispatching switch (its owner's controller): `dispatching` says it, `dispatching on|off` sets it
1093
+ if (id && !['on', 'off'].includes(id)) throw new Error('dispatching takes on or off');
1094
+ // switching it is the manager's: a caller outside any session, or a session the workflow's params.managers names
1095
+ if (id && sid && !withBoard((b) => rolesFor(b.db, '', sid, workflowFor(root).params)).includes('manager')) throw new Error(`switching dispatching is the manager's act; ${callerAddress()} is not a manager of this board (params.managers)`);
1096
+ const state = id ? setDispatching(root, slug, id === 'on', args.reason ?? rest.slice(1).join(' ')) : dispatchingOf(root, slug);
1097
+ say(args, `board ${slug}: dispatching ${state.on ? 'on' : 'off'}${state.at ? ` since ${state.at}` : ''}${state.by ? ` by ${state.by}` : ''}${state.reason ? ` (${state.reason})` : ''}`, { board: slug, ...state });
1098
+ return 0;
1099
+ }
1090
1100
  case 'pause': {
1091
1101
  if (id) {
1092
1102
  await withBoardAsync((b) => engine.act(b, id, 'pause', { payload: { reason: args.reason ?? rest.slice(1).join(' ') } }));
@@ -1157,10 +1167,10 @@ async function boardsVerb(root, rest, args) {
1157
1167
  const [action = 'list', target] = rest;
1158
1168
  switch (action) {
1159
1169
  case 'list': case 'ls': {
1160
- const current = boardOf(root, {});
1161
- const boards = boardsOf(root, { includeArchived: args.all }).map((b) => ({ ...b, is_current: b.slug === current, counts: counts(root, b.slug) }));
1170
+ const selected = boardOf(root, {});
1171
+ const boards = boardsOf(root, { includeArchived: args.all }).map((b) => ({ ...b, is_current: b.slug === selected, counts: counts(root, b.slug) }));
1162
1172
  if (args.json) { say(args, '', boards); return 0; }
1163
- process.stdout.write(`${''.padEnd(2)} ${'SLUG'.padEnd(24)} ${'NAME'.padEnd(28)} COUNTS\n${boards.map((b) => `${(b.is_current ? '●' : ' ').padEnd(2)} ${b.slug.padEnd(24)} ${`${b.name}${b.archived ? ' [archived]' : ''}`.padEnd(28)} ${Object.entries(b.counts).sort().map(([k, v]) => `${k}=${v}`).join(', ') || '(empty)'}`).join('\n')}\n\nCurrent board: ${current}\n`);
1173
+ current().out(`${''.padEnd(2)} ${'SLUG'.padEnd(24)} ${'NAME'.padEnd(28)} COUNTS\n${boards.map((b) => `${(b.is_current ? '●' : ' ').padEnd(2)} ${b.slug.padEnd(24)} ${`${b.name}${b.archived ? ' [archived]' : ''}`.padEnd(28)} ${Object.entries(b.counts).sort().map(([k, v]) => `${k}=${v}`).join(', ') || '(empty)'}`).join('\n')}\n\nCurrent board: ${selected}\n`);
1164
1174
  return 0;
1165
1175
  }
1166
1176
  case 'create': case 'new': {
@@ -1215,7 +1225,7 @@ async function boardsVerb(root, rest, args) {
1215
1225
  case 'export': {
1216
1226
  const slug = normalSlug(target ?? boardOf(root, args));
1217
1227
  if (!boardExists(root, slug)) throw new Error(`board '${slug}' has no database`);
1218
- const out = resolve(args.output ?? `${slug}.tar.gz`);
1228
+ const out = callerPath(args.output ?? `${slug}.tar.gz`);
1219
1229
  const made = exportBoard(root, slug, out, { attachments: !args['no-attachments'], logs: Boolean(args['include-logs']) });
1220
1230
  say(args, `Exported board '${slug}' → ${out}\n Tasks: ${made.counts.tasks}\n Comments: ${made.counts.task_comments}\n Attachments: ${made.counts.attachment_files}`, made);
1221
1231
  return 0;
@@ -1242,6 +1252,8 @@ const TABLES = ['tasks', 'task_links', 'task_comments', 'task_events', 'task_run
1242
1252
  */
1243
1253
  function scrub(db) {
1244
1254
  db.exec('DELETE FROM kanban_notify_subs');
1255
+ // a fencing token is the home's runtime custody, never a board's portable data (fence.mjs)
1256
+ db.exec("CREATE TABLE IF NOT EXISTS supercode_board_state (key TEXT PRIMARY KEY, value TEXT); DELETE FROM supercode_board_state WHERE key = 'owner_epoch'");
1245
1257
  db.exec(`UPDATE tasks SET claim_lock = NULL, claim_expires = NULL, worker_pid = NULL, current_run_id = NULL, last_heartbeat_at = NULL,
1246
1258
  session_id = NULL, project_id = NULL, consecutive_failures = 0, last_failure_error = NULL`);
1247
1259
  db.exec("UPDATE tasks SET status = 'ready' WHERE status = 'running'");
@@ -1318,6 +1330,10 @@ function importBoard(root, archive, as) {
1318
1330
  const name = staged.name || manifest.board_name || slug;
1319
1331
  writeMeta(root, slug, { name, description: staged.description ?? '', icon: staged.icon ?? '', color: staged.color ?? '', archived: false });
1320
1332
  const warnings = [];
1333
+ // the landed store keeps no other home's epoch: it is dropped before the board's first (fenced) transaction, which
1334
+ // stores this process's (an import holds the home's lock)
1335
+ const landed = new DatabaseSync(join(dest, 'kanban.db'));
1336
+ try { landed.exec("CREATE TABLE IF NOT EXISTS supercode_board_state (key TEXT PRIMARY KEY, value TEXT); DELETE FROM supercode_board_state WHERE key = 'owner_epoch'"); } finally { landed.close(); }
1321
1337
  const b = openBoard(root, slug);
1322
1338
  let counts;
1323
1339
  try {
@@ -1456,6 +1472,96 @@ export function summary(r) {
1456
1472
  * re-read every tick, so a profile's change takes effect without a restart). Card claims
1457
1473
  * are version-checked by the backing; the dispatcher takes no file lock.
1458
1474
  */
1475
+ /** Record the board's dispatching switch: on or off, by whom and why, as one row of its state. */
1476
+ function setDispatching(root, slug, on, reason) {
1477
+ const value = { on, by: actorOf() ?? principalOf() ?? null, at: new Date().toISOString(), reason: reason || null };
1478
+ const board = openBoard(root, slug);
1479
+ try {
1480
+ tx(board, (db) => {
1481
+ db.exec('CREATE TABLE IF NOT EXISTS supercode_board_state (key TEXT PRIMARY KEY, value TEXT)');
1482
+ db.prepare("INSERT INTO supercode_board_state (key, value) VALUES ('dispatching', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(JSON.stringify(value));
1483
+ });
1484
+ } finally { board.close(); }
1485
+ return value;
1486
+ }
1487
+
1488
+ /**
1489
+ * The owner's door: every card verb on this home's boards, from the CLI and the machine daemon's board door, answered
1490
+ * here in a call of its own (call.mjs), with the home this process holds. One owner per home, by an OS lock held for
1491
+ * its whole life by a holder process of its own (an exclusive SQLite lock on `<home>/board.lock`, released when the
1492
+ * owner ends and the holder's standard input closes): a second serve refuses, and only the lock's owner removes and
1493
+ * binds the socket.
1494
+ */
1495
+ async function answerVerbs(root, log) {
1496
+ const home = boardHome(root);
1497
+ const lockPath = join(home, 'board.lock');
1498
+ // The lock is held by a process of its own (lock-holder.mjs), so nothing this process reads can drop it; a verb
1499
+ // naming the lock or socket as its input is refused (owner-files.mjs)
1500
+ let lock;
1501
+ try { lock = await holdLock(lockPath, { onLost: (error) => { log(`${error.message}: this home has no owner now; stopping`); process.exit(1); } }); }
1502
+ catch (error) { throw new Error(`the boards of ${root} already have their owner (${lockPath} is held: ${error.message}); one serve per home`); }
1503
+ ownHome(home);
1504
+ // the lock held, this owner takes its fencing epoch and fences every board, before it answers anything (fence.mjs)
1505
+ const epoch = takeEpoch(root, () => listBoards(root).map((slug) => openBoard(root, slug)));
1506
+ log(`owner epoch ${epoch}`);
1507
+ const path = boardOwnerSocket(root);
1508
+ try { unlinkSync(path); } catch { /* none left */ }
1509
+ // allowHalfOpen: a caller that ends its side after its request still gets the answer, written when it is ready
1510
+ const server = createServer({ allowHalfOpen: true }, (socket) => {
1511
+ let input = '';
1512
+ socket.setEncoding('utf8');
1513
+ socket.on('error', () => { /* the caller went away */ });
1514
+ socket.on('data', (chunk) => {
1515
+ if (input === null) return;
1516
+ input += chunk;
1517
+ const end = input.indexOf('\n');
1518
+ if (end < 0) return;
1519
+ const line = input.slice(0, end);
1520
+ input = null;
1521
+ // one answer per call, whatever the request did: a request that fails is a failed answer, never the owner's end
1522
+ answerOne(root, line)
1523
+ .catch((error) => ({ code: 1, stdout: '', stderr: `supercode workflow: ${error?.message ?? error}\n` }))
1524
+ .then((answer) => { if (!socket.destroyed) socket.end(`${JSON.stringify(answer)}\n`); });
1525
+ });
1526
+ });
1527
+ await new Promise((done, fail) => { server.once('error', fail); server.listen(path, () => { server.off('error', fail); done(); }); });
1528
+ try { chmodSync(path, 0o600); } catch { /* the home is the user's own */ }
1529
+ log(`answering the card verbs of ${root} at ${path}`);
1530
+ return () => { server.close(); try { unlinkSync(path); } catch { /* gone */ } lock.release(); };
1531
+ }
1532
+
1533
+ /** One request to the owner, answered whole: the verb run in the caller's call, its output kept for the answer. */
1534
+ async function answerOne(root, line) {
1535
+ try {
1536
+ const request = JSON.parse(line);
1537
+ const tool = request?.tool && typeof request.tool.name === 'string' ? request.tool : null;
1538
+ const argv = tool ? ['tool'] : Array.isArray(request?.argv) && request.argv.every((arg) => typeof arg === 'string') ? request.argv : null;
1539
+ if (request?.v !== 1 || !argv?.length) return { code: 1, stdout: '', stderr: 'supercode workflow: a request names a verb\n' };
1540
+ const verb = parse(argv)._[0];
1541
+ if (BOARD_OPERATION_VERBS.has(verb) && verb !== 'dispatching') return { code: 1, stdout: '', stderr: `supercode workflow: ${verb} is run on the board's machine as its own act, not sent to the owner\n` };
1542
+ const session = request.session && typeof request.session.id === 'string' && ['claude-code', 'codex'].includes(request.session.harness) ? request.session : null;
1543
+ const caller = typeof request.caller === 'string' ? request.caller : session ? `sc:${localMachine()}:${session.harness}:${session.id}` : null;
1544
+ const facts = Object.fromEntries(Object.entries(request.facts ?? {}).filter(([key, value]) => CALLER_FACTS.includes(key) && typeof value === 'string'));
1545
+ // a caller names its Teams context (the CLI and the daemon always do): one that names none is refused before anything
1546
+ // is done, never answered with the owner's
1547
+ if (!facts.SUPERCODE_CONTEXT) return { code: 1, stdout: '', stderr: 'supercode workflow: the request names no Teams context (facts.SUPERCODE_CONTEXT); nothing was done\n' };
1548
+ const cwd = typeof request.cwd === 'string' && request.cwd ? request.cwd : root;
1549
+ const stdin = typeof request.stdin === 'string' ? request.stdin : null;
1550
+ let stdout = '';
1551
+ let stderr = '';
1552
+ let code;
1553
+ await within({ caller, facts, cwd, stdin, out: (text) => { stdout += text; return true; }, err: (text) => { stderr += text; return true; } }, async () => {
1554
+ // a worker's board tool (mcp/server.mjs), answered by the board's tools in this call (board/tools.mjs)
1555
+ if (tool) { stdout = await boardTool(root, tool.name, tool.args && typeof tool.args === 'object' ? tool.args : {}, Object.fromEntries(Object.entries(tool.env ?? {}).filter(([key, value]) => /^HERMES_(KANBAN_TASK|KANBAN_RUN_ID|KANBAN_BOARD|PROFILE)$/.test(key) && typeof value === 'string'))); code = 0; return; }
1556
+ try { code = await runVerb([...argv, '--root', root, ...(typeof request.board === 'string' && request.board ? ['--board', request.board] : [])]); }
1557
+ catch (error) { stderr += `supercode workflow: ${error?.message ?? error}\n`; code = 1; }
1558
+ });
1559
+ return { code: code ?? 0, stdout, stderr };
1560
+ } catch (error) {
1561
+ return { code: 1, stdout: '', stderr: `supercode workflow: ${error?.message ?? error}\n` };
1562
+ }
1563
+ }
1564
+
1459
1565
  async function serve(root, args = {}) {
1460
1566
  // Hermes's daemon flags: --interval seconds, --max starts per tick, --failure-limit, --pidfile, --verbose
1461
1567
  const override = Number(args.interval) > 0 ? { interval: Number(args.interval) } : {};
@@ -1471,7 +1577,7 @@ async function serve(root, args = {}) {
1471
1577
  const kept = join(root, 'logs', 'workflow-serve.log');
1472
1578
  const log = (text) => {
1473
1579
  const line = `${new Date().toISOString()} ${text}\n`;
1474
- process.stdout.write(line);
1580
+ current().out(line);
1475
1581
  try { mkdirSync(join(root, 'logs'), { recursive: true }); appendFileSync(kept, line); } catch { /* the pane still has it */ }
1476
1582
  };
1477
1583
  // the orchestrator this serve runs, as installed: a newer install ends the serve after its tick, so whatever keeps it
@@ -1501,7 +1607,7 @@ async function serve(root, args = {}) {
1501
1607
  // the same kind as rounds that stopped, so it goes where every board verb's error line reads it. Only a refusal
1502
1608
  // in hand counts: the team's 4xx answer, which the daemon records with the publication (from supercode-teams
1503
1609
  // 0.3.44). An attempt still being retried, or a daemon that records no answer, says nothing here.
1504
- const held = boardPublishing(root)?.failing;
1610
+ const held = (await boardPublishing(root))?.failing;
1505
1611
  const refused = held && Number(held.status) >= 400 && Number(held.status) < 500 ? held : null;
1506
1612
  const heldLine = refused ? [`publishing to the team is held: the team refused ${refused.card}'s ${refused.kind} (sequence ${refused.sequence}) with ${refused.status}${refused.code ? ` ${refused.code}` : ''}: ${String(refused.error ?? '').split('\n')[0].slice(0, 200)} (tried ${refused.attempts} time(s)); nothing of this home after it is published until the team takes it`] : [];
1507
1613
  const temporary = `${heartbeat}.${process.pid}.tmp`;
@@ -1530,13 +1636,25 @@ async function serve(root, args = {}) {
1530
1636
  }
1531
1637
  };
1532
1638
  const running = installed();
1533
- log(`dispatching the boards of ${root} from ${localMachine()} (orchestrator ${running ?? 'unknown'})`);
1639
+ await knowLocalMachine();
1640
+ // The board's owner: it answers every card verb on this home's boards (answerVerbs) for as long as it runs, whether
1641
+ // or not it dispatches.
1642
+ // the held home is read again on its next use after a file it is decoded from changes (file events, no interval),
1643
+ // watched before anything reads it; a watch that cannot be kept stops the owner, for its keeper to start it again
1644
+ const unfollowHome = followHome(root, {
1645
+ onChange: (name) => log(`home changed (${name}): read again on its next use`),
1646
+ onLost: (error) => { log(`the home's files can no longer be watched (${error.message}); stopping, for the keeper to start the owner again`); stop(); },
1647
+ });
1648
+ let closeVerbs;
1649
+ try { closeVerbs = await answerVerbs(root, log); } catch (error) { unfollowHome(); throw error; }
1650
+ log(`owning the boards of ${root} on ${localMachine()} (orchestrator ${running ?? 'unknown'}); dispatching where a board's dispatching is on (\`supercode workflow dispatching\`)`);
1534
1651
  events = watchBoard({ root, binary: supercodeBin(), env: commandEnv(), log });
1535
1652
  // The board's events are published by this machine's connector, not by the dispatcher (D137, D138 step 4): the home is
1536
1653
  // named to the daemon once, and the connector follows its `workflow events` from then on, with or without this serve.
1537
- { const named = runtimeDoor(null, 'harness.v1.boards.register', { root }, { timeout: 30_000 });
1538
- const refused = named.code === 0 ? JSON.parse(named.stdout || '{}').refused ?? null : null;
1539
- log(named.code !== 0 ? `board events: not published; the machine daemon did not take this home: ${(named.stderr || named.stdout).trim().split('\n').at(-1)}`
1654
+ { let named = null, refusal = null;
1655
+ try { named = await machineCall(null, 'harness.v1.boards.register', { root }, { timeoutMs: 30_000 }); } catch (error) { refusal = error.message; }
1656
+ const refused = named?.refused ?? null;
1657
+ log(refusal ? `board events: not published; the machine daemon did not take this home: ${refusal}`
1540
1658
  : refused ? `board events: registered, but the team refused this machine's credential (${refused.status}); nothing is published until it is re-enrolled`
1541
1659
  : `board events: this home is published by the machine's connector`); }
1542
1660
  log('dispatch wakes on board writes, document edits and board mail; the interval sweep reads each machine\'s own door');
@@ -1559,14 +1677,23 @@ async function serve(root, args = {}) {
1559
1677
  if (wakeReason) log(`dispatch wake ${wakeReason.sources.join(', ')}; event age ${began - wakeReason.at}ms`);
1560
1678
  wakeReason = null;
1561
1679
  try {
1562
- const state = await laneState(root);
1680
+ const state = await ownCall(() => laneState(root));
1563
1681
  const settings = { ...state.settings, ...override };
1564
1682
  interval = settings.interval;
1565
- const reports = await tick({ root, lanes: state.lanes, settings, params, deploymentReceipt: true });
1566
- round += 1;
1567
- // what the round recorded is delivered by a pass of its own, which this loop never waits for
1568
- if (!stopped) startDelivery(root, { log: (level, line) => { if (level === 'warn' || args.verbose) log(line); } });
1569
- if (!stopped) await watchRounds(reports);
1683
+ // Publishing is the owner's, dispatching or not: what a writer outside this process marked changed is published
1684
+ // on the wake its write caused, and the retained history kept bounded (store.mjs publishMarked).
1685
+ const published = await ownCall(async () => publishMarked(root));
1686
+ if (published && args.verbose) log(`published ${published} card(s) a writer outside the owner changed`);
1687
+ // Dispatching is a controller over the boards whose own state says it is on; the owner answers verbs either way.
1688
+ const dispatching = listBoards(root).filter((slug) => dispatchingOf(root, slug).on);
1689
+ const reports = dispatching.length ? await ownCall(() => tick({ root, lanes: state.lanes, settings, params, slugs: dispatching, deploymentReceipt: true })) : [];
1690
+ // with dispatching off on every board the owner only answers verbs: no round, no delivery, nothing told
1691
+ if (dispatching.length) {
1692
+ round += 1;
1693
+ // what the round recorded is delivered by a pass of its own, which this loop never waits for
1694
+ if (!stopped) startDelivery(root, { slugs: dispatching, log: (level, line) => { if (level === 'warn' || args.verbose) log(line); } });
1695
+ if (!stopped) await watchRounds(reports);
1696
+ }
1570
1697
  for (const r of reports) {
1571
1698
  nextMailAt = Math.min(nextMailAt, r.next_mail_at ?? Infinity);
1572
1699
  const said = summary(r);
@@ -1606,6 +1733,9 @@ async function serve(root, args = {}) {
1606
1733
  if (moved) { log(`orchestrator ${moved} is installed (this serve runs ${running}); stopping, for its keeper to start the new one`); break; }
1607
1734
  }
1608
1735
  } finally {
1736
+ closeVerbs();
1737
+ unfollowHome();
1738
+ await closeDoors();
1609
1739
  events?.close();
1610
1740
  for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP']) process.off(signal, stop);
1611
1741
  if (args.pidfile) { try { unlinkSync(args.pidfile); } catch { /* gone */ } }