trantor 0.18.35 → 0.18.37

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.35",
3
+ "version": "0.18.37",
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
@@ -17,6 +17,10 @@ import { writeHandoff, spawnBaton, resolveHandoffSurface } from "../hooks/lib/ha
17
17
  const cwd = process.cwd();
18
18
  const resolved = resolveHandoffSurface({ projectDir: process.env.CLAUDE_PROJECT_DIR || cwd, sessionId: process.env.CLAUDE_SESSION_ID || "" });
19
19
  const project = resolved.project;
20
+ // #6218 — the transcript is found under the RESOLVED session directory (CLAUDE_PROJECT_DIR, the
21
+ // dir the session was launched in — how Claude itself names ~/.claude/projects), never a shell
22
+ // cwd that cd'd somewhere else: a handoff record must carry the transcript it was written from.
23
+ const sessionDir = resolved.projectDir;
20
24
 
21
25
  // Model-authored handoffs ride THIS binary too (`trantor handoff` — always the global install's
22
26
  // CURRENT code, never the plugin-cache copy a session booted with, the stale-0.18.20 bug). A piped
@@ -38,10 +42,10 @@ if (stdinIsPipe() || process.argv.includes("--latest")) {
38
42
  autoBaton();
39
43
  }
40
44
 
41
- // The active session's transcript = newest *.jsonl directly in this project's Claude dir
42
- // (~/.claude/projects/<cwd-with-slashes-as-dashes>/), excluding the subagents/ subtree.
45
+ // The active session's transcript = newest *.jsonl directly in the session dir's Claude dir
46
+ // (~/.claude/projects/<sessionDir-with-slashes-as-dashes>/), excluding the subagents/ subtree.
43
47
  function findTranscript() {
44
- const dashed = cwd.replace(/\//g, "-");
48
+ const dashed = sessionDir.replace(/\//g, "-");
45
49
  const base = join(homedir(), ".claude", "projects");
46
50
  let best = "", bestM = 0;
47
51
  let dirs = []; try { dirs = readdirSync(base).filter(d => d === dashed || d.endsWith(dashed)); } catch {}
@@ -9,7 +9,7 @@
9
9
  // agent arrives it RESUMES the CLI session (native resume = full context kept) with that
10
10
  // message as the prompt. The model just works and ends its turn; the runner does the rest.
11
11
  import { execSync, spawnSync, spawn } from "node:child_process";
12
- import { readFileSync, writeFileSync, unlinkSync, existsSync, appendFileSync, mkdirSync } from "node:fs";
12
+ import { readFileSync, writeFileSync, unlinkSync, existsSync, appendFileSync, mkdirSync, realpathSync } from "node:fs";
13
13
  import { join, basename } from "node:path";
14
14
  import { homedir } from "node:os";
15
15
  import { resolveProject, resolveHub, withEnvFiles, hostId } from "../lib/project.mjs";
@@ -24,7 +24,8 @@ import {
24
24
  } from "../lib/classify-failure.mjs";
25
25
  import { capWake, capBcast, pickLessons, composePrompt } from "./crew-payload.mjs";
26
26
  import {
27
- cardRef, carriesWork, parseTurnTokens, parseResetAt, reasonWithBalances, PARKING_REASONS,
27
+ cardRef, carriesWork, parseTurnTokens, parseResetAt, reasonWithBalances, quotaResetAt, PARKING_REASONS,
28
+ senderProjectOf, isLinkedProject,
28
29
  } from "../lib/turn-policy.mjs";
29
30
 
30
31
  const AGENT = process.argv[2];
@@ -109,6 +110,26 @@ function ensureSeatWorktree(sourceDir) {
109
110
  }
110
111
 
111
112
  const TURN_DIR = ensureSeatWorktree(DIR);
113
+
114
+ // #6154: opencode prints no session id on stdout, but it records every session in its own sqlite
115
+ // DB with the directory the session was created in. The newest row for OUR worktree is the only
116
+ // session a resume may pin — anything else in that DB belongs to another project on this machine,
117
+ // which is exactly what `run -c` used to hand us. Read-only, fail-open: no DB or no row means the
118
+ // next turn starts fresh, which is always safe, instead of resuming a stranger, which never is.
119
+ const OC_DB = join(process.env.XDG_DATA_HOME || join(homedir(), ".local", "share"), "opencode", "opencode.db");
120
+ function ocSid(dir) {
121
+ try {
122
+ // opencode stores the directory as IT sees its cwd, which on macOS can be the /private/var
123
+ // realpath of the /var/... path the runner holds — query both spellings.
124
+ const dirs = [dir];
125
+ try { const real = realpathSync(dir); if (real !== dir) dirs.push(real); } catch {}
126
+ const list = dirs.map((d) => `'${d.replaceAll("'", "''")}'`).join(", ");
127
+ const q = `SELECT id FROM session WHERE directory IN (${list}) ORDER BY time_updated DESC LIMIT 1;`;
128
+ const r = spawnSync("sqlite3", ["-readonly", OC_DB, q], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 });
129
+ const id = String(r.stdout || "").trim();
130
+ return /^ses_[A-Za-z0-9]+$/.test(id) ? id : "";
131
+ } catch { return ""; }
132
+ }
112
133
  // RUNNER_SESSION override: an orchestrator seat (bin/orchestrate.mjs) runs the same CLI as a crew
113
134
  // seat but must live on the bus under its own name (claude-orch:proj), or it would collide with a
114
135
  // plain claude crew seat on the same project.
@@ -244,16 +265,25 @@ const CLI = {
244
265
  // --yolo in prompt mode (prompt mode auto-approves tools), and emits session_-prefixed ids.
245
266
  kimi: { first: `kimi{M} -p "$(cat {P})" < /dev/null`,
246
267
  next: `kimi{M} -r {SID} -p "$(cat {P})" < /dev/null`, mflag: " --model ", sid: /To resume this session: kimi -r (\S+)/ },
247
- deepseek: { first: `opencode run{M} "$(cat {P})"`,
248
- next: `opencode run -c{M} "$(cat {P})"`, mflag: " -m ", env: join(homedir(), ".token-scrooge", ".env") },
249
- opencode: { first: `opencode run{M} "$(cat {P})"`,
250
- next: `opencode run -c{M} "$(cat {P})"`, mflag: " -m ", env: join(homedir(), ".token-scrooge", ".env") },
268
+ // #6154: the opencode family never resumes blind. `run -c` continues the GLOBALLY last session
269
+ // on this machine any project's (opencode.db showed a pr-os session interleaved between two
270
+ // trantor ones) and the resumed session's stored directory becomes the Location every relative
271
+ // path resolves against. A seat then `cd desktop/src-tauri` inside its own worktree while
272
+ // opencode resolves it against a stranger's root, the bash tool reads it as external_directory
273
+ // and auto-rejects, and the turn dies mid-work with everything uncommitted. So: every spawn
274
+ // pins --dir to the seat worktree, and a resume pins -s to the session id looked up from
275
+ // opencode's own DB by directory — the session CREATED here (ocSid below). A missed lookup
276
+ // degrades to a fresh session, never to a foreign one.
277
+ deepseek: { first: `opencode run --dir {DIR}{M} "$(cat {P})"`,
278
+ next: `opencode run --dir {DIR} -s {SID}{M} "$(cat {P})"`, mflag: " -m ", pinned: true, env: join(homedir(), ".token-scrooge", ".env") },
279
+ opencode: { first: `opencode run --dir {DIR}{M} "$(cat {P})"`,
280
+ next: `opencode run --dir {DIR} -s {SID}{M} "$(cat {P})"`, mflag: " -m ", pinned: true, env: join(homedir(), ".token-scrooge", ".env") },
251
281
  // OpenRouter rides the opencode CLI exactly like deepseek/glm, but under its OWN agent label so
252
282
  // its bus identity is `openrouter:<project>` (RELAY_AGENT is set per-spawn) — never colliding with
253
283
  // the glm `opencode` seat. Model ids come pre-qualified (`openrouter/<vendor>/<model>`). Sources
254
284
  // the token-scrooge .env so an existing OPENROUTER_API_KEY authenticates with no extra wiring.
255
- openrouter: { first: `opencode run{M} "$(cat {P})"`,
256
- next: `opencode run -c{M} "$(cat {P})"`, mflag: " -m ", env: join(homedir(), ".token-scrooge", ".env") },
285
+ openrouter: { first: `opencode run --dir {DIR}{M} "$(cat {P})"`,
286
+ next: `opencode run --dir {DIR} -s {SID}{M} "$(cat {P})"`, mflag: " -m ", pinned: true, env: join(homedir(), ".token-scrooge", ".env") },
257
287
  claude: { first: `claude{M} -p "$(cat {P})" --dangerously-skip-permissions`,
258
288
  next: `claude -c{M} -p "$(cat {P})" --dangerously-skip-permissions`, mflag: " --model " },
259
289
  // DeepSeek Harness. Every turn is a FRESH session — headless has no resume yet — so the seat
@@ -275,7 +305,7 @@ if (!CLI[AGENT]) log(`'${AGENT}' is not a built-in seat — running it as an ope
275
305
 
276
306
  // RUNNER_RULES / RUNNER_KICKOFF env overrides: the runner is also the substrate for non-crew
277
307
  // always-on seats (the fleet DUTY agent, bin/duty.mjs) whose doctrine is not "work your card".
278
- const RULES = process.env.RUNNER_RULES || `Rules: you are ${SESSION} on the trantor crew. Before starting a card, read YOUR card: relay_board with card:<id> (the card, its deps, its notes, and the last five done cards whose title shares a word); never the whole board. Work your assigned file(s), report on the bus (relay_send, <280 chars), move your Kanban card as you go with a NOTE saying what you did (doing -> testing -> done; in 'testing' run YOUR OWN test file — never the full npm test, suites collide across seats — plus \`node bin/slop-gate.mjs\` when the repo has one: it lints ONLY your changed files against the anti-slop rules, and a card must not reach done with slop-gate failing; use 'failed' + a report if anything breaks). If you need something from another session, message THAT SESSION (relay_peers to find its id, relay_send to reach it) — never ask the human to pass it along; carrying messages between agents is the job this bus exists to remove. When your work for THIS message is finished, END YOUR TURN — do NOT park, do NOT loop relay_wait; the runner waits for you and will wake you with the next message.`;
308
+ const RULES = process.env.RUNNER_RULES || `Rules: you are ${SESSION} on the trantor crew. Before starting a card, read YOUR card: relay_board with card:<id> (the card, its deps, its notes, and the last five done cards whose title shares a word); never the whole board. Work your assigned file(s), report on the bus (relay_send, <280 chars), move your Kanban card as you go with a NOTE saying what you did (doing -> testing -> done; in 'testing' run YOUR OWN test file — never the full npm test, suites collide across seats — plus \`node bin/slop-gate.mjs\` when the repo has one: it lints ONLY your changed files against the anti-slop rules, and a card must not reach done with slop-gate failing; use 'failed' + a report if anything breaks). If you need something from another session, message THAT SESSION (relay_peers to find its id, relay_send to reach it) — never ask the human to pass it along; carrying messages between agents is the job this bus exists to remove. When your work for THIS message is finished, END YOUR TURN — do NOT park, do NOT loop relay_wait; the runner waits for you and will wake you with the next message. Path discipline: build/test from your worktree root ${TURN_DIR} with absolute paths or --manifest-path/--prefix instead of cd-ing into subdirs, and put anything that must land outside the repo under ${TURN_DIR}/.agent-bus-out/ (gitignored) — never ~/.agent-bus. Cross-project action is a breach: never \`trantor up\` a crew, register a seat, or send a card/contract into a project other than ${PROJ} unless the operator ran \`trantor policy link ${PROJ} <other> --reason "<why>"\` first — the hub, the CLI and this runner all refuse it mechanically, so ask the operator to link the projects instead of routing around the refusal.`;
279
309
 
280
310
  // ---- the pulse (Scape's Lloyd/Argus loop, Trantor-shaped) --------------------
281
311
  // A message-driven seat is DEAF between messages. An orchestrator seat with a mission needs a
@@ -406,8 +436,10 @@ async function reportFailure(exit, trigger, undelivered = 0, reasonOverride = ""
406
436
  // redelivered to. So those two reasons PARK — the queue is kept, the ladder stops, and the room is
407
437
  // told once, with the reset time when the CLI printed one. `trantor up` (a restart) resumes.
408
438
  let parkAnnounced = false;
409
- async function parkSeat(reason, undelivered) {
410
- const resetAt = parseResetAt(lastErrText);
439
+ async function parkSeat(reason, undelivered, resetHint = 0) {
440
+ // A seat that went QUIET printed no wall message to parse (#6131), so its own balance row is the
441
+ // only place the reset time exists. Output first when there is any: it is this turn's evidence.
442
+ const resetAt = parseResetAt(lastErrText) || resetHint;
411
443
  const when = resetAt ? new Date(resetAt).toLocaleString() : "";
412
444
  if (!parkAnnounced) {
413
445
  parkAnnounced = true;
@@ -427,8 +459,12 @@ async function parkSeat(reason, undelivered) {
427
459
  async function balanceRows() {
428
460
  try {
429
461
  const { fetchBalances } = await import("../lib/balances.mjs");
462
+ // Same key sources the crew itself uses: QWEN_API_KEY (and most others) live in
463
+ // ~/.agent-bus/.env, not in the runner's inherited environment — reading bare process.env
464
+ // here would report "no key" and quietly leave every silent turn classified as a crash.
465
+ const { resolveKeys } = await import("../lib/provider-keys.mjs");
430
466
  return await Promise.race([
431
- fetchBalances(process.env, { only: [AGENT] }),
467
+ fetchBalances(resolveKeys(process.env), { only: [AGENT] }),
432
468
  new Promise((r) => setTimeout(() => r([]), 4000)),
433
469
  ]);
434
470
  } catch { return []; }
@@ -520,9 +556,11 @@ async function runTurn(prompt, isFirst, trigger = "kickoff") {
520
556
  // exit-0 turn with real output must never be re-labelled "auth" by the #5405 escalation — the
521
557
  // qwen specimen committed aa3c340 while its captured stream still tripped the auth regex.
522
558
  const headBefore = gitOut(["rev-parse", "HEAD"], TURN_DIR);
523
- let cmd = (isFirst || (cli.sid && !sid)) ? cli.first : cli.next;
559
+ // #6154: a pinned seat with no sid yet resumes as FRESH — the guard below fails open, because
560
+ // a resume without an id must fall back to a new session, never to `next`'s bare resume shape.
561
+ let cmd = (isFirst || ((cli.sid || cli.pinned) && !sid)) ? cli.first : cli.next;
524
562
  const mfrag = MODEL && cli.mflag ? `${cli.mflag}${MODEL}` : "";
525
- cmd = cmd.replaceAll("{M}", mfrag).replaceAll("{P}", pf).replaceAll("{SID}", sid);
563
+ cmd = cmd.replaceAll("{M}", mfrag).replaceAll("{P}", pf).replaceAll("{SID}", sid).replaceAll("{DIR}", TURN_DIR);
526
564
  // PRECEDENCE, and it is easy to get backwards — this is the second time.
527
565
  // Each file is PREPENDED, so the one prepended LAST runs FIRST, and in shell the file that runs
528
566
  // LAST wins. To make ~/.agent-bus/.env (the CREW layer) win it must be prepended FIRST, i.e.
@@ -558,6 +596,10 @@ async function runTurn(prompt, isFirst, trigger = "kickoff") {
558
596
  // the window with no ERRF growth earns ONE direct stall report to the foreman, never a kill.
559
597
  const WD_MS = Number(process.env.TRANTOR_TURN_WATCHDOG_MS || 15 * 60 * 1000);
560
598
  const STAMPF = join(homedir(), ".agent-bus", `turnstamp-${AGENT}-${PROJ}.json`);
599
+ // Written by the shell's own time box (below) and read back here — the only honest signal that
600
+ // the turn was CUT rather than that the CLI failed on its own. Cleared before every turn.
601
+ const CUTF = join(homedir(), ".agent-bus", `turncut-${AGENT}-${PROJ}`);
602
+ try { unlinkSync(CUTF); } catch {}
561
603
  try {
562
604
  writeFileSync(STAMPF, JSON.stringify({ turn: TURN, startedAt: Date.now() }));
563
605
  const wd = spawn(process.execPath, [join(import.meta.dirname, "turn-watchdog.mjs"), STAMPF, ERRF, String(WD_MS), SESSION, PROJ, HUB],
@@ -567,8 +609,35 @@ async function runTurn(prompt, isFirst, trigger = "kickoff") {
567
609
  // Preserve the CLI's exit before waiting for the stderr process substitution. Without the
568
610
  // explicit wait, a short failing CLI can return while its error is still in the scrub pipe;
569
611
  // under load the classifier then reads an empty ERRF and reports the wrong failure reason.
570
- const shell = `set -o pipefail; { ${inner} ; } 2> >(${SCRUB} --tee2 ${ERRF}); turn_exit=$?; wait; exit $turn_exit`;
571
- const r = spawnSync("/bin/bash", ["-c", shell], {
612
+ // #6134-followup: the time box has to fire from INSIDE the shell, while the process tree is
613
+ // still standing. Killing the turn's process group from node missed a grandchild — codex runs
614
+ // its own commands via setsid, so `sleep 400` sat in a different group and survived
615
+ // process.kill(-pid). Worse, by the time node's timeout has killed bash the survivors have been
616
+ // reparented to init, so there is no tree left to walk and nothing to sweep.
617
+ //
618
+ // So bash boxes itself: at the deadline it walks its own descendants and kills them bottom-up.
619
+ // setsid changes a process's group and session but NEVER its parent, so `pgrep -P` recursion
620
+ // reaches exactly the children that a group signal cannot. Children first, then the parent, so
621
+ // nothing gets reparented mid-sweep and escapes the walk.
622
+ //
623
+ // The marker file is how node learns the turn was cut rather than merely failing: an exit status
624
+ // alone cannot tell "killed at the box" from "the CLI died on its own".
625
+ const sweep = `sweep() { local p; for p in $(pgrep -P $1 2>/dev/null); do sweep $p; done; kill -KILL $1 2>/dev/null; }`;
626
+ const box = TURN_MAX_MS ? `
627
+ ${sweep}
628
+ ( sleep ${Math.ceil(TURN_MAX_MS / 1000)}
629
+ kill -0 $job 2>/dev/null || exit 0
630
+ : > ${CUTF}
631
+ sweep $job
632
+ ) & boxpid=$!` : "boxpid=";
633
+ const shell = `set -o pipefail
634
+ { ${inner} ; } 2> >(${SCRUB} --tee2 ${ERRF}) &
635
+ job=$!${box}
636
+ wait $job; turn_exit=$?
637
+ [ -n "$boxpid" ] && kill $boxpid 2>/dev/null
638
+ wait
639
+ exit $turn_exit`;
640
+ const spawnOpts = {
572
641
  // detached: bash leads its OWN process group, so the time box can kill the CLI and everything
573
642
  // it spawned with one signal instead of orphaning the model process behind a dead shell.
574
643
  // stdin is /dev/null for every seat (it already was for codex/kimi/dsh via `< /dev/null`):
@@ -576,9 +645,14 @@ async function runTurn(prompt, isFirst, trigger = "kickoff") {
576
645
  // takes SIGTTIN and stops forever. Nothing here runs interactively — every CLI is in -p /
577
646
  // exec / run mode — so closing stdin is what makes the group safe.
578
647
  detached: true,
579
- ...(TURN_MAX_MS ? { timeout: TURN_MAX_MS, killSignal: "SIGKILL" } : {}),
580
648
  cwd: TURN_DIR, encoding: "utf8", stdio: cli.sid ? ["ignore", "pipe", "inherit"] : ["ignore", "inherit", "inherit"],
581
649
  env: { ...process.env, RELAY_URL: HUB, RELAY_AGENT: AGENT, RELAY_SESSION: SESSION, RELAY_PROJECT: PROJ,
650
+ // #6228: badges this seat's env as belonging to PROJ, distinctly from a one-off RELAY_PROJECT
651
+ // override (bin/crew.sh's own tests, and any deliberate `RELAY_PROJECT=x trantor up` from a
652
+ // plain shell, set RELAY_PROJECT alone and must keep working — only THIS marker means "the
653
+ // env I'm running in already has a project home"). crew.sh's `up` guard refuses to bring up a
654
+ // DIFFERENT project's crew from a shell carrying this badge, same as it refuses TRANTOR_ORCH.
655
+ TRANTOR_SEAT: PROJ,
582
656
  // A RUNNER-MANAGED SEAT MUST NEVER HAND ITSELF A BATON.
583
657
  //
584
658
  // The handoff machinery exists for an INTERACTIVE session: near its context limit it writes a
@@ -591,15 +665,22 @@ async function runTurn(prompt, isFirst, trigger = "kickoff") {
591
665
  // sitting there days later. To the operator that reads as "why are there two duty agents".
592
666
  TRANTOR_NO_HANDOFF_SPAWN: "1", TRANTOR_NO_BATON_SPAWN: "1" },
593
667
  maxBuffer: 16 * 1024 * 1024,
594
- });
668
+ };
669
+ // A BACKSTOP only, deliberately later than the shell's own box: if bash itself wedges, node
670
+ // still ends the turn. When the in-shell box works — the normal path — this never fires, which
671
+ // is the point: the shell kills while the tree is still walkable, node cannot.
672
+ if (TURN_MAX_MS) { spawnOpts.timeout = TURN_MAX_MS + 30000; spawnOpts.killSignal = "SIGKILL"; }
673
+ const r = spawnSync("/bin/bash", ["-c", shell], spawnOpts);
595
674
  try { unlinkSync(STAMPF); } catch {} // turn over — disarm the watchdog
596
- // #6134: node's timeout signals only the shell it spawned. The CLI and its children share the
597
- // detached group, so sweep the whole group — otherwise a boxed turn leaves a model still running
598
- // (and still billing) against a runner that has moved on.
599
- const cut = !!TURN_MAX_MS && (r.error?.code === "ETIMEDOUT" || (r.signal === "SIGKILL" && Date.now() - t0 >= TURN_MAX_MS));
600
- if (cut && r.pid) {
601
- try { process.kill(-r.pid, "SIGKILL"); } catch {}
602
- log(`\x1b[33mturn cut at the ${Math.round(TURN_MAX_MS / 1000)}s time box — CLI process group ended\x1b[0m`);
675
+ // The shell's box leaves the marker; the backstop leaves an ETIMEDOUT. Either way the turn was
676
+ // cut, not merely failed.
677
+ const boxed = existsSync(CUTF);
678
+ const cut = !!TURN_MAX_MS && (boxed || r.error?.code === "ETIMEDOUT");
679
+ if (cut) {
680
+ // Belt and braces after the shell's descendant sweep: anything still sharing the turn's group.
681
+ if (r.pid) { try { process.kill(-r.pid, "SIGKILL"); } catch {} }
682
+ try { unlinkSync(CUTF); } catch {}
683
+ log(`\x1b[33mturn cut at the ${Math.round(TURN_MAX_MS / 1000)}s time box — CLI and every descendant ended${boxed ? "" : " (node backstop: bash itself was wedged)"}\x1b[0m`);
603
684
  }
604
685
  // #5869: scrub AT REST, synchronously, before anything reads the file back. The explicit shell
605
686
  // wait above drains the live stderr scrubber first; this pass is defense in depth for redaction.
@@ -612,6 +693,10 @@ async function runTurn(prompt, isFirst, trigger = "kickoff") {
612
693
  try { ownOut = stripPromptEcho(readFileSync(ERRF, "utf8"), readPromptText(pf)); } catch { ownOut = ""; }
613
694
  lastErrText = ownOut.slice(-4000);
614
695
  if (cli.sid && r.stdout) { const m = r.stdout.match(cli.sid); if (m) sid = m[1]; }
696
+ // #6154: the opencode family prints no sid on stdout — the id comes from opencode's own DB,
697
+ // keyed by the worktree the session was created in. Fail-open: nothing found leaves sid empty,
698
+ // and the next turn starts fresh rather than resuming whatever other project ran last.
699
+ if (cli.pinned) { const found = ocSid(TURN_DIR); if (found) sid = found; }
615
700
  const realExit = r.status;
616
701
  // A zero exit is NOT proof the turn ran: opencode prints "401 Unauthorized" / "Invalid API key"
617
702
  // and exits 0, so a bare 0 made the runner ack "✅ done", clear the pending queue and heartbeat
@@ -651,7 +736,10 @@ async function runTurn(prompt, isFirst, trigger = "kickoff") {
651
736
  // #6134: what the turn COST, from the CLI's own usage line. Zero means this CLI printed none —
652
737
  // never that the turn was free. `trantor seat-why` totals these into today's spend per seat.
653
738
  const tokens = parseTurnTokens(ownOut);
654
- telemetry({ ts: Date.now(), agent: AGENT, project: PROJ, turn: TURN, trigger, model: MODEL || "cli-default", duration_ms: Date.now() - t0, exit: realExit, effExit, authFailed: effExit !== realExit, emptyOutput: lastEmptyOutput, verdict, ...(tokens ? { tokens } : {}), ...(cut ? { cut: true } : {}) });
739
+ const telemetryRow = { ts: Date.now(), agent: AGENT, project: PROJ, turn: TURN, trigger, model: MODEL || "cli-default", duration_ms: Date.now() - t0, exit: realExit, effExit, authFailed: effExit !== realExit, emptyOutput: lastEmptyOutput, verdict };
740
+ if (tokens) telemetryRow.tokens = tokens;
741
+ if (cut) telemetryRow.cut = true;
742
+ telemetry(telemetryRow);
655
743
  log(`turn ended (exit ${realExit}${effExit !== realExit ? ` → effective ${effExit} (${lastEmptyOutput ? "empty-output" : "auth"})` : ""}, ${((Date.now() - t0) / 1000).toFixed(0)}s)`);
656
744
  if (realExit === 0 && effExit === 0) { cmuxStatus("idle", "#8a94a6", "robot"); herdrAgent("idle"); } // finished this turn, waiting for the next
657
745
  // #5965 — TURN END. A clean exit means the seat is idle again; say so right away so the app stops
@@ -756,6 +844,20 @@ function shouldWake(message) {
756
844
  && (message.text.includes(`@${AGENT}`) || message.text.toLowerCase().includes(`${AGENT}:`));
757
845
  }
758
846
 
847
+ // #6228: the operator's declared project links, cached briefly so a burst of wakes doesn't hit
848
+ // /policy per message. Fails CLOSED on an unreachable hub (keeps whatever was last known, empty
849
+ // on a cold start) — the mechanical fence the hub enforces at write time must not go soft just
850
+ // because the read-side cache call happened to miss.
851
+ let linksCache = { at: 0, links: [] };
852
+ async function currentLinks() {
853
+ if (Date.now() - linksCache.at < 60000) return linksCache.links;
854
+ try {
855
+ const r = await api("/policy");
856
+ linksCache = { at: Date.now(), links: Array.isArray(r?.links) ? r.links : [] };
857
+ } catch {}
858
+ return linksCache.links;
859
+ }
860
+
759
861
  function askedExcerpt(message) {
760
862
  let text = String(message?.text || "").replace(/\s+/g, " ").trim();
761
863
  const nested = text.search(/\s+[·|]\s*asked\s*:/i);
@@ -861,7 +963,19 @@ function askedExcerpt(message) {
861
963
  // deafness problem: the seat would never learn what it was told (#6134).
862
964
  const bcast = [...rest.filter(m => !direct.includes(m) && !mentions.includes(m)), ...fyi];
863
965
  pendingBcast.push(...bcast); // wake-policy: plain broadcasts batch, they don't wake
864
- const wake = [...direct, ...mentions];
966
+ const wakeCandidates = [...direct, ...mentions];
967
+ // #6228: a wake naming another project (its sender's home project, not this seat's, and the
968
+ // two are not `trantor policy link`ed) is dropped without acting — never queued, never folded
969
+ // into context. One report goes back to the sender so it does not just look like silence.
970
+ const links = wakeCandidates.length ? await currentLinks() : [];
971
+ const crossProject = wakeCandidates.filter(m => !isLinkedProject(senderProjectOf(m.from), PROJ, links));
972
+ for (const m of crossProject) {
973
+ const sp = senderProjectOf(m.from) || "?";
974
+ log(`\x1b[33mcross-project wake dropped\x1b[0m — ${m.from} (${sp}) is not ${PROJ}'s project and the two are not linked`);
975
+ api("/send", { from: SESSION, to: m.from, project: sp, kind: "status",
976
+ text: `⛔ cross-project: ${SESSION} is ${PROJ}'s seat, not ${sp}'s — dropped without acting. Link them first: trantor policy link ${PROJ} ${sp} --reason "<why>"` }).catch(() => {});
977
+ }
978
+ const wake = wakeCandidates.filter(m => !crossProject.includes(m));
865
979
  if (!wake.length) { if (bcast.length) { savePending(pendingWake, pendingBcast); log(`${bcast.length} broadcast(s) batched (no wake) — ${pendingBcast.length} pending`); } continue; }
866
980
  // Queue BEFORE running the turn, and persist immediately. Everything between here and a clean
867
981
  // exit 0 — the CLI dying, the machine losing power — now leaves a record of what this seat owes.
@@ -927,11 +1041,18 @@ function askedExcerpt(message) {
927
1041
  // #6131: a silent turn on a seat whose plan reads spent is exhaustion wearing a crash's
928
1042
  // clothes. Only that one reason is ever re-read, and only from the seat's own balance rows.
929
1043
  let reason = classify(ec);
930
- if (reason === "empty-output") reason = reasonWithBalances(reason, await balanceRows());
1044
+ let quotaReset = 0;
1045
+ if (reason === "empty-output") {
1046
+ const rows = await balanceRows();
1047
+ reason = reasonWithBalances(reason, rows);
1048
+ // The rows that just proved the plan is spent also carry when it lifts — a park that names
1049
+ // the reset is a wait, a park that cannot is a seat the operator has to remember by hand.
1050
+ if (reason === "exhausted") quotaReset = quotaResetAt(rows);
1051
+ }
931
1052
  savePending(pendingWake, pendingBcast);
932
1053
  await reportFailure(ec, "message", pendingWake.length, reason);
933
1054
  if (PARKING_REASONS.has(reason)) {
934
- retryAt = await parkSeat(reason, pendingWake.length);
1055
+ retryAt = await parkSeat(reason, pendingWake.length, quotaReset);
935
1056
  await notifyAssigners(assigners,
936
1057
  `⛔ your contract is PARKED on ${SESSION} (${reason}) — not retrying · asked: "${asked}"`);
937
1058
  lastTurnAt = Date.now();
package/bin/crew.sh CHANGED
@@ -76,6 +76,40 @@ SEATDIR="$HOME/.agent-bus/seats"; mkdir -p "$SEATDIR"
76
76
  DRY="${CREW_DRY_RUN:-0}"
77
77
  run() { if [ "$DRY" = "1" ]; then echo "[dry] $*"; else eval "$*"; fi; }
78
78
 
79
+ # ── cross-project guard (#6228) ────────────────────────────────────────────────────────────────────
80
+ # The pr-os incident: an orchestrator pane badged TRANTOR_ORCH=pr-os ran `trantor up` while cd'd into
81
+ # the crebral-com worktree and brought up seats there under its OWN session. TRANTOR_ORCH (set once by
82
+ # `trantor open`, never per-invocation) and TRANTOR_SEAT (set once by crew-runner.mjs on every seat it
83
+ # spawns, see bin/crew-runner.mjs) are both STABLE badges of "this shell already has a project home" —
84
+ # unlike RELAY_PROJECT alone, which this same script's own tests legitimately override per-invocation
85
+ # to name a target explicitly (a bare `RELAY_PROJECT=x trantor up` from an unbadged shell is the
86
+ # operator, not a breach, and must keep working). A badged shell whose badge disagrees with the `up`
87
+ # target refuses mechanically — the CLI belt to the hub's own 403 (hub.mjs crossProjectGuard).
88
+ # A declared `trantor policy link` opens the door, same exception the hub and runner honor — so this
89
+ # belt must check, not just refuse on sight. `policy.mjs check` asks the hub; CREW_TEST_POLICY_LINKS
90
+ # is a hermetic override for test-crew.sh (comma-separated `a:b` pairs) so the drill never depends on
91
+ # a real hub being reachable, or on nothing already listening on the default loopback port.
92
+ if [ "$CMD" = "up" ]; then
93
+ BADGE="${TRANTOR_ORCH:-${TRANTOR_SEAT:-}}"
94
+ if [ -n "$BADGE" ] && [ "$BADGE" != "$PROJ" ]; then
95
+ LINKED=0
96
+ if [ -n "${CREW_TEST_POLICY_LINKS+x}" ]; then
97
+ case ",${CREW_TEST_POLICY_LINKS}," in
98
+ *",$BADGE:$PROJ,"*|*",$PROJ:$BADGE,"*) LINKED=1 ;;
99
+ esac
100
+ elif node "$BUS_DIR/bin/policy.mjs" check "$BADGE" "$PROJ" >/dev/null 2>&1; then
101
+ LINKED=1
102
+ fi
103
+ if [ "$LINKED" != "1" ]; then
104
+ echo "trantor: refused — this shell is badged for '$BADGE', not '$PROJ'." >&2
105
+ echo " Cross-project action is a breach unless the operator linked the projects." >&2
106
+ echo " Run: trantor policy link $BADGE $PROJ --reason \"<why>\"" >&2
107
+ exit 1
108
+ fi
109
+ echo "trantor: '$BADGE' ↔ '$PROJ' is policy-linked — proceeding." >&2
110
+ fi
111
+ fi
112
+
79
113
  # ── STATE helpers ──────────────────────────────────────────────────────────────────────────────────
80
114
  # Row schema (TSV): PROJECT <TAB> KIND <TAB> AGENT <TAB> HANDLE
81
115
  # KIND=win HANDLE=Terminal window id (one row per agent)
package/bin/policy.mjs CHANGED
@@ -2,7 +2,10 @@
2
2
  // trantor policy — the autonomy ladder's admin surface (PRD §6): show levels + links,
3
3
  // set a project's level, declare that two projects are codependent.
4
4
  // trantor policy show | set <project> <1-4> | link <a> <b> --reason "<why>" | unlink <a> <b>
5
+ // trantor policy check <a> <b> — exit 0 (prints "yes") if linked or identical, else exit 1 ("no")
5
6
  // Drafted by scrooge (deepseek-v4-flash), integrated by the orchestrator.
7
+ // `check` is what bin/crew.sh's cross-project guard (#6228) shells out to: the CLI belt needs to
8
+ // know whether the operator already linked the two projects before it refuses `trantor up`.
6
9
  import { readFileSync } from "node:fs";
7
10
  import { join } from "node:path";
8
11
  import { homedir } from "node:os";
@@ -30,7 +33,7 @@ const post = async (hub, payload) => {
30
33
 
31
34
  const legend = { 1: "1 observe", 2: "2 warn", 3: "3 gate", 4: "4 auto" };
32
35
  function usage() {
33
- console.log('usage: trantor policy show | set <project> <1-4> | link <a> <b> --reason "<why>" | unlink <a> <b>');
36
+ console.log('usage: trantor policy show | set <project> <1-4> | link <a> <b> --reason "<why>" | unlink <a> <b> | check <a> <b>');
34
37
  process.exit(1);
35
38
  }
36
39
 
@@ -80,4 +83,21 @@ if (cmd === "unlink") {
80
83
  process.exit(0);
81
84
  }
82
85
 
86
+ if (cmd === "check") {
87
+ if (!arg1 || !arg2) usage();
88
+ if (arg1 === arg2) { console.log("yes"); process.exit(0); }
89
+ let linked = false;
90
+ for (const hub of hubs) {
91
+ try {
92
+ const data = await get(hub);
93
+ if ((data.links || []).some((l) => {
94
+ const ps = (l.projects || []).map((p) => String(p).toLowerCase());
95
+ return ps.includes(arg1.toLowerCase()) && ps.includes(arg2.toLowerCase());
96
+ })) { linked = true; break; }
97
+ } catch { /* unreachable hub: fails closed, not linked */ }
98
+ }
99
+ console.log(linked ? "yes" : "no");
100
+ process.exit(linked ? 0 : 1);
101
+ }
102
+
83
103
  usage();
package/bin/slop-gate.mjs CHANGED
@@ -43,6 +43,21 @@ if (SURFACE) {
43
43
  // excluded legacy files (hub.mjs, mcp.mjs) stay excluded here too.
44
44
  const r = spawnSync("npx", ["oxlint", ...args], { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 });
45
45
  const out = (r.stdout || "") + (r.stderr || "");
46
+ // A gate that cannot run must not read as clean. Witnessed 2026-09-03: a seat worktree with no
47
+ // node_modules made `npx oxlint` die with an npm error, the empty output had zero hits, and
48
+ // "slop-gate clean" rode into main on 23 real anti-slop errors. the exit contract below is the
49
+ // only proof it ran; without it, exit 2 and say so.
50
+ // oxlint prints no run summary here (diagnostics only), so "it ran" is read from its exit
51
+ // contract: 0 = clean, 1 = diagnostics printed. An npm error, a launch error, an exit code oxlint
52
+ // never uses, a 1 with no diagnostic line, or "No files found" is not a verdict.
53
+ const diag = /:\d+:\d+: (error|warning)\b/.test(out);
54
+ const ran = !r.error && !/^npm (error|ERR!)/m.test(out) && !/No files found to lint/.test(out)
55
+ && (r.status === 0 || (r.status === 1 && diag));
56
+ if (!ran) {
57
+ console.error(out.trim().split("\n").slice(-6).join("\n"));
58
+ console.error("\nslop-gate: could not run oxlint, so there is no verdict and this is NOT clean. Install deps in this checkout (pnpm install) and rerun.");
59
+ process.exit(2);
60
+ }
46
61
  const hits = out.split("\n").filter(l => /error\s+anti-slop\(/.test(l));
47
62
 
48
63
  if (hits.length) {
@@ -11,7 +11,7 @@
11
11
  // wall when we know the window size. Both paths share a per-session guard so we
12
12
  // never write/spawn twice for the same context window.
13
13
  import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync, openSync, readSync, fstatSync, closeSync, rmSync } from "node:fs";
14
- import { join, basename, dirname } from "node:path";
14
+ import { join, basename, dirname, sep } from "node:path";
15
15
  import { homedir, hostname } from "node:os";
16
16
  import { execSync, spawn } from "node:child_process";
17
17
  import { fileURLToPath } from "node:url";
@@ -621,18 +621,52 @@ export function orchProjectForSession(sid) {
621
621
  return "";
622
622
  }
623
623
 
624
+ // Does the cwd actually lie inside THIS project's ground (#6218)? Two true homes: a directory
625
+ // named for the project (the project root or anything below it — the #6074 subfolder case), and
626
+ // the project's agent-bus worktrees (<bus>/worktrees/<project>/<seat>). Pure + exported so the
627
+ // drill can pin both without touching the operator's disk layout.
628
+ export function cwdInsideProject(projectName, dir, bus = process.env.AGENT_BUS_DIR || process.env.RELAY_DATA_DIR || join(homedir(), ".agent-bus")) {
629
+ if (!projectName || !dir) return false;
630
+ const wt = join(bus, "worktrees", projectName);
631
+ if (dir === wt || dir.startsWith(wt + sep)) return true;
632
+ let cur = dir;
633
+ for (;;) {
634
+ if (basename(cur) === projectName) return true;
635
+ const parent = dirname(cur);
636
+ if (parent === cur) return false;
637
+ cur = parent;
638
+ }
639
+ }
640
+
624
641
  // The one resolver. Returns { project, projectDir, pane, surface }:
625
642
  // pane — the session's own pane id ("" when it is not a hosted pane)
626
643
  // surface — "pane" (the baton MUST take the pane leg) or "window" (today's behavior)
627
644
  // project — the REGISTERED project name; a subfolder cwd never renames it
645
+ // #6218 — the badge wins only where it is TRUE. Witnessed 2026-09-03: a run from a
646
+ // trantor-badged shell in ~/development/tiny-timer wrote "handoff saved for trantor" with
647
+ // tiny-timer's transcript inside. #6074's registration-before-cwd rule was written for a
648
+ // subfolder cwd of the SAME project; a FOREIGN project dir is a different case and must not be
649
+ // silently relabeled. So the badge (and the rest of the registration chain behind it) claims
650
+ // this handoff only when the cwd lies inside the named project's directory or one of its
651
+ // worktrees; a badged shell sitting elsewhere resolves from the cwd, and ONE warning line names
652
+ // both — the badge that lied and the project the record actually went to.
628
653
  export function resolveHandoffSurface({ projectDir, sessionId, env = process.env } = {}) {
629
654
  const dir = projectDir || env.CLAUDE_PROJECT_DIR || process.cwd();
630
655
  const badge = String(env.TRANTOR_ORCH || "").trim();
631
656
  let project = "";
632
- if (badge && badge !== "1") project = badge; // `trantor open` badge carries the name
633
- if (!project && env.RELAY_PROJECT) project = String(env.RELAY_PROJECT).trim();
634
- if (!project) project = orchProjectForSession(sessionId);
635
- if (!project) project = resolveProject(dir); // last resort: cwd (git-root aware)
657
+ let foreignBadge = "";
658
+ if (badge && badge !== "1") { // `trantor open` badge carries the name
659
+ if (cwdInsideProject(badge, dir)) project = badge;
660
+ else foreignBadge = badge; // the badge lies about this cwd
661
+ }
662
+ if (!project && !foreignBadge && env.RELAY_PROJECT) project = String(env.RELAY_PROJECT).trim();
663
+ if (!project && !foreignBadge) project = orchProjectForSession(sessionId);
664
+ // Last resort: the cwd (git-root aware). With a foreign badge, the shell's own RELAY_PROJECT
665
+ // is scrubbed from the fallback's env — it is the same registration that just lied (#6218).
666
+ if (!project) project = resolveProject(dir, foreignBadge ? { ...env, RELAY_PROJECT: "" } : env);
667
+ if (foreignBadge) {
668
+ console.error(`trantor handoff: TRANTOR_ORCH=${foreignBadge} but the working directory (${dir}) is not inside ${foreignBadge}'s project or its worktrees — saving the handoff for ${project} (resolved from the cwd)`);
669
+ }
636
670
  return { project, projectDir: dir, pane: paneSurfaceEnv(env), surface: paneSurfaceEnv(env) ? "pane" : "window" };
637
671
  }
638
672
 
package/hub.mjs CHANGED
@@ -10,7 +10,7 @@ import { homedir, hostname } from "node:os";
10
10
  import { join } from "node:path";
11
11
  import { timingSafeEqual, randomBytes } from "node:crypto";
12
12
  import { verifyRequest, verifyEndorsement, publicView } from "./lib/identity.mjs";
13
- import { DEFAULT_ORG } from "./lib/store-contract.mjs";
13
+ import { DEFAULT_ORG, IDENTITY_KINDS } from "./lib/store-contract.mjs";
14
14
  import { assertNoSecrets } from "./lib/scrub.mjs";
15
15
  import { createPersistHealth } from "./lib/persist-health.mjs";
16
16
 
@@ -220,7 +220,12 @@ function normalizeState(loaded = {}) {
220
220
  // migrate old numeric form
221
221
  s.peers[session] = typeof v === "number"
222
222
  ? { lastSeen: v, status: "", project: "" }
223
- : { lastSeen: v.lastSeen || 0, status: v.status || "", project: v.project || "", pubkey: v.pubkey || "", identity: v.identity || null, authWarning: v.authWarning || "", hookVersion: v.hookVersion || "", deliveredUpTo: v.deliveredUpTo || v.delivered_up_to || 0, _on: v._on === true || v.online === true };
223
+ // #6170: `kind` must be carried across the load. This normalizer rebuilds every peer from an
224
+ // explicit field list, so a field missing here is dropped no matter how faithfully the store
225
+ // returned it — which is exactly what happened: the column was added, Postgres held the right
226
+ // values, and the kinds still came back empty on the first live restart. llm/model stay
227
+ // out on purpose: those ARE in-memory presence, re-supplied by the next heartbeat.
228
+ : { lastSeen: v.lastSeen || 0, status: v.status || "", project: v.project || "", pubkey: v.pubkey || "", identity: v.identity || null, authWarning: v.authWarning || "", hookVersion: v.hookVersion || "", kind: v.kind || "", deliveredUpTo: v.deliveredUpTo || v.delivered_up_to || 0, _on: v._on === true || v.online === true };
224
229
  }
225
230
  return s;
226
231
  }
@@ -1096,6 +1101,61 @@ function projectFromRequest(P, q, b) {
1096
1101
  }
1097
1102
  return canon(String(b?.project || q?.project || "").slice(0, 80));
1098
1103
  }
1104
+ // --- Cross-project guard (#6228) -----------------------------------------------------------
1105
+ // scopeAllows/canRead answer "can this identity touch project P at all" — and most identities
1106
+ // are minted project:"*" role:"owner" by defaultScopesFor (any non-agent kind: an orchestrator,
1107
+ // genesis, a human). That is not a project fence; it is exactly the loophole the pr-os
1108
+ // orchestrator walked through to register seats and post contracts into crebral-com from its
1109
+ // own session, nothing it was ever working. This is the fence: does the CALLER's own project
1110
+ // (its identity name's "kind:project" suffix, the same convention defaultScopesFor reads) match
1111
+ // the project the request ACTS on. Only a declared `trantor policy link` (state.orgPolicy.links,
1112
+ // the same store /policy reads and writes — POST /policy is itself OWNER_ENDPOINTS-gated) or the
1113
+ // operator's own identity (kind "human", the key /instance/supersede already treats as owner)
1114
+ // opens the door. Applies to the five endpoints that reach across sessions or mint access:
1115
+ // /send, /task, /task/update, /register, /invite.
1116
+ const CROSS_PROJECT_ENDPOINTS = new Set(["/send", "/task", "/task/update", "/register", "/invite"]);
1117
+ function projectsLinked(a, b) {
1118
+ if (!a || !b || a === b) return true;
1119
+ return overseerPolicy().links.some(l => {
1120
+ const ps = (l.projects || []).map(p => canon(p));
1121
+ return ps.includes(a) && ps.includes(b);
1122
+ });
1123
+ }
1124
+ // The caller's home project, by the SAME "name suffix after the colon" rule defaultScopesFor
1125
+ // uses to mint a fresh identity's default scope. An identity with no colon in its name (a bare
1126
+ // human alias, or a tool identity never given a project) has no home to fence — nothing to check.
1127
+ function callerProject(auth) {
1128
+ const name = String(auth?.identity?.name || "");
1129
+ return name.includes(":") ? canon(name.slice(name.lastIndexOf(":") + 1)) : "";
1130
+ }
1131
+ function crossProjectTarget(P, b) {
1132
+ if (P === "/send") {
1133
+ if (!b?.to || b.to === "all") return ""; // broadcast: existing hub-wide behavior, untouched
1134
+ const to = String(b.to);
1135
+ return canon(state.peers[to]?.project || (to.includes(":") ? to.slice(to.lastIndexOf(":") + 1) : ""));
1136
+ }
1137
+ if (P === "/task" || P === "/register") return canon(String(b?.project || "").slice(0, 80));
1138
+ if (P === "/task/update") {
1139
+ const t = state.tasks.find(x => x.id === Number(b?.id));
1140
+ return t?.project || "";
1141
+ }
1142
+ if (P === "/invite") {
1143
+ // an invite MINTS access into whatever project(s) its scopes name — a wildcard scope names none.
1144
+ const scopes = Array.isArray(b?.scopes) ? b.scopes : [];
1145
+ return scopes.map(s => canon(String(s?.project || ""))).find(p => p && p !== "*") || "";
1146
+ }
1147
+ return "";
1148
+ }
1149
+ function crossProjectGuard(auth, P, b) {
1150
+ if (!CROSS_PROJECT_ENDPOINTS.has(P) || AUTH_MODE === "off" || !auth?.identity) return { ok: true };
1151
+ if (auth.identity.kind === "human") return { ok: true }; // the operator's own key
1152
+ const home = callerProject(auth);
1153
+ if (!home) return { ok: true };
1154
+ const target = crossProjectTarget(P, b);
1155
+ if (!target || target === home || projectsLinked(home, target)) return { ok: true };
1156
+ return { ok: false, code: 403,
1157
+ error: `cross-project: ${home} may not act on ${target} — cross-project action is a breach unless the operator linked the projects. Run: trantor policy link ${home} ${target} --reason "<why>"` };
1158
+ }
1099
1159
  // Is a stored baton claim still worth honouring? The claim names the instance it spared
1100
1160
  // (`exceptInstanceId`), so we defer to THAT instance only while it is still being seen. A claimant
1101
1161
  // that died stops muzzling its twins; one that comes back starts again. Records claimed before
@@ -1447,6 +1507,10 @@ const server = http.createServer(async (req, res) => {
1447
1507
  const raw = req._rawBody || "";
1448
1508
  const verified = verifyRequest({ headers: req.headers, method: req.method, path: authPath(u), body: raw });
1449
1509
  if (!verified.ok) return json(res, 401, { error: verified.reason || "bad signature" });
1510
+ const requestedKind = String(b0.kind || "agent").slice(0, 40);
1511
+ if (!IDENTITY_KINDS.includes(requestedKind)) {
1512
+ return json(res, 400, { error: `kind must be one of: ${IDENTITY_KINDS.join(", ")}`, allowedKinds: IDENTITY_KINDS });
1513
+ }
1450
1514
  const existing = findIdentity(verified.pubkey);
1451
1515
  if (existing) return json(res, 200, { ok: true, identity: publicView(existing), scopes: existing.scopes || [] });
1452
1516
  let enrolledBy = "";
@@ -1482,7 +1546,7 @@ const server = http.createServer(async (req, res) => {
1482
1546
  }
1483
1547
  const identity = {
1484
1548
  name: String(b0.name || "").slice(0, 120) || verified.pubkey.slice(0, 16),
1485
- kind: String(b0.kind || "agent").slice(0, 40),
1549
+ kind: requestedKind,
1486
1550
  pubkey: verified.pubkey,
1487
1551
  createdAt: now(),
1488
1552
  enrolledBy,
@@ -1501,14 +1565,20 @@ const server = http.createServer(async (req, res) => {
1501
1565
  const az = authorize(auth, req.method, P, "*");
1502
1566
  if (!az.ok) return json(res, az.code || 403, { error: az.error || "forbidden" });
1503
1567
  const bi = await body(req);
1568
+ const invitedKind = String(bi.kind || "agent").slice(0, 40);
1569
+ if (!IDENTITY_KINDS.includes(invitedKind)) {
1570
+ return json(res, 400, { error: `kind must be one of: ${IDENTITY_KINDS.join(", ")}`, allowedKinds: IDENTITY_KINDS });
1571
+ }
1504
1572
  const scopes = (Array.isArray(bi.scopes) ? bi.scopes : []).map(cleanScope).filter(Boolean).slice(0, 20);
1505
1573
  if (!scopes.length) return json(res, 400, { error: "scopes required" });
1574
+ const cpg = crossProjectGuard(auth, P, { scopes });
1575
+ if (!cpg.ok) return json(res, cpg.code, { error: cpg.error });
1506
1576
  // Honour the requested TTL. A 60s FLOOR here silently inflated `ttlSec: 1` to a minute, so a
1507
1577
  // token the caller asked to expire in a second stayed valid — and an expired-token test passed
1508
1578
  // a live token straight through /enroll. Cap the ceiling, never the floor.
1509
1579
  const ttlSec = Math.min(Math.max(Number(bi.ttlSec) || 86400, 1), 30 * 86400);
1510
1580
  const token = randomBytes(24).toString("hex");
1511
- state.inviteTokens[token] = { scopes, expiresAt: now() + ttlSec * 1000, used: false,
1581
+ state.inviteTokens[token] = { scopes, kind: invitedKind, expiresAt: now() + ttlSec * 1000, used: false,
1512
1582
  createdBy: auth.identity?.pubkey || "", createdAt: now() };
1513
1583
  dirty = true;
1514
1584
  return json(res, 200, { ok: true, token, scopes, expiresAt: state.inviteTokens[token].expiresAt });
@@ -1517,7 +1587,10 @@ const server = http.createServer(async (req, res) => {
1517
1587
  const authz = authorize(auth, req.method, P, projectFromRequest(P, q, b0));
1518
1588
  if (!authz.ok) return json(res, authz.code || 403, { error: authz.error || "forbidden" });
1519
1589
  if (req.method === "POST" && P === "/register") {
1520
- const b = await body(req); touch(b.session, b.status, b.project, b.hookVersion, auth);
1590
+ const b = await body(req);
1591
+ const cpg = crossProjectGuard(auth, P, b);
1592
+ if (!cpg.ok) return json(res, cpg.code, { error: cpg.error });
1593
+ touch(b.session, b.status, b.project, b.hookVersion, auth);
1521
1594
  // WHO is this, really: the LLM brand + the exact model currently loaded. In-memory like the
1522
1595
  // rest of presence — the next heartbeat re-supplies it after a restart.
1523
1596
  const pr = state.peers[b.session];
@@ -1919,7 +1992,10 @@ const server = http.createServer(async (req, res) => {
1919
1992
  }
1920
1993
  // --- Kanban tasks ---
1921
1994
  if (req.method === "POST" && P === "/task") { // create a card
1922
- const b = await body(req); touch(b.by, undefined, b.project, undefined, auth);
1995
+ const b = await body(req);
1996
+ const cpg = crossProjectGuard(auth, P, b);
1997
+ if (!cpg.ok) return json(res, cpg.code, { error: cpg.error });
1998
+ touch(b.by, undefined, b.project, undefined, auth);
1923
1999
  if (b.title !== undefined) b.title = stripNulText(b.title);
1924
2000
  if (b.note !== undefined) b.note = stripNulText(b.note);
1925
2001
  const st0 = ["todo","doing","testing","failed","done","blocked"].includes(b.status) ? b.status : "todo";
@@ -2075,6 +2151,8 @@ const server = http.createServer(async (req, res) => {
2075
2151
  if (req.method === "POST" && P === "/task/update") { // move/edit a card
2076
2152
  const b = await body(req); const t = state.tasks.find(x => x.id === Number(b.id));
2077
2153
  if (!t) return json(res, 404, { error: "no such task" });
2154
+ const cpg = crossProjectGuard(auth, P, b);
2155
+ if (!cpg.ok) return json(res, cpg.code, { error: cpg.error });
2078
2156
  if (b.title !== undefined) b.title = stripNulText(b.title);
2079
2157
  if (b.note !== undefined) b.note = stripNulText(b.note);
2080
2158
  // Board integrity (#5406): a card can never change hands silently. The assignee is frozen once
@@ -2777,6 +2855,8 @@ const server = http.createServer(async (req, res) => {
2777
2855
  const secretCheck = assertNoSecrets(text);
2778
2856
  if (!secretCheck.ok) return json(res, 400, { error: "secret detected", kinds: secretCheck.kinds || [] });
2779
2857
  if (auth?.identity && String(b.from) !== String(auth.identity.name || "")) return json(res, 403, { error: "from must match signer" });
2858
+ const cpg = crossProjectGuard(auth, P, b);
2859
+ if (!cpg.ok) return json(res, cpg.code, { error: cpg.error });
2780
2860
  touch(b.from, undefined, undefined, undefined, auth);
2781
2861
  // attribute the message to a project so the dashboard can show it in that project's lane.
2782
2862
  // explicit b.project wins; else the sender's known project; else parsed from a "host:project" id.
package/lib/balances.mjs CHANGED
@@ -152,8 +152,61 @@ export const ADAPTERS = [
152
152
  return { remainingPct, plan, resetTime: head?.nextResetTime || null, detail: "" };
153
153
  },
154
154
  },
155
+ {
156
+ // #6131. The token plan's graded remaining-% lives ONLY behind the QwenCloud console's
157
+ // cookie-authenticated API (platform-api.qianwenai.com/tokenplan/personal/api/v2/usage — the
158
+ // console SPA calls it with a browser session; an API key gets the login page back). What the
159
+ // KEY can read is the plan's WALL: the inference gateway answers 429 insufficient_quota with a
160
+ // retry-after and the reset time the moment the 7-day plan is spent. That is exactly the state
161
+ // this row exists for — a seat that is about to go quiet — so it reads the wall, not the gauge,
162
+ // and says "unknown" rather than inventing a percentage it never measured.
163
+ //
164
+ // The probe consumes NO tokens. `messages: []` is an invalid request the model never sees, but
165
+ // the quota gate runs BEFORE that validation. Captured live 2026-09-03 against the spent plan:
166
+ // a real model + empty messages → the same 429 as a real completion; an unknown model → 404
167
+ // model_not_found; a bad key → 401 invalid_api_key. So the gate sits between model lookup and
168
+ // request validation, and an empty-messages probe is the cheapest thing that can trip it.
169
+ provider: "qwen", label: "Qwen", kind: "quota", match: ["qwen"], envKeys: ["QWEN_API_KEY"],
170
+ async fetch(key, env = {}) {
171
+ const base = String(env.QWEN_BASE_URL || "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1").replace(/\/+$/, "");
172
+ // Ask the plan which models it has rather than hard-coding an id that a rename would break
173
+ // (and this doubles as the auth check: a rejected key 401s here, before the probe).
174
+ const model = ((await getJSON(`${base}/models`, key)).data || [])[0]?.id;
175
+ if (!model) throw new Error("token plan lists no models");
176
+ const r = await fetch(`${base}/chat/completions`, {
177
+ method: "POST",
178
+ headers: { Authorization: `Bearer ${key}`, "content-type": "application/json", Accept: "application/json" },
179
+ body: JSON.stringify({ model, messages: [] }),
180
+ signal: AbortSignal.timeout(TIMEOUT),
181
+ });
182
+ let body; try { body = JSON.parse(await r.text()); } catch { body = null; }
183
+ const err = body?.error || {};
184
+ if (r.status === 401 || err.code === "invalid_api_key") throw new Error("invalid API key");
185
+ if (r.status === 429 || err.code === "insufficient_quota") {
186
+ // retry-after is authoritative (seconds, exact); the message carries the same instant as
187
+ // "MM-DD HH:MM:SS UTC" with no year, so it is only the fallback.
188
+ const secs = num(r.headers.get("retry-after"));
189
+ const resetTime = secs > 0 ? Date.now() + secs * 1000 : qwenResetFromMessage(err.message);
190
+ return { remainingPct: 0, plan: "token plan", resetTime, detail: "7-day token plan exhausted" };
191
+ }
192
+ // The gate let the request through, so the plan still has room — but how much is console-only.
193
+ return { remainingPct: null, plan: "token plan", resetTime: null, detail: "plan active (remaining % is console-only)" };
194
+ },
195
+ },
155
196
  ];
156
197
 
198
+ /// "The quota will reset at 09-09 14:32:00 UTC." — Qwen omits the year, so assume the current one
199
+ /// and roll forward when that lands in the past (the plan resets ahead of now, never behind it).
200
+ export function qwenResetFromMessage(msg, now = Date.now()) {
201
+ const m = /reset at (\d{2})-(\d{2}) (\d{2}):(\d{2})(?::(\d{2}))? UTC/i.exec(String(msg || ""));
202
+ if (!m) return null;
203
+ const [, mo, d, h, mi, s] = m;
204
+ const at = (year) => Date.UTC(year, Number(mo) - 1, Number(d), Number(h), Number(mi), Number(s || 0));
205
+ const year = new Date(now).getUTCFullYear();
206
+ const t = at(year);
207
+ return Number.isFinite(t) ? (t >= now ? t : at(year + 1)) : null;
208
+ }
209
+
157
210
  // thresholds: prepaid by currency, quota by percent-remaining. Override via config.json `lowBalance`
158
211
  // (currency keys) and `lowQuotaPct`.
159
212
  export const DEFAULT_LOW = { USD: 5, CNY: 35, EUR: 5 };
@@ -203,7 +256,8 @@ export async function fetchBalances(env = process.env, opts = {}) {
203
256
  const envKey = a.envKeys.find((k) => env[k]);
204
257
  if (!envKey && !a.keyless) return null; // configured but no key in env → can't query
205
258
  const base = { provider: a.provider, label: a.label, kind: a.kind, via: envKey || "oauth" };
206
- try { return { ...base, ok: true, ...(await a.fetch(envKey ? env[envKey] : undefined)) }; }
259
+ // `env` rides along for adapters that read more than a key (Qwen's base URL); the rest ignore it.
260
+ try { return { ...base, ok: true, ...(await a.fetch(envKey ? env[envKey] : undefined, env)) }; }
207
261
  catch (e) { return { ...base, ok: false, error: String(e?.message || e) }; }
208
262
  });
209
263
  const rows = (await Promise.all(jobs)).filter(Boolean);
@@ -278,7 +332,7 @@ export function fmtBalance(e) {
278
332
  }
279
333
 
280
334
  function fmtReset(t) {
281
- const ms = typeof t === "number" ? t : Date.parse(t);
335
+ const ms = Number.isFinite(t) ? t : Date.parse(t);
282
336
  if (!ms || isNaN(ms)) return "";
283
337
  const hrs = (ms - Date.now()) / 3600e3;
284
338
  if (hrs < 0) return "soon";
package/lib/enroll.mjs CHANGED
@@ -45,9 +45,10 @@ export async function ensureEnrolled(hubUrl, identity, project, { timeoutMs = 40
45
45
  if (!owner?.privkey) return { ok: false, reason: "no-owner-key" };
46
46
 
47
47
  try {
48
+ const identityKind = kind || identity.kind || "agent";
48
49
  const inv = await sfetch(`${hubUrl}/invite`, {
49
50
  method: "POST", headers: { "content-type": "application/json" },
50
- body: JSON.stringify({ scopes: [{ project, role: "write" }], ttlSec: 300 }),
51
+ body: JSON.stringify({ scopes: [{ project, role: "write" }], ttlSec: 300, kind: identityKind }),
51
52
  signal: AbortSignal.timeout(timeoutMs),
52
53
  }, owner);
53
54
  if (!inv.ok) return { ok: false, reason: `invite-${inv.status}` };
@@ -55,7 +56,7 @@ export async function ensureEnrolled(hubUrl, identity, project, { timeoutMs = 40
55
56
 
56
57
  const en = await sfetch(`${hubUrl}/enroll`, {
57
58
  method: "POST", headers: { "content-type": "application/json" },
58
- body: JSON.stringify({ name: identity.name, kind: kind || identity.kind || "agent", token }),
59
+ body: JSON.stringify({ name: identity.name, kind: identityKind, token }),
59
60
  signal: AbortSignal.timeout(timeoutMs),
60
61
  }, identity);
61
62
  return en.ok ? { ok: true, reason: "enrolled" } : { ok: false, reason: `enroll-${en.status}` };
package/lib/project.mjs CHANGED
@@ -18,8 +18,11 @@ export function gitRoot(dir) {
18
18
  }
19
19
 
20
20
  // Stable project key for a working directory. RELAY_PROJECT > git-root basename > cwd basename.
21
- export function resolveProject(cwd = process.cwd()) {
22
- if (process.env.RELAY_PROJECT) return process.env.RELAY_PROJECT.slice(0, 80);
21
+ // The env comes in as a parameter (#6218): the handoff resolver must be able to resolve the cwd
22
+ // WITHOUT the shell's RELAY_PROJECT — a badge that lied about the cwd must not get a second
23
+ // voice through the fallback's own env read.
24
+ export function resolveProject(cwd = process.cwd(), env = process.env) {
25
+ if (env.RELAY_PROJECT) return env.RELAY_PROJECT.slice(0, 80);
23
26
  const root = gitRoot(cwd);
24
27
  // A LINKED WORKTREE must resolve to its MAIN repo's name, not its own directory name. Seat
25
28
  // worktrees live at ~/.agent-bus/worktrees/<project>/<agent>, so the old basename rule named the
@@ -20,6 +20,9 @@
20
20
  // an empty schema is free; adding it after 1,542 cards have migrated is surgery on live data.
21
21
  // ---------------------------------------------------------------------------------------------
22
22
  export const SCHEMA_VERSION = 1;
23
+ export const IDENTITY_KINDS = Object.freeze(["human", "agent", "tool"]);
24
+
25
+ const IDENTITY_KINDS_SQL = IDENTITY_KINDS.map(kind => `'${kind}'`).join(",");
23
26
 
24
27
  export const SCHEMA_SQL = `
25
28
  CREATE TABLE IF NOT EXISTS orgs (
@@ -41,13 +44,19 @@ CREATE TABLE IF NOT EXISTS identities (
41
44
  pubkey TEXT PRIMARY KEY,
42
45
  org_id TEXT REFERENCES orgs(id) ON DELETE CASCADE,
43
46
  name TEXT NOT NULL,
44
- kind TEXT NOT NULL CHECK (kind IN ('human','agent')),
47
+ kind TEXT NOT NULL CHECK (kind IN (${IDENTITY_KINDS_SQL})),
45
48
  scopes JSONB NOT NULL DEFAULT '{}'::jsonb, -- { "<project>": "owner"|"write"|"read" }
46
49
  enrolled_by TEXT,
47
50
  created_at BIGINT NOT NULL,
48
51
  revoked_at BIGINT -- set, never deleted: revocation is audit
49
52
  );
50
53
 
54
+ -- 2026-09-03: installs created under CHECK (kind IN ('human','agent')) widen on boot; the genesis
55
+ -- identity enrols as 'tool' (#6068) and the narrower check poisoned every persist delta for 13 min.
56
+ ALTER TABLE identities DROP CONSTRAINT IF EXISTS identities_kind_check;
57
+ ALTER TABLE identities ADD CONSTRAINT identities_kind_check CHECK (kind IN (${IDENTITY_KINDS_SQL}));
58
+
59
+
51
60
  -- THE LOG. Append-only, never updated, never deleted except by retention. Everything else derives.
52
61
  CREATE TABLE IF NOT EXISTS events (
53
62
  id BIGSERIAL PRIMARY KEY,
@@ -120,8 +129,19 @@ CREATE TABLE IF NOT EXISTS peers (
120
129
  last_seen BIGINT,
121
130
  online BOOLEAN DEFAULT FALSE,
122
131
  delivered_up_to BIGINT DEFAULT 0,
132
+ kind TEXT, -- #6170: WHAT this session is — 'agent' (crew seat),
133
+ -- 'orch', 'genesis', 'tool'. The overseer's crew
134
+ -- exemption reads it (#6075/#6148), so when a restart
135
+ -- forgot it the hub warned about its own crew.
123
136
  PRIMARY KEY (org_id, session)
124
137
  );
138
+ -- additive migration for hubs whose peers table predates the kind column (#6170)
139
+ ALTER TABLE peers ADD COLUMN IF NOT EXISTS kind TEXT;
140
+ -- DELIBERATELY no CHECK on peers.kind, unlike identities.kind above. The hub accepts whatever a
141
+ -- client stamps (hub.mjs /register takes any string up to 40 chars) and the vocabulary grows with
142
+ -- the product — 'genesis' arrived in #6068, 'orch' in #6075. #6169 is the cost of getting this
143
+ -- wrong in the other direction: a CHECK narrower than the values in flight poisoned every persist
144
+ -- delta for 13 minutes. A column that stores what it is given cannot fail that way.
125
145
 
126
146
  -- The fields that currently ride in-memory and are LOST on restart. This is the debt being paid.
127
147
  CREATE TABLE IF NOT EXISTS kv (
package/lib/store-pg.mjs CHANGED
@@ -117,6 +117,7 @@ function peerFromRow(row) {
117
117
  lastSeen: row.last_seen == null ? 0 : Number(row.last_seen),
118
118
  online: !!row.online,
119
119
  deliveredUpTo: row.delivered_up_to == null ? 0 : Number(row.delivered_up_to),
120
+ kind: row.kind || "", // #6170: survives the restart that used to forget who was crew
120
121
  };
121
122
  }
122
123
 
@@ -332,8 +333,8 @@ export class PgStore {
332
333
  async touchPeer(orgId, session, patch = {}) {
333
334
  if (!session || session === "all") return;
334
335
  await this.pool.query(
335
- `INSERT INTO peers(session, org_id, pubkey, project, status, hook_version, last_seen, online, delivered_up_to)
336
- VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9)
336
+ `INSERT INTO peers(session, org_id, pubkey, project, status, hook_version, last_seen, online, delivered_up_to, kind)
337
+ VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
337
338
  ON CONFLICT(org_id, session) DO UPDATE SET
338
339
  pubkey=COALESCE(EXCLUDED.pubkey, peers.pubkey),
339
340
  project=COALESCE(NULLIF(EXCLUDED.project,''), peers.project),
@@ -341,11 +342,17 @@ export class PgStore {
341
342
  hook_version=COALESCE(NULLIF(EXCLUDED.hook_version,''), peers.hook_version),
342
343
  last_seen=EXCLUDED.last_seen,
343
344
  online=EXCLUDED.online,
344
- delivered_up_to=GREATEST(peers.delivered_up_to, EXCLUDED.delivered_up_to)`,
345
+ delivered_up_to=GREATEST(peers.delivered_up_to, EXCLUDED.delivered_up_to),
346
+ -- #6170: a KINDLESS beat must never demote a known peer. Most heartbeats carry no kind
347
+ -- (the MCP one only learns it under TRANTOR_ORCH), and plain last_seen refreshes are the
348
+ -- majority of writes here — overwriting on every one is how the orchestrator kept
349
+ -- reverting to a nameless agent between registrations.
350
+ kind=COALESCE(NULLIF(EXCLUDED.kind,''), peers.kind)`,
345
351
  [
346
352
  session, orgId, patch.pubkey || null, patch.project || "", patch.status ?? null,
347
353
  patch.hookVersion || patch.hook_version || "", ms(patch.lastSeen || patch.last_seen),
348
354
  patch.online ?? true, Number(patch.deliveredUpTo || patch.delivered_up_to || 0),
355
+ patch.kind || "",
349
356
  ],
350
357
  );
351
358
  }
@@ -474,11 +481,15 @@ export class PgStore {
474
481
  );
475
482
  for (const [session, p] of Object.entries(state.peers || {})) {
476
483
  await c.query(
477
- `INSERT INTO peers(session, org_id, pubkey, project, status, hook_version, last_seen, online, delivered_up_to)
478
- VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9)
484
+ `INSERT INTO peers(session, org_id, pubkey, project, status, hook_version, last_seen, online, delivered_up_to, kind)
485
+ VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
479
486
  ON CONFLICT(org_id, session) DO UPDATE SET pubkey=EXCLUDED.pubkey, project=EXCLUDED.project, status=EXCLUDED.status,
480
- hook_version=EXCLUDED.hook_version, last_seen=EXCLUDED.last_seen, online=EXCLUDED.online, delivered_up_to=GREATEST(peers.delivered_up_to, EXCLUDED.delivered_up_to)`,
481
- [session, orgId, p.pubkey || "", p.project || "", p.status || "", p.hookVersion || "", Number(p.lastSeen || 0), p._on === true || p.online === true, Number(p.deliveredUpTo || 0)],
487
+ hook_version=EXCLUDED.hook_version, last_seen=EXCLUDED.last_seen, online=EXCLUDED.online, delivered_up_to=GREATEST(peers.delivered_up_to, EXCLUDED.delivered_up_to),
488
+ -- #6170: same non-demoting rule as touchPeer. A snapshot is written from MEMORY, and
489
+ -- memory is exactly where the kind was being lost, so a blank in the snapshot means
490
+ -- "not known right now", never "this peer is nothing".
491
+ kind=COALESCE(NULLIF(EXCLUDED.kind,''), peers.kind)`,
492
+ [session, orgId, p.pubkey || "", p.project || "", p.status || "", p.hookVersion || "", Number(p.lastSeen || 0), p._on === true || p.online === true, Number(p.deliveredUpTo || 0), p.kind || ""],
482
493
  );
483
494
  }
484
495
  await c.query(
@@ -598,11 +609,15 @@ export class PgStore {
598
609
  if (messages.deletes.length) await c.query("DELETE FROM messages WHERE org_id=$1 AND id = ANY($2::bigint[])", [orgId, messages.deletes]);
599
610
  for (const p of peers.upserts) {
600
611
  await c.query(
601
- `INSERT INTO peers(session, org_id, pubkey, project, status, hook_version, last_seen, online, delivered_up_to)
602
- VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9)
612
+ `INSERT INTO peers(session, org_id, pubkey, project, status, hook_version, last_seen, online, delivered_up_to, kind)
613
+ VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
603
614
  ON CONFLICT(org_id, session) DO UPDATE SET pubkey=EXCLUDED.pubkey, project=EXCLUDED.project, status=EXCLUDED.status,
604
- hook_version=EXCLUDED.hook_version, last_seen=EXCLUDED.last_seen, online=EXCLUDED.online, delivered_up_to=GREATEST(peers.delivered_up_to, EXCLUDED.delivered_up_to)`,
605
- [p.session, orgId, p.pubkey || "", p.project || "", p.status || "", p.hookVersion || "", Number(p.lastSeen || 0), p._on === true || p.online === true, Number(p.deliveredUpTo || 0)],
615
+ hook_version=EXCLUDED.hook_version, last_seen=EXCLUDED.last_seen, online=EXCLUDED.online, delivered_up_to=GREATEST(peers.delivered_up_to, EXCLUDED.delivered_up_to),
616
+ -- #6170: THIS is the path a running hub actually persists through saveDelta on the
617
+ -- persist tick, not touchPeer or saveSnapshot. Adding the column to the other two and
618
+ -- not this one is why the first live restart still came back with empty kinds.
619
+ kind=COALESCE(NULLIF(EXCLUDED.kind,''), peers.kind)`,
620
+ [p.session, orgId, p.pubkey || "", p.project || "", p.status || "", p.hookVersion || "", Number(p.lastSeen || 0), p._on === true || p.online === true, Number(p.deliveredUpTo || 0), p.kind || ""],
606
621
  );
607
622
  }
608
623
  if (peers.deletes.length) await c.query("DELETE FROM peers WHERE org_id=$1 AND session = ANY($2::text[])", [orgId, peers.deletes]);
@@ -109,6 +109,42 @@ export function reasonWithBalances(reason, rows) {
109
109
  return reason === "empty-output" && quotaSpent(rows) ? "exhausted" : reason;
110
110
  }
111
111
 
112
+ /// #6131: when the seat said nothing, its balance row is the only place the reset time exists —
113
+ /// `parseResetAt` had no output to read. The EARLIEST spent row wins, because the seat is usable
114
+ /// again the moment the first of its walls lifts. 0 when no spent row named a time.
115
+ export function quotaResetAt(rows, now = Date.now()) {
116
+ const times = (Array.isArray(rows) ? rows : []).flatMap((r) => {
117
+ if (!r || !r.ok) return [];
118
+ if (r.kind === "quota") return r.remainingPct != null && r.remainingPct <= 0 ? [r.resetTime] : [];
119
+ if (r.kind === "windows") {
120
+ return (r.windows || []).filter((w) => w.locked || (w.usedPct != null && w.usedPct >= 100)).map((w) => w.resetsAt);
121
+ }
122
+ return [];
123
+ }).map((t) => (Number.isFinite(t) ? t : Date.parse(t))).filter((t) => Number.isFinite(t) && t > now);
124
+ return times.length ? Math.min(...times) : 0;
125
+ }
126
+
112
127
  /// Seats that park rather than retry. A backend error is the provider having a bad minute and the
113
128
  /// ladder is exactly right for it; a spent plan or a rejected key will not fix itself on a timer.
114
129
  export const PARKING_REASONS = new Set(["exhausted", "auth"]);
130
+
131
+ /// #6228: the sender's home project, by the same "name suffix after the last colon" convention
132
+ /// every crew/orch identity is minted with (isRunnerSession in crew-runner.mjs uses the mirror
133
+ /// check). A session id with no colon (a bare human alias) has no home project to fence.
134
+ export function senderProjectOf(session) {
135
+ const s = String(session || "");
136
+ return s.includes(":") ? s.slice(s.lastIndexOf(":") + 1) : "";
137
+ }
138
+
139
+ /// #6228: is a wake from `senderProject` allowed to reach a seat whose own project is
140
+ /// `seatProject`? Same project always is; a declared `trantor policy link` (the hub's own
141
+ /// state.orgPolicy.links, mirrored to every seat via GET /policy) opens the door for two named
142
+ /// projects; anything else is a cross-project wake and must be dropped, never worked — the
143
+ /// runner's half of the guard the hub enforces at write time.
144
+ export function isLinkedProject(senderProject, seatProject, links) {
145
+ if (!senderProject || !seatProject || senderProject === seatProject) return true;
146
+ return (Array.isArray(links) ? links : []).some((l) => {
147
+ const ps = (l?.projects || []).map((p) => String(p || "").toLowerCase());
148
+ return ps.includes(String(senderProject).toLowerCase()) && ps.includes(String(seatProject).toLowerCase());
149
+ });
150
+ }
package/mcp.mjs CHANGED
@@ -380,7 +380,7 @@ server.tool("relay_peers", "Find who you can talk to: the live agent sessions on
380
380
  return { content: [{ type: "text", text: lines.join("\n") || "no peers yet" }] };
381
381
  });
382
382
 
383
- server.tool("relay_send", "Send a live message to another agent session (or 'all' to broadcast). Reach the other agent YOURSELF: if you are about to ask the human to pass something along, tell the session directly instead — asking a person to carry a message between two agents is a failure, not politeness. Don't know the id? relay_peers lists them, linked projects included. Cross-project sends are allowed.",
383
+ server.tool("relay_send", "Send a live message to another agent session (or 'all' to broadcast). Reach the other agent YOURSELF: if you are about to ask the human to pass something along, tell the session directly instead — asking a person to carry a message between two agents is a failure, not politeness. Don't know the id? relay_peers lists them, linked projects included. Cross-project action is a breach unless the operator linked the projects (`trantor policy link <a> <b> --reason \"<why>\"`) — the hub answers 403 for a send into an unlinked project.",
384
384
  { to: z.string().describe("target session id, or 'all'"), text: z.string().describe("message body"),
385
385
  wake: z.boolean().optional().describe("false = context, not a contract: the message batches into the target's next turn instead of buying it a whole CLI session. Use it for acks, FYIs and queue notes; leave it unset for anything you expect worked on.") },
386
386
  async ({ to, text, wake }) => {
@@ -480,8 +480,15 @@ const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
480
480
  const nonProjectReason = nonSeatReason(projectDir);
481
481
  const isHomeDirSession = !!nonProjectReason;
482
482
 
483
+ // #6170: WHAT this session is, when it can know. sessionstart stamps kind "orch" on the
484
+ // orchestrator pane, but that runs once — every MCP beat afterwards was kindless, and the hub's
485
+ // crew exemption reads the peer row's kind, so the orchestrator kept being demoted by its own
486
+ // heartbeat and then warned about as an intruder on its own project. Same test sessionstart uses:
487
+ // TRANTOR_ORCH names the project this pane orchestrates.
488
+ const KIND = process.env.TRANTOR_ORCH && process.env.TRANTOR_ORCH === PROJECT ? { kind: "orch" } : {};
489
+
483
490
  if (!isHomeDirSession) {
484
- await api("POST", "/register", { session: SESSION, project: PROJECT, status: `active in ${PROJECT}`, hookVersion: MCP_VERSION })
491
+ await api("POST", "/register", { session: SESSION, project: PROJECT, status: `active in ${PROJECT}`, hookVersion: MCP_VERSION, ...KIND })
485
492
  .catch((err) => { process.stderr.write(`[trantor-mcp] initial register failed: ${err?.message || err}\n`); });
486
493
 
487
494
  // Heartbeat — keep this session's presence fresh for as long as the MCP process lives.
@@ -493,7 +500,7 @@ if (!isHomeDirSession) {
493
500
  // hub refreshes lastSeen but preserves the session's meaningful status. setInterval pauses during
494
501
  // sleep and fires on wake, so presence self-heals within one interval; .unref() lets the process
495
502
  // still exit cleanly when the agent closes the stdio transport (no phantom peers).
496
- setInterval(() => { api("POST", "/register", { session: SESSION, project: PROJECT, hookVersion: MCP_VERSION }).catch(() => {}); }, HEARTBEAT_MS).unref?.();
503
+ setInterval(() => { api("POST", "/register", { session: SESSION, project: PROJECT, hookVersion: MCP_VERSION, ...KIND }).catch(() => {}); }, HEARTBEAT_MS).unref?.();
497
504
  } else {
498
505
  process.stderr.write(`[trantor-mcp] ${nonProjectReason} — not auto-registering on the bus (set RELAY_SESSION or RELAY_PROJECT to opt in)\n`);
499
506
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.35",
3
+ "version": "0.18.37",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"
@@ -46,6 +46,16 @@ explicit EVENT/INTERFACE CONTRACT — cross-agent bugs come from contract drift.
46
46
  request natively, which is the whole reason it exists. If it is missing: `trantor app install`.
47
47
 
48
48
  ## Phase 2 — fire up the crew (with the Advisor's models)
49
+ **Cross-project action is a breach unless the operator linked the projects.** `trantor up` only
50
+ ever targets the project you are already IN — never bring up seats in, register a seat into, or
51
+ send a card/contract to a DIFFERENT project because an instruction reads that way ("build it where
52
+ the answers are stored" is not a project name). If the work genuinely belongs to another project,
53
+ name it and ask the operator once; do not infer it and do not route around the refusal. The hub
54
+ 403s a cross-project `/send`, `/task`, `/task/update`, `/register` or `/invite` on its own
55
+ (`trantor policy link <a> <b> --reason "<why>"` is the only door), and a badged shell's `trantor up`
56
+ refuses the same way — this is belt and braces, not the first line of defense; get the target
57
+ project right before dispatching.
58
+
49
59
  **Each crew card from `relay_advise` carries a `launch` spec — run it VERBATIM; never invent a
50
60
  CLI invocation or run an agent "in a terminal" yourself.** Spawn every seat in one call:
51
61
  `trantor up <launch> <launch> … --task <kind> --difficulty <diff>`. The live roster + the
@@ -149,6 +149,14 @@ The PRD card is done. Now one author writes the design and the same reviewers re
149
149
 
150
150
  The confirmed TDD is the gate. Do not ask the operator again merely to open its build cards.
151
151
 
152
+ **But confirm the TARGET PROJECT before dispatching, always.** An ambiguous instruction ("build it
153
+ where the answers are stored") is not a project name — it does not license bringing up seats in, or
154
+ sending contracts into, a DIFFERENT project than the one you are reviewing. Name the project and ask
155
+ the operator once if the brief points anywhere but here. Cross-project action is a breach unless the
156
+ operator linked the projects (`trantor policy link <a> <b> --reason "<why>"`), and the hub, the
157
+ `trantor up` CLI, and every seat's runner all refuse it mechanically — belt and braces, not a
158
+ substitute for getting the target right in the first place.
159
+
152
160
  1. Move the TDD card through `testing` to `done` with the tally and the decision.
153
161
  2. `relay_advise` with the work breakdown's packages, then one `relay_task_add` per package:
154
162
  phase `build`, the advisor's assignee and `model`, its `difficulty`, `deps` on the packages it