@volter/supercode-orchestrator 0.5.69 → 0.5.71

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,26 +47,35 @@ 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:
60
- init [--backing sqlite|files|ztrack] [--document <arcs.md>] [--commit] [--manager <address>] [--archive-max-lines N] [--archive-period month]
78
+ init [--backing sqlite|files|ztrack] [--document <arcs.md>] [--commit] [--manager <address>] [--dispatching on|off] [--archive-max-lines N] [--archive-period month]
61
79
  migrate --backing ztrack --document <arcs.md> --status-map <json>
62
80
  boards list|create|rm|switch|show|rename|set-default-workdir|export|import
63
81
  create <title> [--body | --body-file PATH|-] [--assignee] [--parent]... [--workspace scratch|worktree:<repository>|dir:<repository>] [--branch] [--project]
@@ -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);
@@ -433,6 +414,8 @@ export async function runWorkflow(argv) {
433
414
  return 0;
434
415
  }
435
416
  case 'init': {
417
+ // a malformed switch is refused before anything is made
418
+ if (args.dispatching !== undefined && !['on', 'off'].includes(args.dispatching)) throw new Error('--dispatching takes on or off; nothing was made');
436
419
  // A board on the ztrack backing is made with what its ztrack.json declares besides its document: `--commit` (the
437
420
  // documents live in a git checkout, each write committed and pushed), its manager (`--manager`, else the first one
438
421
  // the home's workflow names), whose own writes it does not commit, and the archive's rotation. A board already there
@@ -464,6 +447,11 @@ export async function runWorkflow(argv) {
464
447
  const own = named(fileURLToPath(new URL('../bin/orchestrator.mjs', import.meta.url)));
465
448
  writeFileSync(record, `${JSON.stringify({ package: '@volter/supercode-orchestrator', version: own?.version ?? null, entry: own?.entry ?? null, supercode: process.env.SUPERCODE_BIN ? named(process.env.SUPERCODE_BIN) : null })}\n`);
466
449
  }
450
+ // its dispatching switch, set before any owner runs (an install's first start: `init --dispatching off`); while an
451
+ // owner runs, `workflow dispatching` is its verb
452
+ if (args.dispatching !== undefined) {
453
+ setDispatching(root, slug, args.dispatching === 'on', 'set at init');
454
+ }
467
455
  const backing = made.backing;
468
456
  made.close();
469
457
  const { profiles } = await laneState(root).catch(() => ({ profiles: [] }));
@@ -481,6 +469,8 @@ export async function runWorkflow(argv) {
481
469
  let workspace = args.workspace;
482
470
  if ((workspace === 'dir' || workspace === 'worktree') && meta.default_workdir) workspace = `${workspace}:${meta.default_workdir}`;
483
471
  // --goal / --no-goal are the create's words; without either, the workflow's initial decides
472
+ // a person filing for themselves sponsors it; an agent filing a person's request names them (proven) or records them unverified
473
+ const sponsorship = args.sponsor || args['requested-by'] ? await requestedSponsor(args) : principalOf()?.startsWith('person:') ? { sponsor: principalOf().slice('person:'.length) } : {};
484
474
  const created = withBoard((b) => engine.createCard(b, {
485
475
  title: rest.join(' '), body: textFrom(args['body-file'], args.body), assignee: args.assignee === 'none' ? null : args.assignee, machine: args.machine,
486
476
  workspace, branch: args.branch, parents: args.parent, priority: args.priority, tenant: args.tenant,
@@ -490,8 +480,7 @@ export async function runWorkflow(argv) {
490
480
  every: args.every ?? null, sessionId: callerSession(), subtaskOf: args['subtask-of'] ?? null, initialStatus: args['initial-status'] ?? null,
491
481
  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
482
  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) } : {}),
483
+ ...sponsorship,
495
484
  }), true);
496
485
  await withBoardAsync(b=>engine.runEffects(b,created.effects??[]));
497
486
  let answer = { id: created.id, existing: created.existing };
@@ -566,8 +555,8 @@ export async function runWorkflow(argv) {
566
555
  return 0;
567
556
  }
568
557
  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');
558
+ if (others > 0) current().out(`Board: ${slug} (${others} other board${others === 1 ? '' : 's'} — \`supercode workflow boards list\`)\n\n`);
559
+ current().out(cards.length ? `${cards.map(line).join('\n')}\n` : '(no matching tasks)\n');
571
560
  return 0;
572
561
  }
573
562
 
@@ -640,19 +629,19 @@ export async function runWorkflow(argv) {
640
629
  }
641
630
  }
642
631
  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`);
632
+ current().out(`${out.join('\n')}\n`);
644
633
  return 0;
645
634
  }
646
635
 
647
636
  // G3: usage from each run's own harness records, summed on agents and on their sponsors
648
637
  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; } };
638
+ const byAgent = agentUsage(root), context = current().facts.SUPERCODE_CONTEXT;
639
+ 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
640
  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
641
  // a harness that keeps no price records tokens only: its cost reads as unrecorded, never as zero
653
642
  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
643
  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)) }))
644
+ 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
645
  .filter((row) => !args.sponsor || row.sponsor === args.sponsor);
657
646
  const total = sum(rows.flatMap((row) => byAgent.get(row.agent)));
658
647
  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 +650,13 @@ export async function runWorkflow(argv) {
661
650
  return 0;
662
651
  }
663
652
 
664
- case 'context': need(1, 'a card id'); process.stdout.write(`${withBoard((b) => view(b, (db) => engine.cardContext(db, id)))}\n`); return 0;
653
+ case 'context': need(1, 'a card id'); current().out(`${withBoard((b) => view(b, (db) => engine.cardContext(db, id)))}\n`); return 0;
665
654
 
666
655
  case 'runs': {
667
656
  need(1, 'a card id');
668
657
  const got = withBoard((b) => engine.runs(b.db, id, { stateType: args['state-type'] ?? null, stateName: args['state-name'] ?? null }));
669
658
  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`);
659
+ 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
660
  return 0;
672
661
  }
673
662
 
@@ -685,14 +674,14 @@ export async function runWorkflow(argv) {
685
674
  case 'assign': case 'reassign': if (args.agent) {
686
675
  // C7: a card role's assignment moves to another agent; the sessions it ran keep their identity
687
676
  need(1, 'a card id');
688
- const context=process.env.SUPERCODE_CONTEXT,scope=agentDoor('scope',[],{context});
677
+ const context=current().facts.SUPERCODE_CONTEXT,scope=agentScope(context);
689
678
  const task=withBoard(b=>engine.getTask(b.db,id));
690
679
  if(!task)throw new Error(`no card ${id}`);
691
680
  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
681
  const role=args.role??'implementer';
693
682
  const assignment=activeAgentAssignments(root).find(item=>item.org===scope.org&&item.kind==='card'&&item.target===id&&item.role===role);
694
683
  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});
684
+ const changed=await reassignAgent(root,scope.org,assignment.id,args.agent,{context});
696
685
  withBoard(b=>tx(b,db=>event(db,id,'agent_assignment_reassigned',{assignment:assignment.id,role,from:assignment.agent_id,to:changed.agent_id})));
697
686
  say(args,`Reassigned ${id}/${role} to agent ${changed.agent_id}; sessions keep their identity`,{ok:true,assignment:changed});return 0;
698
687
  } 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 +742,7 @@ export async function runWorkflow(argv) {
753
742
  }
754
743
  case 'attach': {
755
744
  need(2, 'a card id and a file');
756
- const source = resolve(rest[1]);
745
+ const source = callerPath(rest[1]);
757
746
  if (!existsSync(source) || !statSync(source).isFile()) throw new Error(`no file ${rest[1]}`);
758
747
  const name = basename(args.name ?? source).replace(/[^A-Za-z0-9._ -]/g, '_') || 'attachment';
759
748
  const dir = join(slug === DEFAULT_BOARD ? join(root, 'kanban', 'attachments') : join(boardDir(root, slug), 'attachments'), id);
@@ -768,7 +757,7 @@ export async function runWorkflow(argv) {
768
757
  need(1, 'a card id');
769
758
  const got = withBoard((b) => engine.attachments(b.db, id));
770
759
  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`);
760
+ 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
761
  return 0;
773
762
  }
774
763
  case 'attach-rm': {
@@ -892,7 +881,7 @@ export async function runWorkflow(argv) {
892
881
  case 'diagnostics': case 'diag': {
893
882
  const { settings } = await laneState(root).catch(() => ({ settings: boardSettings({}) }));
894
883
  const fleet = observeFleet(root);
895
- const publishing = boardPublishing(root);
884
+ const publishing = await boardPublishing(root);
896
885
  const expiredMail = mailExpiries();
897
886
  const waitingMail = mailWaiting();
898
887
  const order = ['warning', 'error', 'critical'];
@@ -903,14 +892,14 @@ export async function runWorkflow(argv) {
903
892
  .filter((d) => !args.severity || order.indexOf(d.severity) >= order.indexOf(args.severity));
904
893
  });
905
894
  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');
895
+ 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
896
  return 0;
908
897
  }
909
898
  case 'stats': {
910
899
  const s = withBoard((b) => engine.stats(b.db));
911
900
  if (args.json) { say(args, '', s); return 0; }
912
901
  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`);
902
+ 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
903
  return 0;
915
904
  }
916
905
  case 'assignees': {
@@ -918,7 +907,7 @@ export async function runWorkflow(argv) {
918
907
  const c = withBoard((b) => engine.stats(b.db).by_assignee);
919
908
  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
909
  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`);
910
+ 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
911
  return 0;
923
912
  }
924
913
  case 'log': {
@@ -927,12 +916,12 @@ export async function runWorkflow(argv) {
927
916
  if (!existsSync(file)) {
928
917
  // a pane session keeps no log file: its transcript is the harness's own, named by the run
929
918
  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`);
919
+ 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
920
  return 0;
932
921
  }
933
922
  const text = readFileSync(file, 'utf8');
934
923
  const tail = args.tail ? Number(args.tail) : null;
935
- process.stdout.write(tail ? text.slice(-tail) : text);
924
+ current().out(tail ? text.slice(-tail) : text);
936
925
  return 0;
937
926
  }
938
927
  case 'tail': case 'watch': {
@@ -951,7 +940,7 @@ export async function runWorkflow(argv) {
951
940
  if (kinds && !kinds.has(e.kind)) continue;
952
941
  if (args.assignee && e.assignee !== args.assignee) continue;
953
942
  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`);
943
+ current().out(`[${ts(e.created_at)}] ${verb === 'watch' ? `${e.task_id} ` : ''}${e.kind}${e.payload ? ` ${e.payload}` : ''}\n`);
955
944
  }
956
945
  await new Promise((done) => setTimeout(done, interval));
957
946
  }
@@ -987,7 +976,7 @@ export async function runWorkflow(argv) {
987
976
  case 'notify-list': {
988
977
  const subs = withBoard((b) => engine.subscriptions(b.db, id ?? null));
989
978
  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');
979
+ 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
980
  return 0;
992
981
  }
993
982
 
@@ -1005,7 +994,7 @@ export async function runWorkflow(argv) {
1005
994
  }
1006
995
  const reports = await tick({ root, lanes, settings, params });
1007
996
  if (args.json) say(args, '', reports);
1008
- else for (const r of reports) process.stdout.write(`${summary(r)}\n`);
997
+ else for (const r of reports) current().out(`${summary(r)}\n`);
1009
998
  return reports.some((r) => r.error) ? 1 : 0;
1010
999
  }
1011
1000
  case 'capacity': {
@@ -1024,7 +1013,7 @@ export async function runWorkflow(argv) {
1024
1013
  }
1025
1014
  lines.push(out.would.length ? `would start: ${out.would.join(' ')}` : 'would start: nothing');
1026
1015
  for (const w of out.waiting) lines.push(`${w.id} ${w.why}`);
1027
- process.stdout.write(`${lines.join('\n')}\n`);
1016
+ current().out(`${lines.join('\n')}\n`);
1028
1017
  return 0;
1029
1018
  }
1030
1019
  case 'deliver': {
@@ -1032,7 +1021,7 @@ export async function runWorkflow(argv) {
1032
1021
  // starts it after each round in a process of its own; never part of a round)
1033
1022
  const reports = await deliver({ root, slugs: args.board ? [slug] : null });
1034
1023
  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`);
1024
+ 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
1025
  return reports.some((r) => r.error) ? 1 : 0;
1037
1026
  }
1038
1027
  case 'daemon': case 'serve': return serve(root, args);
@@ -1087,6 +1076,34 @@ export async function runWorkflow(argv) {
1087
1076
  await withBoardAsync((b) => engine.act(b, id, 'cancel', { payload: { reason: args.reason ?? rest.slice(1).join(' ') } }));
1088
1077
  say(args, `${id} cancelled`, { ok: true }); return 0;
1089
1078
  }
1079
+ case 'run-session': {
1080
+ // A headless worker records its harness session on its run (kanban-worker.mjs), through the owner like every board
1081
+ // write. Only the run the owner launched for this worker: the launch's secret (in the worker's environment alone,
1082
+ // its hash kept on the run), its card and its run are checked at the write, against the card's current, unended run.
1083
+ need(2, 'a run id and a session id');
1084
+ // the secret is the worker's own environment (SUPERCODE_RUN_LAUNCH, sent as a caller fact), never an argument
1085
+ const runId = Number(id), harness = args.harness ?? null, launch = current().facts.SUPERCODE_RUN_LAUNCH ?? '', card = typeof args.task === 'string' ? args.task : '';
1086
+ 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');
1087
+ const refused = withBoard((b) => tx(b, (db) => {
1088
+ 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);
1089
+ const saved = json(row?.metadata, {})?.supercode ?? {};
1090
+ if (!row || row.task_id !== card || row.ended_at != null || Number(row.current_run_id) !== runId || saved.surface !== 'headless' || !saved.launch_secret
1091
+ || 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`;
1092
+ engine.setRunSession(db, runId, { ...saved, session_id: rest[1], harness });
1093
+ return null;
1094
+ }));
1095
+ if (refused) throw new Error(refused);
1096
+ say(args, `run ${runId}: session ${rest[1]}`, { ok: true }); return 0;
1097
+ }
1098
+ case 'dispatching': {
1099
+ // the board's dispatching switch (its owner's controller): `dispatching` says it, `dispatching on|off` sets it
1100
+ if (id && !['on', 'off'].includes(id)) throw new Error('dispatching takes on or off');
1101
+ // switching it is the manager's: a caller outside any session, or a session the workflow's params.managers names
1102
+ 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)`);
1103
+ const state = id ? setDispatching(root, slug, id === 'on', args.reason ?? rest.slice(1).join(' ')) : dispatchingOf(root, slug);
1104
+ 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 });
1105
+ return 0;
1106
+ }
1090
1107
  case 'pause': {
1091
1108
  if (id) {
1092
1109
  await withBoardAsync((b) => engine.act(b, id, 'pause', { payload: { reason: args.reason ?? rest.slice(1).join(' ') } }));
@@ -1157,10 +1174,10 @@ async function boardsVerb(root, rest, args) {
1157
1174
  const [action = 'list', target] = rest;
1158
1175
  switch (action) {
1159
1176
  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) }));
1177
+ const selected = boardOf(root, {});
1178
+ const boards = boardsOf(root, { includeArchived: args.all }).map((b) => ({ ...b, is_current: b.slug === selected, counts: counts(root, b.slug) }));
1162
1179
  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`);
1180
+ 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
1181
  return 0;
1165
1182
  }
1166
1183
  case 'create': case 'new': {
@@ -1215,7 +1232,7 @@ async function boardsVerb(root, rest, args) {
1215
1232
  case 'export': {
1216
1233
  const slug = normalSlug(target ?? boardOf(root, args));
1217
1234
  if (!boardExists(root, slug)) throw new Error(`board '${slug}' has no database`);
1218
- const out = resolve(args.output ?? `${slug}.tar.gz`);
1235
+ const out = callerPath(args.output ?? `${slug}.tar.gz`);
1219
1236
  const made = exportBoard(root, slug, out, { attachments: !args['no-attachments'], logs: Boolean(args['include-logs']) });
1220
1237
  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
1238
  return 0;
@@ -1242,6 +1259,8 @@ const TABLES = ['tasks', 'task_links', 'task_comments', 'task_events', 'task_run
1242
1259
  */
1243
1260
  function scrub(db) {
1244
1261
  db.exec('DELETE FROM kanban_notify_subs');
1262
+ // a fencing token is the home's runtime custody, never a board's portable data (fence.mjs)
1263
+ 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
1264
  db.exec(`UPDATE tasks SET claim_lock = NULL, claim_expires = NULL, worker_pid = NULL, current_run_id = NULL, last_heartbeat_at = NULL,
1246
1265
  session_id = NULL, project_id = NULL, consecutive_failures = 0, last_failure_error = NULL`);
1247
1266
  db.exec("UPDATE tasks SET status = 'ready' WHERE status = 'running'");
@@ -1318,6 +1337,10 @@ function importBoard(root, archive, as) {
1318
1337
  const name = staged.name || manifest.board_name || slug;
1319
1338
  writeMeta(root, slug, { name, description: staged.description ?? '', icon: staged.icon ?? '', color: staged.color ?? '', archived: false });
1320
1339
  const warnings = [];
1340
+ // the landed store keeps no other home's epoch: it is dropped before the board's first (fenced) transaction, which
1341
+ // stores this process's (an import holds the home's lock)
1342
+ const landed = new DatabaseSync(join(dest, 'kanban.db'));
1343
+ 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
1344
  const b = openBoard(root, slug);
1322
1345
  let counts;
1323
1346
  try {
@@ -1456,6 +1479,96 @@ export function summary(r) {
1456
1479
  * re-read every tick, so a profile's change takes effect without a restart). Card claims
1457
1480
  * are version-checked by the backing; the dispatcher takes no file lock.
1458
1481
  */
1482
+ /** Record the board's dispatching switch: on or off, by whom and why, as one row of its state. */
1483
+ function setDispatching(root, slug, on, reason) {
1484
+ const value = { on, by: actorOf() ?? principalOf() ?? null, at: new Date().toISOString(), reason: reason || null };
1485
+ const board = openBoard(root, slug);
1486
+ try {
1487
+ tx(board, (db) => {
1488
+ db.exec('CREATE TABLE IF NOT EXISTS supercode_board_state (key TEXT PRIMARY KEY, value TEXT)');
1489
+ db.prepare("INSERT INTO supercode_board_state (key, value) VALUES ('dispatching', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(JSON.stringify(value));
1490
+ });
1491
+ } finally { board.close(); }
1492
+ return value;
1493
+ }
1494
+
1495
+ /**
1496
+ * The owner's door: every card verb on this home's boards, from the CLI and the machine daemon's board door, answered
1497
+ * 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
1498
+ * its whole life by a holder process of its own (an exclusive SQLite lock on `<home>/board.lock`, released when the
1499
+ * owner ends and the holder's standard input closes): a second serve refuses, and only the lock's owner removes and
1500
+ * binds the socket.
1501
+ */
1502
+ async function answerVerbs(root, log) {
1503
+ const home = boardHome(root);
1504
+ const lockPath = join(home, 'board.lock');
1505
+ // The lock is held by a process of its own (lock-holder.mjs), so nothing this process reads can drop it; a verb
1506
+ // naming the lock or socket as its input is refused (owner-files.mjs)
1507
+ let lock;
1508
+ try { lock = await holdLock(lockPath, { onLost: (error) => { log(`${error.message}: this home has no owner now; stopping`); process.exit(1); } }); }
1509
+ catch (error) { throw new Error(`the boards of ${root} already have their owner (${lockPath} is held: ${error.message}); one serve per home`); }
1510
+ ownHome(home);
1511
+ // the lock held, this owner takes its fencing epoch and fences every board, before it answers anything (fence.mjs)
1512
+ const epoch = takeEpoch(root, () => listBoards(root).map((slug) => openBoard(root, slug)));
1513
+ log(`owner epoch ${epoch}`);
1514
+ const path = boardOwnerSocket(root);
1515
+ try { unlinkSync(path); } catch { /* none left */ }
1516
+ // allowHalfOpen: a caller that ends its side after its request still gets the answer, written when it is ready
1517
+ const server = createServer({ allowHalfOpen: true }, (socket) => {
1518
+ let input = '';
1519
+ socket.setEncoding('utf8');
1520
+ socket.on('error', () => { /* the caller went away */ });
1521
+ socket.on('data', (chunk) => {
1522
+ if (input === null) return;
1523
+ input += chunk;
1524
+ const end = input.indexOf('\n');
1525
+ if (end < 0) return;
1526
+ const line = input.slice(0, end);
1527
+ input = null;
1528
+ // one answer per call, whatever the request did: a request that fails is a failed answer, never the owner's end
1529
+ answerOne(root, line)
1530
+ .catch((error) => ({ code: 1, stdout: '', stderr: `supercode workflow: ${error?.message ?? error}\n` }))
1531
+ .then((answer) => { if (!socket.destroyed) socket.end(`${JSON.stringify(answer)}\n`); });
1532
+ });
1533
+ });
1534
+ await new Promise((done, fail) => { server.once('error', fail); server.listen(path, () => { server.off('error', fail); done(); }); });
1535
+ try { chmodSync(path, 0o600); } catch { /* the home is the user's own */ }
1536
+ log(`answering the card verbs of ${root} at ${path}`);
1537
+ return () => { server.close(); try { unlinkSync(path); } catch { /* gone */ } lock.release(); };
1538
+ }
1539
+
1540
+ /** One request to the owner, answered whole: the verb run in the caller's call, its output kept for the answer. */
1541
+ async function answerOne(root, line) {
1542
+ try {
1543
+ const request = JSON.parse(line);
1544
+ const tool = request?.tool && typeof request.tool.name === 'string' ? request.tool : null;
1545
+ const argv = tool ? ['tool'] : Array.isArray(request?.argv) && request.argv.every((arg) => typeof arg === 'string') ? request.argv : null;
1546
+ if (request?.v !== 1 || !argv?.length) return { code: 1, stdout: '', stderr: 'supercode workflow: a request names a verb\n' };
1547
+ const verb = parse(argv)._[0];
1548
+ if (BOARD_OPERATION_VERBS.has(verb)) 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` };
1549
+ const session = request.session && typeof request.session.id === 'string' && ['claude-code', 'codex'].includes(request.session.harness) ? request.session : null;
1550
+ const caller = typeof request.caller === 'string' ? request.caller : session ? `sc:${localMachine()}:${session.harness}:${session.id}` : null;
1551
+ const facts = Object.fromEntries(Object.entries(request.facts ?? {}).filter(([key, value]) => CALLER_FACTS.includes(key) && typeof value === 'string'));
1552
+ // a caller names its Teams context (the CLI and the daemon always do): one that names none is refused before anything
1553
+ // is done, never answered with the owner's
1554
+ if (!facts.SUPERCODE_CONTEXT) return { code: 1, stdout: '', stderr: 'supercode workflow: the request names no Teams context (facts.SUPERCODE_CONTEXT); nothing was done\n' };
1555
+ const cwd = typeof request.cwd === 'string' && request.cwd ? request.cwd : root;
1556
+ const stdin = typeof request.stdin === 'string' ? request.stdin : null;
1557
+ let stdout = '';
1558
+ let stderr = '';
1559
+ let code;
1560
+ await within({ caller, facts, cwd, stdin, out: (text) => { stdout += text; return true; }, err: (text) => { stderr += text; return true; } }, async () => {
1561
+ // a worker's board tool (mcp/server.mjs), answered by the board's tools in this call (board/tools.mjs)
1562
+ 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; }
1563
+ try { code = await runVerb([...argv, '--root', root, ...(typeof request.board === 'string' && request.board ? ['--board', request.board] : [])]); }
1564
+ catch (error) { stderr += `supercode workflow: ${error?.message ?? error}\n`; code = 1; }
1565
+ });
1566
+ return { code: code ?? 0, stdout, stderr };
1567
+ } catch (error) {
1568
+ return { code: 1, stdout: '', stderr: `supercode workflow: ${error?.message ?? error}\n` };
1569
+ }
1570
+ }
1571
+
1459
1572
  async function serve(root, args = {}) {
1460
1573
  // Hermes's daemon flags: --interval seconds, --max starts per tick, --failure-limit, --pidfile, --verbose
1461
1574
  const override = Number(args.interval) > 0 ? { interval: Number(args.interval) } : {};
@@ -1471,7 +1584,7 @@ async function serve(root, args = {}) {
1471
1584
  const kept = join(root, 'logs', 'workflow-serve.log');
1472
1585
  const log = (text) => {
1473
1586
  const line = `${new Date().toISOString()} ${text}\n`;
1474
- process.stdout.write(line);
1587
+ current().out(line);
1475
1588
  try { mkdirSync(join(root, 'logs'), { recursive: true }); appendFileSync(kept, line); } catch { /* the pane still has it */ }
1476
1589
  };
1477
1590
  // the orchestrator this serve runs, as installed: a newer install ends the serve after its tick, so whatever keeps it
@@ -1501,7 +1614,7 @@ async function serve(root, args = {}) {
1501
1614
  // the same kind as rounds that stopped, so it goes where every board verb's error line reads it. Only a refusal
1502
1615
  // in hand counts: the team's 4xx answer, which the daemon records with the publication (from supercode-teams
1503
1616
  // 0.3.44). An attempt still being retried, or a daemon that records no answer, says nothing here.
1504
- const held = boardPublishing(root)?.failing;
1617
+ const held = (await boardPublishing(root))?.failing;
1505
1618
  const refused = held && Number(held.status) >= 400 && Number(held.status) < 500 ? held : null;
1506
1619
  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
1620
  const temporary = `${heartbeat}.${process.pid}.tmp`;
@@ -1530,13 +1643,25 @@ async function serve(root, args = {}) {
1530
1643
  }
1531
1644
  };
1532
1645
  const running = installed();
1533
- log(`dispatching the boards of ${root} from ${localMachine()} (orchestrator ${running ?? 'unknown'})`);
1646
+ await knowLocalMachine();
1647
+ // The board's owner: it answers every card verb on this home's boards (answerVerbs) for as long as it runs, whether
1648
+ // or not it dispatches.
1649
+ // the held home is read again on its next use after a file it is decoded from changes (file events, no interval),
1650
+ // watched before anything reads it; a watch that cannot be kept stops the owner, for its keeper to start it again
1651
+ const unfollowHome = followHome(root, {
1652
+ onChange: (name) => log(`home changed (${name}): read again on its next use`),
1653
+ onLost: (error) => { log(`the home's files can no longer be watched (${error.message}); stopping, for the keeper to start the owner again`); stop(); },
1654
+ });
1655
+ let closeVerbs;
1656
+ try { closeVerbs = await answerVerbs(root, log); } catch (error) { unfollowHome(); throw error; }
1657
+ log(`owning the boards of ${root} on ${localMachine()} (orchestrator ${running ?? 'unknown'}); dispatching where a board's dispatching is on (\`supercode workflow dispatching\`)`);
1534
1658
  events = watchBoard({ root, binary: supercodeBin(), env: commandEnv(), log });
1535
1659
  // The board's events are published by this machine's connector, not by the dispatcher (D137, D138 step 4): the home is
1536
1660
  // 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)}`
1661
+ { let named = null, refusal = null;
1662
+ try { named = await machineCall(null, 'harness.v1.boards.register', { root }, { timeoutMs: 30_000 }); } catch (error) { refusal = error.message; }
1663
+ const refused = named?.refused ?? null;
1664
+ log(refusal ? `board events: not published; the machine daemon did not take this home: ${refusal}`
1540
1665
  : refused ? `board events: registered, but the team refused this machine's credential (${refused.status}); nothing is published until it is re-enrolled`
1541
1666
  : `board events: this home is published by the machine's connector`); }
1542
1667
  log('dispatch wakes on board writes, document edits and board mail; the interval sweep reads each machine\'s own door');
@@ -1559,14 +1684,23 @@ async function serve(root, args = {}) {
1559
1684
  if (wakeReason) log(`dispatch wake ${wakeReason.sources.join(', ')}; event age ${began - wakeReason.at}ms`);
1560
1685
  wakeReason = null;
1561
1686
  try {
1562
- const state = await laneState(root);
1687
+ const state = await ownCall(() => laneState(root));
1563
1688
  const settings = { ...state.settings, ...override };
1564
1689
  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);
1690
+ // Publishing is the owner's, dispatching or not: what a writer outside this process marked changed is published
1691
+ // on the wake its write caused, and the retained history kept bounded (store.mjs publishMarked).
1692
+ const published = await ownCall(async () => publishMarked(root));
1693
+ if (published && args.verbose) log(`published ${published} card(s) a writer outside the owner changed`);
1694
+ // Dispatching is a controller over the boards whose own state says it is on; the owner answers verbs either way.
1695
+ const dispatching = listBoards(root).filter((slug) => dispatchingOf(root, slug).on);
1696
+ const reports = dispatching.length ? await ownCall(() => tick({ root, lanes: state.lanes, settings, params, slugs: dispatching, deploymentReceipt: true })) : [];
1697
+ // with dispatching off on every board the owner only answers verbs: no round, no delivery, nothing told
1698
+ if (dispatching.length) {
1699
+ round += 1;
1700
+ // what the round recorded is delivered by a pass of its own, which this loop never waits for
1701
+ if (!stopped) startDelivery(root, { slugs: dispatching, log: (level, line) => { if (level === 'warn' || args.verbose) log(line); } });
1702
+ if (!stopped) await watchRounds(reports);
1703
+ }
1570
1704
  for (const r of reports) {
1571
1705
  nextMailAt = Math.min(nextMailAt, r.next_mail_at ?? Infinity);
1572
1706
  const said = summary(r);
@@ -1606,6 +1740,9 @@ async function serve(root, args = {}) {
1606
1740
  if (moved) { log(`orchestrator ${moved} is installed (this serve runs ${running}); stopping, for its keeper to start the new one`); break; }
1607
1741
  }
1608
1742
  } finally {
1743
+ closeVerbs();
1744
+ unfollowHome();
1745
+ await closeDoors();
1609
1746
  events?.close();
1610
1747
  for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP']) process.off(signal, stop);
1611
1748
  if (args.pidfile) { try { unlinkSync(args.pidfile); } catch { /* gone */ } }