trantor 0.17.56 → 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.56",
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.56",
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
 
@@ -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);
@@ -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
  }
@@ -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/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
@@ -63,7 +63,7 @@ const server = new McpServer({ name: "trantor", version: "0.1.0" });
63
63
 
64
64
  server.tool("relay_whoami", "Show this session's relay identity, project, and the hub URL.", {}, async () => {
65
65
  await api("POST", "/register", { session: SESSION, project: PROJECT }).catch(() => {});
66
- 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)}` }] };
67
67
  });
68
68
 
69
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.56",
3
+ "version": "0.17.57",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"