trantor 0.18.9 → 0.18.11

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.9",
3
+ "version": "0.18.11",
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
@@ -371,7 +371,7 @@ rate, not work rate.
371
371
 
372
372
  ```
373
373
  trantor setup | doctor | connect | profile | provider | models
374
- | up <agents…> | swap <old> <new> | down | ui | advise | hub | watch
374
+ | up <agents…> | swap <old> <new> | down | seat-why <agent> | ui | advise | hub | watch
375
375
  | adopt <project> | reconcile | duty | orchestrate | patrol | app | backfill | init-hooks
376
376
  ```
377
377
 
@@ -385,6 +385,10 @@ trantor setup | doctor | connect | profile | provider | models
385
385
  (`glm:zai-coding-plan --difficulty hard`); spawns are verified on the bus with one retry;
386
386
  geometry auto-detects the screen you're working on (`CREW_RECT="X,Y,W,H"` to override).
387
387
  - **`trantor swap <oldAgent> <newSpec>`** replaces an exhausted agent with a live-selected one.
388
+ - **`trantor seat-why <agent> [--json]`** — WHY a seat is down, from evidence (the err file first,
389
+ then logs, tracked panes, live pids): `live`, `dead-quota`, `dead-auth`, `dead-crash`,
390
+ `no-runner`, or `no-pane`, each with the advice that actually fits. A quota-dead seat is
391
+ indistinguishable from a broken bus until you read the err file — this reads it for you.
388
392
  - **`trantor down`** kills crew processes via their ttys and closes windows without macOS
389
393
  "Terminate?" dialogs.
390
394
 
package/bin/adopt.mjs CHANGED
@@ -1,150 +1,107 @@
1
1
  #!/usr/bin/env node
2
- // trantor adopt — graduate a project from the machine-local hub to a remote hub, in ONE command.
2
+ // `trantor adopt`take over a session that is already running in a Terminal.
3
3
  //
4
- // trantor adopt <project> [--hub <url>] [--dry] [--force]
4
+ // You cannot move a running pty into herdr. That is the wall this hits, and it is not going away.
5
+ // But the pty was never the valuable part: the CONVERSATION is, and that lives in a transcript on
6
+ // disk. So adopting is a two-step move — learn which session id is live, then reopen THAT
7
+ // conversation inside Trantor with --resume. The terminal changes; the thread does not.
5
8
  //
6
- // The crm-platform lesson: a new project is born unpinned, lives on the local hub (by design —
7
- // TDD §12.1's fallback), and moving it to the shared hub was three separate ceremonies (enroll
8
- // identities, migrate data, write the pin) spread across two machines. This collapses them:
9
- //
10
- // 1. read the project's rows off the LOCAL hub state (tasks/events/messages)
11
- // 2. enroll this machine's identities for the project on the target hub (owner-signed invites):
12
- // the orchestrator (<host>:<project>) as owner, every existing seat key as write
13
- // 3. POST /import (owner-signed) — the hub merges, remapping colliding card ids itself
14
- // 4. verify the count round-trip, THEN write the routing pin
15
- //
16
- // No ssh, no direct Postgres access: the hub's /import endpoint is the migration surface.
17
- // Live sessions keep their old routing until restarted — adopt SAYS so rather than pretending.
18
- import { readFileSync, writeFileSync, existsSync, readdirSync } from "node:fs";
19
- import { join } from "node:path";
9
+ // Which session is live cannot be read from the process: macOS does not expose another process's
10
+ // environment, so CLAUDE_CODE_SESSION_ID is unreachable. The transcript's modification time is the
11
+ // evidence we do have, and it is good evidence but not proof a session that just ended and one
12
+ // still running look similar for a minute. So this SHOWS the candidates and defaults to the
13
+ // newest, rather than asserting which one is yours.
14
+ import { readdirSync, statSync, existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
15
+ import { execFileSync } from "node:child_process";
16
+ import { join, dirname } from "node:path";
20
17
  import { homedir } from "node:os";
21
- import { hostId, DEFAULT_HUB_URL } from "../lib/project.mjs";
22
- import { loadOrCreate, signRequest } from "../lib/identity.mjs";
23
- import { sfetchJson } from "../lib/signed-fetch.mjs";
24
- import { scan } from "../lib/splitbrain.mjs";
25
-
26
- const argv = process.argv.slice(2);
27
- const PROJECT = argv.find(a => !a.startsWith("--")) || "";
28
- const arg = (k) => { const i = argv.indexOf(`--${k}`); return i >= 0 ? (argv[i + 1] ?? "") : ""; };
29
- const has = (k) => argv.includes(`--${k}`);
30
- if (!PROJECT) { console.error("usage: trantor adopt <project> [--hub <url>] [--dry] [--force]"); process.exit(1); }
31
-
32
- const BUS_DIR = process.env.AGENT_BUS_DIR || join(homedir(), ".agent-bus");
33
- const CONFIG_PATH = join(BUS_DIR, "config.json");
34
- let config = {}; try { config = JSON.parse(readFileSync(CONFIG_PATH, "utf8")); } catch {}
35
- const LOCAL = config.url || "http://127.0.0.1:4477";
36
-
37
- // target: --hub wins; else the hub most of the fleet already lives on
38
- const pinCounts = {};
39
- for (const u of Object.values(config.hubs || {})) if (!/127\.0\.0\.1|localhost/.test(u)) pinCounts[u] = (pinCounts[u] || 0) + 1;
40
- const TARGET = arg("hub") || Object.entries(pinCounts).sort((a, b) => b[1] - a[1])[0]?.[0] || "";
41
- if (!TARGET) { console.error("no remote hub known — pass --hub <url> (no non-local pins exist to infer one from)"); process.exit(1); }
42
- if ((config.hubs || {})[PROJECT] === TARGET) { console.log(`${PROJECT} is already pinned to ${TARGET} — nothing to do.`); process.exit(0); }
18
+ import { resolveProject } from "../lib/project.mjs";
43
19
 
44
- // 1. the project's rows, straight from the local hub's state file (full fidelity, no pagination)
45
- const statePath = process.env.RELAY_STATE || join(BUS_DIR, "bus.json");
46
- let local = {}; try { local = JSON.parse(readFileSync(statePath, "utf8")); } catch {}
47
- const tasks = (local.tasks || []).filter(t => t.project === PROJECT);
48
- const events = (local.events || []).filter(e => e.project === PROJECT);
49
- const messages = (local.messages || []).filter(m => (m.project || "") === PROJECT);
50
- const livePeers = Object.entries(local.peers || {}).filter(([, p]) => p.project === PROJECT && Date.now() - (p.lastSeen || 0) < 5 * 60 * 1000);
20
+ const D = "\x1b[2m", B = "\x1b[1m", Y = "\x1b[33m", G = "\x1b[32m", R = "\x1b[0m";
21
+ const args = process.argv.slice(2);
22
+ const flag = (n) => { const i = args.indexOf(n); return i >= 0 ? args[i + 1] : null; };
51
23
 
52
- const owner = String(config.ownerIdentity || "");
53
- if (!owner) { console.error("config.ownerIdentity is not set — enrolments need an owner to sign invites"); process.exit(1); }
24
+ const project = args.find(a => !a.startsWith("--") && args[args.indexOf(a) - 1] !== "--session")
25
+ || resolveProject(process.cwd());
26
+ const devRoot = process.env.TRANTOR_DEV_ROOT || join(homedir(), "development");
27
+ const dir = join(devRoot, project);
28
+ if (!existsSync(dir)) {
29
+ console.error(`no local checkout for ${project} (looked in ${devRoot})`);
30
+ process.exit(1);
31
+ }
54
32
 
55
- // identities: the orchestrator as owner + every seat key that already exists for this project
56
- const safe = (s) => s.replace(/[^A-Za-z0-9_.-]/g, "_");
57
- const keyFiles = (() => { try { return readdirSync(join(BUS_DIR, "keys")); } catch { return []; } })();
58
- const orchestrator = `${hostId()}:${PROJECT}`;
59
- const seats = keyFiles
60
- .filter(f => f.endsWith(`_${safe(PROJECT)}.json`))
61
- .map(f => f.replace(/\.json$/, "").replace(`_${safe(PROJECT)}`, `:${PROJECT}`))
62
- .filter(n => n !== safe(orchestrator).replace(`_${safe(PROJECT)}`, `:${PROJECT}`) && n !== orchestrator);
33
+ /** claude keeps a project's transcripts under a slug of its working directory. */
34
+ const slug = dir.replace(/[/.]/g, "-");
35
+ const tdir = join(homedir(), ".claude", "projects", slug);
36
+ if (!existsSync(tdir)) {
37
+ console.error(`no claude sessions have ever run in ${dir}`);
38
+ process.exit(1);
39
+ }
63
40
 
64
- console.log(`adopt : ${PROJECT}`);
65
- console.log(`from : ${LOCAL} (${tasks.length} cards · ${events.length} events · ${messages.length} messages)`);
66
- console.log(`to : ${TARGET}`);
67
- console.log(`enroll : ${orchestrator} (owner)${seats.length ? ` + ${seats.join(", ")} (write)` : ""}`);
68
- if (livePeers.length) console.log(`⚠ LIVE : ${livePeers.map(([s]) => s).join(", ")} — they keep the OLD routing until restarted`);
69
- if (has("dry")) { console.log("\n[dry run] nothing changed."); process.exit(0); }
41
+ const RECENT_MS = 60 * 60 * 1000;
42
+ const now = Date.now();
43
+ const candidates = readdirSync(tdir)
44
+ .filter(f => f.endsWith(".jsonl"))
45
+ .map(f => {
46
+ const p = join(tdir, f);
47
+ const st = statSync(p);
48
+ return { id: f.replace(/\.jsonl$/, ""), mtime: st.mtimeMs, size: st.size };
49
+ })
50
+ .filter(c => now - c.mtime < RECENT_MS)
51
+ .sort((a, b) => b.mtime - a.mtime);
70
52
 
71
- const ownerId = loadOrCreate(owner, "human");
72
- async function enroll(name, role) {
73
- const invBody = JSON.stringify({ scopes: [{ project: PROJECT, role }], ttlSec: 600 });
74
- const invSig = signRequest(ownerId, { method: "POST", path: "/invite", body: invBody });
75
- const inv = await (await fetch(`${TARGET}/invite`, { method: "POST", headers: { "content-type": "application/json", ...invSig }, body: invBody, signal: AbortSignal.timeout(8000) })).json();
76
- if (!inv.token) throw new Error(`invite for ${name}: ${inv.error || "no token"}`);
77
- const id = loadOrCreate(name, "agent");
78
- const body = JSON.stringify({ token: inv.token, name, pubkey: id.pubkey, kind: "agent" });
79
- const sig = signRequest(id, { method: "POST", path: "/enroll", body });
80
- const r = await (await fetch(`${TARGET}/enroll`, { method: "POST", headers: { "content-type": "application/json", ...sig }, body, signal: AbortSignal.timeout(8000) })).json();
81
- if (!r.ok) throw new Error(`enroll ${name}: ${r.error || "failed"}`);
82
- console.log(` ✓ enrolled ${name} (${role})`);
53
+ if (!candidates.length) {
54
+ console.error(`no session has written to ${project} in the last hour — nothing to adopt`);
55
+ console.error(`${D}start a fresh one instead: trantor open ${project}${R}`);
56
+ process.exit(1);
83
57
  }
84
58
 
85
- try {
86
- await enroll(orchestrator, "owner");
87
- for (const s of seats) await enroll(s, "write");
59
+ const chosen = flag("--session") || candidates[0].id;
60
+ if (!candidates.some(c => c.id === chosen) && flag("--session")) {
61
+ console.error(`${chosen} has not written to ${project} recently`);
62
+ process.exit(1);
63
+ }
88
64
 
89
- const imp = await sfetchJson(`${TARGET}/import`, {
90
- identity: ownerId,
91
- payload: { project: PROJECT, tasks, events, messages, by: owner, force: has("force") },
92
- signal: AbortSignal.timeout(60000),
93
- });
94
- const impJson = await imp.json();
95
- if (!impJson.ok) throw new Error(`import: ${impJson.error || imp.status}${impJson.existing ? ` (${impJson.existing} cards already there — --force to merge anyway)` : ""}`);
96
- console.log(`imported : ${impJson.tasks} cards · ${impJson.events} events · ${impJson.messages} messages${impJson.remapped ? ` · ${impJson.remapped} card id(s) remapped` : ""}`);
65
+ const ago = (ms) => { const s = Math.round((now - ms) / 1000); return s < 90 ? `${s}s ago` : `${Math.round(s / 60)}m ago`; };
66
+ const kb = (n) => (n > 1e6 ? `${(n / 1e6).toFixed(1)}MB` : `${Math.round(n / 1e3)}KB`);
97
67
 
98
- // verify BEFORE pinning — a pin pointing at a hub that doesn't have the data is a data outage
99
- const check = await (await sfetchJson(`${TARGET}/tasks?project=${encodeURIComponent(PROJECT)}`, { method: "GET", identity: ownerId })).json();
100
- const remoteCount = (check.tasks || []).length;
101
- if (remoteCount < tasks.length) throw new Error(`verify: target has ${remoteCount} cards, local has ${tasks.length} — NOT pinning`);
102
- console.log(`verified : ${remoteCount} cards on target`);
68
+ console.log(`${B}adopt${R} · ${project}`);
69
+ for (const c of candidates.slice(0, 5)) {
70
+ const mark = c.id === chosen ? `${G}→${R}` : " ";
71
+ console.log(` ${mark} ${c.id} ${D}${ago(c.mtime).padEnd(9)} ${kb(c.size)}${R}`);
72
+ }
73
+ if (candidates.length > 1) {
74
+ console.log(`${D}the newest is assumed to be yours; pick another with --session <id>${R}`);
75
+ }
103
76
 
104
- config.hubs = config.hubs || {};
105
- config.hubs[PROJECT] = TARGET;
106
- writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n");
107
- console.log(`pinned : ${PROJECT} ${TARGET}`);
77
+ // Record it where `trantor open` looks. Same file, same format the orchestrator pane already uses,
78
+ // so adopting and opening fresh converge on one mechanism rather than two.
79
+ const busDir = process.env.AGENT_BUS_DIR || join(homedir(), ".agent-bus");
80
+ const store = join(busDir, "orch-sessions.txt");
81
+ mkdirSync(dirname(store), { recursive: true });
82
+ const rows = existsSync(store) ? readFileSync(store, "utf8").split("\n").filter(Boolean) : [];
83
+ const kept = rows.filter(r => r.split("\t")[0] !== project);
84
+ writeFileSync(store, [...kept, `${project}\t${chosen}`].join("\n") + "\n");
85
+ console.log(`\n${G}recorded${R} ${chosen} as ${project}'s orchestrator session`);
108
86
 
109
- // TELL the stale sessions, don't just print at a human who may never see this terminal again.
110
- // A live session holds its hub URL for its whole life: its MCP server resolved the route at
111
- // boot and nothing re-reads config.json. So the moment the pin is written, every one of these
112
- // is recording onto a hub nobody reads any more — the exact split-brain crebral-health spent
113
- // two sessions diagnosing. They are still listening on the OLD hub, so that is where the
114
- // notice has to go.
115
- if (livePeers.length) {
116
- const notice = `📦 ${PROJECT} has MOVED to ${TARGET}. You are still bound to ${LOCAL}, so your cards and messages now land on a hub nobody is reading. RESTART to pick up the pin — crew seats: \`trantor down && trantor up\` · Claude sessions: restart the session.`;
117
- let told = 0;
118
- for (const [session] of livePeers) {
119
- try {
120
- await sfetchJson(`${LOCAL}/send`, { identity: ownerId, payload: { from: owner, to: session, project: PROJECT, text: notice }, signal: AbortSignal.timeout(8000) });
121
- told++;
122
- } catch (e) { console.log(` ⚠ could not notify ${session}: ${e.message}`); }
123
- }
124
- // …and once to the room, for anything live that never registered as a peer.
125
- try { await sfetchJson(`${LOCAL}/send`, { identity: ownerId, payload: { from: owner, to: "all", project: PROJECT, text: notice }, signal: AbortSignal.timeout(8000) }); } catch {}
126
- console.log(`\n⚠ ${livePeers.length} live session(s) still route to the OLD hub — told ${told} of them to restart:`);
127
- for (const [s] of livePeers) console.log(` ${s}`);
128
- console.log(` crew seats: trantor down && trantor up · Claude sessions: restart them when convenient.`);
87
+ // Two live claudes on one transcript is the one thing that must not happen: they would interleave
88
+ // writes into the same file. So say plainly what has to happen first.
89
+ let running = [];
90
+ try {
91
+ const pids = execFileSync("/usr/bin/pgrep", ["-x", "claude"], { encoding: "utf8" }).split("\n").filter(Boolean);
92
+ for (const pid of pids) {
93
+ try {
94
+ const out = execFileSync("/usr/sbin/lsof", ["-a", "-d", "cwd", "-p", pid, "-Fn"], { encoding: "utf8" });
95
+ if (out.split("\n").some(l => l.startsWith("n") && l.slice(1) === dir)) running.push(pid);
96
+ } catch { /* process went away between listing and asking */ }
129
97
  }
130
- console.log(`\n✓ adopted. New sessions on ${PROJECT} land on ${TARGET}.`);
98
+ } catch { /* nothing running */ }
131
99
 
132
- // Prove the move actually landed as one hub, rather than trusting that it did. A migration is
133
- // precisely the moment a project is most likely to end up living in two places at once.
134
- try {
135
- const { findings, blind } = await scan(config, ownerId, { defaultUrl: DEFAULT_HUB_URL, timeoutMs: 6000 });
136
- const mine = findings.filter(f => f.project === PROJECT);
137
- if (mine.length) {
138
- console.log(`\n⚠ split-brain check on ${PROJECT}:`);
139
- for (const f of mine) { console.log(` ${f.message}`); console.log(` → ${f.fix}`); }
140
- } else if (blind.length) {
141
- console.log(`\nsplit-brain check: partial — could not read ${blind.map(b => b.url).join(", ")}`);
142
- } else {
143
- console.log(`split-brain check: clean — ${PROJECT} is live on one hub only.`);
144
- }
145
- } catch {}
146
- } catch (e) {
147
- console.error(`\n✗ adopt failed: ${e.message}`);
148
- console.error("nothing was pinned — routing is unchanged.");
149
- process.exit(1);
100
+ if (running.length) {
101
+ console.log(`\n${Y}A claude is still running in ${dir} (pid ${running.join(", ")}).${R}`);
102
+ console.log(`Quit that window first — two sessions writing one transcript will corrupt the thread.`);
103
+ console.log(`Then: ${B}cd ${dir} && trantor open${R}`);
104
+ } else {
105
+ console.log(`\nNothing is running there. Continue it in Trantor with:`);
106
+ console.log(` ${B}cd ${dir} && trantor open${R}`);
150
107
  }
@@ -0,0 +1,83 @@
1
+ #!/usr/bin/env node
2
+ // `trantor autonomy` — read and set the three dials from the CLI.
3
+ //
4
+ // The app will grow a settings pane for this, but the CLI has to work on its own: crew.sh asks
5
+ // this for the harness dial every time it starts your session, and a headless machine has no app.
6
+ import { resolveAutonomy, setAutonomy, loadAutonomy, AUTONOMY_PATH } from "../lib/autonomy.mjs";
7
+ import { resolveProject } from "../lib/project.mjs";
8
+
9
+ const D = "\x1b[2m", B = "\x1b[1m", R = "\x1b[0m";
10
+ const args = process.argv.slice(2);
11
+ const cmd = args[0] || "show";
12
+
13
+ function projectFlag() {
14
+ const i = args.indexOf("--project");
15
+ if (i >= 0 && args[i + 1]) return args[i + 1];
16
+ if (args.includes("--global")) return null;
17
+ return resolveProject(process.cwd());
18
+ }
19
+
20
+ const BOOLS = ["commit", "push", "deploy", "swapDeadSeat", "retryFailedTurn"];
21
+ const ENUMS = { harness: ["prompt", "bypass"] };
22
+
23
+ if (cmd === "get") {
24
+ // Machine-readable, one value, no decoration — crew.sh reads this.
25
+ const key = args[1];
26
+ const a = resolveAutonomy(projectFlag() || "");
27
+ if (!(key in a)) { console.error(`unknown dial '${key}'`); process.exit(1); }
28
+ console.log(String(a[key]));
29
+ } else if (cmd === "set") {
30
+ const key = args[1];
31
+ let value = args[2];
32
+ const project = projectFlag();
33
+ if (BOOLS.includes(key)) {
34
+ if (!["on", "off", "true", "false"].includes(value)) {
35
+ console.error(`${key} takes on|off`); process.exit(1);
36
+ }
37
+ value = value === "on" || value === "true";
38
+ } else if (ENUMS[key]) {
39
+ if (!ENUMS[key].includes(value)) { console.error(`${key} takes ${ENUMS[key].join("|")}`); process.exit(1); }
40
+ } else {
41
+ console.error(`unknown dial '${key}' — one of: ${[...Object.keys(ENUMS), ...BOOLS].join(", ")}`);
42
+ process.exit(1);
43
+ }
44
+ const out = setAutonomy(project, { [key]: value });
45
+ console.log(`${key} = ${out[key]}${project ? ` for ${project}` : " (default for every project)"}`);
46
+ // The dependencies can quietly refuse what was just asked for, so say when that happened rather
47
+ // than letting the operator believe a dial is on.
48
+ if (key === "push" && value === true && out.push === false) {
49
+ console.log(`${D}push stayed off: it needs commit on first${R}`);
50
+ }
51
+ if (key === "deploy" && value === true && out.deploy === false) {
52
+ console.log(`${D}deploy stayed off: it needs push on first${R}`);
53
+ }
54
+ } else if (cmd === "json") {
55
+ // The app reads through THIS, not by parsing autonomy.json itself. The dependency rules (push
56
+ // implies commit, deploy implies push) live in one place, and a second implementation in Rust
57
+ // would drift from it the first time either side changed.
58
+ const project = projectFlag();
59
+ const cfg = loadAutonomy();
60
+ console.log(JSON.stringify({
61
+ project,
62
+ resolved: resolveAutonomy(project || "", cfg),
63
+ defaults: resolveAutonomy("", { ...cfg, projects: {} }),
64
+ overridden: project && cfg.projects?.[project] ? Object.keys(cfg.projects[project]) : [],
65
+ path: AUTONOMY_PATH(),
66
+ }));
67
+ } else if (cmd === "show" || cmd === "list") {
68
+ const project = projectFlag();
69
+ const a = resolveAutonomy(project || "");
70
+ const cfg = loadAutonomy();
71
+ const overridden = project && cfg.projects?.[project] ? Object.keys(cfg.projects[project]) : [];
72
+ const mark = k => (overridden.includes(k) ? `${D} (set for ${project})${R}` : "");
73
+ console.log(`${B}autonomy${R}${project ? ` · ${project}` : " · defaults"}`);
74
+ console.log(`\n ${B}harness${R} ${a.harness}${mark("harness")} ${D}whether YOUR claude asks before acting${R}`);
75
+ console.log(` ${D}what a crew AGENT may do unattended is the overseer's level, per project, on the hub${R}`);
76
+ console.log(`\n ${D}what Trantor does on your behalf:${R}`);
77
+ for (const k of BOOLS) console.log(` ${B}${k}${R}${" ".repeat(Math.max(1, 11 - k.length))}${a[k] ? "on" : "off"}${mark(k)}`);
78
+ console.log(`\n${D}${AUTONOMY_PATH()}${R}`);
79
+ console.log(`${D}trantor autonomy set commit on · set harness bypass --global${R}`);
80
+ } else {
81
+ console.log("usage: trantor autonomy [show] | get <dial> | set <dial> <value> [--project P | --global]");
82
+ process.exit(1);
83
+ }
package/bin/baton.mjs CHANGED
@@ -4,7 +4,7 @@
4
4
  // self-announcing session, and closes THIS window once it takes over. Run from inside the session you
5
5
  // want to hand off. (The richer MODEL-authored handoff is the /trantor:handoff skill.)
6
6
  import { readdirSync, statSync } from "node:fs";
7
- import { join } from "node:path";
7
+ import { join, basename } from "node:path";
8
8
  import { homedir } from "node:os";
9
9
  import { resolveProject } from "../lib/project.mjs";
10
10
  import { writeHandoff, spawnBaton } from "../hooks/lib/handoff.mjs";
@@ -30,7 +30,10 @@ function findTranscript() {
30
30
  }
31
31
 
32
32
  const transcript = findTranscript();
33
- const { file } = writeHandoff({ projectDir: cwd, sessionId: "", transcript, trigger: "manual-cli", force: true }); // manual = intentional, bypass the storm guard
33
+ // The transcript's filename IS the writing session's id record it, or an orchestrator-thread
34
+ // handoff carries no writer and the baton-hold + map-follow logic in sessionstart.mjs can't fire.
35
+ const sessionId = transcript ? basename(transcript, ".jsonl") : "";
36
+ const { file } = writeHandoff({ projectDir: cwd, sessionId, transcript, trigger: "manual-cli", force: true }); // manual = intentional, bypass the storm guard
34
37
  console.log(`📋 handoff saved for ${project}: ${file}`);
35
38
  const { spawned, armed, windowId } = spawnBaton({ projectDir: cwd, handoffFile: file });
36
39
  console.log(spawned
package/bin/cli.mjs CHANGED
@@ -23,6 +23,11 @@ switch (cmd) {
23
23
  case "advise": run("bin/advise.mjs"); break;
24
24
  case "verify": run("bin/crew-verify.mjs"); break;
25
25
  case "up": process.argv.splice(2, 1); spawn("/bin/bash", [join(ROOT, "bin/crew.sh"), "up", ...args], { stdio: "inherit", cwd: process.cwd() }).on("exit", c => process.exit(c ?? 0)); break;
26
+ case "open": spawn("/bin/bash", [join(ROOT, "bin/crew.sh"), "open", ...args], { stdio: "inherit", cwd: process.cwd() }).on("exit", c => process.exit(c ?? 0)); break;
27
+ case "herdr": spawn(process.execPath, [join(ROOT, "bin/herdr-agent.mjs"), ...args], { stdio: "inherit", cwd: process.cwd() }).on("exit", c => process.exit(c ?? 0)); break;
28
+ case "autonomy": spawn(process.execPath, [join(ROOT, "bin/autonomy.mjs"), ...args], { stdio: "inherit", cwd: process.cwd() }).on("exit", c => process.exit(c ?? 0)); break;
29
+ case "adopt": spawn(process.execPath, [join(ROOT, "bin/adopt.mjs"), ...args], { stdio: "inherit", cwd: process.cwd() }).on("exit", c => process.exit(c ?? 0)); break;
30
+ case "integrate": spawn(process.execPath, [join(ROOT, "bin/integrate.mjs"), ...args], { stdio: "inherit", cwd: process.cwd() }).on("exit", c => process.exit(c ?? 0)); break;
26
31
  case "down": spawn("/bin/bash", [join(ROOT, "bin/crew.sh"), "down", ...args], { stdio: "inherit", cwd: process.cwd() }).on("exit", c => process.exit(c ?? 0)); break;
27
32
  case "swap": spawn("/bin/bash", [join(ROOT, "bin/crew.sh"), "swap", ...args], { stdio: "inherit", cwd: process.cwd() }).on("exit", c => process.exit(c ?? 0)); break;
28
33
  case "prune": spawn("/bin/bash", [join(ROOT, "bin/crew.sh"), "prune", ...args], { stdio: "inherit", cwd: process.cwd() }).on("exit", c => process.exit(c ?? 0)); break;
@@ -74,6 +79,7 @@ switch (cmd) {
74
79
  case "inbox": run("bin/inbox.mjs"); break;
75
80
  case "duty": run("bin/duty.mjs"); break;
76
81
  case "seats": case "seat": run("bin/seats.mjs"); break;
82
+ case "seat-why": case "why": run("bin/seat-why.mjs"); break;
77
83
  case "orchestrate": run("bin/orchestrate.mjs"); break;
78
84
  case "app": run("bin/app.mjs"); break;
79
85
  case "patrol": run("bin/patrol.mjs"); break;
@@ -185,6 +191,11 @@ switch (cmd) {
185
191
  trantor provider bring ANY model (BYOM): list seats · add <name> --key … · remove <name>
186
192
  trantor models browse live models behind each seat + the router's pick per difficulty
187
193
  trantor up … spawn a crew here: trantor up codex kimi deepseek:deepseek glm:zai-coding-plan
194
+ trantor open host THIS session as the project's orchestrator pane (trantor down spares it)
195
+ trantor herdr install|remove|status the login agent that keeps panes alive across a reboot
196
+ trantor autonomy how much Trantor may do unasked: seats, your harness, commit/push/deploy
197
+ trantor adopt take over a session already running in a Terminal, then open it here
198
+ trantor integrate collect the crew's work, merge it, verify it, push it (--dry-run to rehearse)
188
199
  trantor down tear the crew down (kills processes, closes windows, no dialogs)
189
200
  trantor prune drop dead crew-window tracking rows (ghost workspaces/panes) without spawning anything
190
201
  trantor ui open the live dashboard (board + flow views)
@@ -201,6 +212,7 @@ switch (cmd) {
201
212
  trantor hub run the hub in the foreground (setup installs it as a service instead)
202
213
  …or manage per-project hub pins: hub list · hub set <project> <url> · hub unset <project>
203
214
  seats: which project lives in which directory — seats · seats add · seats up · seats login install
215
+ trantor seat-why WHY a seat is down (err file, logs, pids): seat-why <agent> [--json] — quota, auth, crash, or just no pane
204
216
  trantor watch live bus feed in the terminal
205
217
  trantor inbox THIS session's unread bus messages, signed (works under enforce) — [--all] [--consume] [--json]
206
218
  trantor policy the autonomy ladder: show | set <project> <1-4> | link <a> <b> --reason "<why>"
@@ -150,6 +150,28 @@ function cmuxStatus(value, color, icon = "robot", opts = {}) {
150
150
  const col = opts.alert ? color : (BRAND_HEX[AGENT.toLowerCase()] || color);
151
151
  try { spawnSync(CMUX_BIN, ["set-status", SESSION, `${AGENT} · ${value}`, "--color", col, "--icon", icon, "--priority", String(opts.priority ?? 0)], { stdio: "ignore", timeout: 1500, env: { ...process.env, CMUX_QUIET: "1" } }); } catch {}
152
152
  }
153
+ // herdr drops a pane's agent registration when the process inside it exits — and a seat's CLI
154
+ // exits at the END OF EVERY TURN. Reporting once when the pane is created is therefore not enough:
155
+ // the seat vanishes from `herdr agent list` after its first turn, `herdr agent attach` starts
156
+ // answering agent_not_found, and the app renders that raw error where the terminal should be.
157
+ // Observed 2026-08-27 on codex, which was crash-looping on an exhausted quota.
158
+ //
159
+ // So re-report at every turn boundary, which also gives herdr a truthful working/idle state.
160
+ // NOTE the argument order: the pane id comes FIRST, before the flags.
161
+ function herdrAgent(state) {
162
+ try {
163
+ const f = join(homedir(), ".agent-bus", "crew-windows.txt");
164
+ if (!existsSync(f)) return;
165
+ const row = readFileSync(f, "utf8").split("\n")
166
+ .map(l => l.split("\t"))
167
+ .filter(c => c.length >= 4 && c[0] === PROJ && c[1] === "herdr" && c[2] === AGENT)
168
+ .pop();
169
+ if (!row || !row[3]) return;
170
+ spawnSync("herdr", ["pane", "report-agent", row[3], "--source", "crew", "--agent", AGENT, "--state", state],
171
+ { stdio: "ignore", timeout: 1500 });
172
+ } catch { /* no herdr, or no row for this seat: the cmux/tmux paths do not need it */ }
173
+ }
174
+
153
175
  function cmuxLog(message, level = "info") {
154
176
  if (!inCmux()) return;
155
177
  try { spawnSync(CMUX_BIN, ["log", String(message).slice(0, 200), "--level", level], { stdio: "ignore", timeout: 1500, env: { ...process.env, CMUX_QUIET: "1" } }); } catch {}
@@ -221,6 +243,12 @@ const PULSE_PROMPT = `[pulse] Re-read your mission note (${MISSION_FILE} in your
221
243
  // every non-zero turn to the bus in real time so the orchestrator (and `trantor swap`)
222
244
  // can react, and flip presence to errored/down.
223
245
  let consecFails = 0;
246
+ // The failure STATE the room has already been told about. A seat that is down stays down, and
247
+ // saying so again every retry is repetition, not news — the monitoring doctrine this project holds
248
+ // everyone else to says report duration, not repetition. Observed cost: a permanently exhausted
249
+ // codex seat broadcast "DOWN" to `all` 31 times over six hours, and every broadcast is a turn for
250
+ // every live seat, so two working agents spent the evening reading the same sentence.
251
+ let announced = "";
224
252
  let lastErrText = "";
225
253
  const ERRF = join(homedir(), ".agent-bus", `err-${AGENT}-${PROJ}.txt`);
226
254
 
@@ -262,6 +290,12 @@ function loadPending() {
262
290
  } catch { return { wake: [], bcast: [] }; }
263
291
  }
264
292
 
293
+ // Auth-failure markers in TURN OUTPUT. opencode prints its auth error ("401 Unauthorized" /
294
+ // "Invalid API key") and STILL exits 0, so a bare 0 from the CLI is not proof the turn ran
295
+ // (card #5405). This regex gates the exit-0 path in runTurn; kept tighter than
296
+ // classifyFailure's set (no bare /expired/) so a healthy transcript never trips it.
297
+ const AUTH_MARKER_RE = /unauthor|401|403|forbidden|invalid[ _-]?api[ _-]?key|authentication? failed|token expired/i;
298
+
265
299
  function classifyFailure(exit, errText) {
266
300
  const t = (errText || "").toLowerCase();
267
301
  if (exit === 127) return "missing-cli";
@@ -287,8 +321,17 @@ async function reportFailure(exit, trigger, undelivered = 0) {
287
321
  const text = down
288
322
  ? `🛑 ${SESSION} DOWN — ${consecFails} consecutive failures (${reason}, exit ${exit})${hint}${held}`
289
323
  : `⚠️ ${SESSION} turn FAILED (${trigger}, exit ${exit} · ${reason})${hint}${held}`;
290
- await api("/send", { from: SESSION, to: "all", text, project: PROJ }).catch(() => {});
291
- cmuxStatus(down ? "down" : "error", "#ef6a6a", "alert", { alert: true, priority: 90 }); cmuxLog(`turn failed: ${reason} (exit ${exit})`, "error");
324
+ // Announce a CHANGE of state, never the continuation of one. The registered status above already
325
+ // carries "down: exhausted · N fails" for anyone who looks, which is state and costs nobody a
326
+ // turn; the broadcast is the event, and an unchanged state is not an event.
327
+ const state = `${down ? "down" : "error"}:${reason}`;
328
+ if (state !== announced) {
329
+ announced = state;
330
+ await api("/send", { from: SESSION, to: "all", text, project: PROJ }).catch(() => {});
331
+ } else {
332
+ log(`still ${state} (${consecFails} fails) — already announced, staying quiet`);
333
+ }
334
+ cmuxStatus(down ? "down" : "error", "#ef6a6a", "alert", { alert: true, priority: 90 }); herdrAgent("blocked"); cmuxLog(`turn failed: ${reason} (exit ${exit})`, "error");
292
335
  log(`\x1b[31mreported failure to bus: ${reason} (exit ${exit})\x1b[0m`);
293
336
  }
294
337
 
@@ -322,9 +365,11 @@ async function notifyAssigners(pairs, text) {
322
365
  async function reportHealthy() {
323
366
  if (consecFails === 0) return; // already healthy — don't spam
324
367
  consecFails = 0;
368
+ // Recovery is a change too, so the next failure is news again.
369
+ announced = "";
325
370
  await api("/register", { session: SESSION, project: PROJ, status: `active in ${PROJ}`, llm: AGENT, model: MODEL }).catch(() => {});
326
371
  await api("/send", { from: SESSION, to: "all", text: `✅ ${SESSION} recovered`, project: PROJ }).catch(() => {});
327
- cmuxStatus("ok", "#14b8a6", "check");
372
+ cmuxStatus("ok", "#14b8a6", "check"); herdrAgent("idle");
328
373
  }
329
374
 
330
375
  let sid = "";
@@ -348,7 +393,7 @@ function runTurn(prompt, isFirst, trigger = "kickoff") {
348
393
  const envs = [join(homedir(), ".agent-bus", ".env"), cli.env].filter(f => f && existsSync(f));
349
394
  cmd = withEnvFiles(cmd, envs);
350
395
  log(`turn starting (${isFirst ? "fresh session" : "resume"})${MODEL ? ` · model=${MODEL}` : ""}`);
351
- cmuxStatus("building", "#4a90d9", "hammer", { priority: 50 });
396
+ cmuxStatus("building", "#4a90d9", "hammer", { priority: 50 }); herdrAgent("working");
352
397
  // inherit stdio so the window shows the agent working live; also capture for sid-parsing.
353
398
  // Tee stderr to ERRF (still shown live in the window) so a failed turn can be classified.
354
399
  try { appendFileSync(ERRF, "", { flag: "w" }); } catch {}
@@ -378,10 +423,21 @@ function runTurn(prompt, isFirst, trigger = "kickoff") {
378
423
  });
379
424
  try { lastErrText = readFileSync(ERRF, "utf8").slice(-4000); } catch { lastErrText = ""; }
380
425
  if (cli.sid && r.stdout) { const m = r.stdout.match(cli.sid); if (m) sid = m[1]; }
381
- telemetry({ ts: Date.now(), agent: AGENT, project: PROJ, turn: TURN, trigger, model: MODEL || "default", duration_ms: Date.now() - t0, exit: r.status });
382
- log(`turn ended (exit ${r.status}, ${((Date.now() - t0) / 1000).toFixed(0)}s)`);
383
- if (r.status === 0) cmuxStatus("idle", "#8a94a6", "robot"); // finished this turn, waiting for the next
384
- return r.status;
426
+ const realExit = r.status;
427
+ // A zero exit is NOT proof the turn ran: opencode prints "401 Unauthorized" / "Invalid API key"
428
+ // and exits 0, so a bare 0 made the runner ack " done", clear the pending queue and heartbeat
429
+ // green through an auth outage (card #5405). Cross-check the turn output and treat an
430
+ // exit-0-with-auth turn as FAILED. Telemetry keeps the REAL exit; the returned code is the
431
+ // effective one every call site branches on (kickoff, pulse, deliverWake).
432
+ let effExit = realExit;
433
+ if (realExit === 0 && AUTH_MARKER_RE.test(lastErrText)) {
434
+ effExit = 1;
435
+ log("\x1b[31mexit 0 but turn output shows an auth failure — treating as FAILED (auth)\x1b[0m");
436
+ }
437
+ telemetry({ ts: Date.now(), agent: AGENT, project: PROJ, turn: TURN, trigger, model: MODEL || "default", duration_ms: Date.now() - t0, exit: realExit, effExit, authFailed: effExit !== realExit });
438
+ log(`turn ended (exit ${realExit}${effExit !== realExit ? ` → effective ${effExit} (auth)` : ""}, ${((Date.now() - t0) / 1000).toFixed(0)}s)`);
439
+ if (realExit === 0 && effExit === 0) { cmuxStatus("idle", "#8a94a6", "robot"); herdrAgent("idle"); } // finished this turn, waiting for the next
440
+ return effExit;
385
441
  }
386
442
 
387
443
  // ---- main loop ----