trantor 0.17.69 → 0.17.72
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 +1 -1
- package/bin/adopt.mjs +35 -2
- package/bin/crew-runner.mjs +102 -15
- package/bin/doctor.mjs +32 -1
- package/bin/focus-title.mjs +57 -0
- package/hooks/heartbeat.mjs +4 -1
- package/hooks/inbox-deliver.mjs +22 -11
- package/hooks/lib/api.mjs +49 -16
- package/hooks/precompact.mjs +3 -1
- package/hooks/prompt-focus.mjs +32 -4
- package/hooks/sessionstart.mjs +7 -2
- package/hooks/stop-inbox.mjs +2 -2
- package/hooks/subagent-start.mjs +3 -0
- package/hooks/todo-sync.mjs +13 -8
- package/hub.mjs +79 -8
- package/lib/splitbrain.mjs +130 -0
- package/mcp.mjs +10 -4
- package/package.json +3 -3
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.72",
|
|
4
4
|
"description": "Trantor — the hub-world for AI agent crews: live message bus, presence, project Kanban/flow board + crew orchestration for independent AI coding agents (Claude, Codex, Gemini, Kimi, DeepSeek)",
|
|
5
5
|
"mcpServers": {
|
|
6
6
|
"relay": {
|
package/bin/adopt.mjs
CHANGED
|
@@ -18,9 +18,10 @@
|
|
|
18
18
|
import { readFileSync, writeFileSync, existsSync, readdirSync } from "node:fs";
|
|
19
19
|
import { join } from "node:path";
|
|
20
20
|
import { homedir } from "node:os";
|
|
21
|
-
import { hostId } from "../lib/project.mjs";
|
|
21
|
+
import { hostId, DEFAULT_HUB_URL } from "../lib/project.mjs";
|
|
22
22
|
import { loadOrCreate, signRequest } from "../lib/identity.mjs";
|
|
23
23
|
import { sfetchJson } from "../lib/signed-fetch.mjs";
|
|
24
|
+
import { scan } from "../lib/splitbrain.mjs";
|
|
24
25
|
|
|
25
26
|
const argv = process.argv.slice(2);
|
|
26
27
|
const PROJECT = argv.find(a => !a.startsWith("--")) || "";
|
|
@@ -104,12 +105,44 @@ try {
|
|
|
104
105
|
config.hubs[PROJECT] = TARGET;
|
|
105
106
|
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n");
|
|
106
107
|
console.log(`pinned : ${PROJECT} → ${TARGET}`);
|
|
108
|
+
|
|
109
|
+
// TELL the stale sessions, don't just print at a human who may never see this terminal again.
|
|
110
|
+
// A live session holds its hub URL for its whole life: its MCP server resolved the route at
|
|
111
|
+
// boot and nothing re-reads config.json. So the moment the pin is written, every one of these
|
|
112
|
+
// is recording onto a hub nobody reads any more — the exact split-brain crebral-health spent
|
|
113
|
+
// two sessions diagnosing. They are still listening on the OLD hub, so that is where the
|
|
114
|
+
// notice has to go.
|
|
107
115
|
if (livePeers.length) {
|
|
108
|
-
|
|
116
|
+
const notice = `📦 ${PROJECT} has MOVED to ${TARGET}. You are still bound to ${LOCAL}, so your cards and messages now land on a hub nobody is reading. RESTART to pick up the pin — crew seats: \`trantor down && trantor up\` · Claude sessions: restart the session.`;
|
|
117
|
+
let told = 0;
|
|
118
|
+
for (const [session] of livePeers) {
|
|
119
|
+
try {
|
|
120
|
+
await sfetchJson(`${LOCAL}/send`, { identity: ownerId, payload: { from: owner, to: session, project: PROJECT, text: notice }, signal: AbortSignal.timeout(8000) });
|
|
121
|
+
told++;
|
|
122
|
+
} catch (e) { console.log(` ⚠ could not notify ${session}: ${e.message}`); }
|
|
123
|
+
}
|
|
124
|
+
// …and once to the room, for anything live that never registered as a peer.
|
|
125
|
+
try { await sfetchJson(`${LOCAL}/send`, { identity: ownerId, payload: { from: owner, to: "all", project: PROJECT, text: notice }, signal: AbortSignal.timeout(8000) }); } catch {}
|
|
126
|
+
console.log(`\n⚠ ${livePeers.length} live session(s) still route to the OLD hub — told ${told} of them to restart:`);
|
|
109
127
|
for (const [s] of livePeers) console.log(` ${s}`);
|
|
110
128
|
console.log(` crew seats: trantor down && trantor up · Claude sessions: restart them when convenient.`);
|
|
111
129
|
}
|
|
112
130
|
console.log(`\n✓ adopted. New sessions on ${PROJECT} land on ${TARGET}.`);
|
|
131
|
+
|
|
132
|
+
// Prove the move actually landed as one hub, rather than trusting that it did. A migration is
|
|
133
|
+
// precisely the moment a project is most likely to end up living in two places at once.
|
|
134
|
+
try {
|
|
135
|
+
const { findings, blind } = await scan(config, ownerId, { defaultUrl: DEFAULT_HUB_URL, timeoutMs: 6000 });
|
|
136
|
+
const mine = findings.filter(f => f.project === PROJECT);
|
|
137
|
+
if (mine.length) {
|
|
138
|
+
console.log(`\n⚠ split-brain check on ${PROJECT}:`);
|
|
139
|
+
for (const f of mine) { console.log(` ${f.message}`); console.log(` → ${f.fix}`); }
|
|
140
|
+
} else if (blind.length) {
|
|
141
|
+
console.log(`\nsplit-brain check: partial — could not read ${blind.map(b => b.url).join(", ")}`);
|
|
142
|
+
} else {
|
|
143
|
+
console.log(`split-brain check: clean — ${PROJECT} is live on one hub only.`);
|
|
144
|
+
}
|
|
145
|
+
} catch {}
|
|
113
146
|
} catch (e) {
|
|
114
147
|
console.error(`\n✗ adopt failed: ${e.message}`);
|
|
115
148
|
console.error("nothing was pinned — routing is unchanged.");
|
package/bin/crew-runner.mjs
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// agent arrives it RESUMES the CLI session (native resume = full context kept) with that
|
|
10
10
|
// message as the prompt. The model just works and ends its turn; the runner does the rest.
|
|
11
11
|
import { execSync, spawnSync } from "node:child_process";
|
|
12
|
-
import { readFileSync, existsSync, appendFileSync } from "node:fs";
|
|
12
|
+
import { readFileSync, writeFileSync, unlinkSync, existsSync, appendFileSync } from "node:fs";
|
|
13
13
|
import { join, basename } from "node:path";
|
|
14
14
|
import { homedir } from "node:os";
|
|
15
15
|
import { resolveProject, resolveHub } from "../lib/project.mjs";
|
|
@@ -174,6 +174,39 @@ let consecFails = 0;
|
|
|
174
174
|
let lastErrText = "";
|
|
175
175
|
const ERRF = join(homedir(), ".agent-bus", `err-${AGENT}-${PROJ}.txt`);
|
|
176
176
|
|
|
177
|
+
// ---- undelivered wake messages (the runner owns delivery, not the hub) ----
|
|
178
|
+
// The hub hands a message out exactly ONCE: the poll cursor advances the instant we read it, and
|
|
179
|
+
// nothing ever re-fires. So a turn that died — API outage, quota wall, crashed CLI — used to take
|
|
180
|
+
// its wake message down with it, and an escalation addressed to this seat was gone forever with
|
|
181
|
+
// no trace anywhere. The queue below makes delivery the runner's job: a message is not consumed
|
|
182
|
+
// until a turn actually exits 0. It survives a runner restart on disk, retries on its own backoff
|
|
183
|
+
// so a silent bus still gets it through, and says how many are outstanding every time it reports.
|
|
184
|
+
const PENDF = join(homedir(), ".agent-bus", `pending-${AGENT}-${PROJ}.json`);
|
|
185
|
+
// A cap, so a long outage cannot grow the queue without bound. Overflow drops the OLDEST and says
|
|
186
|
+
// so on the bus — a silent drop is the exact failure this whole mechanism exists to end.
|
|
187
|
+
const PENDING_MAX = 50;
|
|
188
|
+
// Backoff between redelivery attempts. Starts fast (a blip clears in 30s) and lands at 15 minutes,
|
|
189
|
+
// which is the cadence for "this seat is properly down" rather than a retry storm against a hub
|
|
190
|
+
// that is already refusing us.
|
|
191
|
+
// TRANTOR_RETRY_MS (comma-separated ms) shortens the ladder so the redelivery drill can exercise
|
|
192
|
+
// a real backoff in seconds instead of waiting out the production one.
|
|
193
|
+
const RETRY_MS = (() => {
|
|
194
|
+
const custom = String(process.env.TRANTOR_RETRY_MS || "").split(",").map(Number).filter(n => Number.isFinite(n) && n >= 0);
|
|
195
|
+
return custom.length ? custom : [30e3, 60e3, 120e3, 300e3, 900e3];
|
|
196
|
+
})();
|
|
197
|
+
function savePending(wake, bcast) {
|
|
198
|
+
try {
|
|
199
|
+
if (!wake.length && !bcast.length) { try { unlinkSync(PENDF); } catch {} return; }
|
|
200
|
+
writeFileSync(PENDF, JSON.stringify({ agent: AGENT, project: PROJ, ts: Date.now(), wake, bcast }));
|
|
201
|
+
} catch {}
|
|
202
|
+
}
|
|
203
|
+
function loadPending() {
|
|
204
|
+
try {
|
|
205
|
+
const j = JSON.parse(readFileSync(PENDF, "utf8"));
|
|
206
|
+
return { wake: Array.isArray(j.wake) ? j.wake : [], bcast: Array.isArray(j.bcast) ? j.bcast : [] };
|
|
207
|
+
} catch { return { wake: [], bcast: [] }; }
|
|
208
|
+
}
|
|
209
|
+
|
|
177
210
|
function classifyFailure(exit, errText) {
|
|
178
211
|
const t = (errText || "").toLowerCase();
|
|
179
212
|
if (exit === 127) return "missing-cli";
|
|
@@ -184,7 +217,7 @@ function classifyFailure(exit, errText) {
|
|
|
184
217
|
return "crashed";
|
|
185
218
|
}
|
|
186
219
|
|
|
187
|
-
async function reportFailure(exit, trigger) {
|
|
220
|
+
async function reportFailure(exit, trigger, undelivered = 0) {
|
|
188
221
|
consecFails++;
|
|
189
222
|
const reason = classifyFailure(exit, lastErrText);
|
|
190
223
|
const down = consecFails >= 2;
|
|
@@ -193,9 +226,12 @@ async function reportFailure(exit, trigger) {
|
|
|
193
226
|
const hint = reason === "exhausted" ? " — needs `trantor swap`"
|
|
194
227
|
: reason === "auth" ? " — check credentials"
|
|
195
228
|
: reason === "missing-cli" ? " — CLI not on PATH" : "";
|
|
229
|
+
// The count of messages this seat is HOLDING is the operator-actionable half of a failure: a
|
|
230
|
+
// crashed pulse costs nothing, a crashed turn sitting on three escalations is someone waiting.
|
|
231
|
+
const held = undelivered ? ` · holding ${undelivered} undelivered message${undelivered > 1 ? "s" : ""} (will retry)` : "";
|
|
196
232
|
const text = down
|
|
197
|
-
? `🛑 ${SESSION} DOWN — ${consecFails} consecutive failures (${reason}, exit ${exit})${hint}`
|
|
198
|
-
: `⚠️ ${SESSION} turn FAILED (${trigger}, exit ${exit} · ${reason})${hint}`;
|
|
233
|
+
? `🛑 ${SESSION} DOWN — ${consecFails} consecutive failures (${reason}, exit ${exit})${hint}${held}`
|
|
234
|
+
: `⚠️ ${SESSION} turn FAILED (${trigger}, exit ${exit} · ${reason})${hint}${held}`;
|
|
199
235
|
await api("/send", { from: SESSION, to: "all", text, project: PROJ }).catch(() => {});
|
|
200
236
|
cmuxStatus(down ? "down" : "error", "#ef6a6a", "alert", { alert: true, priority: 90 }); cmuxLog(`turn failed: ${reason} (exit ${exit})`, "error");
|
|
201
237
|
log(`\x1b[31mreported failure to bus: ${reason} (exit ${exit})\x1b[0m`);
|
|
@@ -278,9 +314,18 @@ async function loadLessons() {
|
|
|
278
314
|
});
|
|
279
315
|
} catch {}
|
|
280
316
|
|
|
281
|
-
|
|
317
|
+
// Wake messages this seat has PULLED off the bus but not yet worked successfully, plus the
|
|
318
|
+
// broadcasts batched behind them. Restored from disk first: a runner that was killed mid-turn
|
|
319
|
+
// (or a machine that rebooted) still owes those messages, and the hub will never send them again.
|
|
320
|
+
const restored = loadPending();
|
|
321
|
+
let pendingWake = restored.wake;
|
|
322
|
+
let pendingBcast = restored.bcast;
|
|
323
|
+
let retryAt = 0; // 0 = deliver at the next opportunity
|
|
324
|
+
let deliveryFails = 0; // consecutive failed attempts at the SAME pending batch
|
|
325
|
+
if (pendingWake.length) log(`\x1b[33m${pendingWake.length} message(s) survived from a previous run — redelivering\x1b[0m`);
|
|
326
|
+
|
|
282
327
|
const ec0 = runTurn(KICKOFF + LESSONS, true, "kickoff");
|
|
283
|
-
if (ec0) await reportFailure(ec0, "kickoff"); // a failed kickoff = the "fired up, died, nobody knew" case
|
|
328
|
+
if (ec0) await reportFailure(ec0, "kickoff", pendingWake.length); // a failed kickoff = the "fired up, died, nobody knew" case
|
|
284
329
|
let lastTurnAt = Date.now();
|
|
285
330
|
if (PULSE_MS) log(`pulse armed — mission re-read every ${Math.round(PULSE_MS / 1000)}s (${MISSION_FILE})`);
|
|
286
331
|
log(`parked — long-polling the bus as ${SESSION} (free; this poll is also the heartbeat)`);
|
|
@@ -295,9 +340,15 @@ async function loadLessons() {
|
|
|
295
340
|
log("parked — waiting for the next message or pulse");
|
|
296
341
|
continue;
|
|
297
342
|
}
|
|
298
|
-
//
|
|
299
|
-
|
|
300
|
-
|
|
343
|
+
// A due REDELIVERY runs before we go back to waiting — during an outage the bus is silent by
|
|
344
|
+
// definition, so the retry timer is the only thing that will ever move these messages.
|
|
345
|
+
if (pendingWake.length && Date.now() >= retryAt) { await deliverWake(); continue; }
|
|
346
|
+
// cap the long-poll hold so neither a due pulse nor a due redelivery waits out a silent 280s window
|
|
347
|
+
const due = [];
|
|
348
|
+
if (PULSE_MS) due.push(PULSE_MS - (Date.now() - lastTurnAt));
|
|
349
|
+
if (pendingWake.length) due.push(retryAt - Date.now());
|
|
350
|
+
const holdS = due.length
|
|
351
|
+
? Math.max(5, Math.min(280, Math.ceil(Math.min(...due) / 1000)))
|
|
301
352
|
: 280;
|
|
302
353
|
let msgs = [];
|
|
303
354
|
try {
|
|
@@ -320,15 +371,51 @@ async function loadLessons() {
|
|
|
320
371
|
const bcast = msgs.filter(m => m.to === "all" && !mentions.includes(m));
|
|
321
372
|
pendingBcast.push(...bcast); // wake-policy: plain broadcasts batch, they don't wake
|
|
322
373
|
const wake = [...direct, ...mentions];
|
|
323
|
-
if (!wake.length) { if (bcast.length) log(`${bcast.length} broadcast(s) batched (no wake) — ${pendingBcast.length} pending`); continue; }
|
|
374
|
+
if (!wake.length) { if (bcast.length) { savePending(pendingWake, pendingBcast); log(`${bcast.length} broadcast(s) batched (no wake) — ${pendingBcast.length} pending`); } continue; }
|
|
375
|
+
// Queue BEFORE running the turn, and persist immediately. Everything between here and a clean
|
|
376
|
+
// exit 0 — the CLI dying, the machine losing power — now leaves a record of what this seat owes.
|
|
377
|
+
pendingWake.push(...wake);
|
|
378
|
+
if (pendingWake.length > PENDING_MAX) {
|
|
379
|
+
const dropped = pendingWake.splice(0, pendingWake.length - PENDING_MAX);
|
|
380
|
+
log(`\x1b[31mundelivered queue overflowed — dropped ${dropped.length} oldest message(s)\x1b[0m`);
|
|
381
|
+
await api("/send", { from: SESSION, to: "all", project: PROJ,
|
|
382
|
+
text: `⚠️ ${SESSION} dropped ${dropped.length} undelivered message(s) — queue hit its ${PENDING_MAX} cap during a failure streak` }).catch(() => {});
|
|
383
|
+
}
|
|
384
|
+
savePending(pendingWake, pendingBcast);
|
|
385
|
+
// Respect an active backoff: a new message during an outage joins the batch, it does not
|
|
386
|
+
// reset the clock and hammer a CLI that is already failing.
|
|
387
|
+
if (Date.now() < retryAt) { log(`queued — ${pendingWake.length} undelivered, next attempt in ${Math.max(0, Math.round((retryAt - Date.now()) / 1000))}s`); continue; }
|
|
388
|
+
await deliverWake();
|
|
389
|
+
log("parked — waiting for the next message");
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Run the pending batch. The messages are cleared ONLY on exit 0; any other outcome leaves them
|
|
393
|
+
// queued, on disk, with a backoff — which is the whole point of the change.
|
|
394
|
+
async function deliverWake() {
|
|
395
|
+
const wake = pendingWake;
|
|
324
396
|
const ctx = pendingBcast.length ? `\nFYI broadcasts since your last turn (context only):\n${pendingBcast.map(m => `[${m.from} -> all]: ${m.text}`).join("\n")}\n` : "";
|
|
325
|
-
pendingBcast = [];
|
|
326
397
|
const lines = wake.map(m => `[${m.from}${m.to === "all" ? " -> all (mentions you)" : ""}]: ${m.text}`).join("\n");
|
|
327
|
-
|
|
398
|
+
// Say plainly that this is a second look. Without it the model re-reads an old escalation as
|
|
399
|
+
// brand new and can redo work it already half-did before the turn died.
|
|
400
|
+
const again = deliveryFails
|
|
401
|
+
? `\n(REDELIVERY, attempt ${deliveryFails + 1} — an earlier turn failed before acting on ${wake.length > 1 ? "these" : "this"}. Check what you already did before repeating it.)\n`
|
|
402
|
+
: "";
|
|
403
|
+
const prompt = `NEW BUS MESSAGE${wake.length > 1 ? "S" : ""} for you:\n${lines}\n${ctx}${again}\nAct on what's addressed to you, then end your turn.\n\n${RULES}`;
|
|
328
404
|
await loadLessons();
|
|
329
|
-
const
|
|
330
|
-
|
|
405
|
+
const trigger = wake.some(m => m.to === SESSION) ? "direct message" : "@mention";
|
|
406
|
+
const ec = runTurn(prompt + LESSONS, false, deliveryFails ? `${trigger} (redelivery)` : trigger);
|
|
407
|
+
if (ec) {
|
|
408
|
+
deliveryFails++;
|
|
409
|
+
const wait = RETRY_MS[Math.min(deliveryFails - 1, RETRY_MS.length - 1)];
|
|
410
|
+
retryAt = Date.now() + wait;
|
|
411
|
+
savePending(pendingWake, pendingBcast);
|
|
412
|
+
await reportFailure(ec, "message", pendingWake.length);
|
|
413
|
+
log(`\x1b[31m${pendingWake.length} message(s) still UNDELIVERED — next attempt in ${Math.round(wait / 1000)}s\x1b[0m`);
|
|
414
|
+
} else {
|
|
415
|
+
pendingWake = []; pendingBcast = []; deliveryFails = 0; retryAt = 0;
|
|
416
|
+
savePending([], []);
|
|
417
|
+
await reportHealthy();
|
|
418
|
+
}
|
|
331
419
|
lastTurnAt = Date.now();
|
|
332
|
-
log("parked — waiting for the next message");
|
|
333
420
|
}
|
|
334
421
|
})();
|
package/bin/doctor.mjs
CHANGED
|
@@ -8,6 +8,9 @@ import { join, dirname } from "node:path";
|
|
|
8
8
|
import { homedir } from "node:os";
|
|
9
9
|
import { execSync } from "node:child_process";
|
|
10
10
|
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { resolveProject, resolveHub, DEFAULT_HUB_URL } from "../lib/project.mjs";
|
|
12
|
+
import { loadOrCreate } from "../lib/identity.mjs";
|
|
13
|
+
import { scan } from "../lib/splitbrain.mjs";
|
|
11
14
|
|
|
12
15
|
const H = homedir();
|
|
13
16
|
const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
@@ -39,7 +42,11 @@ say("TRANTOR DOCTOR\n");
|
|
|
39
42
|
section("core");
|
|
40
43
|
Number(process.versions.node.split(".")[0]) >= 18 ? ok(`node ${process.versions.node}`) : warn(`node ${process.versions.node} too old`, "install node >= 18");
|
|
41
44
|
const cfg = read(join(H, ".agent-bus", "config.json")) || {};
|
|
42
|
-
|
|
45
|
+
// The hub THIS directory's project actually routes to — pins first. Reading only the global
|
|
46
|
+
// default meant the doctor could report a healthy local hub while every session in the project
|
|
47
|
+
// was talking to netcup, which is exactly the blindness the routing section below exists to end.
|
|
48
|
+
const PROJECT = resolveProject(process.cwd());
|
|
49
|
+
const HUB = resolveHub(PROJECT);
|
|
43
50
|
try {
|
|
44
51
|
const h = await (await fetch(`${HUB}/health`, { signal: AbortSignal.timeout(2000) })).json();
|
|
45
52
|
ok(`hub up at ${HUB} (${h.peers} peers known)`);
|
|
@@ -56,6 +63,30 @@ if (pkg?.version) {
|
|
|
56
63
|
tooOld ? warn(`trantor v${pkg.version} too old — heartbeat/presence requires v0.17.0+`, "npm update -g trantor") : ok(`trantor v${pkg.version}`);
|
|
57
64
|
} else warn("could not read trantor version", "reinstall: npm install -g trantor");
|
|
58
65
|
|
|
66
|
+
// ── hub routing: is any project split across two hubs? ───────────────────────────────────────
|
|
67
|
+
// Cards, messages and collision detection only work over ONE hub. A project split across two
|
|
68
|
+
// breaks silently — every seat reports healthy and half the work records where nobody looks.
|
|
69
|
+
section("hub routing");
|
|
70
|
+
{
|
|
71
|
+
const pin = (cfg.hubs || {})[PROJECT] || "";
|
|
72
|
+
say(` ${PROJECT} → ${HUB}${pin ? " (pinned)" : process.env.RELAY_URL ? " (RELAY_URL override)" : " (unpinned — falls back to the default)"}`);
|
|
73
|
+
const owner = String(cfg.ownerIdentity || "");
|
|
74
|
+
// Unsigned, an enforce hub answers "signature required" and a full hub reads as deserted. Sign
|
|
75
|
+
// as the owner when we have one, and say plainly when we cannot rather than guessing.
|
|
76
|
+
const identity = owner ? (() => { try { return loadOrCreate(owner, "human"); } catch { return null; } })() : null;
|
|
77
|
+
if (!identity) note("no owner identity in config — hubs are probed UNSIGNED, so an enforce hub will refuse the read");
|
|
78
|
+
let scanned = null;
|
|
79
|
+
try { scanned = await scan(cfg, identity, { defaultUrl: DEFAULT_HUB_URL, timeoutMs: 6000 }); }
|
|
80
|
+
catch (e) { note(`split-brain check could not run (${e?.message || e})`); }
|
|
81
|
+
if (scanned) {
|
|
82
|
+
REPORT.splitbrain = { findings: scanned.findings, blind: scanned.blind, checked: scanned.checked };
|
|
83
|
+
for (const b of scanned.blind) warn(`hub ${b.url} could not be read — ${b.reason}`, "detection is PARTIAL until this hub answers; a split hiding behind it will not be reported");
|
|
84
|
+
for (const f of scanned.findings) f.severity === "warn" ? warn(f.message, f.fix) : warn(`SPLIT-BRAIN — ${f.message}`, f.fix);
|
|
85
|
+
if (!scanned.findings.length && !scanned.blind.length) ok(`no split-brain — every live project sits on exactly one hub (${scanned.checked} hub${scanned.checked === 1 ? "" : "s"} cross-checked)`);
|
|
86
|
+
else if (!scanned.findings.length) ok(`no split-brain among the ${scanned.checked} hub${scanned.checked === 1 ? "" : "s"} that answered`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
59
90
|
// claude plugin
|
|
60
91
|
section("claude (the orchestrator)");
|
|
61
92
|
if (!has("claude")) warn("claude CLI not found", "install Claude Code: https://claude.com/claude-code");
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// trantor focus-title — give a session's focus card a title a human can skim, written by a CHEAP model.
|
|
3
|
+
//
|
|
4
|
+
// node bin/focus-title.mjs --id <cardId> --hub <url> --prompt-file <path> [--project <p>]
|
|
5
|
+
//
|
|
6
|
+
// The focus card is titled from the user's raw prompt by a regex in hooks/prompt-focus.mjs. That is
|
|
7
|
+
// the right thing to do IN the turn — a hook that waits on an LLM delays every prompt the user
|
|
8
|
+
// types — but a raw prompt makes a poor board card: it is long, it is addressed to Claude rather
|
|
9
|
+
// than describing work, and half of it is context the board does not need. So the hook posts the
|
|
10
|
+
// heuristic title instantly and hands the rewrite to this, DETACHED: the card is on the board in
|
|
11
|
+
// milliseconds and gets its readable line a few seconds later.
|
|
12
|
+
//
|
|
13
|
+
// Economics (the Scrooge doctrine): one `-t summarize -d easy` call, only for prompts the heuristic
|
|
14
|
+
// actually mangles — the hook does not even spawn this for a short, already-clear prompt. The
|
|
15
|
+
// result lands in `summary`, the same field the board already prefers over `title`, and the hub
|
|
16
|
+
// clears it on every refocus so a stale line can never shadow live work.
|
|
17
|
+
import { execSync, spawnSync } from "node:child_process";
|
|
18
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { homedir } from "node:os";
|
|
21
|
+
import { loadOrCreate } from "../lib/identity.mjs";
|
|
22
|
+
import { sfetchJson } from "../lib/signed-fetch.mjs";
|
|
23
|
+
|
|
24
|
+
const argv = process.argv.slice(2);
|
|
25
|
+
const val = (k) => { const i = argv.indexOf(`--${k}`); return i >= 0 ? (argv[i + 1] ?? "") : ""; };
|
|
26
|
+
const ID = Number(val("id"));
|
|
27
|
+
const HUB = val("hub");
|
|
28
|
+
const PROMPT_FILE = val("prompt-file");
|
|
29
|
+
if (!ID || !HUB || !PROMPT_FILE) process.exit(0); // nothing to do; never a visible failure
|
|
30
|
+
|
|
31
|
+
const scroogeBin = () => process.env.SCROOGE_BIN
|
|
32
|
+
|| (() => { try { return execSync("command -v scrooge", { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); } catch { return ""; } })()
|
|
33
|
+
|| (existsSync(new URL("../engine/bin/scrooge", import.meta.url)) ? new URL("../engine/bin/scrooge", import.meta.url).pathname : "");
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
const raw = readFileSync(PROMPT_FILE, "utf8").replace(/\s+/g, " ").trim();
|
|
37
|
+
if (!raw) process.exit(0);
|
|
38
|
+
const bin = scroogeBin();
|
|
39
|
+
if (!bin) process.exit(0); // no economics engine installed — heuristic title stands
|
|
40
|
+
|
|
41
|
+
const ask = `Rewrite this message to an AI coding assistant as a Kanban card title: what the WORK is, action first, in plain words a human skims. At most 70 characters. No quotes, no trailing period, no "the user wants". If it is several requests, name the main one. Return ONLY the title.
|
|
42
|
+
|
|
43
|
+
${raw.slice(0, 1800)}`;
|
|
44
|
+
const res = spawnSync(bin, ["-t", "summarize", "-d", "easy"], { input: ask, encoding: "utf8", timeout: 60000 });
|
|
45
|
+
if (res.error || !res.stdout) process.exit(0);
|
|
46
|
+
// A cheap model sometimes wraps or explains. Take the first non-empty line and strip the wrapper.
|
|
47
|
+
const line = String(res.stdout).split("\n").map(l => l.trim()).find(l => l && !/^```/.test(l)) || "";
|
|
48
|
+
const title = line.replace(/^["'`]+|["'`.]+$/g, "").replace(/^(title|card)\s*:\s*/i, "").trim().slice(0, 70);
|
|
49
|
+
// Guard against the failure modes that would make the board WORSE than the heuristic: an empty
|
|
50
|
+
// answer, a refusal, or the model echoing the prompt back at us.
|
|
51
|
+
if (title.length < 8 || /^(sorry|i can|as an ai)/i.test(title) || title.toLowerCase() === raw.toLowerCase().slice(0, title.length)) process.exit(0);
|
|
52
|
+
|
|
53
|
+
const owner = (() => { try { return JSON.parse(readFileSync(join(process.env.AGENT_BUS_DIR || join(homedir(), ".agent-bus"), "config.json"), "utf8")).ownerIdentity; } catch { return ""; } })();
|
|
54
|
+
const identity = loadOrCreate(owner || "admin", "human");
|
|
55
|
+
await sfetchJson(`${HUB}/task/update`, { identity, payload: { id: ID, summary: title, by: "scrooge-focus-title" }, signal: AbortSignal.timeout(8000) });
|
|
56
|
+
} catch { /* a board title is never worth surfacing an error for */ }
|
|
57
|
+
process.exit(0);
|
package/hooks/heartbeat.mjs
CHANGED
|
@@ -106,7 +106,10 @@ async function maybeEarlyWarn(stdinRaw, session) {
|
|
|
106
106
|
}
|
|
107
107
|
|
|
108
108
|
async function main(stdinRaw) {
|
|
109
|
-
|
|
109
|
+
// input.cwd FIRST — every hook must derive the project the SAME way, or two hooks in one
|
|
110
|
+
// session resolve two projects, two hubs, and half the work records where nobody reads.
|
|
111
|
+
let _in = {}; try { _in = JSON.parse(stdinRaw || "{}"); } catch {}
|
|
112
|
+
const projectDir = _in.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
110
113
|
// Mirror sessionstart.mjs: home-directory sessions aren't project work — don't register
|
|
111
114
|
// them (would spawn a phantom "<username>" board). Opt in with RELAY_SESSION/RELAY_PROJECT.
|
|
112
115
|
if (!process.env.RELAY_SESSION && !process.env.RELAY_PROJECT && projectDir === homedir()) return;
|
package/hooks/inbox-deliver.mjs
CHANGED
|
@@ -33,20 +33,28 @@ const FETCH_TIMEOUT_MS = Number(process.env.RELAY_INBOX_TIMEOUT_MS || 1500);
|
|
|
33
33
|
// additionalContext payload (the model still gets the readable message).
|
|
34
34
|
function sanitize(s) { return String(s == null ? "" : s).replace(/[\x00-\x1f\x7f-\x9f]/g, " "); }
|
|
35
35
|
|
|
36
|
-
async function getInbox(session, since, instance) {
|
|
37
|
-
const { ok, json } = await signedGet(`/inbox?session=${encodeURIComponent(session)}&since=${since}`, { timeoutMs: FETCH_TIMEOUT_MS, session, instance });
|
|
36
|
+
async function getInbox(session, since, instance, project) {
|
|
37
|
+
const { ok, json } = await signedGet(`/inbox?session=${encodeURIComponent(session)}&since=${since}`, { timeoutMs: FETCH_TIMEOUT_MS, session, instance, project });
|
|
38
38
|
if (!ok || !json) throw new Error("hub unreachable");
|
|
39
39
|
return json; // { messages: [...], cursor, superseded? }
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
// PostToolUse hands us the tool-input JSON on stdin
|
|
43
|
-
//
|
|
44
|
-
//
|
|
42
|
+
// PostToolUse hands us the tool-input JSON on stdin, and we MUST drain it: a large tool input
|
|
43
|
+
// (e.g. a big Write) can exceed the 64KB pipe buffer and block the parent's write if nobody reads.
|
|
44
|
+
// It used to drain into the void and resolve with NOTHING, so `main(stdinRaw)` always got
|
|
45
|
+
// undefined — which silently cost two things: `session_id` (so the per-instance cursor in
|
|
46
|
+
// docs/INSTANCE-KEYS-CONTRACT.md never actually keyed by instance) and `cwd` (so this hook could
|
|
47
|
+
// only ever guess its project from the process directory). Draining and KEEPING the bytes is the
|
|
48
|
+
// same protection, minus the amnesia.
|
|
45
49
|
function drainStdin() {
|
|
46
50
|
return new Promise(res => {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
51
|
+
let d = "";
|
|
52
|
+
try {
|
|
53
|
+
process.stdin.setEncoding("utf8"); process.stdin.resume();
|
|
54
|
+
process.stdin.on("data", c => (d += c));
|
|
55
|
+
process.stdin.on("end", () => res(d));
|
|
56
|
+
} catch { res(d); }
|
|
57
|
+
setTimeout(() => res(d), 80);
|
|
50
58
|
});
|
|
51
59
|
}
|
|
52
60
|
|
|
@@ -64,7 +72,10 @@ async function main(stdinRaw) {
|
|
|
64
72
|
// name, different session_id) has its own ledger and can't eat this session's messages.
|
|
65
73
|
let instanceId = "";
|
|
66
74
|
try { instanceId = String(JSON.parse(stdinRaw || "{}").session_id || ""); } catch {}
|
|
67
|
-
|
|
75
|
+
// input.cwd FIRST — every hook must derive the project the SAME way, or two hooks in one
|
|
76
|
+
// session resolve two projects, two hubs, and half the work records where nobody reads.
|
|
77
|
+
let _in = {}; try { _in = JSON.parse(stdinRaw || "{}"); } catch {}
|
|
78
|
+
const projectDir = _in.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
68
79
|
// Mirror heartbeat.mjs / sessionstart.mjs: a home-directory session isn't project work and
|
|
69
80
|
// isn't on the bus — nothing to deliver. Opt in with RELAY_SESSION / RELAY_PROJECT.
|
|
70
81
|
if (!process.env.RELAY_SESSION && !process.env.RELAY_PROJECT && projectDir === homedir()) return "{}";
|
|
@@ -94,7 +105,7 @@ async function main(stdinRaw) {
|
|
|
94
105
|
// so we start listening "from now" instead of replaying the whole backlog of old broadcasts.
|
|
95
106
|
if (!existsSync(cursorFile)) {
|
|
96
107
|
try {
|
|
97
|
-
const { cursor } = await getInbox(session, 0, instanceId);
|
|
108
|
+
const { cursor } = await getInbox(session, 0, instanceId, project);
|
|
98
109
|
writeFileSync(cursorFile, String(cursor || 0));
|
|
99
110
|
} catch {}
|
|
100
111
|
return "{}";
|
|
@@ -105,7 +116,7 @@ async function main(stdinRaw) {
|
|
|
105
116
|
|
|
106
117
|
let messages = [], next = cursor, superseded = false;
|
|
107
118
|
try {
|
|
108
|
-
const res = await getInbox(session, cursor, instanceId);
|
|
119
|
+
const res = await getInbox(session, cursor, instanceId, project);
|
|
109
120
|
messages = Array.isArray(res.messages) ? res.messages : [];
|
|
110
121
|
next = res.cursor || cursor;
|
|
111
122
|
superseded = res.superseded === true;
|
package/hooks/lib/api.mjs
CHANGED
|
@@ -49,6 +49,32 @@ export function sessionContext(projectDir) {
|
|
|
49
49
|
return { session, project, projectDir: dir };
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
// THE PROJECT A REQUEST IS ABOUT, which is not always the project the hook process is standing in.
|
|
53
|
+
//
|
|
54
|
+
// This distinction cost two diagnosis sessions. A hook stamps its payload with the project from
|
|
55
|
+
// Claude's session cwd (`input.cwd`), but the hub URL used to come from the HOOK PROCESS's cwd.
|
|
56
|
+
// Launch a session from ~/development and those disagree: every card says "crebral-health" and
|
|
57
|
+
// every one of them lands on the LOCAL hub, because "development" has no pin and falls through to
|
|
58
|
+
// the global default. Nothing errors, the seat looks healthy, and half the work records where
|
|
59
|
+
// nobody reads. So: the project travels WITH the request, explicit > payload > query > cwd.
|
|
60
|
+
function projectFromQuery(pathOrUrl) {
|
|
61
|
+
const m = String(pathOrUrl).match(/[?&]project=([^&]*)/);
|
|
62
|
+
try { return m ? decodeURIComponent(m[1]) : ""; } catch { return m ? m[1] : ""; }
|
|
63
|
+
}
|
|
64
|
+
function projectOf(explicit, payload, pathOrUrl) {
|
|
65
|
+
if (explicit) return explicit;
|
|
66
|
+
if (payload && typeof payload === "object" && payload.project) return String(payload.project);
|
|
67
|
+
return projectFromQuery(pathOrUrl);
|
|
68
|
+
}
|
|
69
|
+
// The signing identity for a request about `project`. An explicit RELAY_SESSION/RELAY_AGENT still
|
|
70
|
+
// wins (crew seats inherit them); otherwise the peer is named for the project being written, not
|
|
71
|
+
// for wherever the hook happens to be running.
|
|
72
|
+
function sessionFor(project) {
|
|
73
|
+
if (process.env.RELAY_SESSION) return process.env.RELAY_SESSION;
|
|
74
|
+
const p = project || sessionContext().project;
|
|
75
|
+
return process.env.RELAY_AGENT ? `${process.env.RELAY_AGENT}:${p}` : `${hostId()}:${p}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
52
78
|
// The keypair for a session name, memoised per process. loadOrCreate is itself idempotent + atomic,
|
|
53
79
|
// but a hook may sign several requests in one run — avoid re-reading the file each time.
|
|
54
80
|
const _idCache = new Map();
|
|
@@ -86,14 +112,19 @@ function enrolledPath(session) {
|
|
|
86
112
|
const busDir = process.env.AGENT_BUS_DIR || join(homedir(), ".agent-bus");
|
|
87
113
|
return join(busDir, "keys", `${String(session).replace(/[^A-Za-z0-9_.-]/g, "_")}.enrolled`);
|
|
88
114
|
}
|
|
89
|
-
export async function ensureEnrolled(session, identity) {
|
|
115
|
+
export async function ensureEnrolled(session, identity, project) {
|
|
90
116
|
if (!identity?.pubkey) return;
|
|
117
|
+
const hub = relayUrl(project);
|
|
91
118
|
const stamp = enrolledPath(session);
|
|
92
|
-
|
|
119
|
+
// The stamp records the HUB as well as the key. It used to record only the key, so a session
|
|
120
|
+
// enrolled on one hub was considered enrolled everywhere — and its first request to a second
|
|
121
|
+
// hub went out as an unknown identity.
|
|
122
|
+
const mark = `${identity.pubkey}\t${hub}`;
|
|
123
|
+
try { if (existsSync(stamp) && readFileSync(stamp, "utf8").trim() === mark) return; } catch {}
|
|
93
124
|
try {
|
|
94
125
|
// sfetchJson is the FROZEN single call-site shape (lib/signed-fetch.mjs): it stringifies the
|
|
95
126
|
// payload, sets content-type, and signs — so every hook signs identically with zero hand-rolling.
|
|
96
|
-
const r = await sfetchJson(`${
|
|
127
|
+
const r = await sfetchJson(`${hub}/enroll`, {
|
|
97
128
|
method: "POST",
|
|
98
129
|
payload: { pubkey: identity.pubkey, name: session, kind: "agent" },
|
|
99
130
|
identity,
|
|
@@ -101,7 +132,7 @@ export async function ensureEnrolled(session, identity) {
|
|
|
101
132
|
});
|
|
102
133
|
if (r.ok) {
|
|
103
134
|
try { mkdirSync(join((process.env.AGENT_BUS_DIR || join(homedir(), ".agent-bus")), "keys"), { recursive: true }); } catch {}
|
|
104
|
-
try { writeFileSync(stamp,
|
|
135
|
+
try { writeFileSync(stamp, mark, { mode: 0o600 }); } catch {}
|
|
105
136
|
}
|
|
106
137
|
} catch {}
|
|
107
138
|
}
|
|
@@ -109,8 +140,8 @@ export async function ensureEnrolled(session, identity) {
|
|
|
109
140
|
// Accept either a full URL (rare — a caller that already built one) or a hub-relative path. We do
|
|
110
141
|
// NOT fold the origin into the signature (signed-fetch signs path+query only), so a request proxied
|
|
111
142
|
// through a different host still verifies.
|
|
112
|
-
function toUrl(pathOrUrl) {
|
|
113
|
-
return /^https?:\/\//.test(pathOrUrl) ? pathOrUrl : `${relayUrl()}${pathOrUrl}`;
|
|
143
|
+
function toUrl(pathOrUrl, project) {
|
|
144
|
+
return /^https?:\/\//.test(pathOrUrl) ? pathOrUrl : `${relayUrl(project || projectFromQuery(pathOrUrl))}${pathOrUrl}`;
|
|
114
145
|
}
|
|
115
146
|
|
|
116
147
|
// Unsigned GET → { ok, status, json|null }. Never throws.
|
|
@@ -123,8 +154,8 @@ function toUrl(pathOrUrl) {
|
|
|
123
154
|
// /peers roster. So reads stay unsigned: accepted+flagged under the default `warn` mode, and the
|
|
124
155
|
// roster stays global. Flipping individual reads to signed later is a one-liner once a read needs
|
|
125
156
|
// enforce-mode attribution (add a signedGet that passes `identity` through sfetchJson with GET).
|
|
126
|
-
export async function getJSON(pathOrUrl, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
127
|
-
const url = toUrl(pathOrUrl);
|
|
157
|
+
export async function getJSON(pathOrUrl, { timeoutMs = DEFAULT_TIMEOUT_MS, project } = {}) {
|
|
158
|
+
const url = toUrl(pathOrUrl, projectOf(project, null, pathOrUrl));
|
|
128
159
|
try {
|
|
129
160
|
const r = await fetch(url, { method: "GET", signal: AbortSignal.timeout(timeoutMs) });
|
|
130
161
|
// parse the body on FAILURE too — a refusal's payload (denial note, queue guidance,
|
|
@@ -145,13 +176,14 @@ export async function getJSON(pathOrUrl, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}
|
|
|
145
176
|
// First user: the overseer-warn hook's /overseer/context — a project-scoped read, so the
|
|
146
177
|
// enforce hub's own-project scope filtering is the correct behavior, not a loss. Roster-style
|
|
147
178
|
// reads (/peers, /catchup cross-project discovery) stay on getJSON on purpose — see above.
|
|
148
|
-
export async function signedGet(pathOrUrl, { timeoutMs = DEFAULT_TIMEOUT_MS, session, instance } = {}) {
|
|
149
|
-
const
|
|
179
|
+
export async function signedGet(pathOrUrl, { timeoutMs = DEFAULT_TIMEOUT_MS, session, instance, project } = {}) {
|
|
180
|
+
const proj = projectOf(project, null, pathOrUrl);
|
|
181
|
+
const sess = session || sessionFor(proj);
|
|
150
182
|
const durable = loadIdentity(sess);
|
|
151
183
|
const id = instance ? loadInstance(sess, instance) : durable;
|
|
152
|
-
await ensureEnrolled(sess, durable);
|
|
184
|
+
await ensureEnrolled(sess, durable, proj); // instances never enroll — the DURABLE key does
|
|
153
185
|
try {
|
|
154
|
-
const r = await sfetchJson(toUrl(pathOrUrl), {
|
|
186
|
+
const r = await sfetchJson(toUrl(pathOrUrl, proj), {
|
|
155
187
|
method: "GET",
|
|
156
188
|
identity: id,
|
|
157
189
|
signal: AbortSignal.timeout(timeoutMs),
|
|
@@ -170,15 +202,16 @@ export async function signedGet(pathOrUrl, { timeoutMs = DEFAULT_TIMEOUT_MS, ses
|
|
|
170
202
|
}
|
|
171
203
|
|
|
172
204
|
// Signed POST → { ok, status, json|null }. Never throws.
|
|
173
|
-
export async function signedPost(pathOrUrl, payload, { timeoutMs = DEFAULT_TIMEOUT_MS, session, instance } = {}) {
|
|
174
|
-
const
|
|
205
|
+
export async function signedPost(pathOrUrl, payload, { timeoutMs = DEFAULT_TIMEOUT_MS, session, instance, project } = {}) {
|
|
206
|
+
const proj = projectOf(project, payload, pathOrUrl);
|
|
207
|
+
const sess = session || sessionFor(proj);
|
|
175
208
|
const durable = loadIdentity(sess);
|
|
176
209
|
const id = instance ? loadInstance(sess, instance) : durable;
|
|
177
|
-
await ensureEnrolled(sess, durable);
|
|
210
|
+
await ensureEnrolled(sess, durable, proj); // instances never enroll — the DURABLE key does
|
|
178
211
|
try {
|
|
179
212
|
// sfetchJson (FROZEN) stringifies the payload + signs with `id` in one call — the single shape
|
|
180
213
|
// every client uses (lib/signed-fetch.mjs). We pass our memoised identity so it doesn't re-load.
|
|
181
|
-
const r = await sfetchJson(toUrl(pathOrUrl), {
|
|
214
|
+
const r = await sfetchJson(toUrl(pathOrUrl, proj), {
|
|
182
215
|
method: "POST",
|
|
183
216
|
payload,
|
|
184
217
|
identity: id,
|
package/hooks/precompact.mjs
CHANGED
|
@@ -18,7 +18,9 @@ function readStdin() {
|
|
|
18
18
|
|
|
19
19
|
try {
|
|
20
20
|
const input = JSON.parse((await readStdin()) || "{}");
|
|
21
|
-
|
|
21
|
+
// input.cwd FIRST — every hook must derive the project the SAME way, or two hooks in one
|
|
22
|
+
// session resolve two projects, two hubs, and half the work records where nobody reads.
|
|
23
|
+
const projectDir = input.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
22
24
|
const projectName = basename(projectDir);
|
|
23
25
|
const transcript = input.transcript_path || "";
|
|
24
26
|
const trigger = input.trigger || "auto";
|
package/hooks/prompt-focus.mjs
CHANGED
|
@@ -3,11 +3,17 @@
|
|
|
3
3
|
// so a REGULAR (non-crew) Claude session's OWN work shows IN PROGRESS on the board as it happens — not only
|
|
4
4
|
// when it commits or dispatches a sub-agent. ONE rolling card per session (the hub re-titles it as the focus
|
|
5
5
|
// shifts and closes it to "done" when the session goes offline). Trivial acks ("yes", "go ahead") don't
|
|
6
|
-
// refocus. Fail-silent + fast: NO LLM call
|
|
7
|
-
//
|
|
6
|
+
// refocus. Fail-silent + fast: NO LLM call ON THE TURN PATH — the title is a heuristic clean of the
|
|
7
|
+
// prompt, posted immediately. A long prompt then hands its rewrite to bin/focus-title.mjs, spawned
|
|
8
|
+
// DETACHED so a cheap model can produce a readable board line a few seconds later without the user
|
|
9
|
+
// ever waiting on it. Never blocks or delays the turn.
|
|
8
10
|
import { homedir } from "node:os";
|
|
11
|
+
import { join, dirname } from "node:path";
|
|
12
|
+
import { writeFileSync, mkdirSync } from "node:fs";
|
|
13
|
+
import { spawn } from "node:child_process";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
9
15
|
import { resolveProject, hostId } from "../lib/project.mjs";
|
|
10
|
-
import { signedPost } from "./lib/api.mjs";
|
|
16
|
+
import { signedPost, relayUrl } from "./lib/api.mjs";
|
|
11
17
|
|
|
12
18
|
function readStdin() {
|
|
13
19
|
return new Promise(res => { let d = ""; process.stdin.setEncoding("utf8");
|
|
@@ -42,7 +48,29 @@ try {
|
|
|
42
48
|
const project = resolveProject(cwd);
|
|
43
49
|
const session = process.env.RELAY_SESSION
|
|
44
50
|
|| (process.env.RELAY_AGENT ? `${process.env.RELAY_AGENT}:${project}` : `${hostId()}:${project}`);
|
|
45
|
-
|
|
51
|
+
// The Claude Code session UUID. `session` above is a BUS id — per host+project — so without this
|
|
52
|
+
// two Claude sessions in one project share (and fight over) a single focus card, and sub-agent
|
|
53
|
+
// cards, whose `parent` is exactly this UUID, have nothing to nest under.
|
|
54
|
+
const cc = String(input.session_id || "").slice(0, 120);
|
|
55
|
+
const r = await signedPost("/focus", { session, project, title: titleFrom(trimmed), by: session, cc }, { session });
|
|
56
|
+
|
|
57
|
+
// Only pay a model when the heuristic actually mangles the prompt. A short, already-clear ask
|
|
58
|
+
// ("fix the login redirect") reads fine as-is and buying a rewrite for it is exactly the kind of
|
|
59
|
+
// reflexive spend the economics doctrine exists to stop.
|
|
60
|
+
const id = r?.json?.id;
|
|
61
|
+
if (id && trimmed.length > 90 && process.env.TRANTOR_NO_SCROOGE_TITLES !== "1") {
|
|
62
|
+
try {
|
|
63
|
+
const busDir = process.env.AGENT_BUS_DIR || join(homedir(), ".agent-bus");
|
|
64
|
+
mkdirSync(busDir, { recursive: true });
|
|
65
|
+
const pf = join(busDir, `focus-prompt-${String(cc || session).replace(/[^A-Za-z0-9_.-]/g, "_")}.txt`);
|
|
66
|
+
writeFileSync(pf, trimmed);
|
|
67
|
+
const worker = join(dirname(dirname(fileURLToPath(import.meta.url))), "bin", "focus-title.mjs");
|
|
68
|
+
// Detached + unref'd + stdio ignored: the hook returns NOW. Nothing downstream waits on this,
|
|
69
|
+
// and a worker that dies takes the heuristic title with it, which is a fine outcome.
|
|
70
|
+
spawn(process.execPath, [worker, "--id", String(id), "--hub", relayUrl(project), "--prompt-file", pf, "--project", project],
|
|
71
|
+
{ detached: true, stdio: "ignore" }).unref();
|
|
72
|
+
} catch {}
|
|
73
|
+
}
|
|
46
74
|
} catch (e) {
|
|
47
75
|
process.stderr.write(`[trantor] prompt-focus error: ${e?.message || e}\n`);
|
|
48
76
|
}
|
package/hooks/sessionstart.mjs
CHANGED
|
@@ -129,7 +129,10 @@ try {
|
|
|
129
129
|
let source = "", stdinObj = {};
|
|
130
130
|
try { stdinObj = JSON.parse((await readStdin()) || "{}"); source = stdinObj.source || ""; } catch {}
|
|
131
131
|
userTitle = (stdinObj && stdinObj.session_title) ? String(stdinObj.session_title) : ""; // user already named it
|
|
132
|
-
|
|
132
|
+
// input.cwd FIRST, matching hooks/prompt-focus.mjs. When they disagreed, two hooks in ONE
|
|
133
|
+
// session resolved two different projects — and therefore two different hubs — so half a
|
|
134
|
+
// session's work recorded on a hub nobody was reading.
|
|
135
|
+
const projectDir = stdinObj.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
133
136
|
// Sessions started in the home directory itself aren't project work — registering
|
|
134
137
|
// them spawns a phantom "<username>" project board on the dashboard. Set
|
|
135
138
|
// RELAY_SESSION (or RELAY_PROJECT) to deliberately put a home-dir session on the bus.
|
|
@@ -142,7 +145,9 @@ try {
|
|
|
142
145
|
sessionTitle = project; // baseline — enriched with the current work item after the catch-up fetch below
|
|
143
146
|
const session = process.env.RELAY_SESSION
|
|
144
147
|
|| (process.env.RELAY_AGENT ? `${process.env.RELAY_AGENT}:${project}` : `${hostId()}:${project}`);
|
|
145
|
-
|
|
148
|
+
// relayUrl() with no project resolves from the hook process's cwd, which is not always the
|
|
149
|
+
// project the session is about. Pass it, or every call below can address the wrong hub.
|
|
150
|
+
const url = relayUrl(project);
|
|
146
151
|
|
|
147
152
|
// register self + post an initial presence status (no LLM turn — instant for others to read)
|
|
148
153
|
await jpost(`${url}/register`, { session, project, status: `active in ${project}` }, session).catch(() => {});
|
package/hooks/stop-inbox.mjs
CHANGED
|
@@ -78,7 +78,7 @@ async function main() {
|
|
|
78
78
|
let messages = [];
|
|
79
79
|
try {
|
|
80
80
|
// PEEK: look without claiming delivery. We may yet decide to let the stop through.
|
|
81
|
-
const peek = await signedGet(`/inbox?session=${encodeURIComponent(session)}&since=${cursor}&peek=1`, { timeoutMs: FETCH_TIMEOUT_MS, session, instance: instanceId });
|
|
81
|
+
const peek = await signedGet(`/inbox?session=${encodeURIComponent(session)}&since=${cursor}&peek=1`, { timeoutMs: FETCH_TIMEOUT_MS, session, instance: instanceId, project });
|
|
82
82
|
if (!peek.ok) return allow();
|
|
83
83
|
// Superseded twin (instance-keys contract): a newer instance claimed the baton — this session
|
|
84
84
|
// stands down. Blocking ITS stop over messages the new instance will handle would trap it.
|
|
@@ -92,7 +92,7 @@ async function main() {
|
|
|
92
92
|
// Committed now: claim delivery for real so neither inbox-deliver nor the deferred waker repeats it.
|
|
93
93
|
let next = cursor;
|
|
94
94
|
try {
|
|
95
|
-
const claim = await signedGet(`/inbox?session=${encodeURIComponent(session)}&since=${cursor}`, { timeoutMs: FETCH_TIMEOUT_MS, session, instance: instanceId });
|
|
95
|
+
const claim = await signedGet(`/inbox?session=${encodeURIComponent(session)}&since=${cursor}`, { timeoutMs: FETCH_TIMEOUT_MS, session, instance: instanceId, project });
|
|
96
96
|
if (claim.ok) next = claim.json?.cursor || cursor;
|
|
97
97
|
} catch {}
|
|
98
98
|
try { writeFileSync(cursorFile, String(next)); } catch {}
|
package/hooks/subagent-start.mjs
CHANGED
|
@@ -53,8 +53,11 @@ try {
|
|
|
53
53
|
// with the SubagentStop "done" card on any client that predates agent_id pairing (legacy fallback).
|
|
54
54
|
const task = String(ti.prompt || ti.description || agentType).replace(/\s+/g, " ").trim().slice(0, 90);
|
|
55
55
|
const title = `${agentType}: ${task}`.slice(0, 180);
|
|
56
|
+
// parent at CREATE time too, not only on the SubagentStart enrich — a card that spends its
|
|
57
|
+
// whole doing-life unparented is un-nestable exactly while it is the interesting one.
|
|
56
58
|
await signedPost("/task", {
|
|
57
59
|
project, title, status: "doing", agentType,
|
|
60
|
+
parent: String(input.session_id || "").slice(0, 120),
|
|
58
61
|
assignee: `${agentType}:${project}`, by: `${hostId()}:${project}`,
|
|
59
62
|
source: "cc-subagent", costKind: "subagent-notional", phase: "sub-agents",
|
|
60
63
|
});
|
package/hooks/todo-sync.mjs
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
// so SOLO work (no crew fired up) shows up live and accrues timeline history. The hub reconciles by
|
|
4
4
|
// todo text (pending/in_progress/completed -> todo/doing/done). Fail-silent by contract: a bad hub,
|
|
5
5
|
// a home-dir session, or any error must never block or break the tool flow.
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { resolveProject, hostId } from "../lib/project.mjs";
|
|
8
8
|
import { signedPost } from "./lib/api.mjs";
|
|
9
9
|
function readStdin() {
|
|
10
10
|
return new Promise(res => { let d = ""; process.stdin.setEncoding("utf8");
|
|
@@ -13,21 +13,26 @@ function readStdin() {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
async function main() {
|
|
16
|
-
|
|
16
|
+
// stdin FIRST: input.cwd is the session's own directory and it decides both the project and the
|
|
17
|
+
// hub, so it has to be read before either is resolved.
|
|
18
|
+
let input = {};
|
|
19
|
+
try { input = JSON.parse((await readStdin()) || "{}"); } catch { return; }
|
|
20
|
+
const projectDir = input.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
17
21
|
// Mirror sessionstart/heartbeat: home-directory sessions aren't project work — don't card them
|
|
18
22
|
// (would spawn a phantom "<username>" board). Opt in with RELAY_SESSION/RELAY_PROJECT.
|
|
19
23
|
if (!process.env.RELAY_SESSION && !process.env.RELAY_PROJECT && projectDir === homedir()) return;
|
|
20
|
-
|
|
21
|
-
let input = {};
|
|
22
|
-
try { input = JSON.parse((await readStdin()) || "{}"); } catch { return; }
|
|
23
24
|
if (input.tool_name && input.tool_name !== "TodoWrite") return; // the matcher should scope us, but be safe
|
|
24
25
|
const todos = input.tool_input?.todos;
|
|
25
26
|
if (!Array.isArray(todos) || !todos.length) return;
|
|
26
27
|
|
|
27
28
|
// Identity EXACTLY as mcp.mjs/heartbeat resolve it, so we card the same peer the relay registered.
|
|
28
|
-
|
|
29
|
+
// This said `basename(projectDir)` and `hostname()`, which is not the same thing at all:
|
|
30
|
+
// resolveProject keys off the GIT ROOT (a subdirectory forked its own lane) and hostId is the
|
|
31
|
+
// stable machine id, where hostname() drifts to "MacBook-Pro-M1.local" on some networks and
|
|
32
|
+
// splits one machine into two peers.
|
|
33
|
+
const project = resolveProject(projectDir);
|
|
29
34
|
const session = process.env.RELAY_SESSION
|
|
30
|
-
|| (process.env.RELAY_AGENT ? `${process.env.RELAY_AGENT}:${project}` : `${
|
|
35
|
+
|| (process.env.RELAY_AGENT ? `${process.env.RELAY_AGENT}:${project}` : `${hostId()}:${project}`);
|
|
31
36
|
|
|
32
37
|
await signedPost("/todos", { session, project, by: session, todos: todos.map(t => ({ content: t.content, status: t.status })) });
|
|
33
38
|
}
|
package/hub.mjs
CHANGED
|
@@ -36,6 +36,11 @@ const PEER_TTL_MS = Math.max(Number.isFinite(_peerTtlRaw) ? _peerTtlRaw : PEER_T
|
|
|
36
36
|
// long-running task is never touched — the owner-alive-but-idle case is handled by the manual /sweep path.
|
|
37
37
|
const REAP_GRACE_MS = Number(process.env.RELAY_REAP_GRACE_MS || 15 * 60 * 1000); // 15m offline + untouched
|
|
38
38
|
const FOCUS_OFFLINE_MS = Number(process.env.RELAY_FOCUS_OFFLINE_MS || ONLINE_MS); // close a focus card once its session is offline (not the old 6h)
|
|
39
|
+
// Backstop for the case the peer heartbeat cannot see: several Claude sessions share ONE bus
|
|
40
|
+
// identity (it is per host+project), so a sibling that is still alive keeps the whole assignee
|
|
41
|
+
// "online" and a dead session's focus card would hang in `doing` forever. Long, because a card
|
|
42
|
+
// untouched for an hour is routine — a big task runs a long time between prompts.
|
|
43
|
+
const FOCUS_IDLE_MS = Number(process.env.RELAY_FOCUS_IDLE_MS || 6 * 60 * 60 * 1000);
|
|
39
44
|
const REAP_INTERVAL_MS = Number(process.env.RELAY_REAP_INTERVAL_MS || 60000); // how often the reaper sweeps (env-tunable; tests set it low)
|
|
40
45
|
if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
|
|
41
46
|
const isLoopbackHost = (host) => {
|
|
@@ -429,15 +434,49 @@ backfillCardEvents();
|
|
|
429
434
|
// A session's live "focus" card (source:"session") tracks what a REGULAR session is working on RIGHT NOW
|
|
430
435
|
// (set from each user prompt by hooks/prompt-focus.mjs). When the session ends (pruned offline), close its
|
|
431
436
|
// open focus card to "done" so the board doesn't keep a dead session "in progress" forever.
|
|
432
|
-
function
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
(t.history ||= []).push({ from: t.status, to: "done", by: session, ts: now() });
|
|
437
|
+
function closeFocusCard(t, by) {
|
|
438
|
+
if (!t || t.status === "done") return false;
|
|
439
|
+
(t.history ||= []).push({ from: t.status, to: "done", by, ts: now() });
|
|
436
440
|
if (t.history.length > 60) t.history.splice(0, 20);
|
|
437
|
-
appendCardEvent("moved", t,
|
|
441
|
+
appendCardEvent("moved", t, by, t.status, "done");
|
|
438
442
|
t.status = "done"; t.updated = now();
|
|
439
443
|
return true;
|
|
440
444
|
}
|
|
445
|
+
// A bus session id is per (host, project), so ONE assignee can own SEVERAL open focus cards — one
|
|
446
|
+
// per live Claude session in that project. Close every one of them when the peer goes away; the
|
|
447
|
+
// old single-card `find` left the rest sitting in `doing` forever.
|
|
448
|
+
function closeFocus(session) {
|
|
449
|
+
let closed = false;
|
|
450
|
+
for (const t of state.tasks) {
|
|
451
|
+
if (t.source === "session" && t.assignee === session && t.status !== "done") closed = closeFocusCard(t, session) || closed;
|
|
452
|
+
}
|
|
453
|
+
return closed;
|
|
454
|
+
}
|
|
455
|
+
// A git card and a focus card meet on the same bus id (`${hostId()}:${project}`), which is what the
|
|
456
|
+
// backfill posts as its assignee. One bus id can own several open focus cards now, so close the
|
|
457
|
+
// most recently active one — the session that just committed is the one that most recently spoke.
|
|
458
|
+
const COMMIT_FOCUS_WINDOW_MS = Number(process.env.RELAY_COMMIT_FOCUS_MS || 10 * 60 * 1000);
|
|
459
|
+
function linkCommitToFocus(commitCard, by) {
|
|
460
|
+
// A HISTORICAL backfill (`--since "14 days ago"`) posts dozens of old commits at once and must
|
|
461
|
+
// never close whatever a session happens to be doing today. Only a fresh commit closes a focus.
|
|
462
|
+
if (Math.abs(now() - (commitCard.ts || 0)) > COMMIT_FOCUS_WINDOW_MS) return false;
|
|
463
|
+
const owner = commitCard.assignee || by || "";
|
|
464
|
+
if (!owner) return false;
|
|
465
|
+
const proj = canon(commitCard.project || "");
|
|
466
|
+
const open = state.tasks
|
|
467
|
+
.filter(x => x.source === "session" && x.status !== "done" && x.assignee === owner && canon(x.project) === proj)
|
|
468
|
+
.sort((a, b2) => (b2.updated || 0) - (a.updated || 0));
|
|
469
|
+
const focus = open[0];
|
|
470
|
+
if (!focus) return false;
|
|
471
|
+
focus.commitCard = commitCard.id; // the two halves point at each other, so the
|
|
472
|
+
commitCard.focusCard = focus.id; // card drawer can walk either way
|
|
473
|
+
(focus.history ||= []).push({ from: focus.status, to: "done", by: owner, ts: now(), note: `closed by commit — ${String(commitCard.title || "").slice(0, 80)}` });
|
|
474
|
+
if (focus.history.length > 60) focus.history.splice(0, 20);
|
|
475
|
+
appendCardEvent("moved", focus, owner, focus.status, "done");
|
|
476
|
+
focus.status = "done"; focus.updated = now();
|
|
477
|
+
appendEvent("focus", focus.project, owner, { taskId: focus.id, closedBy: commitCard.id, reason: "commit" });
|
|
478
|
+
return true;
|
|
479
|
+
}
|
|
441
480
|
function prunePeers() {
|
|
442
481
|
const cutoff = now() - PEER_TTL_MS;
|
|
443
482
|
let removed = false;
|
|
@@ -469,13 +508,18 @@ function cardOwnerOnline(t, cutoff) {
|
|
|
469
508
|
function reapStaleCards() {
|
|
470
509
|
const onCut = now() - ONLINE_MS;
|
|
471
510
|
const focusCut = now() - FOCUS_OFFLINE_MS;
|
|
511
|
+
const idleCut = now() - FOCUS_IDLE_MS;
|
|
472
512
|
const graceCut = now() - REAP_GRACE_MS;
|
|
473
513
|
let changed = false;
|
|
474
514
|
for (const t of state.tasks) {
|
|
475
515
|
if (t.status === "done" || t.status === "stale") continue;
|
|
476
516
|
if (t.source === "session") { // (a) focus cards → done when session offline
|
|
477
517
|
const p = state.peers[t.assignee];
|
|
478
|
-
|
|
518
|
+
const peerGone = !p || (p.lastSeen || 0) < focusCut;
|
|
519
|
+
// …or when THIS card has gone quiet for a very long time, which is the only signal available
|
|
520
|
+
// for a dead Claude session whose bus identity a living sibling keeps warm.
|
|
521
|
+
const longIdle = (t.updated || t.ts || 0) < idleCut;
|
|
522
|
+
if (peerGone || longIdle) { if (closeFocusCard(t, peerGone ? t.assignee : "reaper")) changed = true; }
|
|
479
523
|
continue;
|
|
480
524
|
}
|
|
481
525
|
if ((t.status === "doing" || t.status === "testing") // (b) offline-owner work cards → stale
|
|
@@ -1459,6 +1503,11 @@ const server = http.createServer(async (req, res) => {
|
|
|
1459
1503
|
if (b.source === "cc-subagent") { t._fp = subFp(b.title); if (b.agentType) t._atype = String(b.agentType).slice(0, 40); if (b.agentId) t._aid = String(b.agentId).slice(0, 80); if (b.parent) t.parent = String(b.parent).slice(0, 120); t.count = 1; if (t.status === "doing") { t._everStarted = true; t._inflight = 1; } }
|
|
1460
1504
|
state.tasks.push(t); if (state.tasks.length > 2000) state.tasks.splice(0, 500);
|
|
1461
1505
|
appendCardEvent("created", t, b.by, null, st0);
|
|
1506
|
+
// A COMMIT closes the focus. A focus card says "this session is working on X right now"; the
|
|
1507
|
+
// commit is X arriving, so the card that was rolling forever now completes with the commit
|
|
1508
|
+
// attached to it — the board finally shows a finished unit of work instead of an open card
|
|
1509
|
+
// whose title keeps changing. The next prompt opens a fresh one.
|
|
1510
|
+
if (b.source === "git" && st0 === "done") linkCommitToFocus(t, b.by);
|
|
1462
1511
|
dirty = true; return json(res, 200, { ok: true, task: t });
|
|
1463
1512
|
}
|
|
1464
1513
|
if (req.method === "POST" && P === "/task/update") { // move/edit a card
|
|
@@ -1556,20 +1605,38 @@ const server = http.createServer(async (req, res) => {
|
|
|
1556
1605
|
const session = String(b.session || b.by || "").slice(0, 120);
|
|
1557
1606
|
const project = canon(String(b.project || "").slice(0, 80));
|
|
1558
1607
|
const title = String(b.title || "").replace(/\s+/g, " ").trim().slice(0, 200);
|
|
1608
|
+
// `cc` = the Claude Code session UUID (hooks/prompt-focus.mjs passes session_id). It is the
|
|
1609
|
+
// ONLY per-session key on the board: `assignee` is a bus id, which is per host+project, so
|
|
1610
|
+
// two Claude sessions in one project used to fight over a single rolling card — and every
|
|
1611
|
+
// sub-agent card, whose `parent` is that same UUID, had nothing to join to (measured over
|
|
1612
|
+
// 431 live cards, joining on the bus id resolved 0 of them). With `cc` stored here, a
|
|
1613
|
+
// sub-agent nests under the session that actually spawned it.
|
|
1614
|
+
const cc = String(b.cc || "").slice(0, 120);
|
|
1559
1615
|
if (!session || !project || !title) return json(res, 400, { error: "session, project, title required" });
|
|
1560
1616
|
touch(session, undefined, project, undefined, auth);
|
|
1561
|
-
|
|
1617
|
+
// Match on cc when the client sends one — but never let a cc-bearing prompt adopt a card
|
|
1618
|
+
// from a DIFFERENT session. A client too old to send cc keeps the original assignee match.
|
|
1619
|
+
let t = cc
|
|
1620
|
+
? state.tasks.find(x => x.source === "session" && x.cc === cc && canon(x.project) === project && x.status !== "done")
|
|
1621
|
+
|| state.tasks.find(x => x.source === "session" && !x.cc && x.assignee === session && canon(x.project) === project && x.status !== "done")
|
|
1622
|
+
: state.tasks.find(x => x.source === "session" && x.assignee === session && canon(x.project) === project && x.status !== "done");
|
|
1562
1623
|
if (t) {
|
|
1563
1624
|
if (t.title !== title) { // refocus: re-title in place + record the shift (keeps the trail)
|
|
1564
1625
|
(t.history ||= []).push({ from: t.status, to: t.status, by: session, ts: now(), note: title.slice(0, 90) });
|
|
1565
1626
|
if (t.history.length > 60) t.history.splice(0, 20);
|
|
1627
|
+
// A rolling card's narrative describes the OLD focus. The board renders `summary ||
|
|
1628
|
+
// title`, so leaving it would let a stale one-liner shadow what the session is doing
|
|
1629
|
+
// right now — the summarizer (or bin/focus-title.mjs) writes a fresh one.
|
|
1630
|
+
if (t.summary) t.summary = "";
|
|
1566
1631
|
t.title = title; appendCardEvent("updated", t, session, null, null);
|
|
1567
1632
|
appendEvent("focus", project, session, { taskId: t.id, title, shift: true });
|
|
1568
1633
|
}
|
|
1634
|
+
if (cc && !t.cc) t.cc = cc; // an in-flight card from an older client gets its key
|
|
1569
1635
|
t.status = "doing"; t.updated = now();
|
|
1570
1636
|
} else {
|
|
1571
1637
|
t = { id: ++state.taskSeq, project, title, assignee: session, status: "doing", source: "session",
|
|
1572
1638
|
difficulty: "", model: "", deps: [], by: session, ts: now(), updated: now(),
|
|
1639
|
+
cc: cc || undefined,
|
|
1573
1640
|
history: [{ to: "doing", by: session, ts: now() }] };
|
|
1574
1641
|
state.tasks.push(t); appendCardEvent("created", t, session, null, "doing");
|
|
1575
1642
|
appendEvent("focus", project, session, { taskId: t.id, title, shift: false });
|
|
@@ -1579,7 +1646,11 @@ const server = http.createServer(async (req, res) => {
|
|
|
1579
1646
|
}
|
|
1580
1647
|
if (req.method === "GET" && P === "/focus") { // the session's open focus card (for sub-agent nesting)
|
|
1581
1648
|
const session = String(q.session || "");
|
|
1582
|
-
const
|
|
1649
|
+
const cc = String(q.cc || "");
|
|
1650
|
+
// ?cc= is the precise lookup (one Claude session); ?session= stays the coarse one.
|
|
1651
|
+
const t = cc
|
|
1652
|
+
? state.tasks.find(x => x.source === "session" && x.cc === cc && x.status !== "done")
|
|
1653
|
+
: state.tasks.find(x => x.source === "session" && x.assignee === session && x.status !== "done");
|
|
1583
1654
|
if (t && !canRead(auth, t.project || "")) return json(res, 404, { id: null, task: null });
|
|
1584
1655
|
return json(res, 200, { id: t ? t.id : null, task: t || null });
|
|
1585
1656
|
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// trantor — hub split-brain detection.
|
|
2
|
+
//
|
|
3
|
+
// A project lives on exactly ONE hub (TDD §12.1). When that stops being true nothing errors: the
|
|
4
|
+
// crew registers on hub A, the orchestrator and the board read hub B, every seat reports healthy,
|
|
5
|
+
// and the work simply lands somewhere nobody is looking. It cost two full diagnosis sessions in
|
|
6
|
+
// crebral-health (2026-08-14 and again 2026-08-18) before anyone thought to compare the two hubs.
|
|
7
|
+
// Nothing on the machine was wrong; the two halves just never met.
|
|
8
|
+
//
|
|
9
|
+
// So detection is a cross-check, not a health check: probe every hub this machine knows about, ask
|
|
10
|
+
// each one who is LIVE on it, and compare that against the pins in ~/.agent-bus/config.json.
|
|
11
|
+
//
|
|
12
|
+
// THE TRAP THIS LIB EXISTS TO AVOID: an unsigned read of an enforce hub answers
|
|
13
|
+
// `{"error":"signature required"}` with HTTP 401, and `(body.peers || [])` turns that refusal into
|
|
14
|
+
// a confident empty list — a hub full of agents reads as deserted. That nearly got called a fleet
|
|
15
|
+
// outage. probeHub() therefore reports a REASON, never a silent empty roster, and analyze() carries
|
|
16
|
+
// unreadable hubs through to the caller as `blind` so a partial answer can never be printed as a
|
|
17
|
+
// clean bill of health.
|
|
18
|
+
import { sfetch } from "./signed-fetch.mjs";
|
|
19
|
+
|
|
20
|
+
/** Compare hub URLs the way a human would: trailing slash and loopback spelling are not identity. */
|
|
21
|
+
export function normalizeHub(url) {
|
|
22
|
+
let u = String(url || "").trim().replace(/\/+$/, "");
|
|
23
|
+
return u.replace(/^http:\/\/localhost(?=[:/]|$)/i, "http://127.0.0.1");
|
|
24
|
+
}
|
|
25
|
+
export const isLocalHub = (url) => /^https?:\/\/(127\.0\.0\.1|localhost|\[::1\])(?=[:/]|$)/i.test(String(url || ""));
|
|
26
|
+
|
|
27
|
+
/** Every hub this machine could be talking to: the global default, and every per-project pin. */
|
|
28
|
+
export function hubsFromConfig(config = {}, defaultUrl = "http://127.0.0.1:4477") {
|
|
29
|
+
const seen = new Map();
|
|
30
|
+
const add = (url, source) => {
|
|
31
|
+
const u = normalizeHub(url);
|
|
32
|
+
if (!/^https?:\/\//.test(u)) return;
|
|
33
|
+
if (!seen.has(u)) seen.set(u, { url: u, sources: [] });
|
|
34
|
+
seen.get(u).sources.push(source);
|
|
35
|
+
};
|
|
36
|
+
add(config.url || defaultUrl, "config.url");
|
|
37
|
+
for (const [project, url] of Object.entries(config.hubs || {})) add(url, `pin:${project}`);
|
|
38
|
+
return [...seen.values()];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Ask one hub who is live on it. Never conflates "refused us" with "nobody home". */
|
|
42
|
+
export async function probeHub(url, identity, { timeoutMs = 6000, fetchImpl = null } = {}) {
|
|
43
|
+
const u = normalizeHub(url);
|
|
44
|
+
const base = { url: u, ok: false, reason: "", authMode: "", hubVersion: "", peers: [] };
|
|
45
|
+
let res, text;
|
|
46
|
+
try {
|
|
47
|
+
const doFetch = fetchImpl || ((p, o, id) => sfetch(p, o, id));
|
|
48
|
+
res = await doFetch(`${u}/peers`, { signal: AbortSignal.timeout(timeoutMs) }, identity);
|
|
49
|
+
text = await res.text();
|
|
50
|
+
} catch (e) {
|
|
51
|
+
return { ...base, reason: `unreachable (${e?.message || e})` };
|
|
52
|
+
}
|
|
53
|
+
let body;
|
|
54
|
+
try { body = JSON.parse(text); } catch { return { ...base, reason: `unreadable response (HTTP ${res.status})` }; }
|
|
55
|
+
// The refusal case, spelled out. Both shapes matter: an enforce hub 401s with {error}, and a
|
|
56
|
+
// scope-limited identity can get a 200 whose roster is filtered — only the first is a blind spot.
|
|
57
|
+
if (!res.ok || body?.error) {
|
|
58
|
+
const why = body?.error || `HTTP ${res.status}`;
|
|
59
|
+
return { ...base, reason: /signature|unauthor|forbidden|401|403/i.test(why) ? `not authorized (${why})` : `refused (${why})` };
|
|
60
|
+
}
|
|
61
|
+
if (!Array.isArray(body?.peers)) return { ...base, reason: "no peer roster in the response" };
|
|
62
|
+
return { url: u, ok: true, reason: "", authMode: body.authMode || "", hubVersion: body.hubVersion || "",
|
|
63
|
+
peers: body.peers.map(p => ({ session: p.session, project: p.project || "", online: !!p.online, lastSeen: p.lastSeen || 0, llm: p.llm || "" })) };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Cross-check live presence against the pins.
|
|
68
|
+
*
|
|
69
|
+
* @param probes probeHub() results, one per hub
|
|
70
|
+
* @param config the raw ~/.agent-bus/config.json
|
|
71
|
+
* @returns {{findings: Array, blind: Array, checked: number}}
|
|
72
|
+
*/
|
|
73
|
+
export function analyze(probes = [], config = {}, defaultUrl = "http://127.0.0.1:4477") {
|
|
74
|
+
const pins = Object.fromEntries(Object.entries(config.hubs || {}).map(([p, u]) => [p, normalizeHub(u)]));
|
|
75
|
+
const fallback = normalizeHub(config.url || defaultUrl);
|
|
76
|
+
const readable = probes.filter(p => p.ok);
|
|
77
|
+
const blind = probes.filter(p => !p.ok).map(p => ({ url: p.url, reason: p.reason }));
|
|
78
|
+
|
|
79
|
+
// project -> hubs it is LIVE on
|
|
80
|
+
const liveOn = new Map();
|
|
81
|
+
for (const probe of readable) {
|
|
82
|
+
for (const peer of probe.peers) {
|
|
83
|
+
if (!peer.online || !peer.project) continue;
|
|
84
|
+
if (!liveOn.has(peer.project)) liveOn.set(peer.project, new Map());
|
|
85
|
+
const byHub = liveOn.get(peer.project);
|
|
86
|
+
if (!byHub.has(probe.url)) byHub.set(probe.url, []);
|
|
87
|
+
byHub.get(probe.url).push(peer.session);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const findings = [];
|
|
92
|
+
for (const [project, byHub] of [...liveOn.entries()].sort()) {
|
|
93
|
+
const hubs = [...byHub.keys()];
|
|
94
|
+
const pin = pins[project] || "";
|
|
95
|
+
const expected = pin || fallback;
|
|
96
|
+
if (hubs.length > 1) {
|
|
97
|
+
findings.push({ kind: "split", project, severity: "critical", hubs, expected,
|
|
98
|
+
sessions: Object.fromEntries([...byHub].map(([h, s]) => [h, s])),
|
|
99
|
+
message: `${project} has LIVE sessions on ${hubs.length} hubs at once — ${hubs.map(h => `${h} (${byHub.get(h).join(", ")})`).join(" and ")}. They cannot see each other's cards or messages.`,
|
|
100
|
+
fix: pin
|
|
101
|
+
? `everything must be on ${pin}: restart the sessions on the other hub (crew: trantor down && trantor up · Claude sessions: restart them)`
|
|
102
|
+
: `pin it first — trantor hub set ${project} <url> — then restart every session so they all follow the pin` });
|
|
103
|
+
} else if (pin && hubs[0] !== pin) {
|
|
104
|
+
findings.push({ kind: "off-pin", project, severity: "critical", hubs, expected: pin,
|
|
105
|
+
sessions: Object.fromEntries([...byHub].map(([h, s]) => [h, s])),
|
|
106
|
+
message: `${project} is pinned to ${pin} but its live sessions (${byHub.get(hubs[0]).join(", ")}) are on ${hubs[0]} — their work records where nobody is reading.`,
|
|
107
|
+
fix: `restart them so they pick up the pin (crew: trantor down && trantor up · Claude sessions: restart them)` });
|
|
108
|
+
} else if (!pin && !isLocalHub(hubs[0])) {
|
|
109
|
+
findings.push({ kind: "unpinned-remote", project, severity: "warn", hubs, expected: fallback,
|
|
110
|
+
sessions: Object.fromEntries([...byHub].map(([h, s]) => [h, s])),
|
|
111
|
+
message: `${project} is live on ${hubs[0]} but has no pin — the next session started here falls back to ${fallback} and splits the project in two.`,
|
|
112
|
+
fix: `trantor hub set ${project} ${hubs[0]}` });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// A pin aimed at a hub we could not read is its own fault: sessions will route there and go quiet.
|
|
116
|
+
for (const [project, url] of Object.entries(pins)) {
|
|
117
|
+
const dead = blind.find(b => b.url === url);
|
|
118
|
+
if (dead) findings.push({ kind: "pin-unreachable", project, severity: "critical", hubs: [], expected: url,
|
|
119
|
+
message: `${project} is pinned to ${url}, which this machine cannot read (${dead.reason}) — sessions on it will look alive and record nothing here.`,
|
|
120
|
+
fix: `check the hub is up and this machine is enrolled on it, or re-pin: trantor hub set ${project} <url>` });
|
|
121
|
+
}
|
|
122
|
+
return { findings, blind, checked: readable.length };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Probe + analyze in one call. Hubs are probed concurrently — a dead one must not stall the rest. */
|
|
126
|
+
export async function scan(config = {}, identity, { defaultUrl = "http://127.0.0.1:4477", timeoutMs = 6000 } = {}) {
|
|
127
|
+
const hubs = hubsFromConfig(config, defaultUrl);
|
|
128
|
+
const probes = await Promise.all(hubs.map(h => probeHub(h.url, identity, { timeoutMs })));
|
|
129
|
+
return { ...analyze(probes, config, defaultUrl), probes };
|
|
130
|
+
}
|
package/mcp.mjs
CHANGED
|
@@ -99,10 +99,12 @@ async function seedCursor() {
|
|
|
99
99
|
// mints a random id at boot — its lifetime ≈ the session's. The endorsed subkey it keys signs all
|
|
100
100
|
// traffic; the durable identity keeps enrollment and attribution.
|
|
101
101
|
const INSTANCE_ID = `mcp-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
102
|
-
async function api(method, path, payload) {
|
|
102
|
+
async function api(method, path, payload, { timeoutMs } = {}) {
|
|
103
|
+
// PROJECT explicitly, never the client's cwd fallback: this server's project is fixed at boot,
|
|
104
|
+
// and letting the hub be re-derived per call is how a session ends up writing to two hubs.
|
|
103
105
|
const r = method.toUpperCase() === "GET"
|
|
104
|
-
? await signedGet(path, { session: SESSION, instance: INSTANCE_ID })
|
|
105
|
-
: await signedPost(path, payload, { session: SESSION, instance: INSTANCE_ID });
|
|
106
|
+
? await signedGet(path, { session: SESSION, instance: INSTANCE_ID, project: PROJECT, timeoutMs })
|
|
107
|
+
: await signedPost(path, payload, { session: SESSION, instance: INSTANCE_ID, project: PROJECT, timeoutMs });
|
|
106
108
|
if (!r.ok) throw new Error(`hub ${r.status} on ${path}`);
|
|
107
109
|
return r.json;
|
|
108
110
|
}
|
|
@@ -310,7 +312,11 @@ server.tool("relay_wait", "Block up to `timeout` seconds waiting for the next me
|
|
|
310
312
|
// resolves inline on every current client instead of being shipped to the background.
|
|
311
313
|
const w = Math.min(timeout ?? 25, 110);
|
|
312
314
|
await seedCursor();
|
|
313
|
-
|
|
315
|
+
// The client-side deadline must OUTLIVE the hub's hold. Since reads moved onto the shared
|
|
316
|
+
// signed client, this call inherited its 1.5s default — so every long-poll that had no message
|
|
317
|
+
// already waiting was aborted at 1.5s and surfaced as "hub 0 on /poll". Parking was dead: the
|
|
318
|
+
// tool erred on every quiet wait and only ever "worked" when a message beat the abort.
|
|
319
|
+
const { messages, cursor: c } = await api("GET", `/poll?session=${encodeURIComponent(SESSION)}&since=${cursor}&wait=${w}`, undefined, { timeoutMs: (w + 15) * 1000 });
|
|
314
320
|
cursor = c;
|
|
315
321
|
return { content: [{ type: "text", text: messages.length ? messages.map(fmt).join("\n") : "(timed out, no message)" }] };
|
|
316
322
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.72",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"trantor": "bin/cli.mjs"
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"pg": "^8.22.0"
|
|
12
12
|
},
|
|
13
13
|
"scripts": {
|
|
14
|
-
"test": "node test.mjs && node test-scenarios.mjs && node test-failure.mjs && node test-handoff.mjs && node test-agents.mjs && node test-update.mjs && node test-handoff-guard.mjs && node test-balances.mjs && node test-subagent-cost.mjs && node test-inbox.mjs && node test-inflight.mjs && node test-notify.mjs && node test-focus.mjs && node test-reaper.mjs && node test-events.mjs && node test-proposals.mjs && node test-scrub.mjs && node test-store-delta.mjs && node test-claims.mjs && node test-adopt.mjs && node test-summarize.mjs && node test-identity-core.mjs && node test-identity.mjs && node test-identity-instances.mjs && node test-duty.mjs && node test-discovery.mjs && node test-doctor.mjs && node test-overseer.mjs && node test-overseer-lib.mjs && node test-overseer-warn.mjs && node test-provider-keys.mjs && node test-inbox-delivery.mjs && node test-hub-routing.mjs && python3 engine/test-routing.py && node test-reconcile.mjs && node test-resources.mjs && node test-patrol.mjs && node test-bridge.mjs && bash test-crew.sh"
|
|
14
|
+
"test": "node test.mjs && node test-scenarios.mjs && node test-failure.mjs && node test-redelivery.mjs && node test-handoff.mjs && node test-agents.mjs && node test-update.mjs && node test-handoff-guard.mjs && node test-balances.mjs && node test-subagent-cost.mjs && node test-inbox.mjs && node test-inflight.mjs && node test-notify.mjs && node test-focus.mjs && node test-reaper.mjs && node test-events.mjs && node test-proposals.mjs && node test-scrub.mjs && node test-store-delta.mjs && node test-claims.mjs && node test-adopt.mjs && node test-summarize.mjs && node test-identity-core.mjs && node test-identity.mjs && node test-identity-instances.mjs && node test-duty.mjs && node test-discovery.mjs && node test-doctor.mjs && node test-splitbrain.mjs && node test-overseer.mjs && node test-overseer-lib.mjs && node test-overseer-warn.mjs && node test-provider-keys.mjs && node test-inbox-delivery.mjs && node test-hub-routing.mjs && node test-hook-routing.mjs && node test-relay-wait.mjs && python3 engine/test-routing.py && node test-reconcile.mjs && node test-resources.mjs && node test-patrol.mjs && node test-bridge.mjs && bash test-crew.sh"
|
|
15
15
|
},
|
|
16
16
|
"description": "The hub-world for AI agent crews \u2014 orchestrate Claude Code, Codex, GLM, Kimi, DeepSeek & any OpenRouter model as live crews with a plan-aware Advisor, a Kanban/flow command center, a testing gate, and an economics brain (Scrooge).",
|
|
17
17
|
"files": [
|
|
@@ -54,4 +54,4 @@
|
|
|
54
54
|
"engines": {
|
|
55
55
|
"node": ">=18"
|
|
56
56
|
}
|
|
57
|
-
}
|
|
57
|
+
}
|