trantor 0.18.5 → 0.18.7
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/bin/cli.mjs +31 -3
- package/bin/crew-runner.mjs +12 -1
- package/bin/duty.mjs +75 -4
- package/package.json +1 -1
- package/skills/crew/SKILL.md +5 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.7",
|
|
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/bin/cli.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { spawn } from "node:child_process";
|
|
5
5
|
import { join, dirname } from "node:path";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
|
-
import { readFileSync } from "node:fs";
|
|
7
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
8
8
|
|
|
9
9
|
const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
10
10
|
const [, , cmd, ...args] = process.argv;
|
|
@@ -138,9 +138,37 @@ switch (cmd) {
|
|
|
138
138
|
else console.error(`Enrollment failed: ${j.error || r.statusText}`);
|
|
139
139
|
break;
|
|
140
140
|
}
|
|
141
|
+
// A BROWSER CANNOT READ AN ENFORCE-MODE HUB. The page itself is served (200), but every data
|
|
142
|
+
// endpoint answers 401 to an unsigned request — /projects, /tasks, /peers — because a browser has
|
|
143
|
+
// no keypair and nothing in a webview can safely hold one. So the dashboard renders as an empty
|
|
144
|
+
// shell with no projects, which reads as "the hub is broken" when the hub is fine and refusing
|
|
145
|
+
// correctly. That is exactly what happened to a crew launch on 2026-08-26.
|
|
146
|
+
//
|
|
147
|
+
// The desktop app is the surface that works: it signs every request in Rust, which is also why
|
|
148
|
+
// the webview never touches a key. Prefer it, and when it is missing say plainly why the browser
|
|
149
|
+
// will look empty rather than opening one and letting the operator draw the wrong conclusion.
|
|
141
150
|
case "ui": {
|
|
142
|
-
|
|
143
|
-
|
|
151
|
+
const { resolveHubInfo } = await import(join(ROOT, "lib/project.mjs"));
|
|
152
|
+
const { resolveProject } = await import(join(ROOT, "lib/project.mjs"));
|
|
153
|
+
const url = resolveHubInfo(resolveProject(process.cwd())).url;
|
|
154
|
+
|
|
155
|
+
let authMode = "";
|
|
156
|
+
try {
|
|
157
|
+
const r = await fetch(`${url}/health`, { signal: AbortSignal.timeout(5000) });
|
|
158
|
+
authMode = String((await r.json()).authMode || "");
|
|
159
|
+
} catch { /* hub unreachable — fall through and let the browser show that */ }
|
|
160
|
+
|
|
161
|
+
const hasApp = process.platform === "darwin" && existsSync("/Applications/Trantor.app");
|
|
162
|
+
if (authMode === "enforce" && hasApp) {
|
|
163
|
+
spawn("open", ["-a", "Trantor"], { stdio: "ignore", detached: true }).unref();
|
|
164
|
+
console.log(`dashboard → Trantor.app (${url} is auth:enforce — a browser cannot sign, so it would show an empty board)`);
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
if (authMode === "enforce") {
|
|
168
|
+
console.log(`⚠ ${url} is auth:enforce. A browser cannot sign its requests, so the board will be EMPTY —`);
|
|
169
|
+
console.log(` every data endpoint returns 401. Install the desktop app instead: trantor app install`);
|
|
170
|
+
console.log(` Opening it anyway so you can see for yourself…`);
|
|
171
|
+
}
|
|
144
172
|
spawn(process.platform === "darwin" ? "open" : "xdg-open", [url], { stdio: "ignore", detached: true }).unref();
|
|
145
173
|
console.log(`dashboard → ${url}`);
|
|
146
174
|
break;
|
package/bin/crew-runner.mjs
CHANGED
|
@@ -326,7 +326,18 @@ function runTurn(prompt, isFirst, trigger = "kickoff") {
|
|
|
326
326
|
const inner = cli.sid ? `${cmd} | tee /dev/stderr` : `${cmd} | tee -a ${ERRF}`;
|
|
327
327
|
const r = spawnSync("/bin/bash", ["-c", `set -o pipefail; { ${inner} ; } 2> >(tee -a ${ERRF} >&2)`], {
|
|
328
328
|
cwd: DIR, encoding: "utf8", stdio: cli.sid ? ["ignore", "pipe", "inherit"] : "inherit",
|
|
329
|
-
env: { ...process.env, RELAY_URL: HUB, RELAY_AGENT: AGENT, RELAY_PROJECT: PROJ
|
|
329
|
+
env: { ...process.env, RELAY_URL: HUB, RELAY_AGENT: AGENT, RELAY_PROJECT: PROJ,
|
|
330
|
+
// A RUNNER-MANAGED SEAT MUST NEVER HAND ITSELF A BATON.
|
|
331
|
+
//
|
|
332
|
+
// The handoff machinery exists for an INTERACTIVE session: near its context limit it writes a
|
|
333
|
+
// handoff and opens a fresh window to carry on. A seat has no use for that — the runner is its
|
|
334
|
+
// lifecycle manager and wakes it per event — so the spawn just leaks an unmanaged interactive
|
|
335
|
+
// session into a window nobody asked for.
|
|
336
|
+
//
|
|
337
|
+
// Observed on the duty seat: handoff records at 17:24 and 18:59 on 2026-08-24, and two stray
|
|
338
|
+
// `claude` processes in ~/.agent-bus/trantor-duty started at 17:24:57 and 18:59:50, still
|
|
339
|
+
// sitting there days later. To the operator that reads as "why are there two duty agents".
|
|
340
|
+
TRANTOR_NO_HANDOFF_SPAWN: "1", TRANTOR_NO_BATON_SPAWN: "1" },
|
|
330
341
|
maxBuffer: 16 * 1024 * 1024,
|
|
331
342
|
});
|
|
332
343
|
try { lastErrText = readFileSync(ERRF, "utf8").slice(-4000); } catch { lastErrText = ""; }
|
package/bin/duty.mjs
CHANGED
|
@@ -116,6 +116,47 @@ function alivePid() {
|
|
|
116
116
|
return 0;
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
+
// The cmux workspace this seat owns. One name, so `up` can find and replace its predecessor and
|
|
120
|
+
// `down` can take the surface away with the process.
|
|
121
|
+
const CMUX_WS_NAME = "trantor-duty";
|
|
122
|
+
|
|
123
|
+
// Surface override, same variable and same values bin/crew.sh already uses for crew seats:
|
|
124
|
+
// CREW_MUX=terminal force a Terminal window (what the window-content drills assert on)
|
|
125
|
+
// CREW_MUX=cmux require cmux
|
|
126
|
+
// unset / anything else = auto: cmux when it answers, Terminal otherwise.
|
|
127
|
+
const SURFACE = String(process.env.CREW_MUX || "auto").toLowerCase();
|
|
128
|
+
|
|
129
|
+
function cmuxBinary() {
|
|
130
|
+
if (SURFACE === "terminal") return "";
|
|
131
|
+
for (const c of ["cmux", "/Applications/cmux.app/Contents/Resources/bin/cmux"]) {
|
|
132
|
+
try { execSync(`${c} ping`, { stdio: "ignore", timeout: 3000 }); return c; } catch {}
|
|
133
|
+
}
|
|
134
|
+
return "";
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Close every workspace this seat owns. No-op when cmux is absent or its socket is off. */
|
|
138
|
+
function closeDutyWorkspace() {
|
|
139
|
+
const bin = cmuxBinary();
|
|
140
|
+
if (!bin) return 0;
|
|
141
|
+
let closed = 0;
|
|
142
|
+
try {
|
|
143
|
+
const listed = JSON.parse(execSync(`${bin} workspace list --id-format both --json`,
|
|
144
|
+
{ encoding: "utf8", timeout: 5000, env: { ...process.env, CMUX_QUIET: "1" } }));
|
|
145
|
+
for (const w of listed.workspaces || []) {
|
|
146
|
+
const title = w.custom_title || w.title || "";
|
|
147
|
+
// Title AND directory. Matching on title alone makes this global: a duty instance running
|
|
148
|
+
// with a temp HOME (which is exactly what test-duty-seat.mjs does) would close the REAL
|
|
149
|
+
// seat's workspace and take the production seat down with it. That happened once, on
|
|
150
|
+
// 2026-08-26, and the operator found their duty agent simply gone. Only ever close a
|
|
151
|
+
// workspace that belongs to THIS seat's bus directory.
|
|
152
|
+
if (title === CMUX_WS_NAME && w.id && w.current_directory === DIR) {
|
|
153
|
+
try { execSync(`${bin} close-workspace --workspace ${w.id}`, { stdio: "ignore", timeout: 5000 }); closed++; } catch {}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
} catch {}
|
|
157
|
+
return closed;
|
|
158
|
+
}
|
|
159
|
+
|
|
119
160
|
if (cmd === "up") {
|
|
120
161
|
const hub = fleetHub();
|
|
121
162
|
mkdirSync(DIR, { recursive: true });
|
|
@@ -144,9 +185,35 @@ if (cmd === "up") {
|
|
|
144
185
|
const exports = Object.entries(env).map(([k, v]) =>
|
|
145
186
|
`export ${k}=$(cat <<'TRANTOR_${k}_EOF'\n${v}\nTRANTOR_${k}_EOF\n)`).join("\n");
|
|
146
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 });
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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.
|
|
195
|
+
const cmuxBin = cmuxBinary();
|
|
196
|
+
|
|
197
|
+
let openedInCmux = false;
|
|
198
|
+
if (cmuxBin) {
|
|
199
|
+
try {
|
|
200
|
+
// Replace, never stack: take the previous duty workspace away before opening this one.
|
|
201
|
+
// Closing first is safe here (unlike a crew pane swap) — the seat is a single surface with
|
|
202
|
+
// nothing to preserve.
|
|
203
|
+
closeDutyWorkspace();
|
|
204
|
+
execSync(`${cmuxBin} new-workspace --name ${JSON.stringify(CMUX_WS_NAME)} --cwd ${JSON.stringify(DIR)} --command ${JSON.stringify(`bash ${launcher}`)} --focus false`,
|
|
205
|
+
{ stdio: "ignore", timeout: 8000, env: { ...process.env, CMUX_QUIET: "1" } });
|
|
206
|
+
openedInCmux = true;
|
|
207
|
+
} catch (e) {
|
|
208
|
+
console.error(`cmux launch failed (${e?.message || e}) — falling back to a Terminal window`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
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`); }
|
|
216
|
+
}
|
|
150
217
|
// The runner lives inside Terminal, so its pid is not ours to know: find it the same way `down`
|
|
151
218
|
// does. Poll briefly, since Terminal takes a moment to start the shell.
|
|
152
219
|
for (let i = 0; i < 25 && !pid; i++) {
|
|
@@ -163,7 +230,7 @@ if (cmd === "up") {
|
|
|
163
230
|
pid = child.pid;
|
|
164
231
|
}
|
|
165
232
|
writeFileSync(PIDF, String(pid));
|
|
166
|
-
console.log(`— duty agent up: ${SESSION} (pid ${pid})${WINDOW ? " in a
|
|
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}`);
|
|
167
234
|
const fed = await registerDutySeat(hub, SESSION);
|
|
168
235
|
if (fed) console.log(` hub feeds it: undelivered DMs (>${Math.round(Number(process.env.RELAY_DUTY_UNDELIVERED_MS || 600000) / 60000)}m) + overseer warnings.`);
|
|
169
236
|
process.exit(0); // the seat IS up; a hub that won't feed it is a warning, not a failed start
|
|
@@ -174,6 +241,10 @@ if (cmd === "down") {
|
|
|
174
241
|
if (pid) { try { process.kill(pid); } catch {} console.log(`— duty seat stopped (pid ${pid}) —`); }
|
|
175
242
|
else console.log("no duty seat running");
|
|
176
243
|
try { execSync(`pkill -f "crew-runner.mjs ${AGENT} ${DIR}"`, { stdio: "ignore" }); } catch {}
|
|
244
|
+
// Close the seat's cmux workspace too. Killing the process leaves the surface behind, and a dead
|
|
245
|
+
// pane titled trantor-duty is indistinguishable from a live one at a glance — which is the exact
|
|
246
|
+
// confusion this whole change is about.
|
|
247
|
+
closeDutyWorkspace();
|
|
177
248
|
try { rmSync(PIDF, { force: true }); } catch {}
|
|
178
249
|
// Clear the hub's pointer too — escalations aimed at a seat that no longer exists are messages
|
|
179
250
|
// sent into a hole, and the hub has no other way to learn the seat went away.
|
package/package.json
CHANGED
package/skills/crew/SKILL.md
CHANGED
|
@@ -36,7 +36,11 @@ explicit EVENT/INTERFACE CONTRACT — cross-agent bugs come from contract drift.
|
|
|
36
36
|
2. One card per package: `relay_task_add(title, assignee, difficulty, model)` — set `model`
|
|
37
37
|
to the advisor-routed model (or the CLI's default name); difficulty + model show as badges
|
|
38
38
|
on the card. Assignees: `codex:<project>` etc. Keep one for yourself.
|
|
39
|
-
3. Open the dashboard:
|
|
39
|
+
3. Open the dashboard: **`trantor ui`** — which opens the **desktop app**, not a browser.
|
|
40
|
+
Do NOT open the hub URL in a browser. A remote hub runs `auth:enforce`, and a browser cannot
|
|
41
|
+
sign its requests: the page loads but `/projects`, `/tasks` and `/peers` all return 401, so the
|
|
42
|
+
board renders EMPTY. That looks like a broken hub and is not one. The desktop app signs every
|
|
43
|
+
request natively, which is the whole reason it exists. If it is missing: `trantor app install`.
|
|
40
44
|
|
|
41
45
|
## Phase 2 — fire up the crew (with the Advisor's models)
|
|
42
46
|
**Each crew card from `relay_advise` carries a `launch` spec — run it VERBATIM; never invent a
|