trantor 0.18.35 → 0.18.36

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.36",
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": {
@@ -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";
@@ -109,6 +109,26 @@ function ensureSeatWorktree(sourceDir) {
109
109
  }
110
110
 
111
111
  const TURN_DIR = ensureSeatWorktree(DIR);
112
+
113
+ // #6154: opencode prints no session id on stdout, but it records every session in its own sqlite
114
+ // DB with the directory the session was created in. The newest row for OUR worktree is the only
115
+ // session a resume may pin — anything else in that DB belongs to another project on this machine,
116
+ // which is exactly what `run -c` used to hand us. Read-only, fail-open: no DB or no row means the
117
+ // next turn starts fresh, which is always safe, instead of resuming a stranger, which never is.
118
+ const OC_DB = join(process.env.XDG_DATA_HOME || join(homedir(), ".local", "share"), "opencode", "opencode.db");
119
+ function ocSid(dir) {
120
+ try {
121
+ // opencode stores the directory as IT sees its cwd, which on macOS can be the /private/var
122
+ // realpath of the /var/... path the runner holds — query both spellings.
123
+ const dirs = [dir];
124
+ try { const real = realpathSync(dir); if (real !== dir) dirs.push(real); } catch {}
125
+ const list = dirs.map((d) => `'${d.replaceAll("'", "''")}'`).join(", ");
126
+ const q = `SELECT id FROM session WHERE directory IN (${list}) ORDER BY time_updated DESC LIMIT 1;`;
127
+ const r = spawnSync("sqlite3", ["-readonly", OC_DB, q], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 });
128
+ const id = String(r.stdout || "").trim();
129
+ return /^ses_[A-Za-z0-9]+$/.test(id) ? id : "";
130
+ } catch { return ""; }
131
+ }
112
132
  // RUNNER_SESSION override: an orchestrator seat (bin/orchestrate.mjs) runs the same CLI as a crew
113
133
  // seat but must live on the bus under its own name (claude-orch:proj), or it would collide with a
114
134
  // plain claude crew seat on the same project.
@@ -244,16 +264,25 @@ const CLI = {
244
264
  // --yolo in prompt mode (prompt mode auto-approves tools), and emits session_-prefixed ids.
245
265
  kimi: { first: `kimi{M} -p "$(cat {P})" < /dev/null`,
246
266
  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") },
267
+ // #6154: the opencode family never resumes blind. `run -c` continues the GLOBALLY last session
268
+ // on this machine any project's (opencode.db showed a pr-os session interleaved between two
269
+ // trantor ones) and the resumed session's stored directory becomes the Location every relative
270
+ // path resolves against. A seat then `cd desktop/src-tauri` inside its own worktree while
271
+ // opencode resolves it against a stranger's root, the bash tool reads it as external_directory
272
+ // and auto-rejects, and the turn dies mid-work with everything uncommitted. So: every spawn
273
+ // pins --dir to the seat worktree, and a resume pins -s to the session id looked up from
274
+ // opencode's own DB by directory — the session CREATED here (ocSid below). A missed lookup
275
+ // degrades to a fresh session, never to a foreign one.
276
+ deepseek: { first: `opencode run --dir {DIR}{M} "$(cat {P})"`,
277
+ next: `opencode run --dir {DIR} -s {SID}{M} "$(cat {P})"`, mflag: " -m ", pinned: true, env: join(homedir(), ".token-scrooge", ".env") },
278
+ opencode: { first: `opencode run --dir {DIR}{M} "$(cat {P})"`,
279
+ next: `opencode run --dir {DIR} -s {SID}{M} "$(cat {P})"`, mflag: " -m ", pinned: true, env: join(homedir(), ".token-scrooge", ".env") },
251
280
  // OpenRouter rides the opencode CLI exactly like deepseek/glm, but under its OWN agent label so
252
281
  // its bus identity is `openrouter:<project>` (RELAY_AGENT is set per-spawn) — never colliding with
253
282
  // the glm `opencode` seat. Model ids come pre-qualified (`openrouter/<vendor>/<model>`). Sources
254
283
  // 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") },
284
+ openrouter: { first: `opencode run --dir {DIR}{M} "$(cat {P})"`,
285
+ next: `opencode run --dir {DIR} -s {SID}{M} "$(cat {P})"`, mflag: " -m ", pinned: true, env: join(homedir(), ".token-scrooge", ".env") },
257
286
  claude: { first: `claude{M} -p "$(cat {P})" --dangerously-skip-permissions`,
258
287
  next: `claude -c{M} -p "$(cat {P})" --dangerously-skip-permissions`, mflag: " --model " },
259
288
  // DeepSeek Harness. Every turn is a FRESH session — headless has no resume yet — so the seat
@@ -275,7 +304,7 @@ if (!CLI[AGENT]) log(`'${AGENT}' is not a built-in seat — running it as an ope
275
304
 
276
305
  // RUNNER_RULES / RUNNER_KICKOFF env overrides: the runner is also the substrate for non-crew
277
306
  // 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.`;
307
+ 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.`;
279
308
 
280
309
  // ---- the pulse (Scape's Lloyd/Argus loop, Trantor-shaped) --------------------
281
310
  // A message-driven seat is DEAF between messages. An orchestrator seat with a mission needs a
@@ -520,9 +549,11 @@ async function runTurn(prompt, isFirst, trigger = "kickoff") {
520
549
  // exit-0 turn with real output must never be re-labelled "auth" by the #5405 escalation — the
521
550
  // qwen specimen committed aa3c340 while its captured stream still tripped the auth regex.
522
551
  const headBefore = gitOut(["rev-parse", "HEAD"], TURN_DIR);
523
- let cmd = (isFirst || (cli.sid && !sid)) ? cli.first : cli.next;
552
+ // #6154: a pinned seat with no sid yet resumes as FRESH — the guard below fails open, because
553
+ // a resume without an id must fall back to a new session, never to `next`'s bare resume shape.
554
+ let cmd = (isFirst || ((cli.sid || cli.pinned) && !sid)) ? cli.first : cli.next;
524
555
  const mfrag = MODEL && cli.mflag ? `${cli.mflag}${MODEL}` : "";
525
- cmd = cmd.replaceAll("{M}", mfrag).replaceAll("{P}", pf).replaceAll("{SID}", sid);
556
+ cmd = cmd.replaceAll("{M}", mfrag).replaceAll("{P}", pf).replaceAll("{SID}", sid).replaceAll("{DIR}", TURN_DIR);
526
557
  // PRECEDENCE, and it is easy to get backwards — this is the second time.
527
558
  // Each file is PREPENDED, so the one prepended LAST runs FIRST, and in shell the file that runs
528
559
  // LAST wins. To make ~/.agent-bus/.env (the CREW layer) win it must be prepended FIRST, i.e.
@@ -558,6 +589,10 @@ async function runTurn(prompt, isFirst, trigger = "kickoff") {
558
589
  // the window with no ERRF growth earns ONE direct stall report to the foreman, never a kill.
559
590
  const WD_MS = Number(process.env.TRANTOR_TURN_WATCHDOG_MS || 15 * 60 * 1000);
560
591
  const STAMPF = join(homedir(), ".agent-bus", `turnstamp-${AGENT}-${PROJ}.json`);
592
+ // Written by the shell's own time box (below) and read back here — the only honest signal that
593
+ // the turn was CUT rather than that the CLI failed on its own. Cleared before every turn.
594
+ const CUTF = join(homedir(), ".agent-bus", `turncut-${AGENT}-${PROJ}`);
595
+ try { unlinkSync(CUTF); } catch {}
561
596
  try {
562
597
  writeFileSync(STAMPF, JSON.stringify({ turn: TURN, startedAt: Date.now() }));
563
598
  const wd = spawn(process.execPath, [join(import.meta.dirname, "turn-watchdog.mjs"), STAMPF, ERRF, String(WD_MS), SESSION, PROJ, HUB],
@@ -567,7 +602,34 @@ async function runTurn(prompt, isFirst, trigger = "kickoff") {
567
602
  // Preserve the CLI's exit before waiting for the stderr process substitution. Without the
568
603
  // explicit wait, a short failing CLI can return while its error is still in the scrub pipe;
569
604
  // 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`;
605
+ // #6134-followup: the time box has to fire from INSIDE the shell, while the process tree is
606
+ // still standing. Killing the turn's process group from node missed a grandchild — codex runs
607
+ // its own commands via setsid, so `sleep 400` sat in a different group and survived
608
+ // process.kill(-pid). Worse, by the time node's timeout has killed bash the survivors have been
609
+ // reparented to init, so there is no tree left to walk and nothing to sweep.
610
+ //
611
+ // So bash boxes itself: at the deadline it walks its own descendants and kills them bottom-up.
612
+ // setsid changes a process's group and session but NEVER its parent, so `pgrep -P` recursion
613
+ // reaches exactly the children that a group signal cannot. Children first, then the parent, so
614
+ // nothing gets reparented mid-sweep and escapes the walk.
615
+ //
616
+ // The marker file is how node learns the turn was cut rather than merely failing: an exit status
617
+ // alone cannot tell "killed at the box" from "the CLI died on its own".
618
+ const sweep = `sweep() { local p; for p in $(pgrep -P $1 2>/dev/null); do sweep $p; done; kill -KILL $1 2>/dev/null; }`;
619
+ const box = TURN_MAX_MS ? `
620
+ ${sweep}
621
+ ( sleep ${Math.ceil(TURN_MAX_MS / 1000)}
622
+ kill -0 $job 2>/dev/null || exit 0
623
+ : > ${CUTF}
624
+ sweep $job
625
+ ) & boxpid=$!` : "boxpid=";
626
+ const shell = `set -o pipefail
627
+ { ${inner} ; } 2> >(${SCRUB} --tee2 ${ERRF}) &
628
+ job=$!${box}
629
+ wait $job; turn_exit=$?
630
+ [ -n "$boxpid" ] && kill $boxpid 2>/dev/null
631
+ wait
632
+ exit $turn_exit`;
571
633
  const r = spawnSync("/bin/bash", ["-c", shell], {
572
634
  // detached: bash leads its OWN process group, so the time box can kill the CLI and everything
573
635
  // it spawned with one signal instead of orphaning the model process behind a dead shell.
@@ -576,7 +638,10 @@ async function runTurn(prompt, isFirst, trigger = "kickoff") {
576
638
  // takes SIGTTIN and stops forever. Nothing here runs interactively — every CLI is in -p /
577
639
  // exec / run mode — so closing stdin is what makes the group safe.
578
640
  detached: true,
579
- ...(TURN_MAX_MS ? { timeout: TURN_MAX_MS, killSignal: "SIGKILL" } : {}),
641
+ // A BACKSTOP only, deliberately later than the shell's own box: if bash itself wedges, node
642
+ // still ends the turn. When the in-shell box works — the normal path — this never fires, which
643
+ // is the point: the shell kills while the tree is still walkable, node cannot.
644
+ ...(TURN_MAX_MS ? { timeout: TURN_MAX_MS + 30000, killSignal: "SIGKILL" } : {}),
580
645
  cwd: TURN_DIR, encoding: "utf8", stdio: cli.sid ? ["ignore", "pipe", "inherit"] : ["ignore", "inherit", "inherit"],
581
646
  env: { ...process.env, RELAY_URL: HUB, RELAY_AGENT: AGENT, RELAY_SESSION: SESSION, RELAY_PROJECT: PROJ,
582
647
  // A RUNNER-MANAGED SEAT MUST NEVER HAND ITSELF A BATON.
@@ -593,13 +658,15 @@ async function runTurn(prompt, isFirst, trigger = "kickoff") {
593
658
  maxBuffer: 16 * 1024 * 1024,
594
659
  });
595
660
  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`);
661
+ // The shell's box leaves the marker; the backstop leaves an ETIMEDOUT. Either way the turn was
662
+ // cut, not merely failed.
663
+ const boxed = existsSync(CUTF);
664
+ const cut = !!TURN_MAX_MS && (boxed || r.error?.code === "ETIMEDOUT");
665
+ if (cut) {
666
+ // Belt and braces after the shell's descendant sweep: anything still sharing the turn's group.
667
+ if (r.pid) { try { process.kill(-r.pid, "SIGKILL"); } catch {} }
668
+ try { unlinkSync(CUTF); } catch {}
669
+ 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
670
  }
604
671
  // #5869: scrub AT REST, synchronously, before anything reads the file back. The explicit shell
605
672
  // wait above drains the live stderr scrubber first; this pass is defense in depth for redaction.
@@ -612,6 +679,10 @@ async function runTurn(prompt, isFirst, trigger = "kickoff") {
612
679
  try { ownOut = stripPromptEcho(readFileSync(ERRF, "utf8"), readPromptText(pf)); } catch { ownOut = ""; }
613
680
  lastErrText = ownOut.slice(-4000);
614
681
  if (cli.sid && r.stdout) { const m = r.stdout.match(cli.sid); if (m) sid = m[1]; }
682
+ // #6154: the opencode family prints no sid on stdout — the id comes from opencode's own DB,
683
+ // keyed by the worktree the session was created in. Fail-open: nothing found leaves sid empty,
684
+ // and the next turn starts fresh rather than resuming whatever other project ran last.
685
+ if (cli.pinned) { const found = ocSid(TURN_DIR); if (found) sid = found; }
615
686
  const realExit = r.status;
616
687
  // A zero exit is NOT proof the turn ran: opencode prints "401 Unauthorized" / "Invalid API key"
617
688
  // and exits 0, so a bare 0 made the runner ack "✅ done", clear the pending queue and heartbeat
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
  }
@@ -1447,6 +1452,10 @@ const server = http.createServer(async (req, res) => {
1447
1452
  const raw = req._rawBody || "";
1448
1453
  const verified = verifyRequest({ headers: req.headers, method: req.method, path: authPath(u), body: raw });
1449
1454
  if (!verified.ok) return json(res, 401, { error: verified.reason || "bad signature" });
1455
+ const requestedKind = String(b0.kind || "agent").slice(0, 40);
1456
+ if (!IDENTITY_KINDS.includes(requestedKind)) {
1457
+ return json(res, 400, { error: `kind must be one of: ${IDENTITY_KINDS.join(", ")}`, allowedKinds: IDENTITY_KINDS });
1458
+ }
1450
1459
  const existing = findIdentity(verified.pubkey);
1451
1460
  if (existing) return json(res, 200, { ok: true, identity: publicView(existing), scopes: existing.scopes || [] });
1452
1461
  let enrolledBy = "";
@@ -1482,7 +1491,7 @@ const server = http.createServer(async (req, res) => {
1482
1491
  }
1483
1492
  const identity = {
1484
1493
  name: String(b0.name || "").slice(0, 120) || verified.pubkey.slice(0, 16),
1485
- kind: String(b0.kind || "agent").slice(0, 40),
1494
+ kind: requestedKind,
1486
1495
  pubkey: verified.pubkey,
1487
1496
  createdAt: now(),
1488
1497
  enrolledBy,
@@ -1501,6 +1510,10 @@ const server = http.createServer(async (req, res) => {
1501
1510
  const az = authorize(auth, req.method, P, "*");
1502
1511
  if (!az.ok) return json(res, az.code || 403, { error: az.error || "forbidden" });
1503
1512
  const bi = await body(req);
1513
+ const invitedKind = String(bi.kind || "agent").slice(0, 40);
1514
+ if (!IDENTITY_KINDS.includes(invitedKind)) {
1515
+ return json(res, 400, { error: `kind must be one of: ${IDENTITY_KINDS.join(", ")}`, allowedKinds: IDENTITY_KINDS });
1516
+ }
1504
1517
  const scopes = (Array.isArray(bi.scopes) ? bi.scopes : []).map(cleanScope).filter(Boolean).slice(0, 20);
1505
1518
  if (!scopes.length) return json(res, 400, { error: "scopes required" });
1506
1519
  // Honour the requested TTL. A 60s FLOOR here silently inflated `ttlSec: 1` to a minute, so a
@@ -1508,7 +1521,7 @@ const server = http.createServer(async (req, res) => {
1508
1521
  // a live token straight through /enroll. Cap the ceiling, never the floor.
1509
1522
  const ttlSec = Math.min(Math.max(Number(bi.ttlSec) || 86400, 1), 30 * 86400);
1510
1523
  const token = randomBytes(24).toString("hex");
1511
- state.inviteTokens[token] = { scopes, expiresAt: now() + ttlSec * 1000, used: false,
1524
+ state.inviteTokens[token] = { scopes, kind: invitedKind, expiresAt: now() + ttlSec * 1000, used: false,
1512
1525
  createdBy: auth.identity?.pubkey || "", createdAt: now() };
1513
1526
  dirty = true;
1514
1527
  return json(res, 200, { ok: true, token, scopes, expiresAt: state.inviteTokens[token].expiresAt });
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}` };
@@ -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]);
package/mcp.mjs CHANGED
@@ -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.36",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"