trantor 0.18.18 → 0.18.20

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.18",
3
+ "version": "0.18.20",
4
4
  "description": "Trantor \u2014 the hub-world for AI agent crews: live message bus, presence, project Kanban/flow board + crew orchestration for independent AI coding agents (Claude, Codex, Gemini, Kimi, DeepSeek)",
5
5
  "mcpServers": {
6
6
  "relay": {
package/README.md CHANGED
@@ -230,6 +230,14 @@ project's workspace, so when you run several sessions each driving its own crew,
230
230
  can't nuke another's. `trantor down <agent>` drops a single seat; `trantor down --all --yes` tears down
231
231
  every project's crew.
232
232
 
233
+ Since 0.18.20, **seat trouble wakes the foreman instead of hoping someone looks**: a failing or
234
+ dead seat direct-messages the project's orchestrator (broadcasts wake nobody), a detached
235
+ watchdog reports a turn running silent past 15 minutes — once, without killing it — and the
236
+ failure classifier tells a provider backend error ("retry or swap") from real quota exhaustion
237
+ ("wait the window out"). The duty seat itself now runs under a launchd keepalive, so the fleet's
238
+ janitor relaunches after a crash or reboot instead of dying silently, and the hub routes
239
+ escalations back to their senders whenever the janitor goes dark.
240
+
233
241
  **One-time setup:**
234
242
  - Install cmux — `brew install --cask cmux` (or grab it from **[cmux.com](https://cmux.com)**).
235
243
  - Trantor drives cmux over its control socket, which is off to outside processes by default. Enable it in
@@ -370,6 +378,7 @@ rate, not work rate.
370
378
  | `relay_project_brief(text)` | The project's what/why on the dashboard |
371
379
  | `relay_task_add(title, …, difficulty, model, deps, note?, project?)` | Cards with difficulty/model badges + DAG edges; `note` seeds the card's **permanent log**; `project` targets another board when you orchestrate from elsewhere |
372
380
  | `relay_task_move(id, status, note?)` | `todo → doing → testing → done` (the gate), `failed`, `blocked` — moves to testing/done should carry a `note`: what you did + the evidence, stored on the card forever |
381
+ | `relay_task_check(id, index, done?)` | Tick one acceptance item on a card's checklist (seeded via `relay_task_add`'s `checklist`) — checked/total is the card's one honest progress denominator |
373
382
  | `relay_board` | The project's full board, as text |
374
383
  | `relay_scrooge(prompt, task?, difficulty?)` | Fractal cheap-model delegation, with the ledger receipt |
375
384
  | `relay_lesson(text, scope?)` | Record a failure lesson — auto-injected into all future crews |
@@ -8,11 +8,11 @@
8
8
  // over plain HTTP (zero tokens, doubles as a heartbeat), and when a message addressed to this
9
9
  // agent arrives it RESUMES the CLI session (native resume = full context kept) with that
10
10
  // message as the prompt. The model just works and ends its turn; the runner does the rest.
11
- import { execSync, spawnSync } from "node:child_process";
11
+ import { execSync, spawnSync, spawn } from "node:child_process";
12
12
  import { readFileSync, writeFileSync, unlinkSync, existsSync, appendFileSync, mkdirSync } from "node:fs";
13
13
  import { join, basename } from "node:path";
14
14
  import { homedir } from "node:os";
15
- import { resolveProject, resolveHub, withEnvFiles } from "../lib/project.mjs";
15
+ import { resolveProject, resolveHub, withEnvFiles, hostId } from "../lib/project.mjs";
16
16
  import { loadOrCreate } from "../lib/identity.mjs";
17
17
  import { signedHeaders } from "../lib/signed-fetch.mjs";
18
18
  import { ensureEnrolled } from "../lib/enroll.mjs";
@@ -314,6 +314,11 @@ const AUTH_MARKER_RE = /unauthor|401|403|forbidden|invalid[ _-]?api[ _-]?key|aut
314
314
  function classifyFailure(exit, errText) {
315
315
  const t = (errText || "").toLowerCase();
316
316
  if (exit === 127) return "missing-cli";
317
+ // #5684: a provider BACKEND failure is not quota — it wants retry/swap, not a window wait.
318
+ // The specimen (#5683): codex's "unexpected status 404 Not Found … /responses/compact" was
319
+ // labelled "exhausted" and the operator was advised to wait out a window that did not exist.
320
+ // 401/403/429 deliberately fall through to the auth/exhausted branches below.
321
+ if (/unexpected status (404|408|410|5\d\d)|internal server error|bad gateway|service unavailable|gateway time.?out|econnrefused|connection refused|socket hang ?up|network is unreachable/.test(t)) return "backend-error";
317
322
  // "reached your … limit" / "usage limit" catch the subscription CLIs (Claude's "You've reached
318
323
  // your Fable 5 limit"), which say nothing about quota or credits and would otherwise read as a crash.
319
324
  if (/quota|insufficient|credit|balance|payment required|402|429|too many requests|rate.?limit|exceeded your|reached your [^.\n]*limit|usage limit|out of (credit|quota)/.test(t)) return "exhausted";
@@ -329,6 +334,7 @@ async function reportFailure(exit, trigger, undelivered = 0) {
329
334
  await api("/register", { session: SESSION, project: PROJ, status, llm: AGENT, model: MODEL }).catch(() => {});
330
335
  const hint = reason === "exhausted" ? " — needs `trantor swap`"
331
336
  : reason === "auth" ? " — check credentials"
337
+ : reason === "backend-error" ? " — provider backend error (NOT quota): retry, or `trantor swap` to another provider"
332
338
  : reason === "missing-cli" ? " — CLI not on PATH" : "";
333
339
  // The count of messages this seat is HOLDING is the operator-actionable half of a failure: a
334
340
  // crashed pulse costs nothing, a crashed turn sitting on three escalations is someone waiting.
@@ -343,6 +349,12 @@ async function reportFailure(exit, trigger, undelivered = 0) {
343
349
  if (state !== announced) {
344
350
  announced = state;
345
351
  await api("/send", { from: SESSION, to: "all", text, project: PROJ }).catch(() => {});
352
+ // #5684: a broadcast does not wake anyone — the incident is the operator spotting dead seats
353
+ // before the foreman did, twice in one morning. The same state-change event now goes DIRECT
354
+ // to the project's orchestrator (direct = wake), gated identically so a standing outage says
355
+ // it once. A seat that IS the orchestrator's own runner has nobody above it to wake.
356
+ const orch = `${hostId()}:${PROJ}`;
357
+ if (orch !== SESSION) await api("/send", { from: SESSION, to: orch, text, project: PROJ }).catch(() => {});
346
358
  } else {
347
359
  log(`still ${state} (${consecFails} fails) — already announced, staying quiet`);
348
360
  }
@@ -420,6 +432,17 @@ function runTurn(prompt, isFirst, trigger = "kickoff") {
420
432
  // `tee /dev/stderr`; the rest now tee straight into ERRF. A real pipeline (not a process
421
433
  // substitution) so bash waits for tee to flush before we read the file back.
422
434
  const inner = cli.sid ? `${cmd} | tee /dev/stderr` : `${cmd} | tee -a ${ERRF}`;
435
+ // #5684: runTurn is spawnSync, so the runner cannot watch its own turn — a DETACHED watchdog
436
+ // does. Armed by a stamp file, disarmed when the turn ends (stamp removed below); a turn past
437
+ // the window with no ERRF growth earns ONE direct stall report to the foreman, never a kill.
438
+ const WD_MS = Number(process.env.TRANTOR_TURN_WATCHDOG_MS || 15 * 60 * 1000);
439
+ const STAMPF = join(homedir(), ".agent-bus", `turnstamp-${AGENT}-${PROJ}.json`);
440
+ try {
441
+ writeFileSync(STAMPF, JSON.stringify({ turn: TURN, startedAt: Date.now() }));
442
+ const wd = spawn(process.execPath, [join(import.meta.dirname, "turn-watchdog.mjs"), STAMPF, ERRF, String(WD_MS), SESSION, PROJ, HUB],
443
+ { detached: true, stdio: "ignore" });
444
+ wd.unref();
445
+ } catch {}
423
446
  const r = spawnSync("/bin/bash", ["-c", `set -o pipefail; { ${inner} ; } 2> >(tee -a ${ERRF} >&2)`], {
424
447
  cwd: TURN_DIR, encoding: "utf8", stdio: cli.sid ? ["ignore", "pipe", "inherit"] : "inherit",
425
448
  env: { ...process.env, RELAY_URL: HUB, RELAY_AGENT: AGENT, RELAY_PROJECT: PROJ,
@@ -436,6 +459,7 @@ function runTurn(prompt, isFirst, trigger = "kickoff") {
436
459
  TRANTOR_NO_HANDOFF_SPAWN: "1", TRANTOR_NO_BATON_SPAWN: "1" },
437
460
  maxBuffer: 16 * 1024 * 1024,
438
461
  });
462
+ try { unlinkSync(STAMPF); } catch {} // turn over — disarm the watchdog
439
463
  try { lastErrText = readFileSync(ERRF, "utf8").slice(-4000); } catch { lastErrText = ""; }
440
464
  if (cli.sid && r.stdout) { const m = r.stdout.match(cli.sid); if (m) sid = m[1]; }
441
465
  const realExit = r.status;
package/bin/doctor.mjs CHANGED
@@ -10,6 +10,7 @@ import { execSync } from "node:child_process";
10
10
  import { fileURLToPath } from "node:url";
11
11
  import { resolveProject, resolveHub, DEFAULT_HUB_URL } from "../lib/project.mjs";
12
12
  import { loadOrCreate } from "../lib/identity.mjs";
13
+ import { sfetchJson } from "../lib/signed-fetch.mjs";
13
14
  import { scan } from "../lib/splitbrain.mjs";
14
15
 
15
16
  const H = homedir();
@@ -87,6 +88,55 @@ section("hub routing");
87
88
  }
88
89
  }
89
90
 
91
+ // ── duty seat: is the fleet's watcher actually alive? ────────────────────────────────────────
92
+ // The duty seat sat dead for four days (2026-08-27→31) while everything else reported green.
93
+ // A dead watcher raises no error of its own — it just stops producing nudges — so this row makes
94
+ // that state loud: process, keepalive, hub registration and the freshness of the seat's last hub
95
+ // beat, each with its fix.
96
+ section("duty seat (the fleet watcher)");
97
+ {
98
+ const BUSD = join(H, ".agent-bus");
99
+ const DUTY_PLIST = join(H, "Library", "LaunchAgents", "com.trantor.duty.plist");
100
+ const FIX_UP = "trantor duty up (installs the launchd keepalive com.trantor.duty, which relaunches the seat after a crash or reboot)";
101
+ let pid = 0;
102
+ try { pid = Number(readFileSync(join(BUSD, "duty.pid"), "utf8")) || 0; if (pid) process.kill(pid, 0); else pid = 0; } catch { pid = 0; }
103
+ const keepalive = existsSync(DUTY_PLIST);
104
+ // The seat watches the FLEET hub — the most common project hub in config, the exact pick
105
+ // bin/duty.mjs makes — not necessarily THIS project's hub.
106
+ const counts = new Map();
107
+ for (const u of Object.values(cfg.hubs || {})) counts.set(u, (counts.get(u) || 0) + 1);
108
+ const fleet = [...counts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] || cfg.url || "";
109
+ const signed = cfg.ownerIdentity ? (() => { try { return loadOrCreate(cfg.ownerIdentity, "human"); } catch { return null; } })() : null;
110
+ const hubGet = (path) => sfetchJson(`${fleet}${path}`, { method: "GET", identity: signed, signal: AbortSignal.timeout(4000) })
111
+ .then((r) => (r?.ok ? r.json() : null)).catch(() => null);
112
+ if (!fleet) {
113
+ note("duty seat: no hub pinned — cannot check the fleet feed");
114
+ } else {
115
+ const st = await hubGet("/overseer/status");
116
+ const peers = await hubGet("/peers");
117
+ const dutySession = st?.dutySession || "";
118
+ const beat = dutySession ? (peers?.sessions || []).find((p) => p.session === dutySession)?.lastSeen || 0 : 0;
119
+ const ageMin = beat ? Math.floor((Date.now() - beat) / 60000) : null;
120
+ const age = ageMin == null ? "no beat yet" : ageMin < 1 ? "beat just now" : ageMin < 60 ? `last beat ${ageMin}m ago` : `last beat ${Math.floor(ageMin / 60)}h ago`;
121
+ if (!st || !peers) {
122
+ // The core section already flags a dead hub; here we only refuse to guess.
123
+ note(`duty seat: hub feed UNKNOWN — ${fleet} did not answer the duty read${cfg.ownerIdentity ? "" : " (no owner identity to sign with)"}`);
124
+ pid ? ok(`duty seat: process running (pid ${pid})`) : warn("duty seat: not running and its hub feed cannot be checked", FIX_UP);
125
+ } else if (pid && dutySession && ageMin != null && ageMin > 5) {
126
+ warn(`duty seat: process up (pid ${pid}) but the hub heard nothing for ${Math.floor(ageMin / 60) >= 1 ? Math.floor(ageMin / 60) + "h " : ""}${ageMin % 60}m — it is running deaf`, "trantor duty down && trantor duty up");
127
+ } else if (pid) {
128
+ ok(`duty seat: running (pid ${pid}${dutySession ? `, ${dutySession} on the fleet hub` : ""}, ${age})`);
129
+ if (process.platform === "darwin" && !keepalive) warn("duty seat: running WITHOUT a keepalive — a crash or reboot leaves it down", FIX_UP);
130
+ } else if (dutySession) {
131
+ warn(`duty seat: the hub still points at ${dutySession} but no seat process is running — escalations go into a hole`, FIX_UP);
132
+ } else if (keepalive) {
133
+ warn("duty seat: keepalive installed but the seat is down — launchd should have relaunched it", `launchctl list | grep com.trantor.duty then: trantor duty down && trantor duty up`);
134
+ } else {
135
+ warn("duty seat: none — nobody is watching the fleet (undelivered mail and dead seats go unnudged)", FIX_UP);
136
+ }
137
+ }
138
+ }
139
+
90
140
  // claude plugin
91
141
  section("claude (the orchestrator)");
92
142
  if (!has("claude")) warn("claude CLI not found", "install Claude Code: https://claude.com/claude-code");
package/bin/duty.mjs CHANGED
@@ -6,14 +6,20 @@
6
6
  // trantor duty down stop
7
7
  // trantor duty status pid + last turns + presence
8
8
  //
9
+ // Keepalive (the 4-day silent death, 2026-08-27→31): by default the seat runs HEADLESS under a
10
+ // launchd service (label com.trantor.duty, KeepAlive=true) — launchd relaunches it after a crash
11
+ // and at every login, so the watcher stops being one bad afternoon away from silently gone.
12
+ // `--window` opts into the visible cmux/Terminal surface instead; a window CANNOT be kept alive
13
+ // by launchd, so that mode says plainly that nothing will bring it back.
14
+ //
9
15
  // Division of labor (the overseer doctrine, extended): DETECTION stays mechanical and hub-side —
10
16
  // RELAY_DUTY_SESSION makes the hub DM this seat when a direct message sits undelivered past
11
17
  // RELAY_DUTY_UNDELIVERED_MS or the overseer emits a warning. The SEAT only triages: relay, wake,
12
18
  // annotate, and only involves the human when a real decision is needed. It runs under the same
13
19
  // crew-runner that keeps crew seats alive (long-poll wake, turn telemetry, failure reporting) —
14
20
  // just with a triage doctrine instead of "work your card" (RUNNER_RULES / CREW_KICKOFF).
15
- import { spawn, execSync } from "node:child_process";
16
- import { readFileSync, writeFileSync, existsSync, mkdirSync, openSync, rmSync } from "node:fs";
21
+ import { spawn, execSync, execFileSync } from "node:child_process";
22
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, openSync, rmSync, unlinkSync } from "node:fs";
17
23
  import { join, dirname } from "node:path";
18
24
  import { homedir } from "node:os";
19
25
  import { fileURLToPath } from "node:url";
@@ -25,6 +31,9 @@ const BUS = process.env.AGENT_BUS_DIR || join(homedir(), ".agent-bus");
25
31
  const DIR = join(BUS, "trantor-duty"); // the seat's cwd, and therefore its bus id
26
32
  const PIDF = join(BUS, "duty.pid");
27
33
  const LOGF = join(BUS, "duty.log");
34
+ const LAUNCHER = join(BUS, "duty-launch.sh");
35
+ const DUTY_LABEL = "com.trantor.duty";
36
+ const DUTY_PLIST = join(homedir(), "Library", "LaunchAgents", `${DUTY_LABEL}.plist`);
28
37
 
29
38
  const argv = process.argv.slice(2);
30
39
  const cmd = argv[0] || "status";
@@ -46,8 +55,12 @@ function fleetHub() {
46
55
  // a patrol script, send a templated nudge, post 280 chars. Nobody chose that; it was inherited.
47
56
  // Precedence: --model flag > CREW_MODEL env > sonnet. `--model inherit` restores the old behaviour.
48
57
  const DUTY_MODEL = val("model", "") || process.env.CREW_MODEL || "sonnet";
49
- // Visible by default; --headless keeps the old background behaviour for launchd and CI.
50
- const WINDOW = !argv.includes("--headless") && process.platform === "darwin";
58
+ // Headless is the DEFAULT now, and it rides the launchd keepalive. The old default a visible
59
+ // window, headless only as a silent fallback — is exactly how the seat died quietly on
60
+ // 2026-08-27: the osascript window-open failed, the fallback printed one line into a log nobody
61
+ // reads, and the fleet had no watcher for four days. Headless+keepalive is the honest default;
62
+ // `--window` is the explicit choice of a surface launchd cannot keep alive.
63
+ const WINDOW = argv.includes("--window") && process.platform === "darwin";
51
64
  const AGENT = val("agent", "claude");
52
65
  // Named, not inherited. It used to be "claude:fleet" purely because the seat's directory was
53
66
  // called fleet and identity is derived from directory basename — the same identity-by-position
@@ -134,6 +147,71 @@ function cmuxBinary() {
134
147
  return "";
135
148
  }
136
149
 
150
+ // launchd, invoked by NAME so a drill can stub it on PATH (seats.mjs hardcodes /bin/launchctl,
151
+ // which is why its install has no drill). Every call swallows its error: on a machine without
152
+ // launchd the keepalive path is simply unavailable and the caller says so.
153
+ const bootoutDuty = () => { try { execFileSync("launchctl", ["bootout", `gui/${process.getuid()}/${DUTY_LABEL}`], { stdio: "ignore", timeout: 8000 }); return true; } catch { return false; } };
154
+ const bootstrapDuty = () => { try { execFileSync("launchctl", ["bootstrap", `gui/${process.getuid()}`, DUTY_PLIST], { stdio: "ignore", timeout: 8000 }); return true; } catch { return false; } };
155
+ const dutyLoaded = () => { try { return execFileSync("launchctl", ["list"], { encoding: "utf8", timeout: 8000 }).split("\n").some((l) => l.includes(DUTY_LABEL)); } catch { return false; } };
156
+
157
+ // Same service shape as the hub (deploy/com.trantor.hub.plist): the long-running process is the
158
+ // job, RunAtLoad + KeepAlive bring it back after a crash and at every login. One deliberate
159
+ // addition, ThrottleInterval=30: a seat that exits because the hub is down must not hot-loop —
160
+ // a KeepAlive job retrying every 10s forever is how this machine hit load 490 on 2026-08-21.
161
+ function keepalivePlistBody() {
162
+ return `<?xml version="1.0" encoding="UTF-8"?>
163
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
164
+ <!-- trantor duty seat as an always-on launchd service. \`trantor duty up\` rewrites this;
165
+ \`trantor duty down\` removes it. -->
166
+ <plist version="1.0">
167
+ <dict>
168
+ <key>Label</key><string>${DUTY_LABEL}</string>
169
+ <key>ProgramArguments</key>
170
+ <array><string>/bin/bash</string><string>${LAUNCHER}</string></array>
171
+ <key>RunAtLoad</key><true/>
172
+ <key>KeepAlive</key><true/>
173
+ <key>ThrottleInterval</key><integer>30</integer>
174
+ <key>StandardOutPath</key><string>${LOGF}</string>
175
+ <key>StandardErrorPath</key><string>${LOGF}</string>
176
+ </dict>
177
+ </plist>
178
+ `;
179
+ }
180
+
181
+ // The rules are ~4KB of prose with backticks, quotes and $ in them, so they cannot ride a
182
+ // command line or an AppleScript string. A launcher script carries them instead. The values ride
183
+ // SINGLE QUOTES (apostrophes escaped as '"'"'): backticks, $( ), parens and newlines are then all
184
+ // literal. The previous form — $(cat <<'EOF' … EOF) — silently broke under macOS bash 3.2 the
185
+ // moment a value contained a backtick (the rules do): the export failed, the seat started without
186
+ // its kickoff text, and the only symptom was a syntax-error line in a log nobody reads.
187
+ function writeLauncher(env) {
188
+ const shquote = (v) => `'${String(v).replace(/'/g, `'\\''`)}'`;
189
+ const exports = Object.entries(env).map(([k, v]) => `export ${k}=${shquote(v)}`).join("\n");
190
+ writeFileSync(LAUNCHER, `#!/bin/bash\n# written by \`trantor duty up\` — safe to delete when the seat is down\n${exports}\nexec ${JSON.stringify(process.execPath)} ${JSON.stringify(join(ROOT, "bin", "crew-runner.mjs"))} ${AGENT} ${JSON.stringify(DIR)}\n`, { mode: 0o700 });
191
+ }
192
+
193
+ // The old headless start: a detached child of whoever ran `up`. No keepalive — it dies with a
194
+ // reboot and nothing brings it back. Kept for non-darwin and as the loud last fallback.
195
+ function startDetached(env) {
196
+ const out = openSync(LOGF, "a");
197
+ const child = spawn(process.execPath, [join(ROOT, "bin", "crew-runner.mjs"), AGENT, DIR], {
198
+ detached: true, stdio: ["ignore", out, out], env: { ...process.env, ...env },
199
+ });
200
+ child.unref();
201
+ return child.pid;
202
+ }
203
+
204
+ // Find the runner's pid once a surface (window or launchd) should have started it, and park it
205
+ // in the pidfile `down`/`status` read. Terminal/launchd take a moment, so poll briefly.
206
+ function pollRunnerPid(max = 25) {
207
+ let pid = 0;
208
+ for (let i = 0; i < max && !pid; i++) {
209
+ try { pid = Number(execSync(`pgrep -f "crew-runner.mjs ${AGENT} ${DIR}" | head -1`, { encoding: "utf8" }).trim()) || 0; } catch {}
210
+ if (!pid) execSync("sleep 0.2");
211
+ }
212
+ return pid;
213
+ }
214
+
137
215
  /** Close every workspace this seat owns. No-op when cmux is absent or its socket is off. */
138
216
  function closeDutyWorkspace() {
139
217
  const bin = cmuxBinary();
@@ -166,77 +244,94 @@ if (cmd === "up") {
166
244
  if (!(await ensureFleetIdentity(hub))) process.exit(1);
167
245
  const env = (() => {
168
246
  const e = { RELAY_URL: hub, RUNNER_RULES: RULES, CREW_KICKOFF: KICKOFF,
169
- RUNNER_TITLE: "Trantor Duty Agent", RUNNER_ABOUT: ABOUT };
247
+ RUNNER_TITLE: "Trantor Duty Agent", RUNNER_ABOUT: ABOUT,
248
+ // launchd starts jobs with a MINIMAL Path — the resurrected seat could not find
249
+ // `claude` and every turn died exit 127 "missing-cli" (found live 2026-08-31,
250
+ // duty's own triage caught it). Bake the operator's PATH from `up` time into the
251
+ // launcher, exactly like every other value it carries.
252
+ PATH: process.env.PATH || "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin" };
170
253
  if (DUTY_MODEL !== "inherit") e.CREW_MODEL = DUTY_MODEL;
171
254
  return e;
172
255
  })();
173
256
 
257
+ writeLauncher(env);
258
+
174
259
  let pid = 0;
260
+ let how = "";
261
+
175
262
  if (WINDOW) {
176
- // A WINDOW, by default. Headless was the old behaviour and it hid the thing: an always-on agent
177
- // nobody can see is exactly what unsettles a person who finds the process, and the seat's own
178
- // introduction (RUNNER_ABOUT) is worthless printed into a log file nobody opens. Crew seats have
179
- // always opened windows; the duty seat now does too.
180
- //
181
- // The rules are ~4KB of prose with backticks, quotes and $ in them, so they cannot ride a
182
- // command line or an AppleScript string. A launcher script carries them instead: a quoted
183
- // heredoc means the shell expands nothing, and osascript only ever sees the path.
184
- const launcher = join(BUS, "duty-launch.sh");
185
- const exports = Object.entries(env).map(([k, v]) =>
186
- `export ${k}=$(cat <<'TRANTOR_${k}_EOF'\n${v}\nTRANTOR_${k}_EOF\n)`).join("\n");
187
- writeFileSync(launcher, `#!/bin/bash\n# written by \`trantor duty up\` — safe to delete when the seat is down\n${exports}\nexec ${JSON.stringify(process.execPath)} ${JSON.stringify(join(ROOT, "bin", "crew-runner.mjs"))} ${AGENT} ${JSON.stringify(DIR)}\n`, { mode: 0o700 });
188
- // PREFER CMUX. Terminal.app was the only surface here, and a plain window is stacking by
189
- // construction: every `duty up` opens another one and nothing closes the last, so restarts
190
- // accumulate windows that all look like live duty agents. cmux gives the seat ONE named
191
- // workspace that gets REPLACED on each up — the same "replace, never stack" rule bin/crew.sh
192
- // already applies to crew seats, which is why they never pile up and this did.
193
- //
194
- // Terminal remains the fallback: no cmux, or its control socket off, and nothing changes.
263
+ // --window: the visible surface. cmux first (ONE named workspace, replaced on each up the
264
+ // same "replace, never stack" rule bin/crew.sh applies to crew seats), Terminal as fallback.
265
+ // A window cannot be kept alive by launchd, so `how` says plainly that nothing will bring
266
+ // the seat back if it dies.
195
267
  const cmuxBin = cmuxBinary();
196
-
197
- let openedInCmux = false;
268
+ let opened = false;
198
269
  if (cmuxBin) {
199
270
  try {
200
271
  // Replace, never stack: take the previous duty workspace away before opening this one.
201
272
  // Closing first is safe here (unlike a crew pane swap) — the seat is a single surface with
202
273
  // nothing to preserve.
203
274
  closeDutyWorkspace();
204
- execSync(`${cmuxBin} new-workspace --name ${JSON.stringify(CMUX_WS_NAME)} --cwd ${JSON.stringify(DIR)} --command ${JSON.stringify(`bash ${launcher}`)} --focus false`,
275
+ execSync(`${cmuxBin} new-workspace --name ${JSON.stringify(CMUX_WS_NAME)} --cwd ${JSON.stringify(DIR)} --command ${JSON.stringify(`bash ${LAUNCHER}`)} --focus false`,
205
276
  { stdio: "ignore", timeout: 8000, env: { ...process.env, CMUX_QUIET: "1" } });
206
- openedInCmux = true;
277
+ opened = true;
207
278
  } catch (e) {
208
279
  console.error(`cmux launch failed (${e?.message || e}) — falling back to a Terminal window`);
209
280
  }
210
281
  }
211
-
212
- if (!openedInCmux) {
213
- const osa = `tell application "Terminal"\n do script ${JSON.stringify(`bash ${launcher}`)}\n activate\nend tell\n`;
214
- try { execSync(`osascript -e ${JSON.stringify(osa)}`, { stdio: "ignore", timeout: 8000 }); }
215
- catch (e) { console.error(`could not open a window (${e?.message || e}) falling back to headless`); }
282
+ if (!opened) {
283
+ const osa = `tell application "Terminal"\n do script ${JSON.stringify(`bash ${LAUNCHER}`)}\n activate\nend tell\n`;
284
+ try { execSync(`osascript -e ${JSON.stringify(osa)}`, { stdio: "ignore", timeout: 8000 }); opened = true; }
285
+ catch (e) {
286
+ // The 2026-08-27 incident: this printed one quiet line, fell back headless, and the fleet
287
+ // had no watcher for four days. Now it is loud, and the keepalive path below takes over.
288
+ console.error(` ⚠️ could not open a window (${e?.message || e}) — falling back to the headless launchd keepalive`);
289
+ }
216
290
  }
217
- // The runner lives inside Terminal, so its pid is not ours to know: find it the same way `down`
218
- // does. Poll briefly, since Terminal takes a moment to start the shell.
219
- for (let i = 0; i < 25 && !pid; i++) {
220
- try { pid = Number(execSync(`pgrep -f "crew-runner.mjs ${AGENT} ${DIR}" | head -1`, { encoding: "utf8" }).trim()) || 0; } catch {}
221
- if (!pid) execSync("sleep 0.2");
291
+ if (opened) {
292
+ pid = pollRunnerPid();
293
+ writeFileSync(PIDF, String(pid));
294
+ how = "in a window (NO keepalive if it dies or the Mac reboots, it stays down)";
222
295
  }
223
296
  }
224
- if (!pid) {
225
- const out = openSync(LOGF, "a");
226
- const child = spawn(process.execPath, [join(ROOT, "bin", "crew-runner.mjs"), AGENT, DIR], {
227
- detached: true, stdio: ["ignore", out, out], env: { ...process.env, ...env },
228
- });
229
- child.unref();
230
- pid = child.pid;
297
+
298
+ if (!how) {
299
+ if (process.platform === "darwin") {
300
+ // The honest default: headless under launchd, so a crashed seat relaunches itself instead
301
+ // of dying silently (the seat sat dead 2026-08-27→31 before anyone noticed).
302
+ mkdirSync(dirname(DUTY_PLIST), { recursive: true }); // a fresh machine has no LaunchAgents dir yet
303
+ writeFileSync(DUTY_PLIST, keepalivePlistBody());
304
+ bootoutDuty();
305
+ if (bootstrapDuty()) {
306
+ pid = pollRunnerPid(15);
307
+ writeFileSync(PIDF, String(pid));
308
+ how = `headless under the launchd keepalive ${DUTY_LABEL} (relaunched after a crash or reboot)`;
309
+ if (!pid) console.error(` ⚠️ keepalive installed but no runner seen after 3s — check: launchctl list | grep ${DUTY_LABEL}`);
310
+ } else {
311
+ console.error(" ⚠️ launchctl bootstrap failed — starting a plain headless seat with NO keepalive");
312
+ pid = startDetached(env);
313
+ writeFileSync(PIDF, String(pid));
314
+ how = "headless, NO keepalive (launchd refused the job)";
315
+ }
316
+ } else {
317
+ pid = startDetached(env);
318
+ writeFileSync(PIDF, String(pid));
319
+ how = `headless, NO keepalive (launchd is macOS-only; ${process.platform} gets a plain background seat)`;
320
+ }
231
321
  }
232
- writeFileSync(PIDF, String(pid));
233
- console.log(`— duty agent up: ${SESSION} (pid ${pid})${WINDOW ? " in a window" : " headless"} on ${DUTY_MODEL === "inherit" ? "the CLI default model" : DUTY_MODEL} watching ${hub} — log: ${LOGF}`);
322
+
323
+ console.log(`— duty agent up: ${SESSION} (pid ${pid}) ${how} on ${DUTY_MODEL === "inherit" ? "the CLI default model" : DUTY_MODEL} watching ${hub} — log: ${LOGF}`);
234
324
  const fed = await registerDutySeat(hub, SESSION);
235
325
  if (fed) console.log(` hub feeds it: undelivered DMs (>${Math.round(Number(process.env.RELAY_DUTY_UNDELIVERED_MS || 600000) / 60000)}m) + overseer warnings.`);
236
326
  process.exit(0); // the seat IS up; a hub that won't feed it is a warning, not a failed start
237
327
  }
238
328
 
239
329
  if (cmd === "down") {
330
+ const hadKeepalive = existsSync(DUTY_PLIST);
331
+ // Unload the keepalive FIRST (bootout kills the job's process), then remove the plist so a
332
+ // reboot cannot resurrect the seat behind a `down`. `up` rewrites both.
333
+ bootoutDuty();
334
+ if (hadKeepalive) { try { unlinkSync(DUTY_PLIST); } catch {} }
240
335
  const pid = alivePid();
241
336
  if (pid) { try { process.kill(pid); } catch {} console.log(`— duty seat stopped (pid ${pid}) —`); }
242
337
  else console.log("no duty seat running");
@@ -249,6 +344,18 @@ if (cmd === "down") {
249
344
  // Clear the hub's pointer too — escalations aimed at a seat that no longer exists are messages
250
345
  // sent into a hole, and the hub has no other way to learn the seat went away.
251
346
  await registerDutySeat(fleetHub(), "");
347
+ // GOING LOUD. A quiet `down` is how the seat sat dead for four days (2026-08-27→31) while every
348
+ // other surface reported green: nothing errors when the watcher is gone, it just stops watching.
349
+ // Anyone turning the watcher off must see, in that moment, exactly what they are leaving dark.
350
+ if (pid || hadKeepalive) {
351
+ console.log(`
352
+ ────────────────────────────────────────────────────────────
353
+ ⚠️ DUTY IS DOWN. Nobody is watching the fleet now.
354
+ Undelivered mail and dead seats will go unnudged — last time
355
+ that silence lasted four days (Aug 27→31) before anyone noticed.
356
+ Bring it back: trantor duty up
357
+ ────────────────────────────────────────────────────────────`);
358
+ }
252
359
  process.exit(0);
253
360
  }
254
361
 
@@ -263,6 +370,9 @@ if (cmd === "down") {
263
370
  console.log(" and never edits your project files. Stop it with: trantor duty down");
264
371
  console.log("");
265
372
  console.log(pid ? `RUNNING (pid ${pid}) as ${SESSION}` : "NOT running");
373
+ console.log(existsSync(DUTY_PLIST)
374
+ ? `keepalive: installed (${DUTY_LABEL}${process.platform === "darwin" ? (dutyLoaded() ? ", loaded" : ", not loaded in this session") : ""}) — launchd relaunches the seat after a crash or reboot`
375
+ : "keepalive: NOT installed — a crash or reboot leaves the seat down (trantor duty up installs it)");
266
376
  // A running seat the hub isn't feeding looks identical to a working one from the outside — which
267
377
  // is the whole failure mode this command exists to make visible. So ask the hub, don't assume.
268
378
  const hub = fleetHub();
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env node
2
+ // Turn watchdog (#5684). runTurn is spawnSync — the runner cannot watch its own turn — so this
3
+ // DETACHED helper does: armed at turn start, disarmed by turn end (the stamp file vanishes or
4
+ // its turn number moves on). A turn that runs past the window with NO output growth earns ONE
5
+ // direct stall report to the foreman (episode, never a timer storm), and the turn is never
6
+ // killed — reporting is the whole job. The operator's 2026-08-31 complaint is the incident:
7
+ // seats sat visibly dead in their panes while every signal channel stayed quiet.
8
+ //
9
+ // node bin/turn-watchdog.mjs <stampFile> <errFile> <windowMs> <session> <project> <hubUrl>
10
+ import { readFileSync, existsSync, statSync } from "node:fs";
11
+ import { hostId } from "../lib/project.mjs";
12
+ import { signedPost } from "../hooks/lib/api.mjs";
13
+
14
+ const [stampFile, errFile, windowMsRaw, session, project, hub] = process.argv.slice(2);
15
+ const windowMs = Number(windowMsRaw) || 15 * 60 * 1000; // no floor: drills pass tiny windows, and one report per turn caps the damage anyway
16
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
17
+
18
+ const readStamp = () => { try { return JSON.parse(readFileSync(stampFile, "utf8")); } catch { return null; } };
19
+ const errSize = () => { try { return statSync(errFile).size; } catch { return 0; } };
20
+
21
+ const armed = readStamp();
22
+ if (!armed) process.exit(0);
23
+ let baseline = errSize();
24
+
25
+ for (;;) {
26
+ await sleep(windowMs);
27
+ const s = readStamp();
28
+ if (!s || s.turn !== armed.turn) process.exit(0); // turn ended — nothing to say
29
+ const size = errSize();
30
+ if (size > baseline + 200) { baseline = size; continue; } // producing output: working, re-arm
31
+ const mins = Math.round((Date.now() - (s.startedAt || Date.now())) / 60000);
32
+ const orch = `${hostId()}:${project}`;
33
+ const text = `⏱ ${session} turn STALLED — running ${mins}m with no output (turn ${s.turn}). Not killed; check its pane, or \`trantor swap\`.`;
34
+ // Direct = wake. The foreman first; if this seat IS the foreman's own runner, say it to all.
35
+ const to = orch === session ? "all" : orch;
36
+ try { await signedPost(`${hub}/send`, { from: session, to, text, project }, { session }); } catch {}
37
+ process.exit(0); // one report per turn, by construction
38
+ }
@@ -96,13 +96,17 @@ export function guardContextTokens(rows) {
96
96
  }
97
97
 
98
98
  // The transcript logs the model WITHOUT the [1m] marker, so we cannot tell a
99
- // 200k window from a 1M one. There is therefore no safe universal default — the
100
- // window must be declared (env RELAY_CONTEXT_WINDOW or config.contextWindow) for
101
- // the proactive early-warning to activate. Returns 0 when unknown (→ no warning).
99
+ // 200k window from a 1M one in general. Fable is the known exception (#5503):
100
+ // its window is 1M and the name is all the transcript ever gives us — the
101
+ // undeclared window kept the early-warning off and the session hit the wall
102
+ // silently. An explicit declaration (env RELAY_CONTEXT_WINDOW or
103
+ // config.contextWindow) always wins over any name-based inference. Returns 0
104
+ // when unknown (→ no warning).
102
105
  export function resolveWindow(model = "", conf = readConfig()) {
103
106
  const explicit = Number(process.env.RELAY_CONTEXT_WINDOW || conf.contextWindow || 0);
104
107
  if (explicit > 0) return explicit;
105
108
  if (/\[1m\]|-1m\b|:1m\b/i.test(model)) return 1_000_000; // honored if ever present
109
+ if (/fable/i.test(model)) return 1_000_000; // #5503: fable is 1M by name
106
110
  return 0;
107
111
  }
108
112
 
package/hub.mjs CHANGED
@@ -156,6 +156,16 @@ function appendTaskNote(t, b, ts = Date.now()) {
156
156
  if (!b || typeof b.note !== "string") return false;
157
157
  return appendTaskLog(t, b.by || "", b.note, ts);
158
158
  }
159
+ // Card checklists (#5624): acceptance items are the one honest denominator for a progress bar.
160
+ // Accepts plain strings (fresh items) or {text,done} (round-trips); caps 20 items x 200 chars.
161
+ // Returns null for a non-array so callers can distinguish "not sent" from "sent empty".
162
+ function cleanChecklist(v) {
163
+ if (!Array.isArray(v)) return null;
164
+ return v.slice(0, 20)
165
+ .map(it => typeof it === "string" ? { text: it.slice(0, 200), done: false }
166
+ : { text: String(it?.text ?? "").slice(0, 200), done: !!it?.done })
167
+ .filter(it => it.text);
168
+ }
159
169
  function runTaskBootMigrations() {
160
170
  let changed = false;
161
171
  const bootNow = Date.now();
@@ -427,6 +437,26 @@ setTimeout(overseerTick, 2000).unref?.();
427
437
  let DUTY_SESSION = String(process.env.RELAY_DUTY_SESSION || state.dutySession || "");
428
438
  const DUTY_UNDELIVERED_MS = Number(process.env.RELAY_DUTY_UNDELIVERED_MS || 10 * 60 * 1000);
429
439
  const dutyEscalated = new Set();
440
+ // #5686: the janitor died 08-27 and NOTHING noticed for 4 days — the hub kept escalating to a
441
+ // corpse. Duty liveness is now a first-class state: dark = configured but no heartbeat inside
442
+ // DUTY_DARK_MS. Episode semantics (one event per transition, a standing flag on /health), and
443
+ // while dark, escalations go to the party owed the reply instead of the dead seat.
444
+ const DUTY_DARK_MS = Number(process.env.RELAY_DUTY_DARK_MS || 10 * 60 * 1000);
445
+ let dutyDarkSince = 0;
446
+ // A freshly appointed seat has no heartbeat yet and is NOT a corpse: the dark clock starts at
447
+ // appointment (boot or POST /overseer/duty), so a newborn gets one full window to first-poll.
448
+ let dutySeenFloor = Date.now();
449
+ function dutyLiveness() {
450
+ if (!DUTY_SESSION) return { configured: false, online: false, lastSeenMs: 0 };
451
+ const seen = Math.max(state.peers[DUTY_SESSION]?.lastSeen || 0, dutySeenFloor);
452
+ const lastSeenMs = now() - seen;
453
+ return { configured: true, online: lastSeenMs < DUTY_DARK_MS, lastSeenMs: Math.max(0, lastSeenMs) };
454
+ }
455
+ function dutyQueuedEscalations() {
456
+ if (!DUTY_SESSION) return 0;
457
+ const upTo = state.peers[DUTY_SESSION]?.deliveredUpTo || 0;
458
+ return state.messages.reduce((n, m) => n + (m.to === DUTY_SESSION && m.id > upTo ? 1 : 0), 0);
459
+ }
430
460
  function hubSend(to, text, project) {
431
461
  const msg = { id: ++state.seq, ts: now(), from: "hub:duty", to, text: String(text).slice(0, 2000), project: String(project || "").slice(0, 80) };
432
462
  state.messages.push(msg); if (state.messages.length > 5000) state.messages.splice(0, 1000);
@@ -436,6 +466,15 @@ function hubSend(to, text, project) {
436
466
  }
437
467
  function dutyTick() {
438
468
  if (!DUTY_SESSION) return;
469
+ // #5686: track the dark episode BEFORE escalating, so this tick already routes around a corpse.
470
+ const live = dutyLiveness();
471
+ if (!live.online && !dutyDarkSince) {
472
+ dutyDarkSince = now();
473
+ appendEvent("duty-dark", "", "hub:duty", { text: `duty seat ${DUTY_SESSION} has no heartbeat — seat trouble is not being triaged (trantor duty up)` });
474
+ } else if (live.online && dutyDarkSince) {
475
+ appendEvent("duty-back", "", "hub:duty", { text: `duty seat ${DUTY_SESSION} is back after ${Math.round((now() - dutyDarkSince) / 60000)}m dark` });
476
+ dutyDarkSince = 0;
477
+ }
439
478
  const cutoff = now() - DUTY_UNDELIVERED_MS;
440
479
  const floor = now() - 24 * 3600 * 1000; // never escalate ancient history
441
480
  for (const m of state.messages) {
@@ -450,7 +489,10 @@ function dutyTick() {
450
489
  if (dutyEscalated.has(m.id)) continue;
451
490
  if ((state.peers[m.to]?.deliveredUpTo || 0) >= m.id) continue;
452
491
  dutyEscalated.add(m.id);
453
- hubSend(DUTY_SESSION,
492
+ // #5686: a dark janitor must not eat escalations. Route to the SENDER — the party who
493
+ // believes they were heard and are owed the reply — with the duty outage named, so the
494
+ // failure is visible to someone who can act instead of queued on a corpse.
495
+ hubSend(dutyDarkSince ? m.from : DUTY_SESSION,
454
496
  `⚠️ UNDELIVERED for ${Math.round((now() - m.ts) / 60000)}m: #${m.id} ${m.from} -> ${m.to} — "${String(m.text).slice(0, 280)}" — the recipient has not been handed this (recipient last seen ${state.peers[m.to]?.lastSeen ? Math.round((now() - state.peers[m.to].lastSeen) / 60000) + "m ago" : "never"}). Triage: is the recipient's session idle, deaf (wrong hub / old hooks), or gone? Relay, wake, or note it on their board.`,
455
497
  m.project || "");
456
498
  }
@@ -1539,6 +1581,8 @@ const server = http.createServer(async (req, res) => {
1539
1581
  const session = String(b.session).slice(0, 120);
1540
1582
  DUTY_SESSION = session;
1541
1583
  state.dutySession = session;
1584
+ dutySeenFloor = Date.now(); // #5686: appointment restarts the dark clock — a newborn is not a corpse
1585
+ if (dutyDarkSince) { dutyDarkSince = 0; } // fresh seat, fresh episode accounting
1542
1586
  dirty = true;
1543
1587
  return json(res, 200, { ok: true, dutySession: DUTY_SESSION });
1544
1588
  }
@@ -1826,6 +1870,7 @@ const server = http.createServer(async (req, res) => {
1826
1870
  deps: Array.isArray(b.deps) ? [...new Set(b.deps.map(Number).filter(n => Number.isInteger(n) && n > 0))].slice(0, 20) : [],
1827
1871
  by: b.by || "", ts: ts0, updated: ts0,
1828
1872
  history: [{ to: st0, by: b.by || "", ts: ts0 }] };
1873
+ { const cl = cleanChecklist(b.checklist); if (cl?.length) t.checklist = cl; } // #5624 — rides `extra`, survives restarts
1829
1874
  if (b.source === "cc-subagent") { t._fp = subFp(b.title); if (b.agentType) t._atype = String(b.agentType).slice(0, 40); if (b.agentId) t._aid = String(b.agentId).slice(0, 80); if (b.parent) t.parent = String(b.parent).slice(0, 120); t.count = 1; if (t.status === "doing") { t._everStarted = true; t._inflight = 1; } }
1830
1875
  appendTaskNote(t, b, ts0);
1831
1876
  state.tasks.push(t); if (state.tasks.length > 2000) state.tasks.splice(0, 500);
@@ -1862,11 +1907,29 @@ const server = http.createServer(async (req, res) => {
1862
1907
  // the narrative line a human reads on the board ("assigned — did"), written by the cheap
1863
1908
  // summarizer; rides the tasks.extra column, so it survives restarts everywhere
1864
1909
  if (b.summary !== undefined) t.summary = String(b.summary).slice(0, 220);
1910
+ // #5624: full checklist replace (null clears). Item-level toggles ride /task/checklist-toggle.
1911
+ if (b.checklist !== undefined) {
1912
+ const cl = cleanChecklist(b.checklist);
1913
+ if (cl) { if (cl.length) t.checklist = cl; else delete t.checklist; }
1914
+ else if (b.checklist === null) delete t.checklist;
1915
+ }
1865
1916
  appendTaskNote(t, b);
1866
1917
  if (b.delete) { eventType = "deleted"; eventFrom = null; eventTo = null; state.tasks = state.tasks.filter(x => x.id !== t.id); }
1867
1918
  appendCardEvent(eventType, t, b.by, eventFrom, eventTo);
1868
1919
  t.updated = now(); dirty = true; return json(res, 200, { ok: true, task: t });
1869
1920
  }
1921
+ // #5624: toggle ONE acceptance item. Index-addressed against the card's current checklist —
1922
+ // a stale index 400s instead of silently toggling the wrong item.
1923
+ if (req.method === "POST" && P === "/task/checklist-toggle") {
1924
+ const b = await body(req); const t = state.tasks.find(x => x.id === Number(b.id));
1925
+ if (!t) return json(res, 404, { error: "no such task" });
1926
+ const i = Number(b.index);
1927
+ if (!Array.isArray(t.checklist) || !Number.isInteger(i) || i < 0 || i >= t.checklist.length) {
1928
+ return json(res, 400, { error: "no such checklist item" });
1929
+ }
1930
+ t.checklist[i].done = !!b.done;
1931
+ t.updated = now(); dirty = true; return json(res, 200, { ok: true, task: t });
1932
+ }
1870
1933
  // Manual board sweep — the aggressive companion to the automatic reaper. The reaper only touches
1871
1934
  // OFFLINE-owner cards (no false positives on live work); /sweep is the explicit "this live seat forgot
1872
1935
  // its card" path: it stales EVERY doing/testing card untouched past `olderMs`, regardless of owner
@@ -2617,7 +2680,9 @@ const server = http.createServer(async (req, res) => {
2617
2680
  if (req.method === "GET" && (P === "/" || P === "/ui")) {
2618
2681
  res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); return res.end(UI || "<h1>trantor</h1><p>dashboard unavailable</p>");
2619
2682
  }
2620
- if (P === "/health") return json(res, 200, { ok: true, authMode: AUTH_MODE, peers: Object.keys(state.peers).length, messages: state.messages.length, streams: streams.length });
2683
+ if (P === "/health") return json(res, 200, { ok: true, authMode: AUTH_MODE, peers: Object.keys(state.peers).length, messages: state.messages.length, streams: streams.length,
2684
+ // #5686: duty liveness rides /health so the app's Home strip and doctor read one truth.
2685
+ duty: { ...dutyLiveness(), darkSinceMs: dutyDarkSince ? now() - dutyDarkSince : 0, queuedEscalations: dutyQueuedEscalations() } });
2621
2686
  json(res, 404, { error: "not found" });
2622
2687
  } catch (e) { json(res, 500, { error: String(e?.message || e) }); }
2623
2688
  });
package/mcp.mjs CHANGED
@@ -182,11 +182,19 @@ server.tool("relay_contracts", "What you dispatched and are still owed. Lists ev
182
182
  });
183
183
 
184
184
  server.tool("relay_task_add", "Add a Kanban card to a project's board on the dashboard (what you're about to work on). Defaults: THIS project, assigned to you, status 'todo'. Pass `project` to target another board — e.g. when you orchestrate a crew that runs in a different directory than the one you launched Claude from. Keep the team's progress visible. Attach a `note` whenever context isn't obvious from the title — it lands on the card's permanent log ({ts,by,text}, kept: last 40).",
185
- { title: z.string().describe("short task title"), status: z.enum(["todo","doing","testing","failed","done","blocked"]).optional(), assignee: z.string().optional().describe("session id to assign (default: you)"), difficulty: z.enum(["easy","medium","hard"]).optional().describe("difficulty tag — drives model/agent routing (relay_advise) and shows on the board"), model: z.string().optional().describe("the model this card is routed to (from relay_advise routing, or the CLI default) — shown on the card"), deps: z.array(z.number()).optional().describe("card ids this card depends on — drawn as branch edges in the Flow view (e.g. integration depends on every crew card)"), phase: z.string().optional().describe("phase/milestone this card belongs to (e.g. 'P5', 'Auth', 'Launch') — groups it in the Flow view's phase flowchart. Optional; otherwise inferred from the title prefix + time."), note: z.string().max(2000).optional().describe("optional card-log entry (<=2000 chars): context, the plan, or a link — stored on the card as {ts,by,text}"), project: z.string().optional().describe("board to add to (default: this session's project). Set to the crew's project when you orchestrate from a different directory") },
186
- async ({ title, status, assignee, difficulty, model, deps, phase, note, project }) => {
185
+ { title: z.string().describe("short task title"), status: z.enum(["todo","doing","testing","failed","done","blocked"]).optional(), assignee: z.string().optional().describe("session id to assign (default: you)"), difficulty: z.enum(["easy","medium","hard"]).optional().describe("difficulty tag — drives model/agent routing (relay_advise) and shows on the board"), model: z.string().optional().describe("the model this card is routed to (from relay_advise routing, or the CLI default) — shown on the card"), deps: z.array(z.number()).optional().describe("card ids this card depends on — drawn as branch edges in the Flow view (e.g. integration depends on every crew card)"), phase: z.string().optional().describe("phase/milestone this card belongs to (e.g. 'P5', 'Auth', 'Launch') — groups it in the Flow view's phase flowchart. Optional; otherwise inferred from the title prefix + time."), note: z.string().max(2000).optional().describe("optional card-log entry (<=2000 chars): context, the plan, or a link — stored on the card as {ts,by,text}"), project: z.string().optional().describe("board to add to (default: this session's project). Set to the crew's project when you orchestrate from a different directory"), checklist: z.array(z.string().max(200)).max(20).optional().describe("acceptance items for the card — the honest denominator for its progress bar. Tick them off with relay_task_check as each is truly met") },
186
+ async ({ title, status, assignee, difficulty, model, deps, phase, note, project, checklist }) => {
187
187
  const proj = project || PROJECT;
188
- const { task } = await api("POST", "/task", { project: proj, title, status: status || "todo", assignee: assignee || SESSION, difficulty, model, deps, phase, note, by: SESSION });
189
- return { content: [{ type: "text", text: `card #${task.id} added to ${proj}: "${title}" [${task.status}]${phase?` · phase ${phase}`:""}` }] };
188
+ const { task } = await api("POST", "/task", { project: proj, title, status: status || "todo", assignee: assignee || SESSION, difficulty, model, deps, phase, note, checklist, by: SESSION });
189
+ return { content: [{ type: "text", text: `card #${task.id} added to ${proj}: "${title}" [${task.status}]${phase?` · phase ${phase}`:""}${task.checklist?.length?` · ${task.checklist.length} acceptance item(s)`:""}` }] };
190
+ });
191
+
192
+ server.tool("relay_task_check", "Tick (or untick) ONE acceptance item on a card's checklist — the card's progress bar reads checked/total, so tick an item only when it is genuinely met (tests run, behavior observed), never to make the bar move. Items are 0-indexed in the order relay_task_add listed them.",
193
+ { id: z.number().describe("card id"), index: z.number().int().min(0).describe("0-based checklist item index"), done: z.boolean().optional().describe("default true; pass false to untick") },
194
+ async ({ id, index, done }) => {
195
+ const { task } = await api("POST", "/task/checklist-toggle", { id, index, done: done !== false, by: SESSION });
196
+ const n = task.checklist.filter(c => c.done).length;
197
+ return { content: [{ type: "text", text: `card #${id} checklist: [${done !== false ? "x" : " "}] "${task.checklist[index].text}" — ${n}/${task.checklist.length} done` }] };
190
198
  });
191
199
 
192
200
  server.tool("relay_phase_goal", "Set what a PHASE is for — its goal — shown as the phase header in the Flow view (overrides the theme auto-derived from card titles). Capture this when you plan a phase so the board says what each milestone needs to do, not just 'P5'. Phase keys match relay_task_add's `phase` (or the inferred title-prefix family like 'P5').",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.18",
3
+ "version": "0.18.20",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"
@@ -11,7 +11,7 @@
11
11
  "zod": "^4.4.3"
12
12
  },
13
13
  "scripts": {
14
- "test": "node bin/slop-gate.mjs --surface desktop/src && node test.mjs && node test-scenarios.mjs && node test-failure.mjs && node test-redelivery.mjs && node test-crew-completion.mjs && node test-contracts.mjs && node test-autonomy.mjs && node test-integrate.mjs && node test-handoff.mjs && node test-handoff-summarizer.mjs && node test-baton-turn-boundary.mjs && node test-agents.mjs && node test-update.mjs && node test-handoff-guard.mjs && node test-baton-latest.mjs && node test-balances.mjs && node test-subagent-cost.mjs && node test-inbox.mjs && node test-inflight.mjs && node test-notify.mjs && node test-focus.mjs && node test-cardlog.mjs && node test-relay-note.mjs && node test-reaper.mjs && node test-events.mjs && node test-proposals.mjs && node test-scrub.mjs && node test-store-delta.mjs && node test-message-re.mjs && node test-claims.mjs && node test-adopt.mjs && node test-summarize.mjs && node test-identity-core.mjs && node test-identity.mjs && node test-identity-instances.mjs && node test-duty.mjs && node test-duty-seat.mjs && node test-discovery.mjs && node test-doctor.mjs && node test-crew-env.mjs && node test-desktop-transport.mjs && node test-crew-worktree.mjs && bash test-crew-herdr.sh && npm --prefix desktop run test --silent && node test-splitbrain.mjs && node test-overseer.mjs && node test-overseer-lib.mjs && node test-overseer-warn.mjs && node test-provider-keys.mjs && node test-inbox-delivery.mjs && node test-identity-drift.mjs && node test-inbox-staleness.mjs && node test-seats.mjs && node test-seat-identity.mjs && node test-hub-routing.mjs && node test-hook-routing.mjs && node test-relay-wait.mjs && node test-dsh-seat.mjs && node test-kimi-bridge.mjs && node test-kimi-events.mjs && python3 engine/test-routing.py && node test-reconcile.mjs && node test-resources.mjs && node test-patrol.mjs && node test-bridge.mjs && bash test-crew.sh"
14
+ "test": "node bin/slop-gate.mjs --surface desktop/src && node test.mjs && node test-scenarios.mjs && node test-failure.mjs && node test-redelivery.mjs && node test-crew-completion.mjs && node test-contracts.mjs && node test-autonomy.mjs && node test-integrate.mjs && node test-handoff.mjs && node test-handoff-summarizer.mjs && node test-baton-turn-boundary.mjs && node test-agents.mjs && node test-update.mjs && node test-handoff-guard.mjs && node test-baton-latest.mjs && node test-balances.mjs && node test-subagent-cost.mjs && node test-inbox.mjs && node test-inflight.mjs && node test-notify.mjs && node test-focus.mjs && node test-cardlog.mjs && node test-checklist.mjs && node test-relay-note.mjs && node test-reaper.mjs && node test-events.mjs && node test-proposals.mjs && node test-scrub.mjs && node test-store-delta.mjs && node test-message-re.mjs && node test-claims.mjs && node test-adopt.mjs && node test-summarize.mjs && node test-identity-core.mjs && node test-identity.mjs && node test-identity-instances.mjs && node test-duty.mjs && node test-duty-dark.mjs && node test-duty-seat.mjs && node test-discovery.mjs && node test-doctor.mjs && node test-crew-env.mjs && node test-desktop-transport.mjs && node test-crew-worktree.mjs && bash test-crew-herdr.sh && npm --prefix desktop run test --silent && node test-splitbrain.mjs && node test-overseer.mjs && node test-overseer-lib.mjs && node test-overseer-warn.mjs && node test-provider-keys.mjs && node test-inbox-delivery.mjs && node test-identity-drift.mjs && node test-inbox-staleness.mjs && node test-seats.mjs && node test-seat-identity.mjs && node test-hub-routing.mjs && node test-hook-routing.mjs && node test-relay-wait.mjs && node test-dsh-seat.mjs && node test-kimi-bridge.mjs && node test-kimi-events.mjs && python3 engine/test-routing.py && node test-reconcile.mjs && node test-resources.mjs && node test-patrol.mjs && node test-bridge.mjs && bash test-crew.sh"
15
15
  },
16
16
  "description": "The hub-world for AI agent crews \u2014 orchestrate Claude Code, Codex, GLM, Kimi, DeepSeek & any OpenRouter model as live crews with a plan-aware Advisor, a Kanban/flow command center, a testing gate, and an economics brain (Scrooge).",
17
17
  "files": [