@volter/supercode-orchestrator 0.4.148 → 0.4.150

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/board/cli.mjs CHANGED
@@ -7,13 +7,13 @@
7
7
  import { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
8
8
  import { spawnSync } from 'node:child_process';
9
9
  import { basename, join, resolve } from 'node:path';
10
- import { tmpdir } from 'node:os';
10
+ import { tmpdir, userInfo } from 'node:os';
11
11
  import { read } from '../orchestration.mjs';
12
12
  import { actorOf, view, backingPaths, layOut, backingOf, boardExists, boardPath, DEFAULT_BOARD, event, json, listBoards, logsDir, now, openBoard, setActor, tx } from './store.mjs';
13
13
  import { isLocalWorkspace } from './workspace.mjs';
14
14
  import { openZtrackBoard } from './ztrack.mjs';
15
15
  import * as engine from './engine.mjs';
16
- import { adoptSession, boardAddress, boardLabel, boardSettings, callerSession, commandEnv, reconcileCallerLaunch, closeRequested, lanesOf, localMachine, observeFleet, paused, plan, startCard, supercodeBin, tick } from './dispatch.mjs';
16
+ import { requestAdoption, boardAddress, boardLabel, boardSettings, callerSession, commandEnv, reconcileCallerLaunch, closeRequested, lanesOf, localMachine, observeFleet, paused, plan, startCard, supercodeBin, tick } from './dispatch.mjs';
17
17
  import { watchBoard } from './wake.mjs';
18
18
  import { completionOwnerOf, isLatestReviewer, loadWorkflow, requestSessionClose, scopeOf, statusOf, useWorkflow, workflowEntry, workflowFor } from './workflow.mjs';
19
19
  import { evaluate } from './expr.mjs';
@@ -29,14 +29,14 @@ Hermes Kanban's verbs, with their flags:
29
29
  [--model] [--provider] [--goal|--no-goal] [--goal-max-turns] [--initial-status blocked|running]
30
30
  swarm <goal> --worker PROFILE:TITLE[:SKILL,SKILL]... --verifier P --synthesizer P
31
31
  list [--mine] [--assignee] [--status] [--tenant] [--session] [--archived] [--sort] show <id> context <id> runs <id>
32
- assign <id> <profile|none> reassign <id> <profile> [--reclaim] [--reason] set-model <id> [model] [--provider] set-goal <id>... [--goal-max-turns N] [--off]
32
+ 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]
33
33
  claim <id> [--ttl] reclaim <id> [--reason] heartbeat <id> [--note] [--until <time|30m>] link|unlink <parent> <child>
34
34
  comment <id> <text> [--author] [--max-len] attach <id> <path> [--name] [--content-type] attachments <id> attach-rm <n>
35
35
  complete <id>... [--result] [--summary] [--metadata JSON] edit <id> --result [--summary] [--metadata]
36
36
  block <id> [reason] (--on <card> | --waiting-on <message id>) [--ids ...] schedule <id> [reason] [--ids ...]
37
- pause <id> --reason <why> resume <id> cancel <id> set-every <id> <interval|none>
37
+ pause <id> --reason <why> resume <id> cancel <id>
38
38
  request-state <id> --state block|pause|done|cancelled --reason <why> [--on <card>|--waiting-on <message>] [--evidence <pointers>]
39
- unblock <id>... [--reason] request-review <id> --pr URL --pr-head SHA [--summary] [--reviewer] [--force] request-changes <id> <reason> reopen-review <id>... reopen <id>... [--reason]
39
+ 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]
40
40
  promote <id> [reason] [--ids ...] [--force] [--dry-run] archive <id>... [--rm <id>...]
41
41
  specify <id> [--title --body] [--workspace scratch|worktree:<repository>|dir:<repository>] [--branch NAME] [--all]
42
42
  decompose <id> [--child "<title>[@<profile>]"]... [--all]
@@ -48,7 +48,7 @@ Hermes Kanban's verbs, with their flags:
48
48
  supercode's own:
49
49
  init --backing sqlite|files · boards create <slug> --backing sqlite|files
50
50
  Where the board is kept: sqlite is Hermes's kanban.db (the default); files is one JSON file per card.
51
- create … --machine M --subtask-of ARC --notify ADDRESS|--no-notify --no-start [--review-only]
51
+ create … --machine M --subtask-of ARC --notify ADDRESS|--no-notify --no-start
52
52
  With a pane lane and room, a card starts at once and answers "started" with its session; else it answers why it
53
53
  waits, and its creator is told when it starts. A subtask is worked by its arc's session and never starts alone.
54
54
  move <id> <machine|none> adopt <id> <session> [--assignee P] [--role ROLE] close <id> serve (= daemon) list --machine M
@@ -61,10 +61,9 @@ supercode's own:
61
61
  create ... --adopt <session> hold dispatch until that live session is attached
62
62
  goto <id> <status> [reason] [--<field> value]: the event the workflow says takes the card to that status, sent.
63
63
 
64
- specify <id> --review-only|--no-review-only: a manager declares a standalone independent review job.
65
64
  specify <id> --workspace dir:<repository> --branch NAME: the manager retargets a stopped card without replacing its history.
66
- request-review <id> --pr URL --pr-head SHA [--branch NAME]: hand over an open PR at its pushed head.
67
- request-state <id> --state done --reviewed-sha SHA: record the verdict for the requested PR head.
65
+ request-review <id> (--pr URL --pr-head SHA)...: hand over every open PR the arc changed, each at its pushed head; one review covers them all.
66
+ request-state <id> --state done (--reviewed-sha SHA)...: record the verdict on every requested PR head.
68
67
  request-state <id>... --state STATE --reason REASON: request several decisions in one retained manager mail.
69
68
  complete <id>: creator/manager accepts the independently reviewed arc; deployment CI is not a board gate.
70
69
  specify/decompose with no spec start a session on the triage card to write it. A card's own session may comment on,
@@ -72,9 +71,9 @@ supercode's own:
72
71
  workflow declares who may block, pause or finish a card; request-state mails its manager without changing state.
73
72
  `;
74
73
 
75
- const MULTI = new Set(['parent', 'skill', 'worker', 'child', 'ids', 'rm']);
74
+ const MULTI = new Set(['parent', 'skill', 'worker', 'child', 'ids', 'rm', 'pr', 'pr-head', 'reviewed-sha', 'finding']);
76
75
  const FLAGS = new Set(['json', 'triage', 'no-notify', 'no-start', 'all', 'force', 'help', 'mine', 'archived', 'reclaim', 'dry-run',
77
- 'goal', 'no-goal', 'review-only', 'no-review-only', 'off', 'switch', 'delete', 'no-attachments', 'include-logs', 'verbose']);
76
+ 'goal', 'no-goal', 'off', 'switch', 'delete', 'no-attachments', 'include-logs', 'verbose']);
78
77
  // flags that take every following word up to the next flag (Hermes's nargs='+')
79
78
  const GREEDY = new Set(['ids', 'rm']);
80
79
 
@@ -180,11 +179,11 @@ async function laneState(root) {
180
179
  }
181
180
 
182
181
  /** The caller's own supercode address, when it is a session supercode can reach (for its card's notices). */
182
+ // The calling session's own address, as its actor is named: no fleet scan (a `message list` costs seconds).
183
183
  function callerAddress() {
184
184
  const id = callerSession();
185
185
  if (!id) return null;
186
- const fleet = observeFleet();
187
- return [...fleet.alive].find((a) => a.endsWith(`:${id}`)) ?? null;
186
+ return `sc:${localMachine()}:${id === process.env.CODEX_THREAD_ID ? 'codex' : 'claude-code'}:${id}`;
188
187
  }
189
188
 
190
189
  const callerName = () => process.env.HERMES_PROFILE || process.env.USER || 'user';
@@ -207,7 +206,7 @@ const cardDict = (t) => ({
207
206
  created_by: t.created_by, created_at: t.created_at, started_at: t.started_at, completed_at: t.completed_at, result: t.result,
208
207
  skills: t.skills ?? [], max_retries: t.max_retries, model_override: t.model_override, provider_override: t.provider_override,
209
208
  session_id: t.session_id, workflow_template_id: t.workflow_template_id, current_step_key: t.current_step_key,
210
- machine: t.machine ?? null, subtask_of: t.subtask_of ?? null, review_only: Boolean(t.review_only),
209
+ machine: t.machine ?? null, subtask_of: t.subtask_of ?? null,
211
210
  });
212
211
 
213
212
  /** Hermes's durations: seconds, or 30s / 5m / 2h / 1d. */
@@ -239,7 +238,6 @@ const metadataOf = (v) => {
239
238
  export async function runWorkflow(argv) {
240
239
  const args = parse(argv);
241
240
  const [verb, ...rest] = args._;
242
- if (args['review-only'] && args['no-review-only']) throw new Error('Choose review-only or no-review-only, not both.');
243
241
  if (!verb || verb === 'help' || args.help) { process.stdout.write(HELP); return 0; }
244
242
  const root = homeOf(args);
245
243
  const slug = boardOf(root, args);
@@ -266,8 +264,8 @@ export async function runWorkflow(argv) {
266
264
  if (verb === 'comment' && isLatestReviewer(db, cardId, actorOf()) && !roles.includes('reviewer')) roles.push('reviewer');
267
265
  return roles;
268
266
  });
269
- // a verb with no session behind it (a person at the CLI, the board file's sync) is the manager's, as its roles are
270
- if (!sid && verb !== 'help') { try { setActor([].concat(workflowFor(root).params?.managers ?? [])[0] ?? null); } catch { /* no workflow */ } }
267
+ // a verb with no session behind it is recorded as the OS user's; it holds no role on any card
268
+ if (!sid && verb !== 'help') setActor(`user:${userInfo().username}`);
271
269
 
272
270
  switch (verb) {
273
271
  case 'migrate': {
@@ -282,7 +280,7 @@ export async function runWorkflow(argv) {
282
280
  const migrated = openZtrackBoard(preparing, { create: true, document: args.document, seed: { db: source.db, mapping }, init: layOut, root, slug, compatPath: paths.sqlite });
283
281
  migrated.close();
284
282
  } finally { source.close(); }
285
- renameSync(`${preparing}.runtime.db`, `${paths.ztrack}.runtime.db`);
283
+ renameSync(`${preparing}.board.db`, `${paths.ztrack}.board.db`);
286
284
  renameSync(preparing, paths.ztrack);
287
285
  say(args, `Board migrated to ${args.document}; kanban.db is now a Hermes export`, { ok: true, backing: 'ztrack', document: args.document });
288
286
  return 0;
@@ -298,7 +296,6 @@ export async function runWorkflow(argv) {
298
296
 
299
297
  case 'create': {
300
298
  need(1, 'a title');
301
- const { lanes, settings } = await laneState(root);
302
299
  const maxRetries = args['max-retries'] !== undefined ? Number(args['max-retries']) : null;
303
300
  if (maxRetries !== null && !(maxRetries >= 1)) throw new Error(`--max-retries must be >= 1 (got ${args['max-retries']}); use 1 to trip on the first failure`);
304
301
  const meta = boardMeta(root, slug);
@@ -310,29 +307,25 @@ export async function runWorkflow(argv) {
310
307
  workspace, branch: args.branch, parents: args.parent, priority: args.priority, tenant: args.tenant,
311
308
  triage: args.triage, idempotencyKey: args['idempotency-key'], maxRuntime: duration(args['max-runtime']),
312
309
  maxRetries, skills: args.skill, model: args.model, provider: args.provider, createdBy: args['created-by'] ?? callerName(),
313
- noStart: Boolean(args['no-start']), adopt: args.adopt ?? null, reviewOnly: Boolean(args['review-only']),
310
+ noStart: Boolean(args['no-start']), adopt: args.adopt ?? null,
314
311
  every: args.every ?? null, sessionId: callerSession(), subtaskOf: args['subtask-of'] ?? null, initialStatus: args['initial-status'] ?? null,
315
312
  project: args.project ?? meta.project_id ?? null, goal: args.goal ? { maxTurns: args['goal-max-turns'] ? Number(args['goal-max-turns']) : null } : args['no-goal'] ? false : null,
316
313
  }), true);
317
314
  let answer = { id: created.id, existing: created.existing };
318
315
  if (args.adopt) {
319
316
  withBoard(b => { if (!rolesFor(b.db, created.id, sid, workflowFor(root).params).includes('manager')) throw new Error('Only the manager adopts an existing session.'); });
320
- const adopted = withBoard(b => adoptSession(b, created.id, args.adopt, { ttl: settings.ttl }));
321
- answer = { ...answer, started: true, session: adopted.address, mode: adopted.mode };
322
- } else if (!args['no-start'] && !created.existing && !args['subtask-of']) {
323
- const [report] = await tick({ root, lanes, settings, only: created.id, slugs: [slug] });
324
- const started = report.started.find(([t]) => t === created.id);
325
- const queued = report.queued.find(([t]) => t === created.id);
326
- const failed = report.failed.find(([t]) => t === created.id);
327
- answer = { ...answer, ...(started ? { started: true, session: started[1], mode: started[2] } : {}), ...(queued ? { started: false, waits: queued[1] } : {}), ...(failed ? { started: false, failed: failed[1] } : {}) };
317
+ withBoard(b => requestAdoption(b, created.id, args.adopt));
318
+ answer = { ...answer, adoption_requested: args.adopt };
328
319
  }
320
+ // Starting a card is the dispatcher's: this write's wake hint reaches it, and the card's
321
+ // thread records the start. A create never runs a dispatch tick inline.
329
322
  // the creator hears what happens from here on; a start answered above is not told twice
330
323
  const address = args['no-notify'] ? null : args.notify ?? callerAddress();
331
324
  if (address) withBoard((b) => engine.subscribe(b, created.id, address, 'creator'));
332
325
  const task = withBoard((b) => view(b, (db) => engine.getTask(db, created.id)));
333
326
  const recipients = withBoard(b => engine.subscriptions(b.db, created.id)).filter(s => s.platform === 'supercode').map(s => s.chat_id);
334
327
  if (args.json) { say(args, '', { ...cardDict(task), ...answer, notify: address ?? null, recipients }); return answer.failed ? 1 : 0; }
335
- const said = answer.started ? `; started session ${answer.session} (${answer.mode})` : answer.failed ? `; did not start: ${answer.failed}` : answer.waits ? `; waits: ${answer.waits}` : '';
328
+ 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}` : '';
336
329
  say(args, `${created.existing ? 'Existing' : 'Created'} ${created.id} (${task.status}, assignee=${task.assignee || '-'})${said}${recipients.length ? `; notices go to ${recipients.join(', ')}` : ''}`, null);
337
330
  return answer.failed ? 1 : 0;
338
331
  }
@@ -472,17 +465,18 @@ export async function runWorkflow(argv) {
472
465
  return 0;
473
466
  }
474
467
 
475
- case 'assign': need(2, 'a card id and a profile (or none)'); await withBoardAsync((b) => engine.assign(b, id, rest[1] === 'none' ? null : rest[1])); say(args, `Assigned ${id} to ${rest[1] === 'none' ? '(nobody)' : rest[1]}`, { ok: true }); return 0;
476
- case 'reassign': {
477
- need(2, 'a card id and a profile');
478
- await withBoardAsync(async (b) => {
479
- const task = engine.getTask(b.db, id);
480
- if (task?.current_run_id != null && args.reclaim) await engine.reclaim(b, id, args.reason ?? 'reassign');
481
- await engine.assign(b, id, rest[1] === 'none' ? null : rest[1]);
482
- });
483
- say(args, `Reassigned ${id} to ${rest[1]}`, { ok: true }); return 0;
468
+ // One door sets a card's properties; the workflow's `set` rules say who may and what follows (a live run
469
+ // whose session must change is ended, and the dispatcher starts the next itself). `assign`/`reassign` are
470
+ // Hermes's names for setting the assignee, and `set-model` its name for setting the model.
471
+ case 'set': {
472
+ need(3, 'a card id, a property (assignee, machine, model or every) and its value (or none)');
473
+ const value = rest[2] === 'none' ? null : rest[2];
474
+ const cards = ids([id]);
475
+ for (const t of cards) await withBoardAsync((b) => engine.set(b, t, rest[1], value, { provider: args.provider ?? null }));
476
+ say(args, cards.map((t) => `${t}: ${rest[1]} ${value ?? '(none)'}`).join('\n'), { ok: true }); return 0;
484
477
  }
485
- case 'set-model': need(1, 'a card id'); await withBoardAsync((b) => engine.setModel(b, id, rest[1] ?? null, args.provider ?? null)); say(args, rest[1] ? `${id} runs on model ${rest[1]}${args.provider ? ` (${args.provider})` : ''} from its next start` : `${id} uses its profile's model`, { ok: true }); return 0;
478
+ case 'set-model': need(1, 'a card id'); await withBoardAsync((b) => engine.set(b, id, 'model', rest[1] ?? null, { provider: args.provider ?? null })); say(args, rest[1] ? `${id} runs on model ${rest[1]}${args.provider ? ` (${args.provider})` : ''} from its next start` : `${id} uses its profile's model`, { ok: true }); return 0;
479
+ case 'assign': case 'reassign': need(2, 'a card id and a profile (or none)'); await withBoardAsync((b) => engine.set(b, id, 'assignee', rest[1] === 'none' ? null : rest[1])); say(args, `Assigned ${id} to ${rest[1] === 'none' ? '(nobody)' : rest[1]}`, { ok: true }); return 0;
486
480
  case 'set-goal': {
487
481
  // a card into goal mode (or out of it with --off) from its next idle turn: the heartbeat keeps its session going
488
482
  need(1, 'a card id');
@@ -491,7 +485,6 @@ export async function runWorkflow(argv) {
491
485
  for (const t of ids()) await withBoardAsync((b) => engine.setGoal(b, t, { on: !args.off, maxTurns }));
492
486
  say(args, ids().map((t) => args.off ? `${t} is no longer a goal card` : `${t} is a goal card${maxTurns ? ` (${maxTurns} turns)` : ''}`).join('\n'), { ok: true }); return 0;
493
487
  }
494
- case 'move': need(2, 'a card id and a machine (or none)'); await withBoardAsync((b) => engine.setMachine(b, id, rest[1] === 'none' ? null : rest[1])); say(args, `${id} runs on ${rest[1]}`, { ok: true }); return 0;
495
488
 
496
489
  case 'claim': {
497
490
  need(1, 'a card id');
@@ -552,7 +545,7 @@ export async function runWorkflow(argv) {
552
545
  case 'complete': {
553
546
  need(1, 'a card id');
554
547
  const metadata = metadataOf(args.metadata);
555
- 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, branch: args.branch ?? null, pr: args.pr ?? null, pr_head: args['pr-head'] ?? null, reviewed_sha: args['reviewed-sha'] ?? null }));
548
+ 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 }));
556
549
  say(args, rest.map((t) => `Completed ${t}`).join('\n'), { ok: true, completed: rest }); return 0;
557
550
  }
558
551
  case 'edit': {
@@ -577,13 +570,21 @@ export async function runWorkflow(argv) {
577
570
  case 'unblock': {
578
571
  need(1, 'a card id');
579
572
  const results = [];
580
- for (const t of ids()) results.push([t, (await withBoardAsync((b) => engine.unblock(b, t, { reason: args.reason ?? null, on: args.on ?? null }))).to]);
573
+ for (const t of ids()) results.push([t, (await withBoardAsync((b) => engine.unblock(b, t, { reason: args.reason ?? null, on: args.on ?? args['waiting-on'] ?? null }))).to]);
581
574
  // Hermes records the reason as a comment on each card it unblocks
582
575
  if (args.reason) for (const [t] of results) await withBoardAsync((b) => engine.comment(b, t, callerName(), `UNBLOCK: ${args.reason}`));
583
576
  say(args, results.map(([t, s]) => `Unblocked ${t} → ${s}`).join('\n'), { ok: true, results }); return 0;
584
577
  }
585
578
  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;
586
- case 'request-changes': need(2, 'a card id and the reason'); await withBoardAsync((b) => engine.requestChanges(b, id, { reason: rest.slice(1).join(' '), reviewer: args.reviewer ?? null })); say(args, `${id} sent back for changes`, { ok: true }); return 0;
579
+ case 'request-changes': {
580
+ need(2, 'a card id and the reason');
581
+ // each finding is filed as a task on the arc first, so the arc stays open until a pushed head fixes it
582
+ const findings = [].concat(args.finding ?? []).map((f) => String(f).trim()).filter(Boolean);
583
+ const filed = [];
584
+ for (const finding of findings) filed.push((await withBoardAsync((b) => engine.createCard(b, { title: finding, subtaskOf: id }))).id);
585
+ await withBoardAsync((b) => engine.requestChanges(b, id, { reason: rest.slice(1).join(' '), reviewer: args.reviewer ?? null }));
586
+ say(args, `${id} sent back for changes${filed.length ? `; findings filed as ${filed.join(', ')}` : ''}`, { ok: true, findings: filed }); return 0;
587
+ }
587
588
  case 'reopen': {
588
589
  // a done card back to work; the cards that built on it are invalidated with it (Hermes's reopen)
589
590
  need(1, 'a card id');
@@ -637,8 +638,8 @@ export async function runWorkflow(argv) {
637
638
  if (!targets[0]) throw new Error(`${verb} needs a card id, or --all`);
638
639
  const results = [];
639
640
  for (const t of targets) {
640
- if (verb === 'specify' && (args.title !== undefined || args.body !== undefined || args.workspace !== undefined || args.branch !== undefined || args['review-only'] || args['no-review-only'])) {
641
- const moved = await withBoardAsync((b) => engine.specify(b, t, { title: args.title ?? null, body: args.body ?? null, workspace: args.workspace ?? null, branch: args.branch ?? null, reviewOnly: args['review-only'] ? true : args['no-review-only'] ? false : null, author: args.author ?? callerAddress() ?? callerName() }));
641
+ if (verb === 'specify' && (args.title !== undefined || args.body !== undefined || args.workspace !== undefined || args.branch !== undefined)) {
642
+ const moved = await withBoardAsync((b) => engine.specify(b, t, { title: args.title ?? null, body: args.body ?? null, workspace: args.workspace ?? null, branch: args.branch ?? null, author: args.author ?? callerAddress() ?? callerName() }));
642
643
  results.push({ id: t, specified: true, status: moved.to });
643
644
  } else if (verb === 'decompose' && args.child?.length) {
644
645
  const children = args.child.map((c) => { const [title, assignee] = c.split(/@(?=[^@]*$)/); return { title: title.trim(), assignee: assignee?.trim() || undefined }; });
@@ -811,11 +812,6 @@ export async function runWorkflow(argv) {
811
812
  return report.status === 'corrupt' ? 1 : 0;
812
813
  }
813
814
 
814
- case 'set-every': {
815
- need(1, 'a card id');
816
- await withBoardAsync((b) => engine.act(b, id, 'set_every', { payload: { every: rest[1] === 'none' ? null : rest[1] } }));
817
- say(args, `${id} recurrence updated`, { ok: true }); return 0;
818
- }
819
815
  case 'cancel': {
820
816
  need(1, 'a card id');
821
817
  await withBoardAsync((b) => engine.act(b, id, 'cancel', { payload: { reason: args.reason ?? rest.slice(1).join(' ') } }));
@@ -850,9 +846,8 @@ export async function runWorkflow(argv) {
850
846
  case 'adopt': {
851
847
  need(2, 'a card id and a session');
852
848
  withBoard(b => { if (!rolesFor(b.db, id, sid, workflowFor(root).params).includes('manager')) throw new Error('Only the manager adopts an existing session.'); });
853
- const { settings } = await laneState(root);
854
- const got = withBoard((b) => adoptSession(b, id, rest[1], { workspace: args.workspace ?? null, ttl: settings.ttl, assignee: args.assignee ?? null, role: args.role ?? null }));
855
- say(args, `${id} is worked by ${got.address} (pane ${got.pane ?? '?'} in ${got.workspace ?? 'its card\'s directory'})`, { ok: true, ...got });
849
+ withBoard((b) => requestAdoption(b, id, rest[1], { workspace: args.workspace ?? null, assignee: args.assignee ?? null, role: args.role ?? null }));
850
+ say(args, `${id} is held for ${rest[1]}; the dispatcher verifies and binds it on its next tick (session_adopted, or adoption_failed with the reason)`, { ok: true, requested: rest[1] });
856
851
  return 0;
857
852
  }
858
853
  case 'goto': {
@@ -1099,12 +1094,12 @@ function ownCard(db, sessionId) {
1099
1094
  const READS = new Set(['list', 'ls', 'show', 'context', 'runs', 'log', 'tail', 'watch', 'stats', 'assignees', 'diagnostics', 'diag', 'attachments', 'notify-list', 'help', 'init']);
1100
1095
 
1101
1096
  /**
1102
- * Who a caller is on card `cardId`, in the workflow's roles: a person or the fleet's manager at the CLI (no session)
1103
- * is `manager`; the session of the card's current run has that run's role; the card's working session between runs
1097
+ * Who a caller is on card `cardId`, in the workflow's roles: a caller with no session behind it has no role (the
1098
+ * manager acts from its own session, named in params.managers); the session of the card's current run has that run's role; the card's working session between runs
1104
1099
  * is its `implementer`; the session working a subtask's arc is `arc`; its recorded creator also has the creator role; any other session is nobody on it.
1105
1100
  */
1106
1101
  function rolesFor(db, cardId, sessionId, params = {}) {
1107
- if (!sessionId) return ['manager', 'owner'];
1102
+ if (!sessionId) return [];
1108
1103
  // a session the workflow names as the board's manager (params.managers: its addresses) is the manager on every card
1109
1104
  const managers = Array.isArray(params.managers) ? params.managers.map(String) : [];
1110
1105
  if (managers.some((m) => m === sessionId || m.endsWith(`:${sessionId}`))) return ['manager'];
@@ -1133,7 +1128,7 @@ function authorize(db, verb, rest, args, own) {
1133
1128
  if (verb === 'boards') { if (!['list', 'ls', 'show', 'current', undefined].includes(rest[0])) refuse('does not change the home\'s boards'); return; }
1134
1129
  // 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
1135
1130
  // (what a session may do to a card is its workflow's: each transition's `by`)
1136
- if ((['pause', 'resume'].includes(verb) && !rest[0]) || ['swarm', 'dispatch', 'daemon', 'serve', 'gc', 'repair', 'adopt', 'close', 'move', 'claim', 'attach-rm', 'migrate'].includes(verb)) refuse(`does not ${verb}; the fleet's manager does`);
1131
+ if ((['pause', 'resume'].includes(verb) && !rest[0]) || ['swarm', 'dispatch', 'daemon', 'serve', 'gc', 'repair', 'adopt', 'close', 'set', 'claim', 'attach-rm', 'migrate'].includes(verb)) refuse(`does not ${verb}; the fleet's manager does`);
1137
1132
  if (args.all) refuse(`does not ${verb} every card; only its own`);
1138
1133
  if (['notify-subscribe', 'notify-unsubscribe'].includes(verb) && args['for-card']) {
1139
1134
  if (args['for-card'] !== own.id) refuse(`subscribes only its own arc (--for-card ${own.id})`);
@@ -1211,18 +1206,30 @@ async function serve(root, args = {}) {
1211
1206
  // An installer owns this operational window, not a card pause. Finish the
1212
1207
  // current tick, acknowledge the request, then leave every run untouched.
1213
1208
  // A replacement serve sees the same request before its first tick.
1209
+ // A request whose installer is gone (its pane closed, or it was killed before clearing it) is void.
1214
1210
  const installation = join(root, 'installation-window.json');
1215
- if (existsSync(installation)) {
1216
- const request = JSON.parse(readFileSync(installation, 'utf8'));
1211
+ const readJson = (path) => { try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; } };
1212
+ const request = existsSync(installation) ? readJson(installation) : null;
1213
+ const installerAlive = (pid) => { try { process.kill(Number(pid), 0); return true; } catch (error) { return error.code === 'EPERM'; } };
1214
+ if (request?.token && request.pid && !installerAlive(request.pid)) {
1215
+ log(`installation window ${request.token} of ${request.owner} is void: its installer ${request.pid} is gone`);
1216
+ for (const path of [installation, join(root, 'installation-window.ack.json')]) if (readJson(path)?.token === request.token) try { unlinkSync(path); } catch { /* gone */ }
1217
+ } else if (request?.token) {
1217
1218
  const acknowledgment = join(root, 'installation-window.ack.json');
1218
- const previous = existsSync(acknowledgment) ? JSON.parse(readFileSync(acknowledgment, 'utf8')) : null;
1219
+ const previous = readJson(acknowledgment);
1219
1220
  if (previous?.token !== request.token || previous?.pid !== process.pid) {
1220
- writeFileSync(acknowledgment, JSON.stringify({ token: request.token, pid: process.pid, at: Date.now() }));
1221
+ const temporary = `${acknowledgment}.${process.pid}`;
1222
+ writeFileSync(temporary, JSON.stringify({ token: request.token, pid: process.pid, at: Date.now() }));
1223
+ renameSync(temporary, acknowledgment);
1221
1224
  log(`installation window held by ${request.owner}; dispatch waits`);
1222
1225
  }
1223
1226
  await new Promise(resolve => setTimeout(resolve, 250));
1224
1227
  continue;
1225
1228
  }
1229
+ // A serve never ticks on code older than the installed orchestrator, also right after an
1230
+ // installation window: its keeper starts the new one, whose board store this one does not keep.
1231
+ const replaced = (() => { const now = installed(); return running && now && now !== running ? now : null; })();
1232
+ if (replaced) { log(`orchestrator ${replaced} is installed (this serve runs ${running}); stopping, for its keeper to start the new one`); break; }
1226
1233
  pausedSaid = Boolean(paused(root)) && pausedSaid;
1227
1234
  let interval = 60;
1228
1235
  let nextMailAt = Infinity;