trantor 0.17.55 → 0.17.56
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/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/bin/cli.mjs +4 -0
- package/bin/inbox.mjs +64 -0
- package/bin/overseer-narrate.mjs +77 -0
- package/bin/policy.mjs +74 -0
- package/bin/summarize.mjs +6 -1
- package/hooks/hooks.json +5 -1
- package/hooks/lib/api.mjs +22 -0
- package/hooks/overseer-warn.mjs +84 -0
- package/hub.mjs +121 -5
- package/lib/overseer.mjs +140 -0
- package/mcp.mjs +9 -7
- package/package.json +1 -1
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"name": "trantor",
|
|
14
14
|
"source": "./",
|
|
15
15
|
"description": "The hub-world for AI agent crews. Say \"fire up the crew\" and Claude becomes the architect: a plan-aware Advisor routes the work (solo / cheap inline calls / live crew of Codex, GLM, Kimi & DeepSeek in their own terminal windows), a Kanban/flow command center with a testing gate tracks it, and an economics brain (Scrooge) keeps the receipts. Includes the relay MCP, a SessionStart auto-discovery hook, and a PreCompact context-handoff so a fresh session can take over a full window instead of compacting.",
|
|
16
|
-
"version": "0.17.
|
|
16
|
+
"version": "0.17.56",
|
|
17
17
|
"author": {
|
|
18
18
|
"name": "Sasha Bogojevic"
|
|
19
19
|
},
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.56",
|
|
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/cli.mjs
CHANGED
|
@@ -68,6 +68,8 @@ switch (cmd) {
|
|
|
68
68
|
case "handoff": run("bin/baton.mjs"); break;
|
|
69
69
|
case "adopt": run("bin/adopt.mjs"); break;
|
|
70
70
|
case "summarize": run("bin/summarize.mjs"); break;
|
|
71
|
+
case "policy": run("bin/policy.mjs"); break;
|
|
72
|
+
case "inbox": run("bin/inbox.mjs"); break;
|
|
71
73
|
case "identity": {
|
|
72
74
|
const { load, publicView, generate, keyPath } = await import(join(ROOT, "lib/identity.mjs"));
|
|
73
75
|
const sub = args[0], name = args[1] || "human";
|
|
@@ -162,6 +164,8 @@ switch (cmd) {
|
|
|
162
164
|
trantor hub run the hub in the foreground (setup installs it as a service instead)
|
|
163
165
|
…or manage per-project hub pins: hub list · hub set <project> <url> · hub unset <project>
|
|
164
166
|
trantor watch live bus feed in the terminal
|
|
167
|
+
trantor inbox THIS session's unread bus messages, signed (works under enforce) — [--all] [--consume] [--json]
|
|
168
|
+
trantor policy the autonomy ladder: show | set <project> <1-4> | link <a> <b> --reason "<why>"
|
|
165
169
|
|
|
166
170
|
Claude Code plugin (the orchestrator side):
|
|
167
171
|
claude plugin marketplace add sashabogi/trantor && claude plugin install trantor
|
package/bin/inbox.mjs
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// trantor inbox — read THIS session's bus messages from the terminal, signed.
|
|
3
|
+
//
|
|
4
|
+
// trantor inbox peek at unread (does NOT consume — hooks still deliver them)
|
|
5
|
+
// trantor inbox --all the full history for this session (peek)
|
|
6
|
+
// trantor inbox --consume read AND advance the delivery cursor (marks them delivered)
|
|
7
|
+
// trantor inbox --json raw JSON out
|
|
8
|
+
// trantor inbox --limit N show at most N messages (default 30)
|
|
9
|
+
//
|
|
10
|
+
// Born from the 2026-07-30 agent-UX gap: a session asked to "check your messages" had NO way to —
|
|
11
|
+
// the MCP tools 401'd on the enforce hub and there was no CLI. Reads here are SIGNED with the same
|
|
12
|
+
// session keypair the hooks and MCP server use, so they work under RELAY_AUTH=enforce; the hub's
|
|
13
|
+
// scope filtering (own project + direct messages) is the intended behavior, not a limitation.
|
|
14
|
+
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { homedir } from "node:os";
|
|
17
|
+
import { resolveProject, hostId, resolveHub } from "../lib/project.mjs";
|
|
18
|
+
import { loadOrCreate } from "../lib/identity.mjs";
|
|
19
|
+
import { sfetchJson } from "../lib/signed-fetch.mjs";
|
|
20
|
+
|
|
21
|
+
const argv = process.argv.slice(2);
|
|
22
|
+
const has = (k) => argv.includes(`--${k}`);
|
|
23
|
+
const val = (k) => { const i = argv.indexOf(`--${k}`); return i >= 0 ? (argv[i + 1] ?? "") : ""; };
|
|
24
|
+
|
|
25
|
+
const project = resolveProject(process.cwd());
|
|
26
|
+
const session = process.env.RELAY_SESSION
|
|
27
|
+
|| (process.env.RELAY_AGENT ? `${process.env.RELAY_AGENT}:${project}` : `${hostId()}:${project}`);
|
|
28
|
+
const hub = resolveHub(project);
|
|
29
|
+
const identity = loadOrCreate(session, "agent");
|
|
30
|
+
|
|
31
|
+
// Default `since` = the SAME cursor file the delivery hooks keep, so "trantor inbox" means
|
|
32
|
+
// "what have my hooks not handed me yet" — not a 2,000-message historical replay.
|
|
33
|
+
const safe = session.replace(/[^A-Za-z0-9_.-]/g, "_");
|
|
34
|
+
const cursorFile = join(process.env.AGENT_BUS_DIR || join(homedir(), ".agent-bus"), `inbox-cursor-${safe}.id`);
|
|
35
|
+
let since = 0;
|
|
36
|
+
if (!has("all")) { try { since = Number(readFileSync(cursorFile, "utf8")) || 0; } catch {} }
|
|
37
|
+
|
|
38
|
+
const peek = !has("consume");
|
|
39
|
+
const url = `${hub}/inbox?session=${encodeURIComponent(session)}&since=${since}${peek ? "&peek=1" : ""}`;
|
|
40
|
+
let r;
|
|
41
|
+
try { r = await sfetchJson(url, { method: "GET", identity, signal: AbortSignal.timeout(8000) }); }
|
|
42
|
+
catch (e) { console.error(`hub unreachable at ${hub}: ${e.message}`); process.exit(1); }
|
|
43
|
+
if (!r.ok) {
|
|
44
|
+
console.error(`hub ${r.status} on /inbox — ${r.status === 401 ? "this identity isn't enrolled on the hub (RELAY_ENROLL=invite?)" : "read failed"}`);
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
const { messages = [], cursor = since } = await r.json();
|
|
48
|
+
|
|
49
|
+
if (has("json")) { console.log(JSON.stringify({ session, hub, cursor, messages }, null, 2)); }
|
|
50
|
+
else {
|
|
51
|
+
const limit = Math.max(1, Number(val("limit")) || 30);
|
|
52
|
+
const show = messages.slice(-limit);
|
|
53
|
+
console.log(`${session} @ ${hub} — ${messages.length} message(s)${messages.length > show.length ? `, showing last ${show.length}` : ""}${peek ? " (peek)" : ""}`);
|
|
54
|
+
for (const m of show) {
|
|
55
|
+
const when = new Date(m.ts).toLocaleString();
|
|
56
|
+
console.log(`\n#${m.id} ${when} ${m.from} -> ${m.to}`);
|
|
57
|
+
console.log(` ${String(m.text || "").split("\n").join("\n ")}`);
|
|
58
|
+
}
|
|
59
|
+
if (!messages.length) console.log("(inbox empty — you're caught up)");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// --consume: the hub's shared delivery ledger already advanced (non-peek read); mirror it into the
|
|
63
|
+
// local cursor file so the delivery hooks agree these are handled and don't re-inject them.
|
|
64
|
+
if (!peek && cursor > since) { try { writeFileSync(cursorFile, String(cursor)); } catch {} }
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// trantor overseer-narrate — narrate overseer.warn events with plain-language action text.
|
|
3
|
+
//
|
|
4
|
+
// trantor overseer-narrate [--limit N] [--dry] [--quiet]
|
|
5
|
+
//
|
|
6
|
+
// The overseer tick emits overseer.warn events as mechanical, computed facts (kind, sessions, files,
|
|
7
|
+
// detail). This worker gives each un-narrated warning a one-line action text ("coordinate over the
|
|
8
|
+
// bus / split files / declare a link") written by a CHEAP model, then POSTs it back to the hub.
|
|
9
|
+
// Economics by design: candidates are unnarrated overseer.warn events, batched into ONE cheap-model
|
|
10
|
+
// call per hub, difficulty easy. Runs ambiently from the heartbeat and on demand.
|
|
11
|
+
import { execSync, spawnSync } from "node:child_process";
|
|
12
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { homedir } from "node:os";
|
|
15
|
+
import { loadOrCreate } from "../lib/identity.mjs";
|
|
16
|
+
import { sfetchJson } from "../lib/signed-fetch.mjs";
|
|
17
|
+
|
|
18
|
+
const argv = process.argv.slice(2);
|
|
19
|
+
const val = (k) => { const i = argv.indexOf(`--${k}`); return i >= 0 ? (argv[i + 1] ?? "") : ""; };
|
|
20
|
+
const has = (k) => argv.includes(`--${k}`);
|
|
21
|
+
const QUIET = has("quiet");
|
|
22
|
+
const say = (...a) => { if (!QUIET) console.log(...a); };
|
|
23
|
+
|
|
24
|
+
const BUS_DIR = process.env.AGENT_BUS_DIR || join(homedir(), ".agent-bus");
|
|
25
|
+
let config = {}; try { config = JSON.parse(readFileSync(join(BUS_DIR, "config.json"), "utf8")); } catch {}
|
|
26
|
+
const LIMIT = Math.max(1, Number(val("limit")) || 100);
|
|
27
|
+
const OWNER = config.ownerIdentity || "admin";
|
|
28
|
+
const ownerId = loadOrCreate(OWNER, "human");
|
|
29
|
+
|
|
30
|
+
const scroogeBin = () => process.env.SCROOGE_BIN
|
|
31
|
+
|| (() => { try { return execSync("command -v scrooge", { encoding: "utf8" }).trim(); } catch {} })()
|
|
32
|
+
|| (existsSync(new URL("../engine/bin/scrooge", import.meta.url)) ? new URL("../engine/bin/scrooge", import.meta.url).pathname : "");
|
|
33
|
+
|
|
34
|
+
const hubs = new Set([config.url || "http://127.0.0.1:4477", ...Object.values(config.hubs || {})]);
|
|
35
|
+
const get = async (hub, path) => (await sfetchJson(`${hub}${path}`, { method: "GET", identity: ownerId, signal: AbortSignal.timeout(8000) })).json();
|
|
36
|
+
|
|
37
|
+
let narrated = 0, considered = 0;
|
|
38
|
+
for (const hub of hubs) {
|
|
39
|
+
let events = [];
|
|
40
|
+
try { events = (await get(hub, `/events?type=overseer.&limit=${LIMIT}`)).events ?? []; }
|
|
41
|
+
catch { continue; }
|
|
42
|
+
const unnarrated = events.filter(e => !e.narrated && e.detail);
|
|
43
|
+
considered += unnarrated.length;
|
|
44
|
+
if (!unnarrated.length) continue;
|
|
45
|
+
say(`${hub}: ${unnarrated.length} overseer warning(s) need narration`);
|
|
46
|
+
|
|
47
|
+
const blocks = unnarrated.map(e =>
|
|
48
|
+
`EVENT #${e.id} [${e.project}]\nKIND: ${e.kind}\nSESSIONS: ${(e.sessions || []).join(", ")}\nFILES: ${(e.files || []).join(", ")}\nDETAIL: ${e.detail}`
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
const prompt = `You write one-line action narratives for AI-agent collision warnings. For EACH event below, write what the sessions should do — coordinate over the bus, split files, declare a link. <=200 chars. Cite sessions/files/projects from the detail. Return ONLY a JSON array: [{"eventId":<number>,"text":"..."}]
|
|
52
|
+
|
|
53
|
+
${blocks.join("\n\n")}`;
|
|
54
|
+
|
|
55
|
+
const bin = scroogeBin();
|
|
56
|
+
if (!bin) { console.error("scrooge not found (set SCROOGE_BIN) — cannot narrate."); process.exit(1); }
|
|
57
|
+
const res = spawnSync(bin, ["-t", "reason", "-d", "easy", "--json"], { input: prompt, encoding: "utf8", timeout: 120000 });
|
|
58
|
+
if (res.error || (res.status !== 0 && !res.stdout)) { console.error(`scrooge failed: ${(res.stderr || res.error?.message || "").slice(-200)}`); continue; }
|
|
59
|
+
let rows = [];
|
|
60
|
+
try { const out = res.stdout || ""; rows = JSON.parse(out.slice(out.indexOf("["), out.lastIndexOf("]") + 1)); }
|
|
61
|
+
catch { console.error(`unparseable narrator output: ${(res.stdout || "").slice(0, 200)}`); continue; }
|
|
62
|
+
|
|
63
|
+
const byId = new Map(unnarrated.map(e => [e.id, e]));
|
|
64
|
+
for (const r of rows) {
|
|
65
|
+
const e = byId.get(Number(r.eventId));
|
|
66
|
+
const text = String(r.text || "").trim().slice(0, 200);
|
|
67
|
+
if (!e || !text) continue;
|
|
68
|
+
if (has("dry")) { say(` event #${e.id} would become: ${text}`); continue; }
|
|
69
|
+
try {
|
|
70
|
+
const res = await sfetchJson(`${hub}/overseer/narrate`, { identity: ownerId, payload: { eventId: e.id, text }, signal: AbortSignal.timeout(8000) });
|
|
71
|
+
if (!res.ok) { say(` event #${e.id} POST failed: ${res.status}`); continue; }
|
|
72
|
+
narrated++;
|
|
73
|
+
say(` event #${e.id} → ${text}`);
|
|
74
|
+
} catch (e2) { say(` event #${e.id} write failed: ${e2.message}`); }
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
say(`\n${has("dry") ? "[dry] " : ""}${narrated} narration(s) written · ${considered} event(s) considered`);
|
package/bin/policy.mjs
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// trantor policy — the autonomy ladder's admin surface (PRD §6): show levels + links,
|
|
3
|
+
// set a project's level, declare that two projects are codependent.
|
|
4
|
+
// trantor policy show | set <project> <1-4> | link <a> <b> --reason "<why>"
|
|
5
|
+
// Drafted by scrooge (deepseek-v4-flash), integrated by the orchestrator.
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { loadOrCreate } from "../lib/identity.mjs";
|
|
10
|
+
import { sfetchJson } from "../lib/signed-fetch.mjs";
|
|
11
|
+
|
|
12
|
+
const BUS_DIR = process.env.AGENT_BUS_DIR || join(homedir(), ".agent-bus");
|
|
13
|
+
let config;
|
|
14
|
+
try { config = JSON.parse(readFileSync(join(BUS_DIR, "config.json"), "utf8")); } catch { config = {}; }
|
|
15
|
+
|
|
16
|
+
const OWNER = config.ownerIdentity || "admin";
|
|
17
|
+
const ownerId = loadOrCreate(OWNER, "human");
|
|
18
|
+
const hubs = new Set([config.url || "http://127.0.0.1:4477", ...Object.values(config.hubs || {})]);
|
|
19
|
+
|
|
20
|
+
const get = async (hub) => {
|
|
21
|
+
const res = await sfetchJson(`${hub}/policy`, { method: "GET", identity: ownerId, signal: AbortSignal.timeout(8000) });
|
|
22
|
+
return res.json();
|
|
23
|
+
};
|
|
24
|
+
const post = async (hub, payload) => {
|
|
25
|
+
const res = await sfetchJson(`${hub}/policy`, { method: "POST", identity: ownerId, payload, signal: AbortSignal.timeout(8000) });
|
|
26
|
+
const json = await res.json();
|
|
27
|
+
if (!res.ok || json.error) throw new Error(json.error || `HTTP ${res.status}`);
|
|
28
|
+
return json;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const legend = { 1: "1 observe", 2: "2 warn", 3: "3 gate", 4: "4 auto" };
|
|
32
|
+
function usage() {
|
|
33
|
+
console.log('usage: trantor policy show | set <project> <1-4> | link <a> <b> --reason "<why>"');
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const [,, cmd, arg1, arg2] = process.argv;
|
|
38
|
+
|
|
39
|
+
if (cmd === "show" || !cmd) {
|
|
40
|
+
for (const hub of hubs) {
|
|
41
|
+
try {
|
|
42
|
+
const data = await get(hub);
|
|
43
|
+
console.log(hub);
|
|
44
|
+
console.log(" autonomy:");
|
|
45
|
+
for (const [proj, level] of Object.entries(data.autonomy || {})) console.log(` ${proj}: ${legend[level] || level}`);
|
|
46
|
+
console.log(" links:");
|
|
47
|
+
for (const l of data.links || []) console.log(` ${l.projects.join(" ↔ ")} — ${l.reason} (by ${l.declaredBy})`);
|
|
48
|
+
if (!(data.links || []).length) console.log(" (none)");
|
|
49
|
+
} catch (err) { console.warn(` ⚠ ${hub}: ${err.message}`); }
|
|
50
|
+
}
|
|
51
|
+
process.exit(0);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (cmd === "set") {
|
|
55
|
+
if (!arg1 || !["1", "2", "3", "4"].includes(arg2)) usage();
|
|
56
|
+
for (const hub of hubs) {
|
|
57
|
+
try { await post(hub, { autonomy: { [arg1]: Number(arg2) } }); console.log(`✓ ${arg1} → ${legend[Number(arg2)]} on ${hub}`); }
|
|
58
|
+
catch (err) { console.warn(`⚠ ${hub}: ${err.message}`); }
|
|
59
|
+
}
|
|
60
|
+
process.exit(0);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (cmd === "link") {
|
|
64
|
+
const reasonIdx = process.argv.indexOf("--reason");
|
|
65
|
+
const reason = reasonIdx >= 0 ? process.argv[reasonIdx + 1] : null;
|
|
66
|
+
if (!arg1 || !arg2 || !reason) usage();
|
|
67
|
+
for (const hub of hubs) {
|
|
68
|
+
try { await post(hub, { link: { projects: [arg1, arg2], reason } }); console.log(`✓ ${arg1} ↔ ${arg2} on ${hub}`); }
|
|
69
|
+
catch (err) { console.warn(`⚠ ${hub}: ${err.message}`); }
|
|
70
|
+
}
|
|
71
|
+
process.exit(0);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
usage();
|
package/bin/summarize.mjs
CHANGED
|
@@ -45,7 +45,12 @@ const machineTitled = (t) =>
|
|
|
45
45
|
|
|
46
46
|
const hubs = new Set([config.url || "http://127.0.0.1:4477", ...Object.values(config.hubs || {})]);
|
|
47
47
|
const get = async (hub, path) => (await sfetchJson(`${hub}${path}`, { method: "GET", identity: ownerId, signal: AbortSignal.timeout(8000) })).json();
|
|
48
|
-
const post = async (hub, path, payload) =>
|
|
48
|
+
const post = async (hub, path, payload) => {
|
|
49
|
+
const r = await sfetchJson(`${hub}${path}`, { identity: ownerId, payload, signal: AbortSignal.timeout(8000) });
|
|
50
|
+
const j = await r.json();
|
|
51
|
+
if (!r.ok || j?.error) throw new Error(`${path} → ${r.status} ${j?.error || ""}`);
|
|
52
|
+
return j;
|
|
53
|
+
};
|
|
49
54
|
|
|
50
55
|
let wrote = 0, considered = 0;
|
|
51
56
|
for (const hub of hubs) {
|
package/hooks/hooks.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"description": "trantor — auto-register each session + inject live roster (SessionStart); turn each substantive user prompt into the session's live 'focus' card so a regular session's own work shows IN PROGRESS (UserPromptSubmit); post an in-flight 'doing' card when a sub-agent is dispatched + claim each file edit on the bus and hand the model a warning when ANOTHER live session is touching the same file (PreToolUse) and enrich it with the native agent_id + parent session when the sub-agent spawns (SubagentStart); heartbeat presence on every tool call + deliver unread bus messages to a busy session mid-turn + mirror the session's TodoWrite list onto the board as cards (PostToolUse); write a handoff before compaction (PreCompact); surface CC's own background/child agents (fork/--agent/subtask) on the board when they need input or complete (Notification); card each sub-agent's notional API cost when it finishes (SubagentStop); refuse to go idle while a peer's DIRECT message sits unread, handing it to the model instead (Stop)",
|
|
2
|
+
"description": "trantor — auto-register each session + inject live roster + overseer landscape warning: linked projects, live peers, files in flight, collisions (SessionStart); turn each substantive user prompt into the session's live 'focus' card so a regular session's own work shows IN PROGRESS (UserPromptSubmit); post an in-flight 'doing' card when a sub-agent is dispatched + claim each file edit on the bus and hand the model a warning when ANOTHER live session is touching the same file (PreToolUse) and enrich it with the native agent_id + parent session when the sub-agent spawns (SubagentStart); heartbeat presence on every tool call + deliver unread bus messages to a busy session mid-turn + mirror the session's TodoWrite list onto the board as cards (PostToolUse); write a handoff before compaction (PreCompact); surface CC's own background/child agents (fork/--agent/subtask) on the board when they need input or complete (Notification); card each sub-agent's notional API cost when it finishes (SubagentStop); refuse to go idle while a peer's DIRECT message sits unread, handing it to the model instead (Stop)",
|
|
3
3
|
"hooks": {
|
|
4
4
|
"SessionStart": [
|
|
5
5
|
{
|
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
{
|
|
9
9
|
"type": "command",
|
|
10
10
|
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/sessionstart.mjs"
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"type": "command",
|
|
14
|
+
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/overseer-warn.mjs"
|
|
11
15
|
}
|
|
12
16
|
]
|
|
13
17
|
}
|
package/hooks/lib/api.mjs
CHANGED
|
@@ -119,6 +119,28 @@ export async function getJSON(pathOrUrl, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}
|
|
|
119
119
|
} catch { return { ok: false, status: 0, json: null }; }
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
// Signed GET → { ok, status, json|null }. Never throws. The "one-liner" the getJSON comment
|
|
123
|
+
// promised: for reads that MUST work under RELAY_AUTH=enforce (which 401s unsigned reads).
|
|
124
|
+
// First user: the overseer-warn hook's /overseer/context — a project-scoped read, so the
|
|
125
|
+
// enforce hub's own-project scope filtering is the correct behavior, not a loss. Roster-style
|
|
126
|
+
// reads (/peers, /catchup cross-project discovery) stay on getJSON on purpose — see above.
|
|
127
|
+
export async function signedGet(pathOrUrl, { timeoutMs = DEFAULT_TIMEOUT_MS, session } = {}) {
|
|
128
|
+
const sess = session || sessionContext().session;
|
|
129
|
+
const id = loadIdentity(sess);
|
|
130
|
+
await ensureEnrolled(sess, id);
|
|
131
|
+
try {
|
|
132
|
+
const r = await sfetchJson(toUrl(pathOrUrl), {
|
|
133
|
+
method: "GET",
|
|
134
|
+
identity: id,
|
|
135
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
136
|
+
});
|
|
137
|
+
if (!r.ok) return { ok: false, status: r.status, json: null };
|
|
138
|
+
const text = await r.text();
|
|
139
|
+
let json = null; try { json = text ? JSON.parse(text) : null; } catch {}
|
|
140
|
+
return { ok: true, status: r.status, json };
|
|
141
|
+
} catch { return { ok: false, status: 0, json: null }; }
|
|
142
|
+
}
|
|
143
|
+
|
|
122
144
|
// Signed POST → { ok, status, json|null }. Never throws.
|
|
123
145
|
export async function signedPost(pathOrUrl, payload, { timeoutMs = DEFAULT_TIMEOUT_MS, session } = {}) {
|
|
124
146
|
const sess = session || sessionContext().session;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// trantor SessionStart overseer-warn — a session hears about collisions through its OWN harness.
|
|
3
|
+
//
|
|
4
|
+
// THE doctrine (docs/OVERSEER-CONTRACT.md): detection is MECHANICAL and lives in the hub
|
|
5
|
+
// (GET /overseer/context computes level/links/peers/inflight/warnings); this hook only NARRATES
|
|
6
|
+
// what the hub already knows, at the one moment narration is cheap and useful — session start.
|
|
7
|
+
// A session never reaches into another session's process; context arrives via its own hook.
|
|
8
|
+
//
|
|
9
|
+
// Deliberately INFORMATIONAL below level 3, and never blocking at any level: the hook emits
|
|
10
|
+
// additionalContext or nothing. Warn mode annotates, it does not gate (see hub: "warn mode
|
|
11
|
+
// NEVER blocks").
|
|
12
|
+
//
|
|
13
|
+
// Fail-open is a contract, not a convenience: a hook that throws or hangs breaks the user's
|
|
14
|
+
// session. Hub down, timeout (1500ms), malformed payload, missing project — all resolve to {}.
|
|
15
|
+
// signedGet, not getJSON: RELAY_AUTH=enforce hubs 401 unsigned reads, and this hook fails open —
|
|
16
|
+
// an unsigned read here means the overseer warning silently NEVER reaches a session in production.
|
|
17
|
+
// /overseer/context is project-scoped, so enforce's own-project read filtering is correct for it.
|
|
18
|
+
import { relayUrl, sessionContext, signedGet } from "./lib/api.mjs";
|
|
19
|
+
|
|
20
|
+
const silent = () => { process.stdout.write("{}"); process.exit(0); };
|
|
21
|
+
|
|
22
|
+
function readStdin() {
|
|
23
|
+
return new Promise(res => {
|
|
24
|
+
let d = ""; process.stdin.setEncoding("utf8");
|
|
25
|
+
process.stdin.on("data", c => { d += c; });
|
|
26
|
+
process.stdin.on("end", () => res(d));
|
|
27
|
+
setTimeout(() => res(d), 400);
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const ago = s => (s < 60 ? `${s}s` : s < 3600 ? `${Math.round(s / 60)}m` : `${Math.round(s / 3600)}h`);
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
const raw = await readStdin();
|
|
35
|
+
const input = JSON.parse(raw || "{}");
|
|
36
|
+
const ctx = sessionContext(input.cwd);
|
|
37
|
+
if (!ctx.project) silent();
|
|
38
|
+
|
|
39
|
+
const r = await signedGet(`${relayUrl(ctx.project)}/overseer/context?project=${encodeURIComponent(ctx.project)}`, { session: ctx.session });
|
|
40
|
+
if (!r.ok || !r.json || typeof r.json !== "object") silent();
|
|
41
|
+
const c = r.json;
|
|
42
|
+
|
|
43
|
+
const level = Number(c.level || 1);
|
|
44
|
+
const warnings = Array.isArray(c.warnings) ? c.warnings : [];
|
|
45
|
+
const inflight = Array.isArray(c.inflight) ? c.inflight : [];
|
|
46
|
+
const links = Array.isArray(c.links) ? c.links : [];
|
|
47
|
+
const peers = Array.isArray(c.peers) ? c.peers : [];
|
|
48
|
+
|
|
49
|
+
// level<2 is observe: the hub still logs, but sessions are not narrated at. And at any level,
|
|
50
|
+
// nothing to say -> silence (an empty warning is noise that trains sessions to ignore real ones).
|
|
51
|
+
if (level < 2 || (!warnings.length && !inflight.length && !links.length)) silent();
|
|
52
|
+
|
|
53
|
+
const parts = [];
|
|
54
|
+
if (links.length) {
|
|
55
|
+
parts.push("Linked projects (declared codependence): " +
|
|
56
|
+
links.map(l => `${(l.projects || []).join(" + ")} — ${l.reason || "linked"}`).join("; ") + ".");
|
|
57
|
+
}
|
|
58
|
+
if (peers.length) {
|
|
59
|
+
parts.push("Live sessions on this/linked projects: " +
|
|
60
|
+
peers.map(p => `${p.session}${p.llm ? ` (${[p.llm, p.model].filter(Boolean).join("·")})` : ""}${p.status ? `, ${p.status}` : ""}`).join(", ") + ".");
|
|
61
|
+
}
|
|
62
|
+
if (inflight.length) {
|
|
63
|
+
parts.push("Files in flight right now: " +
|
|
64
|
+
inflight.map(f => `${f.file} — ${f.session} (${ago(Number(f.agoSec) || 0)} ago)`).join(", ") + ".");
|
|
65
|
+
}
|
|
66
|
+
if (warnings.length) {
|
|
67
|
+
parts.push("Overseer warnings: " +
|
|
68
|
+
warnings.map(w => w.detail || w.kind).filter(Boolean).join("; ") + ".");
|
|
69
|
+
}
|
|
70
|
+
parts.push("Before editing a file another session has in flight, coordinate over the bus (relay_send) or split the work.");
|
|
71
|
+
|
|
72
|
+
let text = parts.join(" ");
|
|
73
|
+
if (text.length > 900) text = text.slice(0, 897) + "…";
|
|
74
|
+
|
|
75
|
+
process.stdout.write(JSON.stringify({
|
|
76
|
+
hookSpecificOutput: {
|
|
77
|
+
hookEventName: "SessionStart",
|
|
78
|
+
additionalContext: `⚠️ trantor overseer: ${text}`,
|
|
79
|
+
},
|
|
80
|
+
}));
|
|
81
|
+
process.exit(0);
|
|
82
|
+
} catch {
|
|
83
|
+
silent();
|
|
84
|
+
}
|
package/hub.mjs
CHANGED
|
@@ -203,6 +203,59 @@ async function reloadFromStore() {
|
|
|
203
203
|
if (reloadAgain) { reloadAgain = false; scheduleStoreReload(); }
|
|
204
204
|
}
|
|
205
205
|
}
|
|
206
|
+
// --- the OVERSEER (levels 1-2 + the level-3 gate) -------------------------------------------
|
|
207
|
+
// Detection is MECHANICAL (lib/overseer.mjs, pure); the LLM only narrates (bin/overseer-narrate).
|
|
208
|
+
// Lazy import: the engine module lands from a crew seat in parallel — the hub runs without it and
|
|
209
|
+
// picks it up on next restart. Warnings dedup on a 10-minute window so a standing collision does
|
|
210
|
+
// not spam the feed; at level >= 3 a file-conflict opens a verify gate (the go/no-go primitive).
|
|
211
|
+
let _overseer = null;
|
|
212
|
+
import("./lib/overseer.mjs").then(m => { _overseer = m; }).catch(() => {});
|
|
213
|
+
const OVERSEER_TICK_MS = Number(process.env.RELAY_OVERSEER_TICK_MS || 30 * 1000);
|
|
214
|
+
const OVERSEER_DEDUP_MS = Number(process.env.RELAY_OVERSEER_DEDUP_MS || 10 * 60 * 1000);
|
|
215
|
+
const overseerWarned = new Map(); // dedup key -> ts
|
|
216
|
+
function overseerPolicy() {
|
|
217
|
+
const p = state.orgPolicy && typeof state.orgPolicy === "object" ? state.orgPolicy : {};
|
|
218
|
+
return {
|
|
219
|
+
autonomy: { "*": 1, ...(p.autonomy || {}) },
|
|
220
|
+
links: Array.isArray(p.links) ? p.links : [],
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
function overseerInputs() {
|
|
224
|
+
return {
|
|
225
|
+
peers: Object.entries(state.peers).map(([session, v]) => ({
|
|
226
|
+
session, project: v.project || "", lastSeen: v.lastSeen || 0,
|
|
227
|
+
llm: v.llm || "", model: v.model || "", status: v.status || "",
|
|
228
|
+
})),
|
|
229
|
+
claims: [...fileClaims.values()],
|
|
230
|
+
...overseerPolicy(),
|
|
231
|
+
now: now(),
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
function overseerTick() {
|
|
235
|
+
if (!_overseer?.detectCollisions) return;
|
|
236
|
+
let collisions = [];
|
|
237
|
+
try { collisions = _overseer.detectCollisions(overseerInputs()) || []; } catch { return; }
|
|
238
|
+
const cut = now() - OVERSEER_DEDUP_MS;
|
|
239
|
+
for (const [k, ts] of overseerWarned) if (ts < cut) overseerWarned.delete(k);
|
|
240
|
+
const pol = overseerPolicy();
|
|
241
|
+
for (const c of collisions) {
|
|
242
|
+
const key = `${c.project} ${c.kind} ${(c.sessions || []).join(",")} ${(c.files || []).join(",")}`;
|
|
243
|
+
if (overseerWarned.has(key)) continue;
|
|
244
|
+
overseerWarned.set(key, now());
|
|
245
|
+
appendEvent("overseer.warn", c.project, "overseer",
|
|
246
|
+
{ kind: c.kind, sessions: c.sessions || [], files: c.files || [], detail: c.detail || "", narrated: false });
|
|
247
|
+
const level = _overseer.levelFor ? _overseer.levelFor(c.project, pol.autonomy) : 1;
|
|
248
|
+
if (level >= 3 && c.kind === "file-conflict") {
|
|
249
|
+
const g = { id: ++state.verifyGateSeq, project: c.project, status: "open", ts: now(),
|
|
250
|
+
by: "overseer", claim: `file conflict: ${(c.files || []).join(", ")} — ${(c.sessions || []).join(" vs ")}`,
|
|
251
|
+
why: c.detail || "two live sessions on the same file", howToVerify: "decide who proceeds; coordinate over the bus" };
|
|
252
|
+
state.verifyGates.push(g); dirty = true;
|
|
253
|
+
appendEvent("verify.gate.opened", c.project, "overseer", { gateId: g.id, claim: g.claim, why: g.why });
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
setInterval(overseerTick, OVERSEER_TICK_MS).unref?.();
|
|
258
|
+
|
|
206
259
|
if (durableStore?.subscribeChanges) {
|
|
207
260
|
durableStore.subscribeChanges((p) => {
|
|
208
261
|
if (p && p.src === HUB_SRC) return;
|
|
@@ -404,8 +457,8 @@ function cmpSemver(a, b) {
|
|
|
404
457
|
}
|
|
405
458
|
const AUTH_HEADERS = ["x-trantor-pubkey", "x-trantor-sig", "x-trantor-ts", "x-trantor-nonce"];
|
|
406
459
|
const PUBLIC_ENDPOINTS = new Set(["/", "/ui", "/health", "/enroll"]);
|
|
407
|
-
const OWNER_ENDPOINTS = new Set(["/project/delete", "/sweep", "/reconcile", "/invite", "/import"]);
|
|
408
|
-
const READ_ENDPOINTS = new Set(["/peers", "/tasks", "/events", "/inbox", "/peer", "/card", "/stream", "/history", "/projects", "/catchup", "/phases", "/recent", "/handoffs", "/verify-gates", "/claims"]);
|
|
460
|
+
const OWNER_ENDPOINTS = new Set(["/project/delete", "/sweep", "/reconcile", "/invite", "/import", "/policy"]);
|
|
461
|
+
const READ_ENDPOINTS = new Set(["/peers", "/tasks", "/events", "/inbox", "/peer", "/card", "/stream", "/history", "/projects", "/catchup", "/phases", "/recent", "/handoffs", "/verify-gates", "/claims", "/overseer/context"]);
|
|
409
462
|
const roleRank = { read: 1, write: 2, owner: 3 };
|
|
410
463
|
const hasAuthHeaders = (req) => AUTH_HEADERS.some(h => !!req.headers[h]);
|
|
411
464
|
const authPath = (u) => `${u.pathname}${u.search || ""}`;
|
|
@@ -463,15 +516,24 @@ async function authenticate(req, path) {
|
|
|
463
516
|
return { ok: false, code: 401, error: "signature required" };
|
|
464
517
|
}
|
|
465
518
|
const raw = req.method === "GET" || req.method === "HEAD" ? undefined : await rawBody(req);
|
|
519
|
+
// WARN MODE NEVER BLOCKS — it annotates. That is its entire contract: an observation period
|
|
520
|
+
// where the hub records what WOULD fail under enforce. The restarted local hub proved the
|
|
521
|
+
// failure mode: signed requests from a not-yet-enrolled identity got 401 "unknown identity"
|
|
522
|
+
// while UNSIGNED requests passed — punishing exactly the clients that already do the right
|
|
523
|
+
// thing. Under warn: bad signature, replay and unknown identity all pass with a warning;
|
|
524
|
+
// under enforce they are the hard failures they should be.
|
|
525
|
+
const soft = (warning) => AUTH_MODE === "warn"
|
|
526
|
+
? { ok: true, mode: AUTH_MODE, trusted: false, warning }
|
|
527
|
+
: { ok: false, code: 401, error: warning };
|
|
466
528
|
const verified = verifyRequest({ headers: req.headers, method: req.method, path, body: raw });
|
|
467
|
-
if (!verified.ok) return
|
|
529
|
+
if (!verified.ok) return soft(verified.reason || "bad signature");
|
|
468
530
|
const nonceKey = `${verified.pubkey}:${verified.nonce}`;
|
|
469
531
|
for (const [k, ts] of seenNonces) if (Math.abs(now() - ts) > 120000) seenNonces.delete(k);
|
|
470
|
-
if (seenNonces.has(nonceKey)) return
|
|
532
|
+
if (seenNonces.has(nonceKey)) return soft("replay");
|
|
471
533
|
seenNonces.set(nonceKey, verified.ts);
|
|
472
534
|
if (seenNonces.size > 10000) seenNonces.delete(seenNonces.keys().next().value);
|
|
473
535
|
const identity = findIdentity(verified.pubkey);
|
|
474
|
-
if (!identity) return
|
|
536
|
+
if (!identity) return soft("unknown identity");
|
|
475
537
|
return { ok: true, mode: AUTH_MODE, trusted: true, pubkey: verified.pubkey, identity };
|
|
476
538
|
}
|
|
477
539
|
function authorize(auth, method, P, project) {
|
|
@@ -863,6 +925,60 @@ const server = http.createServer(async (req, res) => {
|
|
|
863
925
|
appendEvent("project.adopted", proj, String(b.by || ""), { counts: added });
|
|
864
926
|
return json(res, 200, { ok: true, ...added });
|
|
865
927
|
}
|
|
928
|
+
if (req.method === "GET" && P === "/policy") {
|
|
929
|
+
return json(res, 200, overseerPolicy());
|
|
930
|
+
}
|
|
931
|
+
if (req.method === "POST" && P === "/policy") {
|
|
932
|
+
const b = await body(req);
|
|
933
|
+
const p = state.orgPolicy && typeof state.orgPolicy === "object" ? state.orgPolicy : {};
|
|
934
|
+
p.autonomy = { ...(p.autonomy || {}) };
|
|
935
|
+
p.links = Array.isArray(p.links) ? p.links : [];
|
|
936
|
+
if (b.autonomy && typeof b.autonomy === "object") {
|
|
937
|
+
for (const [proj, lvl] of Object.entries(b.autonomy)) {
|
|
938
|
+
const n = Number(lvl);
|
|
939
|
+
if ([1, 2, 3, 4].includes(n)) p.autonomy[canon(String(proj).slice(0, 80))] = n;
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
if (b.link && Array.isArray(b.link.projects) && b.link.projects.length >= 2 && b.link.reason) {
|
|
943
|
+
const projects = b.link.projects.slice(0, 4).map(x => canon(String(x).slice(0, 80))).sort();
|
|
944
|
+
const key = projects.join(" ");
|
|
945
|
+
if (!p.links.some(l => (l.projects || []).slice().sort().join(" ") === key)) {
|
|
946
|
+
p.links.push({ projects, reason: String(b.link.reason).slice(0, 140),
|
|
947
|
+
declaredBy: auth?.identity?.name || String(b.by || ""), ts: now() });
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
state.orgPolicy = p; dirty = true;
|
|
951
|
+
return json(res, 200, { ok: true, ...overseerPolicy() });
|
|
952
|
+
}
|
|
953
|
+
// What a session arriving on <project> needs to know: its autonomy level, who else is live,
|
|
954
|
+
// which files are in flight, which projects are declared codependent, current collisions.
|
|
955
|
+
if (req.method === "GET" && P === "/overseer/context") {
|
|
956
|
+
const proj = canon(String(q.project || "").slice(0, 80));
|
|
957
|
+
if (!proj) return json(res, 400, { error: "project required" });
|
|
958
|
+
const pol = overseerPolicy();
|
|
959
|
+
const level = _overseer?.levelFor ? _overseer.levelFor(proj, pol.autonomy) : (pol.autonomy[proj] ?? pol.autonomy["*"] ?? 1);
|
|
960
|
+
const links = pol.links.filter(l => (l.projects || []).includes(proj));
|
|
961
|
+
const linked = new Set(links.flatMap(l => l.projects).filter(x => x !== proj));
|
|
962
|
+
const cutoff = now() - ONLINE_MS;
|
|
963
|
+
const peersOut = Object.entries(state.peers)
|
|
964
|
+
.filter(([, v]) => v.lastSeen > cutoff && (v.project === proj || linked.has(v.project)))
|
|
965
|
+
.map(([session, v]) => ({ session, project: v.project || "", llm: v.llm || "", model: v.model || "", status: v.status || "" }));
|
|
966
|
+
pruneClaims();
|
|
967
|
+
const inflight = [...fileClaims.values()].filter(c => c.project === proj)
|
|
968
|
+
.map(c => ({ file: c.file, session: c.session, agoSec: Math.round((now() - c.ts) / 1000) }));
|
|
969
|
+
let warnings = [];
|
|
970
|
+
try { warnings = (_overseer?.detectCollisions ? _overseer.detectCollisions(overseerInputs()) : [])
|
|
971
|
+
.filter(c => c.project === proj || linked.has(c.project)); } catch {}
|
|
972
|
+
return json(res, 200, { level, links: links.map(l => ({ projects: l.projects, reason: l.reason })), peers: peersOut, inflight, warnings });
|
|
973
|
+
}
|
|
974
|
+
if (req.method === "POST" && P === "/overseer/narrate") {
|
|
975
|
+
const b = await body(req);
|
|
976
|
+
const ev = state.events.find(e => e.id === Number(b.eventId) && e.type === "overseer.warn");
|
|
977
|
+
if (!ev) return json(res, 404, { error: "no such overseer.warn event" });
|
|
978
|
+
ev.narrated = true; ev.narration = String(b.text || "").slice(0, 300);
|
|
979
|
+
dirty = true;
|
|
980
|
+
return json(res, 200, { ok: true });
|
|
981
|
+
}
|
|
866
982
|
if (req.method === "POST" && P === "/claim") {
|
|
867
983
|
const b = await body(req);
|
|
868
984
|
const proj = canon(String(b.project || "").slice(0, 80));
|
package/lib/overseer.mjs
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
const PEER_LIVE_MS = 5 * 60 * 1000;
|
|
2
|
+
const CLAIM_LIVE_MS = 10 * 60 * 1000;
|
|
3
|
+
|
|
4
|
+
const KINDS = new Set(["same-project-sessions", "file-conflict", "linked-activity"]);
|
|
5
|
+
|
|
6
|
+
const asArray = (v) => Array.isArray(v) ? v : [];
|
|
7
|
+
const clean = (v) => String(v ?? "").trim();
|
|
8
|
+
const finiteNumber = (v) => Number.isFinite(Number(v)) ? Number(v) : null;
|
|
9
|
+
|
|
10
|
+
function isLevel(v) {
|
|
11
|
+
return v === 1 || v === 2 || v === 3 || v === 4;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function levelFor(project, autonomy = {}) {
|
|
15
|
+
const key = clean(project);
|
|
16
|
+
const direct = autonomy?.[key];
|
|
17
|
+
if (isLevel(direct)) return direct;
|
|
18
|
+
const fallback = autonomy?.["*"];
|
|
19
|
+
return isLevel(fallback) ? fallback : 1;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function isFresh(ts, now, ttl) {
|
|
23
|
+
const n = finiteNumber(ts);
|
|
24
|
+
return n != null && now - n <= ttl;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function sortedStrings(items) {
|
|
28
|
+
return [...new Set(Array.from(items ?? []).map(clean).filter(Boolean))].sort((a, b) => a.localeCompare(b));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function pushCollision(out, collision) {
|
|
32
|
+
if (!collision.project || !KINDS.has(collision.kind) || collision.sessions.length === 0) return;
|
|
33
|
+
out.push({
|
|
34
|
+
project: collision.project,
|
|
35
|
+
kind: collision.kind,
|
|
36
|
+
sessions: sortedStrings(collision.sessions),
|
|
37
|
+
files: sortedStrings(collision.files ?? []),
|
|
38
|
+
detail: collision.detail,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function collisionKey(c) {
|
|
43
|
+
return [
|
|
44
|
+
c.project,
|
|
45
|
+
c.kind,
|
|
46
|
+
c.files[0] ?? "",
|
|
47
|
+
c.sessions[0] ?? "",
|
|
48
|
+
c.detail,
|
|
49
|
+
].join("\u0000");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function sortCollisions(collisions) {
|
|
53
|
+
return [...collisions].sort((a, b) =>
|
|
54
|
+
a.project.localeCompare(b.project) ||
|
|
55
|
+
a.kind.localeCompare(b.kind) ||
|
|
56
|
+
(a.files[0] ?? "").localeCompare(b.files[0] ?? "") ||
|
|
57
|
+
(a.sessions[0] ?? "").localeCompare(b.sessions[0] ?? "") ||
|
|
58
|
+
a.detail.localeCompare(b.detail)
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function detectCollisions({ peers = [], claims = [], links = [], autonomy = {}, now } = {}) {
|
|
63
|
+
const at = finiteNumber(now) ?? 0;
|
|
64
|
+
const out = [];
|
|
65
|
+
|
|
66
|
+
const livePeers = [];
|
|
67
|
+
const seenPeers = new Set();
|
|
68
|
+
for (const peer of asArray(peers)) {
|
|
69
|
+
const session = clean(peer?.session);
|
|
70
|
+
const project = clean(peer?.project);
|
|
71
|
+
if (!session || !project || !isFresh(peer?.lastSeen, at, PEER_LIVE_MS)) continue;
|
|
72
|
+
const key = `${project}\u0000${session}`;
|
|
73
|
+
if (seenPeers.has(key)) continue;
|
|
74
|
+
seenPeers.add(key);
|
|
75
|
+
livePeers.push({ ...peer, session, project });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const sessionsByProject = new Map();
|
|
79
|
+
for (const peer of livePeers) {
|
|
80
|
+
const sessions = sessionsByProject.get(peer.project) ?? [];
|
|
81
|
+
sessions.push(peer.session);
|
|
82
|
+
sessionsByProject.set(peer.project, sessions);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
for (const project of sortedStrings(sessionsByProject.keys())) {
|
|
86
|
+
const sessions = sortedStrings(sessionsByProject.get(project));
|
|
87
|
+
if (sessions.length < 2) continue;
|
|
88
|
+
pushCollision(out, {
|
|
89
|
+
project,
|
|
90
|
+
kind: "same-project-sessions",
|
|
91
|
+
sessions,
|
|
92
|
+
files: [],
|
|
93
|
+
detail: `${sessions.join(", ")} are live on project ${project}.`,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const claimSessionsByFile = new Map();
|
|
98
|
+
for (const claim of asArray(claims)) {
|
|
99
|
+
const project = clean(claim?.project);
|
|
100
|
+
const file = clean(claim?.file);
|
|
101
|
+
const session = clean(claim?.session);
|
|
102
|
+
if (!project || !file || !session || !isFresh(claim?.ts, at, CLAIM_LIVE_MS)) continue;
|
|
103
|
+
const key = `${project}\u0000${file}`;
|
|
104
|
+
const sessions = claimSessionsByFile.get(key) ?? new Set();
|
|
105
|
+
sessions.add(session);
|
|
106
|
+
claimSessionsByFile.set(key, sessions);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
for (const key of [...claimSessionsByFile.keys()].sort()) {
|
|
110
|
+
const [project, file] = key.split("\u0000");
|
|
111
|
+
const sessions = sortedStrings(claimSessionsByFile.get(key));
|
|
112
|
+
if (sessions.length < 2) continue;
|
|
113
|
+
pushCollision(out, {
|
|
114
|
+
project,
|
|
115
|
+
kind: "file-conflict",
|
|
116
|
+
sessions,
|
|
117
|
+
files: [file],
|
|
118
|
+
detail: `${sessions.join(", ")} have live claims on ${project}/${file}.`,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
for (const link of asArray(links)) {
|
|
123
|
+
const projects = sortedStrings(link?.projects ?? []);
|
|
124
|
+
if (projects.length < 2) continue;
|
|
125
|
+
const activeProjects = projects.filter((project) => (sessionsByProject.get(project) ?? []).length > 0);
|
|
126
|
+
if (activeProjects.length < 2) continue;
|
|
127
|
+
const sessions = sortedStrings(activeProjects.flatMap((project) => sessionsByProject.get(project) ?? []));
|
|
128
|
+
pushCollision(out, {
|
|
129
|
+
project: activeProjects[0],
|
|
130
|
+
kind: "linked-activity",
|
|
131
|
+
sessions,
|
|
132
|
+
files: [],
|
|
133
|
+
detail: `Linked projects ${activeProjects.join(", ")} have live sessions ${sessions.join(", ")}.`,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const deduped = new Map();
|
|
138
|
+
for (const collision of sortCollisions(out)) deduped.set(collisionKey(collision), collision);
|
|
139
|
+
return [...deduped.values()];
|
|
140
|
+
}
|
package/mcp.mjs
CHANGED
|
@@ -11,7 +11,7 @@ import { homedir, hostname } from "node:os";
|
|
|
11
11
|
import { execSync, spawnSync } from "node:child_process";
|
|
12
12
|
import { advise } from "./bin/advise.mjs";
|
|
13
13
|
import { resolveProject, hostId, resolveHub } from "./lib/project.mjs";
|
|
14
|
-
import { signedPost,
|
|
14
|
+
import { signedPost, signedGet } from "./hooks/lib/api.mjs";
|
|
15
15
|
import { assertNoSecrets } from "./lib/scrub.mjs";
|
|
16
16
|
import { z } from "zod";
|
|
17
17
|
|
|
@@ -43,14 +43,16 @@ async function seedCursor() {
|
|
|
43
43
|
} catch { /* hub down: the subsequent real call surfaces the error; leave cursor at 0 */ }
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
// Every hub
|
|
47
|
-
// hooks/lib/api.mjs
|
|
48
|
-
// `from` hole (the 2026-07-28 RCE). Reads
|
|
49
|
-
//
|
|
50
|
-
//
|
|
46
|
+
// Every hub call is SIGNED with this session's Ed25519 keypair (TDD §7.3) via the shared client in
|
|
47
|
+
// hooks/lib/api.mjs. Writes: that is what binds /send's `from` to the signer and closes the
|
|
48
|
+
// self-asserted `from` hole (the 2026-07-28 RCE). Reads TOO (the 2026-07-30 agent-UX gap): an
|
|
49
|
+
// enforce hub 401s unsigned reads, which made relay_inbox/board/peers dead for the very agents the
|
|
50
|
+
// bus exists for. Signed reads are scope-filtered by the hub to this identity's grants — for a
|
|
51
|
+
// session reading its own project + DMs that is the intended behavior. The client fail-opens on a
|
|
52
|
+
// down hub (returns {ok:false}); we surface that as a thrown Error so individual tools .catch it.
|
|
51
53
|
async function api(method, path, payload) {
|
|
52
54
|
const r = method.toUpperCase() === "GET"
|
|
53
|
-
? await
|
|
55
|
+
? await signedGet(path, { session: SESSION })
|
|
54
56
|
: await signedPost(path, payload, { session: SESSION });
|
|
55
57
|
if (!r.ok) throw new Error(`hub ${r.status} on ${path}`);
|
|
56
58
|
return r.json;
|