trantor 0.17.52 → 0.17.55
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 +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +29 -0
- package/bin/adopt.mjs +117 -0
- package/bin/cli.mjs +94 -1
- package/bin/crew-runner.mjs +45 -8
- package/bin/crew.sh +59 -14
- package/bin/doctor.mjs +20 -9
- package/bin/migrate-store.mjs +0 -0
- package/bin/summarize.mjs +100 -0
- package/deploy/backup.sh +49 -0
- package/deploy/docker-compose.yml +29 -0
- package/deploy/hub.env +29 -0
- package/deploy/restore.sh +69 -0
- package/deploy/retention.sh +12 -0
- package/deploy/setup-netcup.sh +87 -0
- package/deploy/trantor-hub.service +50 -0
- package/hooks/agent-notify.mjs +3 -14
- package/hooks/file-claim.mjs +91 -0
- package/hooks/heartbeat.mjs +36 -16
- package/hooks/hooks.json +111 -11
- package/hooks/inbox-deliver.mjs +7 -18
- package/hooks/lib/api.mjs +141 -0
- package/hooks/lib/balance-check.mjs +3 -10
- package/hooks/lib/handoff.mjs +7 -8
- package/hooks/prompt-focus.mjs +8 -12
- package/hooks/sessionstart.mjs +10 -13
- package/hooks/stop-inbox.mjs +106 -0
- package/hooks/subagent-cost.mjs +8 -16
- package/hooks/subagent-start.mjs +3 -15
- package/hooks/todo-sync.mjs +3 -19
- package/hub.mjs +0 -0
- package/lib/enroll.mjs +65 -0
- package/lib/identity.mjs +153 -0
- package/lib/project.mjs +55 -0
- package/lib/scrub.mjs +67 -0
- package/lib/signed-fetch.mjs +40 -0
- package/lib/store-contract.mjs +190 -0
- package/lib/store-pg.mjs +682 -0
- package/mcp.mjs +40 -15
- package/package.json +4 -3
- package/ui.html +150 -4
|
@@ -0,0 +1,100 @@
|
|
|
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) => (await sfetchJson(`${hub}${path}`, { identity: ownerId, payload, signal: AbortSignal.timeout(8000) })).json();
|
|
49
|
+
|
|
50
|
+
let wrote = 0, considered = 0;
|
|
51
|
+
for (const hub of hubs) {
|
|
52
|
+
let cards = [];
|
|
53
|
+
try { cards = (await get(hub, PROJECT ? `/tasks?project=${encodeURIComponent(PROJECT)}` : "/tasks")).tasks ?? []; }
|
|
54
|
+
catch { continue; }
|
|
55
|
+
const cand = cards
|
|
56
|
+
.filter(t => !t.summary && (has("all") || machineTitled(String(t.title || ""))))
|
|
57
|
+
.sort((a, b) => (b.updated ?? 0) - (a.updated ?? 0))
|
|
58
|
+
.slice(0, LIMIT);
|
|
59
|
+
considered += cand.length;
|
|
60
|
+
if (!cand.length) continue;
|
|
61
|
+
say(`${hub}: ${cand.length} card(s) need a narrative`);
|
|
62
|
+
|
|
63
|
+
// each card's own story: status + thread tail (the agent's reports of what it did)
|
|
64
|
+
const blocks = [];
|
|
65
|
+
for (const t of cand) {
|
|
66
|
+
let thread = "";
|
|
67
|
+
try {
|
|
68
|
+
const c = await get(hub, `/card?id=${t.id}`);
|
|
69
|
+
thread = (c.messages ?? []).slice(-5).map(m => `${m.from}: ${String(m.text).replace(/\s+/g, " ").slice(0, 220)}`).join("\n");
|
|
70
|
+
} catch {}
|
|
71
|
+
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}` : ""}`);
|
|
72
|
+
}
|
|
73
|
+
const prompt = `You write one-line narratives for cards on an AI-agent Kanban board. For EACH card below:
|
|
74
|
+
- "assigned": the task in plain language, action-first, <=70 chars (from TITLE; ignore XML/ids/protocol noise)
|
|
75
|
+
- "did": what actually happened per THREAD and status, <=80 chars; "" if nothing has happened yet
|
|
76
|
+
Plain words a human skims. No ids, no paths unless essential, no XML.
|
|
77
|
+
Return ONLY a JSON array: [{"id":<number>,"assigned":"...","did":"..."}]
|
|
78
|
+
|
|
79
|
+
${blocks.join("\n\n")}`;
|
|
80
|
+
|
|
81
|
+
const bin = scroogeBin();
|
|
82
|
+
if (!bin) { console.error("scrooge not found (set SCROOGE_BIN) — cannot summarize."); process.exit(1); }
|
|
83
|
+
const res = spawnSync(bin, ["-t", "summarize", "-d", "easy", "--json"], { input: prompt, encoding: "utf8", timeout: 120000 });
|
|
84
|
+
if (res.error || (res.status !== 0 && !res.stdout)) { console.error(`scrooge failed: ${(res.stderr || res.error?.message || "").slice(-200)}`); continue; }
|
|
85
|
+
let rows = [];
|
|
86
|
+
try { const out = res.stdout || ""; rows = JSON.parse(out.slice(out.indexOf("["), out.lastIndexOf("]") + 1)); }
|
|
87
|
+
catch { console.error(`unparseable summarizer output: ${(res.stdout || "").slice(0, 200)}`); continue; }
|
|
88
|
+
|
|
89
|
+
const byId = new Map(cand.map(t => [t.id, t]));
|
|
90
|
+
for (const r of rows) {
|
|
91
|
+
const t = byId.get(Number(r.id));
|
|
92
|
+
const assigned = String(r.assigned || "").trim();
|
|
93
|
+
if (!t || !assigned) continue;
|
|
94
|
+
const summary = (r.did ? `${assigned} — ${String(r.did).trim()}` : assigned).slice(0, 220);
|
|
95
|
+
if (has("dry")) { say(` #${t.id} would become: ${summary}`); continue; }
|
|
96
|
+
try { await post(hub, "/task/update", { id: t.id, summary, by: "scrooge-summarizer" }); wrote++; say(` #${t.id} → ${summary}`); }
|
|
97
|
+
catch (e) { say(` #${t.id} write failed: ${e.message}`); }
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
say(`\n${has("dry") ? "[dry] " : ""}${wrote} narrative(s) written · ${considered} candidate(s) seen`);
|
package/deploy/backup.sh
ADDED
|
@@ -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
|
package/hooks/agent-notify.mjs
CHANGED
|
@@ -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
|
|
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
|
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// trantor PreToolUse file-claim — shared-resource awareness across sessions.
|
|
3
|
+
//
|
|
4
|
+
// THE problem this solves: two orchestrators (or an orchestrator and a crew seat) touching the
|
|
5
|
+
// same project learn about each other's edits at git time — merge conflicts, clobbered work,
|
|
6
|
+
// "Edit succeeded but didn't persist". The bus already carries messages; this makes it carry
|
|
7
|
+
// INTENT: before every file edit, the session posts a claim, and the hub answers with any LIVE
|
|
8
|
+
// claim on the same file by a DIFFERENT session. That answer is handed to the acting session's
|
|
9
|
+
// own model as context — through its own harness, the only safe channel (see stop-inbox.mjs for
|
|
10
|
+
// why reaching across process boundaries is forbidden).
|
|
11
|
+
//
|
|
12
|
+
// Deliberately INFORMATIONAL, never blocking: a warning in context lets the model decide to
|
|
13
|
+
// coordinate (relay_send) or proceed; a denied edit would make the hook a lock server, and a
|
|
14
|
+
// lock server that fails open (as hooks must) is worse than no lock at all.
|
|
15
|
+
//
|
|
16
|
+
// Fail-open + cheap by contract: tight timeout, per-(session,file) stamp throttles re-claims of
|
|
17
|
+
// the same file to once per window — but the FIRST touch of each file always goes out, and that
|
|
18
|
+
// is exactly the moment a collision warning matters.
|
|
19
|
+
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
20
|
+
import { join, relative, isAbsolute } from "node:path";
|
|
21
|
+
import { homedir } from "node:os";
|
|
22
|
+
import { relayUrl, sessionContext, signedPost } from "./lib/api.mjs";
|
|
23
|
+
|
|
24
|
+
const FETCH_TIMEOUT_MS = Number(process.env.RELAY_CLAIM_TIMEOUT_MS || 900);
|
|
25
|
+
const RECLAIM_MS = Number(process.env.RELAY_RECLAIM_MS || 60 * 1000);
|
|
26
|
+
|
|
27
|
+
const allow = () => { process.stdout.write("{}"); process.exit(0); };
|
|
28
|
+
|
|
29
|
+
function readStdin() {
|
|
30
|
+
return new Promise(res => {
|
|
31
|
+
let d = ""; process.stdin.setEncoding("utf8");
|
|
32
|
+
process.stdin.on("data", c => { d += c; });
|
|
33
|
+
process.stdin.on("end", () => res(d));
|
|
34
|
+
setTimeout(() => res(d), 400);
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function fileOf(toolName, input) {
|
|
39
|
+
if (!input || typeof input !== "object") return "";
|
|
40
|
+
if (toolName === "NotebookEdit") return String(input.notebook_path || "");
|
|
41
|
+
return String(input.file_path || "");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const ago = s => (s < 60 ? `${s}s` : s < 3600 ? `${Math.round(s / 60)}m` : `${Math.round(s / 3600)}h`);
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const raw = await readStdin();
|
|
48
|
+
const input = JSON.parse(raw || "{}");
|
|
49
|
+
const abs = fileOf(String(input.tool_name || ""), input.tool_input);
|
|
50
|
+
if (!abs) allow();
|
|
51
|
+
|
|
52
|
+
const ctx = sessionContext(input.cwd);
|
|
53
|
+
if (!ctx.project) allow();
|
|
54
|
+
// claims compare by path, and absolute paths differ per machine — store repo-relative
|
|
55
|
+
const file = isAbsolute(abs) && !relative(ctx.projectDir, abs).startsWith("..")
|
|
56
|
+
? relative(ctx.projectDir, abs)
|
|
57
|
+
: abs;
|
|
58
|
+
|
|
59
|
+
// throttle re-claims of the same file; never throttle its first touch
|
|
60
|
+
const stampDir = join(process.env.AGENT_BUS_DIR || join(homedir(), ".agent-bus"), "claims");
|
|
61
|
+
const stamp = join(stampDir, `${ctx.session} ${file}`.replace(/[^A-Za-z0-9_.-]/g, "_"));
|
|
62
|
+
try {
|
|
63
|
+
const last = Number(readFileSync(stamp, "utf8"));
|
|
64
|
+
if (Date.now() - last < RECLAIM_MS) allow();
|
|
65
|
+
} catch {}
|
|
66
|
+
|
|
67
|
+
const r = await signedPost(`${relayUrl(ctx.project)}/claim`,
|
|
68
|
+
{ project: ctx.project, file, session: ctx.session },
|
|
69
|
+
{ timeoutMs: FETCH_TIMEOUT_MS, session: ctx.session });
|
|
70
|
+
|
|
71
|
+
try { mkdirSync(stampDir, { recursive: true }); writeFileSync(stamp, String(Date.now())); } catch {}
|
|
72
|
+
|
|
73
|
+
const conflicts = r.ok ? r.json?.conflicts ?? [] : [];
|
|
74
|
+
if (!conflicts.length) allow();
|
|
75
|
+
|
|
76
|
+
const who = conflicts.map(c => `${c.session} (${ago(c.agoSec)} ago)`).join(", ");
|
|
77
|
+
process.stdout.write(JSON.stringify({
|
|
78
|
+
hookSpecificOutput: {
|
|
79
|
+
hookEventName: "PreToolUse",
|
|
80
|
+
permissionDecision: "allow",
|
|
81
|
+
additionalContext:
|
|
82
|
+
`⚠️ trantor: ${who} also edited ${file} in project "${ctx.project}" within the last few minutes — ` +
|
|
83
|
+
`you are both touching the same file RIGHT NOW. Before making conflicting changes, coordinate over ` +
|
|
84
|
+
`the bus: relay_send to ${conflicts[0].session} saying what you're changing, or split the work. ` +
|
|
85
|
+
`Proceed only if your edits cannot collide.`,
|
|
86
|
+
},
|
|
87
|
+
}));
|
|
88
|
+
process.exit(0);
|
|
89
|
+
} catch {
|
|
90
|
+
allow();
|
|
91
|
+
}
|
package/hooks/heartbeat.mjs
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// per HEARTBEAT_MS, and a short fetch timeout means we never add real latency to a tool call.
|
|
14
14
|
// We POST /register WITHOUT a status field so the session's meaningful status is preserved
|
|
15
15
|
// (the hub only overwrites status when one is supplied).
|
|
16
|
-
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
16
|
+
import { readFileSync, writeFileSync, existsSync, statSync, openSync, readSync, closeSync } from "node:fs";
|
|
17
17
|
import { join, basename, dirname } from "node:path";
|
|
18
18
|
import { homedir, hostname } from "node:os";
|
|
19
19
|
import { spawn } from "node:child_process";
|
|
@@ -21,12 +21,33 @@ import { fileURLToPath } from "node:url";
|
|
|
21
21
|
import { readConfig, contextUsage, warnFrac, alreadyHandedOff, markHandedOff, controllingTty, terminalWindowForTty, subagentsActive } from "./lib/handoff.mjs";
|
|
22
22
|
import { resolveProject, hostId } from "../lib/project.mjs";
|
|
23
23
|
import { installedVersion } from "./lib/update-check.mjs"; // report our hook version so the hub can flag stale sessions
|
|
24
|
+
import { signedPost } from "./lib/api.mjs";
|
|
24
25
|
|
|
25
26
|
const HEARTBEAT_MS = Number(process.env.RELAY_HEARTBEAT_MS || 60 * 1000);
|
|
26
27
|
const FETCH_TIMEOUT_MS = Number(process.env.RELAY_HEARTBEAT_TIMEOUT_MS || 1500);
|
|
27
28
|
const INFLIGHT_MS = 5 * 60 * 1000;
|
|
28
29
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
29
30
|
|
|
31
|
+
// The model this session is ACTUALLY running, read from the transcript tail — the harness does not
|
|
32
|
+
// hand hooks a model field, but every assistant entry records one. Tail-read only (transcripts grow
|
|
33
|
+
// to MBs); any failure returns "" and the peer simply keeps its last known model.
|
|
34
|
+
function modelFromTranscript(stdinRaw) {
|
|
35
|
+
try {
|
|
36
|
+
const tp = JSON.parse(stdinRaw || "{}").transcript_path;
|
|
37
|
+
if (!tp || !existsSync(tp)) return "";
|
|
38
|
+
const size = statSync(tp).size, want = Math.min(size, 65536);
|
|
39
|
+
const f = openSync(tp, "r");
|
|
40
|
+
try {
|
|
41
|
+
const buf = Buffer.alloc(want);
|
|
42
|
+
readSync(f, buf, 0, want, size - want);
|
|
43
|
+
const m = [...buf.toString("utf8").matchAll(/"model"\s*:\s*"([^"]+)"/g)]
|
|
44
|
+
.map(x => x[1]).filter(v => v.startsWith("claude"));
|
|
45
|
+
return m.length ? m[m.length - 1] : "";
|
|
46
|
+
} finally { closeSync(f); }
|
|
47
|
+
} catch { return ""; }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
30
51
|
function readStdin() {
|
|
31
52
|
return new Promise(res => { let d = ""; process.stdin.setEncoding("utf8");
|
|
32
53
|
process.stdin.on("data", c => (d += c)); process.stdin.on("end", () => res(d));
|
|
@@ -84,15 +105,6 @@ async function maybeEarlyWarn(stdinRaw, session) {
|
|
|
84
105
|
} catch {}
|
|
85
106
|
}
|
|
86
107
|
|
|
87
|
-
function relayUrl() {
|
|
88
|
-
if (process.env.RELAY_URL) return process.env.RELAY_URL;
|
|
89
|
-
try {
|
|
90
|
-
const cfg = join(homedir(), ".agent-bus", "config.json");
|
|
91
|
-
if (existsSync(cfg)) { const u = JSON.parse(readFileSync(cfg, "utf8")).url; if (u) return u; }
|
|
92
|
-
} catch {}
|
|
93
|
-
return "http://127.0.0.1:4477";
|
|
94
|
-
}
|
|
95
|
-
|
|
96
108
|
async function main(stdinRaw) {
|
|
97
109
|
const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
98
110
|
// Mirror sessionstart.mjs: home-directory sessions aren't project work — don't register
|
|
@@ -118,13 +130,21 @@ async function main(stdinRaw) {
|
|
|
118
130
|
try { writeFileSync(stamp, String(Date.now())); } catch {}
|
|
119
131
|
|
|
120
132
|
// POST /register with no status -> hub refreshes lastSeen + project, preserves status.
|
|
133
|
+
await signedPost("/register", { session, project, llm: "claude", model: modelFromTranscript(stdinRaw),
|
|
134
|
+
hookVersion: (() => { try { return installedVersion(); } catch { return ""; } })() }, { session, timeoutMs: Number(process.env.RELAY_HEARTBEAT_TIMEOUT_MS || 1500) });
|
|
135
|
+
|
|
136
|
+
// Ambient narratives: once an hour (machine-wide stamp), spawn the summarizer detached — the
|
|
137
|
+
// board's machine-titled cards gain a plain-language "assigned — did" line without anyone asking.
|
|
121
138
|
try {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
139
|
+
const sumStamp = join(homedir(), ".agent-bus", "summarize.stamp");
|
|
140
|
+
const last = existsSync(sumStamp) ? Number(readFileSync(sumStamp, "utf8")) || 0 : 0;
|
|
141
|
+
if (Date.now() - last > 60 * 60 * 1000) {
|
|
142
|
+
writeFileSync(sumStamp, String(Date.now()));
|
|
143
|
+
const worker = spawn(process.execPath, [join(HERE, "..", "bin", "summarize.mjs"), "--quiet"], {
|
|
144
|
+
detached: true, stdio: "ignore",
|
|
145
|
+
});
|
|
146
|
+
worker.unref();
|
|
147
|
+
}
|
|
128
148
|
} catch {}
|
|
129
149
|
|
|
130
150
|
// Same cadence as the presence ping: check context pressure and hand off early
|