@volter/supercode-orchestrator 0.5.205 → 0.5.206

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.
Files changed (50) hide show
  1. package/README.md +138 -4
  2. package/activation.mjs +93 -2
  3. package/adapters/rh2.mjs +7 -2
  4. package/agents.mjs +24 -2
  5. package/bin/agent-channel.mjs +41 -22
  6. package/bin/connection-runner.mjs +141 -0
  7. package/bin/orchestrator.mjs +11 -2
  8. package/bin/runtime.mjs +16 -9
  9. package/board/args.mjs +3 -2
  10. package/board/attachment-read.mjs +77 -0
  11. package/board/call.mjs +4 -2
  12. package/board/capacity.mjs +46 -9
  13. package/board/ci.mjs +157 -53
  14. package/board/cli.mjs +225 -87
  15. package/board/client.mjs +18 -2
  16. package/board/custody.mjs +109 -0
  17. package/board/dispatch.mjs +297 -75
  18. package/board/doors.mjs +47 -17
  19. package/board/engine.mjs +78 -24
  20. package/board/fleet-listeners.mjs +268 -0
  21. package/board/fleet-observer.mjs +79 -0
  22. package/board/fleet-targets.mjs +82 -0
  23. package/board/incidents.mjs +7 -0
  24. package/board/listing.mjs +5 -0
  25. package/board/machine-fleet.mjs +7 -0
  26. package/board/notice-receipts.mjs +67 -0
  27. package/board/owner-door-worker.mjs +61 -0
  28. package/board/owner-door.mjs +46 -0
  29. package/board/owner-health.mjs +8 -2
  30. package/board/published.mjs +2 -2
  31. package/board/record-view.mjs +2 -0
  32. package/board/store.mjs +55 -3
  33. package/board/tools.mjs +11 -4
  34. package/board/workflow.mjs +91 -28
  35. package/board/ztrack.mjs +87 -21
  36. package/board-input.d.ts +2 -0
  37. package/board-input.mjs +16 -0
  38. package/connection.mjs +153 -0
  39. package/index.d.ts +7 -5
  40. package/index.mjs +1 -1
  41. package/installed-apps.mjs +31 -20
  42. package/loop.mjs +91 -36
  43. package/mcp/bridge.mjs +12 -0
  44. package/mcp/hermes-kanban-tools.json +22 -14
  45. package/mcp/mount.mjs +10 -9
  46. package/ontology.d.ts +37 -0
  47. package/operator.mjs +72 -4
  48. package/orchestration.mjs +7 -2
  49. package/package.json +9 -5
  50. package/reducer.mjs +74 -5
package/board/cli.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  // `supercode workflow <verb>` (docs/architecture/orchestrator.md §2.9): supercode's own board, in Hermes Kanban's
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
- // output lines, so a caller of Hermes's CLI calls this one unchanged; supercode's own verbs (`set`, `adopt`, `close`,
3
+ // output lines, so a caller of Hermes's CLI calls this one unchanged; supercode's own verbs (`set`, `adopt`, `detach`, `close`,
4
4
  // `serve`, `--at`) are added beside them. The Rust CLI hands every verb but `load` to `bin/orchestrator.mjs workflow`,
5
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`.
@@ -10,26 +10,28 @@ import { DatabaseSync } from 'node:sqlite';
10
10
  import { homeOf, parse } from './args.mjs';
11
11
  import { BOARD_OPERATION_VERBS, boardHome, boardOwnerSocket, CALLER_FACTS } from '@volter/teams/board-owner';
12
12
  import { current, isCallersCall, ownCall, within } from './call.mjs';
13
+ import { detachSession } from './custody.mjs';
13
14
  import { taskStatusOf } from './published.mjs';
14
15
  import { executionOf } from './session-work.mjs';
15
- import { listCardsInTurns, writeCardList } from './listing.mjs';
16
- import { writeOwnerAnswer } from './reply.mjs';
16
+ import { attachmentRecord, readAttachment } from './attachment-read.mjs';
17
+ import { listCardsInTurns, READ_BY_OWNER, writeCardList } from './listing.mjs';
17
18
  import { redactText } from '@volter/teams/redact';
18
- import { createServer } from 'node:net';
19
- import { chmodSync, existsSync, mkdirSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, copyFileSync, readFileSync, appendFileSync, writeFileSync } from 'node:fs';
19
+ import { openOwnerDoor } from './owner-door.mjs';
20
+ import { existsSync, mkdirSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, copyFileSync, readFileSync, appendFileSync, writeFileSync } from 'node:fs';
20
21
  import { notRuntime, ownHome } from './owner-files.mjs';
21
22
  import { execFile, spawnSync } from 'node:child_process';
22
23
  import { holdLock } from './lock-holder.mjs';
23
24
  import { takeEpoch } from './fence.mjs';
24
25
  import { dispatcherNotice, holdOwnerHealth, holdRound, holdSwitch } from './owner-health.mjs';
25
26
  import { boardTool } from './tools.mjs';
27
+ import { assertSourceReceipt } from './ci.mjs';
26
28
  import { noteStep, recordSlow, takeSteps } from '@volter/supercode-harness-sdk/slow-log';
27
29
  // read by name, so an older harness SDK without them still loads: the owner then keeps no per-verb or loop lines
28
30
  import * as slowLog from '@volter/supercode-harness-sdk/slow-log';
29
31
  import { basename, dirname, join, resolve } from 'node:path';
30
32
  import { fileURLToPath } from 'node:url';
31
33
  import { homedir, tmpdir, userInfo } from 'node:os';
32
- import { actorOf, CALLER_RUN_ADDRESS, SESSION_CURRENT_RUNS, SESSION_RUNS, sessionIndexed, heldConfigWatched, heldDocumentsChanged, heldDocumentsWatched, holdBoards, retireHeldBoards, releaseHeldBoards, publishMarked, view, backingPaths, layOut, backingOf, boardExists, boardPath, briefPath, DEFAULT_BOARD, event, json, listBoards, logsDir, now, openBoard, setActor, setPrincipal, principalOf, isCaller, tx } from './store.mjs';
34
+ import { actorOf, CALLER_RUN_ADDRESS, CALLER_RUN_AGENT, SESSION_CURRENT_RUNS, SESSION_RUNS, sessionIndexed, heldConfigWatched, heldDocumentsChanged, heldDocumentsWatched, holdBoards, retireHeldBoards, releaseHeldBoards, publishMarked, view, backingPaths, layOut, backingOf, boardExists, boardCopyOf, boardPath, briefPath, DEFAULT_BOARD, event, json, listBoards, logsDir, now, openBoard, setActor, setPrincipal, principalOf, isCaller, tx } from './store.mjs';
33
35
  import { isLocalWorkspace } from './workspace.mjs';
34
36
  import { openZtrackBoard } from './ztrack.mjs';
35
37
  import * as engine from './engine.mjs';
@@ -69,9 +71,12 @@ async function boardPublishing(root) {
69
71
  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';
70
72
  import { watchBoard } from './wake.mjs';
71
73
  import { attachSideSession, releaseSideSession, sideArcs, sideSessions } from './side-sessions.mjs';
72
- import { onPanesChanged } from './machine-fleet.mjs';
74
+ import { onPanesChanged, onRecordsChanged, readFleetRecords, fleetRecordsCurrent } from './machine-fleet.mjs';
75
+ import { FleetListenerRouter, fleetListenerStatus } from './fleet-listeners.mjs';
76
+ import { enrichFleetEvent, observeRoleFleet } from './fleet-observer.mjs';
77
+ import { fileFleetNotice } from './dispatch.mjs';
73
78
  import { streamBoardPublications } from './event-stream.mjs';
74
- import { completionOwnerOf, followHome, isLatestReviewer, loadWorkflow, readHome, requestSessionClose, scopeOf, statusOf, useWorkflow, workflowEntry, workflowFor } from './workflow.mjs';
79
+ import { completionOwnerOf, followHome, isLatestReviewer, loadWorkflow, readHome, requestSessionClose, scopeOf, statusOf, timeOf, useWorkflow, workflowEntry, workflowFor } from './workflow.mjs';
75
80
  import { evaluate } from './expr.mjs';
76
81
  import { agentDoor,agentScope,activeAgentAssignments,reassignAgent,agentUsage,localAgentRecord,agentRunForSession } from '../agents.mjs';
77
82
 
@@ -94,18 +99,19 @@ The board's verbs, with their flags:
94
99
  boards list|create|rm|switch|show|rename|set-default-workdir|export|import
95
100
  create <title> [--body | --body-file PATH|-] [--assignee] [--parent]... [--workspace scratch|worktree:<repository>|dir:<repository>] [--branch] [--project]
96
101
  [--tenant] [--priority] [--triage] [--idempotency-key] [--max-runtime 30m] [--created-by] [--skill]... [--max-retries]
97
- [--model] [--provider] [--goal|--no-goal] [--goal-max-turns] [--initial-status blocked|running]
102
+ [--model] [--provider] [--reviewer PROFILE] [--goal|--no-goal] [--goal-max-turns] [--initial-status blocked|running]
98
103
  swarm <goal> --worker PROFILE:TITLE[:SKILL,SKILL]... --verifier P --synthesizer P
99
104
  list [--mine] [--assignee] [--status] [--tenant] [--session] [--archived] [--sort] show <id> context <id> brief <id> [--run N] runs <id>
100
- set <id> <assignee|machine|model|every> <value|none> [--provider] [--ids ...] assign <id> <profile|none> set-model <id> [model] [--provider] set-goal <id>... [--goal-max-turns N] [--off]
105
+ set <id> <assignee|machine|model|every|reviewer> <value|none> [--provider] [--ids ...] assign <id> <profile|none> set-model <id> [model] [--provider] set-goal <id>... [--goal-max-turns N] [--off]
101
106
  reassign <id> --agent AGENT [--role implementer] (moves a card role's assignment to another agent; sessions keep their identity)
102
107
  claim <id> [--ttl] reclaim <id> [--reason] heartbeat <id> [--note] [--until <time|30m>] link|unlink <parent> <child>
103
108
  comment <id> (<text> | --file PATH|-) [--author] [--max-len] [(--pr URL --pr-head SHA)...] attach <id> <path> [--name] [--content-type] attachments <id> attach-rm <n>
104
- complete <id>... [--result] [--summary] [--metadata JSON] edit <id> --result [--summary] [--metadata]
109
+ attachment-read <id> <attachment-id> [--offset N --length N --version V] bounded base64/metadata JSON, including through --at
110
+ complete <id>... [--result] [--summary] [--metadata JSON] [--commit SHA] edit <id> --result [--summary] [--metadata]
105
111
  block <id> [reason] (--on <card> | --waiting-on <message id>) [--ids ...] schedule <id> [reason] [--ids ...]
106
112
  pause <id> --reason <why> resume <id> cancel <id> --reason <why>
107
113
  request-state <id> --state block|pause|done|cancelled --reason <why> [--on <card>|--waiting-on <message>] [--evidence <pointers>]
108
- unblock <id>... --on <blocker> --reason <what arrived> request-review <id> (--pr URL --pr-head SHA)... [--summary] [--reviewer] [--force] request-changes <id> <reason> [--finding <finding>]... reopen-review <id>... reopen <id>... [--reason]
114
+ unblock <id>... --on <blocker> --reason <what arrived> request-review <id> (--pr URL --pr-head SHA)... [--summary] [--reviewer] [--force] [--commit SHA] request-changes <id> <reason> [--finding <finding>]... reopen-review <id>... reopen <id>... [--reason]
109
115
  promote <id> [reason] [--ids ...] [--force] [--dry-run] archive <id>... --reason <why> [--rm <id>...]
110
116
  specify <id> [--title --body | --body-file PATH|-] [--workspace scratch|worktree:<repository>|dir:<repository>] [--branch NAME] [--machine M|none] [--all]
111
117
  decompose <id> [--child "<title>[@<profile>]"]... [--all]
@@ -131,7 +137,7 @@ And:
131
137
  create … --machine M --subtask-of ARC --notify ADDRESS|--no-notify --no-start
132
138
  With a pane lane and room, a card starts at once and answers "started" with its session; else it answers why it
133
139
  waits, and its creator is told when it starts. A subtask is worked by its arc's session and never starts alone.
134
- adopt <id> <session> [--assignee P] [--role ROLE] adopt <arc> <session> --side · release <arc> <session> close <id> serve (= daemon) list --machine M
140
+ adopt <id> <session> [--assignee P] [--role ROLE] adopt <arc> <session> --side · release <arc> <session> detach <id> [--to person:ID] close <id> serve (= daemon) list --machine M
135
141
  notify-subscribe <id> --address sc:<machine>:<harness>:<id> [--role creator|worker|dependency]
136
142
  notify-subscribe <dependency> --for-card <your-arc>: subscribe only your arc's session to dependency notices.
137
143
  notify-unsubscribe <dependency> --for-card <your-arc>: remove that arc's subscription only.
@@ -148,6 +154,7 @@ And:
148
154
  request-state <id> --state done (--reviewed-sha SHA)...: record the verdict on every requested PR head.
149
155
  request-state <id>... --state STATE --reason REASON: request several decisions in one retained manager mail.
150
156
  complete <id>: creator/manager accepts the independently reviewed arc; deployment CI is not a board gate.
157
+ listeners status [--json]: declared role fleet listeners, retained filing counts, source cursors and gaps.
151
158
  specify/decompose with no spec start a session on the triage card to write it. A card's own session may comment on,
152
159
  heartbeat and request review of its card, specify or decompose it in triage, and work its subtasks. A side session
153
160
  (adopt --side) works the arc's subtasks and comments on the arc; the arc's run and state stay its main session's. The home's
@@ -255,17 +262,40 @@ const callerName = () => current().facts.HERMES_PROFILE || current().facts.USER
255
262
  * automation running it, its own plain session, or the person its Teams login names. Never the OS user; null when
256
263
  * nothing names one.
257
264
  */
258
- async function callerPrincipal(root, sid, { teams = false } = {}) {
265
+ async function ownerStep(name, fn) {
266
+ const began = performance.now();
267
+ try { return await fn(); }
268
+ finally { noteStep(`owner preparation: ${name}`, performance.now() - began); }
269
+ }
270
+
271
+ function recordedRunAgent(db, sid, address) {
272
+ const rows = db.prepare(CALLER_RUN_AGENT).all(sid, address);
273
+ const owners = new Map();
274
+ for (const row of rows) {
275
+ if (!/^agent_[0-9a-f]{32}$/.test(row.agent_id) || typeof row.org !== 'string' || !row.org) continue;
276
+ owners.set(JSON.stringify([row.org, row.agent_id]), row);
277
+ }
278
+ if (owners.size > 1) throw new Error('recorded native session has conflicting agent ownership');
279
+ return owners.values().next().value ?? null;
280
+ }
281
+
282
+ async function callerPrincipal(root, sid, { teams = false, recordedAgent = null } = {}) {
259
283
  const facts = current().facts;
284
+ if (recordedAgent && facts.SUPERCODE_AGENT_ID && facts.SUPERCODE_AGENT_ID !== recordedAgent.agent_id)
285
+ throw new Error('native session ownership does not match its launch identity');
286
+ if (recordedAgent && facts.SUPERCODE_AGENT_ORG && facts.SUPERCODE_AGENT_ORG !== recordedAgent.org)
287
+ throw new Error('native session Org does not match its launch identity');
260
288
  if (facts.SUPERCODE_AGENT_ID) return `agent:${facts.SUPERCODE_AGENT_ID}`;
261
289
  if (facts.SUPERCODE_AUTOMATION) return `automation:${facts.SUPERCODE_AUTOMATION}`;
262
290
  if (sid) {
263
291
  const harness = boardCaller()?.harness ?? (sid === process.env.CODEX_THREAD_ID ? 'codex' : 'claude-code');
264
- const owner = agentRunForSession(root, [`${harness}:${sid}`]);
265
- if (owner) return `agent:${owner.agent_id}`;
292
+ const owner = await ownerStep('local agent ownership', () => agentRunForSession(root, [`${harness}:${sid}`]));
293
+ if (owner && recordedAgent && (owner.agent_id !== recordedAgent.agent_id || owner.org !== recordedAgent.org))
294
+ throw new Error('local and recorded native session ownership conflict');
295
+ if (owner || recordedAgent) return `agent:${(owner ?? recordedAgent).agent_id}`;
266
296
  // a session an agent declared elsewhere owns (a manager's main session) is that agent's, as Teams records it
267
297
  let declared = null;
268
- try { declared = (await agentDoor('session', [`${harness}:${sid}`], { context: current().facts.SUPERCODE_CONTEXT })).data?.agent?.id ?? null; } catch { /* no Teams: the session */ }
298
+ try { declared = (await ownerStep('Teams session ownership', () => agentDoor('session', [`${harness}:${sid}`], { context: current().facts.SUPERCODE_CONTEXT }))).data?.agent?.id ?? null; } catch { /* no Teams: the session */ }
269
299
  return declared ? `agent:${declared}` : `session:${callerAddress()}`;
270
300
  }
271
301
  if (!teams) return null;
@@ -299,14 +329,15 @@ const ts = (t) => {
299
329
  return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
300
330
  };
301
331
  // a card's status is the workflow's (triaging, reviewing, …); kanban.db stores the Hermes status it maps to
302
- const line = (t) => `${ICONS[t.status] ?? '?'} ${t.id} ${(t.stage ?? t.status).padEnd(8)}${['running','review'].includes(t.status) ? ` [session ${t.execution?.state ?? 'unknown'}]` : ''} ${(t.assignee || '(unassigned)').padEnd(20)}${t.tenant ? ` [${t.tenant}]` : ''} ${t.title}${t.machine ? ` @${t.machine}` : ''}`;
332
+ const line = (t) => `${ICONS[t.status] ?? '?'} ${t.id} ${(t.stage ?? t.status).padEnd(8)}${t.detached ? ` [session belongs to ${t.detached.person}]` : ['running','review'].includes(t.status) ? ` [session ${t.execution?.state ?? 'unknown'}]` : ''} ${(t.assignee || '(unassigned)').padEnd(20)}${t.tenant ? ` [${t.tenant}]` : ''} ${t.title}${t.machine ? ` @${t.machine}` : ''}`;
303
333
  const cardDict = (t) => ({
304
334
  id: t.id, title: t.title, body: t.body, assignee: t.assignee, status: t.status, priority: t.priority, tenant: t.tenant,
305
335
  workspace_kind: t.workspace_kind, repository: t.workspace_anchor ?? null, workspace_path: t.workspace_path, branch_name: t.branch_name, project_id: t.project_id,
306
336
  created_by: t.created_by, created_at: t.created_at, started_at: t.started_at, completed_at: t.completed_at, result: t.result,
307
337
  skills: t.skills ?? [], max_retries: t.max_retries, model_override: t.model_override, provider_override: t.provider_override,
308
338
  session_id: t.session_id, workflow_template_id: t.workflow_template_id, current_step_key: t.current_step_key,
309
- machine: t.machine ?? null, subtask_of: t.subtask_of ?? null, execution: t.execution ?? null,
339
+ machine: t.machine ?? null, subtask_of: t.subtask_of ?? null, execution: t.execution ?? null, detached: t.detached ?? null,
340
+ reviewer: t.reviewer ?? null,
310
341
  });
311
342
 
312
343
  /** Hermes's durations: seconds, or 30s / 5m / 2h / 1d. */
@@ -337,12 +368,20 @@ const metadataOf = (v) => {
337
368
 
338
369
  /** One verb in full, in the call it runs in (call.mjs): a caller's in the owner, or the process's own here. */
339
370
  export async function runVerb(argv) {
340
- await knowLocalMachine();
341
371
  const args = parse(argv);
372
+ assertSourceReceipt(args);
373
+ await ownerStep('machine identity', () => knowLocalMachine());
342
374
  const [verb, ...rest] = args._;
343
375
  if (!verb || verb === 'help' || args.help) { current().out(HELP); return 0; }
344
376
  const root = homeOf(args);
345
377
  if (verb === 'events') return streamBoardPublications({root,after:args.after,once:args.once});
378
+ if (verb === 'listeners') {
379
+ if (rest.length && rest[0] !== 'status') throw new Error('workflow listeners accepts status; declarations live in workflow.yaml listeners');
380
+ const orchestration = await readHome(root);
381
+ const status = { declarations: orchestration.workflow?.listeners ?? {}, ...fleetListenerStatus(root) };
382
+ say(args, JSON.stringify(status, null, 2), status);
383
+ return 0;
384
+ }
346
385
  // C4: one task store per Org. A named board without a store of its own is a saved view over it: its tag selects
347
386
  // the cards it shows, and a card filed through it carries that tag.
348
387
  const named = boardOf(root, args);
@@ -357,20 +396,35 @@ export async function runVerb(argv) {
357
396
 
358
397
  // a caller that is a card's own session (its harness names it; the board recorded which session it launched)
359
398
  const listing = ['list','ls'].includes(verb);
360
- await loadWorkflow(root).catch((error) => { if (verb !== 'init' && verb !== 'boards') throw error; });
399
+ const readingDispatching = verb === 'dispatching' && rest.length === 0;
400
+ const reading = READS.has(verb) || readingDispatching;
401
+ // a listing on the owner's read door reads the workflow the owner holds (owner-door.mjs), and names no session: it
402
+ // records nothing, and `--mine` is the caller's profile
403
+ const readDoor = Boolean(current().readOnly);
404
+ if (!readDoor) await ownerStep('workflow', () => loadWorkflow(root)).catch((error) => { if (verb !== 'init' && verb !== 'boards') throw error; });
361
405
  // A listing does not authorize a transition and needs no manager's main session.
362
- if (!listing) await readAgentMains(root);
363
- const sid = listing && !args.mine ? null : callerSession();
406
+ if (!listing && !readingDispatching) await ownerStep('manager main sessions', () => readAgentMains(root));
407
+ const sid = listing && (!args.mine || readDoor) ? null : callerSession();
364
408
  // what this command records is the calling session's act (its address, as its subscriptions name it)
365
409
  current().recorded = sid && boardExists(root, slug) ? withBoard((b) => b.db.prepare(CALLER_RUN_ADDRESS).get(sid)?.address) ?? null : null;
366
410
  if (sid) setActor(callerAddress());
367
- setPrincipal(await callerPrincipal(root, sid, { teams: !sid && !READS.has(verb) }));
411
+ const recordedAgent = sid && boardExists(root, slug)
412
+ ? await ownerStep('committed native agent ownership', () => withBoard((b) => recordedRunAgent(b.db, sid, callerAddress()))) : null;
413
+ setPrincipal(await ownerStep('caller principal', () => callerPrincipal(root, sid, { teams: !sid && !reading, recordedAgent })));
368
414
  // the dispatcher as the owner holds it, said once per call (owner-health.mjs; nothing is read for it)
369
415
  if (!current().said.has(`dispatcher:${slug}`)) { current().said.add(`dispatcher:${slug}`); for (const line of dispatcherNotice(slug)) current().err(`${line}\n`); }
370
- if (sid && !READS.has(verb) && boardExists(root, slug)) {
416
+ if (sid && !reading && boardExists(root, slug)) {
371
417
  // binding the caller's launch is a write: only the owner, answering the caller's own verb, makes it (a local read
372
418
  // never writes)
373
- await withBoardAsync(async (b) => { if (isCallersCall()) await reconcileCallerLaunch(b, sid); const own = { ...ownCards(b.db, sid), side: sideArcs(b.db, sid) }; if (own.worked.size || own.side.size) authorize(b.db, verb, rest, args, own); });
419
+ await withBoardAsync(async (b) => {
420
+ if (isCallersCall()) await reconcileCallerLaunch(b, sid);
421
+ const own = { ...ownCards(b.db, sid), side: sideArcs(b.db, sid) };
422
+ if (!own.worked.size && !own.side.size) return;
423
+ // A copied home owns no live sessions or manager addresses. Its owner may
424
+ // answer local verbs while every board's dispatching remains off.
425
+ const copyServe = verb === 'serve' && Boolean(boardCopyOf(b)) && listBoards(root).every(board => !dispatchingOf(root, board).on);
426
+ authorize(b.db, verb, rest, args, own, { copyServe });
427
+ });
374
428
  }
375
429
  // the board's workflow (the IR's): what each verb does, and who may send it, is its
376
430
  // every verb reads the board through its workflow (a list settles cards too), so every verb loads it
@@ -466,6 +520,7 @@ export async function runVerb(argv) {
466
520
  const sponsorship = args.sponsor || args['requested-by'] ? await requestedSponsor(args) : principalOf()?.startsWith('person:') ? { sponsor: principalOf().slice('person:'.length) } : {};
467
521
  const created = await withBoardAsync(async (b) => engine.createCard(b, await engine.prepareCard(b, {
468
522
  title: rest.join(' '), body: textFrom(args['body-file'], args.body), assignee: args.assignee === 'none' ? null : args.assignee, machine: args.machine,
523
+ reviewer: args.reviewer ?? null,
469
524
  workspace, branch: args.branch, parents: args.parent, priority: args.priority, tenant: args.tenant,
470
525
  triage: args.triage, idempotencyKey: args['idempotency-key'], maxRuntime: duration(args['max-runtime']),
471
526
  maxRetries, skills: args.skill, model: args.model, provider: args.provider, createdBy: args['created-by'] ?? null,
@@ -543,7 +598,12 @@ export async function runVerb(argv) {
543
598
  got.runs = got.runs.map(run => ({ ...run, execution: executionOf(run) }));
544
599
  got.diagnostics = engine.diagnose(b.db, got.task, { failureLimit: settings.failureLimit, launchWindowSeconds: settings.launchWindow });
545
600
  got.stage = statusOf(b.db, got.task);
546
- got.time = scopeOf(b.db, b, got.task, { params: workflowFor(root).params }).card.time;
601
+ const entry = workflowFor(root);
602
+ // A workflow may explicitly replace the built-in time field. Keep its
603
+ // full derived scope; ordinary totals need only this card's runs.
604
+ got.time = Object.hasOwn(entry.workflow.derived ?? {}, 'time')
605
+ ? scopeOf(b.db, b, got.task, { params: entry.params }).card.time
606
+ : timeOf(b.db, got.task);
547
607
  return got;
548
608
  });
549
609
  const t = card.task;
@@ -554,6 +614,8 @@ export async function runVerb(argv) {
554
614
  }
555
615
  const out = [`Task ${t.id}: ${t.title}`, ` status: ${taskStatusOf(card.stage, t.completed_at)} (lane ${card.stage}${card.stage !== t.status ? `, ${t.status} in kanban.db` : ''})`, ` assignee: ${t.assignee || '-'}`];
556
616
  if (t.machine) out.push(` machine: ${t.machine}`);
617
+ if (t.reviewer) out.push(` reviewer: ${t.reviewer} (card override)`);
618
+ if (t.detached) out.push(` session: ${t.detached.session.address} belongs to ${t.detached.person} (detached ${ts(t.detached.at)}; dispatcher does not own it)`);
557
619
  // where the card's time went: each role's runs, and the rest waiting
558
620
  const mins = (sec) => `${Math.round(sec / 60)}m`;
559
621
  out.push(` time: ${Object.entries(card.time).filter(([, v]) => v > 0).map(([k, v]) => `${k} ${mins(v)}`).join(', ') || '-'}`);
@@ -587,7 +649,7 @@ export async function runVerb(argv) {
587
649
  if (card.comments.length) { out.push('', `Comments (${card.comments.length}):`); for (const c of card.comments) out.push(` [${ts(c.created_at)}] ${c.author}: ${c.body}`); }
588
650
  // the card's history, without the board's own bookkeeping (a claim, a session kept or started, a goal ask, a
589
651
  // message queued); `--all` shows every event, `--json` always does
590
- const PLUMBING = new Set(['claimed', 'session_started', 'session_resumed', 'session_adopted', 'session_replaced', 'session_closed',
652
+ const PLUMBING = new Set(['claimed', 'session_started', 'session_resumed', 'session_adopted', 'agent_session_queued', 'session_replaced', 'session_closed',
591
653
  'goal_set', 'goal_continued', 'decision_asked', 'board_message', 'board_message_sent', 'heartbeat', 'promoted', 'respawn_guarded',
592
654
  'claim_extended', 'reclaimed', 'reconciled', 'status']);
593
655
  const history = args.all ? card.events : card.events.filter((e) => !PLUMBING.has(e.kind));
@@ -600,7 +662,7 @@ export async function runVerb(argv) {
600
662
  for (const r of card.runs) {
601
663
  const s = r.metadata?.supercode;
602
664
  out.push(` #${String(r.id).padEnd(3)} ${(r.outcome || r.status || 'active').padEnd(12)} @${r.profile || '-'} ${r.ended_at ? `${Math.max(0, r.ended_at - r.started_at)}s` : 'active'} ${ts(r.started_at)}`);
603
- if (s) out.push(` ⇢ ${s.session_id ? `session ${s.session_id}${s.pane ? ` in pane ${s.pane}` : ''}` : `worker pid ${s.pid}`} on ${s.machine} (${s.mode}); observed ${executionOf(r).state}`);
665
+ if (s) out.push(` ⇢ ${s.session_id ? `session ${s.session_id}${s.pane ? ` in pane ${s.pane}` : ''}` : `worker pid ${s.pid}`} on ${s.machine} (${s.mode}); ${s.detached_at ? `handed to ${s.detached_to} at ${ts(s.detached_at)}` : `observed ${executionOf(r).state}`}`);
604
666
  if (r.summary) out.push(` → ${r.summary.split('\n')[0].slice(0, 160)}`);
605
667
  if (r.error) out.push(` ! ${r.error.split('\n')[0].slice(0, 160)}`);
606
668
  }
@@ -746,6 +808,14 @@ export async function runVerb(argv) {
746
808
  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`);
747
809
  return 0;
748
810
  }
811
+ case 'attachment-read': {
812
+ need(2, 'a card id and an attachment id');
813
+ const row = withBoard((b) => attachmentRecord(b.db, id, Number(rest[1])));
814
+ const range = await readAttachment(root, slug, row, { offset: args.offset === undefined ? 0 : Number(args.offset),
815
+ length: args.length === undefined ? undefined : Number(args.length), version: args.version ?? null });
816
+ current().out(`${JSON.stringify(range)}\n`);
817
+ return 0;
818
+ }
749
819
  case 'attach-rm': {
750
820
  need(1, 'an attachment id');
751
821
  const row = withBoard((b) => engine.removeAttachment(b, Number(id)));
@@ -756,7 +826,7 @@ export async function runVerb(argv) {
756
826
  case 'complete': {
757
827
  need(1, 'a card id');
758
828
  const metadata = metadataOf(args.metadata);
759
- for (const t of rest) await withBoardAsync((b) => engine.complete(b, t, { summary: args.summary ?? null, result: args.result ?? null, metadata: metadata ?? null, commit: args.commit ?? null, ci_run: args['ci-run'] ?? null, pr: args.pr ?? null, pr_head: args['pr-head'] ?? null, reviewed_sha: args['reviewed-sha'] ?? null }));
829
+ for (const t of rest) await withBoardAsync((b) => engine.complete(b, t, { summary: args.summary ?? null, result: args.result ?? null, metadata: metadata ?? null, commit: args.commit ?? null, pr: args.pr ?? null, pr_head: args['pr-head'] ?? null, reviewed_sha: args['reviewed-sha'] ?? null }));
760
830
  say(args, rest.map((t) => `Completed ${t}`).join('\n'), { ok: true, completed: rest }); return 0;
761
831
  }
762
832
  case 'edit': {
@@ -786,7 +856,7 @@ export async function runVerb(argv) {
786
856
  if (args.reason) for (const [t] of results) await withBoardAsync((b) => engine.comment(b, t, callerName(), `UNBLOCK: ${args.reason}`));
787
857
  say(args, results.map(([t, s]) => `Unblocked ${t} → ${s}`).join('\n'), { ok: true, results }); return 0;
788
858
  }
789
- case 'request-review': need(1, 'a card id'); await withBoardAsync((b) => engine.requestReview(b, id, { summary: args.summary ?? null, reviewer: args.reviewer ?? null, metadata: metadataOf(args.metadata) ?? null, commit: args.commit ?? null, ci_run: args['ci-run'] ?? null, branch: args.branch ?? null, pr: args.pr ?? null, pr_head: args['pr-head'] ?? args.head ?? args.commit ?? null, force: Boolean(args.force), expectedRunId: workerRun(b.db, id, sid) })); say(args, `${id} → review`, { ok: true }); return 0;
859
+ case 'request-review': need(1, 'a card id'); await withBoardAsync((b) => engine.requestReview(b, id, { summary: args.summary ?? null, reviewer: args.reviewer ?? null, metadata: metadataOf(args.metadata) ?? null, commit: args.commit ?? null, branch: args.branch ?? null, pr: args.pr ?? null, pr_head: args['pr-head'] ?? args.head ?? args.commit ?? null, force: Boolean(args.force), expectedRunId: workerRun(b.db, id, sid) })); say(args, `${id} → review`, { ok: true }); return 0;
790
860
  case 'request-changes': {
791
861
  need(2, 'a card id and the reason');
792
862
  // each finding is filed as a task on the arc first, so the arc stays open until a pushed head fixes it
@@ -1120,6 +1190,17 @@ export async function runVerb(argv) {
1120
1190
  const session = await withBoardAsync(b => startCard(b, id, { lanes, settings, roles: rolesFor(b.db, id, sid, workflowFor(root).params) }));
1121
1191
  say(args, `${id}: ${session?.address ?? 'started'}`, { ok: true, session }); return 0;
1122
1192
  }
1193
+ case 'detach': {
1194
+ need(1, 'a card id');
1195
+ const person = args.to ?? (principalOf()?.startsWith('person:') ? principalOf() : null);
1196
+ const detached = withBoard(b => {
1197
+ const roles = rolesFor(b.db, id, sid, workflowFor(root).params);
1198
+ if (!roles.includes('manager') && !(roles.includes('creator') && principalOf()?.startsWith('person:') && person === principalOf())) throw new Error('Only the manager or the signed-in person who created the card detaches its session.');
1199
+ return detachSession(b, id, person);
1200
+ });
1201
+ say(args, `${id}: ${detached.session.address} belongs to ${detached.person}; the dispatcher no longer owns it`, { ok: true, detached });
1202
+ return 0;
1203
+ }
1123
1204
  case 'adopt': {
1124
1205
  need(2, 'a card id and a session');
1125
1206
  withBoard(b => { if (!rolesFor(b.db, id, sid, workflowFor(root).params).includes('manager')) throw new Error('Only the manager adopts an existing session.'); });
@@ -1161,7 +1242,7 @@ export async function runVerb(argv) {
1161
1242
  if (!declared) throw new Error(`no workflow verb ${verb}; \`supercode workflow help\` lists them`);
1162
1243
  need(1, 'a card id');
1163
1244
  const { _: _words, root: _r, board: _b, json: _j, at: _a, ids: _i, ...fields } = args;
1164
- const payload = { ...fields, ci_run: args['ci-run'] ?? fields.ci_run, deployed_sha: args['deployed-sha'] ?? fields.deployed_sha, reviewed_sha: args['reviewed-sha'] ?? fields.reviewed_sha, pr_head: args['pr-head'] ?? fields.pr_head, waiting_on: args['waiting-on'] ?? fields.waiting_on, reason: args.reason ?? (name === 'request_state' ? null : rest.slice(1).join(' ') || null) };
1245
+ const payload = { ...fields, reviewed_sha: args['reviewed-sha'] ?? fields.reviewed_sha, pr_head: args['pr-head'] ?? fields.pr_head, waiting_on: args['waiting-on'] ?? fields.waiting_on, reason: args.reason ?? (name === 'request_state' ? null : rest.slice(1).join(' ') || null) };
1165
1246
  const results = [];
1166
1247
  if (name === 'request_state') results.push(...await withBoardAsync(board => engine.requestStateMany(board, [...rest, ...(args.ids ?? [])], payload)));
1167
1248
  else for (const t of [id, ...(args.ids ?? [])]) results.push([t, (await withBoardAsync((b) => engine.act(b, t, name, { payload }))).to]);
@@ -1426,7 +1507,7 @@ function ownCards(db, sessionId) {
1426
1507
  return { worked, held };
1427
1508
  }
1428
1509
 
1429
- const READS = new Set(['list', 'ls', 'show', 'context', 'brief', 'runs', 'log', 'tail', 'watch', 'stats', 'assignees', 'diagnostics', 'diag', 'github-usage', 'attachments', 'notify-list', 'capacity', 'help', 'init']);
1510
+ const READS = new Set(['list', 'ls', 'show', 'context', 'brief', 'runs', 'log', 'tail', 'watch', 'stats', 'assignees', 'diagnostics', 'diag', 'attachments', 'attachment-read', 'github-usage', 'notify-list', 'capacity', 'help', 'init']);
1430
1511
 
1431
1512
  /** The main session of each agent the workflow names a manager (`sc:<machine>:agent:<name>`), asked of this machine's
1432
1513
  * daemon, which keeps agents' records (mail step 1c), once per call before its verb runs: a manager named by its agent
@@ -1479,10 +1560,11 @@ function rolesFor(db, cardId, sessionId, params = {}) {
1479
1560
  * subtasks, and the subtasks of the arcs it is a side session of, and those arcs themselves only to comment on. What it
1480
1561
  * may do to them is the workflow's (rolesFor).
1481
1562
  */
1482
- function authorize(db, verb, rest, args, own) {
1563
+ function authorize(db, verb, rest, args, own, { copyServe = false } = {}) {
1483
1564
  const cards = [...new Set([...own.worked, ...own.side])];
1484
1565
  const named = cards.join(', ');
1485
1566
  const refuse = (why) => { throw new Error(`the session working ${named} ${why}`); };
1567
+ if (verb === 'serve' && copyServe) return;
1486
1568
  if (verb === 'create') {
1487
1569
  if (!own.worked.has(args['subtask-of']) && !own.side.has(args['subtask-of'])) refuse(`files subtasks of its own card only (--subtask-of ${cards.join(' or ')})`);
1488
1570
  return;
@@ -1490,7 +1572,7 @@ function authorize(db, verb, rest, args, own) {
1490
1572
  if (verb === 'boards') { if (!['list', 'ls', 'show', 'current', undefined].includes(rest[0])) refuse('does not change the home\'s boards'); return; }
1491
1573
  // verbs that act on the board rather than on a card are the manager's and the board's, never a card's session's
1492
1574
  // (what a session may do to a card is its workflow's: each transition's `by`)
1493
- if ((['pause', 'resume'].includes(verb) && !rest[0]) || ['swarm', 'dispatch', 'daemon', 'serve', 'gc', 'repair', 'adopt', 'release', 'close', 'claim', 'attach-rm', 'migrate'].includes(verb)) refuse(`does not ${verb}; the fleet's manager does`);
1575
+ if ((['pause', 'resume'].includes(verb) && !rest[0]) || ['swarm', 'dispatch', 'daemon', 'serve', 'gc', 'repair', 'adopt', 'release', 'detach', 'close', 'claim', 'attach-rm', 'migrate'].includes(verb)) refuse(`does not ${verb}; the fleet's manager does`);
1494
1576
  if (verb === 'set' && !(own.worked.has(rest[0]) && rest[1] === 'every')) refuse("sets only Every on its own auditor card; other properties are the manager's");
1495
1577
  if (args.all) refuse(`does not ${verb} every card; only its own`);
1496
1578
  if (['notify-subscribe', 'notify-unsubscribe'].includes(verb) && args['for-card']) {
@@ -1554,10 +1636,11 @@ function setDispatching(root, slug, on, reason) {
1554
1636
 
1555
1637
  /**
1556
1638
  * The owner's door: every card verb on this home's boards, from the CLI and the machine daemon's board door, answered
1557
- * 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
1558
- * its whole life by a holder process of its own (an exclusive SQLite lock on `<home>/board.lock`, released when the
1559
- * owner ends and the holder's standard input closes): a second serve refuses, and only the lock's owner removes and
1560
- * binds the socket.
1639
+ * in a call of its own (call.mjs), with the home this process holds. The socket is served on a thread of its own
1640
+ * (owner-door.mjs): a listing is read there, and every other verb is answered here, by the board's one writer. One owner
1641
+ * per home, by an OS lock held for its whole life by a holder process of its own (an exclusive SQLite lock on
1642
+ * `<home>/board.lock`, released when the owner ends and the holder's standard input closes): a second serve refuses,
1643
+ * and only the lock's owner removes and binds the socket.
1561
1644
  */
1562
1645
  async function answerVerbs(root, log) {
1563
1646
  const home = boardHome(root);
@@ -1577,59 +1660,51 @@ async function answerVerbs(root, log) {
1577
1660
  log(`owner epoch ${epoch}`);
1578
1661
  const path = boardOwnerSocket(root);
1579
1662
  try { unlinkSync(path); } catch { /* none left */ }
1580
- // allowHalfOpen: a caller that ends its side after its request still gets the answer, written when it is ready
1581
- const server = createServer({ allowHalfOpen: true }, (socket) => {
1582
- let input = '';
1583
- socket.setEncoding('utf8');
1584
- socket.on('error', () => { /* the caller went away */ });
1585
- socket.on('data', (chunk) => {
1586
- if (input === null) return;
1587
- input += chunk;
1588
- const end = input.indexOf('\n');
1589
- if (end < 0) return;
1590
- const line = input.slice(0, end);
1591
- input = null;
1592
- // one answer per call, whatever the request did: a request that fails is a failed answer, never the owner's end
1593
- answerOne(root, line)
1594
- .catch((error) => ({ code: 1, stdout: '', stderr: `supercode workflow: ${error?.message ?? error}\n` }))
1595
- .then((answer) => writeOwnerAnswer(socket, answer))
1596
- .catch(() => { socket.destroy(); }); // a disconnected caller leaves no queued reply
1597
- });
1598
- });
1599
- await new Promise((done, fail) => { server.once('error', fail); server.listen(path, () => { server.off('error', fail); done(); }); });
1600
- try { chmodSync(path, 0o600); } catch { /* the home is the user's own */ }
1663
+ let closeDoor;
1664
+ try { closeDoor = await openOwnerDoor(root, { machine: localMachine(), answer: (line) => answerOne(root, line), log }); }
1665
+ catch (error) { lock.release(); throw error; }
1601
1666
  log(`answering the card verbs of ${root} at ${path}`);
1602
- return () => { server.close(); try { unlinkSync(path); } catch { /* gone */ } lock.release(); };
1667
+ return () => { closeDoor(); try { unlinkSync(path); } catch { /* gone */ } lock.release(); };
1603
1668
  }
1604
1669
 
1605
- /** One request to the owner, answered whole: the verb run in the caller's call, its output kept for the answer. A
1606
- * verb that takes more than a second is a slow line of its own, with the steps that ended while it ran (its own and,
1607
- * in this one process, any other call's that held it) and what the whole process used over the verb's wall time
1608
- * (`whole_process`, slow-log.mjs processUse: every thread and every other call in flight, never the verb alone): its
1609
- * processor time beside the line's `ms` says whether the time was computed or waited for, and the block reads and
1610
- * major page faults say on what. */
1611
- async function answerOne(root, line) {
1612
- const began = performance.now();
1613
- const before = slowLog.processCounters?.() ?? null;
1614
- let named = null, by = null;
1615
- let answer;
1616
- try { answer = await answerRequest(root, line, (verb, caller) => { named = verb; by = caller; }); return answer; }
1617
- finally {
1618
- const ms = performance.now() - began;
1619
- if (named) recordSlow({ kind: 'call', name: `owner verb ${named}`, ms, force: answer?.code !== 0,
1620
- outcome: answer ? answer.code === 0 ? 'ok' : `exit ${answer.code}` : 'unknown', slowest: null,
1621
- extra: { caller: by, response: { code: answer?.code ?? null, stderr: redactText([].concat(answer?.stderr ?? []).join('')).slice(0, 2000) },
1622
- steps: slowLog.stepsWithin?.(began, performance.now()) ?? null, whole_process: before ? slowLog.processUse(before) : null } });
1623
- }
1670
+ /** One request's own steps and the separate process activity during its wait.
1671
+ * Show/list/comment/create also retain receipts below the slow-log threshold;
1672
+ * absence from a slow-only log cannot prove those calls met the time bound. */
1673
+ export async function answerOne(root, line, { readOnly = false } = {}) {
1674
+ const scoped = typeof slowLog.withStepScope === 'function';
1675
+ const run = async () => {
1676
+ const began = performance.now(), before = slowLog.processCounters?.() ?? null;
1677
+ let named = null, by = null, answer = null, outcome = 'error';
1678
+ try {
1679
+ answer = await answerRequest(root, line, (verb, caller) => { named = verb; by = caller; }, { readOnly });
1680
+ outcome = answer?.code === 0 ? 'ok' : `exit ${answer?.code ?? 'unknown'}`;
1681
+ return answer;
1682
+ } finally {
1683
+ const ended = performance.now(), ms = ended - began;
1684
+ // a listing the read door leaves to the owner is recorded there, as the owner answers it
1685
+ if (named && !answer?.byOwner) {
1686
+ const overlap = slowLog.stepsWithin?.(began, ended) ?? null;
1687
+ const used = ms > slowLog.SLOW_MS && before ? slowLog.processUse(before) : null;
1688
+ recordSlow({ kind: 'call', name: `owner verb ${named}`, ms, outcome, slowest: null, force: answer?.code !== 0,
1689
+ includeFast: ['show', 'list', 'comment', 'create'].includes(named),
1690
+ extra: { caller: by, code: answer?.code ?? null, response: { code: answer?.code ?? null, stderr: redactText([].concat(answer?.stderr ?? []).join('')).slice(0, 2000) }, step_scope: scoped ? 'request' : 'process_interval',
1691
+ steps: scoped ? takeSteps() : overlap, ...(scoped ? { overlap_steps: overlap } : {}), ...(used ? { whole_process: used } : {}) } });
1692
+ }
1693
+ }
1694
+ };
1695
+ return scoped ? slowLog.withStepScope(run) : run();
1624
1696
  }
1625
1697
 
1626
- async function answerRequest(root, line, naming) {
1698
+ /** One request answered in a call of its own; on the read door (`readOnly`) a listing only, and `{ byOwner: true }` for
1699
+ * one the owner is to answer (listing.mjs byOwner). */
1700
+ async function answerRequest(root, line, naming, { readOnly = false } = {}) {
1627
1701
  try {
1628
1702
  const request = JSON.parse(line);
1629
1703
  const tool = request?.tool && typeof request.tool.name === 'string' ? request.tool : null;
1630
1704
  const argv = tool ? ['tool'] : Array.isArray(request?.argv) && request.argv.every((arg) => typeof arg === 'string') ? request.argv : null;
1631
1705
  if (request?.v !== 1 || !argv?.length) return { code: 1, stdout: '', stderr: 'supercode workflow: a request names a verb\n' };
1632
1706
  const verb = parse(argv)._[0];
1707
+ if (readOnly && (tool || !['list', 'ls'].includes(verb))) return { byOwner: true };
1633
1708
  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` };
1634
1709
  const session = request.session && typeof request.session.id === 'string' && ['claude-code', 'codex'].includes(request.session.harness) ? request.session : null;
1635
1710
  const caller = typeof request.caller === 'string' ? request.caller : session ? `sc:${localMachine()}:${session.harness}:${session.id}` : null;
@@ -1642,13 +1717,17 @@ async function answerRequest(root, line, naming) {
1642
1717
  const stdin = typeof request.stdin === 'string' ? request.stdin : null;
1643
1718
  const stdout = [];
1644
1719
  const stderr = [];
1645
- let code;
1646
- await within({ caller, facts, cwd, stdin, out: (text) => { stdout.push(text); return true; }, err: (text) => { stderr.push(text); return true; } }, async () => {
1720
+ let code, byOwner = false;
1721
+ await within({ caller, facts, cwd, stdin, readOnly, out: (text) => { stdout.push(text); return true; }, err: (text) => { stderr.push(text); return true; } }, async () => {
1647
1722
  // a worker's board tool (mcp/server.mjs), answered by the board's tools in this call (board/tools.mjs)
1648
1723
  if (tool) { stdout.push(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; }
1649
1724
  try { code = await runVerb([...argv, '--root', root, ...(typeof request.board === 'string' && request.board ? ['--board', request.board] : [])]); }
1650
- catch (error) { stderr.push(`supercode workflow: ${error?.message ?? error}\n`); code = 1; }
1725
+ catch (error) {
1726
+ if (readOnly && error?.code === READ_BY_OWNER) { byOwner = true; return; }
1727
+ stderr.push(`supercode workflow: ${error?.message ?? error}\n`); code = 1;
1728
+ }
1651
1729
  });
1730
+ if (byOwner) return { byOwner: true };
1652
1731
  return { code: code ?? 0, stdout, stderr };
1653
1732
  } catch (error) {
1654
1733
  return { code: 1, stdout: '', stderr: `supercode workflow: ${error?.message ?? error}\n` };
@@ -1663,8 +1742,9 @@ async function serve(root, args = {}) {
1663
1742
  if (args.pidfile) writeFileSync(args.pidfile, `${process.pid}\n`);
1664
1743
  let stopped = false;
1665
1744
  let events;
1745
+ let fleetListeners = null, reloadListeners = () => {}, listenerRevision = 0, listenerBootstrapped = false;
1666
1746
  // a signal ends the round in progress with nothing new started (dispatch.mjs stopDispatching) and no round after it
1667
- const stop = () => { stopped = true; stopDispatching(); events?.close(); };
1747
+ const stop = () => { stopped = true; stopDispatching(); events?.close(); fleetListeners?.suspend(); };
1668
1748
  for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP']) process.on(signal, stop);
1669
1749
  // what the serve says is kept in the home too (logs/workflow-serve.log), so why it stopped outlives its pane
1670
1750
  const kept = join(root, 'logs', 'workflow-serve.log');
@@ -1727,7 +1807,7 @@ async function serve(root, args = {}) {
1727
1807
  // the held home is read again on its next use after a file it is decoded from changes (file events, no interval),
1728
1808
  // watched before anything reads it; a watch that cannot be kept stops the owner, for its keeper to start it again
1729
1809
  const unfollowHome = followHome(root, {
1730
- onChange: (name) => log(`home changed (${name}): read again on its next use`),
1810
+ onChange: (name) => { log(`home changed (${name}): read again on its next use`); listenerRevision++; listenerBootstrapped = false; fleetListeners?.suspend(); reloadListeners(); },
1731
1811
  onLost: (error) => { log(`the home's files can no longer be watched (${error.message}); stopping, for the keeper to start the owner again`); stop(); },
1732
1812
  });
1733
1813
  // Hermes's install is located once per process (a launch's memory snapshot reads it): here, before any verb is
@@ -1767,7 +1847,7 @@ async function serve(root, args = {}) {
1767
1847
  } });
1768
1848
  // a machine's panes changing in its record (a session came up under its pane, a pane or its session ended) is what a
1769
1849
  // round binds a launch, verifies an adoption and reads a session's end from: the next round is due then
1770
- onPanesChanged(() => events?.wake('machine-record'));
1850
+ const unfollowPanes = onPanesChanged(() => events?.wake('machine-record'));
1771
1851
  // the owner holds its boards only now, its watch installed (watch before load): opened once, changed by events
1772
1852
  holdBoards(root);
1773
1853
  // a pause of the collector holds the loop like any synchronous step: each is a step, so a block line names it
@@ -1789,6 +1869,58 @@ async function serve(root, args = {}) {
1789
1869
  };
1790
1870
  await registerOwner();
1791
1871
  onLocalReopen(registerOwner);
1872
+ let listenerRecords = {}, listenerReloading = null, listenerReloadDue = false, listenerReading = null;
1873
+ const listenerErrors = new Set();
1874
+ const listenerLog = text => { if (!listenerErrors.has(text)) { if (listenerErrors.size >= 128) listenerErrors.clear(); listenerErrors.add(text); log(text); } };
1875
+ const scanListeners = ({ waiting = true } = {}) => listenerBootstrapped && fleetRecordsCurrent() && ownCall(() => observeRoleFleet(root, fleetListeners, listenerRecords, listenerLog, { waiting }));
1876
+ const readListenerRecords = () => {
1877
+ if (!fleetListeners.enabled || stopped || listenerReading) return listenerReading;
1878
+ const revision = listenerRevision;
1879
+ listenerReading = readFleetRecords().then(records => {
1880
+ if (revision !== listenerRevision || !fleetListeners.enabled || stopped) return;
1881
+ listenerRecords = records;
1882
+ if (!listenerBootstrapped) { fleetListeners.bootstrap(records); listenerBootstrapped = true; scanListeners(); }
1883
+ })
1884
+ .catch(error => { listenerRecords = {}; listenerLog(`fleet listener records unread: ${String(error.message).slice(0, 300)}`); })
1885
+ .finally(() => { listenerReading = null; });
1886
+ return listenerReading;
1887
+ };
1888
+ fleetListeners = new FleetListenerRouter({ root, log: listenerLog,
1889
+ file: (to, body, key) => ownCall(() => fileFleetNotice(root, to, body, key)),
1890
+ enrich: event => ownCall(() => enrichFleetEvent(root, event)),
1891
+ retry: () => scanListeners({ waiting: false }),
1892
+ });
1893
+ const unfollowRecords = onRecordsChanged(records => { listenerRecords = records; scanListeners(); });
1894
+ reloadListeners = () => {
1895
+ listenerReloadDue = true;
1896
+ if (listenerReloading || stopped) return listenerReloading;
1897
+ listenerReloading = (async () => {
1898
+ while (listenerReloadDue && !stopped) {
1899
+ listenerReloadDue = false;
1900
+ const revision = listenerRevision;
1901
+ try {
1902
+ const orchestration = await ownCall(() => readHome(root));
1903
+ if (revision !== listenerRevision || stopped) continue;
1904
+ fleetListeners.configure(orchestration.workflow?.listeners ?? {});
1905
+ if (fleetListeners.enabled) { scanListeners(); void readListenerRecords(); }
1906
+ } catch (error) {
1907
+ fleetListeners.suspend();
1908
+ listenerLog(`fleet listener configuration unread; retained obligations wait: ${String(error.message).slice(0, 300)}`);
1909
+ // A failed source read is retried by the next round; a failed home read also retains no stale intent.
1910
+ listenerReloadDue = true; break;
1911
+ }
1912
+ }
1913
+ })().finally(() => { listenerReloading = null; });
1914
+ return listenerReloading;
1915
+ };
1916
+ await reloadListeners();
1917
+ // Retry a failed home decode without waiting for a dispatch sweep. Reading the held stream also enforces its
1918
+ // heartbeat deadline and reconnects after local caller loss, even when every board's dispatching is off.
1919
+ let nextListenerRead = 0;
1920
+ const listenerTimer = setInterval(() => {
1921
+ if (listenerReloadDue) void reloadListeners();
1922
+ if (Date.now() >= nextListenerRead) { nextListenerRead = Date.now() + 5000; void readListenerRecords(); }
1923
+ }, 2000); listenerTimer.unref?.();
1792
1924
  log('dispatch wakes on board writes, document edits, board mail and a change of a machine\'s panes in its record; the interval sweep reads each machine\'s own door');
1793
1925
  const stuck = {};
1794
1926
  const warned = {};
@@ -1798,6 +1930,8 @@ async function serve(root, args = {}) {
1798
1930
  let wakeReason = null;
1799
1931
  try {
1800
1932
  while (!stopped) {
1933
+ if (listenerReloadDue) void reloadListeners();
1934
+ if (fleetListeners.enabled) scanListeners();
1801
1935
  // A serve never ticks on code older than the installed orchestrator: after its round it stops, and its keeper
1802
1936
  // starts the new one. Nothing waits for an installation; a second round, old or new, is harmless.
1803
1937
  const replaced = (() => { const now = installed(); return running && now && now !== running ? now : null; })();
@@ -1866,6 +2000,10 @@ async function serve(root, args = {}) {
1866
2000
  if (moved) { log(`orchestrator ${moved} is installed (this serve runs ${running}); stopping, for its keeper to start the new one`); break; }
1867
2001
  }
1868
2002
  } finally {
2003
+ fleetListeners.suspend(); clearInterval(listenerTimer); unfollowRecords(); unfollowPanes();
2004
+ await listenerReloading;
2005
+ await listenerReading;
2006
+ try { await fleetListeners.close(); } catch (error) { log(`fleet listener shutdown: retained filing outcome unknown (${String(error.message).slice(0, 300)})`); }
1869
2007
  // a fetch in flight lands, and what it brought is merged, while this owner still holds the home's lock
1870
2008
  try { const { settleDocuments } = await import('./git-document.mjs'); await settleDocuments(); } catch { /* nothing in flight */ }
1871
2009
  unwatchLoop?.(); releaseHeldBoards(); closeVerbs();