trantor 0.17.80 → 0.17.93

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.
@@ -11,11 +11,11 @@ import { join, basename, dirname } from "node:path";
11
11
  import { homedir, hostname } from "node:os";
12
12
  import { execSync, spawn } from "node:child_process";
13
13
  import { fileURLToPath } from "node:url";
14
- import { resolveProject, hostId } from "../lib/project.mjs";
14
+ import { resolveProject, hostId, resolveHubInfo, knownProjects, nonSeatReason, handoffDir } from "../lib/project.mjs";
15
15
  import { formatSubagentManifest } from "../lib/subagent-manifest.mjs";
16
16
  import { updateAvailable, maybeNotifyDesktop, readConfig } from "./lib/update-check.mjs";
17
17
  import { maybeCheckBalances } from "./lib/balance-check.mjs";
18
- import { relayUrl, getJSON, signedGet, signedPost } from "./lib/api.mjs";
18
+ import { getJSON, signedGet, signedPost } from "./lib/api.mjs";
19
19
  import { ledgerPaths, ensureStart, anchorCursor, writeCursor } from "./lib/inbox-ledger.mjs";
20
20
 
21
21
  // Load the most recent UNCONSUMED handoff for this project (written by precompact.mjs
@@ -25,7 +25,7 @@ import { ledgerPaths, ensureStart, anchorCursor, writeCursor } from "./lib/inbox
25
25
  // for continuity but must NOT claim it, or it steals the handoff from the new window.
26
26
  function loadPendingHandoff(projectName, { claim = true, freshSession = null } = {}) {
27
27
  try {
28
- const dir = join(homedir(), ".agent-bus", "handoffs");
28
+ const dir = handoffDir(); // NEVER join(homedir(), …) here: a drill pointed at a temp bus dir must not claim the real handoffs
29
29
  if (!existsSync(dir)) return null;
30
30
  // Match ONLY this project's handoffs: "<projectName>-<numeric stamp>.json".
31
31
  // NOT a loose startsWith() — that also caught leaked test fixtures like
@@ -134,12 +134,46 @@ try {
134
134
  // session resolved two different projects — and therefore two different hubs — so half a
135
135
  // session's work recorded on a hub nobody was reading.
136
136
  const projectDir = stdinObj.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd();
137
- // Sessions started in the home directory itself aren't project work — registering
138
- // them spawns a phantom "<username>" project board on the dashboard. Set
139
- // RELAY_SESSION (or RELAY_PROJECT) to deliberately put a home-dir session on the bus.
140
- if (!process.env.RELAY_SESSION && !process.env.RELAY_PROJECT && projectDir === homedir()) {
141
- process.stderr.write("[trantor] session in the home directory — not registering on the bus (set RELAY_SESSION to opt in)\n");
142
- process.stdout.write("{}");
137
+ // NOT A SEAT — say so, loudly, to BOTH the user and the model.
138
+ //
139
+ // A session started outside a project (the home directory, a non-repo like ~/development, the
140
+ // plugin cache) is not project work, and registering it mints a phantom "<username>" board that,
141
+ // being unpinned, lands on the LOCAL hub while the real crew lives on the remote one. Declining
142
+ // to register was always right. Doing it via a stderr line NOBODY READS was not: the session
143
+ // then spends an hour believing it is the crebral-health seat and eventually reports "Trantor is
144
+ // unreachable" — with every hub healthy. That is what a macOS reboot produces every time, since
145
+ // reopened Terminal windows come back in $HOME and `claude --resume` restores the conversation
146
+ // but not the directory. So: no registration, and an unmissable explanation of what this session
147
+ // is not, what it therefore cannot do, and the one command that fixes it.
148
+ const notSeat = nonSeatReason(projectDir);
149
+ if (notSeat) {
150
+ const known = knownProjects();
151
+ const O = "\x1b[1;38;5;208m", R = "\x1b[0m";
152
+ const banner = `🟠 ${O}Not a Trantor seat${R} — ${projectDir} is ${notSeat}. `
153
+ + `It is NOT on the bus: no board, no peers, no inbox. `
154
+ + `Start it from the project directory instead: ${O}cd <project> && claude${R}`;
155
+ let ctx = `<trantor-not-a-seat cwd="${sanitize(projectDir)}" reason="${sanitize(notSeat)}">\n`;
156
+ ctx += `⚠️ **This session is NOT registered on Trantor.** Its working directory `;
157
+ ctx += `(\`${sanitize(projectDir)}\`) is ${sanitize(notSeat)} — not a project — so no seat was created for it.\n\n`;
158
+ ctx += `**What that means — do not work around it, and do not report the bus as broken:**\n`;
159
+ ctx += `- You have no project board, no peers, and no inbox. \`relay_peers\` will look empty and \`relay_send\` reaches nobody.\n`;
160
+ ctx += `- Messages other sessions address to this project are NOT lost — they are waiting on the hub for a real seat.\n`;
161
+ ctx += `- The hubs are almost certainly healthy. "Trantor is unreachable" is the WRONG diagnosis here; the right one is "this window is in the wrong directory".\n\n`;
162
+ ctx += `**Tell the user this, in your first reply, before doing anything else.** The fix is to close this window and start Claude from the project directory (\`cd <project> && claude\`), which is what makes it a seat. A session can also be forced onto the bus with \`RELAY_PROJECT=<project> claude\`, but only when there is a reason it cannot run from the directory itself.\n`;
163
+ if (known.length) ctx += `\nProjects pinned on this machine: ${sanitize(known.slice(0, 24).join(", "))}${known.length > 24 ? ", …" : ""}.\n`;
164
+ // If the operator has DECLARED seats, recovery is one command — name it rather than making
165
+ // them remember which directory each project lives in.
166
+ try {
167
+ const { readSeats } = await import("../lib/seats.mjs");
168
+ const declared = Object.keys(readSeats());
169
+ if (declared.length) {
170
+ ctx += `\nDeclared seats on this machine: ${sanitize(declared.slice(0, 24).join(", "))}. `;
171
+ ctx += `\`trantor seats\` shows which are missing and \`trantor seats up\` reopens each one in its own directory.\n`;
172
+ }
173
+ } catch {}
174
+ ctx += `</trantor-not-a-seat>\n`;
175
+ process.stderr.write(`[trantor] ${notSeat} — not registering on the bus (set RELAY_PROJECT to opt in)\n`);
176
+ process.stdout.write(emit(ctx, banner, ""));
143
177
  process.exit(0);
144
178
  }
145
179
  const project = resolveProject(projectDir);
@@ -148,7 +182,9 @@ try {
148
182
  || (process.env.RELAY_AGENT ? `${process.env.RELAY_AGENT}:${project}` : `${hostId()}:${project}`);
149
183
  // relayUrl() with no project resolves from the hook process's cwd, which is not always the
150
184
  // project the session is about. Pass it, or every call below can address the wrong hub.
151
- const url = relayUrl(project);
185
+ // resolveHubInfo carries HOW the hub was chosen so an unpinned project can be flagged below
186
+ // instead of silently routing to the default.
187
+ const { url, via: hubVia } = resolveHubInfo(project);
152
188
 
153
189
  // register self + post an initial presence status (no LLM turn — instant for others to read)
154
190
  await jpost(`${url}/register`, { session, project, status: `active in ${project}` }, session).catch(() => {});
@@ -158,7 +194,25 @@ try {
158
194
  try { peers = (await jget(`${url}/peers`, session)).peers || []; hubAnswered = true; } catch {}
159
195
  const others = peers.filter(p => p.online && p.session !== session);
160
196
 
161
- process.stderr.write(`[trantor] registered as ${session} -> ${url} (${others.length} other live session(s))\n`);
197
+ process.stderr.write(`[trantor] registered as ${session} -> ${url} (via ${hubVia}, ${others.length} other live session(s))\n`);
198
+
199
+ // UNPINNED → the hub was a FALLBACK, not a decision. This session is on the bus, but possibly not
200
+ // the bus its crew is on: pins route the known projects to the remote hub, and anything unpinned
201
+ // drops to the global default. Two seats that believe they are the same project then read and
202
+ // write different stores, each looking perfectly healthy. Never let that be silent.
203
+ if (hubVia === "global" || hubVia === "default") {
204
+ const known = knownProjects();
205
+ const O = "\x1b[1;38;5;208m", R = "\x1b[0m";
206
+ const line = `🟠 ${O}"${project}" is not pinned to a hub${R} — falling back to ${url}. `
207
+ + `If the crew is on another hub this seat cannot see them. Pin it: ${O}trantor hub set ${project} <url>${R}`;
208
+ userBanner = userBanner ? `${userBanner}\n${line}` : line;
209
+ additionalContext += `<trantor-hub-unpinned project="${sanitize(project)}" hub="${sanitize(url)}" via="${sanitize(hubVia)}">\n`;
210
+ additionalContext += `⚠️ **The project "${sanitize(project)}" has no hub pin.** This seat fell back to \`${sanitize(url)}\` (${hubVia === "global" ? "the global default" : "the built-in local default"}), which is a guess, not a routing decision.\n`;
211
+ additionalContext += `If the rest of the crew is pinned to a different hub, you will see an empty board and no peers while every hub is healthy — do NOT diagnose that as "Trantor is down". Confirm with \`trantor hub list\`, and pin this project with \`trantor hub set ${sanitize(project)} <url>\` so the routing is deliberate.\n`;
212
+ if (known.length) additionalContext += `\nPinned projects: ${sanitize(known.slice(0, 24).join(", "))}${known.length > 24 ? ", …" : ""}.\n`;
213
+ additionalContext += `</trantor-hub-unpinned>\n`;
214
+ process.stderr.write(`[trantor] WARNING: project "${project}" is unpinned; hub ${url} chosen via ${hubVia}\n`);
215
+ }
162
216
 
163
217
  if (others.length > 0) {
164
218
  additionalContext += `<trantor session="${session}" hub="${url}">\n`;
@@ -42,6 +42,56 @@ function readStdin() {
42
42
  });
43
43
  }
44
44
 
45
+
46
+ // ---- the metronome: never park while a dispatched contract is stalled ----------------------
47
+ // An empty inbox is not the same as nothing outstanding. A session that dispatched work to a seat
48
+ // that then died sees silence and parks, and the human becomes the one who remembers, which is the
49
+ // complaint this exists to answer. So before letting a stop through we ask the hub what this
50
+ // session is still owed, and refuse ONCE if any of it has gone quiet.
51
+ //
52
+ // Deliberately narrow: only a contract whose assignee is offline, or one that is overdue, blocks.
53
+ // An open contract with a healthy seat working on it is exactly what "in progress" looks like, and
54
+ // nagging about it every stop would be worse than saying nothing. Fail-open throughout: a hub that
55
+ // is down or slow must never trap a session.
56
+ const OVERDUE_MS = (() => {
57
+ const raw = process.env.TRANTOR_CONTRACT_OVERDUE_MS;
58
+ const n = raw === undefined || raw === "" ? NaN : Number(raw);
59
+ return Number.isFinite(n) && n >= 0 ? n : 10 * 60 * 1000;
60
+ })();
61
+
62
+ async function stalledContractCheck({ session, project, instanceId }) {
63
+ if (process.env.RELAY_STOP_CONTRACTS === "0") return allow();
64
+ let contracts = [];
65
+ try {
66
+ const r = await signedGet(`/contracts?session=${encodeURIComponent(session)}&project=${encodeURIComponent(project)}`,
67
+ { timeoutMs: FETCH_TIMEOUT_MS, session, instance: instanceId, project });
68
+ if (!r.ok) return allow();
69
+ contracts = r.json?.contracts || [];
70
+ } catch { return allow(); }
71
+
72
+ const stalled = contracts.filter(c => !c.answered && (!c.assigneeOnline || c.ageMs >= OVERDUE_MS));
73
+ if (!stalled.length) return allow();
74
+
75
+ const mins = (ms) => (ms >= 60000 ? `${Math.round(ms / 60000)}m` : `${Math.round(ms / 1000)}s`);
76
+ const lines = stalled.slice(0, 6).map(c => {
77
+ const health = c.assigneeOnline ? `online, status "${sanitize(c.assigneeStatus || "?")}"`
78
+ : c.assigneeLastSeenMs == null ? "never seen on the bus"
79
+ : `LAST SEEN ${mins(c.assigneeLastSeenMs)} ago`;
80
+ return `- ${sanitize(c.to)} (${health}) — asked ${mins(c.ageMs)} ago: "${sanitize(String(c.text).slice(0, 120))}"`;
81
+ }).join("\n");
82
+
83
+ const reason =
84
+ `You dispatched ${stalled.length} contract(s) that have gone quiet, and you were about to go idle:\n` +
85
+ lines + `\n\n` +
86
+ `Do not just wait, and do not ask the human to check. Use relay_contracts to see everything you are owed, ` +
87
+ `relay_peers to see whether the seat is alive, and relay_send to ask it directly. If a seat is down, say so and ` +
88
+ `either swap it (\`trantor swap <agent>\`) or reassign the work. If the work is genuinely still running, say that ` +
89
+ `plainly and stop. This check will not block you a second time.`;
90
+
91
+ process.stdout.write(JSON.stringify({ decision: "block", reason }));
92
+ process.exit(0);
93
+ }
94
+
45
95
  async function main() {
46
96
  const raw = await readStdin();
47
97
  let input = {};
@@ -97,7 +147,7 @@ async function main() {
97
147
  } catch { return allow(); } // hub down — never trap the session
98
148
 
99
149
  const direct = messages.filter(m => m.to === session);
100
- if (!direct.length) return allow();
150
+ if (!direct.length) return stalledContractCheck({ session, project, instanceId });
101
151
 
102
152
  // Committed now: claim delivery for real so neither inbox-deliver nor the deferred waker repeats it.
103
153
  let next = cursor;
package/hub.mjs CHANGED
@@ -2241,7 +2241,11 @@ const server = http.createServer(async (req, res) => {
2241
2241
  // attribute the message to a project so the dashboard can show it in that project's lane.
2242
2242
  // explicit b.project wins; else the sender's known project; else parsed from a "host:project" id.
2243
2243
  const fromProj = state.peers[b.from]?.project || (b.from && b.from.includes(":") ? b.from.split(":").pop() : "");
2244
- const msg = { id: ++state.seq, ts: now(), from: b.from || "anon", to: b.to || "all", text, project: String(b.project || fromProj || "").slice(0, 80) };
2244
+ // `re` threads an OUTCOME back to the CONTRACT it answers. Without it "what am I still owed"
2245
+ // is guesswork: a seat still working and a seat that died look identical from the sender's
2246
+ // side, which is how an orchestrator ends up waiting forever on a dead peer.
2247
+ const re = Number.isFinite(Number(b.re)) && Number(b.re) > 0 ? Number(b.re) : 0;
2248
+ const msg = { id: ++state.seq, ts: now(), from: b.from || "anon", to: b.to || "all", text, project: String(b.project || fromProj || "").slice(0, 80), ...(re ? { re } : {}) };
2245
2249
  state.messages.push(msg); if (state.messages.length > 5000) state.messages.splice(0, 1000);
2246
2250
  dirty = true; pushToStreams(msg); // <-- instant push to live watchers
2247
2251
  // Mirror onto the unified log. `refs` = the card ids this message cites (#3701), which is what
@@ -2251,6 +2255,46 @@ const server = http.createServer(async (req, res) => {
2251
2255
  appendEvent("message", msg.project, msg.from, { msgId: msg.id, toSession: msg.to, text: msg.text.slice(0, 2000), refs });
2252
2256
  return json(res, 200, { ok: true, id: msg.id });
2253
2257
  }
2258
+ // ---- /contracts: what this session dispatched and has not been answered on ----------------
2259
+ // A contract is a DIRECT message from you to one peer. It closes when that peer sends you an
2260
+ // outcome: strictly by `re`, or, for seats that predate it, oldest-open-first. Broadcasts are
2261
+ // never contracts. Each open one carries the assignee's presence, because the actionable half
2262
+ // of "still waiting" is whether anyone is still on the other end.
2263
+ if (req.method === "GET" && P === "/contracts") {
2264
+ const session = String(q.session || "");
2265
+ if (!session) return json(res, 400, { error: "session required" });
2266
+ const proj = String(q.project || "");
2267
+ const windowMs = Math.max(60000, Number(q.windowMs || 24 * 3600 * 1000));
2268
+ const cutoff = now() - windowMs;
2269
+ const mine = state.messages.filter(m => m.from === session && m.to && m.to !== "all" && m.ts >= cutoff && (!proj || m.project === proj));
2270
+ const replies = state.messages.filter(m => m.to === session && m.from !== session && m.ts >= cutoff);
2271
+ const byRe = new Map();
2272
+ for (const r of replies) if (r.re) byRe.set(Number(r.re), r);
2273
+ const looseByPeer = new Map();
2274
+ for (const r of replies) if (!r.re) { if (!looseByPeer.has(r.from)) looseByPeer.set(r.from, []); looseByPeer.get(r.from).push(r); }
2275
+ for (const arr of looseByPeer.values()) arr.sort((a, b) => a.ts - b.ts);
2276
+ const out = [];
2277
+ for (const c of mine.sort((a, b) => a.ts - b.ts)) {
2278
+ let answer = byRe.get(c.id) || null;
2279
+ if (!answer) {
2280
+ const pool = looseByPeer.get(c.to) || [];
2281
+ const i = pool.findIndex(r => r.ts > c.ts);
2282
+ if (i >= 0) answer = pool.splice(i, 1)[0];
2283
+ }
2284
+ const peer = state.peers[c.to] || null;
2285
+ const seen = peer?.lastSeen || 0;
2286
+ out.push({
2287
+ id: c.id, to: c.to, text: c.text, ts: c.ts, ageMs: now() - c.ts,
2288
+ answered: !!answer,
2289
+ answer: answer ? { id: answer.id, ts: answer.ts, text: answer.text } : null,
2290
+ assigneeOnline: !!seen && (now() - seen) < ONLINE_MS,
2291
+ assigneeStatus: String(peer?.status || ""),
2292
+ assigneeLastSeenMs: seen ? now() - seen : null,
2293
+ });
2294
+ }
2295
+ return json(res, 200, { session, contracts: out, open: out.filter(c => !c.answered).length });
2296
+ }
2297
+
2254
2298
  if (req.method === "GET" && P === "/inbox") {
2255
2299
  if (!canUseInboxSession(auth, q.session)) return json(res, 403, { error: "forbidden" });
2256
2300
  touch(q.session, undefined, undefined, undefined, auth); const since = Number(q.since || 0);
package/lib/project.mjs CHANGED
@@ -6,7 +6,7 @@
6
6
  // RELAY_PROJECT always wins (deliberate override / crew inheritance). The hub
7
7
  // applies an alias map on top of this to fold any historical divergence.
8
8
  import { execSync } from "node:child_process";
9
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
9
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from "node:fs";
10
10
  import { basename, join, dirname } from "node:path";
11
11
  import { homedir, hostname } from "node:os";
12
12
 
@@ -33,7 +33,16 @@ export function resolveProject(cwd = process.cwd()) {
33
33
  // local default. Never throws: hooks run inside the user's tool loop and MUST fail open.
34
34
  export const DEFAULT_HUB_URL = "http://127.0.0.1:4477";
35
35
 
36
- function busDir() { return process.env.AGENT_BUS_DIR || join(homedir(), ".agent-bus"); }
36
+ // The bus directory, resolved in ONE place. Both override names are honoured because both are
37
+ // already in use across the hooks (AGENT_BUS_DIR in api/prompt-focus/file-claim, RELAY_DATA_DIR in
38
+ // handoff/resources/update-check); a reader that honours neither will happily mutate the user's
39
+ // REAL state during a test. That is not hypothetical: on 2026-08-23 a pre-flight drill pointed at
40
+ // a temp bus dir and still CLAIMED two live pending handoffs, because the handoff reader joined
41
+ // homedir() directly while the writer honoured RELAY_DATA_DIR.
42
+ export function busDir() {
43
+ return process.env.AGENT_BUS_DIR || process.env.RELAY_DATA_DIR || join(homedir(), ".agent-bus");
44
+ }
45
+ export function handoffDir() { return join(busDir(), "handoffs"); }
37
46
  function configPath() { return join(busDir(), "config.json"); }
38
47
 
39
48
  export function readConfig() {
@@ -41,18 +50,76 @@ export function readConfig() {
41
50
  return {};
42
51
  }
43
52
 
44
- export function resolveHub(project, env = process.env) {
53
+ // HOW a hub was chosen, not merely which one. The silent fallback is the most expensive failure
54
+ // this system has: an unpinned project resolves to the global default, nothing errors, the seat
55
+ // looks healthy, and its work records on a hub nobody reads (2026-08-19 and again 2026-08-23,
56
+ // after a reboot reopened every window in $HOME). Provenance is therefore part of the answer, so
57
+ // callers that can warn — sessionstart, doctor, relay_whoami — can say WHY.
58
+ // via "env" → RELAY_URL (explicit override: tests, crew seats)
59
+ // via "pin" → config hubs[project] (the only DELIBERATE routing)
60
+ // via "global" → config url (fallback — the project was never pinned)
61
+ // via "default" → built-in local (fallback — there is no config at all)
62
+ export function resolveHubInfo(project, env = process.env) {
45
63
  try {
46
- if (env.RELAY_URL) return env.RELAY_URL;
64
+ if (env.RELAY_URL) return { url: env.RELAY_URL, via: "env" };
47
65
  const cfg = readConfig();
48
66
  const name = project || resolveProject();
49
67
  const u = cfg?.hubs?.[name];
50
- if (u && typeof u === "string") return u;
51
- if (cfg?.url && typeof cfg.url === "string") return cfg.url;
68
+ if (u && typeof u === "string") return { url: u, via: "pin" };
69
+ if (cfg?.url && typeof cfg.url === "string") return { url: cfg.url, via: "global" };
52
70
  } catch {}
53
- return DEFAULT_HUB_URL;
71
+ return { url: DEFAULT_HUB_URL, via: "default" };
72
+ }
73
+
74
+ export function resolveHub(project, env = process.env) {
75
+ return resolveHubInfo(project, env).url;
76
+ }
77
+
78
+ // Every project the operator has deliberately pinned — the "expected one of these" list a
79
+ // misplaced session needs in order to fix itself.
80
+ export function knownProjects() {
81
+ try { const h = readConfig()?.hubs; return h && typeof h === "object" ? Object.keys(h).sort() : []; } catch { return []; }
54
82
  }
55
83
 
84
+ // How many IMMEDIATE children of `dir` are git repos. Bounded (first 200 entries) and fail-safe:
85
+ // this runs in every session start, inside the user's tool loop.
86
+ function countChildRepos(dir) {
87
+ try {
88
+ let n = 0;
89
+ for (const e of readdirSync(dir, { withFileTypes: true }).slice(0, 200)) {
90
+ if (!e.isDirectory() || e.name.startsWith(".")) continue;
91
+ if (existsSync(join(dir, e.name, ".git"))) { n++; if (n >= 2) return n; }
92
+ }
93
+ return n;
94
+ } catch { return 0; }
95
+ }
96
+
97
+ // Is this directory a real project seat, and if not, WHY not? Returns "" for a seat, otherwise a
98
+ // short human reason. A non-seat must never register: it mints a phantom "<username>" or
99
+ // "development" board, and because that name is unpinned it lands on the local hub while the crew
100
+ // lives on the remote one — the exact split that makes an agent report "Trantor is unreachable"
101
+ // while every hub is healthy. An explicit RELAY_SESSION/RELAY_PROJECT is the deliberate opt-in.
102
+ export function nonSeatReason(dir = process.cwd(), env = process.env) {
103
+ try {
104
+ if (env.RELAY_SESSION || env.RELAY_PROJECT) return "";
105
+ const home = homedir();
106
+ if (dir === home) return "the home directory";
107
+ const claudeDir = env.CLAUDE_CONFIG_DIR || join(home, ".claude");
108
+ if (dir.startsWith(join(claudeDir, "plugins", "cache"))) return "the plugin cache";
109
+ // A WORKSPACE CONTAINER (~/development): not a repo itself, but holding several. This is the
110
+ // directory a reopened window most often lands in after the home directory, and it is the
111
+ // worst case — it registers as project "development", which nothing pins, so the seat lands
112
+ // on the fallback hub while its crew is on the pinned one. Both look healthy. A plain non-git
113
+ // directory is NOT disqualified: plenty of real projects have no repo yet.
114
+ if (!gitRoot(dir) && countChildRepos(dir) >= 2) return "a folder of projects, not a project";
115
+ } catch {}
116
+ return "";
117
+ }
118
+
119
+ // Public writer for callers that own their own slice of config.json (lib/seats.mjs). Kept as a
120
+ // named export rather than exposing the internal one, so the file stays the single writer.
121
+ export function writeConfigPublic(cfg) { writeConfig(cfg); }
122
+
56
123
  function writeConfig(cfg) {
57
124
  mkdirSync(busDir(), { recursive: true });
58
125
  writeFileSync(configPath(), JSON.stringify(cfg, null, 2));
package/lib/seats.mjs ADDED
@@ -0,0 +1,155 @@
1
+ // trantor — DECLARED SEATS: which project belongs in which directory, and whether it is running.
2
+ //
3
+ // A session's identity is positional (lib/project.mjs): a window IS whatever folder it stands in.
4
+ // That is fine while nothing moves it. A macOS reboot moves all of them: reopened Terminal windows
5
+ // come back in $HOME, `claude --resume` restores the conversation but not the directory, and every
6
+ // seat quietly becomes a non-seat (2026-08-23 — two crebral windows spent an hour believing they
7
+ // were still seats and finally reported the bus as down).
8
+ //
9
+ // 0.17.81 made that state LOUD. This makes it RECOVERABLE: the operator declares, once, that
10
+ // "crebral-health" lives in ~/development/crebral-health, and afterwards a single command — or a
11
+ // login agent — puts every missing seat back in its own directory. Nothing here guesses: a seat
12
+ // exists because it was declared, and it is live because a real process is standing in its dir.
13
+ import { execFileSync, spawn } from "node:child_process";
14
+ import { existsSync } from "node:fs";
15
+ import { resolve } from "node:path";
16
+ import { readConfig, writeConfigPublic, resolveProject, resolveHubInfo } from "./project.mjs";
17
+
18
+ // ── the registry ────────────────────────────────────────────────────────────────
19
+ // config.json gains `seats: { <project>: { dir } }`. Same file as `hubs`, same shape of decision:
20
+ // a deliberate operator statement, not an inference.
21
+ export function readSeats() {
22
+ try {
23
+ const s = readConfig()?.seats;
24
+ if (!s || typeof s !== "object") return {};
25
+ const out = {};
26
+ for (const [p, v] of Object.entries(s)) {
27
+ const dir = typeof v === "string" ? v : v?.dir;
28
+ const agent = (typeof v === "object" && typeof v?.agent === "string" && v.agent) ? v.agent : "claude";
29
+ if (typeof dir === "string" && dir) out[p] = { dir, agent };
30
+ }
31
+ return out;
32
+ } catch { return {}; }
33
+ }
34
+
35
+ export function declareSeat(project, dir, agent = "claude") {
36
+ if (!project || typeof project !== "string") throw new Error("project required");
37
+ const abs = resolve(dir || process.cwd());
38
+ if (!existsSync(abs)) throw new Error(`directory does not exist: ${abs}`);
39
+ const cfg = readConfig();
40
+ cfg.seats = { ...(cfg.seats && typeof cfg.seats === "object" ? cfg.seats : {}), [project]: { dir: abs, agent } };
41
+ writeConfigPublic(cfg);
42
+ return { project, dir: abs, agent };
43
+ }
44
+
45
+ export function undeclareSeat(project) {
46
+ const cfg = readConfig();
47
+ if (!cfg.seats || typeof cfg.seats !== "object" || !(project in cfg.seats)) return false;
48
+ delete cfg.seats[project];
49
+ if (!Object.keys(cfg.seats).length) delete cfg.seats;
50
+ writeConfigPublic(cfg);
51
+ return true;
52
+ }
53
+
54
+ // ── liveness ────────────────────────────────────────────────────────────────────
55
+ // A seat is live when a real agent process is STANDING IN ITS DIRECTORY. Deliberately local and
56
+ // deliberately not the hub's presence list: the hub says a peer named "crebral-health" checked in,
57
+ // which is exactly the claim that was false all along (a home-dir session can assert any name, and
58
+ // a stale registration outlives the process). A cwd is not assertable.
59
+ //
60
+ // `ps` gives the candidate pids; lsof resolves each one's cwd. Both are bounded and fail-soft —
61
+ // this runs in a CLI and, via `seats status`, in a login agent.
62
+ export function liveAgentDirs(binaries = ["claude", "codex", "opencode"]) {
63
+ const out = []; // [{ pid, comm, cwd }]
64
+ let rows = "";
65
+ try {
66
+ rows = execFileSync("/bin/ps", ["-axo", "pid=,comm="], { encoding: "utf8", timeout: 4000 });
67
+ } catch { return out; }
68
+ const self = String(process.pid);
69
+ for (const line of rows.split("\n")) {
70
+ const m = line.trim().match(/^(\d+)\s+(.*)$/);
71
+ if (!m) continue;
72
+ const [, pid, comm] = m;
73
+ if (pid === self) continue;
74
+ const base = comm.split("/").pop();
75
+ if (!binaries.includes(base)) continue;
76
+ let cwd = "";
77
+ try {
78
+ const fn = execFileSync("/usr/sbin/lsof", ["-a", "-p", pid, "-d", "cwd", "-Fn"],
79
+ { encoding: "utf8", timeout: 4000, stdio: ["ignore", "pipe", "ignore"] });
80
+ const hit = fn.split("\n").find(l => l.startsWith("n"));
81
+ if (hit) cwd = hit.slice(1);
82
+ } catch { /* process died, or lsof is unavailable — treat as unknown, never as live */ }
83
+ if (cwd) out.push({ pid: Number(pid), comm: base, cwd });
84
+ }
85
+ return out;
86
+ }
87
+
88
+ // Status of every declared seat: live (with the pid holding it) or missing (with why).
89
+ // `live` is injectable so a drill can pin the mapping without spawning processes named "claude".
90
+ export function seatStatus(live = liveAgentDirs()) {
91
+ const seats = readSeats();
92
+ return Object.entries(seats).map(([project, { dir, agent }]) => {
93
+ // The seat is held only by the agent it was DECLARED for. A crew seat (opencode, codex) working
94
+ // in the same repo is not the operator's Claude session, and counting it "live" would silently
95
+ // refuse to restore the very window that went missing — a false green in the one place this
96
+ // feature exists to prevent. Report the other occupant, never mistake it for the seat.
97
+ const inDir = live.filter(p => p.cwd === dir);
98
+ const holder = inDir.find(p => p.comm === agent);
99
+ const others = inDir.filter(p => p.comm !== agent);
100
+ const exists = existsSync(dir);
101
+ return {
102
+ project, dir, exists, agent,
103
+ live: !!holder,
104
+ pid: holder?.pid || null,
105
+ others,
106
+ hub: resolveHubInfo(project).url,
107
+ via: resolveHubInfo(project).via,
108
+ why: holder ? ""
109
+ : !exists ? "directory does not exist"
110
+ : others.length ? `no ${agent} here (${others.map(o => `${o.comm} ${o.pid}`).join(", ")} is, but that is not this seat)`
111
+ : `no ${agent} process in this directory`,
112
+ };
113
+ }).sort((a, b) => a.project.localeCompare(b.project));
114
+ }
115
+
116
+ // Seats that are declared, whose directory exists, and that nothing is standing in.
117
+ export function missingSeats(live) { return seatStatus(live).filter(s => !s.live && s.exists); }
118
+
119
+ // ── recovery ────────────────────────────────────────────────────────────────────
120
+ // Open a NEW terminal window in the seat's directory running the agent. macOS-only by design (the
121
+ // same osascript bin/open-session.sh has always used); elsewhere we print the command instead of
122
+ // pretending. Returns { launched, command }.
123
+ export function launchSeat(seat, { command, dryRun = false } = {}) {
124
+ const cmd = `cd ${JSON.stringify(seat.dir)} && ${command || seat.agent || "claude"}`;
125
+ if (dryRun || process.platform !== "darwin") return { launched: false, command: cmd };
126
+ const osa = `tell application "Terminal"\n do script ${JSON.stringify(cmd)}\nend tell\n`;
127
+ try {
128
+ const kid = spawn("/usr/bin/osascript", ["-e", osa], { detached: true, stdio: "ignore" });
129
+ kid.unref();
130
+ return { launched: true, command: cmd };
131
+ } catch (e) {
132
+ return { launched: false, command: cmd, error: e?.message || String(e) };
133
+ }
134
+ }
135
+
136
+ // Seed the registry from what is already true: every pinned project whose name matches a directory
137
+ // under a workspace root. Suggestion only — the caller confirms before anything is written, because
138
+ // a guessed seat is exactly the kind of inference this file exists to replace.
139
+ export function suggestSeats(workspace) {
140
+ const cfg = readConfig();
141
+ const pinned = cfg?.hubs && typeof cfg.hubs === "object" ? Object.keys(cfg.hubs) : [];
142
+ const declared = readSeats();
143
+ const out = [];
144
+ for (const project of pinned) {
145
+ if (declared[project]) continue;
146
+ const dir = resolve(workspace, project);
147
+ if (existsSync(dir)) out.push({ project, dir });
148
+ }
149
+ return out;
150
+ }
151
+
152
+ // The project a directory would register as — so `seats add` can warn when the declared name and
153
+ // the directory's own identity disagree (they must match, or the seat comes up under a name the
154
+ // operator did not declare and nothing routes to it).
155
+ export function projectForDir(dir) { return resolveProject(dir); }
@@ -100,8 +100,14 @@ CREATE TABLE IF NOT EXISTS messages (
100
100
  project TEXT,
101
101
  text TEXT NOT NULL,
102
102
  refs JSONB DEFAULT '[]'::jsonb,
103
+ re BIGINT, -- the message id this one ANSWERS (a contract outcome).
104
+ -- Without it /contracts cannot close the right contract
105
+ -- after a restart and silently falls back to guessing
106
+ -- oldest-open-first.
103
107
  PRIMARY KEY (org_id, id)
104
108
  );
109
+ -- additive migration for hubs whose messages table predates the reply link
110
+ ALTER TABLE messages ADD COLUMN IF NOT EXISTS re BIGINT;
105
111
  CREATE INDEX IF NOT EXISTS messages_org_to ON messages (org_id, to_session, id DESC);
106
112
 
107
113
  CREATE TABLE IF NOT EXISTS peers (
package/lib/store-pg.mjs CHANGED
@@ -76,6 +76,9 @@ function msgFromRow(row) {
76
76
  project: row.project || "",
77
77
  text: row.text || "",
78
78
  refs: Array.isArray(row.refs) ? row.refs : [],
79
+ // absent rather than 0/null when there is no reply link, so `if (m.re)` reads the same as it
80
+ // does on a message the hub minted this session.
81
+ re: row.re == null ? undefined : Number(row.re),
79
82
  };
80
83
  }
81
84
 
@@ -268,9 +271,9 @@ export class PgStore {
268
271
  const refs = Array.isArray(msg.refs) ? msg.refs : [];
269
272
  const r = await this.pool.query(
270
273
  `WITH next_id AS (SELECT COALESCE(MAX(id),0)+1 AS id FROM messages WHERE org_id=$1)
271
- INSERT INTO messages(id, org_id, ts, from_session, to_session, project, text, refs)
272
- SELECT id, $1, $2, $3, $4, $5, $6, $7::jsonb FROM next_id RETURNING id`,
273
- [orgId, ms(msg.ts), msg.from || msg.from_session || "anon", msg.to || msg.to_session || "all", msg.project || "", String(msg.text ?? ""), JSON.stringify(refs)],
274
+ INSERT INTO messages(id, org_id, ts, from_session, to_session, project, text, refs, re)
275
+ SELECT id, $1, $2, $3, $4, $5, $6, $7::jsonb, $8 FROM next_id RETURNING id`,
276
+ [orgId, ms(msg.ts), msg.from || msg.from_session || "anon", msg.to || msg.to_session || "all", msg.project || "", String(msg.text ?? ""), JSON.stringify(refs), Number(msg.re) > 0 ? Number(msg.re) : null],
274
277
  );
275
278
  return Number(r.rows[0].id);
276
279
  }
@@ -431,11 +434,11 @@ export class PgStore {
431
434
  await c.query("SELECT setval(pg_get_serial_sequence('events','id'), GREATEST((SELECT COALESCE(MAX(id),0) FROM events), 1), true)");
432
435
  for (const m of state.messages || []) {
433
436
  await c.query(
434
- `INSERT INTO messages(id, org_id, ts, from_session, to_session, project, text, refs)
435
- VALUES($1,$2,$3,$4,$5,$6,$7,$8::jsonb)
437
+ `INSERT INTO messages(id, org_id, ts, from_session, to_session, project, text, refs, re)
438
+ VALUES($1,$2,$3,$4,$5,$6,$7,$8::jsonb,$9)
436
439
  ON CONFLICT(org_id, id) DO UPDATE SET ts=EXCLUDED.ts, from_session=EXCLUDED.from_session, to_session=EXCLUDED.to_session,
437
- project=EXCLUDED.project, text=EXCLUDED.text, refs=EXCLUDED.refs`,
438
- [Number(m.id), orgId, ms(m.ts), m.from || "anon", m.to || "all", m.project || "", String(m.text ?? ""), JSON.stringify(Array.isArray(m.refs) ? m.refs : [])],
440
+ project=EXCLUDED.project, text=EXCLUDED.text, refs=EXCLUDED.refs, re=EXCLUDED.re`,
441
+ [Number(m.id), orgId, ms(m.ts), m.from || "anon", m.to || "all", m.project || "", String(m.text ?? ""), JSON.stringify(Array.isArray(m.refs) ? m.refs : []), Number(m.re) > 0 ? Number(m.re) : null],
439
442
  );
440
443
  }
441
444
  await c.query(
@@ -549,11 +552,11 @@ export class PgStore {
549
552
  if (events.upserts.length) await c.query("SELECT setval(pg_get_serial_sequence('events','id'), GREATEST((SELECT COALESCE(MAX(id),0) FROM events), 1), true)");
550
553
  for (const m of messages.upserts) {
551
554
  await c.query(
552
- `INSERT INTO messages(id, org_id, ts, from_session, to_session, project, text, refs)
553
- VALUES($1,$2,$3,$4,$5,$6,$7,$8::jsonb)
555
+ `INSERT INTO messages(id, org_id, ts, from_session, to_session, project, text, refs, re)
556
+ VALUES($1,$2,$3,$4,$5,$6,$7,$8::jsonb,$9)
554
557
  ON CONFLICT (org_id, id) DO UPDATE SET ts=EXCLUDED.ts, from_session=EXCLUDED.from_session, to_session=EXCLUDED.to_session,
555
- project=EXCLUDED.project, text=EXCLUDED.text, refs=EXCLUDED.refs`,
556
- [Number(m.id), orgId, ms(m.ts), m.from || "anon", m.to || "all", m.project || "", String(m.text ?? ""), JSON.stringify(Array.isArray(m.refs) ? m.refs : [])],
558
+ project=EXCLUDED.project, text=EXCLUDED.text, refs=EXCLUDED.refs, re=EXCLUDED.re`,
559
+ [Number(m.id), orgId, ms(m.ts), m.from || "anon", m.to || "all", m.project || "", String(m.text ?? ""), JSON.stringify(Array.isArray(m.refs) ? m.refs : []), Number(m.re) > 0 ? Number(m.re) : null],
557
560
  );
558
561
  }
559
562
  if (messages.deletes.length) await c.query("DELETE FROM messages WHERE org_id=$1 AND id = ANY($2::bigint[])", [orgId, messages.deletes]);