@volter/supercode-orchestrator 0.5.205 → 0.5.207

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 (52) 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 +278 -97
  15. package/board/client.mjs +18 -2
  16. package/board/custody.mjs +109 -0
  17. package/board/dispatch.mjs +305 -80
  18. package/board/doors.mjs +47 -17
  19. package/board/engine.mjs +92 -27
  20. package/board/files.mjs +17 -3
  21. package/board/fleet-listeners.mjs +268 -0
  22. package/board/fleet-observer.mjs +79 -0
  23. package/board/fleet-targets.mjs +82 -0
  24. package/board/incidents.mjs +7 -0
  25. package/board/listing.mjs +8 -2
  26. package/board/machine-fleet.mjs +7 -0
  27. package/board/notice-receipts.mjs +67 -0
  28. package/board/notices.mjs +5 -0
  29. package/board/owner-door-worker.mjs +61 -0
  30. package/board/owner-door.mjs +46 -0
  31. package/board/owner-health.mjs +8 -2
  32. package/board/published.mjs +2 -2
  33. package/board/record-view.mjs +2 -0
  34. package/board/store.mjs +84 -3
  35. package/board/tools.mjs +11 -4
  36. package/board/workflow.mjs +96 -31
  37. package/board/ztrack.mjs +207 -32
  38. package/board-input.d.ts +2 -0
  39. package/board-input.mjs +16 -0
  40. package/connection.mjs +153 -0
  41. package/index.d.ts +7 -5
  42. package/index.mjs +1 -1
  43. package/installed-apps.mjs +31 -20
  44. package/loop.mjs +91 -36
  45. package/mcp/bridge.mjs +12 -0
  46. package/mcp/hermes-kanban-tools.json +22 -14
  47. package/mcp/mount.mjs +10 -9
  48. package/ontology.d.ts +37 -0
  49. package/operator.mjs +72 -4
  50. package/orchestration.mjs +7 -2
  51. package/package.json +9 -5
  52. 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
 
@@ -88,24 +93,27 @@ const callerPath = (named) => notRuntime(resolve(current().cwd, named));
88
93
  const SAFE_SESSION = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/;
89
94
  const HELP = `supercode workflow <verb> [--root <home>] [--board <slug>] [--json]
90
95
 
96
+ A card is named by its id (t_4406c88a) or by its number (42 or #42): the short number the board gave it when it was made.
97
+
91
98
  The board's verbs, with their flags:
92
99
  init [--backing sqlite|files|ztrack] [--document <arcs.md>] [--commit] [--manager <address>] [--dispatching on|off] [--archive-max-lines N] [--archive-period month]
93
100
  migrate --backing ztrack --document <arcs.md> --status-map <json>
94
101
  boards list|create|rm|switch|show|rename|set-default-workdir|export|import
95
102
  create <title> [--body | --body-file PATH|-] [--assignee] [--parent]... [--workspace scratch|worktree:<repository>|dir:<repository>] [--branch] [--project]
96
103
  [--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]
104
+ [--model] [--provider] [--reviewer PROFILE] [--goal|--no-goal] [--goal-max-turns] [--initial-status blocked|running]
98
105
  swarm <goal> --worker PROFILE:TITLE[:SKILL,SKILL]... --verifier P --synthesizer P
99
106
  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]
107
+ 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
108
  reassign <id> --agent AGENT [--role implementer] (moves a card role's assignment to another agent; sessions keep their identity)
102
109
  claim <id> [--ttl] reclaim <id> [--reason] heartbeat <id> [--note] [--until <time|30m>] link|unlink <parent> <child>
103
110
  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]
111
+ attachment-read <id> <attachment-id> [--offset N --length N --version V] bounded base64/metadata JSON, including through --at
112
+ complete <id>... [--result] [--summary] [--metadata JSON] [--commit SHA] edit <id> --result [--summary] [--metadata]
105
113
  block <id> [reason] (--on <card> | --waiting-on <message id>) [--ids ...] schedule <id> [reason] [--ids ...]
106
114
  pause <id> --reason <why> resume <id> cancel <id> --reason <why>
107
115
  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]
116
+ 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
117
  promote <id> [reason] [--ids ...] [--force] [--dry-run] archive <id>... --reason <why> [--rm <id>...]
110
118
  specify <id> [--title --body | --body-file PATH|-] [--workspace scratch|worktree:<repository>|dir:<repository>] [--branch NAME] [--machine M|none] [--all]
111
119
  decompose <id> [--child "<title>[@<profile>]"]... [--all]
@@ -131,7 +139,7 @@ And:
131
139
  create … --machine M --subtask-of ARC --notify ADDRESS|--no-notify --no-start
132
140
  With a pane lane and room, a card starts at once and answers "started" with its session; else it answers why it
133
141
  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
142
+ 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
143
  notify-subscribe <id> --address sc:<machine>:<harness>:<id> [--role creator|worker|dependency]
136
144
  notify-subscribe <dependency> --for-card <your-arc>: subscribe only your arc's session to dependency notices.
137
145
  notify-unsubscribe <dependency> --for-card <your-arc>: remove that arc's subscription only.
@@ -148,6 +156,7 @@ And:
148
156
  request-state <id> --state done (--reviewed-sha SHA)...: record the verdict on every requested PR head.
149
157
  request-state <id>... --state STATE --reason REASON: request several decisions in one retained manager mail.
150
158
  complete <id>: creator/manager accepts the independently reviewed arc; deployment CI is not a board gate.
159
+ listeners status [--json]: declared role fleet listeners, retained filing counts, source cursors and gaps.
151
160
  specify/decompose with no spec start a session on the triage card to write it. A card's own session may comment on,
152
161
  heartbeat and request review of its card, specify or decompose it in triage, and work its subtasks. A side session
153
162
  (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 +264,40 @@ const callerName = () => current().facts.HERMES_PROFILE || current().facts.USER
255
264
  * automation running it, its own plain session, or the person its Teams login names. Never the OS user; null when
256
265
  * nothing names one.
257
266
  */
258
- async function callerPrincipal(root, sid, { teams = false } = {}) {
267
+ async function ownerStep(name, fn) {
268
+ const began = performance.now();
269
+ try { return await fn(); }
270
+ finally { noteStep(`owner preparation: ${name}`, performance.now() - began); }
271
+ }
272
+
273
+ function recordedRunAgent(db, sid, address) {
274
+ const rows = db.prepare(CALLER_RUN_AGENT).all(sid, address);
275
+ const owners = new Map();
276
+ for (const row of rows) {
277
+ if (!/^agent_[0-9a-f]{32}$/.test(row.agent_id) || typeof row.org !== 'string' || !row.org) continue;
278
+ owners.set(JSON.stringify([row.org, row.agent_id]), row);
279
+ }
280
+ if (owners.size > 1) throw new Error('recorded native session has conflicting agent ownership');
281
+ return owners.values().next().value ?? null;
282
+ }
283
+
284
+ async function callerPrincipal(root, sid, { teams = false, recordedAgent = null } = {}) {
259
285
  const facts = current().facts;
286
+ if (recordedAgent && facts.SUPERCODE_AGENT_ID && facts.SUPERCODE_AGENT_ID !== recordedAgent.agent_id)
287
+ throw new Error('native session ownership does not match its launch identity');
288
+ if (recordedAgent && facts.SUPERCODE_AGENT_ORG && facts.SUPERCODE_AGENT_ORG !== recordedAgent.org)
289
+ throw new Error('native session Org does not match its launch identity');
260
290
  if (facts.SUPERCODE_AGENT_ID) return `agent:${facts.SUPERCODE_AGENT_ID}`;
261
291
  if (facts.SUPERCODE_AUTOMATION) return `automation:${facts.SUPERCODE_AUTOMATION}`;
262
292
  if (sid) {
263
293
  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}`;
294
+ const owner = await ownerStep('local agent ownership', () => agentRunForSession(root, [`${harness}:${sid}`]));
295
+ if (owner && recordedAgent && (owner.agent_id !== recordedAgent.agent_id || owner.org !== recordedAgent.org))
296
+ throw new Error('local and recorded native session ownership conflict');
297
+ if (owner || recordedAgent) return `agent:${(owner ?? recordedAgent).agent_id}`;
266
298
  // a session an agent declared elsewhere owns (a manager's main session) is that agent's, as Teams records it
267
299
  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 */ }
300
+ 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
301
  return declared ? `agent:${declared}` : `session:${callerAddress()}`;
270
302
  }
271
303
  if (!teams) return null;
@@ -299,14 +331,15 @@ const ts = (t) => {
299
331
  return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
300
332
  };
301
333
  // 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}` : ''}`;
334
+ const line = (t) => `${ICONS[t.status] ?? '?'} ${engine.cardLabel(t.number, t.id).padEnd(16)} ${(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
335
  const cardDict = (t) => ({
304
- id: t.id, title: t.title, body: t.body, assignee: t.assignee, status: t.status, priority: t.priority, tenant: t.tenant,
336
+ id: t.id, number: t.number ?? null, title: t.title, body: t.body, assignee: t.assignee, status: t.status, priority: t.priority, tenant: t.tenant,
305
337
  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
338
  created_by: t.created_by, created_at: t.created_at, started_at: t.started_at, completed_at: t.completed_at, result: t.result,
307
339
  skills: t.skills ?? [], max_retries: t.max_retries, model_override: t.model_override, provider_override: t.provider_override,
308
340
  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,
341
+ machine: t.machine ?? null, subtask_of: t.subtask_of ?? null, execution: t.execution ?? null, detached: t.detached ?? null,
342
+ reviewer: t.reviewer ?? null,
310
343
  });
311
344
 
312
345
  /** Hermes's durations: seconds, or 30s / 5m / 2h / 1d. */
@@ -335,14 +368,52 @@ const metadataOf = (v) => {
335
368
  return got;
336
369
  };
337
370
 
371
+ // A card is named by its id or by its number (`42`, `#42`, store.mjs layOutNumbers) in every place a verb takes a card:
372
+ // its card positionals (none, the first, the first two, or all of them) and its card flags. A number is read as its
373
+ // card's id before anything else reads the verb (its authorization included); a number no card here has is refused.
374
+ const NO_CARD_WORDS = new Set(['create', 'new', 'swarm', 'list', 'ls', 'migrate', 'init', 'boards', 'usage', 'events', 'listeners', 'dispatch',
375
+ 'capacity', 'deliver', 'daemon', 'serve', 'gc', 'repair', 'dispatching', 'attach-rm', 'run-session', 'stats', 'assignees', 'diagnostics', 'diag',
376
+ 'watch', 'help', 'github-usage']);
377
+ // the verbs that act on every positional as a card (each loops over them all)
378
+ const ALL_CARD_WORDS = new Set(['complete', 'archive', 'request-state', 'request_state', 'set-goal', 'unblock', 'reopen', 'reopen-review']);
379
+ const CARD_FLAGS = ['ids', 'subtask-of', 'parent', 'on', 'for-card', 'task', 'rm'];
380
+ const CARD_NUMBER = /^#?([1-9]\d*)$/;
381
+ function resolveCardNumbers(verb, rest, args, withBoard) {
382
+ const words = NO_CARD_WORDS.has(verb) ? 0 : ALL_CARD_WORDS.has(verb) ? rest.length : ['link', 'unlink'].includes(verb) ? 2 : 1;
383
+ const places = [];
384
+ for (let i = 0; i < Math.min(words, rest.length); i++) places.push([rest, i]);
385
+ for (const flag of CARD_FLAGS) {
386
+ if (Array.isArray(args[flag])) args[flag].forEach((_, i) => places.push([args[flag], i]));
387
+ else if (typeof args[flag] === 'string') places.push([args, flag]);
388
+ }
389
+ const numbered = places.filter(([holder, key]) => CARD_NUMBER.test(String(holder[key])));
390
+ if (!numbered.length) return;
391
+ withBoard((b) => view(b, (db) => {
392
+ for (const [holder, key] of numbered) {
393
+ const n = Number(CARD_NUMBER.exec(String(holder[key]))[1]);
394
+ const id = engine.cardByNumber(db, n);
395
+ if (!id) throw new Error(`no card #${n} on this board`);
396
+ holder[key] = id;
397
+ }
398
+ }));
399
+ }
400
+
338
401
  /** 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
402
  export async function runVerb(argv) {
340
- await knowLocalMachine();
341
403
  const args = parse(argv);
404
+ assertSourceReceipt(args);
405
+ await ownerStep('machine identity', () => knowLocalMachine());
342
406
  const [verb, ...rest] = args._;
343
407
  if (!verb || verb === 'help' || args.help) { current().out(HELP); return 0; }
344
408
  const root = homeOf(args);
345
409
  if (verb === 'events') return streamBoardPublications({root,after:args.after,once:args.once});
410
+ if (verb === 'listeners') {
411
+ if (rest.length && rest[0] !== 'status') throw new Error('workflow listeners accepts status; declarations live in workflow.yaml listeners');
412
+ const orchestration = await readHome(root);
413
+ const status = { declarations: orchestration.workflow?.listeners ?? {}, ...fleetListenerStatus(root) };
414
+ say(args, JSON.stringify(status, null, 2), status);
415
+ return 0;
416
+ }
346
417
  // 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
418
  // the cards it shows, and a card filed through it carries that tag.
348
419
  const named = boardOf(root, args);
@@ -351,26 +422,42 @@ export async function runVerb(argv) {
351
422
  const open = (create = false) => { const b = openBoard(root, slug, { create, document: args.document ?? null, backing: create ? args.backing ?? null : null }); b.label = boardLabel(root, slug); return b; };
352
423
  const withBoard = (fn, create = false) => { const b = open(create); try { return fn(b); } finally { b.close(); } };
353
424
  const withBoardAsync = async (fn, create = false) => { const b = open(create); try { return await fn(b); } finally { b.close(); } };
425
+ resolveCardNumbers(verb, rest, args, withBoard);
354
426
  const id = rest[0];
355
427
  const need = (n, what) => { if (rest.length < n) throw new Error(`${verb} needs ${what}`); };
356
428
  const ids = (first = rest) => [...first, ...(args.ids ?? [])];
357
429
 
358
430
  // a caller that is a card's own session (its harness names it; the board recorded which session it launched)
359
431
  const listing = ['list','ls'].includes(verb);
360
- await loadWorkflow(root).catch((error) => { if (verb !== 'init' && verb !== 'boards') throw error; });
432
+ const readingDispatching = verb === 'dispatching' && rest.length === 0;
433
+ const reading = READS.has(verb) || readingDispatching;
434
+ // a listing on the owner's read door reads the workflow the owner holds (owner-door.mjs), and names no session: it
435
+ // records nothing, and `--mine` is the caller's profile
436
+ const readDoor = Boolean(current().readOnly);
437
+ if (!readDoor) await ownerStep('workflow', () => loadWorkflow(root)).catch((error) => { if (verb !== 'init' && verb !== 'boards') throw error; });
361
438
  // 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();
439
+ if (!listing && !readingDispatching) await ownerStep('manager main sessions', () => readAgentMains(root));
440
+ const sid = listing && (!args.mine || readDoor) ? null : callerSession();
364
441
  // what this command records is the calling session's act (its address, as its subscriptions name it)
365
442
  current().recorded = sid && boardExists(root, slug) ? withBoard((b) => b.db.prepare(CALLER_RUN_ADDRESS).get(sid)?.address) ?? null : null;
366
443
  if (sid) setActor(callerAddress());
367
- setPrincipal(await callerPrincipal(root, sid, { teams: !sid && !READS.has(verb) }));
444
+ const recordedAgent = sid && boardExists(root, slug)
445
+ ? await ownerStep('committed native agent ownership', () => withBoard((b) => recordedRunAgent(b.db, sid, callerAddress()))) : null;
446
+ setPrincipal(await ownerStep('caller principal', () => callerPrincipal(root, sid, { teams: !sid && !reading, recordedAgent })));
368
447
  // the dispatcher as the owner holds it, said once per call (owner-health.mjs; nothing is read for it)
369
448
  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)) {
449
+ if (sid && !reading && boardExists(root, slug)) {
371
450
  // binding the caller's launch is a write: only the owner, answering the caller's own verb, makes it (a local read
372
451
  // 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); });
452
+ await withBoardAsync(async (b) => {
453
+ if (isCallersCall()) await reconcileCallerLaunch(b, sid);
454
+ const own = { ...ownCards(b.db, sid), side: sideArcs(b.db, sid) };
455
+ if (!own.worked.size && !own.side.size) return;
456
+ // A copied home owns no live sessions or manager addresses. Its owner may
457
+ // answer local verbs while every board's dispatching remains off.
458
+ const copyServe = verb === 'serve' && Boolean(boardCopyOf(b)) && listBoards(root).every(board => !dispatchingOf(root, board).on);
459
+ authorize(b.db, verb, rest, args, own, { copyServe });
460
+ });
374
461
  }
375
462
  // the board's workflow (the IR's): what each verb does, and who may send it, is its
376
463
  // every verb reads the board through its workflow (a list settles cards too), so every verb loads it
@@ -466,6 +553,7 @@ export async function runVerb(argv) {
466
553
  const sponsorship = args.sponsor || args['requested-by'] ? await requestedSponsor(args) : principalOf()?.startsWith('person:') ? { sponsor: principalOf().slice('person:'.length) } : {};
467
554
  const created = await withBoardAsync(async (b) => engine.createCard(b, await engine.prepareCard(b, {
468
555
  title: rest.join(' '), body: textFrom(args['body-file'], args.body), assignee: args.assignee === 'none' ? null : args.assignee, machine: args.machine,
556
+ reviewer: args.reviewer ?? null,
469
557
  workspace, branch: args.branch, parents: args.parent, priority: args.priority, tenant: args.tenant,
470
558
  triage: args.triage, idempotencyKey: args['idempotency-key'], maxRuntime: duration(args['max-runtime']),
471
559
  maxRetries, skills: args.skill, model: args.model, provider: args.provider, createdBy: args['created-by'] ?? null,
@@ -491,7 +579,7 @@ export async function runVerb(argv) {
491
579
  const recipients = withBoard(b => engine.subscriptions(b.db, created.id)).filter(s => s.platform === 'supercode').map(s => s.chat_id);
492
580
  if (args.json) { say(args, '', { ...cardDict(task), ...answer, notify: address ?? null, recipients }); return answer.failed ? 1 : 0; }
493
581
  const said = answer.adoption_requested ? `; held for ${answer.adoption_requested}, which the dispatcher binds on its next tick` : answer.started ? `; started session ${answer.session} (${answer.mode})` : answer.failed ? `; did not start: ${answer.failed}` : answer.waits ? `; waits: ${answer.waits}` : '';
494
- say(args, `${created.existing ? 'Existing' : 'Created'} ${created.id} (${task.status}, assignee=${task.assignee || '-'})${said}${recipients.length ? `; notices go to ${recipients.join(', ')}` : ''}`, null);
582
+ say(args, `${created.existing ? 'Existing' : 'Created'} ${engine.cardLabel(task.number, created.id)} (${task.status}, assignee=${task.assignee || '-'})${said}${recipients.length ? `; notices go to ${recipients.join(', ')}` : ''}`, null);
495
583
  return answer.failed ? 1 : 0;
496
584
  }
497
585
 
@@ -543,7 +631,12 @@ export async function runVerb(argv) {
543
631
  got.runs = got.runs.map(run => ({ ...run, execution: executionOf(run) }));
544
632
  got.diagnostics = engine.diagnose(b.db, got.task, { failureLimit: settings.failureLimit, launchWindowSeconds: settings.launchWindow });
545
633
  got.stage = statusOf(b.db, got.task);
546
- got.time = scopeOf(b.db, b, got.task, { params: workflowFor(root).params }).card.time;
634
+ const entry = workflowFor(root);
635
+ // A workflow may explicitly replace the built-in time field. Keep its
636
+ // full derived scope; ordinary totals need only this card's runs.
637
+ got.time = Object.hasOwn(entry.workflow.derived ?? {}, 'time')
638
+ ? scopeOf(b.db, b, got.task, { params: entry.params }).card.time
639
+ : timeOf(b.db, got.task);
547
640
  return got;
548
641
  });
549
642
  const t = card.task;
@@ -552,12 +645,16 @@ export async function runVerb(argv) {
552
645
  say(args, '', { task: cardDict(t), side_sessions: withBoard((b) => sideSessions(b.db, t.id)), latest_summary: latest, parents: card.parents.map((p) => p.id), children: card.children, subtasks: card.subtasks, comments: card.comments, events: card.events, runs: card.runs, time: card.time, subscriptions: card.subscriptions, diagnostics: card.diagnostics });
553
646
  return 0;
554
647
  }
555
- 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 || '-'}`];
648
+ const labels = withBoard((b) => view(b, (db) => engine.numbersOf(db, [t.subtask_of, ...card.parents.map((p) => p.id), ...card.children].filter(Boolean))));
649
+ const label = (cardId) => engine.cardLabel(labels.get(cardId), cardId);
650
+ const out = [`Task ${engine.cardLabel(t.number, 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
651
  if (t.machine) out.push(` machine: ${t.machine}`);
652
+ if (t.reviewer) out.push(` reviewer: ${t.reviewer} (card override)`);
653
+ 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
654
  // where the card's time went: each role's runs, and the rest waiting
558
655
  const mins = (sec) => `${Math.round(sec / 60)}m`;
559
656
  out.push(` time: ${Object.entries(card.time).filter(([, v]) => v > 0).map(([k, v]) => `${k} ${mins(v)}`).join(', ') || '-'}`);
560
- if (t.subtask_of) out.push(` subtask of: ${t.subtask_of}`);
657
+ if (t.subtask_of) out.push(` subtask of: ${label(t.subtask_of)}`);
561
658
  if (t.tenant) out.push(` tenant: ${t.tenant}`);
562
659
  out.push(` workspace: ${t.workspace_kind}${t.workspace_anchor ? `:${t.workspace_anchor}` : ''}`);
563
660
  if (t.workspace_path) out.push(` resolved: ${t.workspace_path}`);
@@ -576,9 +673,9 @@ export async function runVerb(argv) {
576
673
  }
577
674
  if (t.started_at) out.push(` started: ${ts(t.started_at)}`);
578
675
  if (t.completed_at) out.push(` completed: ${ts(t.completed_at)}`);
579
- if (card.parents.length) out.push(` parents: ${card.parents.map((p) => p.id).join(', ')}`);
580
- if (card.children.length) out.push(` children: ${card.children.join(', ')}`);
581
- if (card.subtasks.length) out.push(` subtasks: ${card.subtasks.map((s) => `${s.id} (${s.status})`).join(', ')}`);
676
+ if (card.parents.length) out.push(` parents: ${card.parents.map((p) => label(p.id)).join(', ')}`);
677
+ if (card.children.length) out.push(` children: ${card.children.map(label).join(', ')}`);
678
+ if (card.subtasks.length) out.push(` subtasks: ${card.subtasks.map((s) => `${engine.cardLabel(s.number, s.id)} (${s.status})`).join(', ')}`);
582
679
  const side = withBoard((b) => sideSessions(b.db, t.id));
583
680
  if (side.length) out.push(` side sessions: ${side.map((s) => s.address).join(', ')}`);
584
681
  if (t.body) out.push('', 'Body:', t.body);
@@ -587,7 +684,7 @@ export async function runVerb(argv) {
587
684
  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
685
  // the card's history, without the board's own bookkeeping (a claim, a session kept or started, a goal ask, a
589
686
  // 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',
687
+ const PLUMBING = new Set(['claimed', 'session_started', 'session_resumed', 'session_adopted', 'agent_session_queued', 'session_replaced', 'session_closed',
591
688
  'goal_set', 'goal_continued', 'decision_asked', 'board_message', 'board_message_sent', 'heartbeat', 'promoted', 'respawn_guarded',
592
689
  'claim_extended', 'reclaimed', 'reconciled', 'status']);
593
690
  const history = args.all ? card.events : card.events.filter((e) => !PLUMBING.has(e.kind));
@@ -600,7 +697,7 @@ export async function runVerb(argv) {
600
697
  for (const r of card.runs) {
601
698
  const s = r.metadata?.supercode;
602
699
  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}`);
700
+ 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
701
  if (r.summary) out.push(` → ${r.summary.split('\n')[0].slice(0, 160)}`);
605
702
  if (r.error) out.push(` ! ${r.error.split('\n')[0].slice(0, 160)}`);
606
703
  }
@@ -746,6 +843,14 @@ export async function runVerb(argv) {
746
843
  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
844
  return 0;
748
845
  }
846
+ case 'attachment-read': {
847
+ need(2, 'a card id and an attachment id');
848
+ const row = withBoard((b) => attachmentRecord(b.db, id, Number(rest[1])));
849
+ const range = await readAttachment(root, slug, row, { offset: args.offset === undefined ? 0 : Number(args.offset),
850
+ length: args.length === undefined ? undefined : Number(args.length), version: args.version ?? null });
851
+ current().out(`${JSON.stringify(range)}\n`);
852
+ return 0;
853
+ }
749
854
  case 'attach-rm': {
750
855
  need(1, 'an attachment id');
751
856
  const row = withBoard((b) => engine.removeAttachment(b, Number(id)));
@@ -756,7 +861,7 @@ export async function runVerb(argv) {
756
861
  case 'complete': {
757
862
  need(1, 'a card id');
758
863
  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 }));
864
+ 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
865
  say(args, rest.map((t) => `Completed ${t}`).join('\n'), { ok: true, completed: rest }); return 0;
761
866
  }
762
867
  case 'edit': {
@@ -786,7 +891,7 @@ export async function runVerb(argv) {
786
891
  if (args.reason) for (const [t] of results) await withBoardAsync((b) => engine.comment(b, t, callerName(), `UNBLOCK: ${args.reason}`));
787
892
  say(args, results.map(([t, s]) => `Unblocked ${t} → ${s}`).join('\n'), { ok: true, results }); return 0;
788
893
  }
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;
894
+ 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
895
  case 'request-changes': {
791
896
  need(2, 'a card id and the reason');
792
897
  // each finding is filed as a task on the arc first, so the arc stays open until a pushed head fixes it
@@ -993,16 +1098,24 @@ export async function runVerb(argv) {
993
1098
  const reports = await plan({ root, lanes, settings });
994
1099
  const out = { capacity: reports.capacity, would: reports.flatMap((r) => r.would.map((w) => w.id)), waiting: reports.flatMap((r) => r.queued.map(([id, why]) => ({ id, why }))) };
995
1100
  if (args.json) { say(args, '', out); return 0; }
1101
+ // each card as a person reads it, by the number its own board gave it
1102
+ const shown = [...out.would, ...out.waiting.map((w) => w.id), ...out.capacity.flatMap((cap) => cap.groups.flatMap((g) => g.cards))];
1103
+ const labels = new Map();
1104
+ for (const board of listBoards(root)) {
1105
+ const b = openBoard(root, board);
1106
+ try { for (const [cardId, n] of view(b, (db) => engine.numbersOf(db, shown))) labels.set(cardId, engine.cardLabel(n, cardId)); } finally { b.close(); }
1107
+ }
1108
+ const label = (cardId) => labels.get(cardId) ?? cardId;
996
1109
  const lines = [];
997
1110
  for (const cap of out.capacity) {
998
1111
  const filter = Object.entries(cap.where ?? {}).map(([k, v]) => `${k} ${[].concat(v).join('|')}`).join(', ');
999
1112
  const except = Object.entries(cap.unless ?? {}).map(([k, v]) => `${k} ${[].concat(v).join('|')}`).join(', ');
1000
1113
  lines.push(`cap "${cap.name}": ${cap.counts} per ${cap.scope}${filter ? `, where ${filter}` : ''}${except ? `, unless ${except}` : ''}, max ${cap.max}`);
1001
1114
  if (!cap.groups.length) lines.push(' (nothing counted)');
1002
- for (const g of cap.groups) lines.push(` ${cap.scope === 'global' ? 'home' : g.group ?? '(none)'}: ${g.count} of ${g.max ?? 'no max'} ${[...new Set(g.cards)].join(' ')}`);
1115
+ for (const g of cap.groups) lines.push(` ${cap.scope === 'global' ? 'home' : g.group ?? '(none)'}: ${g.count} of ${g.max ?? 'no max'} ${[...new Set(g.cards)].map(label).join(', ')}`);
1003
1116
  }
1004
- lines.push(out.would.length ? `would start: ${out.would.join(' ')}` : 'would start: nothing');
1005
- for (const w of out.waiting) lines.push(`${w.id} ${w.why}`);
1117
+ lines.push(out.would.length ? `would start: ${out.would.map(label).join(', ')}` : 'would start: nothing');
1118
+ for (const w of out.waiting) lines.push(`${label(w.id)} ${w.why}`);
1006
1119
  current().out(`${lines.join('\n')}\n`);
1007
1120
  return 0;
1008
1121
  }
@@ -1120,6 +1233,17 @@ export async function runVerb(argv) {
1120
1233
  const session = await withBoardAsync(b => startCard(b, id, { lanes, settings, roles: rolesFor(b.db, id, sid, workflowFor(root).params) }));
1121
1234
  say(args, `${id}: ${session?.address ?? 'started'}`, { ok: true, session }); return 0;
1122
1235
  }
1236
+ case 'detach': {
1237
+ need(1, 'a card id');
1238
+ const person = args.to ?? (principalOf()?.startsWith('person:') ? principalOf() : null);
1239
+ const detached = withBoard(b => {
1240
+ const roles = rolesFor(b.db, id, sid, workflowFor(root).params);
1241
+ 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.');
1242
+ return detachSession(b, id, person);
1243
+ });
1244
+ say(args, `${id}: ${detached.session.address} belongs to ${detached.person}; the dispatcher no longer owns it`, { ok: true, detached });
1245
+ return 0;
1246
+ }
1123
1247
  case 'adopt': {
1124
1248
  need(2, 'a card id and a session');
1125
1249
  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 +1285,7 @@ export async function runVerb(argv) {
1161
1285
  if (!declared) throw new Error(`no workflow verb ${verb}; \`supercode workflow help\` lists them`);
1162
1286
  need(1, 'a card id');
1163
1287
  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) };
1288
+ 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
1289
  const results = [];
1166
1290
  if (name === 'request_state') results.push(...await withBoardAsync(board => engine.requestStateMany(board, [...rest, ...(args.ids ?? [])], payload)));
1167
1291
  else for (const t of [id, ...(args.ids ?? [])]) results.push([t, (await withBoardAsync((b) => engine.act(b, t, name, { payload }))).to]);
@@ -1426,7 +1550,7 @@ function ownCards(db, sessionId) {
1426
1550
  return { worked, held };
1427
1551
  }
1428
1552
 
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']);
1553
+ 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
1554
 
1431
1555
  /** The main session of each agent the workflow names a manager (`sc:<machine>:agent:<name>`), asked of this machine's
1432
1556
  * daemon, which keeps agents' records (mail step 1c), once per call before its verb runs: a manager named by its agent
@@ -1479,10 +1603,11 @@ function rolesFor(db, cardId, sessionId, params = {}) {
1479
1603
  * subtasks, and the subtasks of the arcs it is a side session of, and those arcs themselves only to comment on. What it
1480
1604
  * may do to them is the workflow's (rolesFor).
1481
1605
  */
1482
- function authorize(db, verb, rest, args, own) {
1606
+ function authorize(db, verb, rest, args, own, { copyServe = false } = {}) {
1483
1607
  const cards = [...new Set([...own.worked, ...own.side])];
1484
1608
  const named = cards.join(', ');
1485
1609
  const refuse = (why) => { throw new Error(`the session working ${named} ${why}`); };
1610
+ if (verb === 'serve' && copyServe) return;
1486
1611
  if (verb === 'create') {
1487
1612
  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
1613
  return;
@@ -1490,7 +1615,7 @@ function authorize(db, verb, rest, args, own) {
1490
1615
  if (verb === 'boards') { if (!['list', 'ls', 'show', 'current', undefined].includes(rest[0])) refuse('does not change the home\'s boards'); return; }
1491
1616
  // 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
1617
  // (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`);
1618
+ 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
1619
  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
1620
  if (args.all) refuse(`does not ${verb} every card; only its own`);
1496
1621
  if (['notify-subscribe', 'notify-unsubscribe'].includes(verb) && args['for-card']) {
@@ -1554,10 +1679,11 @@ function setDispatching(root, slug, on, reason) {
1554
1679
 
1555
1680
  /**
1556
1681
  * 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.
1682
+ * in a call of its own (call.mjs), with the home this process holds. The socket is served on a thread of its own
1683
+ * (owner-door.mjs): a listing is read there, and every other verb is answered here, by the board's one writer. One owner
1684
+ * per home, by an OS lock held for its whole life by a holder process of its own (an exclusive SQLite lock on
1685
+ * `<home>/board.lock`, released when the owner ends and the holder's standard input closes): a second serve refuses,
1686
+ * and only the lock's owner removes and binds the socket.
1561
1687
  */
1562
1688
  async function answerVerbs(root, log) {
1563
1689
  const home = boardHome(root);
@@ -1577,59 +1703,51 @@ async function answerVerbs(root, log) {
1577
1703
  log(`owner epoch ${epoch}`);
1578
1704
  const path = boardOwnerSocket(root);
1579
1705
  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 */ }
1706
+ let closeDoor;
1707
+ try { closeDoor = await openOwnerDoor(root, { machine: localMachine(), answer: (line) => answerOne(root, line), log }); }
1708
+ catch (error) { lock.release(); throw error; }
1601
1709
  log(`answering the card verbs of ${root} at ${path}`);
1602
- return () => { server.close(); try { unlinkSync(path); } catch { /* gone */ } lock.release(); };
1710
+ return () => { closeDoor(); try { unlinkSync(path); } catch { /* gone */ } lock.release(); };
1603
1711
  }
1604
1712
 
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
- }
1713
+ /** One request's own steps and the separate process activity during its wait.
1714
+ * Show/list/comment/create also retain receipts below the slow-log threshold;
1715
+ * absence from a slow-only log cannot prove those calls met the time bound. */
1716
+ export async function answerOne(root, line, { readOnly = false } = {}) {
1717
+ const scoped = typeof slowLog.withStepScope === 'function';
1718
+ const run = async () => {
1719
+ const began = performance.now(), before = slowLog.processCounters?.() ?? null;
1720
+ let named = null, by = null, answer = null, outcome = 'error';
1721
+ try {
1722
+ answer = await answerRequest(root, line, (verb, caller) => { named = verb; by = caller; }, { readOnly });
1723
+ outcome = answer?.code === 0 ? 'ok' : `exit ${answer?.code ?? 'unknown'}`;
1724
+ return answer;
1725
+ } finally {
1726
+ const ended = performance.now(), ms = ended - began;
1727
+ // a listing the read door leaves to the owner is recorded there, as the owner answers it
1728
+ if (named && !answer?.byOwner) {
1729
+ const overlap = slowLog.stepsWithin?.(began, ended) ?? null;
1730
+ const used = ms > slowLog.SLOW_MS && before ? slowLog.processUse(before) : null;
1731
+ recordSlow({ kind: 'call', name: `owner verb ${named}`, ms, outcome, slowest: null, force: answer?.code !== 0,
1732
+ includeFast: ['show', 'list', 'comment', 'create'].includes(named),
1733
+ 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',
1734
+ steps: scoped ? takeSteps() : overlap, ...(scoped ? { overlap_steps: overlap } : {}), ...(used ? { whole_process: used } : {}) } });
1735
+ }
1736
+ }
1737
+ };
1738
+ return scoped ? slowLog.withStepScope(run) : run();
1624
1739
  }
1625
1740
 
1626
- async function answerRequest(root, line, naming) {
1741
+ /** One request answered in a call of its own; on the read door (`readOnly`) a listing only, and `{ byOwner: true }` for
1742
+ * one the owner is to answer (listing.mjs byOwner). */
1743
+ async function answerRequest(root, line, naming, { readOnly = false } = {}) {
1627
1744
  try {
1628
1745
  const request = JSON.parse(line);
1629
1746
  const tool = request?.tool && typeof request.tool.name === 'string' ? request.tool : null;
1630
1747
  const argv = tool ? ['tool'] : Array.isArray(request?.argv) && request.argv.every((arg) => typeof arg === 'string') ? request.argv : null;
1631
1748
  if (request?.v !== 1 || !argv?.length) return { code: 1, stdout: '', stderr: 'supercode workflow: a request names a verb\n' };
1632
1749
  const verb = parse(argv)._[0];
1750
+ if (readOnly && (tool || !['list', 'ls'].includes(verb))) return { byOwner: true };
1633
1751
  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
1752
  const session = request.session && typeof request.session.id === 'string' && ['claude-code', 'codex'].includes(request.session.harness) ? request.session : null;
1635
1753
  const caller = typeof request.caller === 'string' ? request.caller : session ? `sc:${localMachine()}:${session.harness}:${session.id}` : null;
@@ -1642,13 +1760,17 @@ async function answerRequest(root, line, naming) {
1642
1760
  const stdin = typeof request.stdin === 'string' ? request.stdin : null;
1643
1761
  const stdout = [];
1644
1762
  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 () => {
1763
+ let code, byOwner = false;
1764
+ await within({ caller, facts, cwd, stdin, readOnly, out: (text) => { stdout.push(text); return true; }, err: (text) => { stderr.push(text); return true; } }, async () => {
1647
1765
  // a worker's board tool (mcp/server.mjs), answered by the board's tools in this call (board/tools.mjs)
1648
1766
  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
1767
  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; }
1768
+ catch (error) {
1769
+ if (readOnly && error?.code === READ_BY_OWNER) { byOwner = true; return; }
1770
+ stderr.push(`supercode workflow: ${error?.message ?? error}\n`); code = 1;
1771
+ }
1651
1772
  });
1773
+ if (byOwner) return { byOwner: true };
1652
1774
  return { code: code ?? 0, stdout, stderr };
1653
1775
  } catch (error) {
1654
1776
  return { code: 1, stdout: '', stderr: `supercode workflow: ${error?.message ?? error}\n` };
@@ -1663,8 +1785,9 @@ async function serve(root, args = {}) {
1663
1785
  if (args.pidfile) writeFileSync(args.pidfile, `${process.pid}\n`);
1664
1786
  let stopped = false;
1665
1787
  let events;
1788
+ let fleetListeners = null, reloadListeners = () => {}, listenerRevision = 0, listenerBootstrapped = false;
1666
1789
  // 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(); };
1790
+ const stop = () => { stopped = true; stopDispatching(); events?.close(); fleetListeners?.suspend(); };
1668
1791
  for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP']) process.on(signal, stop);
1669
1792
  // what the serve says is kept in the home too (logs/workflow-serve.log), so why it stopped outlives its pane
1670
1793
  const kept = join(root, 'logs', 'workflow-serve.log');
@@ -1727,7 +1850,7 @@ async function serve(root, args = {}) {
1727
1850
  // the held home is read again on its next use after a file it is decoded from changes (file events, no interval),
1728
1851
  // watched before anything reads it; a watch that cannot be kept stops the owner, for its keeper to start it again
1729
1852
  const unfollowHome = followHome(root, {
1730
- onChange: (name) => log(`home changed (${name}): read again on its next use`),
1853
+ onChange: (name) => { log(`home changed (${name}): read again on its next use`); listenerRevision++; listenerBootstrapped = false; fleetListeners?.suspend(); reloadListeners(); },
1731
1854
  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
1855
  });
1733
1856
  // Hermes's install is located once per process (a launch's memory snapshot reads it): here, before any verb is
@@ -1767,7 +1890,7 @@ async function serve(root, args = {}) {
1767
1890
  } });
1768
1891
  // 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
1892
  // 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'));
1893
+ const unfollowPanes = onPanesChanged(() => events?.wake('machine-record'));
1771
1894
  // the owner holds its boards only now, its watch installed (watch before load): opened once, changed by events
1772
1895
  holdBoards(root);
1773
1896
  // a pause of the collector holds the loop like any synchronous step: each is a step, so a block line names it
@@ -1789,6 +1912,58 @@ async function serve(root, args = {}) {
1789
1912
  };
1790
1913
  await registerOwner();
1791
1914
  onLocalReopen(registerOwner);
1915
+ let listenerRecords = {}, listenerReloading = null, listenerReloadDue = false, listenerReading = null;
1916
+ const listenerErrors = new Set();
1917
+ const listenerLog = text => { if (!listenerErrors.has(text)) { if (listenerErrors.size >= 128) listenerErrors.clear(); listenerErrors.add(text); log(text); } };
1918
+ const scanListeners = ({ waiting = true } = {}) => listenerBootstrapped && fleetRecordsCurrent() && ownCall(() => observeRoleFleet(root, fleetListeners, listenerRecords, listenerLog, { waiting }));
1919
+ const readListenerRecords = () => {
1920
+ if (!fleetListeners.enabled || stopped || listenerReading) return listenerReading;
1921
+ const revision = listenerRevision;
1922
+ listenerReading = readFleetRecords().then(records => {
1923
+ if (revision !== listenerRevision || !fleetListeners.enabled || stopped) return;
1924
+ listenerRecords = records;
1925
+ if (!listenerBootstrapped) { fleetListeners.bootstrap(records); listenerBootstrapped = true; scanListeners(); }
1926
+ })
1927
+ .catch(error => { listenerRecords = {}; listenerLog(`fleet listener records unread: ${String(error.message).slice(0, 300)}`); })
1928
+ .finally(() => { listenerReading = null; });
1929
+ return listenerReading;
1930
+ };
1931
+ fleetListeners = new FleetListenerRouter({ root, log: listenerLog,
1932
+ file: (to, body, key) => ownCall(() => fileFleetNotice(root, to, body, key)),
1933
+ enrich: event => ownCall(() => enrichFleetEvent(root, event)),
1934
+ retry: () => scanListeners({ waiting: false }),
1935
+ });
1936
+ const unfollowRecords = onRecordsChanged(records => { listenerRecords = records; scanListeners(); });
1937
+ reloadListeners = () => {
1938
+ listenerReloadDue = true;
1939
+ if (listenerReloading || stopped) return listenerReloading;
1940
+ listenerReloading = (async () => {
1941
+ while (listenerReloadDue && !stopped) {
1942
+ listenerReloadDue = false;
1943
+ const revision = listenerRevision;
1944
+ try {
1945
+ const orchestration = await ownCall(() => readHome(root));
1946
+ if (revision !== listenerRevision || stopped) continue;
1947
+ fleetListeners.configure(orchestration.workflow?.listeners ?? {});
1948
+ if (fleetListeners.enabled) { scanListeners(); void readListenerRecords(); }
1949
+ } catch (error) {
1950
+ fleetListeners.suspend();
1951
+ listenerLog(`fleet listener configuration unread; retained obligations wait: ${String(error.message).slice(0, 300)}`);
1952
+ // A failed source read is retried by the next round; a failed home read also retains no stale intent.
1953
+ listenerReloadDue = true; break;
1954
+ }
1955
+ }
1956
+ })().finally(() => { listenerReloading = null; });
1957
+ return listenerReloading;
1958
+ };
1959
+ await reloadListeners();
1960
+ // Retry a failed home decode without waiting for a dispatch sweep. Reading the held stream also enforces its
1961
+ // heartbeat deadline and reconnects after local caller loss, even when every board's dispatching is off.
1962
+ let nextListenerRead = 0;
1963
+ const listenerTimer = setInterval(() => {
1964
+ if (listenerReloadDue) void reloadListeners();
1965
+ if (Date.now() >= nextListenerRead) { nextListenerRead = Date.now() + 5000; void readListenerRecords(); }
1966
+ }, 2000); listenerTimer.unref?.();
1792
1967
  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
1968
  const stuck = {};
1794
1969
  const warned = {};
@@ -1798,6 +1973,8 @@ async function serve(root, args = {}) {
1798
1973
  let wakeReason = null;
1799
1974
  try {
1800
1975
  while (!stopped) {
1976
+ if (listenerReloadDue) void reloadListeners();
1977
+ if (fleetListeners.enabled) scanListeners();
1801
1978
  // A serve never ticks on code older than the installed orchestrator: after its round it stops, and its keeper
1802
1979
  // starts the new one. Nothing waits for an installation; a second round, old or new, is harmless.
1803
1980
  const replaced = (() => { const now = installed(); return running && now && now !== running ? now : null; })();
@@ -1866,6 +2043,10 @@ async function serve(root, args = {}) {
1866
2043
  if (moved) { log(`orchestrator ${moved} is installed (this serve runs ${running}); stopping, for its keeper to start the new one`); break; }
1867
2044
  }
1868
2045
  } finally {
2046
+ fleetListeners.suspend(); clearInterval(listenerTimer); unfollowRecords(); unfollowPanes();
2047
+ await listenerReloading;
2048
+ await listenerReading;
2049
+ try { await fleetListeners.close(); } catch (error) { log(`fleet listener shutdown: retained filing outcome unknown (${String(error.message).slice(0, 300)})`); }
1869
2050
  // a fetch in flight lands, and what it brought is merged, while this owner still holds the home's lock
1870
2051
  try { const { settleDocuments } = await import('./git-document.mjs'); await settleDocuments(); } catch { /* nothing in flight */ }
1871
2052
  unwatchLoop?.(); releaseHeldBoards(); closeVerbs();