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