trantor 0.18.10 → 0.18.12
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/.claude-plugin/plugin.json +1 -1
- package/README.md +5 -1
- package/bin/adopt.mjs +89 -132
- package/bin/advise.mjs +8 -3
- package/bin/autonomy.mjs +83 -0
- package/bin/baton.mjs +12 -2
- package/bin/cli.mjs +12 -0
- package/bin/crew-runner.mjs +80 -9
- package/bin/crew.sh +240 -11
- package/bin/herdr-agent.mjs +96 -0
- package/bin/integrate.mjs +86 -0
- package/bin/models.mjs +6 -1
- package/bin/seat-why.mjs +27 -0
- package/hooks/lib/handoff.mjs +24 -1
- package/hooks/sessionstart.mjs +39 -8
- package/hub.mjs +95 -4
- package/lib/autonomy.mjs +101 -0
- package/lib/integrate.mjs +94 -0
- package/lib/project.mjs +48 -1
- package/lib/seat-why.mjs +123 -0
- package/mcp.mjs +17 -7
- package/package.json +2 -2
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.12",
|
|
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 —
|
|
2
|
+
// `trantor adopt` — take over a session that is already running in a Terminal.
|
|
3
3
|
//
|
|
4
|
-
//
|
|
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
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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 {
|
|
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
|
-
|
|
45
|
-
const
|
|
46
|
-
|
|
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
|
|
53
|
-
|
|
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
|
-
|
|
56
|
-
const
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
.
|
|
61
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
90
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
98
|
+
} catch { /* nothing running */ }
|
|
131
99
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
}
|
package/bin/advise.mjs
CHANGED
|
@@ -90,14 +90,19 @@ export function loadWorld() {
|
|
|
90
90
|
|
|
91
91
|
const tierOf = (profile, prov) => profile?.providers?.[prov]?.tier || "api";
|
|
92
92
|
|
|
93
|
+
const FLASH_TIER = /(flash|turbo|lite|mini|highspeed|small)/i;
|
|
94
|
+
|
|
93
95
|
// pick the cheapest Scrooge model that clears the difficulty floor for a task kind
|
|
94
96
|
export function scroogeModelFor(registry, caps, kind = "code", difficulty = "easy") {
|
|
95
97
|
const floor = { easy: 0, medium: 35, hard: 55 }[difficulty] ?? 0;
|
|
96
98
|
const cands = Object.entries(registry.models || {})
|
|
97
99
|
.filter(([, m]) => (m.good_for || []).includes(kind))
|
|
98
|
-
.filter(([id]) => (caps[id]?.coding ?? caps[id]?.intelligence ?? 40) >= floor)
|
|
99
|
-
|
|
100
|
-
|
|
100
|
+
.filter(([id]) => (caps[id]?.coding ?? caps[id]?.intelligence ?? 40) >= floor);
|
|
101
|
+
const hard = difficulty === "hard";
|
|
102
|
+
const strong = hard ? cands.filter(([id]) => !FLASH_TIER.test(id)) : cands;
|
|
103
|
+
const pool = strong.length > 0 ? strong : cands;
|
|
104
|
+
pool.sort((a, b) => (a[1].cost_in + a[1].cost_out) - (b[1].cost_in + b[1].cost_out));
|
|
105
|
+
return pool[0] ? { model: pool[0][0], cost_in: pool[0][1].cost_in, cost_out: pool[0][1].cost_out } : null;
|
|
101
106
|
}
|
|
102
107
|
|
|
103
108
|
// crude per-package token forecast (input+output through the executor)
|
package/bin/autonomy.mjs
ADDED
|
@@ -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,8 +30,18 @@ function findTranscript() {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
const transcript = findTranscript();
|
|
33
|
-
|
|
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}`);
|
|
38
|
+
// --write-only: the in-app flow (#5509). The app ends the pane's session itself and reopens it
|
|
39
|
+
// through `trantor open`, which claims this handoff — a Terminal window here would be exactly the
|
|
40
|
+
// wrong surface, so the flag writes, announces, and stops.
|
|
41
|
+
if (process.argv.includes("--write-only")) {
|
|
42
|
+
console.log(`🔄 write-only: no window spawned — the pane takeover (trantor open) claims it next.`);
|
|
43
|
+
process.exit(0);
|
|
44
|
+
}
|
|
35
45
|
const { spawned, armed, windowId } = spawnBaton({ projectDir: cwd, handoffFile: file });
|
|
36
46
|
console.log(spawned
|
|
37
47
|
? `🔄 baton: a fresh session is opening (it'll recap the handoff)${armed ? ` — this window (${windowId}) closes once it takes over` : " — couldn't detect this window; close it yourself once the new one is up"}`
|
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>"
|
package/bin/crew-runner.mjs
CHANGED
|
@@ -44,7 +44,22 @@ function ensureSeatWorktree(sourceDir) {
|
|
|
44
44
|
const branch = `seat/${AGENT}`;
|
|
45
45
|
if (existsSync(seatDir)) {
|
|
46
46
|
const ok = gitOut(["-C", seatDir, "rev-parse", "--is-inside-work-tree"], seatDir) === "true";
|
|
47
|
-
if (ok)
|
|
47
|
+
if (ok) {
|
|
48
|
+
// #5403: a worktree created once builds against THAT day's main forever — every wave since
|
|
49
|
+
// has needed a hand fast-forward. Refresh only when it is CLEAN: a dirty tree is a seat's
|
|
50
|
+
// unintegrated work and a diverged branch is a decision, and refreshing must never eat
|
|
51
|
+
// either. Failure to refresh is loud but non-fatal: stale beats broken.
|
|
52
|
+
const dirty = gitOut(["-C", seatDir, "status", "--porcelain"], seatDir);
|
|
53
|
+
if (dirty === "") {
|
|
54
|
+
const head = gitOut(["-C", root, "rev-parse", "HEAD"], root);
|
|
55
|
+
const ff = head && spawnSync("git", ["-C", seatDir, "merge", "--ff-only", head], { stdio: "ignore", timeout: 15000 });
|
|
56
|
+
if (ff && ff.status === 0) console.log(`\x1b[2m[runner]\x1b[0m ${branch} worktree refreshed to ${head.slice(0, 7)}`);
|
|
57
|
+
else console.log(`\x1b[33m[runner]\x1b[0m ${branch} worktree diverged from main HEAD — left as-is (integrate or reset it)`);
|
|
58
|
+
} else {
|
|
59
|
+
console.log(`\x1b[33m[runner]\x1b[0m ${branch} worktree has uncommitted work — not refreshed`);
|
|
60
|
+
}
|
|
61
|
+
return seatDir;
|
|
62
|
+
}
|
|
48
63
|
console.log(`\x1b[33m[runner]\x1b[0m worktree path exists but is not a git worktree: ${seatDir} — using ${sourceDir}`);
|
|
49
64
|
return sourceDir;
|
|
50
65
|
}
|
|
@@ -150,6 +165,28 @@ function cmuxStatus(value, color, icon = "robot", opts = {}) {
|
|
|
150
165
|
const col = opts.alert ? color : (BRAND_HEX[AGENT.toLowerCase()] || color);
|
|
151
166
|
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
167
|
}
|
|
168
|
+
// herdr drops a pane's agent registration when the process inside it exits — and a seat's CLI
|
|
169
|
+
// exits at the END OF EVERY TURN. Reporting once when the pane is created is therefore not enough:
|
|
170
|
+
// the seat vanishes from `herdr agent list` after its first turn, `herdr agent attach` starts
|
|
171
|
+
// answering agent_not_found, and the app renders that raw error where the terminal should be.
|
|
172
|
+
// Observed 2026-08-27 on codex, which was crash-looping on an exhausted quota.
|
|
173
|
+
//
|
|
174
|
+
// So re-report at every turn boundary, which also gives herdr a truthful working/idle state.
|
|
175
|
+
// NOTE the argument order: the pane id comes FIRST, before the flags.
|
|
176
|
+
function herdrAgent(state) {
|
|
177
|
+
try {
|
|
178
|
+
const f = join(homedir(), ".agent-bus", "crew-windows.txt");
|
|
179
|
+
if (!existsSync(f)) return;
|
|
180
|
+
const row = readFileSync(f, "utf8").split("\n")
|
|
181
|
+
.map(l => l.split("\t"))
|
|
182
|
+
.filter(c => c.length >= 4 && c[0] === PROJ && c[1] === "herdr" && c[2] === AGENT)
|
|
183
|
+
.pop();
|
|
184
|
+
if (!row || !row[3]) return;
|
|
185
|
+
spawnSync("herdr", ["pane", "report-agent", row[3], "--source", "crew", "--agent", AGENT, "--state", state],
|
|
186
|
+
{ stdio: "ignore", timeout: 1500 });
|
|
187
|
+
} catch { /* no herdr, or no row for this seat: the cmux/tmux paths do not need it */ }
|
|
188
|
+
}
|
|
189
|
+
|
|
153
190
|
function cmuxLog(message, level = "info") {
|
|
154
191
|
if (!inCmux()) return;
|
|
155
192
|
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 +258,12 @@ const PULSE_PROMPT = `[pulse] Re-read your mission note (${MISSION_FILE} in your
|
|
|
221
258
|
// every non-zero turn to the bus in real time so the orchestrator (and `trantor swap`)
|
|
222
259
|
// can react, and flip presence to errored/down.
|
|
223
260
|
let consecFails = 0;
|
|
261
|
+
// The failure STATE the room has already been told about. A seat that is down stays down, and
|
|
262
|
+
// saying so again every retry is repetition, not news — the monitoring doctrine this project holds
|
|
263
|
+
// everyone else to says report duration, not repetition. Observed cost: a permanently exhausted
|
|
264
|
+
// codex seat broadcast "DOWN" to `all` 31 times over six hours, and every broadcast is a turn for
|
|
265
|
+
// every live seat, so two working agents spent the evening reading the same sentence.
|
|
266
|
+
let announced = "";
|
|
224
267
|
let lastErrText = "";
|
|
225
268
|
const ERRF = join(homedir(), ".agent-bus", `err-${AGENT}-${PROJ}.txt`);
|
|
226
269
|
|
|
@@ -262,6 +305,12 @@ function loadPending() {
|
|
|
262
305
|
} catch { return { wake: [], bcast: [] }; }
|
|
263
306
|
}
|
|
264
307
|
|
|
308
|
+
// Auth-failure markers in TURN OUTPUT. opencode prints its auth error ("401 Unauthorized" /
|
|
309
|
+
// "Invalid API key") and STILL exits 0, so a bare 0 from the CLI is not proof the turn ran
|
|
310
|
+
// (card #5405). This regex gates the exit-0 path in runTurn; kept tighter than
|
|
311
|
+
// classifyFailure's set (no bare /expired/) so a healthy transcript never trips it.
|
|
312
|
+
const AUTH_MARKER_RE = /unauthor|401|403|forbidden|invalid[ _-]?api[ _-]?key|authentication? failed|token expired/i;
|
|
313
|
+
|
|
265
314
|
function classifyFailure(exit, errText) {
|
|
266
315
|
const t = (errText || "").toLowerCase();
|
|
267
316
|
if (exit === 127) return "missing-cli";
|
|
@@ -287,8 +336,17 @@ async function reportFailure(exit, trigger, undelivered = 0) {
|
|
|
287
336
|
const text = down
|
|
288
337
|
? `🛑 ${SESSION} DOWN — ${consecFails} consecutive failures (${reason}, exit ${exit})${hint}${held}`
|
|
289
338
|
: `⚠️ ${SESSION} turn FAILED (${trigger}, exit ${exit} · ${reason})${hint}${held}`;
|
|
290
|
-
|
|
291
|
-
|
|
339
|
+
// Announce a CHANGE of state, never the continuation of one. The registered status above already
|
|
340
|
+
// carries "down: exhausted · N fails" for anyone who looks, which is state and costs nobody a
|
|
341
|
+
// turn; the broadcast is the event, and an unchanged state is not an event.
|
|
342
|
+
const state = `${down ? "down" : "error"}:${reason}`;
|
|
343
|
+
if (state !== announced) {
|
|
344
|
+
announced = state;
|
|
345
|
+
await api("/send", { from: SESSION, to: "all", text, project: PROJ }).catch(() => {});
|
|
346
|
+
} else {
|
|
347
|
+
log(`still ${state} (${consecFails} fails) — already announced, staying quiet`);
|
|
348
|
+
}
|
|
349
|
+
cmuxStatus(down ? "down" : "error", "#ef6a6a", "alert", { alert: true, priority: 90 }); herdrAgent("blocked"); cmuxLog(`turn failed: ${reason} (exit ${exit})`, "error");
|
|
292
350
|
log(`\x1b[31mreported failure to bus: ${reason} (exit ${exit})\x1b[0m`);
|
|
293
351
|
}
|
|
294
352
|
|
|
@@ -322,9 +380,11 @@ async function notifyAssigners(pairs, text) {
|
|
|
322
380
|
async function reportHealthy() {
|
|
323
381
|
if (consecFails === 0) return; // already healthy — don't spam
|
|
324
382
|
consecFails = 0;
|
|
383
|
+
// Recovery is a change too, so the next failure is news again.
|
|
384
|
+
announced = "";
|
|
325
385
|
await api("/register", { session: SESSION, project: PROJ, status: `active in ${PROJ}`, llm: AGENT, model: MODEL }).catch(() => {});
|
|
326
386
|
await api("/send", { from: SESSION, to: "all", text: `✅ ${SESSION} recovered`, project: PROJ }).catch(() => {});
|
|
327
|
-
cmuxStatus("ok", "#14b8a6", "check");
|
|
387
|
+
cmuxStatus("ok", "#14b8a6", "check"); herdrAgent("idle");
|
|
328
388
|
}
|
|
329
389
|
|
|
330
390
|
let sid = "";
|
|
@@ -348,7 +408,7 @@ function runTurn(prompt, isFirst, trigger = "kickoff") {
|
|
|
348
408
|
const envs = [join(homedir(), ".agent-bus", ".env"), cli.env].filter(f => f && existsSync(f));
|
|
349
409
|
cmd = withEnvFiles(cmd, envs);
|
|
350
410
|
log(`turn starting (${isFirst ? "fresh session" : "resume"})${MODEL ? ` · model=${MODEL}` : ""}`);
|
|
351
|
-
cmuxStatus("building", "#4a90d9", "hammer", { priority: 50 });
|
|
411
|
+
cmuxStatus("building", "#4a90d9", "hammer", { priority: 50 }); herdrAgent("working");
|
|
352
412
|
// inherit stdio so the window shows the agent working live; also capture for sid-parsing.
|
|
353
413
|
// Tee stderr to ERRF (still shown live in the window) so a failed turn can be classified.
|
|
354
414
|
try { appendFileSync(ERRF, "", { flag: "w" }); } catch {}
|
|
@@ -378,10 +438,21 @@ function runTurn(prompt, isFirst, trigger = "kickoff") {
|
|
|
378
438
|
});
|
|
379
439
|
try { lastErrText = readFileSync(ERRF, "utf8").slice(-4000); } catch { lastErrText = ""; }
|
|
380
440
|
if (cli.sid && r.stdout) { const m = r.stdout.match(cli.sid); if (m) sid = m[1]; }
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
441
|
+
const realExit = r.status;
|
|
442
|
+
// A zero exit is NOT proof the turn ran: opencode prints "401 Unauthorized" / "Invalid API key"
|
|
443
|
+
// and exits 0, so a bare 0 made the runner ack "✅ done", clear the pending queue and heartbeat
|
|
444
|
+
// green through an auth outage (card #5405). Cross-check the turn output and treat an
|
|
445
|
+
// exit-0-with-auth turn as FAILED. Telemetry keeps the REAL exit; the returned code is the
|
|
446
|
+
// effective one every call site branches on (kickoff, pulse, deliverWake).
|
|
447
|
+
let effExit = realExit;
|
|
448
|
+
if (realExit === 0 && AUTH_MARKER_RE.test(lastErrText)) {
|
|
449
|
+
effExit = 1;
|
|
450
|
+
log("\x1b[31mexit 0 but turn output shows an auth failure — treating as FAILED (auth)\x1b[0m");
|
|
451
|
+
}
|
|
452
|
+
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 });
|
|
453
|
+
log(`turn ended (exit ${realExit}${effExit !== realExit ? ` → effective ${effExit} (auth)` : ""}, ${((Date.now() - t0) / 1000).toFixed(0)}s)`);
|
|
454
|
+
if (realExit === 0 && effExit === 0) { cmuxStatus("idle", "#8a94a6", "robot"); herdrAgent("idle"); } // finished this turn, waiting for the next
|
|
455
|
+
return effExit;
|
|
385
456
|
}
|
|
386
457
|
|
|
387
458
|
// ---- main loop ----
|