trantor 0.18.31 → 0.18.33
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/baton.mjs +53 -25
- package/bin/connect.mjs +11 -1
- package/bin/crew-runner.mjs +80 -16
- package/bin/crew.sh +44 -11
- package/bin/new.mjs +8 -2
- package/bin/write-handoff.mjs +9 -3
- package/deploy/restart-hub.sh +50 -0
- package/deploy/setup-netcup.sh +7 -2
- package/hooks/lib/api.mjs +2 -2
- package/hooks/lib/handoff.mjs +78 -7
- package/hooks/sessionstart.mjs +9 -2
- package/hooks/stop-inbox.mjs +41 -4
- package/hub.mjs +190 -24
- package/lib/classify-failure.mjs +15 -4
- package/lib/enroll.mjs +2 -2
- package/lib/persist-health.mjs +56 -0
- package/lib/same-project.mjs +65 -0
- package/lib/store-pg.mjs +20 -2
- package/mcp.mjs +5 -3
- package/package.json +2 -2
- package/skills/handoff/SKILL.md +14 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.33",
|
|
4
4
|
"description": "Trantor — 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/baton.mjs
CHANGED
|
@@ -1,16 +1,42 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// `trantor handoff` — one-command manual baton
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
import { readdirSync, statSync } from "node:fs";
|
|
7
|
-
import { join, basename } from "node:path";
|
|
2
|
+
// `trantor handoff` — one-command manual baton. Discovers the current session's transcript, writes
|
|
3
|
+
// a whole-session handoff (auto-summary + verbatim in-flight tail), opens a fresh self-announcing
|
|
4
|
+
// session, and closes THIS window once it takes over. Run from inside the session you want to hand
|
|
5
|
+
// off. (The richer MODEL-authored handoff is the /trantor:handoff skill.)
|
|
6
|
+
import { readdirSync, statSync, fstatSync } from "node:fs";
|
|
7
|
+
import { join, basename, dirname } from "node:path";
|
|
8
8
|
import { homedir } from "node:os";
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
9
|
+
import { spawn } from "node:child_process";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { writeHandoff, spawnBaton, resolveHandoffSurface } from "../hooks/lib/handoff.mjs";
|
|
11
12
|
|
|
13
|
+
// #6074: the skill path (write-handoff.mjs) and this CLI path must share ONE resolution of which
|
|
14
|
+
// project this is and where the session lives. Both call resolveHandoffSurface; the name comes
|
|
15
|
+
// from the session's registration (TRANTOR_ORCH / RELAY_PROJECT / orch-sessions.txt) before the
|
|
16
|
+
// cwd — a subfolder cwd never renames the project.
|
|
12
17
|
const cwd = process.cwd();
|
|
13
|
-
const
|
|
18
|
+
const resolved = resolveHandoffSurface({ projectDir: process.env.CLAUDE_PROJECT_DIR || cwd, sessionId: process.env.CLAUDE_SESSION_ID || "" });
|
|
19
|
+
const project = resolved.project;
|
|
20
|
+
|
|
21
|
+
// Model-authored handoffs ride THIS binary too (`trantor handoff` — always the global install's
|
|
22
|
+
// CURRENT code, never the plugin-cache copy a session booted with, the stale-0.18.20 bug). A piped
|
|
23
|
+
// stdin (a heredoc, a cat, `<<HANDOFF`) means "here is the handoff markdown" — exactly the skill's
|
|
24
|
+
// contract — so forward to write-handoff.mjs in the SAME package (same version, same resolution)
|
|
25
|
+
// and exit with its status. --latest forwards too. A true pipe is a FIFO; a TTY and /dev/null are
|
|
26
|
+
// character devices, so a plain `trantor handoff` typed at a prompt (or run by a hook with stdin
|
|
27
|
+
// at /dev/null) keeps the auto-summary behavior. Detection must NOT be `!process.stdin.isTTY` —
|
|
28
|
+
// that misreads /dev/null as a handoff and errors where auto-summary used to work.
|
|
29
|
+
function stdinIsPipe() {
|
|
30
|
+
try { return fstatSync(0).isFIFO(); } catch { return false; }
|
|
31
|
+
}
|
|
32
|
+
if (stdinIsPipe() || process.argv.includes("--latest")) {
|
|
33
|
+
const helper = join(dirname(fileURLToPath(import.meta.url)), "write-handoff.mjs");
|
|
34
|
+
const child = spawn(process.execPath, [helper, ...process.argv.slice(2)], { stdio: ["inherit", "inherit", "inherit"] });
|
|
35
|
+
child.on("exit", (c) => process.exit(c ?? 1));
|
|
36
|
+
child.on("error", () => process.exit(1));
|
|
37
|
+
} else {
|
|
38
|
+
autoBaton();
|
|
39
|
+
}
|
|
14
40
|
|
|
15
41
|
// The active session's transcript = newest *.jsonl directly in this project's Claude dir
|
|
16
42
|
// (~/.claude/projects/<cwd-with-slashes-as-dashes>/), excluding the subagents/ subtree.
|
|
@@ -29,20 +55,22 @@ function findTranscript() {
|
|
|
29
55
|
return best;
|
|
30
56
|
}
|
|
31
57
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
//
|
|
35
|
-
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
58
|
+
function autoBaton() {
|
|
59
|
+
const transcript = findTranscript();
|
|
60
|
+
// The transcript's filename IS the writing session's id — record it, or an orchestrator-thread
|
|
61
|
+
// handoff carries no writer and the baton-hold + map-follow logic in sessionstart.mjs can't fire.
|
|
62
|
+
const sessionId = transcript ? basename(transcript, ".jsonl") : "";
|
|
63
|
+
const { file } = writeHandoff({ projectDir: cwd, sessionId, transcript, trigger: "manual-cli", force: true, projectName: project }); // manual = intentional, bypass the storm guard
|
|
64
|
+
console.log(`📋 handoff saved for ${project}: ${file}`);
|
|
65
|
+
// --write-only: the in-app flow (#5509). The app ends the pane's session itself and reopens it
|
|
66
|
+
// through `trantor open`, which claims this handoff — a Terminal window here would be exactly the
|
|
67
|
+
// wrong surface, so the flag writes, announces, and stops.
|
|
68
|
+
if (process.argv.includes("--write-only")) {
|
|
69
|
+
console.log(`🔄 write-only: no window spawned — the pane takeover (trantor open) claims it next.`);
|
|
70
|
+
process.exit(0);
|
|
71
|
+
}
|
|
72
|
+
const { spawned, armed, windowId } = spawnBaton({ projectDir: cwd, handoffFile: file });
|
|
73
|
+
console.log(spawned
|
|
74
|
+
? `🔄 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"}`
|
|
75
|
+
: `handoff saved, but couldn't spawn a fresh session (non-macOS or spawn disabled) — open a new session here to take over`);
|
|
44
76
|
}
|
|
45
|
-
const { spawned, armed, windowId } = spawnBaton({ projectDir: cwd, handoffFile: file });
|
|
46
|
-
console.log(spawned
|
|
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"}`
|
|
48
|
-
: `handoff saved, but couldn't spawn a fresh session (non-macOS or spawn disabled) — open a new session here to take over`);
|
package/bin/connect.mjs
CHANGED
|
@@ -40,6 +40,10 @@ function patchJson(path, mutate) {
|
|
|
40
40
|
// while its runner sat on the pinned one — the residual split-brain mechanism (2026-08-20).
|
|
41
41
|
// mcp.mjs resolves the hub from the session's project pin; that resolution must stay in charge.
|
|
42
42
|
const relayEnv = (agent) => ({ RELAY_AGENT: agent });
|
|
43
|
+
// OpenCode hosts several differently-named seats. Its global MCP environment must not stamp all
|
|
44
|
+
// of them "opencode": ambient runner identity wins, while this fallback names a normal interactive
|
|
45
|
+
// OpenCode session that has no RELAY_AGENT/RELAY_SESSION of its own.
|
|
46
|
+
const hostedRelayEnv = (agent) => ({ RELAY_AGENT_FALLBACK: agent });
|
|
43
47
|
|
|
44
48
|
// ---- Claude Code: plugin handles it; verify only ----
|
|
45
49
|
if (has("claude")) {
|
|
@@ -87,7 +91,13 @@ if (has("opencode")) {
|
|
|
87
91
|
report("opencode", patchJson(p, d => {
|
|
88
92
|
d.$schema ||= "https://opencode.ai/config.json";
|
|
89
93
|
d.mcp ||= {};
|
|
90
|
-
d.mcp.relay ||= { type: "local", command: ["node", MCP], enabled: true
|
|
94
|
+
d.mcp.relay ||= { type: "local", command: ["node", MCP], enabled: true };
|
|
95
|
+
d.mcp.relay.environment ||= {};
|
|
96
|
+
// Migrate the old generated pin too: `||=` alone left RELAY_AGENT=opencode in every existing
|
|
97
|
+
// config forever, where OpenCode overlaid it on the qwen/glm/deepseek runner environment.
|
|
98
|
+
delete d.mcp.relay.environment.RELAY_AGENT;
|
|
99
|
+
delete d.mcp.relay.environment.RELAY_SESSION;
|
|
100
|
+
Object.assign(d.mcp.relay.environment, hostedRelayEnv("opencode"));
|
|
91
101
|
}), p);
|
|
92
102
|
}
|
|
93
103
|
|
package/bin/crew-runner.mjs
CHANGED
|
@@ -358,7 +358,7 @@ async function reportFailure(exit, trigger, undelivered = 0) {
|
|
|
358
358
|
const reason = classify(exit);
|
|
359
359
|
const down = consecFails >= 2;
|
|
360
360
|
const status = down ? `down: ${reason} · ${consecFails} fails` : `errored: ${reason}`;
|
|
361
|
-
await api("/register", { session: SESSION, project: PROJ, status, llm: AGENT, model: MODEL }).catch(() => {});
|
|
361
|
+
await api("/register", { session: SESSION, project: PROJ, status, llm: AGENT, model: MODEL, kind: "agent" }).catch(() => {});
|
|
362
362
|
const hint = reason === "exhausted" ? " — needs `trantor swap`"
|
|
363
363
|
: reason === "auth" ? " — check credentials"
|
|
364
364
|
: reason === "backend-error" ? " — provider backend error (NOT quota): retry, or `trantor swap` to another provider"
|
|
@@ -382,13 +382,13 @@ async function reportFailure(exit, trigger, undelivered = 0) {
|
|
|
382
382
|
const state = `${down ? "down" : "error"}:${reason}`;
|
|
383
383
|
if (state !== announced) {
|
|
384
384
|
announced = state;
|
|
385
|
-
await api("/send", { from: SESSION, to: "all", text, project: PROJ }).catch(() => {});
|
|
385
|
+
await api("/send", { from: SESSION, to: "all", text, project: PROJ, kind: "status" }).catch(() => {});
|
|
386
386
|
// #5684: a broadcast does not wake anyone — the incident is the operator spotting dead seats
|
|
387
387
|
// before the foreman did, twice in one morning. The same state-change event now goes DIRECT
|
|
388
388
|
// to the project's orchestrator (direct = wake), gated identically so a standing outage says
|
|
389
389
|
// it once. A seat that IS the orchestrator's own runner has nobody above it to wake.
|
|
390
390
|
const orch = `${hostId()}:${PROJ}`;
|
|
391
|
-
if (orch !== SESSION) await api("/send", { from: SESSION, to: orch, text, project: PROJ }).catch(() => {});
|
|
391
|
+
if (orch !== SESSION) await api("/send", { from: SESSION, to: orch, text, project: PROJ, kind: "alert" }).catch(() => {});
|
|
392
392
|
} else {
|
|
393
393
|
log(`still ${state} (${consecFails} fails) — already announced, staying quiet`);
|
|
394
394
|
}
|
|
@@ -434,7 +434,7 @@ async function notifyAssigners(pairs, text) {
|
|
|
434
434
|
seen.add(f);
|
|
435
435
|
// `re` threads this outcome to the exact contract it answers, so the sender's ledger closes the
|
|
436
436
|
// right one instead of guessing from timing.
|
|
437
|
-
const payload = { from: SESSION, to: f, text: text.slice(0, 280), project: PROJ };
|
|
437
|
+
const payload = { from: SESSION, to: f, text: text.slice(0, 280), project: PROJ, kind: "receipt" };
|
|
438
438
|
if (id) payload.re = id;
|
|
439
439
|
await api("/send", payload).catch(() => {});
|
|
440
440
|
}
|
|
@@ -446,8 +446,8 @@ async function reportHealthy() {
|
|
|
446
446
|
consecFails = 0;
|
|
447
447
|
// Recovery is a change too, so the next failure is news again.
|
|
448
448
|
announced = "";
|
|
449
|
-
await api("/register", { session: SESSION, project: PROJ, status: `active in ${PROJ}`, llm: AGENT, model: MODEL }).catch(() => {});
|
|
450
|
-
await api("/send", { from: SESSION, to: "all", text: `✅ ${SESSION} recovered`, project: PROJ }).catch(() => {});
|
|
449
|
+
await api("/register", { session: SESSION, project: PROJ, status: `active in ${PROJ}`, llm: AGENT, model: MODEL, kind: "agent" }).catch(() => {});
|
|
450
|
+
await api("/send", { from: SESSION, to: "all", text: `✅ ${SESSION} recovered`, project: PROJ, kind: "status" }).catch(() => {});
|
|
451
451
|
cmuxStatus("ok", "#14b8a6", "check"); herdrAgent("idle");
|
|
452
452
|
}
|
|
453
453
|
|
|
@@ -512,7 +512,7 @@ async function runTurn(prompt, isFirst, trigger = "kickoff") {
|
|
|
512
512
|
} catch {}
|
|
513
513
|
const r = spawnSync("/bin/bash", ["-c", `set -o pipefail; { ${inner} ; } 2> >(${SCRUB} --tee2 ${ERRF})`], {
|
|
514
514
|
cwd: TURN_DIR, encoding: "utf8", stdio: cli.sid ? ["ignore", "pipe", "inherit"] : "inherit",
|
|
515
|
-
env: { ...process.env, RELAY_URL: HUB, RELAY_AGENT: AGENT, RELAY_PROJECT: PROJ,
|
|
515
|
+
env: { ...process.env, RELAY_URL: HUB, RELAY_AGENT: AGENT, RELAY_SESSION: SESSION, RELAY_PROJECT: PROJ,
|
|
516
516
|
// A RUNNER-MANAGED SEAT MUST NEVER HAND ITSELF A BATON.
|
|
517
517
|
//
|
|
518
518
|
// The handoff machinery exists for an INTERACTIVE session: near its context limit it writes a
|
|
@@ -617,12 +617,67 @@ function composedTurn({ base = "", wakeText = "", ctxText = "", againText = "",
|
|
|
617
617
|
return built.prompt;
|
|
618
618
|
}
|
|
619
619
|
|
|
620
|
+
const RECEIPT_MARKER = "✅ done on";
|
|
621
|
+
const CARD_REF_RE = /#\d{1,7}(?!\d)/;
|
|
622
|
+
|
|
623
|
+
// Runner-authored metadata is bus state, not work. Typed messages are authoritative; `re` and the
|
|
624
|
+
// stable text marker keep a mixed-version crew safe while older runners are still on the bus.
|
|
625
|
+
function isReceipt(message) {
|
|
626
|
+
const text = String(message?.text || "").trimStart();
|
|
627
|
+
return message?.kind === "receipt" || Number(message?.re) > 0 || text.startsWith(RECEIPT_MARKER);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function isStatusBroadcast(message) {
|
|
631
|
+
if (message?.to !== "all") return false;
|
|
632
|
+
if (message?.kind === "status") return true;
|
|
633
|
+
const text = String(message?.text || "").trim();
|
|
634
|
+
return /^[A-Za-z0-9_.-]+ reporting — ready for a contract\b/.test(text)
|
|
635
|
+
|| /^[✅⚠️🛑]\s+\S+\s+(?:recovered|turn FAILED|DOWN)\b/.test(text);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function isContract(message) {
|
|
639
|
+
const text = String(message?.text || "");
|
|
640
|
+
return message?.kind === "contract" || /^\s*contract\s*:/i.test(text) || CARD_REF_RE.test(text);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function isRunnerSession(session) {
|
|
644
|
+
const suffix = `:${PROJ}`;
|
|
645
|
+
const name = String(session || "");
|
|
646
|
+
if (!name.endsWith(suffix)) return false;
|
|
647
|
+
const label = name.slice(0, -suffix.length);
|
|
648
|
+
// Crew labels are CLI/provider slugs. Host sessions keep their machine-style identity and remain
|
|
649
|
+
// valid direct assigners; runner-to-runner prose needs `contract:` or a card reference.
|
|
650
|
+
return /^[a-z0-9_.-]+$/.test(label) && !label.startsWith("hub:");
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function shouldWake(message) {
|
|
654
|
+
if (isReceipt(message) || isStatusBroadcast(message)) return false;
|
|
655
|
+
if (message?.to === SESSION) {
|
|
656
|
+
if (message?.kind === "status") return false;
|
|
657
|
+
return !isRunnerSession(message?.from) || isContract(message);
|
|
658
|
+
}
|
|
659
|
+
return message?.to === "all"
|
|
660
|
+
&& isContract(message)
|
|
661
|
+
&& (message.text.includes(`@${AGENT}`) || message.text.toLowerCase().includes(`${AGENT}:`));
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function askedExcerpt(message) {
|
|
665
|
+
let text = String(message?.text || "").replace(/\s+/g, " ").trim();
|
|
666
|
+
const nested = text.search(/\s+[·|]\s*asked\s*:/i);
|
|
667
|
+
if (nested >= 0) text = text.slice(0, nested).trim();
|
|
668
|
+
return text.slice(0, 120);
|
|
669
|
+
}
|
|
670
|
+
|
|
620
671
|
(async () => {
|
|
621
672
|
await loadLessons();
|
|
622
673
|
// start cursor at the CURRENT tip so we don't replay history
|
|
623
674
|
let cursor = 0;
|
|
624
675
|
try { const r = await api(`/inbox?session=${encodeURIComponent(SESSION)}&since=0`); cursor = r.cursor || 0; } catch {}
|
|
625
|
-
|
|
676
|
+
// kind "agent" on every beat (#6075): the peer row's kind is the hub's OWN record of what a
|
|
677
|
+
// session is — the overseer's declared-crew exemption reads it, and on the remote hub there is
|
|
678
|
+
// no crew-windows.txt to fall back to. /register preserves absent fields, so a seat running an
|
|
679
|
+
// older runner never loses a kind an updated one stamped.
|
|
680
|
+
await api("/register", { session: SESSION, project: PROJ, status: "crew member booting", llm: AGENT, model: MODEL, kind: "agent" }).catch(() => {});
|
|
626
681
|
// Announce runner-side, signed as THIS seat. Asking the seat to announce itself sent glm's hello
|
|
627
682
|
// out under deepseek's identity whenever opencode seats shared one MCP daemon (lesson on the bus,
|
|
628
683
|
// 2026-07-29): the runner process is per-seat by construction, so its signature cannot be borrowed.
|
|
@@ -631,7 +686,7 @@ function composedTurn({ base = "", wakeText = "", ctxText = "", againText = "",
|
|
|
631
686
|
const { loadOrCreate } = await import("../lib/identity.mjs");
|
|
632
687
|
await sfetchJson(`${HUB}/send`, {
|
|
633
688
|
identity: loadOrCreate(SESSION, "agent"),
|
|
634
|
-
payload: { from: SESSION, to: "all", project: PROJ, text: `${AGENT} reporting — ready for a contract${MODEL ? ` (${MODEL})` : ""}` },
|
|
689
|
+
payload: { from: SESSION, to: "all", project: PROJ, kind: "status", text: `${AGENT} reporting — ready for a contract${MODEL ? ` (${MODEL})` : ""}` },
|
|
635
690
|
signal: AbortSignal.timeout(2500),
|
|
636
691
|
});
|
|
637
692
|
} catch {}
|
|
@@ -640,8 +695,8 @@ function composedTurn({ base = "", wakeText = "", ctxText = "", againText = "",
|
|
|
640
695
|
// broadcasts batched behind them. Restored from disk first: a runner that was killed mid-turn
|
|
641
696
|
// (or a machine that rebooted) still owes those messages, and the hub will never send them again.
|
|
642
697
|
const restored = loadPending();
|
|
643
|
-
let pendingWake = restored.wake;
|
|
644
|
-
let pendingBcast = restored.bcast;
|
|
698
|
+
let pendingWake = restored.wake.filter(shouldWake);
|
|
699
|
+
let pendingBcast = restored.bcast.filter(m => !isReceipt(m) && !isStatusBroadcast(m));
|
|
645
700
|
let retryAt = 0; // 0 = deliver at the next opportunity
|
|
646
701
|
let deliveryFails = 0; // consecutive failed attempts at the SAME pending batch
|
|
647
702
|
if (pendingWake.length) log(`\x1b[33m${pendingWake.length} message(s) survived from a previous run — redelivering\x1b[0m`);
|
|
@@ -675,7 +730,12 @@ function composedTurn({ base = "", wakeText = "", ctxText = "", againText = "",
|
|
|
675
730
|
let msgs = [];
|
|
676
731
|
try {
|
|
677
732
|
const r = await api(`/poll?session=${encodeURIComponent(SESSION)}&since=${cursor}&wait=${holdS}`);
|
|
678
|
-
msgs = r.messages || [];
|
|
733
|
+
msgs = r.messages || [];
|
|
734
|
+
if (r.cursor !== undefined && r.cursor !== null && Number.isFinite(Number(r.cursor))) {
|
|
735
|
+
const reportedCursor = Number(r.cursor);
|
|
736
|
+
if (reportedCursor < cursor) log(`cursor rewound by hub ${cursor} -> ${reportedCursor}`);
|
|
737
|
+
cursor = reportedCursor;
|
|
738
|
+
}
|
|
679
739
|
} catch (e) {
|
|
680
740
|
// Deadline-abort on the LONG-POLL is not an outage — it means the hold expired with no hub
|
|
681
741
|
// response (stalled event loop, napped machine, dead socket). Reconnect immediately and say
|
|
@@ -688,6 +748,10 @@ function composedTurn({ base = "", wakeText = "", ctxText = "", againText = "",
|
|
|
688
748
|
// never wake on your own broadcasts: a claude seat's report contains "claude:" and matched the
|
|
689
749
|
// @mention filter, buying one echo turn per report (seen live on the first pulsed orchestrator)
|
|
690
750
|
msgs = msgs.filter(m => m.from !== SESSION);
|
|
751
|
+
// A receipt is the terminal state of a contract, never a new contract. Consume typed receipts,
|
|
752
|
+
// reply-linked outcomes, and the old stable marker before direct-address logic sees them. Status
|
|
753
|
+
// broadcasts are presence chatter and are dropped rather than saved as future prompt context.
|
|
754
|
+
msgs = msgs.filter(m => !isReceipt(m) && !isStatusBroadcast(m));
|
|
691
755
|
// #5760 (the night of 08-31): the hub's hourly "same-project-sessions" FYI woke every seat
|
|
692
756
|
// into a real CLI turn — three wedged for hours mid-chatter, one on the metered pool. That
|
|
693
757
|
// kind is pure coordination CONTEXT ("no human needs to relay this" — and no turn needs to
|
|
@@ -695,8 +759,8 @@ function composedTurn({ base = "", wakeText = "", ctxText = "", againText = "",
|
|
|
695
759
|
// warnings still wake — those are actionable by the seat right now.
|
|
696
760
|
const fyi = msgs.filter(m => m.from === "hub:duty" && String(m.text || "").startsWith("🤝 OVERSEER same-project-sessions"));
|
|
697
761
|
const rest = msgs.filter(m => !fyi.includes(m));
|
|
698
|
-
const direct = rest.filter(m => m.to === SESSION);
|
|
699
|
-
const mentions = rest.filter(m => m.to === "all" && (m
|
|
762
|
+
const direct = rest.filter(m => m.to === SESSION && shouldWake(m));
|
|
763
|
+
const mentions = rest.filter(m => m.to === "all" && shouldWake(m));
|
|
700
764
|
const bcast = [...rest.filter(m => m.to === "all" && !mentions.includes(m)), ...fyi];
|
|
701
765
|
pendingBcast.push(...bcast); // wake-policy: plain broadcasts batch, they don't wake
|
|
702
766
|
const wake = [...direct, ...mentions];
|
|
@@ -708,7 +772,7 @@ function composedTurn({ base = "", wakeText = "", ctxText = "", againText = "",
|
|
|
708
772
|
const dropped = pendingWake.splice(0, pendingWake.length - PENDING_MAX);
|
|
709
773
|
log(`\x1b[31mundelivered queue overflowed — dropped ${dropped.length} oldest message(s)\x1b[0m`);
|
|
710
774
|
await api("/send", { from: SESSION, to: "all", project: PROJ,
|
|
711
|
-
text: `⚠️ ${SESSION} dropped ${dropped.length} undelivered message(s) — queue hit its ${PENDING_MAX} cap during a failure streak` }).catch(() => {});
|
|
775
|
+
kind: "status", text: `⚠️ ${SESSION} dropped ${dropped.length} undelivered message(s) — queue hit its ${PENDING_MAX} cap during a failure streak` }).catch(() => {});
|
|
712
776
|
}
|
|
713
777
|
savePending(pendingWake, pendingBcast);
|
|
714
778
|
// Respect an active backoff: a new message during an outage joins the batch, it does not
|
|
@@ -741,7 +805,7 @@ function composedTurn({ base = "", wakeText = "", ctxText = "", againText = "",
|
|
|
741
805
|
// Who is owed an answer, captured BEFORE the turn: pendingWake is cleared on success.
|
|
742
806
|
const assigners = [];
|
|
743
807
|
for (const m of wake) if (m.from && !assigners.some(a => a.from === m.from)) assigners.push({ from: m.from, id: m.id });
|
|
744
|
-
const asked =
|
|
808
|
+
const asked = askedExcerpt(wake[0]);
|
|
745
809
|
const tStart = Date.now();
|
|
746
810
|
const prompt = composedTurn({
|
|
747
811
|
wakeText, ctxText, againText,
|
package/bin/crew.sh
CHANGED
|
@@ -352,7 +352,7 @@ spawn_herdr() { # $@ = specs
|
|
|
352
352
|
local surfs=()
|
|
353
353
|
[ -n "$REUSE_WS" ] && wsid="$REUSE_WS"
|
|
354
354
|
for SPEC in "$@"; do
|
|
355
|
-
resolve_spec "$SPEC"
|
|
355
|
+
resolve_spec "$SPEC" || continue
|
|
356
356
|
local cmd; cmd="$(RUN_CMD)"
|
|
357
357
|
if [ -n "$REUSE_WS" ]; then
|
|
358
358
|
# replace-in-place: split the fresh pane FIRST (targeting the agent's old pane when tracked),
|
|
@@ -743,11 +743,19 @@ resolve_model() {
|
|
|
743
743
|
else
|
|
744
744
|
out="$(python3 "$SCROOGE" route --provider "$provider" -t "$task" -d "$diff" --json 2>/dev/null)"
|
|
745
745
|
fi
|
|
746
|
-
[ -n "$out" ]
|
|
747
|
-
|
|
746
|
+
if [ -n "$out" ]; then
|
|
747
|
+
out="$(printf '%s' "$out" | python3 -c 'import json,sys
|
|
748
748
|
try: print(json.load(sys.stdin).get("qualified") or "")
|
|
749
749
|
except Exception: pass' 2>/dev/null)"
|
|
750
|
-
|
|
750
|
+
fi
|
|
751
|
+
# The router can be absent (a fresh machine, a dry run, scrooge without its registry). That must
|
|
752
|
+
# never hand the seat to opencode's GLOBAL default (#6068, the DeepSeek bill) and must not drop
|
|
753
|
+
# the seat either (#6110): fall back INSIDE the provider — the head of its own catalog — and say so.
|
|
754
|
+
if [ -z "$out" ] && [ -n "$cands" ]; then
|
|
755
|
+
out="$provider/${cands%% *}"
|
|
756
|
+
echo "[crew] router unavailable for $agent:$provider — using the provider's own catalog head ($out)" >&2
|
|
757
|
+
fi
|
|
758
|
+
[ -n "$out" ] || { echo "[crew] live model selection failed for $agent:$provider — no router and no catalog; refusing opencode global default" >&2; return 1; }
|
|
751
759
|
[ "${out%%/*}" = "$provider" ] || {
|
|
752
760
|
echo "[crew] router selected $out outside $provider — refusing cross-provider fallback" >&2
|
|
753
761
|
return 1
|
|
@@ -758,7 +766,11 @@ except Exception: pass' 2>/dev/null)"
|
|
|
758
766
|
epoch_ms() { python3 -c 'import time;print(int(time.time()*1000))'; }
|
|
759
767
|
|
|
760
768
|
# resolve_spec <spec> -> sets AGENT + MODEL globals (live-selects a provider-only spec).
|
|
761
|
-
AGENT
|
|
769
|
+
# On failure, returns 1 (AGENT is still set; MODEL is empty) instead of exiting the whole script —
|
|
770
|
+
# callers are responsible for skipping that one seat and continuing the batch. SKIPPED_SEATS
|
|
771
|
+
# accumulates "agent: reason" entries across every spawn path so `up` can report + exit non-zero
|
|
772
|
+
# at the end without killing seats that already launched earlier in the loop.
|
|
773
|
+
AGENT=""; MODEL=""; SKIPPED_SEATS=()
|
|
762
774
|
resolve_spec() {
|
|
763
775
|
local SPEC="$1" FIELD
|
|
764
776
|
AGENT="${SPEC%%:*}"; MODEL=""
|
|
@@ -780,11 +792,26 @@ resolve_spec() {
|
|
|
780
792
|
if [ -n "$FIELD" ]; then
|
|
781
793
|
case "$FIELD" in
|
|
782
794
|
*/*) MODEL="$FIELD" ;;
|
|
783
|
-
*) MODEL="$(resolve_model "$AGENT" "$FIELD" "$TASK" "$DIFF")" ||
|
|
795
|
+
*) MODEL="$(resolve_model "$AGENT" "$FIELD" "$TASK" "$DIFF")" || {
|
|
796
|
+
echo "[crew] ✗ skipping seat '$AGENT' — model resolution failed for $FIELD ($TASK/$DIFF); remaining seats still launch" >&2
|
|
797
|
+
SKIPPED_SEATS+=("$AGENT: model resolution failed for $FIELD ($TASK/$DIFF)")
|
|
798
|
+
return 1
|
|
799
|
+
}
|
|
784
800
|
echo " → $AGENT: live model $MODEL ($FIELD · $TASK/$DIFF)" ;;
|
|
785
801
|
esac
|
|
786
802
|
fi
|
|
787
803
|
}
|
|
804
|
+
# report_skipped_seats: prints a summary of every seat resolve_spec skipped this run (if any) and
|
|
805
|
+
# returns 1 so callers can propagate a non-zero exit — the whole point is that a batch with some
|
|
806
|
+
# skips still launched the rest of the crew, so this is a REPORT, not an abort.
|
|
807
|
+
report_skipped_seats() {
|
|
808
|
+
[ "${#SKIPPED_SEATS[@]}" -gt 0 ] || return 0
|
|
809
|
+
echo ""
|
|
810
|
+
echo "✗✗ ${#SKIPPED_SEATS[@]} seat(s) skipped (model resolution failed) — the rest of the crew still launched:"
|
|
811
|
+
local s
|
|
812
|
+
for s in "${SKIPPED_SEATS[@]}"; do echo " - $s"; done
|
|
813
|
+
return 1
|
|
814
|
+
}
|
|
788
815
|
# Kill any runner ALREADY serving this exact agent+project before starting another.
|
|
789
816
|
#
|
|
790
817
|
# Without this, `trantor up <agent>` ADDS a runner instead of replacing one — and every duplicate
|
|
@@ -816,7 +843,7 @@ spawn_tmux() { # $@ = specs
|
|
|
816
843
|
# a pre-existing session for THIS project = the crew is already up; add missing seats as new panes.
|
|
817
844
|
tmux has-session -t "$TMUX_SESS" 2>/dev/null && first=0
|
|
818
845
|
for SPEC in "$@"; do
|
|
819
|
-
resolve_spec "$SPEC"
|
|
846
|
+
resolve_spec "$SPEC" || continue
|
|
820
847
|
local cmd; cmd="$(RUN_CMD)"
|
|
821
848
|
local pane=""
|
|
822
849
|
if [ "$first" = "1" ]; then
|
|
@@ -867,7 +894,7 @@ spawn_grid() { # $@ = specs
|
|
|
867
894
|
local ROWS=$(( (N + COLS - 1) / COLS ))
|
|
868
895
|
local CW=$(( GW / COLS )) CH=$(( GH / ROWS )) i=0 SPEC
|
|
869
896
|
for SPEC in "$@"; do
|
|
870
|
-
resolve_spec "$SPEC"
|
|
897
|
+
resolve_spec "$SPEC" || continue
|
|
871
898
|
local cmd; cmd="$(RUN_CMD)"
|
|
872
899
|
local C=$(( i % COLS )) R=$(( i / COLS )) X1 Y1 WID=""
|
|
873
900
|
X1=$(( GX + C * CW )); Y1=$(( GY + R * CH ))
|
|
@@ -952,7 +979,7 @@ spawn_cmux() { # $@ = specs
|
|
|
952
979
|
local surfs=()
|
|
953
980
|
[ -n "$REUSE_WS" ] && wsid="$REUSE_WS"
|
|
954
981
|
for SPEC in "$@"; do
|
|
955
|
-
resolve_spec "$SPEC"
|
|
982
|
+
resolve_spec "$SPEC" || continue
|
|
956
983
|
local cmd launcher; cmd="$(RUN_CMD)"; launcher="$(_seat_launcher "$AGENT" "$cmd")"
|
|
957
984
|
if [ -n "$REUSE_WS" ]; then
|
|
958
985
|
# replace-in-place: split the fresh pane FIRST (targeting the agent's old pane when tracked),
|
|
@@ -1054,7 +1081,7 @@ OSA
|
|
|
1054
1081
|
fi
|
|
1055
1082
|
[ -n "$REUSE_TAB" ] && { tabid="$REUSE_TAB"; echo " → reusing existing crew workspace for $PROJ ($tabid)"; }
|
|
1056
1083
|
for SPEC in "$@"; do
|
|
1057
|
-
resolve_spec "$SPEC"
|
|
1084
|
+
resolve_spec "$SPEC" || continue
|
|
1058
1085
|
local cmd launcher; cmd="$(RUN_CMD)"; launcher="$(_seat_launcher "$AGENT" "$cmd")"
|
|
1059
1086
|
# In REUSE mode, replace-in-place: split off the agent's old terminal when tracked, close it after.
|
|
1060
1087
|
local OLD_SURF=""
|
|
@@ -1175,7 +1202,11 @@ echo "— bringing up crew for $PROJ ($CREW_UI) —"
|
|
|
1175
1202
|
SPAWN_EPOCH=$(epoch_ms)
|
|
1176
1203
|
spawn_crew "$@"
|
|
1177
1204
|
|
|
1178
|
-
if [ "$DRY" = "1" ]; then
|
|
1205
|
+
if [ "$DRY" = "1" ]; then
|
|
1206
|
+
echo "— dry run: no bus verify —"
|
|
1207
|
+
report_skipped_seats
|
|
1208
|
+
exit $?
|
|
1209
|
+
fi
|
|
1179
1210
|
echo "— verifying on the bus (the spawn is not the truth; the bus is) —"
|
|
1180
1211
|
AGENTS_ONLY=$(for a in "$@"; do printf "%s " "${a%%:*}"; done)
|
|
1181
1212
|
VER=$(node "$BUS_DIR/bin/crew-verify.mjs" "$PROJ" $AGENTS_ONLY --since "$SPAWN_EPOCH" --timeout 30)
|
|
@@ -1196,3 +1227,5 @@ if [ -n "${RETRY// }" ]; then
|
|
|
1196
1227
|
fi
|
|
1197
1228
|
fi
|
|
1198
1229
|
echo "— crew verified on the bus. Send contracts with relay_send; runners keep agents alive for free. Teardown (this project only): trantor down —"
|
|
1230
|
+
report_skipped_seats
|
|
1231
|
+
exit $?
|
package/bin/new.mjs
CHANGED
|
@@ -129,8 +129,14 @@ try {
|
|
|
129
129
|
// way crew seats do: the operator's owner key mints a project-scoped write invite and the genesis
|
|
130
130
|
// identity spends it. Only when NO owner key is configured (a loopback hub with no owner identity)
|
|
131
131
|
// do we fall back to the plain TOFU enroll.
|
|
132
|
-
const viaOwner = await enrollViaOwnerInvite(hub, identity, name, { timeoutMs: 8000 });
|
|
133
|
-
if (!viaOwner.ok && viaOwner.reason === "no-owner-key") await enrollTofu(session, identity, name);
|
|
132
|
+
const viaOwner = await enrollViaOwnerInvite(hub, identity, name, { timeoutMs: 8000, kind: "tool" });
|
|
133
|
+
if (!viaOwner.ok && viaOwner.reason === "no-owner-key") await enrollTofu(session, identity, name, { kind: "tool" });
|
|
134
|
+
else if (!viaOwner.ok) console.error(`genesis: enrollment via owner invite failed: ${viaOwner.reason}`);
|
|
135
|
+
// #6068: say WHAT this session is. The genesis identity exists to post the brief — without a
|
|
136
|
+
// kind on its peer row the app's seat strip renders it as a seat ("no terminal pane — start it
|
|
137
|
+
// with trantor up genesis"). /register is presence, not speech: no message rides it, nobody
|
|
138
|
+
// wakes. The hub stamps kind on the session row and /peers returns it to the app.
|
|
139
|
+
await signedPost("/register", { session, kind: "tool" }, { session, project: name, timeoutMs: 8000 }).catch(() => {});
|
|
134
140
|
const briefForHub = (brief || `Genesis of ${name} — created by trantor new.`).slice(0, 600);
|
|
135
141
|
const r1 = await signedPost("/project", { project: name, brief: briefForHub, by: session }, { session, project: name, timeoutMs: 8000 });
|
|
136
142
|
if (!r1.ok) throw new Error(`hub ${r1.status} on /project${r1.json?.error ? `: ${r1.json.error}` : ""}`);
|
package/bin/write-handoff.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import { writeFileSync, readFileSync, existsSync, mkdirSync, readdirSync } from
|
|
|
6
6
|
import { join, basename } from "node:path";
|
|
7
7
|
import { homedir, hostname } from "node:os";
|
|
8
8
|
import { execSync } from "node:child_process";
|
|
9
|
-
import { spawnBaton, handoffMode } from "../hooks/lib/handoff.mjs";
|
|
9
|
+
import { spawnBaton, handoffMode, resolveHandoffSurface } from "../hooks/lib/handoff.mjs";
|
|
10
10
|
import { handoffDir } from "../lib/project.mjs";
|
|
11
11
|
|
|
12
12
|
const baton = process.argv.includes("--baton");
|
|
@@ -14,8 +14,14 @@ const baton = process.argv.includes("--baton");
|
|
|
14
14
|
// fresh one on stdin", so a session that had just written a 5KB handoff had to write it again to
|
|
15
15
|
// hand it over — 2m28s of regenerated prose on a live scribe session, 2026-08-24.
|
|
16
16
|
const latest = process.argv.includes("--latest");
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
// #6074: ONE resolver for which project this is and where the session lives — shared with
|
|
18
|
+
// bin/baton.mjs (the `trantor handoff` CLI path) so the two cannot diverge. The name comes from
|
|
19
|
+
// the session's registration (TRANTOR_ORCH / RELAY_PROJECT / orch-sessions.txt) before the cwd;
|
|
20
|
+
// the witnessed crebral-scribe/ios handoff recorded project "ios" from a subfolder cwd and never
|
|
21
|
+
// found its pane.
|
|
22
|
+
const resolved = resolveHandoffSurface({ sessionId: process.env.CLAUDE_SESSION_ID || "" });
|
|
23
|
+
const project = resolved.projectDir;
|
|
24
|
+
const name = resolved.project;
|
|
19
25
|
let summary = "";
|
|
20
26
|
if (!latest) {
|
|
21
27
|
process.stdin.setEncoding("utf8");
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Guarded production restart: an unhealthy durable writer means RAM contains the only current copy.
|
|
3
|
+
set -euo pipefail
|
|
4
|
+
|
|
5
|
+
FORCE=0
|
|
6
|
+
if [ "${1:-}" = "--force" ]; then
|
|
7
|
+
FORCE=1
|
|
8
|
+
shift
|
|
9
|
+
fi
|
|
10
|
+
if [ "$#" -ne 0 ]; then
|
|
11
|
+
echo "usage: deploy/restart-hub.sh [--force]" >&2
|
|
12
|
+
exit 2
|
|
13
|
+
fi
|
|
14
|
+
|
|
15
|
+
# The hub binds RELAY_HOST (the tailnet IP on netcup, where 127.0.0.1 refuses), so the guard reads the
|
|
16
|
+
# same env the service does; RELAY_HUB_URL still overrides.
|
|
17
|
+
ENV_FILE="${RELAY_ENV_FILE:-$(dirname "$0")/hub.env}"
|
|
18
|
+
if [ -f "$ENV_FILE" ]; then
|
|
19
|
+
# shellcheck disable=SC1090
|
|
20
|
+
set -a; . "$ENV_FILE"; set +a
|
|
21
|
+
fi
|
|
22
|
+
HUB_URL="${RELAY_HUB_URL:-http://${RELAY_HOST:-127.0.0.1}:${RELAY_PORT:-4477}}"
|
|
23
|
+
HEALTH=""
|
|
24
|
+
if HEALTH="$(curl --fail --silent --show-error --max-time 5 "$HUB_URL/health")"; then
|
|
25
|
+
REFUSAL="$(node --input-type=module -e '
|
|
26
|
+
const chunks = [];
|
|
27
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
28
|
+
let health;
|
|
29
|
+
try { health = JSON.parse(Buffer.concat(chunks).toString("utf8")); }
|
|
30
|
+
catch { process.stdout.write("hub health response was not valid JSON"); process.exit(0); }
|
|
31
|
+
const p = health.persist;
|
|
32
|
+
if (!p || p.ok !== false) process.exit(0);
|
|
33
|
+
const age = Number(p.failingSinceMs || 0);
|
|
34
|
+
const span = age >= 60000 ? `${Math.floor(age / 60000)}m ${Math.floor((age % 60000) / 1000)}s` : `${Math.floor(age / 1000)}s`;
|
|
35
|
+
process.stdout.write(`hub persistence has failed for ${span} (${Number(p.retries || 0)} retries): ${String(p.lastError || "unknown error")}. Restarting risks losing every state change since persistence stopped.`);
|
|
36
|
+
' <<<"$HEALTH")"
|
|
37
|
+
if [ -n "$REFUSAL" ] && [ "$FORCE" -ne 1 ]; then
|
|
38
|
+
echo "REFUSED: $REFUSAL" >&2
|
|
39
|
+
echo "Resolve persistence first, or rerun with --force to accept the data-loss risk." >&2
|
|
40
|
+
exit 1
|
|
41
|
+
fi
|
|
42
|
+
if [ -n "$REFUSAL" ]; then
|
|
43
|
+
echo "WARNING: --force accepted: $REFUSAL" >&2
|
|
44
|
+
fi
|
|
45
|
+
else
|
|
46
|
+
echo "WARNING: $HUB_URL/health was unreachable; proceeding because no running hub state can be inspected." >&2
|
|
47
|
+
fi
|
|
48
|
+
|
|
49
|
+
systemctl restart trantor-hub
|
|
50
|
+
echo "restarted trantor-hub"
|
package/deploy/setup-netcup.sh
CHANGED
|
@@ -72,10 +72,15 @@ echo " schema applied"
|
|
|
72
72
|
# systemd unit
|
|
73
73
|
cp deploy/trantor-hub.service /etc/systemd/system/trantor-hub.service
|
|
74
74
|
systemctl daemon-reload
|
|
75
|
-
systemctl enable
|
|
75
|
+
systemctl enable trantor-hub
|
|
76
|
+
if systemctl is-active --quiet trantor-hub; then
|
|
77
|
+
bash deploy/restart-hub.sh
|
|
78
|
+
else
|
|
79
|
+
systemctl start trantor-hub
|
|
80
|
+
fi
|
|
76
81
|
|
|
77
82
|
# crons: daily backup + nightly retention
|
|
78
|
-
chmod +x deploy/backup.sh deploy/retention.sh deploy/restore.sh
|
|
83
|
+
chmod +x deploy/backup.sh deploy/retention.sh deploy/restore.sh deploy/restart-hub.sh
|
|
79
84
|
echo "0 2 * * * root /opt/trantor/deploy/backup.sh" > /etc/cron.d/trantor
|
|
80
85
|
echo "0 3 * * * root /opt/trantor/deploy/retention.sh" >> /etc/cron.d/trantor
|
|
81
86
|
|
package/hooks/lib/api.mjs
CHANGED
|
@@ -112,7 +112,7 @@ function enrolledPath(session) {
|
|
|
112
112
|
const busDir = process.env.AGENT_BUS_DIR || join(homedir(), ".agent-bus");
|
|
113
113
|
return join(busDir, "keys", `${String(session).replace(/[^A-Za-z0-9_.-]/g, "_")}.enrolled`);
|
|
114
114
|
}
|
|
115
|
-
export async function ensureEnrolled(session, identity, project) {
|
|
115
|
+
export async function ensureEnrolled(session, identity, project, { kind = "agent" } = {}) {
|
|
116
116
|
if (!identity?.pubkey) return;
|
|
117
117
|
const hub = relayUrl(project);
|
|
118
118
|
const stamp = enrolledPath(session);
|
|
@@ -126,7 +126,7 @@ export async function ensureEnrolled(session, identity, project) {
|
|
|
126
126
|
// payload, sets content-type, and signs — so every hook signs identically with zero hand-rolling.
|
|
127
127
|
const r = await sfetchJson(`${hub}/enroll`, {
|
|
128
128
|
method: "POST",
|
|
129
|
-
payload: { pubkey: identity.pubkey, name: session, kind
|
|
129
|
+
payload: { pubkey: identity.pubkey, name: session, kind },
|
|
130
130
|
identity,
|
|
131
131
|
signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
|
|
132
132
|
});
|