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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.28",
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 (auto-summary variant). Discovers the current session's
3
- // transcript, writes a whole-session handoff (auto-summary + verbatim in-flight tail), opens a fresh
4
- // self-announcing session, and closes THIS window once it takes over. Run from inside the session you
5
- // want to hand off. (The richer MODEL-authored handoff is the /trantor:handoff skill.)
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 { resolveProject } from "../lib/project.mjs";
10
- import { writeHandoff, spawnBaton } from "../hooks/lib/handoff.mjs";
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 project = resolveProject(cwd);
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
- const transcript = findTranscript();
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
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);
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, environment: relayEnv("opencode") };
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
 
@@ -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
- await api("/register", { session: SESSION, project: PROJ, status: "crew member booting", llm: AGENT, model: MODEL }).catch(() => {});
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 || []; cursor = r.cursor ?? cursor;
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.text.includes(`@${AGENT}`) || m.text.toLowerCase().includes(`${AGENT}:`)));
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 = String(wake[0]?.text || "").replace(/\s+/g, " ").trim().slice(0, 90);
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" ] || { echo "[crew] live model selection failed for $agent:$provider — refusing opencode global default" >&2; return 1; }
747
- out="$(printf '%s' "$out" | python3 -c 'import json,sys
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
- [ -n "$out" ] || { echo "[crew] router returned no model for $agent:$provider — refusing opencode global default" >&2; return 1; }
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=""; MODEL=""
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")" || exit 1
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 echo "— dry run: no bus verify —"; exit 0; fi
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}` : ""}`);
@@ -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
- const project = process.env.CLAUDE_PROJECT_DIR || process.cwd();
18
- const name = basename(project);
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"
@@ -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 --now trantor-hub
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: "agent" },
129
+ payload: { pubkey: identity.pubkey, name: session, kind },
130
130
  identity,
131
131
  signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
132
132
  });