trantor 0.17.57 → 0.17.59

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.57",
16
+ "version": "0.17.59",
17
17
  "author": {
18
18
  "name": "Sasha Bogojevic"
19
19
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.17.57",
3
+ "version": "0.17.59",
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": {
@@ -66,7 +66,13 @@ async function api(path, body) {
66
66
  // that shows up as a seat that quietly records nothing rather than one that errors.
67
67
  const url = HUB + path;
68
68
  const sig = signedHeaders(identity, url, opts);
69
- const r = await fetch(url, { ...opts, headers: { ...opts.headers, ...sig } });
69
+ // HARD DEADLINE on every call (2026-08-01, crebral-health kimi seat): a long-poll whose socket
70
+ // dies silently (idle NAT/tailscale reset, no RST delivered) otherwise hangs fetch FOREVER —
71
+ // the runner sat "parked" with zero connections and zero retries while its crew was rebuilt
72
+ // around it. Deadline = the poll's own wait window + slack, so a healthy long-poll never trips
73
+ // it and a dead one surfaces as a catchable error that the main loop retries in 5s.
74
+ const waitS = Number((path.match(/[?&]wait=(\d+)/) || [])[1] || 0);
75
+ const r = await fetch(url, { ...opts, headers: { ...opts.headers, ...sig }, signal: AbortSignal.timeout((waitS + 30) * 1000) });
70
76
  return r.json();
71
77
  }
72
78
 
@@ -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 signedGet(`/inbox?session=${encodeURIComponent(session)}&since=${since}`, { timeoutMs: FETCH_TIMEOUT_MS, session });
36
+ async function getInbox(session, since, instance) {
37
+ const { ok, json } = await signedGet(`/inbox?session=${encodeURIComponent(session)}&since=${since}`, { timeoutMs: FETCH_TIMEOUT_MS, session, instance });
38
38
  if (!ok || !json) throw new Error("hub unreachable");
39
- return json; // { messages: [...], cursor }
39
+ return json; // { messages: [...], cursor, superseded? }
40
40
  }
41
41
 
42
42
  // PostToolUse hands us the tool-input JSON on stdin. We don't need it, but we must DRAIN it:
@@ -58,7 +58,12 @@ function emit(ctx) {
58
58
  try { JSON.parse(out); return out; } catch { return "{}"; }
59
59
  }
60
60
 
61
- async function main() {
61
+ async function main(stdinRaw) {
62
+ // The harness session_id is this session's INSTANCE id (docs/INSTANCE-KEYS-CONTRACT.md): it keys
63
+ // the endorsed subkey that signs our reads AND the local cursor, so a baton twin (same durable
64
+ // name, different session_id) has its own ledger and can't eat this session's messages.
65
+ let instanceId = "";
66
+ try { instanceId = String(JSON.parse(stdinRaw || "{}").session_id || ""); } catch {}
62
67
  const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
63
68
  // Mirror heartbeat.mjs / sessionstart.mjs: a home-directory session isn't project work and
64
69
  // isn't on the bus — nothing to deliver. Opt in with RELAY_SESSION / RELAY_PROJECT.
@@ -70,7 +75,7 @@ async function main() {
70
75
  const session = process.env.RELAY_SESSION
71
76
  || (process.env.RELAY_AGENT ? `${process.env.RELAY_AGENT}:${project}` : `${hostId()}:${project}`);
72
77
 
73
- const safe = session.replace(/[^A-Za-z0-9_.-]/g, "_");
78
+ const safe = (session + (instanceId ? `@${instanceId.slice(0, 8)}` : "")).replace(/[^A-Za-z0-9_.@-]/g, "_");
74
79
  const dir = join(homedir(), ".agent-bus");
75
80
  const pollStamp = join(dir, `inbox-poll-${safe}.stamp`);
76
81
  const cursorFile = join(dir, `inbox-cursor-${safe}.id`);
@@ -89,7 +94,7 @@ async function main() {
89
94
  // so we start listening "from now" instead of replaying the whole backlog of old broadcasts.
90
95
  if (!existsSync(cursorFile)) {
91
96
  try {
92
- const { cursor } = await getInbox(session, 0);
97
+ const { cursor } = await getInbox(session, 0, instanceId);
93
98
  writeFileSync(cursorFile, String(cursor || 0));
94
99
  } catch {}
95
100
  return "{}";
@@ -98,13 +103,19 @@ async function main() {
98
103
  let cursor = 0;
99
104
  try { cursor = Number(readFileSync(cursorFile, "utf8")) || 0; } catch {}
100
105
 
101
- let messages = [], next = cursor;
106
+ let messages = [], next = cursor, superseded = false;
102
107
  try {
103
- const res = await getInbox(session, cursor);
108
+ const res = await getInbox(session, cursor, instanceId);
104
109
  messages = Array.isArray(res.messages) ? res.messages : [];
105
110
  next = res.cursor || cursor;
111
+ superseded = res.superseded === true;
106
112
  } catch { return "{}"; } // hub down / timeout — never block the tool flow
107
113
 
114
+ // Stand-down note (never a block): a newer instance of this durable identity claimed the baton.
115
+ if (superseded && !messages.length) {
116
+ return emit(`<trantor-inbox count="0">\n⚠️ A newer instance of this session has claimed the baton (instance supersession). Stand down: finish your current thought, do not consume bus messages, and let the new session carry the work.\n</trantor-inbox>\n`);
117
+ }
118
+
108
119
  if (!messages.length) return "{}";
109
120
 
110
121
  // Advance the cursor immediately so we don't re-inject these on the next tool call.
@@ -119,6 +130,7 @@ async function main() {
119
130
 
120
131
  const ctx =
121
132
  `<trantor-inbox count="${messages.length}">\n` +
133
+ (superseded ? `⚠️ A newer instance of this session has claimed the baton — stand down after handling anything addressed directly to you; the new session carries the work.\n` : "") +
122
134
  `📬 ${messages.length} new bus message(s) arrived while you were working (you did not poll for these — Trantor surfaced them automatically):\n` +
123
135
  lines.join("\n") + `\n` +
124
136
  `If a peer is asking you something or waiting on you, reply now with the relay_send tool (to their session id). ` +
package/hooks/lib/api.mjs CHANGED
Binary file
@@ -242,6 +242,12 @@ try {
242
242
  });
243
243
  if (handoff) {
244
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
+ }
245
251
  additionalContext += `<trantor-handoff id="${sanitize(handoff.id)}" from="${sanitize(handoff.machine)}" trigger="${sanitize(handoff.trigger)}">\n`;
246
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`;
247
253
  // Verification gates FIRST — these are structured "must verify before shipping" claims the prior
@@ -63,7 +63,10 @@ async function main() {
63
63
 
64
64
  // Share inbox-deliver.mjs's cursor: ONE local delivery ledger, so a message injected mid-turn is never
65
65
  // re-surfaced here, and vice versa.
66
- const safe = session.replace(/[^A-Za-z0-9_.-]/g, "_");
66
+ // Same instance id + per-instance cursor as inbox-deliver (docs/INSTANCE-KEYS-CONTRACT.md):
67
+ // T1 and T2 share one ledger within a session; a baton twin gets its own.
68
+ const instanceId = String(input.session_id || "");
69
+ const safe = (session + (instanceId ? `@${instanceId.slice(0, 8)}` : "")).replace(/[^A-Za-z0-9_.@-]/g, "_");
67
70
  const cursorFile = join(homedir(), ".agent-bus", `inbox-cursor-${safe}.id`);
68
71
  // No cursor yet means inbox-deliver has never run for this session; it initialises to "now" on its
69
72
  // first tool call. Blocking on the whole backlog of old messages would be a terrible first impression.
@@ -75,8 +78,11 @@ async function main() {
75
78
  let messages = [];
76
79
  try {
77
80
  // PEEK: look without claiming delivery. We may yet decide to let the stop through.
78
- const peek = await signedGet(`/inbox?session=${encodeURIComponent(session)}&since=${cursor}&peek=1`, { timeoutMs: FETCH_TIMEOUT_MS, session });
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 signedGet(`/inbox?session=${encodeURIComponent(session)}&since=${cursor}`, { timeoutMs: FETCH_TIMEOUT_MS, session });
95
+ const claim = await signedGet(`/inbox?session=${encodeURIComponent(session)}&since=${cursor}`, { timeoutMs: FETCH_TIMEOUT_MS, session, instance: instanceId });
90
96
  if (claim.ok) next = claim.json?.cursor || cursor;
91
97
  } catch {}
92
98
  try { writeFileSync(cursorFile, String(next)); } catch {}
package/hub.mjs CHANGED
@@ -9,7 +9,7 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync, readdirSy
9
9
  import { homedir } from "node:os";
10
10
  import { join } from "node:path";
11
11
  import { timingSafeEqual, randomBytes } from "node:crypto";
12
- import { verifyRequest, publicView } from "./lib/identity.mjs";
12
+ import { verifyRequest, verifyEndorsement, publicView } from "./lib/identity.mjs";
13
13
  import { DEFAULT_ORG } from "./lib/store-contract.mjs";
14
14
  import { assertNoSecrets } from "./lib/scrub.mjs";
15
15
 
@@ -86,7 +86,7 @@ function scanTelemetry() {
86
86
  // TIMELINE view are untouched; every NEW type is dotted ("message", "presence.online", …) and is
87
87
  // filtered OUT of /history. Loads from the old `cardEvents` key when `events` is absent.
88
88
  function emptyState() {
89
- return { messages: [], peers: {}, seq: 0, tasks: [], taskSeq: 0, projectMeta: {}, lessons: [], events: [], cardEventsBackfilled: false, aliases: {}, phaseMeta: {}, verifyGates: [], verifyGateSeq: 0, balances: { ts: 0, by: "", entries: [] }, subagentCostReset: false, handoffLog: [], identities: {}, inviteTokens: {}, focus: {}, orgPolicy: {} };
89
+ return { messages: [], peers: {}, seq: 0, tasks: [], taskSeq: 0, projectMeta: {}, lessons: [], events: [], cardEventsBackfilled: false, aliases: {}, phaseMeta: {}, verifyGates: [], verifyGateSeq: 0, balances: { ts: 0, by: "", entries: [] }, subagentCostReset: false, handoffLog: [], identities: {}, inviteTokens: {}, focus: {}, orgPolicy: {}, instances: {} };
90
90
  }
91
91
 
92
92
  function normalizeState(loaded = {}) {
@@ -108,6 +108,7 @@ function normalizeState(loaded = {}) {
108
108
  s.handoffLog = Array.isArray(loaded.handoffLog) ? loaded.handoffLog : [];
109
109
  s.identities = loaded.identities && typeof loaded.identities === "object" ? loaded.identities : {};
110
110
  s.inviteTokens = loaded.inviteTokens && typeof loaded.inviteTokens === "object" ? loaded.inviteTokens : {};
111
+ s.instances = loaded.instances && typeof loaded.instances === "object" ? loaded.instances : {};
111
112
  s.focus = loaded.focus && typeof loaded.focus === "object" ? loaded.focus : {};
112
113
  s.orgPolicy = loaded.orgPolicy && typeof loaded.orgPolicy === "object" ? loaded.orgPolicy : {};
113
114
  for (const [session, v] of Object.entries(loaded.peers || {})) {
@@ -532,6 +533,30 @@ async function authenticate(req, path) {
532
533
  if (seenNonces.has(nonceKey)) return soft("replay");
533
534
  seenNonces.set(nonceKey, verified.ts);
534
535
  if (seenNonces.size > 10000) seenNonces.delete(seenNonces.keys().next().value);
536
+ // Instance-subkey path (docs/INSTANCE-KEYS-CONTRACT.md): when the three endorsement headers ride
537
+ // along, x-trantor-pubkey was the INSTANCE key (whose signature we just verified). Verify that the
538
+ // claimed DURABLE key endorsed it, then authenticate AS the durable identity — the instance mints
539
+ // no authority of its own; it is the durable identity, time-boxed to one session.
540
+ const h = (k) => req.headers[k] ?? "";
541
+ const durableHdr = h("x-trantor-durable"), instId = h("x-trantor-inst");
542
+ if (durableHdr && instId) {
543
+ const endorsed = verifyEndorsement({
544
+ durablePubkey: durableHdr, instancePubkey: verified.pubkey, instanceId: instId,
545
+ createdAt: state.instances?.[verified.pubkey]?.createdAt || Number(h("x-trantor-inst-ts")) || 0,
546
+ endorsement: h("x-trantor-endorse"),
547
+ });
548
+ if (!endorsed) return soft("bad endorsement");
549
+ const identity = findIdentity(durableHdr);
550
+ if (!identity) return soft("unknown identity");
551
+ if (!state.instances || typeof state.instances !== "object") state.instances = {};
552
+ const rec = state.instances[verified.pubkey] ||
553
+ { durable: durableHdr, instanceId: instId, name: identity.name || "", firstSeen: now(),
554
+ createdAt: Number(h("x-trantor-inst-ts")) || now(), superseded: false };
555
+ rec.lastSeen = now();
556
+ state.instances[verified.pubkey] = rec; dirty = true;
557
+ return { ok: true, mode: AUTH_MODE, trusted: true, pubkey: durableHdr, identity,
558
+ instanceId: instId, instancePubkey: verified.pubkey, superseded: !!rec.superseded };
559
+ }
535
560
  const identity = findIdentity(verified.pubkey);
536
561
  if (!identity) return soft("unknown identity");
537
562
  return { ok: true, mode: AUTH_MODE, trusted: true, pubkey: verified.pubkey, identity };
@@ -971,6 +996,30 @@ const server = http.createServer(async (req, res) => {
971
996
  .filter(c => c.project === proj || linked.has(c.project)); } catch {}
972
997
  return json(res, 200, { level, links: links.map(l => ({ projects: l.projects, reason: l.reason })), peers: peersOut, inflight, warnings });
973
998
  }
999
+ // Supersession (docs/INSTANCE-KEYS-CONTRACT.md): EXPLICIT, never automatic — the baton-claim
1000
+ // path calls this when a fresh session consumes a handoff. Marks every OTHER instance of the
1001
+ // named durable identity superseded; their /inbox + /poll answers then carry superseded:true so
1002
+ // their own hooks tell the model to stand down. Informational, never a hard block. Accepted
1003
+ // only from an endorsed instance of the SAME durable identity, or the owner.
1004
+ if (req.method === "POST" && P === "/instance/supersede") {
1005
+ const b = await body(req);
1006
+ const name = String(b.name || "").slice(0, 200);
1007
+ const except = String(b.exceptInstanceId || "").slice(0, 200);
1008
+ if (!name) return json(res, 400, { error: "name required" });
1009
+ if (AUTH_MODE !== "off") {
1010
+ const sameIdentity = auth?.identity && String(auth.identity.name || "") === name;
1011
+ const isOwner = auth?.identity?.kind === "human" || scopeAllows(auth?.identity, "", "owner");
1012
+ if (!sameIdentity && !isOwner && AUTH_MODE === "enforce") return json(res, 403, { error: "forbidden" });
1013
+ }
1014
+ let flipped = 0;
1015
+ for (const rec of Object.values(state.instances || {})) {
1016
+ if (rec.name !== name || rec.superseded) continue;
1017
+ if (except && rec.instanceId === except) continue;
1018
+ rec.superseded = now(); flipped++;
1019
+ }
1020
+ if (flipped) dirty = true;
1021
+ return json(res, 200, { ok: true, superseded: flipped });
1022
+ }
974
1023
  if (req.method === "POST" && P === "/overseer/narrate") {
975
1024
  const b = await body(req);
976
1025
  const ev = state.events.find(e => e.id === Number(b.eventId) && e.type === "overseer.warn");
@@ -1798,7 +1847,9 @@ const server = http.createServer(async (req, res) => {
1798
1847
  // through). Advancing the ledger on a peek would tell the deferred waker the message had been
1799
1848
  // delivered when nobody ever saw it — a silent hole exactly where this feature is supposed to help.
1800
1849
  if (q.peek !== "1") markDelivered(q.session, cursor);
1801
- return json(res, 200, { messages: msgs, cursor });
1850
+ // superseded (instance-keys contract): a baton twin that lost the claim learns it HERE, via
1851
+ // its own read — its hooks turn this into a stand-down note for the model. Never a block.
1852
+ return json(res, 200, auth?.superseded ? { messages: msgs, cursor, superseded: true } : { messages: msgs, cursor });
1802
1853
  }
1803
1854
  if (req.method === "GET" && P === "/poll") {
1804
1855
  if (!canUseInboxSession(auth, q.session)) return json(res, 403, { error: "forbidden" });
@@ -1807,7 +1858,7 @@ const server = http.createServer(async (req, res) => {
1807
1858
  const deadline = now() + waitMs;
1808
1859
  const tick = () => {
1809
1860
  const msgs = state.messages.filter(m => m.id > since && deliverable(m, q.session) && inboxReadable(auth, m, q.session));
1810
- if (msgs.length || now() >= deadline) { touch(q.session, undefined, undefined, undefined, auth); const cursor = msgs.length ? msgs[msgs.length - 1].id : since; markDelivered(q.session, cursor); return json(res, 200, { messages: msgs, cursor }); }
1861
+ if (msgs.length || now() >= deadline) { touch(q.session, undefined, undefined, undefined, auth); const cursor = msgs.length ? msgs[msgs.length - 1].id : since; markDelivered(q.session, cursor); return json(res, 200, auth?.superseded ? { messages: msgs, cursor, superseded: true } : { messages: msgs, cursor }); }
1811
1862
  setTimeout(tick, 300);
1812
1863
  };
1813
1864
  return tick();
package/lib/identity.mjs CHANGED
@@ -21,6 +21,7 @@ import { join } from "node:path";
21
21
  import { homedir } from "node:os";
22
22
 
23
23
  export const SCHEME = "trantor-v1";
24
+ export const INST_SCHEME = "trantor-inst-v1";
24
25
  export const SKEW_MS = 120_000; // reject a signature older/newer than this
25
26
  export const HDR = {
26
27
  pubkey: "x-trantor-pubkey",
@@ -28,6 +29,15 @@ export const HDR = {
28
29
  ts: "x-trantor-ts",
29
30
  nonce: "x-trantor-nonce",
30
31
  };
32
+ // Instance-subkey headers (docs/INSTANCE-KEYS-CONTRACT.md). When present, x-trantor-pubkey above
33
+ // carries the INSTANCE pubkey and the request is attributed to the DURABLE identity below, provided
34
+ // the endorsement verifies. Absent → plain v1, unchanged.
35
+ export const HDR_INST = {
36
+ durable: "x-trantor-durable",
37
+ inst: "x-trantor-inst",
38
+ endorse: "x-trantor-endorse",
39
+ instTs: "x-trantor-inst-ts",
40
+ };
31
41
 
32
42
  const busDir = () => process.env.AGENT_BUS_DIR || join(homedir(), ".agent-bus");
33
43
  const keysDir = () => join(busDir(), "keys");
@@ -134,6 +144,75 @@ export function signRequest(identity, { method, path, body }) {
134
144
  };
135
145
  }
136
146
 
147
+ // --- session-instance subkeys (docs/INSTANCE-KEYS-CONTRACT.md) ---------------------------------
148
+ // A per-session-instance keypair, ENDORSED by the durable identity: the durable key signs
149
+ // endorsementString(...), attesting "this instance pubkey speaks as me until it dies". The durable
150
+ // key keeps enrollment/grants/attribution; the instance key signs traffic and dies with the
151
+ // session. Fixes the handoff-twin identity collision (two lineages, two subkeys, one durable name)
152
+ // and gives per-restart credential freshness (the teams login-session model).
153
+ export function endorsementString({ durablePubkey, instancePubkey, instanceId, createdAt }) {
154
+ // Same discipline as canonicalString: newline-joined, fixed arity, no field may contain \n.
155
+ return [INST_SCHEME, String(durablePubkey), String(instancePubkey), String(instanceId), String(createdAt)].join("\n");
156
+ }
157
+
158
+ export const instanceKeyPath = (name, instanceId) =>
159
+ join(keysDir(), "instances", `${safe(name)}@${safe(instanceId)}.json`);
160
+
161
+ // Mint-or-load an endorsed instance identity. Atomic against racing hooks exactly like
162
+ // loadOrCreate: two processes of one session (hooks vs MCP would use DIFFERENT instanceIds, but
163
+ // T1/T2 hooks share one) must converge on a single keypair for a given (name, instanceId).
164
+ export function loadOrCreateInstance(durableIdentity, instanceId) {
165
+ if (!durableIdentity?.privkey || !instanceId) return null;
166
+ const f = instanceKeyPath(durableIdentity.name, instanceId);
167
+ try {
168
+ if (existsSync(f)) {
169
+ const inst = JSON.parse(readFileSync(f, "utf8"));
170
+ if (inst?.pubkey && inst?.privkey && inst?.endorsement) return inst;
171
+ }
172
+ } catch {}
173
+ try {
174
+ mkdirSync(join(keysDir(), "instances"), { recursive: true, mode: 0o700 });
175
+ const { pubkey, privkey } = generate();
176
+ const createdAt = Date.now();
177
+ const msg = Buffer.from(endorsementString({
178
+ durablePubkey: durableIdentity.pubkey, instancePubkey: pubkey, instanceId, createdAt,
179
+ }), "utf8");
180
+ const endorsement = cryptoSign(null, msg, privKeyObject(durableIdentity)).toString("base64");
181
+ const inst = {
182
+ name: durableIdentity.name, instanceId, pubkey, privkey, createdAt, endorsement,
183
+ durablePubkey: durableIdentity.pubkey,
184
+ };
185
+ const tmp = `${f}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
186
+ writeFileSync(tmp, JSON.stringify(inst), { mode: 0o600 });
187
+ if (existsSync(f)) { try { return JSON.parse(readFileSync(f, "utf8")); } catch { return inst; } }
188
+ renameSync(tmp, f);
189
+ chmodSync(f, 0o600);
190
+ return inst;
191
+ } catch { return null; } // unwritable — caller falls back to durable
192
+ }
193
+
194
+ // The extra wire headers an instance identity contributes (alongside the v1 set signed with ITS key).
195
+ export function instanceHeaders(inst) {
196
+ if (!inst?.durablePubkey || !inst?.endorsement) return {};
197
+ return {
198
+ [HDR_INST.durable]: inst.durablePubkey,
199
+ [HDR_INST.inst]: inst.instanceId,
200
+ [HDR_INST.endorse]: inst.endorsement,
201
+ [HDR_INST.instTs]: String(inst.createdAt),
202
+ };
203
+ }
204
+
205
+ // Pure endorsement check: did `durablePubkey` really endorse `instancePubkey` for this instanceId?
206
+ // Enrollment/grants/supersession stay with the hub, which owns that state.
207
+ export function verifyEndorsement({ durablePubkey, instancePubkey, instanceId, createdAt, endorsement }) {
208
+ if (!durablePubkey || !instancePubkey || !instanceId || !createdAt || !endorsement) return false;
209
+ if (!/^[0-9a-f]{64}$/i.test(durablePubkey) || !/^[0-9a-f]{64}$/i.test(instancePubkey)) return false;
210
+ try {
211
+ const msg = Buffer.from(endorsementString({ durablePubkey, instancePubkey, instanceId, createdAt }), "utf8");
212
+ return cryptoVerify(null, msg, pubFromHex(durablePubkey), Buffer.from(endorsement, "base64"));
213
+ } catch { return false; }
214
+ }
215
+
137
216
  // Pure verification: signature + freshness only. Replay defence (nonce memory) and authorization
138
217
  // (is this pubkey known? may it touch this project?) belong to the hub, which owns that state.
139
218
  // Returns { ok, pubkey, ts, nonce, reason }.
@@ -6,7 +6,7 @@
6
6
  // we send the request UNSIGNED rather than failing. Under RELAY_AUTH=warn the hub accepts it and
7
7
  // flags it; under enforce the hub rejects it and the caller sees a 401 — which is the correct place
8
8
  // for that decision, because only the hub knows the policy.
9
- import { signRequest, loadOrCreate } from "./identity.mjs";
9
+ import { signRequest, loadOrCreate, instanceHeaders } from "./identity.mjs";
10
10
 
11
11
  // Sign over path + query only. The origin is not in the canonical string: the same request proxied
12
12
  // through a different host must still verify, and the hub knows its own address.
@@ -19,11 +19,15 @@ function pathOf(url) {
19
19
  export function signedHeaders(identity, url, opts = {}) {
20
20
  if (!identity?.privkey) return {};
21
21
  try {
22
- return signRequest(identity, {
22
+ const v1 = signRequest(identity, {
23
23
  method: (opts.method || "GET").toUpperCase(),
24
24
  path: pathOf(url),
25
25
  body: opts.body,
26
26
  });
27
+ // An INSTANCE identity (docs/INSTANCE-KEYS-CONTRACT.md) carries its endorsement; the extra
28
+ // headers ride along and the hub attributes the request to the durable identity. A plain
29
+ // durable identity contributes nothing here — v1 wire format unchanged.
30
+ return { ...v1, ...instanceHeaders(identity) };
27
31
  } catch { return {}; } // never let signing break a caller
28
32
  }
29
33
 
package/lib/store-pg.mjs CHANGED
@@ -124,6 +124,7 @@ function kvFromState(state) {
124
124
  verifyGateSeq: Number(state.verifyGateSeq || 0),
125
125
  cardEventsBackfilled: !!state.cardEventsBackfilled,
126
126
  inviteTokens: state.inviteTokens || {},
127
+ instances: state.instances || {},
127
128
  },
128
129
  subagentCostReset: !!state.subagentCostReset,
129
130
  seq: Number(state.seq || 0),
@@ -669,6 +670,7 @@ export class PgStore {
669
670
  handoffLog: Array.isArray(kv.handoffLog) ? kv.handoffLog : [],
670
671
  identities,
671
672
  inviteTokens: meta.inviteTokens && typeof meta.inviteTokens === "object" ? meta.inviteTokens : {},
673
+ instances: meta.instances && typeof meta.instances === "object" ? meta.instances : {},
672
674
  focus: kv.focus && typeof kv.focus === "object" ? kv.focus : {},
673
675
  orgPolicy: kv.orgPolicy && typeof kv.orgPolicy === "object" ? kv.orgPolicy : {},
674
676
  };
package/mcp.mjs CHANGED
@@ -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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.17.57",
3
+ "version": "0.17.59",
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": [