trantor 0.17.56 → 0.17.58

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.
@@ -13,7 +13,7 @@
13
13
  "name": "trantor",
14
14
  "source": "./",
15
15
  "description": "The hub-world for AI agent crews. Say \"fire up the crew\" and Claude becomes the architect: a plan-aware Advisor routes the work (solo / cheap inline calls / live crew of Codex, GLM, Kimi & DeepSeek in their own terminal windows), a Kanban/flow command center with a testing gate tracks it, and an economics brain (Scrooge) keeps the receipts. Includes the relay MCP, a SessionStart auto-discovery hook, and a PreCompact context-handoff so a fresh session can take over a full window instead of compacting.",
16
- "version": "0.17.56",
16
+ "version": "0.17.58",
17
17
  "author": {
18
18
  "name": "Sasha Bogojevic"
19
19
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.17.56",
3
+ "version": "0.17.58",
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/balances.mjs CHANGED
@@ -18,11 +18,8 @@ const noPush = args.includes("--no-push");
18
18
  // Only check providers the user configured in `trantor profile` — never stray keys in the ambient env.
19
19
  const configured = Object.keys(loadProfile().providers || {});
20
20
 
21
- function relayUrl() {
22
- if (process.env.RELAY_URL) return process.env.RELAY_URL;
23
- try { const c = join(homedir(), ".agent-bus", "config.json"); if (existsSync(c)) { const u = JSON.parse(readFileSync(c, "utf8")).url; if (u) return u; } } catch {}
24
- return "http://127.0.0.1:4477";
25
- }
21
+ // Signed via the shared client (2026-07-31, agent-UX audit): unsigned POST rejected under enforce.
22
+ import { signedPost } from "../hooks/lib/api.mjs";
26
23
  let _qpct = DEFAULT_LOW_QUOTA_PCT;
27
24
  function thresholds() {
28
25
  try { const c = JSON.parse(readFileSync(join(homedir(), ".agent-bus", "config.json"), "utf8")); if (typeof c.lowQuotaPct === "number") _qpct = c.lowQuotaPct; if (c.lowBalance && typeof c.lowBalance === "object") return { ...DEFAULT_LOW, ...c.lowBalance }; } catch {}
@@ -35,8 +32,7 @@ const low = thresholds();
35
32
  // push the snapshot to the hub (best-effort) so the dashboard + warning line can use it
36
33
  if (!noPush) {
37
34
  try {
38
- await fetch(`${relayUrl()}/balances`, { method: "POST", headers: { "content-type": "application/json" },
39
- body: JSON.stringify({ balances, ts: Date.now() }), signal: AbortSignal.timeout(2500) });
35
+ await signedPost("/balances", { balances, ts: Date.now() }, { timeoutMs: 2500 });
40
36
  } catch {}
41
37
  }
42
38
 
package/bin/catchup.mjs CHANGED
@@ -10,19 +10,20 @@ import { homedir } from "node:os";
10
10
  import { execSync } from "node:child_process";
11
11
  import { resolveProject } from "../lib/project.mjs";
12
12
 
13
- function relayUrl() {
14
- if (process.env.RELAY_URL) return process.env.RELAY_URL;
15
- try { const c = join(homedir(), ".agent-bus", "config.json"); if (existsSync(c)) { const u = JSON.parse(readFileSync(c, "utf8")).url; if (u) return u; } } catch {}
16
- return "http://127.0.0.1:4477";
17
- }
13
+ // Signed read via the shared client (2026-07-31, agent-UX audit): hand-rolled relayUrl missed the
14
+ // per-project hubs map; unsigned reads 401 under enforce. signedGet resolves + signs per call.
15
+ import { relayUrl, signedGet } from "../hooks/lib/api.mjs";
18
16
  const haveScrooge = () => { try { execSync("command -v scrooge", { stdio: "ignore" }); return true; } catch { return false; } };
19
17
 
20
18
  const dir = process.cwd();
21
19
  const project = resolveProject(dir);
22
- const url = relayUrl();
20
+ const url = relayUrl(project);
23
21
 
24
22
  let cu = null;
25
- try { cu = await (await fetch(`${url}/catchup?project=${encodeURIComponent(project)}`, { signal: AbortSignal.timeout(4000) })).json(); } catch (e) { console.error(`could not reach hub at ${url}: ${e.message}`); }
23
+ {
24
+ const r = await signedGet(`/catchup?project=${encodeURIComponent(project)}`, { timeoutMs: 4000 });
25
+ if (r.ok) cu = r.json; else console.error(`could not reach hub at ${url} (status ${r.status})`);
26
+ }
26
27
  let gitlog = "";
27
28
  try { gitlog = execSync(`git -C ${JSON.stringify(dir)} log --oneline -12 2>/dev/null`, { encoding: "utf8" }).trim(); } catch {}
28
29
 
@@ -24,12 +24,13 @@ const SINCE = si >= 0 ? Number(args.splice(si, 2)[1]) : NaN;
24
24
  const [PROJ, ...AGENTS] = args;
25
25
  if (!PROJ || !AGENTS.length) { console.error("usage: crew-verify.mjs <project> <agent...> [--timeout 30] [--since <ms>]"); process.exit(2); }
26
26
 
27
- function hubUrl() {
28
- if (process.env.RELAY_URL) return process.env.RELAY_URL;
29
- try { const u = JSON.parse(readFileSync(join(homedir(), ".agent-bus", "config.json"), "utf8")).url; if (u) return u; } catch {}
30
- return "http://127.0.0.1:4477";
31
- }
32
- const HUB = hubUrl();
27
+ // Signed + per-project hub (2026-07-31, agent-UX audit): unsigned /peers is a dead 401 under
28
+ // enforce — crew verification would report every seat missing while the crew was fine.
29
+ import { resolveHub, hostId } from "../lib/project.mjs";
30
+ import { loadOrCreate } from "../lib/identity.mjs";
31
+ import { sfetchJson } from "../lib/signed-fetch.mjs";
32
+ const HUB = process.env.RELAY_URL || resolveHub(PROJ);
33
+ const VERIFY_ID = loadOrCreate(process.env.RELAY_SESSION || `${hostId()}:${PROJ}`, "agent");
33
34
  // Two distinct clocks, deliberately separate:
34
35
  // - FRESH_SINCE: the freshness threshold. A registration counts only if lastSeen >= this.
35
36
  // Prefer the launcher's pre-spawn epoch (so an early "booting" beat counts); else our start.
@@ -43,7 +44,7 @@ const FRESH_SINCE = Number.isFinite(SINCE) ? SINCE : Date.now();
43
44
  const up = new Set();
44
45
  while (Date.now() < DEADLINE && up.size < want.size) {
45
46
  try {
46
- const { peers } = await (await fetch(`${HUB}/peers`)).json();
47
+ const { peers } = await (await sfetchJson(`${HUB}/peers`, { method: "GET", identity: VERIFY_ID })).json();
47
48
  for (const p of peers) if (want.has(p.session) && p.lastSeen >= FRESH_SINCE) up.add(p.session);
48
49
  } catch {}
49
50
  if (up.size < want.size) await new Promise(s => setTimeout(s, 1500));
package/bin/gates.mjs CHANGED
@@ -11,21 +11,20 @@ const args = process.argv.slice(2);
11
11
  const all = args.includes("--all");
12
12
  const asJson = args.includes("--json");
13
13
 
14
- function relayUrl() {
15
- if (process.env.RELAY_URL) return process.env.RELAY_URL;
16
- try { const u = JSON.parse(readFileSync(join(homedir(), ".agent-bus", "config.json"), "utf8")).url; if (u) return u; } catch {}
17
- return "http://127.0.0.1:4477";
18
- }
14
+ // Signed read via the shared client (2026-07-31, agent-UX audit): the hand-rolled relayUrl here
15
+ // missed the per-project hubs map AND sent unsigned (401 under enforce). relayUrl/signedGet from
16
+ // hooks/lib/api.mjs resolve the cwd project's hub and sign as this session.
17
+ import { relayUrl, signedGet } from "../hooks/lib/api.mjs";
19
18
 
20
19
  const project = resolveProject(process.cwd());
21
- const url = `${relayUrl()}/verify-gates?project=${encodeURIComponent(project)}${all ? "&all=1" : ""}`;
22
20
  let gates = [];
23
- try {
24
- const r = await fetch(url, { signal: AbortSignal.timeout(2500) });
25
- gates = (await r.json()).gates || [];
26
- } catch {
27
- console.error(`could not reach the hub at ${relayUrl()} — is it running? (trantor setup / trantor hub)`);
28
- process.exit(1);
21
+ {
22
+ const r = await signedGet(`/verify-gates?project=${encodeURIComponent(project)}${all ? "&all=1" : ""}`, { timeoutMs: 2500 });
23
+ if (!r.ok) {
24
+ console.error(`could not reach the hub at ${relayUrl(project)} (status ${r.status}) — is it running? (trantor setup / trantor hub)`);
25
+ process.exit(1);
26
+ }
27
+ gates = r.json?.gates || [];
29
28
  }
30
29
 
31
30
  if (asJson) { process.stdout.write(JSON.stringify(gates, null, 2) + "\n"); process.exit(0); }
@@ -11,18 +11,16 @@ import { join } from "node:path";
11
11
  import { homedir } from "node:os";
12
12
  import { resolveProject, hostId } from "../lib/project.mjs";
13
13
 
14
- function relayUrl() {
15
- if (process.env.RELAY_URL) return process.env.RELAY_URL;
16
- try { const c = join(homedir(), ".agent-bus", "config.json"); if (existsSync(c)) { const u = JSON.parse(readFileSync(c, "utf8")).url; if (u) return u; } } catch {}
17
- return "http://127.0.0.1:4477";
18
- }
14
+ // Signed via the shared client (2026-07-31, agent-UX audit): unsigned POST /task was rejected
15
+ // under enforce; hand-rolled relayUrl missed the per-project hubs map.
16
+ import { relayUrl, signedGet, signedPost } from "../hooks/lib/api.mjs";
19
17
  const args = process.argv.slice(2);
20
18
  const arg = (name, def) => { const i = args.indexOf("--" + name); return i >= 0 ? args[i + 1] : def; };
21
19
  const dir = process.cwd();
22
20
  const project = arg("project", resolveProject(dir));
23
21
  const since = arg("since", "14 days ago");
24
22
  const dry = args.includes("--dry-run");
25
- const url = relayUrl();
23
+ const url = relayUrl(project);
26
24
  const me = `${hostId()}:${project}`;
27
25
 
28
26
  const themeOf = (s) => {
@@ -53,7 +51,7 @@ for (const r of rows) {
53
51
  }
54
52
 
55
53
  let existing = new Set();
56
- try { const t = (await (await fetch(`${url}/tasks?project=${encodeURIComponent(project)}`)).json()).tasks || []; existing = new Set(t.map(x => x.title)); } catch {}
54
+ try { const r = await signedGet(`/tasks?project=${encodeURIComponent(project)}`, { session: me }); const t = r.json?.tasks || []; existing = new Set(t.map(x => x.title)); } catch {}
57
55
 
58
56
  const ents = [...groups.entries()].sort((a, b) => a[1].latest - b[1].latest);
59
57
  let posted = 0, skipped = 0;
@@ -68,8 +66,7 @@ for (const [theme, g] of ents) {
68
66
  if (existing.has(title)) { skipped++; continue; }
69
67
  if (dry) { console.log(`+ [${new Date(g.latest).toISOString().slice(0, 10)}] ${theme.padEnd(20)} ${g.commits.length}c ${title.slice(0, 64)}`); posted++; continue; }
70
68
  try {
71
- await fetch(`${url}/task`, { method: "POST", headers: { "content-type": "application/json" },
72
- body: JSON.stringify({ project, title, status: "done", phase: theme, source: "git", ts: g.latest, assignee: me, by: me }) });
69
+ await signedPost("/task", { project, title, status: "done", phase: theme, source: "git", ts: g.latest, assignee: me, by: me }, { session: me });
73
70
  posted++;
74
71
  } catch (e) { console.error(`post failed for "${title}": ${e.message}`); }
75
72
  }
package/bin/reconcile.mjs CHANGED
@@ -14,11 +14,10 @@ import { homedir } from "node:os";
14
14
  import { execSync, spawnSync } from "node:child_process";
15
15
  import { resolveProject } from "../lib/project.mjs";
16
16
 
17
- function relayUrl() {
18
- if (process.env.RELAY_URL) return process.env.RELAY_URL;
19
- try { const c = join(homedir(), ".agent-bus", "config.json"); if (existsSync(c)) { const u = JSON.parse(readFileSync(c, "utf8")).url; if (u) return u; } } catch {}
20
- return "http://127.0.0.1:4477";
21
- }
17
+ // Signed via the shared client (2026-07-31, agent-UX audit): the unsigned POST /task/update was
18
+ // rejected outright under enforce and merely flagged under warn; the hand-rolled relayUrl missed
19
+ // the per-project hubs map. signedGet/signedPost resolve the cwd project's hub + sign per call.
20
+ import { relayUrl, signedGet, signedPost } from "../hooks/lib/api.mjs";
22
21
  function parseDur(s, def) {
23
22
  if (!s) return def;
24
23
  const m = String(s).match(/^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?$/i);
@@ -38,16 +37,16 @@ const doIt = has("--yes", "-y");
38
37
  const difficulty = ["easy", "medium", "hard"].includes(val("--difficulty", "-d")) ? val("--difficulty", "-d") : "medium";
39
38
  const dir = process.cwd();
40
39
  const project = resolveProject(dir);
41
- const url = relayUrl();
40
+ const url = relayUrl(project);
42
41
 
43
42
  async function tasks() {
44
- const r = await fetch(`${url}/tasks?project=${encodeURIComponent(project)}`, { signal: AbortSignal.timeout(6000) });
45
- const j = await r.json();
46
- return Array.isArray(j) ? j : (j.tasks || j.cards || []);
43
+ const r = await signedGet(`/tasks?project=${encodeURIComponent(project)}`, { timeoutMs: 6000 });
44
+ if (!r.ok) return [];
45
+ const j = r.json;
46
+ return Array.isArray(j) ? j : (j?.tasks || j?.cards || []);
47
47
  }
48
48
  async function move(id, status) {
49
- await fetch(`${url}/task/update`, { method: "POST", headers: { "content-type": "application/json" },
50
- body: JSON.stringify({ id, status, by: "reconcile" }), signal: AbortSignal.timeout(4000) }).catch(() => {});
49
+ await signedPost("/task/update", { id, status, by: "reconcile" }, { timeoutMs: 4000 }).catch(() => {});
51
50
  }
52
51
  // the memory record for THIS project (Claude Code stores it per encoded-cwd); optional context.
53
52
  function memoryExcerpt() {
package/bin/recost.mjs CHANGED
@@ -10,11 +10,8 @@ import { join } from "node:path";
10
10
  import { homedir } from "node:os";
11
11
  import { scanSubagentCosts } from "../lib/subagent-scan.mjs";
12
12
 
13
- function relayUrl() {
14
- if (process.env.RELAY_URL) return process.env.RELAY_URL;
15
- try { const c = join(homedir(), ".agent-bus", "config.json"); if (existsSync(c)) { const u = JSON.parse(readFileSync(c, "utf8")).url; if (u) return u; } } catch {}
16
- return "http://127.0.0.1:4477";
17
- }
13
+ // Signed via the shared client (2026-07-31, agent-UX audit): unsigned POST rejected under enforce.
14
+ import { relayUrl, signedPost } from "../hooks/lib/api.mjs";
18
15
 
19
16
  const dry = process.argv.includes("--dry-run");
20
17
  const asJson = process.argv.includes("--json");
@@ -35,10 +32,11 @@ if (dry) {
35
32
  }
36
33
 
37
34
  let result;
38
- try {
39
- result = await fetch(`${relayUrl()}/subagent-recost`, { method: "POST", headers: { "content-type": "application/json" },
40
- body: JSON.stringify({ entries: allEntries }), signal: AbortSignal.timeout(15000) }).then(x => x.json());
41
- } catch (e) { console.error(`recost failed: ${e.message}`); process.exit(1); }
35
+ {
36
+ const r = await signedPost("/subagent-recost", { entries: allEntries }, { timeoutMs: 15000 });
37
+ if (!r.ok) { console.error(`recost failed: hub ${r.status} at ${relayUrl()}`); process.exit(1); }
38
+ result = r.json;
39
+ }
42
40
 
43
41
  const projs = (result?.projects || []);
44
42
  const seeded = projs.filter(p => !p.skipped);
@@ -2,22 +2,24 @@
2
2
  // relay-watch — a live feed of the bus via SSE (true push, no polling). Run in a terminal
3
3
  // to watch sessions talk in real time, or to monitor a presence/status board.
4
4
  // node bin/relay-watch.mjs [session] (default: "all" — see every message)
5
- import { readFileSync, existsSync } from "node:fs";
6
- import { join } from "node:path";
7
- import { homedir } from "node:os";
5
+ // Signed + per-project hub (2026-07-31, agent-UX audit): the hand-rolled relayUrl() here read
6
+ // only the global config `url` (wrong hub for a migrated project) and fetched unsigned (dead
7
+ // 401 under RELAY_AUTH=enforce). Now: canonical resolver + this session's keypair on every call.
8
+ import { resolveProject, hostId, resolveHub } from "../lib/project.mjs";
9
+ import { loadOrCreate } from "../lib/identity.mjs";
10
+ import { sfetch, sfetchJson } from "../lib/signed-fetch.mjs";
8
11
 
9
- function relayUrl() {
10
- if (process.env.RELAY_URL) return process.env.RELAY_URL;
11
- try { const c = join(homedir(), ".agent-bus", "config.json"); if (existsSync(c)) { const u = JSON.parse(readFileSync(c, "utf8")).url; if (u) return u; } } catch {}
12
- return "http://127.0.0.1:4477";
13
- }
14
- const URL_BASE = relayUrl();
12
+ const PROJECT = resolveProject(process.cwd());
13
+ const ME = process.env.RELAY_SESSION
14
+ || (process.env.RELAY_AGENT ? `${process.env.RELAY_AGENT}:${PROJECT}` : `${hostId()}:${PROJECT}`);
15
+ const identity = loadOrCreate(ME, "agent");
16
+ const URL_BASE = resolveHub(PROJECT);
15
17
  const SESSION = process.argv[2] || "all";
16
18
  const t = () => new Date().toLocaleTimeString();
17
19
 
18
20
  async function showPeers() {
19
21
  try {
20
- const { peers } = await (await fetch(`${URL_BASE}/peers`)).json();
22
+ const { peers } = await (await sfetchJson(`${URL_BASE}/peers`, { method: "GET", identity })).json();
21
23
  const live = peers.filter(p => p.online);
22
24
  console.log(`\n live sessions (${live.length}):`);
23
25
  for (const p of live) console.log(` 🟢 ${p.session}${p.status ? ` — ${p.status}` : ""}`);
@@ -30,7 +32,7 @@ async function watch() {
30
32
  await showPeers();
31
33
  for (;;) {
32
34
  try {
33
- const r = await fetch(`${URL_BASE}/stream?session=${encodeURIComponent(SESSION)}`, { headers: { accept: "text/event-stream" } });
35
+ const r = await sfetch(`${URL_BASE}/stream?session=${encodeURIComponent(SESSION)}`, { headers: { accept: "text/event-stream" } }, identity);
34
36
  if (!r.ok || !r.body) throw new Error(`stream ${r.status}`);
35
37
  let buf = "";
36
38
  const dec = new TextDecoder();
@@ -8,19 +8,18 @@ import { readFileSync, existsSync } from "node:fs";
8
8
  import { join, basename } from "node:path";
9
9
  import { homedir, hostname } from "node:os";
10
10
 
11
- function relayUrl() {
12
- if (process.env.RELAY_URL) return process.env.RELAY_URL;
13
- try { const c = join(homedir(), ".agent-bus", "config.json"); if (existsSync(c)) { const u = JSON.parse(readFileSync(c, "utf8")).url; if (u) return u; } } catch {}
14
- return "http://127.0.0.1:4477";
15
- }
11
+ // Signed read via the shared client (2026-07-31, agent-UX audit): unsigned /peers is a dead 401
12
+ // under enforce, which painted "trantor offline" while the hub was fine.
13
+ import { sessionContext, signedGet } from "../hooks/lib/api.mjs";
16
14
  async function main() {
17
15
  let stdin = ""; try { for await (const c of process.stdin) stdin += c; } catch {}
18
16
  let cwd = process.env.CLAUDE_PROJECT_DIR || process.cwd();
19
17
  try { const j = JSON.parse(stdin || "{}"); cwd = j.cwd || j.workspace?.current_dir || cwd; } catch {}
20
- const me = process.env.RELAY_SESSION || `${hostname()}:${basename(cwd)}`;
18
+ const me = process.env.RELAY_SESSION || sessionContext(cwd).session;
21
19
  try {
22
- const r = await fetch(`${relayUrl()}/peers`, { signal: AbortSignal.timeout(800) });
23
- const { peers } = await r.json();
20
+ const r = await signedGet("/peers", { timeoutMs: 800, session: me });
21
+ if (!r.ok) throw new Error(`hub ${r.status}`);
22
+ const { peers } = r.json;
24
23
  const live = peers.filter(p => p.online && p.session !== me).length;
25
24
  process.stdout.write(`\x1b[38;5;43m● trantor\x1b[0m \x1b[2m· ${live} other${live === 1 ? "" : "s"} live\x1b[0m`);
26
25
  } catch {
package/bin/sweep.mjs CHANGED
@@ -12,11 +12,11 @@ import { join } from "node:path";
12
12
  import { homedir } from "node:os";
13
13
  import { resolveProject } from "../lib/project.mjs";
14
14
 
15
- function relayUrl() {
16
- if (process.env.RELAY_URL) return process.env.RELAY_URL;
17
- try { const c = join(homedir(), ".agent-bus", "config.json"); if (existsSync(c)) { const u = JSON.parse(readFileSync(c, "utf8")).url; if (u) return u; } } catch {}
18
- return "http://127.0.0.1:4477";
19
- }
15
+ // Signed as the OWNER (2026-07-31, agent-UX audit): /sweep is an owner endpoint — an unsigned
16
+ // POST is rejected under enforce. Sign with config.ownerIdentity, same pattern as bin/policy.mjs.
17
+ import { relayUrl } from "../hooks/lib/api.mjs";
18
+ import { loadOrCreate } from "../lib/identity.mjs";
19
+ import { sfetchJson } from "../lib/signed-fetch.mjs";
20
20
  function parseDur(s, def) {
21
21
  if (!s) return def;
22
22
  const m = String(s).match(/^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?$/i);
@@ -33,12 +33,15 @@ const olderMs = parseDur(val("--older", "-o"), 30 * 60 * 1000);
33
33
  const doIt = has("--yes", "-y");
34
34
  const allProjects = has("--all-projects", "--all");
35
35
  const project = allProjects ? null : resolveProject(process.cwd());
36
- const url = relayUrl();
36
+ const url = relayUrl(project || undefined);
37
+ let ownerName = "admin";
38
+ try { ownerName = JSON.parse(readFileSync(join(homedir(), ".agent-bus", "config.json"), "utf8")).ownerIdentity || "admin"; } catch {}
39
+ const ownerId = loadOrCreate(ownerName, "human");
37
40
 
38
41
  async function sweep(dryRun) {
39
42
  const body = { olderMs, dryRun, by: "cli-sweep" };
40
43
  if (project) body.project = project;
41
- const r = await fetch(`${url}/sweep`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), signal: AbortSignal.timeout(6000) });
44
+ const r = await sfetchJson(`${url}/sweep`, { identity: ownerId, payload: body, signal: AbortSignal.timeout(6000) });
42
45
  return r.json();
43
46
  }
44
47
 
@@ -24,7 +24,7 @@ import { readFileSync, writeFileSync, existsSync } from "node:fs";
24
24
  import { join } from "node:path";
25
25
  import { homedir } from "node:os";
26
26
  import { resolveProject, hostId } from "../lib/project.mjs";
27
- import { getJSON } from "./lib/api.mjs";
27
+ import { signedGet } from "./lib/api.mjs"; // signed: enforce hubs 401 unsigned reads — unsigned, T1 delivery is silently dead
28
28
 
29
29
  const POLL_MS = Number(process.env.RELAY_INBOX_POLL_MS || 4000);
30
30
  const FETCH_TIMEOUT_MS = Number(process.env.RELAY_INBOX_TIMEOUT_MS || 1500);
@@ -33,10 +33,10 @@ const FETCH_TIMEOUT_MS = Number(process.env.RELAY_INBOX_TIMEOUT_MS || 1500);
33
33
  // additionalContext payload (the model still gets the readable message).
34
34
  function sanitize(s) { return String(s == null ? "" : s).replace(/[\x00-\x1f\x7f-\x9f]/g, " "); }
35
35
 
36
- async function getInbox(session, since) {
37
- const { ok, json } = await getJSON(`/inbox?session=${encodeURIComponent(session)}&since=${since}`, { timeoutMs: FETCH_TIMEOUT_MS });
36
+ async function getInbox(session, since, instance) {
37
+ const { ok, json } = await signedGet(`/inbox?session=${encodeURIComponent(session)}&since=${since}`, { timeoutMs: FETCH_TIMEOUT_MS, session, instance });
38
38
  if (!ok || !json) throw new Error("hub unreachable");
39
- return json; // { messages: [...], cursor }
39
+ return json; // { messages: [...], cursor, superseded? }
40
40
  }
41
41
 
42
42
  // PostToolUse hands us the tool-input JSON on stdin. We don't need it, but we must DRAIN it:
@@ -58,7 +58,12 @@ function emit(ctx) {
58
58
  try { JSON.parse(out); return out; } catch { return "{}"; }
59
59
  }
60
60
 
61
- async function main() {
61
+ async function main(stdinRaw) {
62
+ // The harness session_id is this session's INSTANCE id (docs/INSTANCE-KEYS-CONTRACT.md): it keys
63
+ // the endorsed subkey that signs our reads AND the local cursor, so a baton twin (same durable
64
+ // name, different session_id) has its own ledger and can't eat this session's messages.
65
+ let instanceId = "";
66
+ try { instanceId = String(JSON.parse(stdinRaw || "{}").session_id || ""); } catch {}
62
67
  const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
63
68
  // Mirror heartbeat.mjs / sessionstart.mjs: a home-directory session isn't project work and
64
69
  // isn't on the bus — nothing to deliver. Opt in with RELAY_SESSION / RELAY_PROJECT.
@@ -70,7 +75,7 @@ async function main() {
70
75
  const session = process.env.RELAY_SESSION
71
76
  || (process.env.RELAY_AGENT ? `${process.env.RELAY_AGENT}:${project}` : `${hostId()}:${project}`);
72
77
 
73
- const safe = session.replace(/[^A-Za-z0-9_.-]/g, "_");
78
+ const safe = (session + (instanceId ? `@${instanceId.slice(0, 8)}` : "")).replace(/[^A-Za-z0-9_.@-]/g, "_");
74
79
  const dir = join(homedir(), ".agent-bus");
75
80
  const pollStamp = join(dir, `inbox-poll-${safe}.stamp`);
76
81
  const cursorFile = join(dir, `inbox-cursor-${safe}.id`);
@@ -89,7 +94,7 @@ async function main() {
89
94
  // so we start listening "from now" instead of replaying the whole backlog of old broadcasts.
90
95
  if (!existsSync(cursorFile)) {
91
96
  try {
92
- const { cursor } = await getInbox(session, 0);
97
+ const { cursor } = await getInbox(session, 0, instanceId);
93
98
  writeFileSync(cursorFile, String(cursor || 0));
94
99
  } catch {}
95
100
  return "{}";
@@ -98,13 +103,19 @@ async function main() {
98
103
  let cursor = 0;
99
104
  try { cursor = Number(readFileSync(cursorFile, "utf8")) || 0; } catch {}
100
105
 
101
- let messages = [], next = cursor;
106
+ let messages = [], next = cursor, superseded = false;
102
107
  try {
103
- const res = await getInbox(session, cursor);
108
+ const res = await getInbox(session, cursor, instanceId);
104
109
  messages = Array.isArray(res.messages) ? res.messages : [];
105
110
  next = res.cursor || cursor;
111
+ superseded = res.superseded === true;
106
112
  } catch { return "{}"; } // hub down / timeout — never block the tool flow
107
113
 
114
+ // Stand-down note (never a block): a newer instance of this durable identity claimed the baton.
115
+ if (superseded && !messages.length) {
116
+ return emit(`<trantor-inbox count="0">\n⚠️ A newer instance of this session has claimed the baton (instance supersession). Stand down: finish your current thought, do not consume bus messages, and let the new session carry the work.\n</trantor-inbox>\n`);
117
+ }
118
+
108
119
  if (!messages.length) return "{}";
109
120
 
110
121
  // Advance the cursor immediately so we don't re-inject these on the next tool call.
@@ -119,6 +130,7 @@ async function main() {
119
130
 
120
131
  const ctx =
121
132
  `<trantor-inbox count="${messages.length}">\n` +
133
+ (superseded ? `⚠️ A newer instance of this session has claimed the baton — stand down after handling anything addressed directly to you; the new session carries the work.\n` : "") +
122
134
  `📬 ${messages.length} new bus message(s) arrived while you were working (you did not poll for these — Trantor surfaced them automatically):\n` +
123
135
  lines.join("\n") + `\n` +
124
136
  `If a peer is asking you something or waiting on you, reply now with the relay_send tool (to their session id). ` +
package/hooks/lib/api.mjs CHANGED
Binary file
@@ -71,11 +71,14 @@ function readStdin() {
71
71
  process.stdin.on("data", c => (d += c)); process.stdin.on("end", () => res(d));
72
72
  setTimeout(() => res(d), 100); });
73
73
  }
74
- // Hub reads/writes through the shared client (TDD §8). Writes are SIGNED (Ed25519, via signedPost);
75
- // reads go unsigned for now (see api.mjs:getJSON — pending the hub's DM scope-filtering fix). Each
76
- // resolves to {ok,status,json}; a down hub returns {ok:false,json:null} and the caller's catch keeps
77
- // the session alive (fail-open contract, acceptance §9 #10).
78
- async function jget(u, session) { const r = await getJSON(u, { timeoutMs: 2500 }); return r.ok ? (r.json || {}) : {}; }
74
+ // Hub reads/writes through the shared client (TDD §8), ALL signed (Ed25519). Writes via signedPost
75
+ // close the self-asserted `from` hole; reads via signedGet survive RELAY_AUTH=enforce, which 401s
76
+ // unsigned reads (unsigned, the roster/handoff injection was silently dead on the remote hub — the
77
+ // 2026-07-30 agent-UX gap). The hub scope-filters signed reads to this identity's grants; for a
78
+ // session reading its own project + declared links that is the intended shape. Each resolves to
79
+ // {ok,status,json}; a down hub returns {ok:false,json:null} and the caller's catch keeps the
80
+ // session alive (fail-open contract, acceptance §9 #10).
81
+ async function jget(u, session) { const r = await signedGet(u, { timeoutMs: 2500, session }); return r.ok ? (r.json || {}) : {}; }
79
82
  async function jpost(u, b, session) { return (await signedPost(u, b, { session, timeoutMs: 2500 })).ok; }
80
83
 
81
84
  // Strip control chars from untrusted injected text so the hook's JSON stdout (which
@@ -239,6 +242,12 @@ try {
239
242
  });
240
243
  if (handoff) {
241
244
  process.stderr.write(`[trantor] ${isCompact ? "showing (not claiming, compact)" : "loaded"} pending handoff ${handoff.id}\n`);
245
+ // Baton claimed → supersede every OTHER instance of this durable identity (instance-keys
246
+ // contract). The dying twin's next /inbox or /poll answer tells its model to stand down —
247
+ // the hub-enforced end of the twin message race. Best-effort; compact shows don't claim.
248
+ if (!isCompact && stdinObj.session_id) {
249
+ await jpost(`${url}/instance/supersede`, { name: session, exceptInstanceId: String(stdinObj.session_id) }, session).catch(() => {});
250
+ }
242
251
  additionalContext += `<trantor-handoff id="${sanitize(handoff.id)}" from="${sanitize(handoff.machine)}" trigger="${sanitize(handoff.trigger)}">\n`;
243
252
  additionalContext += `🔄 **You are taking over from a prior session that hit its context limit.** This is a fresh full window. Resume the work below — the prior session's summary, git state, and a pointer to its full transcript (searchable; Foundation/Gaia has it ingested) follow. Continue from "OPEN THREADS & NEXT STEPS"; do not restart from scratch.\n\n`;
244
253
  // Verification gates FIRST — these are structured "must verify before shipping" claims the prior
@@ -25,7 +25,7 @@ import { readFileSync, writeFileSync, existsSync } from "node:fs";
25
25
  import { join } from "node:path";
26
26
  import { homedir } from "node:os";
27
27
  import { resolveProject, hostId } from "../lib/project.mjs";
28
- import { getJSON } from "./lib/api.mjs";
28
+ import { signedGet } from "./lib/api.mjs"; // signed: enforce hubs 401 unsigned reads — unsigned, T2 delivery is silently dead
29
29
 
30
30
  const FETCH_TIMEOUT_MS = Number(process.env.RELAY_STOP_TIMEOUT_MS || 1500);
31
31
 
@@ -63,7 +63,10 @@ async function main() {
63
63
 
64
64
  // Share inbox-deliver.mjs's cursor: ONE local delivery ledger, so a message injected mid-turn is never
65
65
  // re-surfaced here, and vice versa.
66
- const safe = session.replace(/[^A-Za-z0-9_.-]/g, "_");
66
+ // Same instance id + per-instance cursor as inbox-deliver (docs/INSTANCE-KEYS-CONTRACT.md):
67
+ // T1 and T2 share one ledger within a session; a baton twin gets its own.
68
+ const instanceId = String(input.session_id || "");
69
+ const safe = (session + (instanceId ? `@${instanceId.slice(0, 8)}` : "")).replace(/[^A-Za-z0-9_.@-]/g, "_");
67
70
  const cursorFile = join(homedir(), ".agent-bus", `inbox-cursor-${safe}.id`);
68
71
  // No cursor yet means inbox-deliver has never run for this session; it initialises to "now" on its
69
72
  // first tool call. Blocking on the whole backlog of old messages would be a terrible first impression.
@@ -75,8 +78,11 @@ async function main() {
75
78
  let messages = [];
76
79
  try {
77
80
  // PEEK: look without claiming delivery. We may yet decide to let the stop through.
78
- const peek = await getJSON(`/inbox?session=${encodeURIComponent(session)}&since=${cursor}&peek=1`, { timeoutMs: FETCH_TIMEOUT_MS });
81
+ const peek = await signedGet(`/inbox?session=${encodeURIComponent(session)}&since=${cursor}&peek=1`, { timeoutMs: FETCH_TIMEOUT_MS, session, instance: instanceId });
79
82
  if (!peek.ok) return allow();
83
+ // Superseded twin (instance-keys contract): a newer instance claimed the baton — this session
84
+ // stands down. Blocking ITS stop over messages the new instance will handle would trap it.
85
+ if (peek.json?.superseded === true) return allow();
80
86
  messages = peek.json?.messages || [];
81
87
  } catch { return allow(); } // hub down — never trap the session
82
88
 
@@ -86,7 +92,7 @@ async function main() {
86
92
  // Committed now: claim delivery for real so neither inbox-deliver nor the deferred waker repeats it.
87
93
  let next = cursor;
88
94
  try {
89
- const claim = await getJSON(`/inbox?session=${encodeURIComponent(session)}&since=${cursor}`, { timeoutMs: FETCH_TIMEOUT_MS });
95
+ const claim = await signedGet(`/inbox?session=${encodeURIComponent(session)}&since=${cursor}`, { timeoutMs: FETCH_TIMEOUT_MS, session, instance: instanceId });
90
96
  if (claim.ok) next = claim.json?.cursor || cursor;
91
97
  } catch {}
92
98
  try { writeFileSync(cursorFile, String(next)); } catch {}
package/hub.mjs CHANGED
@@ -9,7 +9,7 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync, readdirSy
9
9
  import { homedir } from "node:os";
10
10
  import { join } from "node:path";
11
11
  import { timingSafeEqual, randomBytes } from "node:crypto";
12
- import { verifyRequest, publicView } from "./lib/identity.mjs";
12
+ import { verifyRequest, verifyEndorsement, publicView } from "./lib/identity.mjs";
13
13
  import { DEFAULT_ORG } from "./lib/store-contract.mjs";
14
14
  import { assertNoSecrets } from "./lib/scrub.mjs";
15
15
 
@@ -86,7 +86,7 @@ function scanTelemetry() {
86
86
  // TIMELINE view are untouched; every NEW type is dotted ("message", "presence.online", …) and is
87
87
  // filtered OUT of /history. Loads from the old `cardEvents` key when `events` is absent.
88
88
  function emptyState() {
89
- return { messages: [], peers: {}, seq: 0, tasks: [], taskSeq: 0, projectMeta: {}, lessons: [], events: [], cardEventsBackfilled: false, aliases: {}, phaseMeta: {}, verifyGates: [], verifyGateSeq: 0, balances: { ts: 0, by: "", entries: [] }, subagentCostReset: false, handoffLog: [], identities: {}, inviteTokens: {}, focus: {}, orgPolicy: {} };
89
+ return { messages: [], peers: {}, seq: 0, tasks: [], taskSeq: 0, projectMeta: {}, lessons: [], events: [], cardEventsBackfilled: false, aliases: {}, phaseMeta: {}, verifyGates: [], verifyGateSeq: 0, balances: { ts: 0, by: "", entries: [] }, subagentCostReset: false, handoffLog: [], identities: {}, inviteTokens: {}, focus: {}, orgPolicy: {}, instances: {} };
90
90
  }
91
91
 
92
92
  function normalizeState(loaded = {}) {
@@ -108,6 +108,7 @@ function normalizeState(loaded = {}) {
108
108
  s.handoffLog = Array.isArray(loaded.handoffLog) ? loaded.handoffLog : [];
109
109
  s.identities = loaded.identities && typeof loaded.identities === "object" ? loaded.identities : {};
110
110
  s.inviteTokens = loaded.inviteTokens && typeof loaded.inviteTokens === "object" ? loaded.inviteTokens : {};
111
+ s.instances = loaded.instances && typeof loaded.instances === "object" ? loaded.instances : {};
111
112
  s.focus = loaded.focus && typeof loaded.focus === "object" ? loaded.focus : {};
112
113
  s.orgPolicy = loaded.orgPolicy && typeof loaded.orgPolicy === "object" ? loaded.orgPolicy : {};
113
114
  for (const [session, v] of Object.entries(loaded.peers || {})) {
@@ -532,6 +533,30 @@ async function authenticate(req, path) {
532
533
  if (seenNonces.has(nonceKey)) return soft("replay");
533
534
  seenNonces.set(nonceKey, verified.ts);
534
535
  if (seenNonces.size > 10000) seenNonces.delete(seenNonces.keys().next().value);
536
+ // Instance-subkey path (docs/INSTANCE-KEYS-CONTRACT.md): when the three endorsement headers ride
537
+ // along, x-trantor-pubkey was the INSTANCE key (whose signature we just verified). Verify that the
538
+ // claimed DURABLE key endorsed it, then authenticate AS the durable identity — the instance mints
539
+ // no authority of its own; it is the durable identity, time-boxed to one session.
540
+ const h = (k) => req.headers[k] ?? "";
541
+ const durableHdr = h("x-trantor-durable"), instId = h("x-trantor-inst");
542
+ if (durableHdr && instId) {
543
+ const endorsed = verifyEndorsement({
544
+ durablePubkey: durableHdr, instancePubkey: verified.pubkey, instanceId: instId,
545
+ createdAt: state.instances?.[verified.pubkey]?.createdAt || Number(h("x-trantor-inst-ts")) || 0,
546
+ endorsement: h("x-trantor-endorse"),
547
+ });
548
+ if (!endorsed) return soft("bad endorsement");
549
+ const identity = findIdentity(durableHdr);
550
+ if (!identity) return soft("unknown identity");
551
+ if (!state.instances || typeof state.instances !== "object") state.instances = {};
552
+ const rec = state.instances[verified.pubkey] ||
553
+ { durable: durableHdr, instanceId: instId, name: identity.name || "", firstSeen: now(),
554
+ createdAt: Number(h("x-trantor-inst-ts")) || now(), superseded: false };
555
+ rec.lastSeen = now();
556
+ state.instances[verified.pubkey] = rec; dirty = true;
557
+ return { ok: true, mode: AUTH_MODE, trusted: true, pubkey: durableHdr, identity,
558
+ instanceId: instId, instancePubkey: verified.pubkey, superseded: !!rec.superseded };
559
+ }
535
560
  const identity = findIdentity(verified.pubkey);
536
561
  if (!identity) return soft("unknown identity");
537
562
  return { ok: true, mode: AUTH_MODE, trusted: true, pubkey: verified.pubkey, identity };
@@ -971,6 +996,30 @@ const server = http.createServer(async (req, res) => {
971
996
  .filter(c => c.project === proj || linked.has(c.project)); } catch {}
972
997
  return json(res, 200, { level, links: links.map(l => ({ projects: l.projects, reason: l.reason })), peers: peersOut, inflight, warnings });
973
998
  }
999
+ // Supersession (docs/INSTANCE-KEYS-CONTRACT.md): EXPLICIT, never automatic — the baton-claim
1000
+ // path calls this when a fresh session consumes a handoff. Marks every OTHER instance of the
1001
+ // named durable identity superseded; their /inbox + /poll answers then carry superseded:true so
1002
+ // their own hooks tell the model to stand down. Informational, never a hard block. Accepted
1003
+ // only from an endorsed instance of the SAME durable identity, or the owner.
1004
+ if (req.method === "POST" && P === "/instance/supersede") {
1005
+ const b = await body(req);
1006
+ const name = String(b.name || "").slice(0, 200);
1007
+ const except = String(b.exceptInstanceId || "").slice(0, 200);
1008
+ if (!name) return json(res, 400, { error: "name required" });
1009
+ if (AUTH_MODE !== "off") {
1010
+ const sameIdentity = auth?.identity && String(auth.identity.name || "") === name;
1011
+ const isOwner = auth?.identity?.kind === "human" || scopeAllows(auth?.identity, "", "owner");
1012
+ if (!sameIdentity && !isOwner && AUTH_MODE === "enforce") return json(res, 403, { error: "forbidden" });
1013
+ }
1014
+ let flipped = 0;
1015
+ for (const rec of Object.values(state.instances || {})) {
1016
+ if (rec.name !== name || rec.superseded) continue;
1017
+ if (except && rec.instanceId === except) continue;
1018
+ rec.superseded = now(); flipped++;
1019
+ }
1020
+ if (flipped) dirty = true;
1021
+ return json(res, 200, { ok: true, superseded: flipped });
1022
+ }
974
1023
  if (req.method === "POST" && P === "/overseer/narrate") {
975
1024
  const b = await body(req);
976
1025
  const ev = state.events.find(e => e.id === Number(b.eventId) && e.type === "overseer.warn");
@@ -1798,7 +1847,9 @@ const server = http.createServer(async (req, res) => {
1798
1847
  // through). Advancing the ledger on a peek would tell the deferred waker the message had been
1799
1848
  // delivered when nobody ever saw it — a silent hole exactly where this feature is supposed to help.
1800
1849
  if (q.peek !== "1") markDelivered(q.session, cursor);
1801
- return json(res, 200, { messages: msgs, cursor });
1850
+ // superseded (instance-keys contract): a baton twin that lost the claim learns it HERE, via
1851
+ // its own read — its hooks turn this into a stand-down note for the model. Never a block.
1852
+ return json(res, 200, auth?.superseded ? { messages: msgs, cursor, superseded: true } : { messages: msgs, cursor });
1802
1853
  }
1803
1854
  if (req.method === "GET" && P === "/poll") {
1804
1855
  if (!canUseInboxSession(auth, q.session)) return json(res, 403, { error: "forbidden" });
@@ -1807,7 +1858,7 @@ const server = http.createServer(async (req, res) => {
1807
1858
  const deadline = now() + waitMs;
1808
1859
  const tick = () => {
1809
1860
  const msgs = state.messages.filter(m => m.id > since && deliverable(m, q.session) && inboxReadable(auth, m, q.session));
1810
- if (msgs.length || now() >= deadline) { touch(q.session, undefined, undefined, undefined, auth); const cursor = msgs.length ? msgs[msgs.length - 1].id : since; markDelivered(q.session, cursor); return json(res, 200, { messages: msgs, cursor }); }
1861
+ if (msgs.length || now() >= deadline) { touch(q.session, undefined, undefined, undefined, auth); const cursor = msgs.length ? msgs[msgs.length - 1].id : since; markDelivered(q.session, cursor); return json(res, 200, auth?.superseded ? { messages: msgs, cursor, superseded: true } : { messages: msgs, cursor }); }
1811
1862
  setTimeout(tick, 300);
1812
1863
  };
1813
1864
  return tick();
package/lib/identity.mjs CHANGED
@@ -21,6 +21,7 @@ import { join } from "node:path";
21
21
  import { homedir } from "node:os";
22
22
 
23
23
  export const SCHEME = "trantor-v1";
24
+ export const INST_SCHEME = "trantor-inst-v1";
24
25
  export const SKEW_MS = 120_000; // reject a signature older/newer than this
25
26
  export const HDR = {
26
27
  pubkey: "x-trantor-pubkey",
@@ -28,6 +29,15 @@ export const HDR = {
28
29
  ts: "x-trantor-ts",
29
30
  nonce: "x-trantor-nonce",
30
31
  };
32
+ // Instance-subkey headers (docs/INSTANCE-KEYS-CONTRACT.md). When present, x-trantor-pubkey above
33
+ // carries the INSTANCE pubkey and the request is attributed to the DURABLE identity below, provided
34
+ // the endorsement verifies. Absent → plain v1, unchanged.
35
+ export const HDR_INST = {
36
+ durable: "x-trantor-durable",
37
+ inst: "x-trantor-inst",
38
+ endorse: "x-trantor-endorse",
39
+ instTs: "x-trantor-inst-ts",
40
+ };
31
41
 
32
42
  const busDir = () => process.env.AGENT_BUS_DIR || join(homedir(), ".agent-bus");
33
43
  const keysDir = () => join(busDir(), "keys");
@@ -134,6 +144,75 @@ export function signRequest(identity, { method, path, body }) {
134
144
  };
135
145
  }
136
146
 
147
+ // --- session-instance subkeys (docs/INSTANCE-KEYS-CONTRACT.md) ---------------------------------
148
+ // A per-session-instance keypair, ENDORSED by the durable identity: the durable key signs
149
+ // endorsementString(...), attesting "this instance pubkey speaks as me until it dies". The durable
150
+ // key keeps enrollment/grants/attribution; the instance key signs traffic and dies with the
151
+ // session. Fixes the handoff-twin identity collision (two lineages, two subkeys, one durable name)
152
+ // and gives per-restart credential freshness (the teams login-session model).
153
+ export function endorsementString({ durablePubkey, instancePubkey, instanceId, createdAt }) {
154
+ // Same discipline as canonicalString: newline-joined, fixed arity, no field may contain \n.
155
+ return [INST_SCHEME, String(durablePubkey), String(instancePubkey), String(instanceId), String(createdAt)].join("\n");
156
+ }
157
+
158
+ export const instanceKeyPath = (name, instanceId) =>
159
+ join(keysDir(), "instances", `${safe(name)}@${safe(instanceId)}.json`);
160
+
161
+ // Mint-or-load an endorsed instance identity. Atomic against racing hooks exactly like
162
+ // loadOrCreate: two processes of one session (hooks vs MCP would use DIFFERENT instanceIds, but
163
+ // T1/T2 hooks share one) must converge on a single keypair for a given (name, instanceId).
164
+ export function loadOrCreateInstance(durableIdentity, instanceId) {
165
+ if (!durableIdentity?.privkey || !instanceId) return null;
166
+ const f = instanceKeyPath(durableIdentity.name, instanceId);
167
+ try {
168
+ if (existsSync(f)) {
169
+ const inst = JSON.parse(readFileSync(f, "utf8"));
170
+ if (inst?.pubkey && inst?.privkey && inst?.endorsement) return inst;
171
+ }
172
+ } catch {}
173
+ try {
174
+ mkdirSync(join(keysDir(), "instances"), { recursive: true, mode: 0o700 });
175
+ const { pubkey, privkey } = generate();
176
+ const createdAt = Date.now();
177
+ const msg = Buffer.from(endorsementString({
178
+ durablePubkey: durableIdentity.pubkey, instancePubkey: pubkey, instanceId, createdAt,
179
+ }), "utf8");
180
+ const endorsement = cryptoSign(null, msg, privKeyObject(durableIdentity)).toString("base64");
181
+ const inst = {
182
+ name: durableIdentity.name, instanceId, pubkey, privkey, createdAt, endorsement,
183
+ durablePubkey: durableIdentity.pubkey,
184
+ };
185
+ const tmp = `${f}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
186
+ writeFileSync(tmp, JSON.stringify(inst), { mode: 0o600 });
187
+ if (existsSync(f)) { try { return JSON.parse(readFileSync(f, "utf8")); } catch { return inst; } }
188
+ renameSync(tmp, f);
189
+ chmodSync(f, 0o600);
190
+ return inst;
191
+ } catch { return null; } // unwritable — caller falls back to durable
192
+ }
193
+
194
+ // The extra wire headers an instance identity contributes (alongside the v1 set signed with ITS key).
195
+ export function instanceHeaders(inst) {
196
+ if (!inst?.durablePubkey || !inst?.endorsement) return {};
197
+ return {
198
+ [HDR_INST.durable]: inst.durablePubkey,
199
+ [HDR_INST.inst]: inst.instanceId,
200
+ [HDR_INST.endorse]: inst.endorsement,
201
+ [HDR_INST.instTs]: String(inst.createdAt),
202
+ };
203
+ }
204
+
205
+ // Pure endorsement check: did `durablePubkey` really endorse `instancePubkey` for this instanceId?
206
+ // Enrollment/grants/supersession stay with the hub, which owns that state.
207
+ export function verifyEndorsement({ durablePubkey, instancePubkey, instanceId, createdAt, endorsement }) {
208
+ if (!durablePubkey || !instancePubkey || !instanceId || !createdAt || !endorsement) return false;
209
+ if (!/^[0-9a-f]{64}$/i.test(durablePubkey) || !/^[0-9a-f]{64}$/i.test(instancePubkey)) return false;
210
+ try {
211
+ const msg = Buffer.from(endorsementString({ durablePubkey, instancePubkey, instanceId, createdAt }), "utf8");
212
+ return cryptoVerify(null, msg, pubFromHex(durablePubkey), Buffer.from(endorsement, "base64"));
213
+ } catch { return false; }
214
+ }
215
+
137
216
  // Pure verification: signature + freshness only. Replay defence (nonce memory) and authorization
138
217
  // (is this pubkey known? may it touch this project?) belong to the hub, which owns that state.
139
218
  // Returns { ok, pubkey, ts, nonce, reason }.
@@ -6,7 +6,7 @@
6
6
  // we send the request UNSIGNED rather than failing. Under RELAY_AUTH=warn the hub accepts it and
7
7
  // flags it; under enforce the hub rejects it and the caller sees a 401 — which is the correct place
8
8
  // for that decision, because only the hub knows the policy.
9
- import { signRequest, loadOrCreate } from "./identity.mjs";
9
+ import { signRequest, loadOrCreate, instanceHeaders } from "./identity.mjs";
10
10
 
11
11
  // Sign over path + query only. The origin is not in the canonical string: the same request proxied
12
12
  // through a different host must still verify, and the hub knows its own address.
@@ -19,11 +19,15 @@ function pathOf(url) {
19
19
  export function signedHeaders(identity, url, opts = {}) {
20
20
  if (!identity?.privkey) return {};
21
21
  try {
22
- return signRequest(identity, {
22
+ const v1 = signRequest(identity, {
23
23
  method: (opts.method || "GET").toUpperCase(),
24
24
  path: pathOf(url),
25
25
  body: opts.body,
26
26
  });
27
+ // An INSTANCE identity (docs/INSTANCE-KEYS-CONTRACT.md) carries its endorsement; the extra
28
+ // headers ride along and the hub attributes the request to the durable identity. A plain
29
+ // durable identity contributes nothing here — v1 wire format unchanged.
30
+ return { ...v1, ...instanceHeaders(identity) };
27
31
  } catch { return {}; } // never let signing break a caller
28
32
  }
29
33
 
package/lib/store-pg.mjs CHANGED
@@ -124,6 +124,7 @@ function kvFromState(state) {
124
124
  verifyGateSeq: Number(state.verifyGateSeq || 0),
125
125
  cardEventsBackfilled: !!state.cardEventsBackfilled,
126
126
  inviteTokens: state.inviteTokens || {},
127
+ instances: state.instances || {},
127
128
  },
128
129
  subagentCostReset: !!state.subagentCostReset,
129
130
  seq: Number(state.seq || 0),
@@ -669,6 +670,7 @@ export class PgStore {
669
670
  handoffLog: Array.isArray(kv.handoffLog) ? kv.handoffLog : [],
670
671
  identities,
671
672
  inviteTokens: meta.inviteTokens && typeof meta.inviteTokens === "object" ? meta.inviteTokens : {},
673
+ instances: meta.instances && typeof meta.instances === "object" ? meta.instances : {},
672
674
  focus: kv.focus && typeof kv.focus === "object" ? kv.focus : {},
673
675
  orgPolicy: kv.orgPolicy && typeof kv.orgPolicy === "object" ? kv.orgPolicy : {},
674
676
  };
package/mcp.mjs CHANGED
@@ -21,7 +21,7 @@ const PROJECT = resolveProject(process.env.CLAUDE_PROJECT_DIR || process.cwd());
21
21
  // Hub URL is PER-PROJECT (TDD §12.1): RELAY_URL env → config.json hubs[PROJECT] → legacy
22
22
  // global `url` → local default. A project lives on exactly one hub; codependent projects
23
23
  // must share one, so both are pinned to the same hub via `trantor hub set`.
24
- const URL_BASE = resolveHub(PROJECT);
24
+ const URL_BASE = resolveHub(PROJECT); // boot-time snapshot: startup log only — every api() call re-resolves
25
25
  // Identity: RELAY_SESSION wins; else RELAY_AGENT ("codex", "kimi", …) brands the session per-project
26
26
  // (set it once in the CLI's global MCP config — works in every project); else hostname:project.
27
27
  const SESSION = process.env.RELAY_SESSION
@@ -50,10 +50,14 @@ async function seedCursor() {
50
50
  // bus exists for. Signed reads are scope-filtered by the hub to this identity's grants — for a
51
51
  // session reading its own project + DMs that is the intended behavior. The client fail-opens on a
52
52
  // down hub (returns {ok:false}); we surface that as a thrown Error so individual tools .catch it.
53
+ // Instance id (docs/INSTANCE-KEYS-CONTRACT.md): the MCP server has no harness session_id, so it
54
+ // mints a random id at boot — its lifetime ≈ the session's. The endorsed subkey it keys signs all
55
+ // traffic; the durable identity keeps enrollment and attribution.
56
+ const INSTANCE_ID = `mcp-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
53
57
  async function api(method, path, payload) {
54
58
  const r = method.toUpperCase() === "GET"
55
- ? await signedGet(path, { session: SESSION })
56
- : await signedPost(path, payload, { session: SESSION });
59
+ ? await signedGet(path, { session: SESSION, instance: INSTANCE_ID })
60
+ : await signedPost(path, payload, { session: SESSION, instance: INSTANCE_ID });
57
61
  if (!r.ok) throw new Error(`hub ${r.status} on ${path}`);
58
62
  return r.json;
59
63
  }
@@ -63,7 +67,7 @@ const server = new McpServer({ name: "trantor", version: "0.1.0" });
63
67
 
64
68
  server.tool("relay_whoami", "Show this session's relay identity, project, and the hub URL.", {}, async () => {
65
69
  await api("POST", "/register", { session: SESSION, project: PROJECT }).catch(() => {});
66
- return { content: [{ type: "text", text: `session=${SESSION}\nproject=${PROJECT}\nhub=${URL_BASE}` }] };
70
+ return { content: [{ type: "text", text: `session=${SESSION}\nproject=${PROJECT}\nhub=${resolveHub(PROJECT)}` }] };
67
71
  });
68
72
 
69
73
  server.tool("relay_task_add", "Add a Kanban card to a project's board on the dashboard (what you're about to work on). Defaults: THIS project, assigned to you, status 'todo'. Pass `project` to target another board — e.g. when you orchestrate a crew that runs in a different directory than the one you launched Claude from. Keep the team's progress visible.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.17.56",
3
+ "version": "0.17.58",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"
@@ -11,7 +11,7 @@
11
11
  "pg": "^8.22.0"
12
12
  },
13
13
  "scripts": {
14
- "test": "node test.mjs && node test-scenarios.mjs && node test-failure.mjs && node test-handoff.mjs && node test-agents.mjs && node test-update.mjs && node test-handoff-guard.mjs && node test-balances.mjs && node test-subagent-cost.mjs && node test-inbox.mjs && node test-inflight.mjs && node test-notify.mjs && node test-focus.mjs && node test-reaper.mjs && node test-events.mjs && node test-scrub.mjs && node test-store-delta.mjs && node test-claims.mjs && node test-adopt.mjs && node test-summarize.mjs && node test-identity-core.mjs && node test-identity.mjs && node test-inbox-delivery.mjs && node test-hub-routing.mjs && python3 engine/test-routing.py && node test-reconcile.mjs && bash test-crew.sh"
14
+ "test": "node test.mjs && node test-scenarios.mjs && node test-failure.mjs && node test-handoff.mjs && node test-agents.mjs && node test-update.mjs && node test-handoff-guard.mjs && node test-balances.mjs && node test-subagent-cost.mjs && node test-inbox.mjs && node test-inflight.mjs && node test-notify.mjs && node test-focus.mjs && node test-reaper.mjs && node test-events.mjs && node test-scrub.mjs && node test-store-delta.mjs && node test-claims.mjs && node test-adopt.mjs && node test-summarize.mjs && node test-identity-core.mjs && node test-identity.mjs && node test-identity-instances.mjs && node test-inbox-delivery.mjs && node test-hub-routing.mjs && python3 engine/test-routing.py && node test-reconcile.mjs && bash test-crew.sh"
15
15
  },
16
16
  "description": "The hub-world for AI agent crews — orchestrate Claude Code, Codex, GLM, Kimi, DeepSeek & any OpenRouter model as live crews with a plan-aware Advisor, a Kanban/flow command center, a testing gate, and an economics brain (Scrooge).",
17
17
  "files": [