trantor 0.17.97 → 0.17.99

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.17.97",
3
+ "version": "0.17.99",
4
4
  "description": "Trantor \u2014 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": {
@@ -436,9 +436,17 @@ export function maybeSpawn(projectDir, conf = readConfig()) {
436
436
  export const RECAP_CMD = "claude 'Recap the handoff you just took over — what was the previous session doing, and where do we continue? Then wait for me.'";
437
437
 
438
438
  // Spawn a fresh self-announcing session WITHOUT the dialog (manual handoff — the user already decided).
439
+ // ONE suppression check for every path that can open a terminal window. There were two names for
440
+ // this — TRANTOR_NO_HANDOFF_SPAWN here and TRANTOR_NO_BATON_SPAWN on spawnBaton — and a drill that
441
+ // set the wrong one opened eight live sessions in deleted temp directories, twice. A guard with two
442
+ // names is a guard you can miss, so both are honoured wherever a window is opened.
443
+ export function spawnSuppressed() {
444
+ return process.env.TRANTOR_NO_HANDOFF_SPAWN === "1" || process.env.TRANTOR_NO_BATON_SPAWN === "1";
445
+ }
446
+
439
447
  export function spawnFresh(projectDir) {
440
448
  try {
441
- if (process.platform !== "darwin" || process.env.TRANTOR_NO_HANDOFF_SPAWN === "1") return false;
449
+ if (process.platform !== "darwin" || spawnSuppressed()) return false;
442
450
  const script = join(HERE, "..", "..", "bin", "open-session.sh");
443
451
  if (!existsSync(script)) return false;
444
452
  const child = spawn("/bin/bash", [script, projectDir, RECAP_CMD], { detached: true, stdio: "ignore" });
@@ -482,7 +490,7 @@ export function spawnBaton({ projectDir, handoffFile, conf = readConfig(),
482
490
  // then deleted, each parked on a "do you trust this folder?" prompt. Five of them were found by
483
491
  // the operator on 2026-08-24. A code path that spawns windows needs an off switch, or it cannot
484
492
  // be tested honestly and someone will fake one that does not exist.
485
- if (process.env.TRANTOR_NO_BATON_SPAWN === "1" || conf.batonSpawn === false) {
493
+ if (spawnSuppressed() || conf.batonSpawn === false) {
486
494
  return { spawned: false, armed: false, windowId: "", suppressed: true };
487
495
  }
488
496
  const { windowId, tty } = _resolveWindow(); // original window FIRST, while it's still frontmost
@@ -54,10 +54,17 @@ function readStdin() {
54
54
  // complaint this exists to answer. So before letting a stop through we ask the hub what this
55
55
  // session is still owed, and refuse ONCE if any of it has gone quiet.
56
56
  //
57
- // Deliberately narrow: only a contract whose assignee is offline, or one that is overdue, blocks.
58
- // An open contract with a healthy seat working on it is exactly what "in progress" looks like, and
59
- // nagging about it every stop would be worse than saying nothing. Fail-open throughout: a hub that
60
- // is down or slow must never trap a session.
57
+ // Deliberately narrow: only a contract the hub calls `stalled` blocks — assignee offline, or overdue
58
+ // while online. An open contract with a healthy seat working on it is exactly what "in progress"
59
+ // looks like, and nagging about it every stop would be worse than saying nothing. Fail-open
60
+ // throughout: a hub that is down or slow must never trap a session.
61
+ //
62
+ // `abandoned` deliberately does NOT block. Its assignee has been quiet so long the contract can no
63
+ // longer resolve itself, and blocking on it would nag every future session forever about a seat that
64
+ // is never coming back — the ghost problem this hook was accused of causing. The lifecycle guarantees
65
+ // the dispatcher was already told: a contract is always `stalled` (and blocking) for a long while
66
+ // before it can become `abandoned`. After that the hub keeps it in the ledger, and relay_contracts
67
+ // still shows it, but it stops trapping anyone.
61
68
  const OVERDUE_MS = (() => {
62
69
  const raw = process.env.TRANTOR_CONTRACT_OVERDUE_MS;
63
70
  const n = raw === undefined || raw === "" ? NaN : Number(raw);
@@ -68,13 +75,18 @@ async function stalledContractCheck({ session, project, instanceId }) {
68
75
  if (process.env.RELAY_STOP_CONTRACTS === "0") return allow();
69
76
  let contracts = [];
70
77
  try {
71
- const r = await signedGet(`/contracts?session=${encodeURIComponent(session)}&project=${encodeURIComponent(project)}`,
78
+ const r = await signedGet(`/contracts?session=${encodeURIComponent(session)}&project=${encodeURIComponent(project)}&overdueMs=${OVERDUE_MS}`,
72
79
  { timeoutMs: FETCH_TIMEOUT_MS, session, instance: instanceId, project });
73
80
  if (!r.ok) return allow();
74
81
  contracts = r.json?.contracts || [];
75
82
  } catch { return allow(); }
76
83
 
77
- const stalled = contracts.filter(c => !c.answered && (!c.assigneeOnline || c.ageMs >= OVERDUE_MS));
84
+ // The hub owns the verdict now (it also knows the abandon window). An older hub that does not send
85
+ // `disposition` falls back to the original predicate, so a new hook against an old hub is unchanged.
86
+ const stalled = contracts.filter(c => c.disposition
87
+ ? c.disposition === "stalled"
88
+ : (!c.answered && (!c.assigneeOnline || c.ageMs >= OVERDUE_MS)));
89
+ const abandoned = contracts.filter(c => c.disposition === "abandoned");
78
90
  if (!stalled.length) return allow();
79
91
 
80
92
  const mins = (ms) => (ms >= 60000 ? `${Math.round(ms / 60000)}m` : `${Math.round(ms / 1000)}s`);
@@ -85,9 +97,15 @@ async function stalledContractCheck({ session, project, instanceId }) {
85
97
  return `- ${sanitize(c.to)} (${health}) — asked ${mins(c.ageMs)} ago: "${sanitize(String(c.text).slice(0, 120))}"`;
86
98
  }).join("\n");
87
99
 
100
+ const ghosts = abandoned.length
101
+ ? `\n\nAlso ${abandoned.length} contract(s) are ABANDONED — their assignee has been gone long enough ` +
102
+ `that they can never be answered (${abandoned.slice(0, 3).map(c => sanitize(c.to)).join(", ")}). ` +
103
+ `They no longer block you and you will not be told again. If that work still matters, reassign it.`
104
+ : "";
105
+
88
106
  const reason =
89
107
  `You dispatched ${stalled.length} contract(s) that have gone quiet, and you were about to go idle:\n` +
90
- lines + `\n\n` +
108
+ lines + ghosts + `\n\n` +
91
109
  `Do not just wait, and do not ask the human to check. Use relay_contracts to see everything you are owed, ` +
92
110
  `relay_peers to see whether the seat is alive, and relay_send to ask it directly. If a seat is down, say so and ` +
93
111
  `either swap it (\`trantor swap <agent>\`) or reassign the work. If the work is genuinely still running, say that ` +
package/hub.mjs CHANGED
@@ -46,6 +46,22 @@ const FOCUS_OFFLINE_MS = Number(process.env.RELAY_FOCUS_OFFLINE_MS || ONLINE_MS)
46
46
  // untouched for an hour is routine — a big task runs a long time between prompts.
47
47
  const FOCUS_IDLE_MS = Number(process.env.RELAY_FOCUS_IDLE_MS || 6 * 60 * 60 * 1000);
48
48
  const REAP_INTERVAL_MS = Number(process.env.RELAY_REAP_INTERVAL_MS || 60000); // how often the reaper sweeps (env-tunable; tests set it low)
49
+ // Contract lifecycle. A contract closes when the ASSIGNEE answers — so a seat that does the work and
50
+ // then dies never closes one, and a session found 16 such ghosts in a day, every one with its files
51
+ // already on disk. The dispatcher then gets nagged about them at every stop, forever, and the human
52
+ // becomes the one who remembers. That is the complaint this whole area exists to answer.
53
+ //
54
+ // The fix is the stale-card lane's shape, not an auto-close: quiet is NOT proof of death, so the hub
55
+ // never marks a contract answered on the assignee's behalf. Instead a contract walks a lifecycle —
56
+ // waiting assignee online and inside the overdue window; this is what progress looks like
57
+ // stalled assignee offline, or overdue while online; ACTIONABLE, and what blocks a stop
58
+ // abandoned assignee quiet past CONTRACT_ABANDON_MS; it can no longer resolve itself
59
+ // — and only the middle state nags. Abandoned ones stay in the ledger with their evidence so
60
+ // `relay_contracts` can still show what died, but they stop trapping every future session.
61
+ // The window is deliberately much longer than the stop hook's overdue window, so a contract is
62
+ // always surfaced as `stalled` (and acted on) BEFORE it can go quiet as `abandoned`.
63
+ const CONTRACT_ABANDON_MS = Number(process.env.RELAY_CONTRACT_ABANDON_MS || 60 * 60 * 1000);
64
+ const CONTRACT_WINDOW_MS = Number(process.env.RELAY_CONTRACT_WINDOW_MS || 24 * 60 * 60 * 1000);
49
65
  if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
50
66
  const isLoopbackHost = (host) => {
51
67
  const h = String(host || "").toLowerCase();
@@ -95,7 +111,7 @@ function scanTelemetry() {
95
111
  // TIMELINE view are untouched; every NEW type is dotted ("message", "presence.online", …) and is
96
112
  // filtered OUT of /history. Loads from the old `cardEvents` key when `events` is absent.
97
113
  function emptyState() {
98
- return { messages: [], peers: {}, seq: 0, tasks: [], taskSeq: 0, projectMeta: {}, lessons: [], events: [], cardEventsBackfilled: false, aliases: {}, phaseMeta: {}, verifyGates: [], verifyGateSeq: 0, proposals: [], proposalSeq: 0, balances: { ts: 0, by: "", entries: [] }, subagentCostReset: false, handoffLog: [], identities: {}, inviteTokens: {}, focus: {}, orgPolicy: {}, instances: {}, dutySession: "" };
114
+ return { messages: [], peers: {}, seq: 0, tasks: [], taskSeq: 0, projectMeta: {}, lessons: [], events: [], cardEventsBackfilled: false, aliases: {}, phaseMeta: {}, verifyGates: [], verifyGateSeq: 0, proposals: [], proposalSeq: 0, balances: { ts: 0, by: "", entries: [] }, subagentCostReset: false, handoffLog: [], identities: {}, inviteTokens: {}, focus: {}, orgPolicy: {}, instances: {}, dutySession: "", contractReap: {} };
99
115
  }
100
116
 
101
117
  const CARD_LOG_MAX = 40;
@@ -171,6 +187,7 @@ function normalizeState(loaded = {}) {
171
187
  s.focus = loaded.focus && typeof loaded.focus === "object" ? loaded.focus : {};
172
188
  s.orgPolicy = loaded.orgPolicy && typeof loaded.orgPolicy === "object" ? loaded.orgPolicy : {};
173
189
  s.dutySession = String(loaded.dutySession || "");
190
+ s.contractReap = loaded.contractReap && typeof loaded.contractReap === "object" ? loaded.contractReap : {};
174
191
  for (const [session, v] of Object.entries(loaded.peers || {})) {
175
192
  // migrate old numeric form
176
193
  s.peers[session] = typeof v === "number"
@@ -605,6 +622,124 @@ function reapStaleCards() {
605
622
  }
606
623
  setInterval(reapStaleCards, REAP_INTERVAL_MS).unref?.();
607
624
 
625
+ // ---- the contract ledger -------------------------------------------------------------------
626
+ // ONE derivation, shared by GET /contracts and the reaper below. Two copies of this is how you get
627
+ // an endpoint and a sweeper that disagree about what is open, which is the same "two names for one
628
+ // intent" mistake the spawn guards made.
629
+ //
630
+ // A contract is a DIRECT message from `session` to one peer. It closes when that peer answers:
631
+ // strictly by `re`, or, for seats that predate that column, oldest-open-first. Excluded outright,
632
+ // because none of these can ever be answered and so would hang open forever:
633
+ // - broadcasts (`to === "all"`)
634
+ // - self-dispatch (`from === to`) — a reply from yourself is never counted as an answer
635
+ // - the hub's own pseudo-identities (`hub:*`) and hub-authored mail — nothing polls them (0.17.87)
636
+ // Durations an agent READS and acts on, so they must not round to nonsense. Minutes are the useful
637
+ // unit in production (the abandon window is an hour), but the drills run in seconds and "past the 0m
638
+ // abandon window" is a sentence that tells the reader nothing.
639
+ function humanMs(ms) {
640
+ const n = Math.max(0, Number(ms) || 0);
641
+ if (n < 60000) return `${Math.max(1, Math.round(n / 1000))}s`;
642
+ if (n < 3600000) return `${Math.round(n / 60000)}m`;
643
+ return `${(n / 3600000).toFixed(n < 36000000 ? 1 : 0)}h`;
644
+ }
645
+
646
+ function contractRecipientIsAnswerable(m) {
647
+ return !!m.to && m.to !== "all" && m.from !== m.to && !m.to.startsWith("hub:") && !m.from.startsWith("hub:");
648
+ }
649
+
650
+ function contractsFor(session, { project = "", windowMs = CONTRACT_WINDOW_MS, overdueMs = null } = {}) {
651
+ const t = now();
652
+ const cutoff = t - windowMs;
653
+ const abandonCut = t - CONTRACT_ABANDON_MS;
654
+ const onCut = t - ONLINE_MS;
655
+ const mine = state.messages.filter(m =>
656
+ m.from === session && m.ts >= cutoff && contractRecipientIsAnswerable(m) && (!project || m.project === project));
657
+ const replies = state.messages.filter(m => m.to === session && m.from !== session && m.ts >= cutoff);
658
+ const byRe = new Map();
659
+ for (const r of replies) if (r.re) byRe.set(Number(r.re), r);
660
+ const looseByPeer = new Map();
661
+ for (const r of replies) if (!r.re) { if (!looseByPeer.has(r.from)) looseByPeer.set(r.from, []); looseByPeer.get(r.from).push(r); }
662
+ for (const arr of looseByPeer.values()) arr.sort((a, b) => a.ts - b.ts);
663
+
664
+ const out = [];
665
+ for (const c of mine.sort((a, b) => a.ts - b.ts)) {
666
+ let answer = byRe.get(c.id) || null;
667
+ if (!answer) {
668
+ const pool = looseByPeer.get(c.to) || [];
669
+ const i = pool.findIndex(r => r.ts > c.ts);
670
+ if (i >= 0) answer = pool.splice(i, 1)[0];
671
+ }
672
+ const peer = state.peers[c.to] || null;
673
+ const seen = peer?.lastSeen || 0;
674
+ const online = !!seen && seen > onCut;
675
+ const ageMs = t - c.ts;
676
+ const reaped = state.contractReap?.[String(c.id)] || null;
677
+
678
+ // An answer ALWAYS wins, including over a recorded abandonment: the reap is evidence, not a
679
+ // tombstone. A seat that comes back and reports still closes its own contract.
680
+ let disposition;
681
+ if (answer) disposition = "answered";
682
+ else if (seen < abandonCut) disposition = "abandoned"; // covers never-seen (seen === 0)
683
+ else if (!online || (overdueMs != null && ageMs >= overdueMs)) disposition = "stalled";
684
+ else disposition = "waiting";
685
+
686
+ out.push({
687
+ id: c.id, to: c.to, text: c.text, ts: c.ts, ageMs,
688
+ answered: !!answer,
689
+ answer: answer ? { id: answer.id, ts: answer.ts, text: answer.text } : null,
690
+ disposition,
691
+ assigneeOnline: online,
692
+ assigneeStatus: String(peer?.status || ""),
693
+ assigneeLastSeenMs: seen ? t - seen : null,
694
+ reaped: reaped ? { ts: reaped.ts, reason: reaped.reason } : null,
695
+ });
696
+ }
697
+ return out;
698
+ }
699
+
700
+ // Every session that has dispatched inside the ledger window. The reaper needs all of them; the
701
+ // endpoint only ever asks about one.
702
+ function contractDispatchers(windowMs = CONTRACT_WINDOW_MS) {
703
+ const cutoff = now() - windowMs;
704
+ const set = new Set();
705
+ for (const m of state.messages) if (m.ts >= cutoff && contractRecipientIsAnswerable(m)) set.add(m.from);
706
+ return set;
707
+ }
708
+
709
+ // The contract reaper. Records — never invents an answer for — a contract whose assignee has been
710
+ // quiet past CONTRACT_ABANDON_MS, so the abandonment survives a hub restart (in-memory-only state is
711
+ // exactly why the escalation backlog re-fires on every restart) and shows up once in the FEED.
712
+ // After this the contract stops counting as open, so it stops nagging every future session; it stays
713
+ // listed with its evidence, so `relay_contracts` can still show what died.
714
+ function reapAbandonedContracts() {
715
+ let changed = false;
716
+ for (const session of contractDispatchers()) {
717
+ for (const c of contractsFor(session)) {
718
+ if (c.disposition !== "abandoned") continue;
719
+ const key = String(c.id);
720
+ if (state.contractReap[key]) continue;
721
+ const quiet = c.assigneeLastSeenMs == null
722
+ ? "never seen on the bus"
723
+ : `last seen ${humanMs(c.assigneeLastSeenMs)} ago`;
724
+ const reason = `assignee ${c.to} ${quiet}, past the ${humanMs(CONTRACT_ABANDON_MS)} abandon window`;
725
+ state.contractReap[key] = { ts: now(), from: session, to: c.to, reason, dispatchedTs: c.ts };
726
+ appendEvent("contract.abandoned", "", "reaper", {
727
+ msgId: c.id, fromSession: session, toSession: c.to, reason,
728
+ text: String(c.text || "").slice(0, 500),
729
+ });
730
+ changed = true;
731
+ }
732
+ }
733
+ // Forget reap records whose contract has aged out of the ledger window entirely — nothing can
734
+ // read them any more and the map would grow without bound.
735
+ const cutoff = now() - CONTRACT_WINDOW_MS;
736
+ for (const [key, r] of Object.entries(state.contractReap)) {
737
+ if ((r?.dispatchedTs || 0) < cutoff) { delete state.contractReap[key]; changed = true; }
738
+ }
739
+ if (changed) dirty = true;
740
+ }
741
+ setInterval(reapAbandonedContracts, REAP_INTERVAL_MS).unref?.();
742
+
608
743
  // dashboard HTML (read once at startup)
609
744
  let UI = "";
610
745
  try { UI = readFileSync(new URL("./ui.html", import.meta.url), "utf8"); } catch {}
@@ -2288,36 +2423,18 @@ const server = http.createServer(async (req, res) => {
2288
2423
  if (req.method === "GET" && P === "/contracts") {
2289
2424
  const session = String(q.session || "");
2290
2425
  if (!session) return json(res, 400, { error: "session required" });
2291
- const proj = String(q.project || "");
2292
- const windowMs = Math.max(60000, Number(q.windowMs || 24 * 3600 * 1000));
2293
- const cutoff = now() - windowMs;
2294
- const mine = state.messages.filter(m => m.from === session && m.to && m.to !== "all" && m.ts >= cutoff && (!proj || m.project === proj));
2295
- const replies = state.messages.filter(m => m.to === session && m.from !== session && m.ts >= cutoff);
2296
- const byRe = new Map();
2297
- for (const r of replies) if (r.re) byRe.set(Number(r.re), r);
2298
- const looseByPeer = new Map();
2299
- for (const r of replies) if (!r.re) { if (!looseByPeer.has(r.from)) looseByPeer.set(r.from, []); looseByPeer.get(r.from).push(r); }
2300
- for (const arr of looseByPeer.values()) arr.sort((a, b) => a.ts - b.ts);
2301
- const out = [];
2302
- for (const c of mine.sort((a, b) => a.ts - b.ts)) {
2303
- let answer = byRe.get(c.id) || null;
2304
- if (!answer) {
2305
- const pool = looseByPeer.get(c.to) || [];
2306
- const i = pool.findIndex(r => r.ts > c.ts);
2307
- if (i >= 0) answer = pool.splice(i, 1)[0];
2308
- }
2309
- const peer = state.peers[c.to] || null;
2310
- const seen = peer?.lastSeen || 0;
2311
- out.push({
2312
- id: c.id, to: c.to, text: c.text, ts: c.ts, ageMs: now() - c.ts,
2313
- answered: !!answer,
2314
- answer: answer ? { id: answer.id, ts: answer.ts, text: answer.text } : null,
2315
- assigneeOnline: !!seen && (now() - seen) < ONLINE_MS,
2316
- assigneeStatus: String(peer?.status || ""),
2317
- assigneeLastSeenMs: seen ? now() - seen : null,
2318
- });
2319
- }
2320
- return json(res, 200, { session, contracts: out, open: out.filter(c => !c.answered).length });
2426
+ const windowMs = Math.max(60000, Number(q.windowMs || CONTRACT_WINDOW_MS));
2427
+ const rawOverdue = q.overdueMs === undefined || q.overdueMs === "" ? null : Number(q.overdueMs);
2428
+ const overdueMs = Number.isFinite(rawOverdue) ? Math.max(0, rawOverdue) : null;
2429
+ const out = contractsFor(session, { project: String(q.project || ""), windowMs, overdueMs });
2430
+ const by = (d) => out.filter(c => c.disposition === d).length;
2431
+ // `open` stays the count a caller has to DO something about, which is what it always meant.
2432
+ // Abandoned ones are reported separately: they are the ledger's record of what died, not work.
2433
+ return json(res, 200, {
2434
+ session, contracts: out,
2435
+ open: out.filter(c => !c.answered && c.disposition !== "abandoned").length,
2436
+ waiting: by("waiting"), stalled: by("stalled"), abandoned: by("abandoned"), answered: by("answered"),
2437
+ });
2321
2438
  }
2322
2439
 
2323
2440
  if (req.method === "GET" && P === "/inbox") {
@@ -126,7 +126,7 @@ CREATE TABLE IF NOT EXISTS peers (
126
126
  -- The fields that currently ride in-memory and are LOST on restart. This is the debt being paid.
127
127
  CREATE TABLE IF NOT EXISTS kv (
128
128
  org_id TEXT NOT NULL,
129
- key TEXT NOT NULL, -- 'verifyGates'|'balances'|'handoffLog'|'aliases'|'phaseMeta'|'focus'|'projectMeta'|'lessons'|'orgPolicy'|'meta'
129
+ key TEXT NOT NULL, -- 'verifyGates'|'balances'|'handoffLog'|'aliases'|'phaseMeta'|'focus'|'projectMeta'|'lessons'|'orgPolicy'|'meta'|'contractReap'
130
130
  value JSONB NOT NULL,
131
131
  PRIMARY KEY (org_id, key)
132
132
  );
@@ -138,9 +138,12 @@ CREATE TABLE IF NOT EXISTS schema_meta (version INT PRIMARY KEY, applied_at BIGI
138
138
  // `proposals` (agent-proposed permissions, v0.17.68) especially: a denied proposal is a MEMORY —
139
139
  // the hub refuses near-duplicate re-proposals against it, and a restart that forgets denials
140
140
  // silently re-opens every door the operator closed.
141
+ // `contractReap` likewise: it records that a dispatched contract's assignee went quiet for good.
142
+ // Held only in memory it would be rebuilt from scratch on every restart, and the whole ghost backlog
143
+ // would re-announce itself — the same shape of bug as the in-memory escalation set.
141
144
  export const KV_KEYS = ["verifyGates", "balances", "handoffLog", "aliases", "phaseMeta", "focus",
142
145
  "projectMeta", "lessons", "orgPolicy", "meta", "subagentCostReset", "seq",
143
- "proposals"];
146
+ "proposals", "contractReap"];
144
147
 
145
148
  // ---------------------------------------------------------------------------------------------
146
149
  // INVARIANTS — carried forward from 0.17.54. Breaking one silently corrupts the board.
package/lib/store-pg.mjs CHANGED
@@ -123,6 +123,7 @@ function kvFromState(state) {
123
123
  lessons: state.lessons || [],
124
124
  orgPolicy: state.orgPolicy || {},
125
125
  proposals: state.proposals || [],
126
+ contractReap: state.contractReap || {},
126
127
  meta: {
127
128
  taskSeq: Number(state.taskSeq || 0),
128
129
  verifyGateSeq: Number(state.verifyGateSeq || 0),
@@ -681,6 +682,7 @@ export class PgStore {
681
682
  instances: meta.instances && typeof meta.instances === "object" ? meta.instances : {},
682
683
  focus: kv.focus && typeof kv.focus === "object" ? kv.focus : {},
683
684
  orgPolicy: kv.orgPolicy && typeof kv.orgPolicy === "object" ? kv.orgPolicy : {},
685
+ contractReap: kv.contractReap && typeof kv.contractReap === "object" ? kv.contractReap : {},
684
686
  };
685
687
  }
686
688
  }
package/mcp.mjs CHANGED
@@ -136,26 +136,40 @@ server.tool("relay_whoami", "Show this session's relay identity, project, hub UR
136
136
  return { content: [{ type: "text", text }] };
137
137
  });
138
138
 
139
- server.tool("relay_contracts", "What you dispatched and are still owed. Lists every DIRECT message you sent to another session that has not been answered yet, with how long it has been outstanding, whether that session is still alive, and its last known status. Call this before concluding a crew is idle, stuck, or done — silence on the bus is not evidence either way. Answered contracts carry the outcome text.", {},
139
+ server.tool("relay_contracts", "What you dispatched and are still owed. Lists every DIRECT message you sent to another session, with how long it has been outstanding, whether that session is still alive, and its last known status. Each one carries a disposition: WAITING (assignee alive and working — normal), STALLED (assignee offline or overdue — poke it, swap it, or reassign), ABANDONED (assignee gone so long the contract can never be answered — the work needs reassigning, nobody is coming back), or answered, with the outcome text. Call this before concluding a crew is idle, stuck, or done — silence on the bus is not evidence either way.", {},
140
140
  async () => {
141
141
  let r;
142
142
  try { r = await api("GET", `/contracts?session=${encodeURIComponent(SESSION)}&project=${encodeURIComponent(PROJECT)}`); }
143
143
  catch (e) { return { content: [{ type: "text", text: `could not reach the hub: ${e?.message || e}` }] }; }
144
144
  const all = r?.contracts || [];
145
- const open = all.filter(c => !c.answered);
146
145
  if (!all.length) return { content: [{ type: "text", text: "You have not dispatched any contracts in the last 24h." }] };
146
+ // Fall back to the pre-disposition shape when talking to an older hub.
147
+ const disp = (c) => c.disposition || (c.answered ? "answered" : (c.assigneeOnline ? "waiting" : "stalled"));
148
+ const open = all.filter(c => disp(c) === "waiting" || disp(c) === "stalled");
149
+ const abandoned = all.filter(c => disp(c) === "abandoned");
147
150
  const mins = (ms) => (ms >= 60000 ? `${Math.round(ms / 60000)}m` : `${Math.round(ms / 1000)}s`);
151
+ const MARK = { answered: "✅", waiting: "⏳", stalled: "⚠️", abandoned: "🪦" };
148
152
  const line = (c) => {
149
- const health = c.answered ? "" :
153
+ const d = disp(c);
154
+ const health = d === "answered" ? "" :
150
155
  c.assigneeOnline ? ` · alive (${c.assigneeStatus || "no status"})`
151
156
  : c.assigneeLastSeenMs == null ? " · NEVER SEEN on the bus"
152
157
  : ` · LAST SEEN ${mins(c.assigneeLastSeenMs)} ago`;
153
- const out = c.answered ? ` → ${String(c.answer?.text || "").slice(0, 120)}` : "";
154
- return `${c.answered ? "" : ""} #${c.id} ${c.to} (${mins(c.ageMs)} ago)${health}: "${String(c.text).slice(0, 100)}"${out}`;
158
+ const out = d === "answered" ? ` → ${String(c.answer?.text || "").slice(0, 120)}` : "";
159
+ const tag = d === "answered" ? "" : ` [${d.toUpperCase()}]`;
160
+ return `${MARK[d] || "⏳"} #${c.id} → ${c.to} (${mins(c.ageMs)} ago)${tag}${health}: "${String(c.text).slice(0, 100)}"${out}`;
155
161
  };
156
- const text = `${open.length} outstanding of ${all.length} contract(s) in the last 24h:\n`
162
+ const notes = [];
163
+ if (open.some(c => disp(c) === "stalled")) {
164
+ notes.push("⚠️ A STALLED contract's assignee is offline or overdue. Waiting will not finish the work — check it, swap it (`trantor swap <agent>`), or reassign.");
165
+ }
166
+ if (abandoned.length) {
167
+ notes.push(`🪦 ${abandoned.length} ABANDONED: the assignee has been gone too long for these to ever be answered. Nobody is coming back — reassign the work or drop it deliberately.`);
168
+ }
169
+ const text = `${open.length} outstanding of ${all.length} contract(s) in the last 24h`
170
+ + (abandoned.length ? ` (plus ${abandoned.length} abandoned)` : "") + `:\n`
157
171
  + all.slice(-25).map(line).join("\n")
158
- + (open.some(c => !c.assigneeOnline) ? "\n\n⚠️ At least one assignee is not alive on the bus. Waiting on it will not finish the work — check it, swap it, or reassign." : "");
172
+ + (notes.length ? "\n\n" + notes.join("\n") : "");
159
173
  return { content: [{ type: "text", text }] };
160
174
  });
161
175
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.17.97",
3
+ "version": "0.17.99",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"
@@ -92,6 +92,17 @@ Loop until the board is done — you are a foreman, not a mailbox:
92
92
  3. ACT within one cycle: failed card → read the report, send a fix contract, card back to
93
93
  doing · dead agent → `trantor up <agent>` (re-verifies) + resend contract · silent-but-alive
94
94
  → direct-message nudge naming the card.
95
+ Use `relay_contracts` for the ledger of what you dispatched and are still owed. Every contract
96
+ carries a **disposition**, and each one means a different action:
97
+ - **WAITING** — assignee alive, inside the overdue window. This is what progress looks like. Do
98
+ nothing; do not nudge it just because it is quiet.
99
+ - **STALLED** — assignee offline, or overdue while online. Act NOW: check the seat, `trantor up`
100
+ / `trantor swap` it, or reassign. This is also the only disposition that blocks your stop.
101
+ - **ABANDONED** — the assignee has been gone long enough that the contract can never be
102
+ answered. Nobody is coming back. **Reassign the work or drop it deliberately** — it will not
103
+ block you again, and it will not resolve itself.
104
+ A contract is never closed on the assignee's behalf: quiet is not an outcome. An abandoned one
105
+ stays in the ledger with the evidence, and a seat that revives still closes its own contract.
95
106
  4. Grunt sub-tasks that appear mid-build (a regex, a config block, a doc paragraph) →
96
107
  `relay_scrooge`, don't burn a crew seat or your own window.
97
108
  5. Record lessons as you diagnose (`relay_lesson(text, scope)` — global or per-agent quirks);