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.
@@ -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`);
@@ -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
  }
@@ -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
+ }
@@ -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
- await fetch(`${relayUrl()}/register`, {
123
- method: "POST",
124
- headers: { "content-type": "application/json" },
125
- body: JSON.stringify({ session, project, hookVersion: (() => { try { return installedVersion(); } catch { return ""; } })() }),
126
- signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
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