trantor 0.17.55 → 0.17.57

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.55",
16
+ "version": "0.17.57",
17
17
  "author": {
18
18
  "name": "Sasha Bogojevic"
19
19
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.17.55",
3
+ "version": "0.17.57",
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
 
package/bin/cli.mjs CHANGED
@@ -68,6 +68,8 @@ switch (cmd) {
68
68
  case "handoff": run("bin/baton.mjs"); break;
69
69
  case "adopt": run("bin/adopt.mjs"); break;
70
70
  case "summarize": run("bin/summarize.mjs"); break;
71
+ case "policy": run("bin/policy.mjs"); break;
72
+ case "inbox": run("bin/inbox.mjs"); break;
71
73
  case "identity": {
72
74
  const { load, publicView, generate, keyPath } = await import(join(ROOT, "lib/identity.mjs"));
73
75
  const sub = args[0], name = args[1] || "human";
@@ -162,6 +164,8 @@ switch (cmd) {
162
164
  trantor hub run the hub in the foreground (setup installs it as a service instead)
163
165
  …or manage per-project hub pins: hub list · hub set <project> <url> · hub unset <project>
164
166
  trantor watch live bus feed in the terminal
167
+ trantor inbox THIS session's unread bus messages, signed (works under enforce) — [--all] [--consume] [--json]
168
+ trantor policy the autonomy ladder: show | set <project> <1-4> | link <a> <b> --reason "<why>"
165
169
 
166
170
  Claude Code plugin (the orchestrator side):
167
171
  claude plugin marketplace add sashabogi/trantor && claude plugin install trantor
@@ -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/inbox.mjs ADDED
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env node
2
+ // trantor inbox — read THIS session's bus messages from the terminal, signed.
3
+ //
4
+ // trantor inbox peek at unread (does NOT consume — hooks still deliver them)
5
+ // trantor inbox --all the full history for this session (peek)
6
+ // trantor inbox --consume read AND advance the delivery cursor (marks them delivered)
7
+ // trantor inbox --json raw JSON out
8
+ // trantor inbox --limit N show at most N messages (default 30)
9
+ //
10
+ // Born from the 2026-07-30 agent-UX gap: a session asked to "check your messages" had NO way to —
11
+ // the MCP tools 401'd on the enforce hub and there was no CLI. Reads here are SIGNED with the same
12
+ // session keypair the hooks and MCP server use, so they work under RELAY_AUTH=enforce; the hub's
13
+ // scope filtering (own project + direct messages) is the intended behavior, not a limitation.
14
+ import { readFileSync, writeFileSync, existsSync } from "node:fs";
15
+ import { join } from "node:path";
16
+ import { homedir } from "node:os";
17
+ import { resolveProject, hostId, resolveHub } from "../lib/project.mjs";
18
+ import { loadOrCreate } from "../lib/identity.mjs";
19
+ import { sfetchJson } from "../lib/signed-fetch.mjs";
20
+
21
+ const argv = process.argv.slice(2);
22
+ const has = (k) => argv.includes(`--${k}`);
23
+ const val = (k) => { const i = argv.indexOf(`--${k}`); return i >= 0 ? (argv[i + 1] ?? "") : ""; };
24
+
25
+ const project = resolveProject(process.cwd());
26
+ const session = process.env.RELAY_SESSION
27
+ || (process.env.RELAY_AGENT ? `${process.env.RELAY_AGENT}:${project}` : `${hostId()}:${project}`);
28
+ const hub = resolveHub(project);
29
+ const identity = loadOrCreate(session, "agent");
30
+
31
+ // Default `since` = the SAME cursor file the delivery hooks keep, so "trantor inbox" means
32
+ // "what have my hooks not handed me yet" — not a 2,000-message historical replay.
33
+ const safe = session.replace(/[^A-Za-z0-9_.-]/g, "_");
34
+ const cursorFile = join(process.env.AGENT_BUS_DIR || join(homedir(), ".agent-bus"), `inbox-cursor-${safe}.id`);
35
+ let since = 0;
36
+ if (!has("all")) { try { since = Number(readFileSync(cursorFile, "utf8")) || 0; } catch {} }
37
+
38
+ const peek = !has("consume");
39
+ const url = `${hub}/inbox?session=${encodeURIComponent(session)}&since=${since}${peek ? "&peek=1" : ""}`;
40
+ let r;
41
+ try { r = await sfetchJson(url, { method: "GET", identity, signal: AbortSignal.timeout(8000) }); }
42
+ catch (e) { console.error(`hub unreachable at ${hub}: ${e.message}`); process.exit(1); }
43
+ if (!r.ok) {
44
+ console.error(`hub ${r.status} on /inbox — ${r.status === 401 ? "this identity isn't enrolled on the hub (RELAY_ENROLL=invite?)" : "read failed"}`);
45
+ process.exit(1);
46
+ }
47
+ const { messages = [], cursor = since } = await r.json();
48
+
49
+ if (has("json")) { console.log(JSON.stringify({ session, hub, cursor, messages }, null, 2)); }
50
+ else {
51
+ const limit = Math.max(1, Number(val("limit")) || 30);
52
+ const show = messages.slice(-limit);
53
+ console.log(`${session} @ ${hub} — ${messages.length} message(s)${messages.length > show.length ? `, showing last ${show.length}` : ""}${peek ? " (peek)" : ""}`);
54
+ for (const m of show) {
55
+ const when = new Date(m.ts).toLocaleString();
56
+ console.log(`\n#${m.id} ${when} ${m.from} -> ${m.to}`);
57
+ console.log(` ${String(m.text || "").split("\n").join("\n ")}`);
58
+ }
59
+ if (!messages.length) console.log("(inbox empty — you're caught up)");
60
+ }
61
+
62
+ // --consume: the hub's shared delivery ledger already advanced (non-peek read); mirror it into the
63
+ // local cursor file so the delivery hooks agree these are handled and don't re-inject them.
64
+ if (!peek && cursor > since) { try { writeFileSync(cursorFile, String(cursor)); } catch {} }
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env node
2
+ // trantor overseer-narrate — narrate overseer.warn events with plain-language action text.
3
+ //
4
+ // trantor overseer-narrate [--limit N] [--dry] [--quiet]
5
+ //
6
+ // The overseer tick emits overseer.warn events as mechanical, computed facts (kind, sessions, files,
7
+ // detail). This worker gives each un-narrated warning a one-line action text ("coordinate over the
8
+ // bus / split files / declare a link") written by a CHEAP model, then POSTs it back to the hub.
9
+ // Economics by design: candidates are unnarrated overseer.warn events, batched into ONE cheap-model
10
+ // call per hub, difficulty easy. Runs ambiently from the heartbeat and on demand.
11
+ import { execSync, spawnSync } from "node:child_process";
12
+ import { readFileSync, existsSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { homedir } from "node:os";
15
+ import { loadOrCreate } from "../lib/identity.mjs";
16
+ import { sfetchJson } from "../lib/signed-fetch.mjs";
17
+
18
+ const argv = process.argv.slice(2);
19
+ const val = (k) => { const i = argv.indexOf(`--${k}`); return i >= 0 ? (argv[i + 1] ?? "") : ""; };
20
+ const has = (k) => argv.includes(`--${k}`);
21
+ const QUIET = has("quiet");
22
+ const say = (...a) => { if (!QUIET) console.log(...a); };
23
+
24
+ const BUS_DIR = process.env.AGENT_BUS_DIR || join(homedir(), ".agent-bus");
25
+ let config = {}; try { config = JSON.parse(readFileSync(join(BUS_DIR, "config.json"), "utf8")); } catch {}
26
+ const LIMIT = Math.max(1, Number(val("limit")) || 100);
27
+ const OWNER = config.ownerIdentity || "admin";
28
+ const ownerId = loadOrCreate(OWNER, "human");
29
+
30
+ const scroogeBin = () => process.env.SCROOGE_BIN
31
+ || (() => { try { return execSync("command -v scrooge", { encoding: "utf8" }).trim(); } catch {} })()
32
+ || (existsSync(new URL("../engine/bin/scrooge", import.meta.url)) ? new URL("../engine/bin/scrooge", import.meta.url).pathname : "");
33
+
34
+ const hubs = new Set([config.url || "http://127.0.0.1:4477", ...Object.values(config.hubs || {})]);
35
+ const get = async (hub, path) => (await sfetchJson(`${hub}${path}`, { method: "GET", identity: ownerId, signal: AbortSignal.timeout(8000) })).json();
36
+
37
+ let narrated = 0, considered = 0;
38
+ for (const hub of hubs) {
39
+ let events = [];
40
+ try { events = (await get(hub, `/events?type=overseer.&limit=${LIMIT}`)).events ?? []; }
41
+ catch { continue; }
42
+ const unnarrated = events.filter(e => !e.narrated && e.detail);
43
+ considered += unnarrated.length;
44
+ if (!unnarrated.length) continue;
45
+ say(`${hub}: ${unnarrated.length} overseer warning(s) need narration`);
46
+
47
+ const blocks = unnarrated.map(e =>
48
+ `EVENT #${e.id} [${e.project}]\nKIND: ${e.kind}\nSESSIONS: ${(e.sessions || []).join(", ")}\nFILES: ${(e.files || []).join(", ")}\nDETAIL: ${e.detail}`
49
+ );
50
+
51
+ const prompt = `You write one-line action narratives for AI-agent collision warnings. For EACH event below, write what the sessions should do — coordinate over the bus, split files, declare a link. <=200 chars. Cite sessions/files/projects from the detail. Return ONLY a JSON array: [{"eventId":<number>,"text":"..."}]
52
+
53
+ ${blocks.join("\n\n")}`;
54
+
55
+ const bin = scroogeBin();
56
+ if (!bin) { console.error("scrooge not found (set SCROOGE_BIN) — cannot narrate."); process.exit(1); }
57
+ const res = spawnSync(bin, ["-t", "reason", "-d", "easy", "--json"], { input: prompt, encoding: "utf8", timeout: 120000 });
58
+ if (res.error || (res.status !== 0 && !res.stdout)) { console.error(`scrooge failed: ${(res.stderr || res.error?.message || "").slice(-200)}`); continue; }
59
+ let rows = [];
60
+ try { const out = res.stdout || ""; rows = JSON.parse(out.slice(out.indexOf("["), out.lastIndexOf("]") + 1)); }
61
+ catch { console.error(`unparseable narrator output: ${(res.stdout || "").slice(0, 200)}`); continue; }
62
+
63
+ const byId = new Map(unnarrated.map(e => [e.id, e]));
64
+ for (const r of rows) {
65
+ const e = byId.get(Number(r.eventId));
66
+ const text = String(r.text || "").trim().slice(0, 200);
67
+ if (!e || !text) continue;
68
+ if (has("dry")) { say(` event #${e.id} would become: ${text}`); continue; }
69
+ try {
70
+ const res = await sfetchJson(`${hub}/overseer/narrate`, { identity: ownerId, payload: { eventId: e.id, text }, signal: AbortSignal.timeout(8000) });
71
+ if (!res.ok) { say(` event #${e.id} POST failed: ${res.status}`); continue; }
72
+ narrated++;
73
+ say(` event #${e.id} → ${text}`);
74
+ } catch (e2) { say(` event #${e.id} write failed: ${e2.message}`); }
75
+ }
76
+ }
77
+ say(`\n${has("dry") ? "[dry] " : ""}${narrated} narration(s) written · ${considered} event(s) considered`);
package/bin/policy.mjs ADDED
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env node
2
+ // trantor policy — the autonomy ladder's admin surface (PRD §6): show levels + links,
3
+ // set a project's level, declare that two projects are codependent.
4
+ // trantor policy show | set <project> <1-4> | link <a> <b> --reason "<why>"
5
+ // Drafted by scrooge (deepseek-v4-flash), integrated by the orchestrator.
6
+ import { readFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { homedir } from "node:os";
9
+ import { loadOrCreate } from "../lib/identity.mjs";
10
+ import { sfetchJson } from "../lib/signed-fetch.mjs";
11
+
12
+ const BUS_DIR = process.env.AGENT_BUS_DIR || join(homedir(), ".agent-bus");
13
+ let config;
14
+ try { config = JSON.parse(readFileSync(join(BUS_DIR, "config.json"), "utf8")); } catch { config = {}; }
15
+
16
+ const OWNER = config.ownerIdentity || "admin";
17
+ const ownerId = loadOrCreate(OWNER, "human");
18
+ const hubs = new Set([config.url || "http://127.0.0.1:4477", ...Object.values(config.hubs || {})]);
19
+
20
+ const get = async (hub) => {
21
+ const res = await sfetchJson(`${hub}/policy`, { method: "GET", identity: ownerId, signal: AbortSignal.timeout(8000) });
22
+ return res.json();
23
+ };
24
+ const post = async (hub, payload) => {
25
+ const res = await sfetchJson(`${hub}/policy`, { method: "POST", identity: ownerId, payload, signal: AbortSignal.timeout(8000) });
26
+ const json = await res.json();
27
+ if (!res.ok || json.error) throw new Error(json.error || `HTTP ${res.status}`);
28
+ return json;
29
+ };
30
+
31
+ const legend = { 1: "1 observe", 2: "2 warn", 3: "3 gate", 4: "4 auto" };
32
+ function usage() {
33
+ console.log('usage: trantor policy show | set <project> <1-4> | link <a> <b> --reason "<why>"');
34
+ process.exit(1);
35
+ }
36
+
37
+ const [,, cmd, arg1, arg2] = process.argv;
38
+
39
+ if (cmd === "show" || !cmd) {
40
+ for (const hub of hubs) {
41
+ try {
42
+ const data = await get(hub);
43
+ console.log(hub);
44
+ console.log(" autonomy:");
45
+ for (const [proj, level] of Object.entries(data.autonomy || {})) console.log(` ${proj}: ${legend[level] || level}`);
46
+ console.log(" links:");
47
+ for (const l of data.links || []) console.log(` ${l.projects.join(" ↔ ")} — ${l.reason} (by ${l.declaredBy})`);
48
+ if (!(data.links || []).length) console.log(" (none)");
49
+ } catch (err) { console.warn(` ⚠ ${hub}: ${err.message}`); }
50
+ }
51
+ process.exit(0);
52
+ }
53
+
54
+ if (cmd === "set") {
55
+ if (!arg1 || !["1", "2", "3", "4"].includes(arg2)) usage();
56
+ for (const hub of hubs) {
57
+ try { await post(hub, { autonomy: { [arg1]: Number(arg2) } }); console.log(`✓ ${arg1} → ${legend[Number(arg2)]} on ${hub}`); }
58
+ catch (err) { console.warn(`⚠ ${hub}: ${err.message}`); }
59
+ }
60
+ process.exit(0);
61
+ }
62
+
63
+ if (cmd === "link") {
64
+ const reasonIdx = process.argv.indexOf("--reason");
65
+ const reason = reasonIdx >= 0 ? process.argv[reasonIdx + 1] : null;
66
+ if (!arg1 || !arg2 || !reason) usage();
67
+ for (const hub of hubs) {
68
+ try { await post(hub, { link: { projects: [arg1, arg2], reason } }); console.log(`✓ ${arg1} ↔ ${arg2} on ${hub}`); }
69
+ catch (err) { console.warn(`⚠ ${hub}: ${err.message}`); }
70
+ }
71
+ process.exit(0);
72
+ }
73
+
74
+ usage();
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/summarize.mjs CHANGED
@@ -45,7 +45,12 @@ const machineTitled = (t) =>
45
45
 
46
46
  const hubs = new Set([config.url || "http://127.0.0.1:4477", ...Object.values(config.hubs || {})]);
47
47
  const get = async (hub, path) => (await sfetchJson(`${hub}${path}`, { method: "GET", identity: ownerId, signal: AbortSignal.timeout(8000) })).json();
48
- const post = async (hub, path, payload) => (await sfetchJson(`${hub}${path}`, { identity: ownerId, payload, signal: AbortSignal.timeout(8000) })).json();
48
+ const post = async (hub, path, payload) => {
49
+ const r = await sfetchJson(`${hub}${path}`, { identity: ownerId, payload, signal: AbortSignal.timeout(8000) });
50
+ const j = await r.json();
51
+ if (!r.ok || j?.error) throw new Error(`${path} → ${r.status} ${j?.error || ""}`);
52
+ return j;
53
+ };
49
54
 
50
55
  let wrote = 0, considered = 0;
51
56
  for (const hub of hubs) {
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
 
package/hooks/hooks.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "description": "trantor — auto-register each session + inject live roster (SessionStart); turn each substantive user prompt into the session's live 'focus' card so a regular session's own work shows IN PROGRESS (UserPromptSubmit); post an in-flight 'doing' card when a sub-agent is dispatched + claim each file edit on the bus and hand the model a warning when ANOTHER live session is touching the same file (PreToolUse) and enrich it with the native agent_id + parent session when the sub-agent spawns (SubagentStart); heartbeat presence on every tool call + deliver unread bus messages to a busy session mid-turn + mirror the session's TodoWrite list onto the board as cards (PostToolUse); write a handoff before compaction (PreCompact); surface CC's own background/child agents (fork/--agent/subtask) on the board when they need input or complete (Notification); card each sub-agent's notional API cost when it finishes (SubagentStop); refuse to go idle while a peer's DIRECT message sits unread, handing it to the model instead (Stop)",
2
+ "description": "trantor — auto-register each session + inject live roster + overseer landscape warning: linked projects, live peers, files in flight, collisions (SessionStart); turn each substantive user prompt into the session's live 'focus' card so a regular session's own work shows IN PROGRESS (UserPromptSubmit); post an in-flight 'doing' card when a sub-agent is dispatched + claim each file edit on the bus and hand the model a warning when ANOTHER live session is touching the same file (PreToolUse) and enrich it with the native agent_id + parent session when the sub-agent spawns (SubagentStart); heartbeat presence on every tool call + deliver unread bus messages to a busy session mid-turn + mirror the session's TodoWrite list onto the board as cards (PostToolUse); write a handoff before compaction (PreCompact); surface CC's own background/child agents (fork/--agent/subtask) on the board when they need input or complete (Notification); card each sub-agent's notional API cost when it finishes (SubagentStop); refuse to go idle while a peer's DIRECT message sits unread, handing it to the model instead (Stop)",
3
3
  "hooks": {
4
4
  "SessionStart": [
5
5
  {
@@ -8,6 +8,10 @@
8
8
  {
9
9
  "type": "command",
10
10
  "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/sessionstart.mjs"
11
+ },
12
+ {
13
+ "type": "command",
14
+ "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/overseer-warn.mjs"
11
15
  }
12
16
  ]
13
17
  }
@@ -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);
@@ -34,7 +34,7 @@ const FETCH_TIMEOUT_MS = Number(process.env.RELAY_INBOX_TIMEOUT_MS || 1500);
34
34
  function sanitize(s) { return String(s == null ? "" : s).replace(/[\x00-\x1f\x7f-\x9f]/g, " "); }
35
35
 
36
36
  async function getInbox(session, since) {
37
- const { ok, json } = await getJSON(`/inbox?session=${encodeURIComponent(session)}&since=${since}`, { timeoutMs: FETCH_TIMEOUT_MS });
37
+ const { ok, json } = await signedGet(`/inbox?session=${encodeURIComponent(session)}&since=${since}`, { timeoutMs: FETCH_TIMEOUT_MS, session });
38
38
  if (!ok || !json) throw new Error("hub unreachable");
39
39
  return json; // { messages: [...], cursor }
40
40
  }
package/hooks/lib/api.mjs CHANGED
@@ -119,6 +119,28 @@ export async function getJSON(pathOrUrl, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}
119
119
  } catch { return { ok: false, status: 0, json: null }; }
120
120
  }
121
121
 
122
+ // Signed GET → { ok, status, json|null }. Never throws. The "one-liner" the getJSON comment
123
+ // promised: for reads that MUST work under RELAY_AUTH=enforce (which 401s unsigned reads).
124
+ // First user: the overseer-warn hook's /overseer/context — a project-scoped read, so the
125
+ // enforce hub's own-project scope filtering is the correct behavior, not a loss. Roster-style
126
+ // reads (/peers, /catchup cross-project discovery) stay on getJSON on purpose — see above.
127
+ export async function signedGet(pathOrUrl, { timeoutMs = DEFAULT_TIMEOUT_MS, session } = {}) {
128
+ const sess = session || sessionContext().session;
129
+ const id = loadIdentity(sess);
130
+ await ensureEnrolled(sess, id);
131
+ try {
132
+ const r = await sfetchJson(toUrl(pathOrUrl), {
133
+ method: "GET",
134
+ identity: id,
135
+ signal: AbortSignal.timeout(timeoutMs),
136
+ });
137
+ if (!r.ok) return { ok: false, status: r.status, json: null };
138
+ const text = await r.text();
139
+ let json = null; try { json = text ? JSON.parse(text) : null; } catch {}
140
+ return { ok: true, status: r.status, json };
141
+ } catch { return { ok: false, status: 0, json: null }; }
142
+ }
143
+
122
144
  // Signed POST → { ok, status, json|null }. Never throws.
123
145
  export async function signedPost(pathOrUrl, payload, { timeoutMs = DEFAULT_TIMEOUT_MS, session } = {}) {
124
146
  const sess = session || sessionContext().session;
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env node
2
+ // trantor SessionStart overseer-warn — a session hears about collisions through its OWN harness.
3
+ //
4
+ // THE doctrine (docs/OVERSEER-CONTRACT.md): detection is MECHANICAL and lives in the hub
5
+ // (GET /overseer/context computes level/links/peers/inflight/warnings); this hook only NARRATES
6
+ // what the hub already knows, at the one moment narration is cheap and useful — session start.
7
+ // A session never reaches into another session's process; context arrives via its own hook.
8
+ //
9
+ // Deliberately INFORMATIONAL below level 3, and never blocking at any level: the hook emits
10
+ // additionalContext or nothing. Warn mode annotates, it does not gate (see hub: "warn mode
11
+ // NEVER blocks").
12
+ //
13
+ // Fail-open is a contract, not a convenience: a hook that throws or hangs breaks the user's
14
+ // session. Hub down, timeout (1500ms), malformed payload, missing project — all resolve to {}.
15
+ // signedGet, not getJSON: RELAY_AUTH=enforce hubs 401 unsigned reads, and this hook fails open —
16
+ // an unsigned read here means the overseer warning silently NEVER reaches a session in production.
17
+ // /overseer/context is project-scoped, so enforce's own-project read filtering is correct for it.
18
+ import { relayUrl, sessionContext, signedGet } from "./lib/api.mjs";
19
+
20
+ const silent = () => { process.stdout.write("{}"); process.exit(0); };
21
+
22
+ function readStdin() {
23
+ return new Promise(res => {
24
+ let d = ""; process.stdin.setEncoding("utf8");
25
+ process.stdin.on("data", c => { d += c; });
26
+ process.stdin.on("end", () => res(d));
27
+ setTimeout(() => res(d), 400);
28
+ });
29
+ }
30
+
31
+ const ago = s => (s < 60 ? `${s}s` : s < 3600 ? `${Math.round(s / 60)}m` : `${Math.round(s / 3600)}h`);
32
+
33
+ try {
34
+ const raw = await readStdin();
35
+ const input = JSON.parse(raw || "{}");
36
+ const ctx = sessionContext(input.cwd);
37
+ if (!ctx.project) silent();
38
+
39
+ const r = await signedGet(`${relayUrl(ctx.project)}/overseer/context?project=${encodeURIComponent(ctx.project)}`, { session: ctx.session });
40
+ if (!r.ok || !r.json || typeof r.json !== "object") silent();
41
+ const c = r.json;
42
+
43
+ const level = Number(c.level || 1);
44
+ const warnings = Array.isArray(c.warnings) ? c.warnings : [];
45
+ const inflight = Array.isArray(c.inflight) ? c.inflight : [];
46
+ const links = Array.isArray(c.links) ? c.links : [];
47
+ const peers = Array.isArray(c.peers) ? c.peers : [];
48
+
49
+ // level<2 is observe: the hub still logs, but sessions are not narrated at. And at any level,
50
+ // nothing to say -> silence (an empty warning is noise that trains sessions to ignore real ones).
51
+ if (level < 2 || (!warnings.length && !inflight.length && !links.length)) silent();
52
+
53
+ const parts = [];
54
+ if (links.length) {
55
+ parts.push("Linked projects (declared codependence): " +
56
+ links.map(l => `${(l.projects || []).join(" + ")} — ${l.reason || "linked"}`).join("; ") + ".");
57
+ }
58
+ if (peers.length) {
59
+ parts.push("Live sessions on this/linked projects: " +
60
+ peers.map(p => `${p.session}${p.llm ? ` (${[p.llm, p.model].filter(Boolean).join("·")})` : ""}${p.status ? `, ${p.status}` : ""}`).join(", ") + ".");
61
+ }
62
+ if (inflight.length) {
63
+ parts.push("Files in flight right now: " +
64
+ inflight.map(f => `${f.file} — ${f.session} (${ago(Number(f.agoSec) || 0)} ago)`).join(", ") + ".");
65
+ }
66
+ if (warnings.length) {
67
+ parts.push("Overseer warnings: " +
68
+ warnings.map(w => w.detail || w.kind).filter(Boolean).join("; ") + ".");
69
+ }
70
+ parts.push("Before editing a file another session has in flight, coordinate over the bus (relay_send) or split the work.");
71
+
72
+ let text = parts.join(" ");
73
+ if (text.length > 900) text = text.slice(0, 897) + "…";
74
+
75
+ process.stdout.write(JSON.stringify({
76
+ hookSpecificOutput: {
77
+ hookEventName: "SessionStart",
78
+ additionalContext: `⚠️ trantor overseer: ${text}`,
79
+ },
80
+ }));
81
+ process.exit(0);
82
+ } catch {
83
+ silent();
84
+ }
@@ -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
@@ -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
 
@@ -75,7 +75,7 @@ async function main() {
75
75
  let messages = [];
76
76
  try {
77
77
  // 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 });
78
+ const peek = await signedGet(`/inbox?session=${encodeURIComponent(session)}&since=${cursor}&peek=1`, { timeoutMs: FETCH_TIMEOUT_MS, session });
79
79
  if (!peek.ok) return allow();
80
80
  messages = peek.json?.messages || [];
81
81
  } catch { return allow(); } // hub down — never trap the session
@@ -86,7 +86,7 @@ async function main() {
86
86
  // Committed now: claim delivery for real so neither inbox-deliver nor the deferred waker repeats it.
87
87
  let next = cursor;
88
88
  try {
89
- const claim = await getJSON(`/inbox?session=${encodeURIComponent(session)}&since=${cursor}`, { timeoutMs: FETCH_TIMEOUT_MS });
89
+ const claim = await signedGet(`/inbox?session=${encodeURIComponent(session)}&since=${cursor}`, { timeoutMs: FETCH_TIMEOUT_MS, session });
90
90
  if (claim.ok) next = claim.json?.cursor || cursor;
91
91
  } catch {}
92
92
  try { writeFileSync(cursorFile, String(next)); } catch {}
package/hub.mjs CHANGED
@@ -203,6 +203,59 @@ async function reloadFromStore() {
203
203
  if (reloadAgain) { reloadAgain = false; scheduleStoreReload(); }
204
204
  }
205
205
  }
206
+ // --- the OVERSEER (levels 1-2 + the level-3 gate) -------------------------------------------
207
+ // Detection is MECHANICAL (lib/overseer.mjs, pure); the LLM only narrates (bin/overseer-narrate).
208
+ // Lazy import: the engine module lands from a crew seat in parallel — the hub runs without it and
209
+ // picks it up on next restart. Warnings dedup on a 10-minute window so a standing collision does
210
+ // not spam the feed; at level >= 3 a file-conflict opens a verify gate (the go/no-go primitive).
211
+ let _overseer = null;
212
+ import("./lib/overseer.mjs").then(m => { _overseer = m; }).catch(() => {});
213
+ const OVERSEER_TICK_MS = Number(process.env.RELAY_OVERSEER_TICK_MS || 30 * 1000);
214
+ const OVERSEER_DEDUP_MS = Number(process.env.RELAY_OVERSEER_DEDUP_MS || 10 * 60 * 1000);
215
+ const overseerWarned = new Map(); // dedup key -> ts
216
+ function overseerPolicy() {
217
+ const p = state.orgPolicy && typeof state.orgPolicy === "object" ? state.orgPolicy : {};
218
+ return {
219
+ autonomy: { "*": 1, ...(p.autonomy || {}) },
220
+ links: Array.isArray(p.links) ? p.links : [],
221
+ };
222
+ }
223
+ function overseerInputs() {
224
+ return {
225
+ peers: Object.entries(state.peers).map(([session, v]) => ({
226
+ session, project: v.project || "", lastSeen: v.lastSeen || 0,
227
+ llm: v.llm || "", model: v.model || "", status: v.status || "",
228
+ })),
229
+ claims: [...fileClaims.values()],
230
+ ...overseerPolicy(),
231
+ now: now(),
232
+ };
233
+ }
234
+ function overseerTick() {
235
+ if (!_overseer?.detectCollisions) return;
236
+ let collisions = [];
237
+ try { collisions = _overseer.detectCollisions(overseerInputs()) || []; } catch { return; }
238
+ const cut = now() - OVERSEER_DEDUP_MS;
239
+ for (const [k, ts] of overseerWarned) if (ts < cut) overseerWarned.delete(k);
240
+ const pol = overseerPolicy();
241
+ for (const c of collisions) {
242
+ const key = `${c.project} ${c.kind} ${(c.sessions || []).join(",")} ${(c.files || []).join(",")}`;
243
+ if (overseerWarned.has(key)) continue;
244
+ overseerWarned.set(key, now());
245
+ appendEvent("overseer.warn", c.project, "overseer",
246
+ { kind: c.kind, sessions: c.sessions || [], files: c.files || [], detail: c.detail || "", narrated: false });
247
+ const level = _overseer.levelFor ? _overseer.levelFor(c.project, pol.autonomy) : 1;
248
+ if (level >= 3 && c.kind === "file-conflict") {
249
+ const g = { id: ++state.verifyGateSeq, project: c.project, status: "open", ts: now(),
250
+ by: "overseer", claim: `file conflict: ${(c.files || []).join(", ")} — ${(c.sessions || []).join(" vs ")}`,
251
+ why: c.detail || "two live sessions on the same file", howToVerify: "decide who proceeds; coordinate over the bus" };
252
+ state.verifyGates.push(g); dirty = true;
253
+ appendEvent("verify.gate.opened", c.project, "overseer", { gateId: g.id, claim: g.claim, why: g.why });
254
+ }
255
+ }
256
+ }
257
+ setInterval(overseerTick, OVERSEER_TICK_MS).unref?.();
258
+
206
259
  if (durableStore?.subscribeChanges) {
207
260
  durableStore.subscribeChanges((p) => {
208
261
  if (p && p.src === HUB_SRC) return;
@@ -404,8 +457,8 @@ function cmpSemver(a, b) {
404
457
  }
405
458
  const AUTH_HEADERS = ["x-trantor-pubkey", "x-trantor-sig", "x-trantor-ts", "x-trantor-nonce"];
406
459
  const PUBLIC_ENDPOINTS = new Set(["/", "/ui", "/health", "/enroll"]);
407
- const OWNER_ENDPOINTS = new Set(["/project/delete", "/sweep", "/reconcile", "/invite", "/import"]);
408
- const READ_ENDPOINTS = new Set(["/peers", "/tasks", "/events", "/inbox", "/peer", "/card", "/stream", "/history", "/projects", "/catchup", "/phases", "/recent", "/handoffs", "/verify-gates", "/claims"]);
460
+ const OWNER_ENDPOINTS = new Set(["/project/delete", "/sweep", "/reconcile", "/invite", "/import", "/policy"]);
461
+ const READ_ENDPOINTS = new Set(["/peers", "/tasks", "/events", "/inbox", "/peer", "/card", "/stream", "/history", "/projects", "/catchup", "/phases", "/recent", "/handoffs", "/verify-gates", "/claims", "/overseer/context"]);
409
462
  const roleRank = { read: 1, write: 2, owner: 3 };
410
463
  const hasAuthHeaders = (req) => AUTH_HEADERS.some(h => !!req.headers[h]);
411
464
  const authPath = (u) => `${u.pathname}${u.search || ""}`;
@@ -463,15 +516,24 @@ async function authenticate(req, path) {
463
516
  return { ok: false, code: 401, error: "signature required" };
464
517
  }
465
518
  const raw = req.method === "GET" || req.method === "HEAD" ? undefined : await rawBody(req);
519
+ // WARN MODE NEVER BLOCKS — it annotates. That is its entire contract: an observation period
520
+ // where the hub records what WOULD fail under enforce. The restarted local hub proved the
521
+ // failure mode: signed requests from a not-yet-enrolled identity got 401 "unknown identity"
522
+ // while UNSIGNED requests passed — punishing exactly the clients that already do the right
523
+ // thing. Under warn: bad signature, replay and unknown identity all pass with a warning;
524
+ // under enforce they are the hard failures they should be.
525
+ const soft = (warning) => AUTH_MODE === "warn"
526
+ ? { ok: true, mode: AUTH_MODE, trusted: false, warning }
527
+ : { ok: false, code: 401, error: warning };
466
528
  const verified = verifyRequest({ headers: req.headers, method: req.method, path, body: raw });
467
- if (!verified.ok) return { ok: false, code: 401, error: verified.reason || "bad signature" };
529
+ if (!verified.ok) return soft(verified.reason || "bad signature");
468
530
  const nonceKey = `${verified.pubkey}:${verified.nonce}`;
469
531
  for (const [k, ts] of seenNonces) if (Math.abs(now() - ts) > 120000) seenNonces.delete(k);
470
- if (seenNonces.has(nonceKey)) return { ok: false, code: 401, error: "replay" };
532
+ if (seenNonces.has(nonceKey)) return soft("replay");
471
533
  seenNonces.set(nonceKey, verified.ts);
472
534
  if (seenNonces.size > 10000) seenNonces.delete(seenNonces.keys().next().value);
473
535
  const identity = findIdentity(verified.pubkey);
474
- if (!identity) return { ok: false, code: 401, error: "unknown identity" };
536
+ if (!identity) return soft("unknown identity");
475
537
  return { ok: true, mode: AUTH_MODE, trusted: true, pubkey: verified.pubkey, identity };
476
538
  }
477
539
  function authorize(auth, method, P, project) {
@@ -863,6 +925,60 @@ const server = http.createServer(async (req, res) => {
863
925
  appendEvent("project.adopted", proj, String(b.by || ""), { counts: added });
864
926
  return json(res, 200, { ok: true, ...added });
865
927
  }
928
+ if (req.method === "GET" && P === "/policy") {
929
+ return json(res, 200, overseerPolicy());
930
+ }
931
+ if (req.method === "POST" && P === "/policy") {
932
+ const b = await body(req);
933
+ const p = state.orgPolicy && typeof state.orgPolicy === "object" ? state.orgPolicy : {};
934
+ p.autonomy = { ...(p.autonomy || {}) };
935
+ p.links = Array.isArray(p.links) ? p.links : [];
936
+ if (b.autonomy && typeof b.autonomy === "object") {
937
+ for (const [proj, lvl] of Object.entries(b.autonomy)) {
938
+ const n = Number(lvl);
939
+ if ([1, 2, 3, 4].includes(n)) p.autonomy[canon(String(proj).slice(0, 80))] = n;
940
+ }
941
+ }
942
+ if (b.link && Array.isArray(b.link.projects) && b.link.projects.length >= 2 && b.link.reason) {
943
+ const projects = b.link.projects.slice(0, 4).map(x => canon(String(x).slice(0, 80))).sort();
944
+ const key = projects.join(" ");
945
+ if (!p.links.some(l => (l.projects || []).slice().sort().join(" ") === key)) {
946
+ p.links.push({ projects, reason: String(b.link.reason).slice(0, 140),
947
+ declaredBy: auth?.identity?.name || String(b.by || ""), ts: now() });
948
+ }
949
+ }
950
+ state.orgPolicy = p; dirty = true;
951
+ return json(res, 200, { ok: true, ...overseerPolicy() });
952
+ }
953
+ // What a session arriving on <project> needs to know: its autonomy level, who else is live,
954
+ // which files are in flight, which projects are declared codependent, current collisions.
955
+ if (req.method === "GET" && P === "/overseer/context") {
956
+ const proj = canon(String(q.project || "").slice(0, 80));
957
+ if (!proj) return json(res, 400, { error: "project required" });
958
+ const pol = overseerPolicy();
959
+ const level = _overseer?.levelFor ? _overseer.levelFor(proj, pol.autonomy) : (pol.autonomy[proj] ?? pol.autonomy["*"] ?? 1);
960
+ const links = pol.links.filter(l => (l.projects || []).includes(proj));
961
+ const linked = new Set(links.flatMap(l => l.projects).filter(x => x !== proj));
962
+ const cutoff = now() - ONLINE_MS;
963
+ const peersOut = Object.entries(state.peers)
964
+ .filter(([, v]) => v.lastSeen > cutoff && (v.project === proj || linked.has(v.project)))
965
+ .map(([session, v]) => ({ session, project: v.project || "", llm: v.llm || "", model: v.model || "", status: v.status || "" }));
966
+ pruneClaims();
967
+ const inflight = [...fileClaims.values()].filter(c => c.project === proj)
968
+ .map(c => ({ file: c.file, session: c.session, agoSec: Math.round((now() - c.ts) / 1000) }));
969
+ let warnings = [];
970
+ try { warnings = (_overseer?.detectCollisions ? _overseer.detectCollisions(overseerInputs()) : [])
971
+ .filter(c => c.project === proj || linked.has(c.project)); } catch {}
972
+ return json(res, 200, { level, links: links.map(l => ({ projects: l.projects, reason: l.reason })), peers: peersOut, inflight, warnings });
973
+ }
974
+ if (req.method === "POST" && P === "/overseer/narrate") {
975
+ const b = await body(req);
976
+ const ev = state.events.find(e => e.id === Number(b.eventId) && e.type === "overseer.warn");
977
+ if (!ev) return json(res, 404, { error: "no such overseer.warn event" });
978
+ ev.narrated = true; ev.narration = String(b.text || "").slice(0, 300);
979
+ dirty = true;
980
+ return json(res, 200, { ok: true });
981
+ }
866
982
  if (req.method === "POST" && P === "/claim") {
867
983
  const b = await body(req);
868
984
  const proj = canon(String(b.project || "").slice(0, 80));
@@ -0,0 +1,140 @@
1
+ const PEER_LIVE_MS = 5 * 60 * 1000;
2
+ const CLAIM_LIVE_MS = 10 * 60 * 1000;
3
+
4
+ const KINDS = new Set(["same-project-sessions", "file-conflict", "linked-activity"]);
5
+
6
+ const asArray = (v) => Array.isArray(v) ? v : [];
7
+ const clean = (v) => String(v ?? "").trim();
8
+ const finiteNumber = (v) => Number.isFinite(Number(v)) ? Number(v) : null;
9
+
10
+ function isLevel(v) {
11
+ return v === 1 || v === 2 || v === 3 || v === 4;
12
+ }
13
+
14
+ export function levelFor(project, autonomy = {}) {
15
+ const key = clean(project);
16
+ const direct = autonomy?.[key];
17
+ if (isLevel(direct)) return direct;
18
+ const fallback = autonomy?.["*"];
19
+ return isLevel(fallback) ? fallback : 1;
20
+ }
21
+
22
+ function isFresh(ts, now, ttl) {
23
+ const n = finiteNumber(ts);
24
+ return n != null && now - n <= ttl;
25
+ }
26
+
27
+ function sortedStrings(items) {
28
+ return [...new Set(Array.from(items ?? []).map(clean).filter(Boolean))].sort((a, b) => a.localeCompare(b));
29
+ }
30
+
31
+ function pushCollision(out, collision) {
32
+ if (!collision.project || !KINDS.has(collision.kind) || collision.sessions.length === 0) return;
33
+ out.push({
34
+ project: collision.project,
35
+ kind: collision.kind,
36
+ sessions: sortedStrings(collision.sessions),
37
+ files: sortedStrings(collision.files ?? []),
38
+ detail: collision.detail,
39
+ });
40
+ }
41
+
42
+ function collisionKey(c) {
43
+ return [
44
+ c.project,
45
+ c.kind,
46
+ c.files[0] ?? "",
47
+ c.sessions[0] ?? "",
48
+ c.detail,
49
+ ].join("\u0000");
50
+ }
51
+
52
+ function sortCollisions(collisions) {
53
+ return [...collisions].sort((a, b) =>
54
+ a.project.localeCompare(b.project) ||
55
+ a.kind.localeCompare(b.kind) ||
56
+ (a.files[0] ?? "").localeCompare(b.files[0] ?? "") ||
57
+ (a.sessions[0] ?? "").localeCompare(b.sessions[0] ?? "") ||
58
+ a.detail.localeCompare(b.detail)
59
+ );
60
+ }
61
+
62
+ export function detectCollisions({ peers = [], claims = [], links = [], autonomy = {}, now } = {}) {
63
+ const at = finiteNumber(now) ?? 0;
64
+ const out = [];
65
+
66
+ const livePeers = [];
67
+ const seenPeers = new Set();
68
+ for (const peer of asArray(peers)) {
69
+ const session = clean(peer?.session);
70
+ const project = clean(peer?.project);
71
+ if (!session || !project || !isFresh(peer?.lastSeen, at, PEER_LIVE_MS)) continue;
72
+ const key = `${project}\u0000${session}`;
73
+ if (seenPeers.has(key)) continue;
74
+ seenPeers.add(key);
75
+ livePeers.push({ ...peer, session, project });
76
+ }
77
+
78
+ const sessionsByProject = new Map();
79
+ for (const peer of livePeers) {
80
+ const sessions = sessionsByProject.get(peer.project) ?? [];
81
+ sessions.push(peer.session);
82
+ sessionsByProject.set(peer.project, sessions);
83
+ }
84
+
85
+ for (const project of sortedStrings(sessionsByProject.keys())) {
86
+ const sessions = sortedStrings(sessionsByProject.get(project));
87
+ if (sessions.length < 2) continue;
88
+ pushCollision(out, {
89
+ project,
90
+ kind: "same-project-sessions",
91
+ sessions,
92
+ files: [],
93
+ detail: `${sessions.join(", ")} are live on project ${project}.`,
94
+ });
95
+ }
96
+
97
+ const claimSessionsByFile = new Map();
98
+ for (const claim of asArray(claims)) {
99
+ const project = clean(claim?.project);
100
+ const file = clean(claim?.file);
101
+ const session = clean(claim?.session);
102
+ if (!project || !file || !session || !isFresh(claim?.ts, at, CLAIM_LIVE_MS)) continue;
103
+ const key = `${project}\u0000${file}`;
104
+ const sessions = claimSessionsByFile.get(key) ?? new Set();
105
+ sessions.add(session);
106
+ claimSessionsByFile.set(key, sessions);
107
+ }
108
+
109
+ for (const key of [...claimSessionsByFile.keys()].sort()) {
110
+ const [project, file] = key.split("\u0000");
111
+ const sessions = sortedStrings(claimSessionsByFile.get(key));
112
+ if (sessions.length < 2) continue;
113
+ pushCollision(out, {
114
+ project,
115
+ kind: "file-conflict",
116
+ sessions,
117
+ files: [file],
118
+ detail: `${sessions.join(", ")} have live claims on ${project}/${file}.`,
119
+ });
120
+ }
121
+
122
+ for (const link of asArray(links)) {
123
+ const projects = sortedStrings(link?.projects ?? []);
124
+ if (projects.length < 2) continue;
125
+ const activeProjects = projects.filter((project) => (sessionsByProject.get(project) ?? []).length > 0);
126
+ if (activeProjects.length < 2) continue;
127
+ const sessions = sortedStrings(activeProjects.flatMap((project) => sessionsByProject.get(project) ?? []));
128
+ pushCollision(out, {
129
+ project: activeProjects[0],
130
+ kind: "linked-activity",
131
+ sessions,
132
+ files: [],
133
+ detail: `Linked projects ${activeProjects.join(", ")} have live sessions ${sessions.join(", ")}.`,
134
+ });
135
+ }
136
+
137
+ const deduped = new Map();
138
+ for (const collision of sortCollisions(out)) deduped.set(collisionKey(collision), collision);
139
+ return [...deduped.values()];
140
+ }
package/mcp.mjs CHANGED
@@ -11,7 +11,7 @@ import { homedir, hostname } from "node:os";
11
11
  import { execSync, spawnSync } from "node:child_process";
12
12
  import { advise } from "./bin/advise.mjs";
13
13
  import { resolveProject, hostId, resolveHub } from "./lib/project.mjs";
14
- import { signedPost, getJSON } from "./hooks/lib/api.mjs";
14
+ import { signedPost, signedGet } from "./hooks/lib/api.mjs";
15
15
  import { assertNoSecrets } from "./lib/scrub.mjs";
16
16
  import { z } from "zod";
17
17
 
@@ -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
@@ -43,14 +43,16 @@ async function seedCursor() {
43
43
  } catch { /* hub down: the subsequent real call surfaces the error; leave cursor at 0 */ }
44
44
  }
45
45
 
46
- // Every hub WRITE is SIGNED with this session's Ed25519 keypair (TDD §7.3) via the shared client in
47
- // hooks/lib/api.mjs that is what binds /send's `from` to the signer and closes the self-asserted
48
- // `from` hole (the 2026-07-28 RCE). Reads go unsigned for now (pending the hub's DM scope-filtering
49
- // fix see hooks/lib/api.mjs:getJSON). The client fail-opens on a down hub (returns {ok:false}); we
50
- // surface that as a thrown Error so individual tools can .catch it exactly as they did for a bare fetch.
46
+ // Every hub call is SIGNED with this session's Ed25519 keypair (TDD §7.3) via the shared client in
47
+ // hooks/lib/api.mjs. Writes: that is what binds /send's `from` to the signer and closes the
48
+ // self-asserted `from` hole (the 2026-07-28 RCE). Reads TOO (the 2026-07-30 agent-UX gap): an
49
+ // enforce hub 401s unsigned reads, which made relay_inbox/board/peers dead for the very agents the
50
+ // bus exists for. Signed reads are scope-filtered by the hub to this identity's grants for a
51
+ // session reading its own project + DMs that is the intended behavior. The client fail-opens on a
52
+ // down hub (returns {ok:false}); we surface that as a thrown Error so individual tools .catch it.
51
53
  async function api(method, path, payload) {
52
54
  const r = method.toUpperCase() === "GET"
53
- ? await getJSON(path)
55
+ ? await signedGet(path, { session: SESSION })
54
56
  : await signedPost(path, payload, { session: SESSION });
55
57
  if (!r.ok) throw new Error(`hub ${r.status} on ${path}`);
56
58
  return r.json;
@@ -61,7 +63,7 @@ const server = new McpServer({ name: "trantor", version: "0.1.0" });
61
63
 
62
64
  server.tool("relay_whoami", "Show this session's relay identity, project, and the hub URL.", {}, async () => {
63
65
  await api("POST", "/register", { session: SESSION, project: PROJECT }).catch(() => {});
64
- return { content: [{ type: "text", text: `session=${SESSION}\nproject=${PROJECT}\nhub=${URL_BASE}` }] };
66
+ return { content: [{ type: "text", text: `session=${SESSION}\nproject=${PROJECT}\nhub=${resolveHub(PROJECT)}` }] };
65
67
  });
66
68
 
67
69
  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.55",
3
+ "version": "0.17.57",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"