trantor 0.17.53 → 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.
Files changed (47) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +29 -0
  4. package/bin/adopt.mjs +117 -0
  5. package/bin/cli.mjs +98 -1
  6. package/bin/crew-runner.mjs +45 -8
  7. package/bin/crew.sh +27 -0
  8. package/bin/doctor.mjs +20 -9
  9. package/bin/inbox.mjs +64 -0
  10. package/bin/migrate-store.mjs +0 -0
  11. package/bin/overseer-narrate.mjs +77 -0
  12. package/bin/policy.mjs +74 -0
  13. package/bin/summarize.mjs +105 -0
  14. package/deploy/backup.sh +49 -0
  15. package/deploy/docker-compose.yml +29 -0
  16. package/deploy/hub.env +29 -0
  17. package/deploy/restore.sh +69 -0
  18. package/deploy/retention.sh +12 -0
  19. package/deploy/setup-netcup.sh +87 -0
  20. package/deploy/trantor-hub.service +50 -0
  21. package/hooks/agent-notify.mjs +3 -14
  22. package/hooks/file-claim.mjs +91 -0
  23. package/hooks/heartbeat.mjs +36 -16
  24. package/hooks/hooks.json +115 -11
  25. package/hooks/inbox-deliver.mjs +7 -18
  26. package/hooks/lib/api.mjs +163 -0
  27. package/hooks/lib/balance-check.mjs +3 -10
  28. package/hooks/lib/handoff.mjs +7 -8
  29. package/hooks/overseer-warn.mjs +84 -0
  30. package/hooks/prompt-focus.mjs +8 -12
  31. package/hooks/sessionstart.mjs +10 -13
  32. package/hooks/stop-inbox.mjs +106 -0
  33. package/hooks/subagent-cost.mjs +8 -16
  34. package/hooks/subagent-start.mjs +3 -15
  35. package/hooks/todo-sync.mjs +3 -19
  36. package/hub.mjs +0 -0
  37. package/lib/enroll.mjs +65 -0
  38. package/lib/identity.mjs +153 -0
  39. package/lib/overseer.mjs +140 -0
  40. package/lib/project.mjs +55 -0
  41. package/lib/scrub.mjs +67 -0
  42. package/lib/signed-fetch.mjs +40 -0
  43. package/lib/store-contract.mjs +190 -0
  44. package/lib/store-pg.mjs +682 -0
  45. package/mcp.mjs +42 -15
  46. package/package.json +4 -3
  47. package/ui.html +150 -4
@@ -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();
@@ -0,0 +1,105 @@
1
+ #!/usr/bin/env node
2
+ // trantor summarize — narrative cards: what was ASSIGNED and what was DONE, in plain language.
3
+ //
4
+ // trantor summarize [--project <p>] [--limit N] [--all] [--dry] [--quiet]
5
+ //
6
+ // Title-cleaning makes machine-generated cards readable; it cannot make them MEAN anything. This
7
+ // gives each card a one-line narrative ("assigned — did") written by a CHEAP model from the card's
8
+ // own thread (its events + the messages that cite it). The board then reads as a story a human can
9
+ // follow, which was the ask: "what the agent was assigned, what the agent did, what the flow was."
10
+ //
11
+ // Economics by design (the Scrooge doctrine): candidates are MACHINE-TITLED cards without a
12
+ // summary (--all widens to every unsummarized card), batched into ONE cheap-model call per hub,
13
+ // difficulty easy, capped per run. The summary lands via /task/update and rides the tasks.extra
14
+ // column — permanent, never recomputed. Runs ambiently from the heartbeat (hourly, detached) and
15
+ // on demand as `trantor summarize`.
16
+ import { execSync, spawnSync } from "node:child_process";
17
+ import { readFileSync, existsSync } from "node:fs";
18
+ import { join } from "node:path";
19
+ import { homedir } from "node:os";
20
+ import { loadOrCreate } from "../lib/identity.mjs";
21
+ import { sfetchJson } from "../lib/signed-fetch.mjs";
22
+
23
+ const argv = process.argv.slice(2);
24
+ const val = (k) => { const i = argv.indexOf(`--${k}`); return i >= 0 ? (argv[i + 1] ?? "") : ""; };
25
+ const has = (k) => argv.includes(`--${k}`);
26
+ const QUIET = has("quiet");
27
+ const say = (...a) => { if (!QUIET) console.log(...a); };
28
+
29
+ const BUS_DIR = process.env.AGENT_BUS_DIR || join(homedir(), ".agent-bus");
30
+ let config = {}; try { config = JSON.parse(readFileSync(join(BUS_DIR, "config.json"), "utf8")); } catch {}
31
+ const LIMIT = Math.max(1, Number(val("limit")) || 25);
32
+ const PROJECT = val("project");
33
+ const OWNER = config.ownerIdentity || "admin";
34
+ const ownerId = loadOrCreate(OWNER, "human");
35
+
36
+ const scroogeBin = () => process.env.SCROOGE_BIN
37
+ || (() => { try { return execSync("command -v scrooge", { encoding: "utf8" }).trim(); } catch {} })()
38
+ || (existsSync(new URL("../engine/bin/scrooge", import.meta.url)) ? new URL("../engine/bin/scrooge", import.meta.url).pathname : "");
39
+
40
+ // A title no human wrote: protocol frames, dumped prompts, id soup. These are the cards whose
41
+ // board presence is noise until a narrative replaces them.
42
+ const machineTitled = (t) =>
43
+ /^\s*[<{[]/.test(t) || /<task-notification>|<system-reminder>|toolu_[A-Za-z0-9]/.test(t) ||
44
+ /^\s*(subagent|general-purpose|Explore|Task|Plan):/i.test(t) || t.length > 130;
45
+
46
+ const hubs = new Set([config.url || "http://127.0.0.1:4477", ...Object.values(config.hubs || {})]);
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) => {
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
+ };
54
+
55
+ let wrote = 0, considered = 0;
56
+ for (const hub of hubs) {
57
+ let cards = [];
58
+ try { cards = (await get(hub, PROJECT ? `/tasks?project=${encodeURIComponent(PROJECT)}` : "/tasks")).tasks ?? []; }
59
+ catch { continue; }
60
+ const cand = cards
61
+ .filter(t => !t.summary && (has("all") || machineTitled(String(t.title || ""))))
62
+ .sort((a, b) => (b.updated ?? 0) - (a.updated ?? 0))
63
+ .slice(0, LIMIT);
64
+ considered += cand.length;
65
+ if (!cand.length) continue;
66
+ say(`${hub}: ${cand.length} card(s) need a narrative`);
67
+
68
+ // each card's own story: status + thread tail (the agent's reports of what it did)
69
+ const blocks = [];
70
+ for (const t of cand) {
71
+ let thread = "";
72
+ try {
73
+ const c = await get(hub, `/card?id=${t.id}`);
74
+ thread = (c.messages ?? []).slice(-5).map(m => `${m.from}: ${String(m.text).replace(/\s+/g, " ").slice(0, 220)}`).join("\n");
75
+ } catch {}
76
+ blocks.push(`CARD #${t.id} [${t.status}]${t.assignee ? ` assignee=${t.assignee}` : ""}\nTITLE: ${String(t.title).replace(/\s+/g, " ").slice(0, 400)}${thread ? `\nTHREAD:\n${thread}` : ""}`);
77
+ }
78
+ const prompt = `You write one-line narratives for cards on an AI-agent Kanban board. For EACH card below:
79
+ - "assigned": the task in plain language, action-first, <=70 chars (from TITLE; ignore XML/ids/protocol noise)
80
+ - "did": what actually happened per THREAD and status, <=80 chars; "" if nothing has happened yet
81
+ Plain words a human skims. No ids, no paths unless essential, no XML.
82
+ Return ONLY a JSON array: [{"id":<number>,"assigned":"...","did":"..."}]
83
+
84
+ ${blocks.join("\n\n")}`;
85
+
86
+ const bin = scroogeBin();
87
+ if (!bin) { console.error("scrooge not found (set SCROOGE_BIN) — cannot summarize."); process.exit(1); }
88
+ const res = spawnSync(bin, ["-t", "summarize", "-d", "easy", "--json"], { input: prompt, encoding: "utf8", timeout: 120000 });
89
+ if (res.error || (res.status !== 0 && !res.stdout)) { console.error(`scrooge failed: ${(res.stderr || res.error?.message || "").slice(-200)}`); continue; }
90
+ let rows = [];
91
+ try { const out = res.stdout || ""; rows = JSON.parse(out.slice(out.indexOf("["), out.lastIndexOf("]") + 1)); }
92
+ catch { console.error(`unparseable summarizer output: ${(res.stdout || "").slice(0, 200)}`); continue; }
93
+
94
+ const byId = new Map(cand.map(t => [t.id, t]));
95
+ for (const r of rows) {
96
+ const t = byId.get(Number(r.id));
97
+ const assigned = String(r.assigned || "").trim();
98
+ if (!t || !assigned) continue;
99
+ const summary = (r.did ? `${assigned} — ${String(r.did).trim()}` : assigned).slice(0, 220);
100
+ if (has("dry")) { say(` #${t.id} would become: ${summary}`); continue; }
101
+ try { await post(hub, "/task/update", { id: t.id, summary, by: "scrooge-summarizer" }); wrote++; say(` #${t.id} → ${summary}`); }
102
+ catch (e) { say(` #${t.id} write failed: ${e.message}`); }
103
+ }
104
+ }
105
+ say(`\n${has("dry") ? "[dry] " : ""}${wrote} narrative(s) written · ${considered} candidate(s) seen`);
@@ -0,0 +1,49 @@
1
+ #!/bin/bash
2
+ # trantor — backup Postgres + hub data to a timestamped tarball.
3
+ # Safe to run while the hub is running (pg_dump is consistent per snapshot).
4
+ # Retention: keeps last $KEEP_DAYS days (default 30).
5
+ # Usage: ./deploy/backup.sh [--pg-password PASSWORD]
6
+ set -euo pipefail
7
+
8
+ BACKUP_DIR="${BACKUP_DIR:-/var/lib/trantor/backups}"
9
+ KEEP_DAYS="${KEEP_DAYS:-30}"
10
+ PG_USER="${PG_USER:-trantor}"
11
+ PG_HOST="${PG_HOST:-127.0.0.1}"
12
+ PG_DB="${PG_DB:-trantor}"
13
+ DATA_DIR="${DATA_DIR:-/var/lib/trantor/data}"
14
+
15
+ PG_PASSWORD=""
16
+ while [ $# -gt 0 ]; do
17
+ case "$1" in
18
+ --pg-password) PG_PASSWORD="$2"; shift 2 ;;
19
+ -p) PG_PASSWORD="$2"; shift 2 ;;
20
+ *) echo "unknown flag: $1"; exit 1 ;;
21
+ esac
22
+ done
23
+
24
+ mkdir -p "$BACKUP_DIR"
25
+ TIMESTAMP="$(date -u +%Y-%m-%dT%H%M%SZ)"
26
+ BACKUP_FILE="$BACKUP_DIR/trantor-$TIMESTAMP.tar.gz"
27
+
28
+ TMP="$(mktemp -d)"
29
+ trap 'rm -rf "$TMP"' EXIT
30
+
31
+ # Dump Postgres
32
+ if [ -n "$PG_PASSWORD" ]; then
33
+ PGPASSWORD="$PG_PASSWORD" pg_dump -U "$PG_USER" -h "$PG_HOST" -d "$PG_DB" -Fc -f "$TMP/pg.dump"
34
+ else
35
+ pg_dump -U "$PG_USER" -h "$PG_HOST" -d "$PG_DB" -Fc -f "$TMP/pg.dump"
36
+ fi
37
+
38
+ # Copy hub data (bus.json, identities, etc.)
39
+ if [ -d "$DATA_DIR" ]; then
40
+ cp -r "$DATA_DIR" "$TMP/hub-data"
41
+ fi
42
+
43
+ tar -czf "$BACKUP_FILE" -C "$TMP" .
44
+
45
+ # Prune backups older than KEEP_DAYS
46
+ find "$BACKUP_DIR" -name "trantor-*.tar.gz" -mtime "+$KEEP_DAYS" -delete
47
+
48
+ echo "backup: $BACKUP_FILE ($(du -h "$BACKUP_FILE" | cut -f1))"
49
+ echo "retention: keeping last $KEEP_DAYS days"
@@ -0,0 +1,29 @@
1
+ # trantor netcup deploy — Dockerised Postgres for the relay hub.
2
+ # Start: docker compose -f deploy/docker-compose.yml up -d
3
+ # Stop: docker compose -f deploy/docker-compose.yml down
4
+ services:
5
+ postgres:
6
+ image: postgres:16-alpine
7
+ container_name: trantor-pg
8
+ restart: unless-stopped
9
+ environment:
10
+ POSTGRES_USER: trantor
11
+ POSTGRES_PASSWORD: ${PG_PASSWORD:?set PG_PASSWORD in .env}
12
+ POSTGRES_DB: trantor
13
+ volumes:
14
+ - pgdata:/var/lib/postgresql/data
15
+ ports:
16
+ - "127.0.0.1:5432:5432"
17
+ healthcheck:
18
+ test: ["CMD-SHELL", "pg_isready -U trantor -d trantor"]
19
+ interval: 10s
20
+ timeout: 5s
21
+ retries: 5
22
+ logging:
23
+ driver: "json-file"
24
+ options:
25
+ max-size: "10m"
26
+ max-file: "3"
27
+
28
+ volumes:
29
+ pgdata:
package/deploy/hub.env ADDED
@@ -0,0 +1,29 @@
1
+ # trantor hub env — deployed on netcup (Debian 13)
2
+ # Bind to the tailnet IP so agents on other machines can reach the hub.
3
+ # Non-loopback bind FORCES RELAY_AUTH=enforce (hub.mjs will exit otherwise).
4
+ RELAY_HOST=100.79.242.104
5
+ RELAY_PORT=4477
6
+ RELAY_AUTH=enforce
7
+ RELAY_ENROLL=invite
8
+
9
+ # Persistent data directory (separate volume, survives restarts)
10
+ RELAY_DATA_DIR=/var/lib/trantor/data
11
+
12
+ # TIME-based retention — events older than this (ms) are pruned.
13
+ # 30 days = 30 × 86400 × 1000
14
+ RELAY_EVENT_RETENTION_MS=2592000000
15
+
16
+ # Unbounded-log guard — cap total events regardless of age
17
+ RELAY_EVENT_CAP=50000
18
+
19
+ # Peer TTL: how long after last heartbeat before a peer is pruned (24h)
20
+ RELAY_PEER_TTL_MS=86400000
21
+
22
+ # Online window: a peer is "online" if last seen within this (5 min)
23
+ RELAY_ONLINE_MS=300000
24
+
25
+ # Stale-card reaper grace: card owner offline for 15 min → stale
26
+ RELAY_REAP_GRACE_MS=900000
27
+
28
+ # Postgres connection (set in /etc/trantor/env, never committed)
29
+ # DATABASE_URL=postgresql://trantor:<password>@127.0.0.1:5432/trantor
@@ -0,0 +1,69 @@
1
+ #!/bin/bash
2
+ # trantor — restore a backup created by backup.sh.
3
+ # WARNING: this drops and recreates the Postgres database.
4
+ # Usage: ./deploy/restore.sh <backup.tar.gz> [--pg-password PASSWORD]
5
+ set -euo pipefail
6
+
7
+ if [ $# -lt 1 ]; then
8
+ echo "usage: $0 <backup.tar.gz> [--pg-password PASS]"
9
+ echo "backups available:"
10
+ ls -1 /var/lib/trantor/backups/ 2>/dev/null || echo " (none)"
11
+ exit 1
12
+ fi
13
+
14
+ BACKUP_FILE="$1"; shift
15
+ PG_USER="${PG_USER:-trantor}"
16
+ PG_HOST="${PG_HOST:-127.0.0.1}"
17
+ PG_DB="${PG_DB:-trantor}"
18
+ DATA_DIR="${DATA_DIR:-/var/lib/trantor/data}"
19
+
20
+ PG_PASSWORD=""
21
+ while [ $# -gt 0 ]; do
22
+ case "$1" in
23
+ --pg-password) PG_PASSWORD="$2"; shift 2 ;;
24
+ -p) PG_PASSWORD="$2"; shift 2 ;;
25
+ *) echo "unknown flag: $1"; exit 1 ;;
26
+ esac
27
+ done
28
+
29
+ if [ ! -f "$BACKUP_FILE" ]; then
30
+ echo "backup file not found: $BACKUP_FILE"
31
+ exit 1
32
+ fi
33
+
34
+ echo "=== RESTORE from $BACKUP_FILE ==="
35
+ TMP="$(mktemp -d)"
36
+ trap 'rm -rf "$TMP"' EXIT
37
+
38
+ tar -xzf "$BACKUP_FILE" -C "$TMP"
39
+
40
+ # Ensure psql/pg_restore are available
41
+ command -v psql >/dev/null 2>&1 || { echo "psql not found — install postgresql-client"; exit 1; }
42
+ command -v pg_restore >/dev/null 2>&1 || { echo "pg_restore not found — install postgresql-client"; exit 1; }
43
+
44
+ # Determine connection mechanism
45
+ CONN=""
46
+ if [ -n "$PG_PASSWORD" ]; then
47
+ export PGPASSWORD="$PG_PASSWORD"
48
+ CONN=(-U "$PG_USER" -h "$PG_HOST" -d "$PG_DB")
49
+ else
50
+ CONN=(-U "$PG_USER" -h "$PG_HOST" -d "$PG_DB")
51
+ fi
52
+
53
+ if [ -f "$TMP/pg.dump" ]; then
54
+ echo "restoring Postgres..."
55
+ psql "${CONN[@]}" -c "SELECT pg_terminate_backend(pg_stat_activity.pid) FROM pg_stat_activity WHERE pg_stat_activity.datname = '$PG_DB' AND pid <> pg_backend_pid();" >/dev/null 2>&1 || true
56
+ psql "${CONN[@]}" -c "DROP DATABASE IF EXISTS $PG_DB;" postgres >/dev/null 2>&1 || true
57
+ psql "${CONN[@]}" -c "CREATE DATABASE $PG_DB OWNER $PG_USER;" postgres >/dev/null 2>&1 || true
58
+ pg_restore --no-owner --no-privileges -U "$PG_USER" -h "$PG_HOST" -d "$PG_DB" -c "$TMP/pg.dump"
59
+ else
60
+ echo "(no pg.dump in backup — skipping Postgres restore)"
61
+ fi
62
+
63
+ if [ -d "$TMP/hub-data" ]; then
64
+ echo "restoring hub data..."
65
+ rm -rf "$DATA_DIR"
66
+ cp -r "$TMP/hub-data" "$DATA_DIR"
67
+ fi
68
+
69
+ echo "=== restore complete ==="
@@ -0,0 +1,12 @@
1
+ #!/bin/bash
2
+ # trantor TIME-based retention — prune old events from Postgres.
3
+ # Keeps projections (tasks, messages, peers) — invariant 5 from store-contract.
4
+ # Run via cron: 0 3 * * * /opt/trantor/deploy/retention.sh
5
+ set -euo pipefail
6
+
7
+ RETENTION_DAYS="${RETENTION_DAYS:-90}"
8
+ CUTOFF_MS="$(node -e "const d=Number(process.env.RETENTION_DAYS||90); process.stdout.write(String(Date.now() - d*864e5))")"
9
+
10
+ docker exec trantor-pg psql -U trantor -d trantor -c "
11
+ DELETE FROM events WHERE ts < ${CUTOFF_MS};
12
+ " 2>&1 | tail -1
@@ -0,0 +1,87 @@
1
+ #!/bin/bash
2
+ # trantor — netcup Debian 13 one-shot server setup. Run as root.
3
+ set -euo pipefail
4
+
5
+ echo "=== trantor netcup setup (node $(node --version)) ==="
6
+
7
+ if ! id trantor &>/dev/null; then
8
+ useradd -r -m -d /var/lib/trantor -s /usr/sbin/nologin trantor
9
+ echo "created trantor user"
10
+ fi
11
+
12
+ install -d -o trantor -g trantor -m 750 /opt/trantor
13
+ install -d -o trantor -g trantor -m 750 /var/lib/trantor/data
14
+ install -d -o trantor -g trantor -m 750 /var/lib/trantor/backups
15
+
16
+ if [ ! -f /opt/trantor/hub.mjs ]; then
17
+ echo "copy repo to /opt/trantor first (e.g. rsync ./ netcup:/opt/trantor/), then re-run"
18
+ exit 1
19
+ fi
20
+ chown -R trantor:trantor /opt/trantor
21
+
22
+ # secrets: generate Postgres password, write to /etc/trantor/env
23
+ # Secrets live in /etc/trantor/pg.env — the EXACT path trantor-hub.service reads via
24
+ # EnvironmentFile. That file is prefixed `-`, so a name mismatch fails SILENTLY: the hub would boot
25
+ # with no DATABASE_URL and no RELAY_STORE, quietly ignoring the Postgres we just provisioned.
26
+ # It must also carry RELAY_STORE=pg — lib/store-pg.mjs is only selected when that is set.
27
+ if [ ! -f /etc/trantor/pg.env ]; then
28
+ PG_PASS="$(openssl rand -base64 32 | tr -d '/+=' | head -c 32)"
29
+ install -d -m 750 /etc/trantor
30
+ # One-time bootstrap secret for the FIRST admin. A remote hub binds non-loopback, so it must run
31
+ # enforce+invite — but minting an invite needs an enrolled owner, and there is none. This token is
32
+ # accepted ONLY while the identity store is empty; the first enrollment closes the path for good.
33
+ BOOT_TOK="$(openssl rand -hex 24)"
34
+ cat > /etc/trantor/pg.env <<EOF
35
+ PG_PASSWORD=$PG_PASS
36
+ RELAY_STORE=pg
37
+ DATABASE_URL=postgresql://trantor:$PG_PASS@127.0.0.1:5432/trantor
38
+ RELAY_BOOTSTRAP_TOKEN=$BOOT_TOK
39
+ EOF
40
+ chmod 600 /etc/trantor/pg.env
41
+ echo "wrote /etc/trantor/pg.env (RELAY_STORE=pg + DATABASE_URL)"
42
+ fi
43
+ source /etc/trantor/pg.env
44
+ export PG_PASSWORD DATABASE_URL RELAY_STORE
45
+
46
+ # install dependencies (node 20 native — no build tools needed)
47
+ cd /opt/trantor
48
+ npm install --omit=dev --no-audit --no-fund 2>/dev/null || true
49
+ chown -R trantor:trantor node_modules
50
+
51
+ # Dockerised Postgres
52
+ echo "starting Postgres..."
53
+ docker compose -f deploy/docker-compose.yml up -d
54
+ RETRIES=30
55
+ until docker exec trantor-pg pg_isready -U trantor -d trantor 2>/dev/null; do
56
+ sleep 1; ((RETRIES--))
57
+ if [ "$RETRIES" -le 0 ]; then echo "Postgres failed to start after 30s"; exit 1; fi
58
+ done
59
+ echo "Postgres ready"
60
+
61
+ # apply schema (from store-contract.mjs)
62
+ echo "applying schema..."
63
+ SCHEMA_SQL="$(node --input-type=module --eval '
64
+ import { SCHEMA_SQL } from "./lib/store-contract.mjs";
65
+ console.log(SCHEMA_SQL);
66
+ ' 2>/dev/null || true)"
67
+ if [ -n "$SCHEMA_SQL" ]; then
68
+ echo "$SCHEMA_SQL" | docker exec -i trantor-pg psql -U trantor -d trantor 2>/dev/null || true
69
+ fi
70
+ echo " schema applied"
71
+
72
+ # systemd unit
73
+ cp deploy/trantor-hub.service /etc/systemd/system/trantor-hub.service
74
+ systemctl daemon-reload
75
+ systemctl enable --now trantor-hub
76
+
77
+ # crons: daily backup + nightly retention
78
+ chmod +x deploy/backup.sh deploy/retention.sh deploy/restore.sh
79
+ echo "0 2 * * * root /opt/trantor/deploy/backup.sh" > /etc/cron.d/trantor
80
+ echo "0 3 * * * root /opt/trantor/deploy/retention.sh" >> /etc/cron.d/trantor
81
+
82
+ echo ""
83
+ echo "=== done ==="
84
+ echo "Hub: http://100.79.242.104:4477"
85
+ echo "Logs: journalctl -u trantor-hub -f"
86
+ echo "Backups: /var/lib/trantor/backups/"
87
+ echo "Restore: bash /opt/trantor/deploy/restore.sh <file>"
@@ -0,0 +1,50 @@
1
+ # trantor hub — systemd service for Debian 13 (netcup).
2
+ # Install: sudo cp deploy/trantor-hub.service /etc/systemd/system/ && sudo systemctl daemon-reload
3
+
4
+ [Unit]
5
+ Description=trantor relay hub (agent bus + dashboard)
6
+ After=network-online.target docker.service
7
+ Wants=network-online.target
8
+ Requires=docker.service
9
+
10
+ [Service]
11
+ Type=simple
12
+ User=trantor
13
+ Group=trantor
14
+ WorkingDirectory=/opt/trantor
15
+ ExecStart=/usr/bin/node /opt/trantor/hub.mjs
16
+ Restart=always
17
+ RestartSec=10
18
+
19
+ # Bind tailnet IP 100.79.242.104 (non-loopback).
20
+ # hub.mjs L39-42 REFUSES non-loopback unless RELAY_AUTH=enforce.
21
+ Environment=RELAY_HOST=100.79.242.104
22
+ Environment=RELAY_PORT=4477
23
+ Environment=RELAY_AUTH=enforce
24
+ Environment=RELAY_ENROLL=invite
25
+
26
+ # Data directory — persistent, outside the repo.
27
+ Environment=RELAY_DATA_DIR=/var/lib/trantor/data
28
+
29
+ # TIME-based retention controls (all env-overridable; defaults shown).
30
+ Environment=RELAY_EVENT_CAP=50000
31
+ Environment=RELAY_ONLINE_MS=300000
32
+ Environment=RELAY_PEER_TTL_MS=21600000
33
+ Environment=RELAY_REAP_GRACE_MS=900000
34
+
35
+ # Postgres connection (pg-store lands via #3931).
36
+ EnvironmentFile=-/etc/trantor/pg.env
37
+
38
+ StandardOutput=journal
39
+ StandardError=journal
40
+ SyslogIdentifier=trantor-hub
41
+
42
+ # Security hardening
43
+ NoNewPrivileges=yes
44
+ ProtectSystem=strict
45
+ ProtectHome=yes
46
+ ReadWritePaths=/var/lib/trantor /tmp
47
+ ReadOnlyPaths=/opt/trantor
48
+
49
+ [Install]
50
+ WantedBy=multi-user.target
@@ -12,21 +12,14 @@
12
12
  // (session_id + notification_type + message are the confirmed fields). We read several candidates and let the
13
13
  // hub key/guard on whatever we send; TRANTOR_DEBUG_NOTIFY=1 dumps the raw payload to stderr so the first real
14
14
  // firing reveals the true shape. Fail-silent: a notification must never block the session.
15
- import { readFileSync, existsSync } from "node:fs";
16
- import { join } from "node:path";
17
- import { homedir } from "node:os";
18
15
  import { resolveProject, hostId } from "../lib/project.mjs";
16
+ import { signedPost } from "./lib/api.mjs";
19
17
 
20
18
  function readStdin() {
21
19
  return new Promise(res => { let d = ""; process.stdin.setEncoding("utf8");
22
20
  process.stdin.on("data", c => (d += c)); process.stdin.on("end", () => res(d));
23
21
  setTimeout(() => res(d), 100); });
24
22
  }
25
- function relayUrl() {
26
- if (process.env.RELAY_URL) return process.env.RELAY_URL;
27
- 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 {}
28
- return "http://127.0.0.1:4477";
29
- }
30
23
 
31
24
  try {
32
25
  const input = JSON.parse((await readStdin()) || "{}");
@@ -48,15 +41,11 @@ try {
48
41
  const msg = String(input.message || "").replace(/\s+/g, " ").trim().slice(0, 90);
49
42
  const title = `${agentType}${msg ? `: ${msg}` : ""}`.slice(0, 180);
50
43
 
51
- await fetch(`${relayUrl()}/task`, {
52
- method: "POST", headers: { "content-type": "application/json" },
53
- body: JSON.stringify({
44
+ await signedPost("/task", {
54
45
  project, source: "cc-bg-agent", notificationType: ntype,
55
46
  title, agentId, agentType, parent,
56
47
  assignee: `${agentType}:${project}`, by: `${hostId()}:${project}`, phase: "sub-agents",
57
- }),
58
- signal: AbortSignal.timeout(1500),
59
- }).catch(() => {});
48
+ });
60
49
  } catch (e) {
61
50
  process.stderr.write(`[trantor] agent-notify error: ${e?.message || e}\n`);
62
51
  }