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
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// agent-bus crew runner — keeps a crew agent alive forever without burning tokens.
|
|
3
|
+
//
|
|
4
|
+
// node crew-runner.mjs <agent> [project-dir]
|
|
5
|
+
//
|
|
6
|
+
// The park problem: CLIs end their turn no matter what you prompt (harnesses actively kill
|
|
7
|
+
// "call relay_wait repeatedly" loops). So the runner owns the waiting: it long-polls the bus
|
|
8
|
+
// over plain HTTP (zero tokens, doubles as a heartbeat), and when a message addressed to this
|
|
9
|
+
// agent arrives it RESUMES the CLI session (native resume = full context kept) with that
|
|
10
|
+
// message as the prompt. The model just works and ends its turn; the runner does the rest.
|
|
11
|
+
import { execSync, spawnSync } from "node:child_process";
|
|
12
|
+
import { readFileSync, existsSync, appendFileSync } from "node:fs";
|
|
13
|
+
import { join, basename } from "node:path";
|
|
14
|
+
import { homedir } from "node:os";
|
|
15
|
+
|
|
16
|
+
const AGENT = process.argv[2];
|
|
17
|
+
const DIR = process.argv[3] || process.cwd();
|
|
18
|
+
const PROJ = basename(DIR);
|
|
19
|
+
const SESSION = `${AGENT}:${PROJ}`;
|
|
20
|
+
if (!AGENT) { console.error("usage: crew-runner.mjs <agent> [project-dir]"); process.exit(1); }
|
|
21
|
+
|
|
22
|
+
function hubUrl() {
|
|
23
|
+
if (process.env.RELAY_URL) return process.env.RELAY_URL;
|
|
24
|
+
try { const u = JSON.parse(readFileSync(join(homedir(), ".agent-bus", "config.json"), "utf8")).url; if (u) return u; } catch {}
|
|
25
|
+
return "http://127.0.0.1:4477";
|
|
26
|
+
}
|
|
27
|
+
const HUB = hubUrl();
|
|
28
|
+
process.on("uncaughtException", (e) => { console.log(`\x1b[31m[runner] UNCAUGHT: ${e?.stack || e}\x1b[0m`); });
|
|
29
|
+
process.on("unhandledRejection", (e) => { console.log(`\x1b[31m[runner] UNHANDLED REJECTION: ${e?.stack || e}\x1b[0m`); });
|
|
30
|
+
const log = (s) => console.log(`\x1b[38;5;43m[runner]\x1b[0m ${s}`);
|
|
31
|
+
const LOGDIR = join(homedir(), ".agent-bus", "logs");
|
|
32
|
+
import { mkdirSync } from "node:fs";
|
|
33
|
+
try { mkdirSync(LOGDIR, { recursive: true }); } catch {}
|
|
34
|
+
let TURN = 0;
|
|
35
|
+
const telemetry = (rec) => { try { appendFileSync(join(LOGDIR, `${AGENT}-${PROJ}.jsonl`), JSON.stringify(rec) + "\n"); } catch {} };
|
|
36
|
+
const banner = (trigger) => {
|
|
37
|
+
console.log(`\x1b[2J\x1b[H\x1b[48;5;236m\x1b[38;5;43m ◤ ${AGENT.toUpperCase()} ◢ agent-bus crew · ${PROJ} · turn ${TURN} · ${trigger}${MODEL ? ` · ${MODEL}` : ""} \x1b[0m\n`);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
async function api(path, body) {
|
|
41
|
+
const opts = body
|
|
42
|
+
? { method: "POST", headers: { "content-type": "application/json", connection: "close" }, body: JSON.stringify(body) }
|
|
43
|
+
: { headers: { connection: "close" } }; // fresh socket per call — long-polls on stale keep-alive sockets reset
|
|
44
|
+
const r = await fetch(HUB + path, opts);
|
|
45
|
+
return r.json();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ---- per-CLI invocation (first turn vs resume turn). {P} = prompt file path ----
|
|
49
|
+
// CREW_MODEL env pins the model: each CLI gets its own flag via {M} (empty when unset).
|
|
50
|
+
let MODEL = process.env.CREW_MODEL || "";
|
|
51
|
+
// opencode expects provider/model — qualify bare ids for the deepseek/opencode agents
|
|
52
|
+
if (MODEL && !MODEL.includes("/") && (AGENT === "deepseek" || AGENT === "opencode")) MODEL = `deepseek/${MODEL}`;
|
|
53
|
+
const CLI = {
|
|
54
|
+
codex: { first: `codex exec{M} --skip-git-repo-check --dangerously-bypass-approvals-and-sandbox "$(cat {P})" < /dev/null`,
|
|
55
|
+
next: `codex exec resume --last{M} --skip-git-repo-check --dangerously-bypass-approvals-and-sandbox "$(cat {P})" < /dev/null`, mflag: " -m " },
|
|
56
|
+
gemini: { first: `gemini --yolo{M} -p "$(cat {P})"`,
|
|
57
|
+
next: `gemini --yolo{M} -r latest -p "$(cat {P})"`, mflag: " -m " },
|
|
58
|
+
kimi: { first: `kimi --print --yolo{M} -p "$(cat {P})" < /dev/null`,
|
|
59
|
+
next: `kimi --print --yolo{M} -r {SID} -p "$(cat {P})" < /dev/null`, mflag: " --model ", sid: /To resume this session: kimi -r ([a-f0-9-]+)/ },
|
|
60
|
+
deepseek: { first: `opencode run{M} "$(cat {P})"`,
|
|
61
|
+
next: `opencode run -c{M} "$(cat {P})"`, mflag: " -m ", env: join(homedir(), ".token-scrooge", ".env") },
|
|
62
|
+
opencode: { first: `opencode run{M} "$(cat {P})"`,
|
|
63
|
+
next: `opencode run -c{M} "$(cat {P})"`, mflag: " -m ", env: join(homedir(), ".token-scrooge", ".env") },
|
|
64
|
+
claude: { first: `claude{M} -p "$(cat {P})" --dangerously-skip-permissions`,
|
|
65
|
+
next: `claude -c{M} -p "$(cat {P})" --dangerously-skip-permissions`, mflag: " --model " },
|
|
66
|
+
};
|
|
67
|
+
const cli = CLI[AGENT];
|
|
68
|
+
if (!cli) { console.error(`unknown agent '${AGENT}' (known: ${Object.keys(CLI).join(", ")})`); process.exit(1); }
|
|
69
|
+
|
|
70
|
+
const RULES = `Rules: you are ${SESSION} on the agent-bus crew. Work your assigned file(s), report on the bus (relay_send, <280 chars), move your Kanban card as you go (doing -> testing -> done; run the tests in 'testing', use 'failed' + a report if they break). When your work for THIS message is finished, END YOUR TURN — do NOT park, do NOT loop relay_wait; the runner waits for you and will wake you with the next message.`;
|
|
71
|
+
|
|
72
|
+
let sid = "";
|
|
73
|
+
function runTurn(prompt, isFirst, trigger = "kickoff") {
|
|
74
|
+
TURN++; banner(trigger);
|
|
75
|
+
const t0 = Date.now();
|
|
76
|
+
const pf = join(homedir(), ".agent-bus", `turn-${AGENT}-${PROJ}.txt`);
|
|
77
|
+
appendFileSync(pf, "", { flag: "w" }); // truncate
|
|
78
|
+
appendFileSync(pf, prompt);
|
|
79
|
+
let cmd = (isFirst || (cli.sid && !sid)) ? cli.first : cli.next;
|
|
80
|
+
const mfrag = MODEL && cli.mflag ? `${cli.mflag}${MODEL}` : "";
|
|
81
|
+
cmd = cmd.replaceAll("{M}", mfrag).replaceAll("{P}", pf).replaceAll("{SID}", sid);
|
|
82
|
+
const envs = [join(homedir(), ".agent-bus", ".env"), cli.env].filter(f => f && existsSync(f));
|
|
83
|
+
for (const f of envs.reverse()) cmd = `set -a; source ${f}; set +a; ${cmd}`; // ~/.agent-bus/.env wins
|
|
84
|
+
log(`turn starting (${isFirst ? "fresh session" : "resume"})${MODEL ? ` · model=${MODEL}` : ""}`);
|
|
85
|
+
// inherit stdio so the window shows the agent working live; also capture for sid-parsing
|
|
86
|
+
const r = spawnSync("/bin/bash", ["-c", cli.sid ? `${cmd} | tee /dev/stderr` : cmd], {
|
|
87
|
+
cwd: DIR, encoding: "utf8", stdio: cli.sid ? ["ignore", "pipe", "inherit"] : "inherit",
|
|
88
|
+
env: { ...process.env, RELAY_URL: HUB, RELAY_AGENT: AGENT, RELAY_PROJECT: PROJ },
|
|
89
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
90
|
+
});
|
|
91
|
+
if (cli.sid && r.stdout) { const m = r.stdout.match(cli.sid); if (m) sid = m[1]; }
|
|
92
|
+
telemetry({ ts: Date.now(), agent: AGENT, project: PROJ, turn: TURN, trigger, model: MODEL || "default", duration_ms: Date.now() - t0, exit: r.status });
|
|
93
|
+
log(`turn ended (exit ${r.status}, ${((Date.now() - t0) / 1000).toFixed(0)}s)`);
|
|
94
|
+
return r.status;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ---- main loop ----
|
|
98
|
+
const KICKOFF = process.env.CREW_KICKOFF ||
|
|
99
|
+
`You just joined. 1) relay_send to "all": "${AGENT} reporting — ready for a contract". 2) relay_inbox — if a contract for you is already waiting, do it now per the Rules. 3) End your turn.\n\n${RULES}`;
|
|
100
|
+
|
|
101
|
+
let LESSONS = "";
|
|
102
|
+
async function loadLessons() {
|
|
103
|
+
try {
|
|
104
|
+
const { lessons } = await api(`/lessons?agent=${encodeURIComponent(AGENT)}`);
|
|
105
|
+
if (lessons?.length) LESSONS = "\n\nLESSONS from previous crews (hard-won — follow them):\n" + lessons.map(l => `- [${l.scope}] ${l.text}`).join("\n");
|
|
106
|
+
} catch {}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
(async () => {
|
|
110
|
+
await loadLessons();
|
|
111
|
+
// start cursor at the CURRENT tip so we don't replay history
|
|
112
|
+
let cursor = 0;
|
|
113
|
+
try { const r = await api(`/inbox?session=${encodeURIComponent(SESSION)}&since=0`); cursor = r.cursor || 0; } catch {}
|
|
114
|
+
await api("/register", { session: SESSION, project: PROJ, status: "crew member booting" }).catch(() => {});
|
|
115
|
+
|
|
116
|
+
let pendingBcast = [];
|
|
117
|
+
runTurn(KICKOFF + LESSONS, true, "kickoff");
|
|
118
|
+
log(`parked — long-polling the bus as ${SESSION} (free; this poll is also the heartbeat)`);
|
|
119
|
+
|
|
120
|
+
while (true) {
|
|
121
|
+
let msgs = [];
|
|
122
|
+
try {
|
|
123
|
+
const r = await api(`/poll?session=${encodeURIComponent(SESSION)}&since=${cursor}&wait=280`);
|
|
124
|
+
msgs = r.messages || []; cursor = r.cursor ?? cursor;
|
|
125
|
+
} catch (e) { log(`hub unreachable (${e.message}) — retrying in 5s`); await new Promise(s => setTimeout(s, 5000)); continue; }
|
|
126
|
+
if (!msgs.length) continue; // heartbeat tick, nothing for us
|
|
127
|
+
const direct = msgs.filter(m => m.to === SESSION);
|
|
128
|
+
const mentions = msgs.filter(m => m.to === "all" && (m.text.includes(`@${AGENT}`) || m.text.toLowerCase().includes(`${AGENT}:`)));
|
|
129
|
+
const bcast = msgs.filter(m => m.to === "all" && !mentions.includes(m));
|
|
130
|
+
pendingBcast.push(...bcast); // wake-policy: plain broadcasts batch, they don't wake
|
|
131
|
+
const wake = [...direct, ...mentions];
|
|
132
|
+
if (!wake.length) { if (bcast.length) log(`${bcast.length} broadcast(s) batched (no wake) — ${pendingBcast.length} pending`); continue; }
|
|
133
|
+
const ctx = pendingBcast.length ? `\nFYI broadcasts since your last turn (context only):\n${pendingBcast.map(m => `[${m.from} -> all]: ${m.text}`).join("\n")}\n` : "";
|
|
134
|
+
pendingBcast = [];
|
|
135
|
+
const lines = wake.map(m => `[${m.from}${m.to === "all" ? " -> all (mentions you)" : ""}]: ${m.text}`).join("\n");
|
|
136
|
+
const prompt = `NEW BUS MESSAGE${wake.length > 1 ? "S" : ""} for you:\n${lines}\n${ctx}\nAct on what's addressed to you, then end your turn.\n\n${RULES}`;
|
|
137
|
+
await loadLessons(); runTurn(prompt + LESSONS, false, direct.length ? "direct message" : "@mention");
|
|
138
|
+
log("parked — waiting for the next message");
|
|
139
|
+
}
|
|
140
|
+
})();
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// crew-verify — prove that spawned crew agents ACTUALLY joined the bus (don't trust the spawn).
|
|
3
|
+
//
|
|
4
|
+
// node crew-verify.mjs <project> <agent...> [--timeout 30]
|
|
5
|
+
//
|
|
6
|
+
// An agent counts as UP when its session (<agent>:<project>) has registered with a lastSeen
|
|
7
|
+
// AFTER this verifier started. Prints one line per agent; exits non-zero listing failures —
|
|
8
|
+
// the launcher retries those, and the orchestrator gets the truth instead of a green lie.
|
|
9
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
|
|
13
|
+
const args = process.argv.slice(2);
|
|
14
|
+
const ti = args.indexOf("--timeout");
|
|
15
|
+
const TIMEOUT = ti >= 0 ? Number(args.splice(ti, 2)[1]) : 30;
|
|
16
|
+
const [PROJ, ...AGENTS] = args;
|
|
17
|
+
if (!PROJ || !AGENTS.length) { console.error("usage: crew-verify.mjs <project> <agent...> [--timeout 30]"); process.exit(2); }
|
|
18
|
+
|
|
19
|
+
function hubUrl() {
|
|
20
|
+
if (process.env.RELAY_URL) return process.env.RELAY_URL;
|
|
21
|
+
try { const u = JSON.parse(readFileSync(join(homedir(), ".agent-bus", "config.json"), "utf8")).url; if (u) return u; } catch {}
|
|
22
|
+
return "http://127.0.0.1:4477";
|
|
23
|
+
}
|
|
24
|
+
const HUB = hubUrl();
|
|
25
|
+
const START = Date.now();
|
|
26
|
+
|
|
27
|
+
(async () => {
|
|
28
|
+
const want = new Set(AGENTS.map(a => `${a}:${PROJ}`));
|
|
29
|
+
const up = new Set();
|
|
30
|
+
while (Date.now() - START < TIMEOUT * 1000 && up.size < want.size) {
|
|
31
|
+
try {
|
|
32
|
+
const { peers } = await (await fetch(`${HUB}/peers`)).json();
|
|
33
|
+
for (const p of peers) if (want.has(p.session) && p.lastSeen >= START) up.add(p.session);
|
|
34
|
+
} catch {}
|
|
35
|
+
if (up.size < want.size) await new Promise(s => setTimeout(s, 1500));
|
|
36
|
+
}
|
|
37
|
+
const failed = [...want].filter(s => !up.has(s));
|
|
38
|
+
for (const s of want) console.log(`${up.has(s) ? "✓" : "✗"} ${s} ${up.has(s) ? "on the bus" : `NOT on the bus after ${TIMEOUT}s`}`);
|
|
39
|
+
if (failed.length) { console.log(`FAILED:${failed.map(s => s.split(":")[0]).join(",")}`); process.exit(1); }
|
|
40
|
+
})();
|
package/bin/crew.sh
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# agent-bus crew launcher v2 — visible terminal windows that CANNOT silently die or silently fail.
|
|
3
|
+
#
|
|
4
|
+
# bin/crew.sh up codex gemini kimi deepseek # one window per agent, in the CURRENT project dir
|
|
5
|
+
# bin/crew.sh down # kill crew processes + close windows (no dialogs)
|
|
6
|
+
#
|
|
7
|
+
# Each window runs bin/crew-runner.mjs: the CLI does one turn and exits; the RUNNER long-polls
|
|
8
|
+
# the bus (free, doubles as heartbeat) and resumes the CLI — with full context — whenever a
|
|
9
|
+
# message arrives. No model-side parking, no harness fights, no token burn while idle.
|
|
10
|
+
#
|
|
11
|
+
# Spawns are SERIALIZED and then VERIFIED on the bus (crew-verify.mjs); failures retry once and
|
|
12
|
+
# are reported loudly — the orchestrator never gets a green lie.
|
|
13
|
+
#
|
|
14
|
+
# Geometry: "crewRect": "X,Y,W,H" in ~/.agent-bus/config.json (or CREW_RECT env) — set once per
|
|
15
|
+
# machine; used for every spawn including respawns. Default: right half of the main display.
|
|
16
|
+
set -u
|
|
17
|
+
CMD="${1:-up}"; shift 2>/dev/null || true
|
|
18
|
+
DIR="$(pwd)"
|
|
19
|
+
PROJ="$(basename "$DIR")"
|
|
20
|
+
BUS_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
|
21
|
+
STATE="$HOME/.agent-bus/crew-windows.txt"
|
|
22
|
+
mkdir -p "$HOME/.agent-bus"
|
|
23
|
+
|
|
24
|
+
down() {
|
|
25
|
+
[ -f "$STATE" ] || { echo "no tracked crew windows"; return 0; }
|
|
26
|
+
while read -r wid; do
|
|
27
|
+
TTY=$(osascript -e "tell application \"Terminal\" to get tty of (first window whose id is $wid)" 2>/dev/null)
|
|
28
|
+
if [ -n "$TTY" ]; then
|
|
29
|
+
# SIGKILL everything on the tty, login included — TUIs trap SIGTERM, and a live login
|
|
30
|
+
# makes Terminal raise the "Terminate running processes?" dialog on close.
|
|
31
|
+
for pid in $(ps -t "${TTY#/dev/}" -o pid= 2>/dev/null); do kill -9 "$pid" 2>/dev/null; done
|
|
32
|
+
fi
|
|
33
|
+
done < "$STATE"
|
|
34
|
+
sleep 1
|
|
35
|
+
while read -r wid; do
|
|
36
|
+
osascript -e "tell application \"Terminal\" to close (first window whose id is $wid)" 2>/dev/null
|
|
37
|
+
done < "$STATE"
|
|
38
|
+
sleep 0.5
|
|
39
|
+
osascript -e 'tell application "System Events" to tell process "Terminal"' \
|
|
40
|
+
-e 'repeat with w in windows' -e 'try' \
|
|
41
|
+
-e 'if exists sheet 1 of w then click button "Terminate" of sheet 1 of w' \
|
|
42
|
+
-e 'end try' -e 'end repeat' -e 'end tell' >/dev/null 2>&1
|
|
43
|
+
rm -f "$STATE"
|
|
44
|
+
echo "crew torn down"
|
|
45
|
+
}
|
|
46
|
+
[ "$CMD" = "down" ] && { down; exit 0; }
|
|
47
|
+
[ "$CMD" != "up" ] && { echo "usage: crew.sh up <agent...> | crew.sh down"; exit 1; }
|
|
48
|
+
[ $# -eq 0 ] && { echo "usage: crew.sh up codex gemini kimi deepseek (any subset; agent:model pins a model, e.g. deepseek:deepseek-v4-pro)"; exit 1; }
|
|
49
|
+
|
|
50
|
+
if [ "$(uname)" != "Darwin" ]; then
|
|
51
|
+
echo "Window spawning is macOS-only. Run one per terminal, in $DIR:"
|
|
52
|
+
for a in "$@"; do echo " node $BUS_DIR/bin/crew-runner.mjs $a $DIR"; done
|
|
53
|
+
exit 0
|
|
54
|
+
fi
|
|
55
|
+
|
|
56
|
+
# one-time wiring for every detected CLI (idempotent, backed up)
|
|
57
|
+
node "$BUS_DIR/bin/connect.mjs" | tail -n +2
|
|
58
|
+
|
|
59
|
+
# ---- geometry: AUTO-DETECTED from the screen you're working on (never hard-coded coords).
|
|
60
|
+
# NSScreen.mainScreen = the display with keyboard focus; visibleFrame excludes menu bar/Dock.
|
|
61
|
+
# Crew tiles into the RIGHT 58% of THAT screen (left side stays for dashboard/main terminal).
|
|
62
|
+
# One-off override: CREW_RECT="X,Y,W,H" env (no persistent config — display setups change).
|
|
63
|
+
if [ -n "${CREW_RECT:-}" ]; then
|
|
64
|
+
IFS=',' read -r GX GY GW GH <<< "$CREW_RECT"
|
|
65
|
+
else
|
|
66
|
+
eval "$(osascript -l JavaScript -e '
|
|
67
|
+
ObjC.import("AppKit");
|
|
68
|
+
const f=$.NSScreen.mainScreen.visibleFrame, prim=$.NSScreen.screens.objectAtIndex(0).frame;
|
|
69
|
+
const yTop = prim.size.height - (f.origin.y + f.size.height);
|
|
70
|
+
const x=Math.round(f.origin.x), y=Math.round(yTop), w=Math.round(f.size.width), h=Math.round(f.size.height);
|
|
71
|
+
`SX=${x} SY=${y} SW=${w} SH=${h}`' 2>/dev/null)"
|
|
72
|
+
if [ -z "${SW:-}" ]; then # fallback: primary display via Finder
|
|
73
|
+
read -r SX SY SW SH <<< "$(osascript -e 'tell application "Finder" to get bounds of window of desktop' | tr ',' ' ')"
|
|
74
|
+
SY=25; SH=$(( SH - 25 ))
|
|
75
|
+
fi
|
|
76
|
+
GX=$(( SX + SW * 42 / 100 )); GY=$SY; GW=$(( SW * 58 / 100 )); GH=$SH
|
|
77
|
+
fi
|
|
78
|
+
echo "— crew area: ${GW}x${GH} at ${GX},${GY} (focused screen, auto-detected) —"
|
|
79
|
+
|
|
80
|
+
spawn_grid() { # $@ = agents — (re)computes the grid for THIS batch and spawns serially
|
|
81
|
+
local N=$# COLS=2
|
|
82
|
+
[ $N -le 2 ] && COLS=1
|
|
83
|
+
local ROWS=$(( (N + COLS - 1) / COLS ))
|
|
84
|
+
local CW=$(( GW / COLS )) CH=$(( GH / ROWS ))
|
|
85
|
+
local i=0 SPEC AGENT MODEL
|
|
86
|
+
for SPEC in "$@"; do
|
|
87
|
+
AGENT="${SPEC%%:*}" # agent[:model] — model rides in as CREW_MODEL
|
|
88
|
+
MODEL=""; [ "$SPEC" != "$AGENT" ] && MODEL="${SPEC#*:}"
|
|
89
|
+
local C=$(( i % COLS )) R=$(( i / COLS ))
|
|
90
|
+
local X1=$(( GX + C * CW )) Y1=$(( GY + R * CH ))
|
|
91
|
+
osascript \
|
|
92
|
+
-e 'tell application "Terminal"' \
|
|
93
|
+
-e " set w to do script \"cd $DIR && clear && CREW_MODEL=$MODEL node $BUS_DIR/bin/crew-runner.mjs $AGENT $DIR\"" \
|
|
94
|
+
-e " set custom title of w to \"$(echo "$AGENT" | tr '[:lower:]' '[:upper:]') — agent-bus crew\"" \
|
|
95
|
+
-e " set theWin to first window whose tabs contains w" \
|
|
96
|
+
-e " set bounds of theWin to {$X1, $Y1, $(( X1 + CW )), $(( Y1 + CH ))}" \
|
|
97
|
+
-e " return id of theWin" \
|
|
98
|
+
-e 'end tell' >> "$STATE" 2>/dev/null && echo " → $AGENT window spawned" || echo " ✗ $AGENT osascript spawn ERROR"
|
|
99
|
+
sleep 1.2 # serialize — rapid-fire 'do script' calls race and silently drop windows
|
|
100
|
+
i=$(( i + 1 ))
|
|
101
|
+
done
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
echo "— spawning crew (serialized) —"
|
|
105
|
+
spawn_grid "$@"
|
|
106
|
+
|
|
107
|
+
echo "— verifying on the bus (the spawn is not the truth; the bus is) —"
|
|
108
|
+
AGENTS_ONLY=$(for a in "$@"; do printf "%s " "${a%%:*}"; done)
|
|
109
|
+
VER=$(node "$BUS_DIR/bin/crew-verify.mjs" "$PROJ" $AGENTS_ONLY --timeout 30)
|
|
110
|
+
echo "$VER"
|
|
111
|
+
RETRY=$(echo "$VER" | grep "^FAILED:" | cut -d: -f2 | tr ',' ' ')
|
|
112
|
+
if [ -n "${RETRY// }" ]; then
|
|
113
|
+
echo "— retrying failed spawns: $RETRY —"
|
|
114
|
+
spawn_grid $RETRY
|
|
115
|
+
VER2=$(node "$BUS_DIR/bin/crew-verify.mjs" "$PROJ" $RETRY --timeout 30)
|
|
116
|
+
echo "$VER2"
|
|
117
|
+
STILL=$(echo "$VER2" | grep "^FAILED:" | cut -d: -f2)
|
|
118
|
+
if [ -n "$STILL" ]; then
|
|
119
|
+
echo ""
|
|
120
|
+
echo "✗✗ CREW INCOMPLETE — these agents are NOT on the bus: $STILL"
|
|
121
|
+
echo " Do NOT assign them work. Investigate their windows or run: crew.sh up ${STILL//,/ }"
|
|
122
|
+
exit 1
|
|
123
|
+
fi
|
|
124
|
+
fi
|
|
125
|
+
echo "— crew verified on the bus. Send contracts with relay_send; runners keep agents alive for free. Teardown: crew.sh down —"
|
package/bin/doctor.mjs
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// trantor doctor — tell a fresh user exactly where they stand and what to do next.
|
|
3
|
+
// Checks: runtime, hub, plugin, each CLI (installed? wired? AUTHENTICATED?), API keys,
|
|
4
|
+
// quota profile, optional Scrooge brain. Prints a checklist with copy-paste fixes.
|
|
5
|
+
// node bin/doctor.mjs
|
|
6
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
7
|
+
import { join, dirname } from "node:path";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { execSync } from "node:child_process";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
|
|
12
|
+
const H = homedir();
|
|
13
|
+
const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
14
|
+
const has = (c) => { try { execSync(`command -v ${c}`, { stdio: "ignore", shell: "/bin/sh" }); return true; } catch { return false; } };
|
|
15
|
+
const read = (p) => { try { return JSON.parse(readFileSync(p, "utf8")); } catch { return null; } };
|
|
16
|
+
const ok = (m) => console.log(` ✓ ${m}`);
|
|
17
|
+
const warn = (m, fix) => { console.log(` ✗ ${m}`); if (fix) console.log(` → ${fix}`); issues++; };
|
|
18
|
+
let issues = 0;
|
|
19
|
+
|
|
20
|
+
console.log("TRANTOR DOCTOR\n");
|
|
21
|
+
|
|
22
|
+
// runtime + hub
|
|
23
|
+
console.log("core");
|
|
24
|
+
Number(process.versions.node.split(".")[0]) >= 18 ? ok(`node ${process.versions.node}`) : warn(`node ${process.versions.node} too old`, "install node >= 18");
|
|
25
|
+
const cfg = read(join(H, ".agent-bus", "config.json")) || {};
|
|
26
|
+
const HUB = process.env.RELAY_URL || cfg.url || "http://127.0.0.1:4477";
|
|
27
|
+
try {
|
|
28
|
+
const h = await (await fetch(`${HUB}/health`, { signal: AbortSignal.timeout(2000) })).json();
|
|
29
|
+
ok(`hub up at ${HUB} (${h.peers} peers known)`);
|
|
30
|
+
} catch {
|
|
31
|
+
warn(`hub not reachable at ${HUB}`, `bash ${join(ROOT, "deploy", "setup.sh")} # installs the always-on service`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// claude plugin
|
|
35
|
+
console.log("\nclaude (the orchestrator)");
|
|
36
|
+
if (!has("claude")) warn("claude CLI not found", "install Claude Code: https://claude.com/claude-code");
|
|
37
|
+
else {
|
|
38
|
+
const st = read(join(H, ".claude", "settings.json")) || {};
|
|
39
|
+
Object.keys(st.enabledPlugins || {}).some(k => k.startsWith("agent-bus@") || k.startsWith("trantor@"))
|
|
40
|
+
? ok("plugin installed")
|
|
41
|
+
: warn("plugin not installed", "claude plugin marketplace add sashabogi/trantor && claude plugin install agent-bus");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// crew CLIs: installed / wired / authenticated
|
|
45
|
+
console.log("\ncrew CLIs (install any subset — seats follow the work)");
|
|
46
|
+
const CLIS = [
|
|
47
|
+
{ name: "codex", bin: "codex", wired: () => (readFileSync(join(H, ".codex", "config.toml"), "utf8")).includes("[mcp_servers.relay]"), auth: () => existsSync(join(H, ".codex", "auth.json")), login: "codex (sign in with your ChatGPT account on first run)" },
|
|
48
|
+
{ name: "gemini", bin: "gemini", wired: () => !!read(join(H, ".gemini", "settings.json"))?.mcpServers?.relay, auth: () => existsSync(join(H, ".gemini", "oauth_creds.json")) || !!process.env.GEMINI_API_KEY || !!process.env.GOOGLE_API_KEY, login: "gemini (Google sign-in on first run, or set GEMINI_API_KEY)" },
|
|
49
|
+
{ name: "kimi", bin: "kimi", wired: () => !!read(join(H, ".kimi", "mcp.json"))?.mcpServers?.relay, auth: () => existsSync(join(H, ".kimi", "credentials")), login: "kimi → /login (Kimi account or Moonshot API key)" },
|
|
50
|
+
{ name: "deepseek (via opencode)", bin: "opencode", wired: () => !!read(join(H, ".config", "opencode", "opencode.json"))?.mcp?.relay, auth: () => !!process.env.DEEPSEEK_API_KEY || (existsSync(join(H, ".agent-bus", ".env")) && readFileSync(join(H, ".agent-bus", ".env"), "utf8").includes("DEEPSEEK_API_KEY")) || !!read(join(H, ".local", "share", "opencode", "auth.json")), login: `get a key at platform.deepseek.com, then: echo 'DEEPSEEK_API_KEY=sk-…' >> ~/.agent-bus/.env` },
|
|
51
|
+
];
|
|
52
|
+
let installed = 0;
|
|
53
|
+
for (const c of CLIS) {
|
|
54
|
+
if (!has(c.bin)) { console.log(` – ${c.name}: not installed (optional)`); continue; }
|
|
55
|
+
installed++;
|
|
56
|
+
let wired = false; try { wired = c.wired(); } catch {}
|
|
57
|
+
wired ? ok(`${c.name}: wired to the bus`) : warn(`${c.name}: installed but not wired`, `node ${join(ROOT, "bin", "connect.mjs")}`);
|
|
58
|
+
let authed = false; try { authed = c.auth(); } catch {}
|
|
59
|
+
authed ? ok(`${c.name}: authenticated`) : warn(`${c.name}: NOT authenticated — it will join the bus but fail on its first turn`, c.login);
|
|
60
|
+
}
|
|
61
|
+
if (!installed) warn("no crew CLIs found", "install at least one of: codex, gemini, kimi, opencode — Trantor orchestrates whatever you have");
|
|
62
|
+
|
|
63
|
+
// brain
|
|
64
|
+
console.log("\nthe brain");
|
|
65
|
+
has("scrooge") || existsSync(join(H, ".local", "bin", "scrooge"))
|
|
66
|
+
? ok("economics engine installed (routing + cost ledger active)")
|
|
67
|
+
: warn("economics engine missing — Advisor runs without live pricing; relay_scrooge dormant", "trantor setup (installs it automatically)");
|
|
68
|
+
const prof = read(join(H, ".agent-bus", "profile.json"));
|
|
69
|
+
prof?.providers && Object.keys(prof.providers).length
|
|
70
|
+
? ok(`quota profile set (${Object.entries(prof.providers).map(([k, v]) => `${k}=${v.plan}`).join(", ")})`)
|
|
71
|
+
: warn("quota profile not set — the Advisor will assume API billing everywhere", `node ${join(ROOT, "bin", "profile.mjs")} set claude=max codex=plus deepseek=api … (use YOUR real plans)`);
|
|
72
|
+
|
|
73
|
+
console.log(issues ? `\n${issues} issue(s) — fix the → lines above, then re-run the doctor.` : "\nAll clear — open a claude session in any project and say: \"fire up the crew\".");
|
|
74
|
+
process.exit(issues ? 1 : 0);
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# agent-bus handoff prompt (macOS) — shown when a session hits its context limit.
|
|
3
|
+
# Asks the user, with a timeout, whether to open a FRESH same-agent session that takes
|
|
4
|
+
# over via the handoff. Default (incl. timeout, or no UI) = open fresh. "Keep compacting" = skip.
|
|
5
|
+
#
|
|
6
|
+
# Usage: handoff-prompt.sh <project-dir> [timeout-seconds]
|
|
7
|
+
# Agent to spawn = $AGENT_CMD (default "claude") — same agent, fresh window.
|
|
8
|
+
DIR="${1:-$HOME}"
|
|
9
|
+
TIMEOUT="${2:-25}"
|
|
10
|
+
AGENT_CMD="${AGENT_CMD:-claude}"
|
|
11
|
+
HERE="$(cd "$(dirname "$0")" && pwd)"
|
|
12
|
+
NAME="$(basename "$DIR")"
|
|
13
|
+
|
|
14
|
+
MSG="agent-bus — this session's context window is full ($NAME). Open a FRESH session to take over with a full window? It loads a handoff of this session. (The current session keeps compacting either way.)"
|
|
15
|
+
|
|
16
|
+
# Best-effort timed dialog. On timeout, error, or no UI session -> empty -> we spawn (the default).
|
|
17
|
+
CHOICE="$(osascript -e "button returned of (display dialog \"${MSG//\"/\\\"}\" buttons {\"Keep compacting\", \"Open fresh session\"} default button \"Open fresh session\" giving up after $TIMEOUT with title \"agent-bus\")" 2>/dev/null)"
|
|
18
|
+
|
|
19
|
+
if [ "$CHOICE" != "Keep compacting" ]; then
|
|
20
|
+
"$HERE/open-session.sh" "$DIR" "$AGENT_CMD"
|
|
21
|
+
fi
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# open-session.sh <dir> <command...> — open a NEW macOS Terminal window in <dir> running <command>.
|
|
3
|
+
# Lets a Claude session spawn sibling terminal sessions (e.g. to test multi-session relay flows).
|
|
4
|
+
DIR="${1:-$HOME}"; shift; CMD="$*"
|
|
5
|
+
osascript >/dev/null <<OSA
|
|
6
|
+
tell application "Terminal"
|
|
7
|
+
do script "cd " & quoted form of "$DIR" & " && $CMD"
|
|
8
|
+
activate
|
|
9
|
+
end tell
|
|
10
|
+
OSA
|
package/bin/profile.mjs
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// agent-bus quota profile — declare what plan each provider runs on, once.
|
|
3
|
+
// The Advisor uses this to pick execution modes (plans can't be detected reliably).
|
|
4
|
+
//
|
|
5
|
+
// node bin/profile.mjs # show current profile
|
|
6
|
+
// node bin/profile.mjs set claude=max codex=plus gemini=tier kimi=coding-plan deepseek=api
|
|
7
|
+
// node bin/profile.mjs set claude=pro # update one provider
|
|
8
|
+
//
|
|
9
|
+
// Plan vocabulary (free-form, but these mean something to the Advisor):
|
|
10
|
+
// api — pay per token (offload aggressively; every orchestrator token is money)
|
|
11
|
+
// pro | plus | tier | coding-plan — a capped subscription (~$20-ish: crew is the only path
|
|
12
|
+
// for real builds; the plan's quota is a scarce budget)
|
|
13
|
+
// max | max-5x | max-20x | ultra — high-tier subscription (cost moot; context horizon decides)
|
|
14
|
+
// none — provider not available on this machine
|
|
15
|
+
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { homedir } from "node:os";
|
|
18
|
+
|
|
19
|
+
const FILE = join(homedir(), ".agent-bus", "profile.json");
|
|
20
|
+
const KNOWN = ["claude", "codex", "gemini", "kimi", "deepseek", "opencode"];
|
|
21
|
+
const TIER = (plan) => {
|
|
22
|
+
const p = String(plan || "none").toLowerCase();
|
|
23
|
+
if (p === "api") return "api";
|
|
24
|
+
if (/^(max|ultra)/.test(p)) return "high-sub";
|
|
25
|
+
if (p === "none") return "none";
|
|
26
|
+
return "capped-sub"; // pro/plus/tier/coding-plan/anything else
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export function loadProfile() {
|
|
30
|
+
try { return JSON.parse(readFileSync(FILE, "utf8")); } catch { return { providers: {} }; }
|
|
31
|
+
}
|
|
32
|
+
export function tierOf(profile, provider) { return TIER(profile?.providers?.[provider]?.plan); }
|
|
33
|
+
|
|
34
|
+
const [, , cmd, ...args] = process.argv;
|
|
35
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
36
|
+
const prof = loadProfile();
|
|
37
|
+
prof.providers ||= {};
|
|
38
|
+
if (cmd === "set") {
|
|
39
|
+
for (const a of args) {
|
|
40
|
+
const [prov, plan] = a.split("=");
|
|
41
|
+
if (!prov || !plan) { console.error(`bad arg '${a}' — use provider=plan`); process.exit(1); }
|
|
42
|
+
prof.providers[prov.toLowerCase()] = { plan: plan.toLowerCase(), tier: TIER(plan) };
|
|
43
|
+
}
|
|
44
|
+
prof.updated = new Date().toISOString().slice(0, 10);
|
|
45
|
+
writeFileSync(FILE, JSON.stringify(prof, null, 2) + "\n");
|
|
46
|
+
console.log("profile saved →", FILE);
|
|
47
|
+
} else if (cmd && cmd !== "show") {
|
|
48
|
+
console.error("usage: profile.mjs [show] | set provider=plan …"); process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
const p = loadProfile();
|
|
51
|
+
console.log("QUOTA PROFILE" + (existsSync(FILE) ? "" : " (not set — Advisor will assume api billing everywhere)"));
|
|
52
|
+
for (const k of new Set([...KNOWN, ...Object.keys(p.providers || {})])) {
|
|
53
|
+
const e = (p.providers || {})[k];
|
|
54
|
+
console.log(` ${k.padEnd(9)} ${e ? `${e.plan.padEnd(12)} → ${e.tier}` : "(unset)"}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// relay-watch — a live feed of the bus via SSE (true push, no polling). Run in a terminal
|
|
3
|
+
// to watch sessions talk in real time, or to monitor a presence/status board.
|
|
4
|
+
// node bin/relay-watch.mjs [session] (default: "all" — see every message)
|
|
5
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
|
|
9
|
+
function relayUrl() {
|
|
10
|
+
if (process.env.RELAY_URL) return process.env.RELAY_URL;
|
|
11
|
+
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 {}
|
|
12
|
+
return "http://127.0.0.1:4477";
|
|
13
|
+
}
|
|
14
|
+
const URL_BASE = relayUrl();
|
|
15
|
+
const SESSION = process.argv[2] || "all";
|
|
16
|
+
const t = () => new Date().toLocaleTimeString();
|
|
17
|
+
|
|
18
|
+
async function showPeers() {
|
|
19
|
+
try {
|
|
20
|
+
const { peers } = await (await fetch(`${URL_BASE}/peers`)).json();
|
|
21
|
+
const live = peers.filter(p => p.online);
|
|
22
|
+
console.log(`\n live sessions (${live.length}):`);
|
|
23
|
+
for (const p of live) console.log(` 🟢 ${p.session}${p.status ? ` — ${p.status}` : ""}`);
|
|
24
|
+
console.log("");
|
|
25
|
+
} catch {}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function watch() {
|
|
29
|
+
console.log(`relay-watch → ${URL_BASE} (watching "${SESSION}") — Ctrl-C to stop`);
|
|
30
|
+
await showPeers();
|
|
31
|
+
for (;;) {
|
|
32
|
+
try {
|
|
33
|
+
const r = await fetch(`${URL_BASE}/stream?session=${encodeURIComponent(SESSION)}`, { headers: { accept: "text/event-stream" } });
|
|
34
|
+
if (!r.ok || !r.body) throw new Error(`stream ${r.status}`);
|
|
35
|
+
let buf = "";
|
|
36
|
+
const dec = new TextDecoder();
|
|
37
|
+
for await (const chunk of r.body) {
|
|
38
|
+
buf += dec.decode(chunk, { stream: true });
|
|
39
|
+
let i;
|
|
40
|
+
while ((i = buf.indexOf("\n\n")) >= 0) {
|
|
41
|
+
const frame = buf.slice(0, i); buf = buf.slice(i + 2);
|
|
42
|
+
for (const line of frame.split("\n")) {
|
|
43
|
+
if (!line.startsWith("data:")) continue;
|
|
44
|
+
try { const m = JSON.parse(line.slice(5).trim()); console.log(` [${t()}] ${m.from} → ${m.to}: ${m.text}`); } catch {}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
} catch (e) {
|
|
49
|
+
console.error(` (stream dropped: ${e?.message || e} — reconnecting in 2s)`);
|
|
50
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
watch();
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// agent-bus statusline — prints a tiny live indicator for the agent's status bar:
|
|
3
|
+
// 🟢 agent-bus · 3 live
|
|
4
|
+
// Claude Code: add to settings.json ->
|
|
5
|
+
// "statusLine": { "type": "command", "command": "node /path/to/agent-bus/bin/statusline.mjs" }
|
|
6
|
+
// Reads session info as JSON on stdin (Claude Code convention); fast + fail-silent.
|
|
7
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
8
|
+
import { join, basename } from "node:path";
|
|
9
|
+
import { homedir, hostname } from "node:os";
|
|
10
|
+
|
|
11
|
+
function relayUrl() {
|
|
12
|
+
if (process.env.RELAY_URL) return process.env.RELAY_URL;
|
|
13
|
+
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 {}
|
|
14
|
+
return "http://127.0.0.1:4477";
|
|
15
|
+
}
|
|
16
|
+
async function main() {
|
|
17
|
+
let stdin = ""; try { for await (const c of process.stdin) stdin += c; } catch {}
|
|
18
|
+
let cwd = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
19
|
+
try { const j = JSON.parse(stdin || "{}"); cwd = j.cwd || j.workspace?.current_dir || cwd; } catch {}
|
|
20
|
+
const me = process.env.RELAY_SESSION || `${hostname()}:${basename(cwd)}`;
|
|
21
|
+
try {
|
|
22
|
+
const r = await fetch(`${relayUrl()}/peers`, { signal: AbortSignal.timeout(800) });
|
|
23
|
+
const { peers } = await r.json();
|
|
24
|
+
const live = peers.filter(p => p.online && p.session !== me).length;
|
|
25
|
+
process.stdout.write(`\x1b[38;5;43m● agent-bus\x1b[0m \x1b[2m· ${live} other${live === 1 ? "" : "s"} live\x1b[0m`);
|
|
26
|
+
} catch {
|
|
27
|
+
process.stdout.write(`\x1b[2m○ agent-bus offline\x1b[0m`); // hub unreachable
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
main();
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { writeFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
3
|
+
import { join, basename } from "node:path";
|
|
4
|
+
import { homedir, hostname } from "node:os";
|
|
5
|
+
import { execSync } from "node:child_process";
|
|
6
|
+
const project = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
7
|
+
const name = basename(project);
|
|
8
|
+
let summary = ""; process.stdin.setEncoding("utf8");
|
|
9
|
+
for await (const c of process.stdin) summary += c;
|
|
10
|
+
const dir = join(homedir(), ".agent-bus", "handoffs");
|
|
11
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
12
|
+
const stamp = (() => { try { return execSync("date +%s",{encoding:"utf8"}).trim(); } catch { return String(process.pid); } })();
|
|
13
|
+
let git=""; try { git = execSync("git -C "+JSON.stringify(project)+" status --short 2>/dev/null | head -30",{encoding:"utf8"}).trim(); } catch {}
|
|
14
|
+
const rec = { id:`${name}-${stamp}`, project, projectName:name, machine:hostname(), trigger:"manual-skill", stamp:Number(stamp)||0, summary:summary.trim()||"(empty)", gitStatus:git, consumed:false };
|
|
15
|
+
const file = join(dir, `${rec.id}.json`);
|
|
16
|
+
writeFileSync(file, JSON.stringify(rec,null,2));
|
|
17
|
+
console.log(`handoff saved: ${file}`);
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# agent-bus for Codex CLI — add to ~/.codex/config.toml
|
|
2
|
+
# Loading this MCP auto-registers the Codex session on the bus + gives the relay_* tools.
|
|
3
|
+
[mcp_servers.relay]
|
|
4
|
+
command = "node"
|
|
5
|
+
args = ["REPLACE/WITH/ABSOLUTE/PATH/agent-bus/mcp.mjs"]
|
|
6
|
+
env = { RELAY_URL = "http://127.0.0.1:4477", RELAY_SESSION = "codex:myproject" }
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"//": "agent-bus for Gemini CLI — merge into ~/.gemini/settings.json (or a project .gemini/settings.json).",
|
|
3
|
+
"mcpServers": {
|
|
4
|
+
"relay": {
|
|
5
|
+
"command": "node",
|
|
6
|
+
"args": ["REPLACE/WITH/ABSOLUTE/PATH/agent-bus/mcp.mjs"],
|
|
7
|
+
"env": { "RELAY_URL": "http://127.0.0.1:4477", "RELAY_SESSION": "gemini:myproject" }
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3
|
+
<!-- trantor hub as an always-on launchd service. setup.sh fills the __PLACEHOLDERS__. -->
|
|
4
|
+
<plist version="1.0">
|
|
5
|
+
<dict>
|
|
6
|
+
<key>Label</key><string>com.trantor.hub</string>
|
|
7
|
+
<key>ProgramArguments</key>
|
|
8
|
+
<array><string>__NODE__</string><string>__REPO__/hub.mjs</string></array>
|
|
9
|
+
<key>WorkingDirectory</key><string>__REPO__</string>
|
|
10
|
+
<key>EnvironmentVariables</key>
|
|
11
|
+
<dict><key>RELAY_HOST</key><string>127.0.0.1</string><key>RELAY_PORT</key><string>4477</string></dict>
|
|
12
|
+
<key>RunAtLoad</key><true/>
|
|
13
|
+
<key>KeepAlive</key><true/>
|
|
14
|
+
<key>StandardOutPath</key><string>/tmp/trantor-hub.log</string>
|
|
15
|
+
<key>StandardErrorPath</key><string>/tmp/trantor-hub.log</string>
|
|
16
|
+
</dict>
|
|
17
|
+
</plist>
|