trantor 0.15.0
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 +19 -0
- package/.claude-plugin/plugin.json +12 -0
- package/LICENSE +21 -0
- package/README.md +210 -0
- package/bin/advise.mjs +147 -0
- package/bin/cli.mjs +53 -0
- package/bin/connect.mjs +94 -0
- package/bin/crew-runner.mjs +140 -0
- package/bin/crew-verify.mjs +40 -0
- package/bin/crew.sh +125 -0
- package/bin/doctor.mjs +74 -0
- package/bin/handoff-prompt.sh +21 -0
- package/bin/open-session.sh +10 -0
- package/bin/profile.mjs +56 -0
- package/bin/relay-watch.mjs +54 -0
- package/bin/statusline.mjs +30 -0
- package/bin/write-handoff.mjs +17 -0
- package/configs/codex-config.toml +6 -0
- package/configs/gemini-settings.json +10 -0
- package/deploy/com.trantor.hub.plist +17 -0
- package/deploy/setup.sh +31 -0
- package/hooks/hooks.json +11 -0
- package/hooks/precompact.mjs +118 -0
- package/hooks/sessionstart.mjs +110 -0
- package/hub.mjs +218 -0
- package/mcp.mjs +156 -0
- package/package.json +54 -0
- package/skills/crew/SKILL.md +82 -0
- package/skills/relay-handoff/SKILL.md +36 -0
- package/ui.html +410 -0
package/deploy/setup.sh
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# trantor one-shot setup: hub service + config + CLI wiring + doctor.
|
|
3
|
+
set -e
|
|
4
|
+
REPO="$(cd "$(dirname "$0")/.." && pwd)"
|
|
5
|
+
NODE="$(command -v node)"
|
|
6
|
+
[ -z "$NODE" ] && { echo "node >= 18 required"; exit 1; }
|
|
7
|
+
mkdir -p "$HOME/.agent-bus"
|
|
8
|
+
[ -f "$HOME/.agent-bus/config.json" ] || echo '{"url":"http://127.0.0.1:4477"}' > "$HOME/.agent-bus/config.json"
|
|
9
|
+
touch "$HOME/.agent-bus/.env" # one place for provider API keys (e.g. DEEPSEEK_API_KEY=…)
|
|
10
|
+
if [ "$(uname)" = "Darwin" ]; then
|
|
11
|
+
PL="$HOME/Library/LaunchAgents/com.trantor.hub.plist"
|
|
12
|
+
sed -e "s|__NODE__|$NODE|" -e "s|__REPO__|$REPO|" "$REPO/deploy/com.trantor.hub.plist" > "$PL"
|
|
13
|
+
launchctl bootout "gui/$(id -u)/com.trantor.hub" 2>/dev/null || true
|
|
14
|
+
launchctl bootstrap "gui/$(id -u)" "$PL"
|
|
15
|
+
echo "✓ hub installed as launchd service (starts at login, restarts on crash)"
|
|
16
|
+
else
|
|
17
|
+
echo "Linux: run the hub under systemd/tmux: RELAY_PORT=4477 node $REPO/hub.mjs"
|
|
18
|
+
fi
|
|
19
|
+
# the economics engine — part of Trantor, installed automatically (it IS the brain)
|
|
20
|
+
if ! command -v scrooge >/dev/null 2>&1 && [ ! -x "$HOME/.local/bin/scrooge" ]; then
|
|
21
|
+
echo "▸ installing the economics engine (routing + cost ledger)…"
|
|
22
|
+
curl -fsSL https://raw.githubusercontent.com/sashabogi/token-scrooge/main/install.sh | bash \
|
|
23
|
+
&& echo "✓ economics engine installed" \
|
|
24
|
+
|| echo " (engine install failed — Trantor still works; the Advisor runs without live pricing. Retry: trantor setup)"
|
|
25
|
+
case ":$PATH:" in *":$HOME/.local/bin:"*) ;; *) echo " note: add ~/.local/bin to your PATH";; esac
|
|
26
|
+
fi
|
|
27
|
+
node "$REPO/bin/connect.mjs"
|
|
28
|
+
echo
|
|
29
|
+
node "$REPO/bin/doctor.mjs" || true
|
|
30
|
+
echo
|
|
31
|
+
echo "Next: claude plugin marketplace add sashabogi/trantor && claude plugin install agent-bus"
|
package/hooks/hooks.json
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"description": "agent-bus — auto-register each session + inject live roster (SessionStart); write a handoff before compaction (PreCompact)",
|
|
3
|
+
"hooks": {
|
|
4
|
+
"SessionStart": [
|
|
5
|
+
{ "matcher": "", "hooks": [ { "type": "command", "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/sessionstart.mjs" } ] }
|
|
6
|
+
],
|
|
7
|
+
"PreCompact": [
|
|
8
|
+
{ "matcher": "", "hooks": [ { "type": "command", "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/precompact.mjs" } ] }
|
|
9
|
+
]
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// agent-bus PreCompact hook — fires right before Claude Code compacts a full
|
|
3
|
+
// context window. Instead of (just) compacting, it writes a rich HANDOFF so you can
|
|
4
|
+
// open a FRESH session that takes over with a new full window. The SessionStart hook
|
|
5
|
+
// detects the pending handoff and loads it.
|
|
6
|
+
//
|
|
7
|
+
// Handoff generation: if `scrooge` is on PATH, it summarizes the recent transcript
|
|
8
|
+
// into a structured handoff cheaply; otherwise it falls back to a raw transcript tail.
|
|
9
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from "node:fs";
|
|
10
|
+
import { join, basename, dirname } from "node:path";
|
|
11
|
+
import { homedir, hostname } from "node:os";
|
|
12
|
+
import { execSync, spawn } from "node:child_process";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
|
|
15
|
+
const HANDOFF_DIR = join(homedir(), ".agent-bus", "handoffs");
|
|
16
|
+
|
|
17
|
+
function readStdin() {
|
|
18
|
+
return new Promise(res => { let d = ""; process.stdin.setEncoding("utf8");
|
|
19
|
+
process.stdin.on("data", c => (d += c)); process.stdin.on("end", () => res(d));
|
|
20
|
+
setTimeout(() => res(d), 100); });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Pull readable recent conversation text from a Claude Code transcript JSONL.
|
|
24
|
+
function recentTranscript(path, maxChars = 16000) {
|
|
25
|
+
try {
|
|
26
|
+
const rows = readFileSync(path, "utf8").split("\n").filter(Boolean).map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
|
|
27
|
+
const turns = [];
|
|
28
|
+
for (const r of rows) {
|
|
29
|
+
if (!(r.type === "user" || r.type === "assistant") || !r.message) continue;
|
|
30
|
+
const c = r.message.content;
|
|
31
|
+
let text = "";
|
|
32
|
+
if (typeof c === "string") text = c;
|
|
33
|
+
else if (Array.isArray(c)) text = c.filter(b => b?.type === "text").map(b => b.text).join("\n");
|
|
34
|
+
if (text.trim() && !text.startsWith("<task-notification") && !text.startsWith("<command")) {
|
|
35
|
+
turns.push(`### ${r.type.toUpperCase()}\n${text.slice(0, 2000)}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
let out = turns.join("\n\n");
|
|
39
|
+
if (out.length > maxChars) out = out.slice(out.length - maxChars); // keep the most recent
|
|
40
|
+
return out;
|
|
41
|
+
} catch { return ""; }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function haveScrooge() { try { execSync("command -v scrooge", { stdio: "ignore" }); return true; } catch { return false; } }
|
|
45
|
+
|
|
46
|
+
function summarize(convo) {
|
|
47
|
+
const sys = "You are writing a SESSION HANDOFF so a fresh Claude Code session can take over without losing context. From the conversation, produce a concise but complete markdown handoff with these sections: TASK (what we're doing + the goal), STATE (done / in-progress), KEY DECISIONS, OPEN THREADS & NEXT STEPS (concrete actions), KEY FILES & locations (exact paths). Be specific. Do not pad.";
|
|
48
|
+
if (haveScrooge()) {
|
|
49
|
+
try {
|
|
50
|
+
return execSync(`scrooge -t summarize -d medium --system ${JSON.stringify(sys)}`, {
|
|
51
|
+
input: convo, encoding: "utf8", timeout: 45000, maxBuffer: 4 * 1024 * 1024,
|
|
52
|
+
}).trim();
|
|
53
|
+
} catch (e) { process.stderr.write(`[agent-bus] scrooge summarize failed: ${e?.message}\n`); }
|
|
54
|
+
}
|
|
55
|
+
// fallback: raw recent tail
|
|
56
|
+
return `*(no summarizer available — raw recent transcript tail)*\n\n${convo.slice(-6000)}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const input = JSON.parse((await readStdin()) || "{}");
|
|
61
|
+
const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
62
|
+
const projectName = basename(projectDir);
|
|
63
|
+
const transcript = input.transcript_path || "";
|
|
64
|
+
const trigger = input.trigger || "auto";
|
|
65
|
+
|
|
66
|
+
const convo = transcript && existsSync(transcript) ? recentTranscript(transcript) : "";
|
|
67
|
+
const summary = convo ? summarize(convo) : "*(no transcript available to summarize)*";
|
|
68
|
+
|
|
69
|
+
let gitStatus = "";
|
|
70
|
+
try { gitStatus = execSync("git -C " + JSON.stringify(projectDir) + " status --short 2>/dev/null | head -30", { encoding: "utf8" }).trim(); } catch {}
|
|
71
|
+
|
|
72
|
+
if (!existsSync(HANDOFF_DIR)) mkdirSync(HANDOFF_DIR, { recursive: true });
|
|
73
|
+
const stamp = (() => { try { return execSync("date +%s", { encoding: "utf8" }).trim(); } catch { return String(process.pid); } })();
|
|
74
|
+
|
|
75
|
+
const record = {
|
|
76
|
+
id: `${projectName}-${stamp}`,
|
|
77
|
+
project: projectDir, projectName,
|
|
78
|
+
machine: hostname(),
|
|
79
|
+
session_id: input.session_id || "",
|
|
80
|
+
trigger, transcript_path: transcript,
|
|
81
|
+
stamp: Number(stamp) || 0,
|
|
82
|
+
summary,
|
|
83
|
+
gitStatus,
|
|
84
|
+
consumed: false,
|
|
85
|
+
};
|
|
86
|
+
const file = join(HANDOFF_DIR, `${record.id}.json`);
|
|
87
|
+
writeFileSync(file, JSON.stringify(record, null, 2));
|
|
88
|
+
process.stderr.write(`[agent-bus] handoff written: ${file} (trigger=${trigger})\n`);
|
|
89
|
+
|
|
90
|
+
// best-effort: ping the relay hub so other sessions/machines know a handoff is ready
|
|
91
|
+
try {
|
|
92
|
+
const cfg = join(homedir(), ".agent-bus", "config.json");
|
|
93
|
+
const url = process.env.RELAY_URL || (existsSync(cfg) ? JSON.parse(readFileSync(cfg, "utf8")).url : "") || "http://127.0.0.1:4477";
|
|
94
|
+
await fetch(`${url}/send`, { method: "POST", headers: { "content-type": "application/json" },
|
|
95
|
+
body: JSON.stringify({ from: `${hostname()}:${projectName}`, to: "all", text: `📋 Handoff ready for ${projectName} — open a fresh session here to take over (id ${record.id}).` }),
|
|
96
|
+
signal: AbortSignal.timeout(2000) }).catch(() => {});
|
|
97
|
+
} catch {}
|
|
98
|
+
|
|
99
|
+
// OPT-IN: on macOS, if config.autoHandoffPrompt is true, ask the user (with a timeout,
|
|
100
|
+
// default = yes) whether to spawn a FRESH same-agent session that takes over via the
|
|
101
|
+
// handoff. Detached so it never blocks compaction. Off by default.
|
|
102
|
+
try {
|
|
103
|
+
const cfg = join(homedir(), ".agent-bus", "config.json");
|
|
104
|
+
const conf = existsSync(cfg) ? JSON.parse(readFileSync(cfg, "utf8")) : {};
|
|
105
|
+
if (conf.autoHandoffPrompt && process.platform === "darwin") {
|
|
106
|
+
const script = join(dirname(fileURLToPath(import.meta.url)), "..", "bin", "handoff-prompt.sh");
|
|
107
|
+
if (existsSync(script)) {
|
|
108
|
+
const child = spawn("/bin/bash", [script, projectDir, String(conf.handoffPromptTimeout || 25)], { detached: true, stdio: "ignore" });
|
|
109
|
+
child.unref();
|
|
110
|
+
process.stderr.write(`[agent-bus] handoff prompt launched (opt-in)\n`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
} catch {}
|
|
114
|
+
} catch (err) {
|
|
115
|
+
process.stderr.write(`[agent-bus] precompact error: ${err?.message || err}\n`);
|
|
116
|
+
}
|
|
117
|
+
process.stdout.write("{}");
|
|
118
|
+
process.exit(0);
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// agent-bus SessionStart hook — every session auto-registers with the hub and
|
|
3
|
+
// gets a roster of OTHER live sessions injected into context, so independent
|
|
4
|
+
// sessions discover each other automatically (locally or across machines).
|
|
5
|
+
//
|
|
6
|
+
// Config resolution (first hit wins):
|
|
7
|
+
// env RELAY_URL → ~/.agent-bus/config.json {"url": "..."} → http://127.0.0.1:4477
|
|
8
|
+
// Identity: env RELAY_SESSION → "<hostname>:<basename(cwd)>" (stable per project/machine)
|
|
9
|
+
import { readFileSync, writeFileSync, existsSync, readdirSync } from "node:fs";
|
|
10
|
+
import { join, basename } from "node:path";
|
|
11
|
+
import { homedir, hostname } from "node:os";
|
|
12
|
+
|
|
13
|
+
// Load the most recent UNCONSUMED handoff for this project (written by precompact.mjs).
|
|
14
|
+
function loadPendingHandoff(projectName) {
|
|
15
|
+
try {
|
|
16
|
+
const dir = join(homedir(), ".agent-bus", "handoffs");
|
|
17
|
+
if (!existsSync(dir)) return null;
|
|
18
|
+
const files = readdirSync(dir).filter(f => f.startsWith(projectName + "-") && f.endsWith(".json")).sort().reverse();
|
|
19
|
+
for (const f of files) {
|
|
20
|
+
const p = join(dir, f);
|
|
21
|
+
const rec = JSON.parse(readFileSync(p, "utf8"));
|
|
22
|
+
if (!rec.consumed) {
|
|
23
|
+
rec.consumed = true; writeFileSync(p, JSON.stringify(rec, null, 2)); // claim it
|
|
24
|
+
return rec;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
} catch {}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function relayUrl() {
|
|
32
|
+
if (process.env.RELAY_URL) return process.env.RELAY_URL;
|
|
33
|
+
try {
|
|
34
|
+
const cfg = join(homedir(), ".agent-bus", "config.json");
|
|
35
|
+
if (existsSync(cfg)) { const u = JSON.parse(readFileSync(cfg, "utf8")).url; if (u) return u; }
|
|
36
|
+
} catch {}
|
|
37
|
+
return "http://127.0.0.1:4477";
|
|
38
|
+
}
|
|
39
|
+
function readStdin() {
|
|
40
|
+
return new Promise(res => { let d = ""; process.stdin.setEncoding("utf8");
|
|
41
|
+
process.stdin.on("data", c => (d += c)); process.stdin.on("end", () => res(d));
|
|
42
|
+
setTimeout(() => res(d), 100); });
|
|
43
|
+
}
|
|
44
|
+
async function jget(u) { const r = await fetch(u, { signal: AbortSignal.timeout(2500) }); return r.json(); }
|
|
45
|
+
async function jpost(u, b) { return fetch(u, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(b), signal: AbortSignal.timeout(2500) }); }
|
|
46
|
+
|
|
47
|
+
// Strip control chars from untrusted injected text so the hook's JSON stdout (which
|
|
48
|
+
// Claude Code parses) stays valid. Keeps tab/newline/CR; replaces 0x00-0x1F (minus
|
|
49
|
+
// those), DEL, and the JS line/paragraph separators.
|
|
50
|
+
function sanitize(s) {
|
|
51
|
+
let out = "";
|
|
52
|
+
for (const ch of String(s ?? "")) {
|
|
53
|
+
const c = ch.codePointAt(0);
|
|
54
|
+
const bad = (c < 0x20 && c !== 9 && c !== 10 && c !== 13) || c === 0x7f || c === 0x2028 || c === 0x2029;
|
|
55
|
+
out += bad ? " " : ch;
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let additionalContext = "";
|
|
61
|
+
try {
|
|
62
|
+
await readStdin();
|
|
63
|
+
const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
64
|
+
const session = process.env.RELAY_SESSION || `${hostname()}:${basename(projectDir)}`;
|
|
65
|
+
const url = relayUrl();
|
|
66
|
+
|
|
67
|
+
// register self + post an initial presence status (no LLM turn — instant for others to read)
|
|
68
|
+
await jpost(`${url}/register`, { session, project: basename(projectDir), status: `active in ${basename(projectDir)}` }).catch(() => {});
|
|
69
|
+
|
|
70
|
+
// fetch roster of OTHER online sessions
|
|
71
|
+
let peers = [];
|
|
72
|
+
try { peers = (await jget(`${url}/peers`)).peers || []; } catch {}
|
|
73
|
+
const others = peers.filter(p => p.online && p.session !== session);
|
|
74
|
+
|
|
75
|
+
process.stderr.write(`[agent-bus] registered as ${session} -> ${url} (${others.length} other live session(s))\n`);
|
|
76
|
+
|
|
77
|
+
if (others.length > 0) {
|
|
78
|
+
additionalContext += `<agent-bus session="${session}" hub="${url}">\n`;
|
|
79
|
+
additionalContext += `You are connected to agent-bus (the cross-agent session bus) as "${session}". Other LIVE agent sessions are running right now:\n`;
|
|
80
|
+
for (const p of others) additionalContext += `- ${sanitize(p.session)}\n`;
|
|
81
|
+
additionalContext += `Use the relay MCP tools (relay_peers, relay_send, relay_inbox, relay_wait) to coordinate with them — hand off work, check for overlap before editing shared files, or ask another session for help. If a sibling session is touching the same project, coordinate before making conflicting changes.\n`;
|
|
82
|
+
additionalContext += `</agent-bus>\n`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Pending handoff? A prior session hit the context limit and left a handoff for this
|
|
86
|
+
// project — take over with this fresh full window instead of starting cold.
|
|
87
|
+
const handoff = loadPendingHandoff(basename(projectDir));
|
|
88
|
+
if (handoff) {
|
|
89
|
+
process.stderr.write(`[agent-bus] loaded pending handoff ${handoff.id}\n`);
|
|
90
|
+
additionalContext += `<agent-bus-handoff id="${sanitize(handoff.id)}" from="${sanitize(handoff.machine)}" trigger="${sanitize(handoff.trigger)}">\n`;
|
|
91
|
+
additionalContext += `🔄 **You are taking over from a prior session that hit its context limit.** This is a fresh full window. Resume the work below — the prior session's summary, git state, and a pointer to its full transcript (searchable; Foundation/Gaia has it ingested) follow. Continue from "OPEN THREADS & NEXT STEPS"; do not restart from scratch.\n\n`;
|
|
92
|
+
additionalContext += `## Handoff summary\n${sanitize(handoff.summary)}\n`;
|
|
93
|
+
if (handoff.gitStatus) additionalContext += `\n## Git working-tree at handoff\n\`\`\`\n${sanitize(handoff.gitStatus)}\n\`\`\`\n`;
|
|
94
|
+
if (handoff.transcript_path) additionalContext += `\n_Full prior transcript: ${sanitize(handoff.transcript_path)}_\n`;
|
|
95
|
+
additionalContext += `</agent-bus-handoff>\n`;
|
|
96
|
+
}
|
|
97
|
+
} catch (err) {
|
|
98
|
+
process.stderr.write(`[agent-bus] sessionstart error: ${err?.message || err}\n`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Hook protocol: emit additionalContext via stdout JSON. Self-validate so we never
|
|
102
|
+
// emit something Claude Code can't parse — fall back to sanitized, then to {}.
|
|
103
|
+
function emit(ctx) {
|
|
104
|
+
const obj = ctx ? { hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: ctx } } : {};
|
|
105
|
+
const out = JSON.stringify(obj);
|
|
106
|
+
try { JSON.parse(out); return out; } catch { /* fall through */ }
|
|
107
|
+
try { return JSON.stringify({ hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: sanitize(ctx) } }); } catch { return "{}"; }
|
|
108
|
+
}
|
|
109
|
+
process.stdout.write(emit(additionalContext));
|
|
110
|
+
process.exit(0);
|
package/hub.mjs
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// agent-bus hub — message bus + presence/status board + SSE push, so independent
|
|
3
|
+
// Claude Code sessions can coordinate (near-instant for watchers, cheap for idle peers).
|
|
4
|
+
// Binds to LOOPBACK (127.0.0.1) by default — local-first and safe (no auth yet). To let other
|
|
5
|
+
// machines reach it (e.g. over a Tailscale tailnet), set RELAY_HOST=0.0.0.0 — but only on a
|
|
6
|
+
// private network, or add auth first. See "Always-on / remote hub" in the README (roadmap).
|
|
7
|
+
import http from "node:http";
|
|
8
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
|
|
12
|
+
const PORT = Number(process.env.RELAY_PORT || 4477);
|
|
13
|
+
const HOST = process.env.RELAY_HOST || "127.0.0.1";
|
|
14
|
+
const DATA_DIR = join(homedir(), ".agent-bus");
|
|
15
|
+
const DATA = join(DATA_DIR, "bus.json");
|
|
16
|
+
const ONLINE_MS = Number(process.env.RELAY_ONLINE_MS || 5 * 60 * 1000);
|
|
17
|
+
if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
|
|
18
|
+
|
|
19
|
+
// peers: { session: { lastSeen, status, project } } ; tasks: kanban cards
|
|
20
|
+
// projectMeta: { project: { brief, by, updated } } — the "what & why" blurb per project
|
|
21
|
+
let state = { messages: [], peers: {}, seq: 0, tasks: [], taskSeq: 0, projectMeta: {}, lessons: [] };
|
|
22
|
+
try {
|
|
23
|
+
if (existsSync(DATA)) {
|
|
24
|
+
const loaded = JSON.parse(readFileSync(DATA, "utf8"));
|
|
25
|
+
state = { messages: loaded.messages || [], peers: {}, seq: loaded.seq || 0, tasks: loaded.tasks || [], taskSeq: loaded.taskSeq || 0, projectMeta: loaded.projectMeta || {}, lessons: loaded.lessons || [] };
|
|
26
|
+
for (const [s, v] of Object.entries(loaded.peers || {})) // migrate old numeric form
|
|
27
|
+
state.peers[s] = typeof v === "number" ? { lastSeen: v, status: "", project: "" } : { lastSeen: v.lastSeen || 0, status: v.status || "", project: v.project || "" };
|
|
28
|
+
}
|
|
29
|
+
} catch {}
|
|
30
|
+
let dirty = false;
|
|
31
|
+
const persist = () => { if (dirty) { try { writeFileSync(DATA, JSON.stringify(state)); dirty = false; } catch {} } };
|
|
32
|
+
setInterval(persist, 1000).unref?.();
|
|
33
|
+
|
|
34
|
+
// dashboard HTML (read once at startup)
|
|
35
|
+
let UI = "";
|
|
36
|
+
try { UI = readFileSync(new URL("./ui.html", import.meta.url), "utf8"); } catch {}
|
|
37
|
+
|
|
38
|
+
// open SSE streams: [{ session, res }]
|
|
39
|
+
const streams = [];
|
|
40
|
+
const now = () => Date.now();
|
|
41
|
+
function body(req) { return new Promise(r => { let d = ""; req.on("data", c => (d += c)); req.on("end", () => { try { r(d ? JSON.parse(d) : {}); } catch { r({}); } }); }); }
|
|
42
|
+
function json(res, code, obj) { res.writeHead(code, { "content-type": "application/json", "access-control-allow-origin": "*" }); res.end(JSON.stringify(obj)); }
|
|
43
|
+
function touch(session, status, project) {
|
|
44
|
+
if (!session || session === "all") return; // "all" is a wildcard, not a real peer
|
|
45
|
+
const p = state.peers[session] || { lastSeen: 0, status: "", project: "" };
|
|
46
|
+
p.lastSeen = now();
|
|
47
|
+
if (status !== undefined) p.status = String(status).slice(0, 280);
|
|
48
|
+
if (project) p.project = String(project).slice(0, 80);
|
|
49
|
+
// derive project from a "host:project" session id if none given
|
|
50
|
+
if (!p.project && session.includes(":")) p.project = session.split(":").pop().slice(0, 80);
|
|
51
|
+
state.peers[session] = p; dirty = true;
|
|
52
|
+
}
|
|
53
|
+
function deliverable(m, session) { return (m.to === session || m.to === "all") && m.from !== session; }
|
|
54
|
+
function pushToStreams(msg) {
|
|
55
|
+
for (const s of streams) if (deliverable(msg, s.session)) { try { s.res.write(`data: ${JSON.stringify(msg)}\n\n`); } catch {} }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const server = http.createServer(async (req, res) => {
|
|
59
|
+
const u = new URL(req.url, "http://x"); const q = Object.fromEntries(u.searchParams); const P = u.pathname;
|
|
60
|
+
try {
|
|
61
|
+
if (req.method === "POST" && P === "/register") { const b = await body(req); touch(b.session, b.status, b.project); return json(res, 200, { ok: true, session: b.session, peers: Object.keys(state.peers) }); }
|
|
62
|
+
if (req.method === "POST" && P === "/status") { const b = await body(req); touch(b.session, b.status ?? "", b.project); return json(res, 200, { ok: true }); }
|
|
63
|
+
if (req.method === "GET" && P === "/peers") {
|
|
64
|
+
const cutoff = now() - ONLINE_MS;
|
|
65
|
+
return json(res, 200, { peers: Object.entries(state.peers).map(([s, v]) => ({ session: s, lastSeen: v.lastSeen, online: v.lastSeen > cutoff, status: v.status || "", project: v.project || "" })) });
|
|
66
|
+
}
|
|
67
|
+
// --- Kanban tasks ---
|
|
68
|
+
if (req.method === "POST" && P === "/task") { // create a card
|
|
69
|
+
const b = await body(req); touch(b.by, undefined, b.project);
|
|
70
|
+
const st0 = ["todo","doing","testing","failed","done","blocked"].includes(b.status) ? b.status : "todo";
|
|
71
|
+
const t = { id: ++state.taskSeq, project: String(b.project || "").slice(0,80), title: String(b.title||"").slice(0,200),
|
|
72
|
+
assignee: b.assignee || "", status: st0,
|
|
73
|
+
difficulty: ["easy","medium","hard"].includes(b.difficulty) ? b.difficulty : "",
|
|
74
|
+
model: String(b.model || "").slice(0, 60),
|
|
75
|
+
deps: Array.isArray(b.deps) ? [...new Set(b.deps.map(Number).filter(n => Number.isInteger(n) && n > 0))].slice(0, 20) : [],
|
|
76
|
+
by: b.by || "", ts: now(), updated: now(),
|
|
77
|
+
history: [{ to: st0, by: b.by || "", ts: now() }] };
|
|
78
|
+
state.tasks.push(t); if (state.tasks.length > 2000) state.tasks.splice(0, 500);
|
|
79
|
+
dirty = true; return json(res, 200, { ok: true, task: t });
|
|
80
|
+
}
|
|
81
|
+
if (req.method === "POST" && P === "/task/update") { // move/edit a card
|
|
82
|
+
const b = await body(req); const t = state.tasks.find(x => x.id === Number(b.id));
|
|
83
|
+
if (!t) return json(res, 404, { error: "no such task" });
|
|
84
|
+
if (b.status && ["todo","doing","testing","failed","done","blocked"].includes(b.status) && b.status !== t.status) {
|
|
85
|
+
(t.history ||= []).push({ from: t.status, to: b.status, by: b.by || "", ts: now() });
|
|
86
|
+
if (t.history.length > 40) t.history.splice(0, 10);
|
|
87
|
+
t.status = b.status;
|
|
88
|
+
}
|
|
89
|
+
if (b.difficulty && ["easy","medium","hard"].includes(b.difficulty)) t.difficulty = b.difficulty;
|
|
90
|
+
if (b.model !== undefined) t.model = String(b.model).slice(0, 60);
|
|
91
|
+
if (Array.isArray(b.deps)) t.deps = [...new Set(b.deps.map(Number).filter(n => Number.isInteger(n) && n > 0 && n !== t.id))].slice(0, 20);
|
|
92
|
+
if (b.assignee !== undefined) t.assignee = b.assignee;
|
|
93
|
+
if (b.title !== undefined) t.title = String(b.title).slice(0,200);
|
|
94
|
+
if (b.delete) state.tasks = state.tasks.filter(x => x.id !== t.id);
|
|
95
|
+
t.updated = now(); dirty = true; return json(res, 200, { ok: true, task: t });
|
|
96
|
+
}
|
|
97
|
+
if (req.method === "GET" && P === "/tasks") {
|
|
98
|
+
const proj = q.project; const ts = proj ? state.tasks.filter(t => t.project === proj) : state.tasks;
|
|
99
|
+
return json(res, 200, { tasks: ts });
|
|
100
|
+
}
|
|
101
|
+
if (req.method === "POST" && P === "/project") { // set a project's brief (what & why)
|
|
102
|
+
const b = await body(req); const k = String(b.project || "").slice(0, 80);
|
|
103
|
+
if (!k) return json(res, 400, { error: "project required" });
|
|
104
|
+
const m = state.projectMeta[k] || {};
|
|
105
|
+
if (b.brief !== undefined) m.brief = String(b.brief).slice(0, 600);
|
|
106
|
+
m.by = b.by || m.by || ""; m.updated = now();
|
|
107
|
+
state.projectMeta[k] = m; dirty = true;
|
|
108
|
+
return json(res, 200, { ok: true, project: k, brief: m.brief || "" });
|
|
109
|
+
}
|
|
110
|
+
if (req.method === "GET" && P === "/projects") { // project-grouped view
|
|
111
|
+
const cutoff = now() - ONLINE_MS; const byProj = {};
|
|
112
|
+
const proj = p => p || "(unassigned)";
|
|
113
|
+
const mk = k => (byProj[k] ||= { project: k, brief: (state.projectMeta[k]?.brief) || "", agents: [], tasks: { todo:0,doing:0,testing:0,failed:0,done:0,blocked:0 }, doingTitles: [] });
|
|
114
|
+
for (const [s, v] of Object.entries(state.peers)) {
|
|
115
|
+
const k = proj(v.project); mk(k).agents.push({ session: s, online: v.lastSeen > cutoff, status: v.status || "" });
|
|
116
|
+
}
|
|
117
|
+
for (const t of state.tasks) { const e = mk(proj(t.project)); e.tasks[t.status] = (e.tasks[t.status]||0)+1; if (t.status === "doing") e.doingTitles.push(t.title); }
|
|
118
|
+
// derive a one-line phase ("where it is in the process") from the board
|
|
119
|
+
for (const e of Object.values(byProj)) {
|
|
120
|
+
const { todo, doing, testing=0, failed=0, done, blocked } = e.tasks; const total = todo+doing+testing+failed+done+blocked;
|
|
121
|
+
e.phase = total === 0 ? "no cards yet"
|
|
122
|
+
: failed > 0 ? `${failed} FAILED — fixing`
|
|
123
|
+
: blocked > 0 ? `blocked on ${blocked} card${blocked>1?"s":""}`
|
|
124
|
+
: testing > 0 ? `verifying: ${testing} in test`
|
|
125
|
+
: doing > 0 ? `building: ${e.doingTitles.slice(0,2).join(", ")}${e.doingTitles.length>2?"…":""}`
|
|
126
|
+
: done === total ? "shipped — all cards done"
|
|
127
|
+
: todo > 0 ? `planned: ${todo} card${todo>1?"s":""} queued`
|
|
128
|
+
: "in progress";
|
|
129
|
+
}
|
|
130
|
+
return json(res, 200, { projects: Object.values(byProj) });
|
|
131
|
+
}
|
|
132
|
+
// --- lessons: cross-agent learning from failures. scope = "global" or an agent brand ("kimi") ---
|
|
133
|
+
if (req.method === "POST" && P === "/lesson") {
|
|
134
|
+
const b = await body(req);
|
|
135
|
+
const text = String(b.text || "").trim().slice(0, 400);
|
|
136
|
+
const scope = String(b.scope || "global").toLowerCase().slice(0, 40);
|
|
137
|
+
if (!text) return json(res, 400, { error: "text required" });
|
|
138
|
+
if (state.lessons.some(l => l.scope === scope && l.text === text)) return json(res, 200, { ok: true, dedup: true });
|
|
139
|
+
state.lessons.push({ id: state.lessons.length + 1, scope, text, by: b.by || "", ts: now() });
|
|
140
|
+
if (state.lessons.length > 500) state.lessons.splice(0, 100);
|
|
141
|
+
dirty = true; return json(res, 200, { ok: true, count: state.lessons.length });
|
|
142
|
+
}
|
|
143
|
+
if (req.method === "GET" && P === "/economics") { // the brain's books, surfaced: scrooge ledger + quota profile
|
|
144
|
+
const out = { scrooge: null, profile: null };
|
|
145
|
+
try { out.profile = JSON.parse(readFileSync(join(homedir(), ".agent-bus", "profile.json"), "utf8")).providers || {}; } catch {}
|
|
146
|
+
try {
|
|
147
|
+
const since = now() / 1000 - (Number(q.hours || 24) * 3600);
|
|
148
|
+
const lines = readFileSync(join(homedir(), ".token-scrooge", "calls.jsonl"), "utf8").trim().split("\n").slice(-3000);
|
|
149
|
+
const calls = lines.map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(c => c && c.ts >= since && c.ok);
|
|
150
|
+
const sum = { calls: calls.length, tokens_in: 0, tokens_out: 0, cost_usd: 0, by_model: {} };
|
|
151
|
+
for (const c of calls) {
|
|
152
|
+
sum.tokens_in += c.tokens_in || 0; sum.tokens_out += c.tokens_out || 0; sum.cost_usd += c.cost_usd || 0;
|
|
153
|
+
const m = sum.by_model[c.model] ||= { calls: 0, cost_usd: 0 };
|
|
154
|
+
m.calls++; m.cost_usd += c.cost_usd || 0;
|
|
155
|
+
}
|
|
156
|
+
// savings vs Opus reference (~$15/M in, $75/M out — same yardstick scrooge ledger uses)
|
|
157
|
+
sum.opus_equiv_usd = +(sum.tokens_in * 15 / 1e6 + sum.tokens_out * 75 / 1e6).toFixed(2);
|
|
158
|
+
sum.cost_usd = +sum.cost_usd.toFixed(4);
|
|
159
|
+
out.scrooge = sum;
|
|
160
|
+
} catch {}
|
|
161
|
+
return json(res, 200, out);
|
|
162
|
+
}
|
|
163
|
+
if (req.method === "GET" && P === "/lessons") {
|
|
164
|
+
const agent = (q.agent || "").toLowerCase();
|
|
165
|
+
const ls = state.lessons.filter(l => l.scope === "global" || (agent && l.scope === agent));
|
|
166
|
+
return json(res, 200, { lessons: ls });
|
|
167
|
+
}
|
|
168
|
+
if (req.method === "POST" && P === "/send") {
|
|
169
|
+
const b = await body(req);
|
|
170
|
+
if (!b.from || !String(b.text ?? "").trim()) return json(res, 400, { error: "from and non-empty text required" });
|
|
171
|
+
touch(b.from);
|
|
172
|
+
// attribute the message to a project so the dashboard can show it in that project's lane.
|
|
173
|
+
// explicit b.project wins; else the sender's known project; else parsed from a "host:project" id.
|
|
174
|
+
const fromProj = state.peers[b.from]?.project || (b.from && b.from.includes(":") ? b.from.split(":").pop() : "");
|
|
175
|
+
const msg = { id: ++state.seq, ts: now(), from: b.from || "anon", to: b.to || "all", text: String(b.text ?? ""), project: String(b.project || fromProj || "").slice(0, 80) };
|
|
176
|
+
state.messages.push(msg); if (state.messages.length > 5000) state.messages.splice(0, 1000);
|
|
177
|
+
dirty = true; pushToStreams(msg); // <-- instant push to live watchers
|
|
178
|
+
return json(res, 200, { ok: true, id: msg.id });
|
|
179
|
+
}
|
|
180
|
+
if (req.method === "GET" && P === "/inbox") {
|
|
181
|
+
touch(q.session); const since = Number(q.since || 0);
|
|
182
|
+
const msgs = state.messages.filter(m => m.id > since && deliverable(m, q.session));
|
|
183
|
+
return json(res, 200, { messages: msgs, cursor: msgs.length ? msgs[msgs.length - 1].id : since });
|
|
184
|
+
}
|
|
185
|
+
if (req.method === "GET" && P === "/poll") {
|
|
186
|
+
touch(q.session); const since = Number(q.since || 0);
|
|
187
|
+
const waitMs = Math.min(Number(q.wait || 25), 290) * 1000; // allow long idle-park
|
|
188
|
+
const deadline = now() + waitMs;
|
|
189
|
+
const tick = () => {
|
|
190
|
+
const msgs = state.messages.filter(m => m.id > since && deliverable(m, q.session));
|
|
191
|
+
if (msgs.length || now() >= deadline) { touch(q.session); return json(res, 200, { messages: msgs, cursor: msgs.length ? msgs[msgs.length - 1].id : since }); }
|
|
192
|
+
setTimeout(tick, 300);
|
|
193
|
+
};
|
|
194
|
+
return tick();
|
|
195
|
+
}
|
|
196
|
+
if (req.method === "GET" && P === "/stream") { // SSE — true push, no polling
|
|
197
|
+
const session = q.session || "all";
|
|
198
|
+
res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", "connection": "keep-alive", "access-control-allow-origin": "*" });
|
|
199
|
+
res.write(`: connected as ${session}\n\n`);
|
|
200
|
+
touch(session, q.status);
|
|
201
|
+
const entry = { session, res };
|
|
202
|
+
streams.push(entry);
|
|
203
|
+
const ka = setInterval(() => { try { res.write(": ka\n\n"); touch(session); } catch {} }, 20000);
|
|
204
|
+
req.on("close", () => { clearInterval(ka); const i = streams.indexOf(entry); if (i >= 0) streams.splice(i, 1); });
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (req.method === "GET" && P === "/recent") { // god-view: last N messages, for the dashboard feed
|
|
208
|
+
const n = Math.min(Number(q.limit || 50), 200);
|
|
209
|
+
return json(res, 200, { messages: state.messages.slice(-n) });
|
|
210
|
+
}
|
|
211
|
+
if (req.method === "GET" && (P === "/" || P === "/ui")) {
|
|
212
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); return res.end(UI || "<h1>agent-bus</h1><p>dashboard unavailable</p>");
|
|
213
|
+
}
|
|
214
|
+
if (P === "/health") return json(res, 200, { ok: true, peers: Object.keys(state.peers).length, messages: state.messages.length, streams: streams.length });
|
|
215
|
+
json(res, 404, { error: "not found" });
|
|
216
|
+
} catch (e) { json(res, 500, { error: String(e?.message || e) }); }
|
|
217
|
+
});
|
|
218
|
+
server.listen(PORT, HOST, () => console.error(`[agent-bus] hub on http://${HOST}:${PORT} (data: ${DATA})`));
|