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.
- package/.claude-plugin/plugin.json +2 -2
- package/bin/cli.mjs +3 -0
- package/bin/crew-runner.mjs +45 -1
- package/bin/duty.mjs +83 -16
- package/bin/seats.mjs +215 -0
- package/bin/write-handoff.mjs +39 -4
- package/hooks/inbox-deliver.mjs +37 -1
- package/hooks/lib/handoff.mjs +11 -1
- package/hooks/sessionstart.mjs +65 -11
- package/hooks/stop-inbox.mjs +51 -1
- package/hub.mjs +45 -1
- package/lib/project.mjs +74 -7
- package/lib/seats.mjs +155 -0
- package/lib/store-contract.mjs +6 -0
- package/lib/store-pg.mjs +14 -11
- package/mcp.mjs +51 -9
- package/package.json +2 -2
- package/skills/handoff/SKILL.md +9 -1
package/hooks/sessionstart.mjs
CHANGED
|
@@ -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 {
|
|
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(),
|
|
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
|
-
//
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
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`;
|
package/hooks/stop-inbox.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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); }
|
package/lib/store-contract.mjs
CHANGED
|
@@ -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]);
|