trantor 0.17.55 → 0.17.57
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/balances.mjs +3 -7
- package/bin/catchup.mjs +8 -7
- package/bin/cli.mjs +4 -0
- package/bin/crew-verify.mjs +8 -7
- package/bin/gates.mjs +11 -12
- package/bin/git-backfill.mjs +6 -9
- package/bin/inbox.mjs +64 -0
- package/bin/overseer-narrate.mjs +77 -0
- package/bin/policy.mjs +74 -0
- package/bin/reconcile.mjs +10 -11
- package/bin/recost.mjs +7 -9
- package/bin/relay-watch.mjs +13 -11
- package/bin/statusline.mjs +7 -8
- package/bin/summarize.mjs +6 -1
- package/bin/sweep.mjs +10 -7
- package/hooks/hooks.json +5 -1
- package/hooks/inbox-deliver.mjs +2 -2
- package/hooks/lib/api.mjs +22 -0
- package/hooks/overseer-warn.mjs +84 -0
- package/hooks/sessionstart.mjs +8 -5
- package/hooks/stop-inbox.mjs +3 -3
- package/hub.mjs +121 -5
- package/lib/overseer.mjs +140 -0
- package/mcp.mjs +11 -9
- 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.57",
|
|
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.57",
|
|
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/balances.mjs
CHANGED
|
@@ -18,11 +18,8 @@ const noPush = args.includes("--no-push");
|
|
|
18
18
|
// Only check providers the user configured in `trantor profile` — never stray keys in the ambient env.
|
|
19
19
|
const configured = Object.keys(loadProfile().providers || {});
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
try { const c = join(homedir(), ".agent-bus", "config.json"); if (existsSync(c)) { const u = JSON.parse(readFileSync(c, "utf8")).url; if (u) return u; } } catch {}
|
|
24
|
-
return "http://127.0.0.1:4477";
|
|
25
|
-
}
|
|
21
|
+
// Signed via the shared client (2026-07-31, agent-UX audit): unsigned POST rejected under enforce.
|
|
22
|
+
import { signedPost } from "../hooks/lib/api.mjs";
|
|
26
23
|
let _qpct = DEFAULT_LOW_QUOTA_PCT;
|
|
27
24
|
function thresholds() {
|
|
28
25
|
try { const c = JSON.parse(readFileSync(join(homedir(), ".agent-bus", "config.json"), "utf8")); if (typeof c.lowQuotaPct === "number") _qpct = c.lowQuotaPct; if (c.lowBalance && typeof c.lowBalance === "object") return { ...DEFAULT_LOW, ...c.lowBalance }; } catch {}
|
|
@@ -35,8 +32,7 @@ const low = thresholds();
|
|
|
35
32
|
// push the snapshot to the hub (best-effort) so the dashboard + warning line can use it
|
|
36
33
|
if (!noPush) {
|
|
37
34
|
try {
|
|
38
|
-
await
|
|
39
|
-
body: JSON.stringify({ balances, ts: Date.now() }), signal: AbortSignal.timeout(2500) });
|
|
35
|
+
await signedPost("/balances", { balances, ts: Date.now() }, { timeoutMs: 2500 });
|
|
40
36
|
} catch {}
|
|
41
37
|
}
|
|
42
38
|
|
package/bin/catchup.mjs
CHANGED
|
@@ -10,19 +10,20 @@ import { homedir } from "node:os";
|
|
|
10
10
|
import { execSync } from "node:child_process";
|
|
11
11
|
import { resolveProject } from "../lib/project.mjs";
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
return "http://127.0.0.1:4477";
|
|
17
|
-
}
|
|
13
|
+
// Signed read via the shared client (2026-07-31, agent-UX audit): hand-rolled relayUrl missed the
|
|
14
|
+
// per-project hubs map; unsigned reads 401 under enforce. signedGet resolves + signs per call.
|
|
15
|
+
import { relayUrl, signedGet } from "../hooks/lib/api.mjs";
|
|
18
16
|
const haveScrooge = () => { try { execSync("command -v scrooge", { stdio: "ignore" }); return true; } catch { return false; } };
|
|
19
17
|
|
|
20
18
|
const dir = process.cwd();
|
|
21
19
|
const project = resolveProject(dir);
|
|
22
|
-
const url = relayUrl();
|
|
20
|
+
const url = relayUrl(project);
|
|
23
21
|
|
|
24
22
|
let cu = null;
|
|
25
|
-
|
|
23
|
+
{
|
|
24
|
+
const r = await signedGet(`/catchup?project=${encodeURIComponent(project)}`, { timeoutMs: 4000 });
|
|
25
|
+
if (r.ok) cu = r.json; else console.error(`could not reach hub at ${url} (status ${r.status})`);
|
|
26
|
+
}
|
|
26
27
|
let gitlog = "";
|
|
27
28
|
try { gitlog = execSync(`git -C ${JSON.stringify(dir)} log --oneline -12 2>/dev/null`, { encoding: "utf8" }).trim(); } catch {}
|
|
28
29
|
|
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/crew-verify.mjs
CHANGED
|
@@ -24,12 +24,13 @@ const SINCE = si >= 0 ? Number(args.splice(si, 2)[1]) : NaN;
|
|
|
24
24
|
const [PROJ, ...AGENTS] = args;
|
|
25
25
|
if (!PROJ || !AGENTS.length) { console.error("usage: crew-verify.mjs <project> <agent...> [--timeout 30] [--since <ms>]"); process.exit(2); }
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
}
|
|
32
|
-
const HUB =
|
|
27
|
+
// Signed + per-project hub (2026-07-31, agent-UX audit): unsigned /peers is a dead 401 under
|
|
28
|
+
// enforce — crew verification would report every seat missing while the crew was fine.
|
|
29
|
+
import { resolveHub, hostId } from "../lib/project.mjs";
|
|
30
|
+
import { loadOrCreate } from "../lib/identity.mjs";
|
|
31
|
+
import { sfetchJson } from "../lib/signed-fetch.mjs";
|
|
32
|
+
const HUB = process.env.RELAY_URL || resolveHub(PROJ);
|
|
33
|
+
const VERIFY_ID = loadOrCreate(process.env.RELAY_SESSION || `${hostId()}:${PROJ}`, "agent");
|
|
33
34
|
// Two distinct clocks, deliberately separate:
|
|
34
35
|
// - FRESH_SINCE: the freshness threshold. A registration counts only if lastSeen >= this.
|
|
35
36
|
// Prefer the launcher's pre-spawn epoch (so an early "booting" beat counts); else our start.
|
|
@@ -43,7 +44,7 @@ const FRESH_SINCE = Number.isFinite(SINCE) ? SINCE : Date.now();
|
|
|
43
44
|
const up = new Set();
|
|
44
45
|
while (Date.now() < DEADLINE && up.size < want.size) {
|
|
45
46
|
try {
|
|
46
|
-
const { peers } = await (await
|
|
47
|
+
const { peers } = await (await sfetchJson(`${HUB}/peers`, { method: "GET", identity: VERIFY_ID })).json();
|
|
47
48
|
for (const p of peers) if (want.has(p.session) && p.lastSeen >= FRESH_SINCE) up.add(p.session);
|
|
48
49
|
} catch {}
|
|
49
50
|
if (up.size < want.size) await new Promise(s => setTimeout(s, 1500));
|
package/bin/gates.mjs
CHANGED
|
@@ -11,21 +11,20 @@ const args = process.argv.slice(2);
|
|
|
11
11
|
const all = args.includes("--all");
|
|
12
12
|
const asJson = args.includes("--json");
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
}
|
|
14
|
+
// Signed read via the shared client (2026-07-31, agent-UX audit): the hand-rolled relayUrl here
|
|
15
|
+
// missed the per-project hubs map AND sent unsigned (401 under enforce). relayUrl/signedGet from
|
|
16
|
+
// hooks/lib/api.mjs resolve the cwd project's hub and sign as this session.
|
|
17
|
+
import { relayUrl, signedGet } from "../hooks/lib/api.mjs";
|
|
19
18
|
|
|
20
19
|
const project = resolveProject(process.cwd());
|
|
21
|
-
const url = `${relayUrl()}/verify-gates?project=${encodeURIComponent(project)}${all ? "&all=1" : ""}`;
|
|
22
20
|
let gates = [];
|
|
23
|
-
|
|
24
|
-
const r = await
|
|
25
|
-
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
|
|
21
|
+
{
|
|
22
|
+
const r = await signedGet(`/verify-gates?project=${encodeURIComponent(project)}${all ? "&all=1" : ""}`, { timeoutMs: 2500 });
|
|
23
|
+
if (!r.ok) {
|
|
24
|
+
console.error(`could not reach the hub at ${relayUrl(project)} (status ${r.status}) — is it running? (trantor setup / trantor hub)`);
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
gates = r.json?.gates || [];
|
|
29
28
|
}
|
|
30
29
|
|
|
31
30
|
if (asJson) { process.stdout.write(JSON.stringify(gates, null, 2) + "\n"); process.exit(0); }
|
package/bin/git-backfill.mjs
CHANGED
|
@@ -11,18 +11,16 @@ import { join } from "node:path";
|
|
|
11
11
|
import { homedir } from "node:os";
|
|
12
12
|
import { resolveProject, hostId } from "../lib/project.mjs";
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
return "http://127.0.0.1:4477";
|
|
18
|
-
}
|
|
14
|
+
// Signed via the shared client (2026-07-31, agent-UX audit): unsigned POST /task was rejected
|
|
15
|
+
// under enforce; hand-rolled relayUrl missed the per-project hubs map.
|
|
16
|
+
import { relayUrl, signedGet, signedPost } from "../hooks/lib/api.mjs";
|
|
19
17
|
const args = process.argv.slice(2);
|
|
20
18
|
const arg = (name, def) => { const i = args.indexOf("--" + name); return i >= 0 ? args[i + 1] : def; };
|
|
21
19
|
const dir = process.cwd();
|
|
22
20
|
const project = arg("project", resolveProject(dir));
|
|
23
21
|
const since = arg("since", "14 days ago");
|
|
24
22
|
const dry = args.includes("--dry-run");
|
|
25
|
-
const url = relayUrl();
|
|
23
|
+
const url = relayUrl(project);
|
|
26
24
|
const me = `${hostId()}:${project}`;
|
|
27
25
|
|
|
28
26
|
const themeOf = (s) => {
|
|
@@ -53,7 +51,7 @@ for (const r of rows) {
|
|
|
53
51
|
}
|
|
54
52
|
|
|
55
53
|
let existing = new Set();
|
|
56
|
-
try { const
|
|
54
|
+
try { const r = await signedGet(`/tasks?project=${encodeURIComponent(project)}`, { session: me }); const t = r.json?.tasks || []; existing = new Set(t.map(x => x.title)); } catch {}
|
|
57
55
|
|
|
58
56
|
const ents = [...groups.entries()].sort((a, b) => a[1].latest - b[1].latest);
|
|
59
57
|
let posted = 0, skipped = 0;
|
|
@@ -68,8 +66,7 @@ for (const [theme, g] of ents) {
|
|
|
68
66
|
if (existing.has(title)) { skipped++; continue; }
|
|
69
67
|
if (dry) { console.log(`+ [${new Date(g.latest).toISOString().slice(0, 10)}] ${theme.padEnd(20)} ${g.commits.length}c ${title.slice(0, 64)}`); posted++; continue; }
|
|
70
68
|
try {
|
|
71
|
-
await
|
|
72
|
-
body: JSON.stringify({ project, title, status: "done", phase: theme, source: "git", ts: g.latest, assignee: me, by: me }) });
|
|
69
|
+
await signedPost("/task", { project, title, status: "done", phase: theme, source: "git", ts: g.latest, assignee: me, by: me }, { session: me });
|
|
73
70
|
posted++;
|
|
74
71
|
} catch (e) { console.error(`post failed for "${title}": ${e.message}`); }
|
|
75
72
|
}
|
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/reconcile.mjs
CHANGED
|
@@ -14,11 +14,10 @@ import { homedir } from "node:os";
|
|
|
14
14
|
import { execSync, spawnSync } from "node:child_process";
|
|
15
15
|
import { resolveProject } from "../lib/project.mjs";
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
}
|
|
17
|
+
// Signed via the shared client (2026-07-31, agent-UX audit): the unsigned POST /task/update was
|
|
18
|
+
// rejected outright under enforce and merely flagged under warn; the hand-rolled relayUrl missed
|
|
19
|
+
// the per-project hubs map. signedGet/signedPost resolve the cwd project's hub + sign per call.
|
|
20
|
+
import { relayUrl, signedGet, signedPost } from "../hooks/lib/api.mjs";
|
|
22
21
|
function parseDur(s, def) {
|
|
23
22
|
if (!s) return def;
|
|
24
23
|
const m = String(s).match(/^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?$/i);
|
|
@@ -38,16 +37,16 @@ const doIt = has("--yes", "-y");
|
|
|
38
37
|
const difficulty = ["easy", "medium", "hard"].includes(val("--difficulty", "-d")) ? val("--difficulty", "-d") : "medium";
|
|
39
38
|
const dir = process.cwd();
|
|
40
39
|
const project = resolveProject(dir);
|
|
41
|
-
const url = relayUrl();
|
|
40
|
+
const url = relayUrl(project);
|
|
42
41
|
|
|
43
42
|
async function tasks() {
|
|
44
|
-
const r = await
|
|
45
|
-
|
|
46
|
-
|
|
43
|
+
const r = await signedGet(`/tasks?project=${encodeURIComponent(project)}`, { timeoutMs: 6000 });
|
|
44
|
+
if (!r.ok) return [];
|
|
45
|
+
const j = r.json;
|
|
46
|
+
return Array.isArray(j) ? j : (j?.tasks || j?.cards || []);
|
|
47
47
|
}
|
|
48
48
|
async function move(id, status) {
|
|
49
|
-
await
|
|
50
|
-
body: JSON.stringify({ id, status, by: "reconcile" }), signal: AbortSignal.timeout(4000) }).catch(() => {});
|
|
49
|
+
await signedPost("/task/update", { id, status, by: "reconcile" }, { timeoutMs: 4000 }).catch(() => {});
|
|
51
50
|
}
|
|
52
51
|
// the memory record for THIS project (Claude Code stores it per encoded-cwd); optional context.
|
|
53
52
|
function memoryExcerpt() {
|
package/bin/recost.mjs
CHANGED
|
@@ -10,11 +10,8 @@ import { join } from "node:path";
|
|
|
10
10
|
import { homedir } from "node:os";
|
|
11
11
|
import { scanSubagentCosts } from "../lib/subagent-scan.mjs";
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
try { const c = join(homedir(), ".agent-bus", "config.json"); if (existsSync(c)) { const u = JSON.parse(readFileSync(c, "utf8")).url; if (u) return u; } } catch {}
|
|
16
|
-
return "http://127.0.0.1:4477";
|
|
17
|
-
}
|
|
13
|
+
// Signed via the shared client (2026-07-31, agent-UX audit): unsigned POST rejected under enforce.
|
|
14
|
+
import { relayUrl, signedPost } from "../hooks/lib/api.mjs";
|
|
18
15
|
|
|
19
16
|
const dry = process.argv.includes("--dry-run");
|
|
20
17
|
const asJson = process.argv.includes("--json");
|
|
@@ -35,10 +32,11 @@ if (dry) {
|
|
|
35
32
|
}
|
|
36
33
|
|
|
37
34
|
let result;
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
35
|
+
{
|
|
36
|
+
const r = await signedPost("/subagent-recost", { entries: allEntries }, { timeoutMs: 15000 });
|
|
37
|
+
if (!r.ok) { console.error(`recost failed: hub ${r.status} at ${relayUrl()}`); process.exit(1); }
|
|
38
|
+
result = r.json;
|
|
39
|
+
}
|
|
42
40
|
|
|
43
41
|
const projs = (result?.projects || []);
|
|
44
42
|
const seeded = projs.filter(p => !p.skipped);
|
package/bin/relay-watch.mjs
CHANGED
|
@@ -2,22 +2,24 @@
|
|
|
2
2
|
// relay-watch — a live feed of the bus via SSE (true push, no polling). Run in a terminal
|
|
3
3
|
// to watch sessions talk in real time, or to monitor a presence/status board.
|
|
4
4
|
// node bin/relay-watch.mjs [session] (default: "all" — see every message)
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
// Signed + per-project hub (2026-07-31, agent-UX audit): the hand-rolled relayUrl() here read
|
|
6
|
+
// only the global config `url` (wrong hub for a migrated project) and fetched unsigned (dead
|
|
7
|
+
// 401 under RELAY_AUTH=enforce). Now: canonical resolver + this session's keypair on every call.
|
|
8
|
+
import { resolveProject, hostId, resolveHub } from "../lib/project.mjs";
|
|
9
|
+
import { loadOrCreate } from "../lib/identity.mjs";
|
|
10
|
+
import { sfetch, sfetchJson } from "../lib/signed-fetch.mjs";
|
|
8
11
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
const URL_BASE = relayUrl();
|
|
12
|
+
const PROJECT = resolveProject(process.cwd());
|
|
13
|
+
const ME = process.env.RELAY_SESSION
|
|
14
|
+
|| (process.env.RELAY_AGENT ? `${process.env.RELAY_AGENT}:${PROJECT}` : `${hostId()}:${PROJECT}`);
|
|
15
|
+
const identity = loadOrCreate(ME, "agent");
|
|
16
|
+
const URL_BASE = resolveHub(PROJECT);
|
|
15
17
|
const SESSION = process.argv[2] || "all";
|
|
16
18
|
const t = () => new Date().toLocaleTimeString();
|
|
17
19
|
|
|
18
20
|
async function showPeers() {
|
|
19
21
|
try {
|
|
20
|
-
const { peers } = await (await
|
|
22
|
+
const { peers } = await (await sfetchJson(`${URL_BASE}/peers`, { method: "GET", identity })).json();
|
|
21
23
|
const live = peers.filter(p => p.online);
|
|
22
24
|
console.log(`\n live sessions (${live.length}):`);
|
|
23
25
|
for (const p of live) console.log(` 🟢 ${p.session}${p.status ? ` — ${p.status}` : ""}`);
|
|
@@ -30,7 +32,7 @@ async function watch() {
|
|
|
30
32
|
await showPeers();
|
|
31
33
|
for (;;) {
|
|
32
34
|
try {
|
|
33
|
-
const r = await
|
|
35
|
+
const r = await sfetch(`${URL_BASE}/stream?session=${encodeURIComponent(SESSION)}`, { headers: { accept: "text/event-stream" } }, identity);
|
|
34
36
|
if (!r.ok || !r.body) throw new Error(`stream ${r.status}`);
|
|
35
37
|
let buf = "";
|
|
36
38
|
const dec = new TextDecoder();
|
package/bin/statusline.mjs
CHANGED
|
@@ -8,19 +8,18 @@ import { readFileSync, existsSync } from "node:fs";
|
|
|
8
8
|
import { join, basename } from "node:path";
|
|
9
9
|
import { homedir, hostname } from "node:os";
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
return "http://127.0.0.1:4477";
|
|
15
|
-
}
|
|
11
|
+
// Signed read via the shared client (2026-07-31, agent-UX audit): unsigned /peers is a dead 401
|
|
12
|
+
// under enforce, which painted "trantor offline" while the hub was fine.
|
|
13
|
+
import { sessionContext, signedGet } from "../hooks/lib/api.mjs";
|
|
16
14
|
async function main() {
|
|
17
15
|
let stdin = ""; try { for await (const c of process.stdin) stdin += c; } catch {}
|
|
18
16
|
let cwd = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
19
17
|
try { const j = JSON.parse(stdin || "{}"); cwd = j.cwd || j.workspace?.current_dir || cwd; } catch {}
|
|
20
|
-
const me = process.env.RELAY_SESSION ||
|
|
18
|
+
const me = process.env.RELAY_SESSION || sessionContext(cwd).session;
|
|
21
19
|
try {
|
|
22
|
-
const r = await
|
|
23
|
-
|
|
20
|
+
const r = await signedGet("/peers", { timeoutMs: 800, session: me });
|
|
21
|
+
if (!r.ok) throw new Error(`hub ${r.status}`);
|
|
22
|
+
const { peers } = r.json;
|
|
24
23
|
const live = peers.filter(p => p.online && p.session !== me).length;
|
|
25
24
|
process.stdout.write(`\x1b[38;5;43m● trantor\x1b[0m \x1b[2m· ${live} other${live === 1 ? "" : "s"} live\x1b[0m`);
|
|
26
25
|
} catch {
|
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/bin/sweep.mjs
CHANGED
|
@@ -12,11 +12,11 @@ import { join } from "node:path";
|
|
|
12
12
|
import { homedir } from "node:os";
|
|
13
13
|
import { resolveProject } from "../lib/project.mjs";
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
}
|
|
15
|
+
// Signed as the OWNER (2026-07-31, agent-UX audit): /sweep is an owner endpoint — an unsigned
|
|
16
|
+
// POST is rejected under enforce. Sign with config.ownerIdentity, same pattern as bin/policy.mjs.
|
|
17
|
+
import { relayUrl } from "../hooks/lib/api.mjs";
|
|
18
|
+
import { loadOrCreate } from "../lib/identity.mjs";
|
|
19
|
+
import { sfetchJson } from "../lib/signed-fetch.mjs";
|
|
20
20
|
function parseDur(s, def) {
|
|
21
21
|
if (!s) return def;
|
|
22
22
|
const m = String(s).match(/^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?$/i);
|
|
@@ -33,12 +33,15 @@ const olderMs = parseDur(val("--older", "-o"), 30 * 60 * 1000);
|
|
|
33
33
|
const doIt = has("--yes", "-y");
|
|
34
34
|
const allProjects = has("--all-projects", "--all");
|
|
35
35
|
const project = allProjects ? null : resolveProject(process.cwd());
|
|
36
|
-
const url = relayUrl();
|
|
36
|
+
const url = relayUrl(project || undefined);
|
|
37
|
+
let ownerName = "admin";
|
|
38
|
+
try { ownerName = JSON.parse(readFileSync(join(homedir(), ".agent-bus", "config.json"), "utf8")).ownerIdentity || "admin"; } catch {}
|
|
39
|
+
const ownerId = loadOrCreate(ownerName, "human");
|
|
37
40
|
|
|
38
41
|
async function sweep(dryRun) {
|
|
39
42
|
const body = { olderMs, dryRun, by: "cli-sweep" };
|
|
40
43
|
if (project) body.project = project;
|
|
41
|
-
const r = await
|
|
44
|
+
const r = await sfetchJson(`${url}/sweep`, { identity: ownerId, payload: body, signal: AbortSignal.timeout(6000) });
|
|
42
45
|
return r.json();
|
|
43
46
|
}
|
|
44
47
|
|
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/inbox-deliver.mjs
CHANGED
|
@@ -24,7 +24,7 @@ import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
|
24
24
|
import { join } from "node:path";
|
|
25
25
|
import { homedir } from "node:os";
|
|
26
26
|
import { resolveProject, hostId } from "../lib/project.mjs";
|
|
27
|
-
import {
|
|
27
|
+
import { signedGet } from "./lib/api.mjs"; // signed: enforce hubs 401 unsigned reads — unsigned, T1 delivery is silently dead
|
|
28
28
|
|
|
29
29
|
const POLL_MS = Number(process.env.RELAY_INBOX_POLL_MS || 4000);
|
|
30
30
|
const FETCH_TIMEOUT_MS = Number(process.env.RELAY_INBOX_TIMEOUT_MS || 1500);
|
|
@@ -34,7 +34,7 @@ const FETCH_TIMEOUT_MS = Number(process.env.RELAY_INBOX_TIMEOUT_MS || 1500);
|
|
|
34
34
|
function sanitize(s) { return String(s == null ? "" : s).replace(/[\x00-\x1f\x7f-\x9f]/g, " "); }
|
|
35
35
|
|
|
36
36
|
async function getInbox(session, since) {
|
|
37
|
-
const { ok, json } = await
|
|
37
|
+
const { ok, json } = await signedGet(`/inbox?session=${encodeURIComponent(session)}&since=${since}`, { timeoutMs: FETCH_TIMEOUT_MS, session });
|
|
38
38
|
if (!ok || !json) throw new Error("hub unreachable");
|
|
39
39
|
return json; // { messages: [...], cursor }
|
|
40
40
|
}
|
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/hooks/sessionstart.mjs
CHANGED
|
@@ -71,11 +71,14 @@ function readStdin() {
|
|
|
71
71
|
process.stdin.on("data", c => (d += c)); process.stdin.on("end", () => res(d));
|
|
72
72
|
setTimeout(() => res(d), 100); });
|
|
73
73
|
}
|
|
74
|
-
// Hub reads/writes through the shared client (TDD §8)
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
|
|
74
|
+
// Hub reads/writes through the shared client (TDD §8), ALL signed (Ed25519). Writes via signedPost
|
|
75
|
+
// close the self-asserted `from` hole; reads via signedGet survive RELAY_AUTH=enforce, which 401s
|
|
76
|
+
// unsigned reads (unsigned, the roster/handoff injection was silently dead on the remote hub — the
|
|
77
|
+
// 2026-07-30 agent-UX gap). The hub scope-filters signed reads to this identity's grants; for a
|
|
78
|
+
// session reading its own project + declared links that is the intended shape. Each resolves to
|
|
79
|
+
// {ok,status,json}; a down hub returns {ok:false,json:null} and the caller's catch keeps the
|
|
80
|
+
// session alive (fail-open contract, acceptance §9 #10).
|
|
81
|
+
async function jget(u, session) { const r = await signedGet(u, { timeoutMs: 2500, session }); return r.ok ? (r.json || {}) : {}; }
|
|
79
82
|
async function jpost(u, b, session) { return (await signedPost(u, b, { session, timeoutMs: 2500 })).ok; }
|
|
80
83
|
|
|
81
84
|
// Strip control chars from untrusted injected text so the hook's JSON stdout (which
|
package/hooks/stop-inbox.mjs
CHANGED
|
@@ -25,7 +25,7 @@ import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
|
25
25
|
import { join } from "node:path";
|
|
26
26
|
import { homedir } from "node:os";
|
|
27
27
|
import { resolveProject, hostId } from "../lib/project.mjs";
|
|
28
|
-
import {
|
|
28
|
+
import { signedGet } from "./lib/api.mjs"; // signed: enforce hubs 401 unsigned reads — unsigned, T2 delivery is silently dead
|
|
29
29
|
|
|
30
30
|
const FETCH_TIMEOUT_MS = Number(process.env.RELAY_STOP_TIMEOUT_MS || 1500);
|
|
31
31
|
|
|
@@ -75,7 +75,7 @@ async function main() {
|
|
|
75
75
|
let messages = [];
|
|
76
76
|
try {
|
|
77
77
|
// PEEK: look without claiming delivery. We may yet decide to let the stop through.
|
|
78
|
-
const peek = await
|
|
78
|
+
const peek = await signedGet(`/inbox?session=${encodeURIComponent(session)}&since=${cursor}&peek=1`, { timeoutMs: FETCH_TIMEOUT_MS, session });
|
|
79
79
|
if (!peek.ok) return allow();
|
|
80
80
|
messages = peek.json?.messages || [];
|
|
81
81
|
} catch { return allow(); } // hub down — never trap the session
|
|
@@ -86,7 +86,7 @@ async function main() {
|
|
|
86
86
|
// Committed now: claim delivery for real so neither inbox-deliver nor the deferred waker repeats it.
|
|
87
87
|
let next = cursor;
|
|
88
88
|
try {
|
|
89
|
-
const claim = await
|
|
89
|
+
const claim = await signedGet(`/inbox?session=${encodeURIComponent(session)}&since=${cursor}`, { timeoutMs: FETCH_TIMEOUT_MS, session });
|
|
90
90
|
if (claim.ok) next = claim.json?.cursor || cursor;
|
|
91
91
|
} catch {}
|
|
92
92
|
try { writeFileSync(cursorFile, String(next)); } catch {}
|
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
|
|
|
@@ -21,7 +21,7 @@ const PROJECT = resolveProject(process.env.CLAUDE_PROJECT_DIR || process.cwd());
|
|
|
21
21
|
// Hub URL is PER-PROJECT (TDD §12.1): RELAY_URL env → config.json hubs[PROJECT] → legacy
|
|
22
22
|
// global `url` → local default. A project lives on exactly one hub; codependent projects
|
|
23
23
|
// must share one, so both are pinned to the same hub via `trantor hub set`.
|
|
24
|
-
const URL_BASE = resolveHub(PROJECT);
|
|
24
|
+
const URL_BASE = resolveHub(PROJECT); // boot-time snapshot: startup log only — every api() call re-resolves
|
|
25
25
|
// Identity: RELAY_SESSION wins; else RELAY_AGENT ("codex", "kimi", …) brands the session per-project
|
|
26
26
|
// (set it once in the CLI's global MCP config — works in every project); else hostname:project.
|
|
27
27
|
const SESSION = process.env.RELAY_SESSION
|
|
@@ -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;
|
|
@@ -61,7 +63,7 @@ const server = new McpServer({ name: "trantor", version: "0.1.0" });
|
|
|
61
63
|
|
|
62
64
|
server.tool("relay_whoami", "Show this session's relay identity, project, and the hub URL.", {}, async () => {
|
|
63
65
|
await api("POST", "/register", { session: SESSION, project: PROJECT }).catch(() => {});
|
|
64
|
-
return { content: [{ type: "text", text: `session=${SESSION}\nproject=${PROJECT}\nhub=${
|
|
66
|
+
return { content: [{ type: "text", text: `session=${SESSION}\nproject=${PROJECT}\nhub=${resolveHub(PROJECT)}` }] };
|
|
65
67
|
});
|
|
66
68
|
|
|
67
69
|
server.tool("relay_task_add", "Add a Kanban card to a project's board on the dashboard (what you're about to work on). Defaults: THIS project, assigned to you, status 'todo'. Pass `project` to target another board — e.g. when you orchestrate a crew that runs in a different directory than the one you launched Claude from. Keep the team's progress visible.",
|