trantor 0.17.40 → 0.17.42
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/hooks/hooks.json +5 -1
- package/hooks/inbox-deliver.mjs +147 -0
- package/hooks/sessionstart.mjs +31 -2
- package/hooks/subagent-start.mjs +46 -0
- package/hub.mjs +0 -0
- package/package.json +2 -2
- package/ui.html +57 -6
|
@@ -6,14 +6,14 @@
|
|
|
6
6
|
},
|
|
7
7
|
"metadata": {
|
|
8
8
|
"description": "Trantor — the hub-world for AI agent crews: live message bus, presence, project Kanban/flow board + context-handoff for independent AI coding agents (Claude, Codex, Gemini, …)",
|
|
9
|
-
"version": "0.17.
|
|
9
|
+
"version": "0.17.42"
|
|
10
10
|
},
|
|
11
11
|
"plugins": [
|
|
12
12
|
{
|
|
13
13
|
"name": "trantor",
|
|
14
14
|
"source": "./",
|
|
15
15
|
"description": "The hub-world for AI agent crews. Say \"fire up the crew\" and Claude becomes the architect: a plan-aware Advisor routes the work (solo / cheap inline calls / live crew of Codex, Gemini, Kimi & DeepSeek in their own terminal windows), a Kanban/flow command center with a testing gate tracks it, and an economics brain (Scrooge) keeps the receipts. Includes the relay MCP, a SessionStart auto-discovery hook, and a PreCompact context-handoff so a fresh session can take over a full window instead of compacting.",
|
|
16
|
-
"version": "0.17.
|
|
16
|
+
"version": "0.17.42",
|
|
17
17
|
"author": {
|
|
18
18
|
"name": "Sasha Bogojevic"
|
|
19
19
|
},
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.42",
|
|
4
4
|
"description": "Trantor — the hub-world for AI agent crews: live message bus, presence, project Kanban/flow board + crew orchestration for independent AI coding agents (Claude, Codex, Gemini, Kimi, DeepSeek)",
|
|
5
5
|
"mcpServers": {
|
|
6
6
|
"relay": {
|
package/hooks/hooks.json
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
{
|
|
2
|
-
"description": "trantor — auto-register each session + inject live roster (SessionStart); heartbeat presence on every tool call + mirror the session's TodoWrite list onto the board as cards (PostToolUse); write a handoff before compaction (PreCompact); card each sub-agent's notional API cost when it finishes (SubagentStop)",
|
|
2
|
+
"description": "trantor — auto-register each session + inject live roster (SessionStart); post an in-flight 'doing' card when a sub-agent is dispatched (PreToolUse); heartbeat presence on every tool call + deliver unread bus messages to a busy session mid-turn + mirror the session's TodoWrite list onto the board as cards (PostToolUse); write a handoff before compaction (PreCompact); card each sub-agent's notional API cost when it finishes (SubagentStop)",
|
|
3
3
|
"hooks": {
|
|
4
4
|
"SessionStart": [
|
|
5
5
|
{ "matcher": "", "hooks": [ { "type": "command", "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/sessionstart.mjs" } ] }
|
|
6
6
|
],
|
|
7
|
+
"PreToolUse": [
|
|
8
|
+
{ "matcher": "Task|Agent", "hooks": [ { "type": "command", "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/subagent-start.mjs" } ] }
|
|
9
|
+
],
|
|
7
10
|
"PostToolUse": [
|
|
8
11
|
{ "matcher": "", "hooks": [ { "type": "command", "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/heartbeat.mjs" } ] },
|
|
12
|
+
{ "matcher": "", "hooks": [ { "type": "command", "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/inbox-deliver.mjs" } ] },
|
|
9
13
|
{ "matcher": "TodoWrite", "hooks": [ { "type": "command", "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/todo-sync.mjs" } ] }
|
|
10
14
|
],
|
|
11
15
|
"PreCompact": [
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// trantor PostToolUse inbox delivery — surface bus messages to a BUSY session.
|
|
3
|
+
//
|
|
4
|
+
// The bug this fixes: Trantor message delivery is pure pull-on-demand. relay_send only
|
|
5
|
+
// enqueues on the hub; the recipient learns of a message ONLY when the model itself
|
|
6
|
+
// chooses to call relay_inbox/relay_wait. A session grinding through a long tool-use loop
|
|
7
|
+
// never makes that choice, so it sits "online" (the heartbeat keeps lastSeen fresh) but
|
|
8
|
+
// DEAF — a peer can ping it twice over 10 minutes and get no reply. (Observed 2026-06-23:
|
|
9
|
+
// a new session pinged a mid-build sibling; the sibling never answered because it was busy
|
|
10
|
+
// and never polled.)
|
|
11
|
+
//
|
|
12
|
+
// The fix, hook-side (don't trust the model to poll): a busy session IS firing tool calls,
|
|
13
|
+
// so this PostToolUse hook runs constantly. Each run polls /inbox and injects any NEW peer
|
|
14
|
+
// messages via hookSpecificOutput.additionalContext — which Claude Code delivers as a
|
|
15
|
+
// system reminder the model acts on IN THE SAME TURN, between its own tool calls. So a ping
|
|
16
|
+
// lands within a few seconds even mid-build, and the model can reply via relay_send without
|
|
17
|
+
// waiting for the human to prompt it.
|
|
18
|
+
//
|
|
19
|
+
// Cheap + fail-silent by contract: a per-session poll stamp gates the network call, a short
|
|
20
|
+
// fetch timeout means we never add real latency, and we ALWAYS exit clean with valid stdout.
|
|
21
|
+
// First run initialises the cursor to "now" (current max id) and injects NOTHING, so a
|
|
22
|
+
// session is never flooded with the whole backlog of old broadcasts on its first tool call.
|
|
23
|
+
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
24
|
+
import { join } from "node:path";
|
|
25
|
+
import { homedir } from "node:os";
|
|
26
|
+
import { resolveProject, hostId } from "../lib/project.mjs";
|
|
27
|
+
|
|
28
|
+
const POLL_MS = Number(process.env.RELAY_INBOX_POLL_MS || 4000);
|
|
29
|
+
const FETCH_TIMEOUT_MS = Number(process.env.RELAY_INBOX_TIMEOUT_MS || 1500);
|
|
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
|
+
|
|
40
|
+
// Keep injected text safe to embed in JSON: drop control chars that could corrupt the
|
|
41
|
+
// additionalContext payload (the model still gets the readable message).
|
|
42
|
+
function sanitize(s) { return String(s == null ? "" : s).replace(/[\x00-\x1f\x7f-\x9f]/g, " "); }
|
|
43
|
+
|
|
44
|
+
async function getInbox(url, session, since) {
|
|
45
|
+
const r = await fetch(`${url}/inbox?session=${encodeURIComponent(session)}&since=${since}`,
|
|
46
|
+
{ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
|
|
47
|
+
if (!r.ok) throw new Error(`hub ${r.status}`);
|
|
48
|
+
return r.json(); // { messages: [...], cursor }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// PostToolUse hands us the tool-input JSON on stdin. We don't need it, but we must DRAIN it:
|
|
52
|
+
// a large tool input (e.g. a big Write) can exceed the 64KB pipe buffer and block the parent's
|
|
53
|
+
// write if nobody reads. Consume + discard, with a short timeout so we never hang.
|
|
54
|
+
function drainStdin() {
|
|
55
|
+
return new Promise(res => {
|
|
56
|
+
try { process.stdin.resume(); process.stdin.on("data", () => {}); process.stdin.on("end", res); }
|
|
57
|
+
catch { res(); }
|
|
58
|
+
setTimeout(res, 80);
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Self-validating stdout: model-facing additionalContext only when we actually deliver.
|
|
63
|
+
function emit(ctx) {
|
|
64
|
+
if (!ctx) return "{}";
|
|
65
|
+
const obj = { hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: ctx } };
|
|
66
|
+
const out = JSON.stringify(obj);
|
|
67
|
+
try { JSON.parse(out); return out; } catch { return "{}"; }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function main() {
|
|
71
|
+
const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
72
|
+
// Mirror heartbeat.mjs / sessionstart.mjs: a home-directory session isn't project work and
|
|
73
|
+
// isn't on the bus — nothing to deliver. Opt in with RELAY_SESSION / RELAY_PROJECT.
|
|
74
|
+
if (!process.env.RELAY_SESSION && !process.env.RELAY_PROJECT && projectDir === homedir()) return "{}";
|
|
75
|
+
|
|
76
|
+
// Resolve THIS session's identity EXACTLY as mcp.mjs / heartbeat.mjs do, so we poll the
|
|
77
|
+
// same peer the relay registered (RELAY_SESSION wins; else RELAY_AGENT brand; else host:project).
|
|
78
|
+
const project = resolveProject(projectDir);
|
|
79
|
+
const session = process.env.RELAY_SESSION
|
|
80
|
+
|| (process.env.RELAY_AGENT ? `${process.env.RELAY_AGENT}:${project}` : `${hostId()}:${project}`);
|
|
81
|
+
|
|
82
|
+
const safe = session.replace(/[^A-Za-z0-9_.-]/g, "_");
|
|
83
|
+
const dir = join(homedir(), ".agent-bus");
|
|
84
|
+
const pollStamp = join(dir, `inbox-poll-${safe}.stamp`);
|
|
85
|
+
const cursorFile = join(dir, `inbox-cursor-${safe}.id`);
|
|
86
|
+
|
|
87
|
+
// Throttle: poll the hub at most once per POLL_MS. Write the stamp BEFORE the network call
|
|
88
|
+
// so a burst of parallel tool calls doesn't all fire (and double-deliver).
|
|
89
|
+
try {
|
|
90
|
+
if (existsSync(pollStamp)) {
|
|
91
|
+
const last = Number(readFileSync(pollStamp, "utf8")) || 0;
|
|
92
|
+
if (Date.now() - last < POLL_MS) return "{}"; // within window — skip
|
|
93
|
+
}
|
|
94
|
+
} catch {}
|
|
95
|
+
try { writeFileSync(pollStamp, String(Date.now())); } catch {}
|
|
96
|
+
|
|
97
|
+
const url = relayUrl();
|
|
98
|
+
|
|
99
|
+
// First run: no cursor yet. Initialise to the current max deliverable id and inject NOTHING,
|
|
100
|
+
// so we start listening "from now" instead of replaying the whole backlog of old broadcasts.
|
|
101
|
+
if (!existsSync(cursorFile)) {
|
|
102
|
+
try {
|
|
103
|
+
const { cursor } = await getInbox(url, session, 0);
|
|
104
|
+
writeFileSync(cursorFile, String(cursor || 0));
|
|
105
|
+
} catch {}
|
|
106
|
+
return "{}";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
let cursor = 0;
|
|
110
|
+
try { cursor = Number(readFileSync(cursorFile, "utf8")) || 0; } catch {}
|
|
111
|
+
|
|
112
|
+
let messages = [], next = cursor;
|
|
113
|
+
try {
|
|
114
|
+
const res = await getInbox(url, session, cursor);
|
|
115
|
+
messages = Array.isArray(res.messages) ? res.messages : [];
|
|
116
|
+
next = res.cursor || cursor;
|
|
117
|
+
} catch { return "{}"; } // hub down / timeout — never block the tool flow
|
|
118
|
+
|
|
119
|
+
if (!messages.length) return "{}";
|
|
120
|
+
|
|
121
|
+
// Advance the cursor immediately so we don't re-inject these on the next tool call.
|
|
122
|
+
try { writeFileSync(cursorFile, String(next)); } catch {}
|
|
123
|
+
|
|
124
|
+
const lines = messages.map(m => {
|
|
125
|
+
const direct = m.to === session;
|
|
126
|
+
const tag = direct ? "📨 DIRECT" : "📣 broadcast";
|
|
127
|
+
const when = (() => { try { return new Date(m.ts).toLocaleTimeString(); } catch { return ""; } })();
|
|
128
|
+
return `- ${tag} from ${sanitize(m.from)}${when ? ` (${when})` : ""}: ${sanitize(m.text)}`;
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const ctx =
|
|
132
|
+
`<trantor-inbox count="${messages.length}">\n` +
|
|
133
|
+
`📬 ${messages.length} new bus message(s) arrived while you were working (you did not poll for these — Trantor surfaced them automatically):\n` +
|
|
134
|
+
lines.join("\n") + `\n` +
|
|
135
|
+
`If a peer is asking you something or waiting on you, reply now with the relay_send tool (to their session id). ` +
|
|
136
|
+
`If a message just needs an ack, send a short one. You can keep working after responding.\n` +
|
|
137
|
+
`</trantor-inbox>\n`;
|
|
138
|
+
|
|
139
|
+
return emit(ctx);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Never block or break the tool flow: drain stdin, swallow everything, always emit valid stdout.
|
|
143
|
+
drainStdin()
|
|
144
|
+
.then(main)
|
|
145
|
+
.then(out => { try { process.stdout.write(out || "{}"); } catch {} })
|
|
146
|
+
.catch(() => { try { process.stdout.write("{}"); } catch {} })
|
|
147
|
+
.finally(() => process.exit(0));
|
package/hooks/sessionstart.mjs
CHANGED
|
@@ -94,11 +94,35 @@ function sanitize(s) {
|
|
|
94
94
|
return out;
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
// Session title for the picker / `claude --resume` / Claude mobile. Claude Code otherwise names a session
|
|
98
|
+
// after its FIRST PROMPT (the `ai-title` transcript entry) — so several sessions started with the same
|
|
99
|
+
// prompt (or sibling sessions in different projects) all look alike. We name it "<project> · <current work>"
|
|
100
|
+
// where the work is the single most relevant in-flight item from the board, so concurrent sessions are
|
|
101
|
+
// instantly distinguishable. (SessionStart can set the title via hookSpecificOutput.sessionTitle; it has no
|
|
102
|
+
// mid-session rename, so this reflects the project's state at startup.)
|
|
103
|
+
function sessionTitleFrom(project, cu) {
|
|
104
|
+
let work = "";
|
|
105
|
+
if (cu) {
|
|
106
|
+
// prefer real work cards (doing → testing → todo), skipping transient cc-subagent infra cards which
|
|
107
|
+
// would otherwise dominate "most recent" and make every session title noise; then the project brief.
|
|
108
|
+
const real = a => (Array.isArray(a) ? a.filter(t => t.source !== "cc-subagent") : []);
|
|
109
|
+
const first = a => (a.length ? a[0].title : "");
|
|
110
|
+
work = first(real(cu.doing)) || first(real(cu.testing)) || first(real(cu.todo)) || cu.brief || "";
|
|
111
|
+
}
|
|
112
|
+
work = String(work || "").replace(/\s+/g, " ").trim()
|
|
113
|
+
.replace(/^v?\d+\.\d+\.\d+\s*[:—–•·-]\s*/i, ""); // drop a leading release-version prefix (just noise here)
|
|
114
|
+
if (work.length > 44) work = work.slice(0, 43).trimEnd() + "…";
|
|
115
|
+
return sanitize(work ? `${project} · ${work}` : project).slice(0, 90);
|
|
116
|
+
}
|
|
117
|
+
|
|
97
118
|
let additionalContext = "";
|
|
98
119
|
let userBanner = ""; // shown to the USER in-terminal via the hook's `systemMessage` (not model-only context)
|
|
120
|
+
let sessionTitle = ""; // picker / --resume / mobile title — set to "<project> · <current work>" below
|
|
121
|
+
let userTitle = ""; // a title the USER set explicitly (--name / rename); never override it
|
|
99
122
|
try {
|
|
100
123
|
let source = "", stdinObj = {};
|
|
101
124
|
try { stdinObj = JSON.parse((await readStdin()) || "{}"); source = stdinObj.source || ""; } catch {}
|
|
125
|
+
userTitle = (stdinObj && stdinObj.session_title) ? String(stdinObj.session_title) : ""; // user already named it
|
|
102
126
|
const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
103
127
|
// Sessions started in the home directory itself aren't project work — registering
|
|
104
128
|
// them spawns a phantom "<username>" project board on the dashboard. Set
|
|
@@ -109,6 +133,7 @@ try {
|
|
|
109
133
|
process.exit(0);
|
|
110
134
|
}
|
|
111
135
|
const project = resolveProject(projectDir);
|
|
136
|
+
sessionTitle = project; // baseline — enriched with the current work item after the catch-up fetch below
|
|
112
137
|
const session = process.env.RELAY_SESSION
|
|
113
138
|
|| (process.env.RELAY_AGENT ? `${process.env.RELAY_AGENT}:${project}` : `${hostId()}:${project}`);
|
|
114
139
|
const url = relayUrl();
|
|
@@ -137,6 +162,7 @@ try {
|
|
|
137
162
|
// SAME project where it stands instead of starting blind. Cheap + LLM-free.
|
|
138
163
|
try {
|
|
139
164
|
const cu = await jget(`${url}/catchup?project=${encodeURIComponent(project)}`).catch(() => null);
|
|
165
|
+
sessionTitle = sessionTitleFrom(project, cu); // "<project> · <current work>" for the picker/--resume/mobile
|
|
140
166
|
let gitlog = "";
|
|
141
167
|
try { gitlog = execSync(`git -C ${JSON.stringify(projectDir)} log --oneline -5 2>/dev/null`, { encoding: "utf8", timeout: 2500 }).trim(); } catch {}
|
|
142
168
|
if ((cu && cu.total > 0) || gitlog) {
|
|
@@ -248,18 +274,21 @@ try {
|
|
|
248
274
|
// Hook protocol: emit additionalContext (model-facing) via stdout JSON, plus an optional
|
|
249
275
|
// `systemMessage` (USER-facing — rendered as a line in the terminal, our update indicator).
|
|
250
276
|
// Self-validate so we never emit something Claude Code can't parse — fall back to sanitized, then {}.
|
|
251
|
-
function emit(ctx, sysMsg) {
|
|
277
|
+
function emit(ctx, sysMsg, title) {
|
|
252
278
|
const obj = {};
|
|
253
279
|
if (ctx) obj.hookSpecificOutput = { hookEventName: "SessionStart", additionalContext: ctx };
|
|
280
|
+
if (title) (obj.hookSpecificOutput ||= { hookEventName: "SessionStart" }).sessionTitle = title;
|
|
254
281
|
if (sysMsg) obj.systemMessage = sysMsg;
|
|
255
282
|
const out = JSON.stringify(obj);
|
|
256
283
|
try { JSON.parse(out); return out; } catch { /* fall through */ }
|
|
257
284
|
try {
|
|
258
285
|
const safe = {};
|
|
259
286
|
if (ctx) safe.hookSpecificOutput = { hookEventName: "SessionStart", additionalContext: sanitize(ctx) };
|
|
287
|
+
if (title) (safe.hookSpecificOutput ||= { hookEventName: "SessionStart" }).sessionTitle = sanitize(title);
|
|
260
288
|
if (sysMsg) safe.systemMessage = sanitize(sysMsg);
|
|
261
289
|
return JSON.stringify(safe);
|
|
262
290
|
} catch { return "{}"; }
|
|
263
291
|
}
|
|
264
|
-
|
|
292
|
+
// Set the session title unless the user already named it explicitly (--name / rename).
|
|
293
|
+
process.stdout.write(emit(additionalContext, userBanner, userTitle ? "" : sessionTitle));
|
|
265
294
|
process.exit(0);
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// trantor PreToolUse hook (Task|Agent) — when a sub-agent is DISPATCHED, post an in-flight "doing" card so
|
|
3
|
+
// the board shows work IN PROGRESS while it runs. The existing SubagentStop hook (subagent-cost.mjs) flips
|
|
4
|
+
// the matching card to "done" via the hub's cc-subagent title-fingerprint dedup. Without this, every auto-
|
|
5
|
+
// card was born "done" (SubagentStop/git-backfill) so nothing ever showed as in progress. Fail-silent:
|
|
6
|
+
// never block or delay the dispatch.
|
|
7
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { resolveProject, hostId } from "../lib/project.mjs";
|
|
11
|
+
|
|
12
|
+
function readStdin() {
|
|
13
|
+
return new Promise(res => { let d = ""; process.stdin.setEncoding("utf8");
|
|
14
|
+
process.stdin.on("data", c => (d += c)); process.stdin.on("end", () => res(d));
|
|
15
|
+
setTimeout(() => res(d), 100); });
|
|
16
|
+
}
|
|
17
|
+
function relayUrl() {
|
|
18
|
+
if (process.env.RELAY_URL) return process.env.RELAY_URL;
|
|
19
|
+
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 {}
|
|
20
|
+
return "http://127.0.0.1:4477";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
const input = JSON.parse((await readStdin()) || "{}");
|
|
25
|
+
const ti = input.tool_input || {};
|
|
26
|
+
const cwd = input.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
27
|
+
const project = resolveProject(cwd);
|
|
28
|
+
const agentType = String(ti.subagent_type || "subagent").slice(0, 40);
|
|
29
|
+
// MUST mirror subagent-cost.mjs's title derivation (lines 100-101) so the hub's title fingerprint pairs
|
|
30
|
+
// this start card with the SubagentStop "done" card into ONE rolling cc-subagent card.
|
|
31
|
+
const task = String(ti.prompt || ti.description || agentType).replace(/\s+/g, " ").trim().slice(0, 90);
|
|
32
|
+
const title = `${agentType}: ${task}`.slice(0, 180);
|
|
33
|
+
await fetch(`${relayUrl()}/task`, {
|
|
34
|
+
method: "POST", headers: { "content-type": "application/json" },
|
|
35
|
+
body: JSON.stringify({
|
|
36
|
+
project, title, status: "doing",
|
|
37
|
+
assignee: `${agentType}:${project}`, by: `${hostId()}:${project}`,
|
|
38
|
+
source: "cc-subagent", costKind: "subagent-notional", phase: "sub-agents",
|
|
39
|
+
}),
|
|
40
|
+
signal: AbortSignal.timeout(1500),
|
|
41
|
+
}).catch(() => {});
|
|
42
|
+
} catch (e) {
|
|
43
|
+
process.stderr.write(`[trantor] subagent-start error: ${e?.message || e}\n`);
|
|
44
|
+
}
|
|
45
|
+
process.stdout.write("{}");
|
|
46
|
+
process.exit(0);
|
package/hub.mjs
CHANGED
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.42",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"trantor": "bin/cli.mjs"
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"zod": "^4.4.3"
|
|
11
11
|
},
|
|
12
12
|
"scripts": {
|
|
13
|
-
"test": "node test.mjs && node test-scenarios.mjs && node test-failure.mjs && node test-handoff.mjs && node test-agents.mjs && node test-update.mjs && node test-handoff-guard.mjs && node test-balances.mjs && node test-subagent-cost.mjs"
|
|
13
|
+
"test": "node test.mjs && node test-scenarios.mjs && node test-failure.mjs && node test-handoff.mjs && node test-agents.mjs && node test-update.mjs && node test-handoff-guard.mjs && node test-balances.mjs && node test-subagent-cost.mjs && node test-inbox.mjs && node test-inflight.mjs"
|
|
14
14
|
},
|
|
15
15
|
"description": "The hub-world for AI agent crews — orchestrate Claude Code, Codex, Gemini, Kimi & DeepSeek as live crews with a plan-aware Advisor, a Kanban/flow command center, a testing gate, and an economics brain (Scrooge).",
|
|
16
16
|
"files": [
|
package/ui.html
CHANGED
|
@@ -15,6 +15,24 @@ header{display:flex;align-items:center;gap:13px;padding:11px 18px;border-bottom:
|
|
|
15
15
|
main{flex:1;display:grid;grid-template-columns:var(--lw,44px) 1fr 330px;min-height:0}
|
|
16
16
|
main.learn-open{--lw:372px}
|
|
17
17
|
.boards{overflow-y:auto;padding:16px 18px}
|
|
18
|
+
/* Project-tab strip (between header + main): jump to a project (multi) or switch focus (solo). */
|
|
19
|
+
.ptabs{display:flex;gap:6px;align-items:center;padding:7px 18px;border-bottom:1px solid var(--line);background:var(--panel);overflow-x:auto;white-space:nowrap}
|
|
20
|
+
.ptab{display:inline-flex;align-items:center;gap:6px;font:inherit;font-size:11px;font-weight:600;color:var(--mut);background:#0c121e;border:1px solid #34465f;border-radius:11px;padding:4px 10px;cursor:pointer;white-space:nowrap;text-decoration:none;flex:none}
|
|
21
|
+
.ptab:hover{color:var(--tx);border-color:var(--grn)}
|
|
22
|
+
.ptab.on{color:#06120c;background:var(--grn);border-color:var(--grn)}
|
|
23
|
+
.ptab.live::before{content:"";width:6px;height:6px;border-radius:50%;background:var(--grn);box-shadow:0 0 6px var(--grn);flex:none}
|
|
24
|
+
.ptab.home{color:var(--dim);font-weight:700}
|
|
25
|
+
/* per-project "open in its own window" pop-out icon */
|
|
26
|
+
.popout{color:var(--mut);text-decoration:none;font-size:16px;line-height:1;cursor:pointer;flex:none}
|
|
27
|
+
.popout:hover{color:var(--grn)}
|
|
28
|
+
.proj.flash{animation:flashp 1.2s ease}
|
|
29
|
+
@keyframes flashp{0%,100%{box-shadow:none}30%{box-shadow:0 0 0 2px var(--grn)}}
|
|
30
|
+
/* SOLO = single-project full-window (deep link ?project=<name>): hide side rails, board fills the window. */
|
|
31
|
+
body.solo main{grid-template-columns:1fr}
|
|
32
|
+
body.solo .learn,body.solo aside{display:none}
|
|
33
|
+
body.solo .boards{padding:0}
|
|
34
|
+
body.solo .popout{display:none}
|
|
35
|
+
body.solo .proj{margin:0;border:none;border-radius:0}
|
|
18
36
|
/* Learning sidebar (collapsible left rail) — surfaces the self-learning loop */
|
|
19
37
|
.learn{background:var(--panel);border-right:1px solid var(--line);display:flex;flex-direction:column;min-height:0;overflow:hidden}
|
|
20
38
|
.learn-head{display:flex;align-items:center;gap:8px;padding:11px 12px;border-bottom:1px solid var(--line);cursor:pointer;white-space:nowrap;user-select:none}
|
|
@@ -275,6 +293,7 @@ aside h2{font-size:10.5px;text-transform:uppercase;letter-spacing:.09em;color:va
|
|
|
275
293
|
<span class="pill" id="credits" title="Provider credit left — $ balance (prepaid) or % quota (coding plans). Refill/pace before a build stalls. Pushed by sessions/CLI (the hub has no keys)." style="display:none"></span>
|
|
276
294
|
<span class="pill"><span id="nproj">0</span> projects · <span id="nsess">0</span> live · <span id="ntask">0</span> cards</span>
|
|
277
295
|
</header>
|
|
296
|
+
<div class="ptabs" id="ptabs" style="display:none"></div>
|
|
278
297
|
<main>
|
|
279
298
|
<section class="learn" id="learn">
|
|
280
299
|
<div class="learn-head" id="learnToggle" title="Learning — lessons, per-LLM reliability, baked-in guardrails">
|
|
@@ -303,6 +322,9 @@ const usd=(n,dp=2)=>(Number(n)||0).toLocaleString('en-US',{minimumFractionDigits
|
|
|
303
322
|
const $usd=(n,sym='$',dp=2)=>sym+usd(n,dp);
|
|
304
323
|
const COLS=[['todo','To Do'],['doing','In Progress'],['testing','Testing'],['done','Done'],['blocked','Blocked']];
|
|
305
324
|
let nmsg=0;
|
|
325
|
+
// SOLO = single-project full-window mode via deep link ?project=<name>. Parsed once (search is static
|
|
326
|
+
// per load). When set, render() shows ONLY that project's board/FLOW/TIMELINE and hides the side rails.
|
|
327
|
+
const SOLO=(()=>{try{return decodeURIComponent(new URLSearchParams(location.search).get('project')||'');}catch(e){return new URLSearchParams(location.search).get('project')||'';}})();
|
|
306
328
|
|
|
307
329
|
/* ---- LLM provider icons (lobehub SVGs — same set used across crebral.ai) ----
|
|
308
330
|
Keyed by the AI coding-CLI brand parsed from a session id (e.g. "claude:crebral"). */
|
|
@@ -715,6 +737,26 @@ function chatLane(msgs){
|
|
|
715
737
|
const rows=msgs.slice(-8).map(m=>`<div class="cmsg ${m.to==='all'?'bc':''}"><span class="ct">${new Date(m.ts).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'})}</span>${iconFor(m.from,13)}<span class="cf">${esc(String(m.from).split(':')[0])}</span><span class="arr">→</span><span class="cto">${esc(m.to==='all'?'all':String(m.to).split(':')[0])}</span>: ${esc(m.text)}</div>`).join('');
|
|
716
738
|
return `<div class="proj-chat"><h5><span class="lc"></span>conversation · ${msgs.length}</h5><div class="chatlog">${rows}</div></div>`;
|
|
717
739
|
}
|
|
740
|
+
// Top project-tab strip: chips for every real project. In SOLO it switches the focused project (navigates);
|
|
741
|
+
// in the multi-project view it smooth-scrolls to that project's section. The current SOLO project is hilit.
|
|
742
|
+
function renderTabStrip(projects){
|
|
743
|
+
const strip=$('#ptabs');if(!strip)return;
|
|
744
|
+
const real=projects.filter(p=>p.project&&p.project!=='(unassigned)');
|
|
745
|
+
if(!real.length){strip.style.display='none';return;}
|
|
746
|
+
strip.style.display='';
|
|
747
|
+
const chips=real.map(p=>{
|
|
748
|
+
const on=SOLO&&(p.project===SOLO||p.project.toLowerCase()===SOLO.toLowerCase());
|
|
749
|
+
const live=(p.agents||[]).some(a=>a.online);
|
|
750
|
+
return `<button class="ptab${on?' on':''}${live?' live':''}" data-proj="${esc(p.project)}" title="${esc(p.project)}"><span>${esc(p.project)}</span></button>`;
|
|
751
|
+
}).join('');
|
|
752
|
+
strip.innerHTML=(SOLO?`<a class="ptab home" href="${esc(location.pathname)}" title="back to all projects">⌂ all</a>`:'')+chips;
|
|
753
|
+
strip.querySelectorAll('.ptab[data-proj]').forEach(b=>b.onclick=()=>{
|
|
754
|
+
const name=b.dataset.proj;
|
|
755
|
+
if(SOLO){location.search='?project='+encodeURIComponent(name);return;}
|
|
756
|
+
const sec=document.querySelector(`.proj[data-projname="${CSS.escape(name)}"], .idle-row[data-projname="${CSS.escape(name)}"]`);
|
|
757
|
+
if(sec){sec.scrollIntoView({behavior:'smooth',block:'start'});sec.classList.add('flash');setTimeout(()=>sec.classList.remove('flash'),1200);}
|
|
758
|
+
});
|
|
759
|
+
}
|
|
718
760
|
async function render(){
|
|
719
761
|
let projects=[],tasks=[],msgs=[];
|
|
720
762
|
try{projects=(await (await fetch('/projects')).json()).projects||[];}catch(e){}
|
|
@@ -790,7 +832,7 @@ async function render(){
|
|
|
790
832
|
const ctl=`<span class="mv"><b class="mvup" data-proj="${esc(p.project)}" title="move up">▲</b><b class="mvdn" data-proj="${esc(p.project)}" title="move down">▼</b></span>${p.idle===true?`<span class="pdel" data-proj="${esc(p.project)}" title="forget this project (cards, peers, brief) — it returns if an agent registers it again">✕</span>`:''}`;
|
|
791
833
|
|
|
792
834
|
return `<div class="proj${p.idle===true?' idle':''}" data-projname="${esc(p.project)}"${p.idle===true?` data-idleproj="${esc(p.project)}"`:''}>`+
|
|
793
|
-
`<div class="proj-h"><span class="pname">📁 <b>${esc(p.project)}</b></span>${vtog}<div class="agents">${agents||'<span class="dim">no agents</span>'}</div><span class="spacer"></span><span class="prog">${done}/${pt.length} done · ${pct}%</span>${ctl}</div>`+
|
|
835
|
+
`<div class="proj-h"><span class="pname">📁 <b>${esc(p.project)}</b></span><a class="popout" href="?project=${encodeURIComponent(p.project)}" target="_blank" rel="noopener" title="open this project in its own window" onclick="event.stopPropagation()">↗</a>${vtog}<div class="agents">${agents||'<span class="dim">no agents</span>'}</div><span class="spacer"></span><span class="prog">${done}/${pt.length} done · ${pct}%</span>${ctl}</div>`+
|
|
794
836
|
`<div class="proj-brief">${brief}${ph?`<span class="phase ${phaseClass(ph)}">${esc(ph)}</span>`:''}</div>`+
|
|
795
837
|
`<div class="pbar"><i style="width:${pct}%"></i></div>`+
|
|
796
838
|
(view === "flow" ? flowHTML(pt, p.project) : view === "timeline" ? timelineHTML(p.project) : `<div class="kanban">${cols}</div>`)+
|
|
@@ -800,12 +842,21 @@ async function render(){
|
|
|
800
842
|
// idle projects (zero online agents) collapse to thin rows below the live boards
|
|
801
843
|
const idleRow=p=>{
|
|
802
844
|
const pt=tasks.filter(t=>t.project===p.project);const done=pt.filter(t=>t.status==='done').length;
|
|
803
|
-
return `<div class="idle-row" data-projname="${esc(p.project)}" data-idleproj="${esc(p.project)}" title="click to expand">💤 <span class="nm">${esc(p.project)}</span><span class="dim">· ${esc(p.phase||'')} · ${done}/${pt.length} cards</span><span class="spacer"></span><span class="mv"><b class="mvup" data-proj="${esc(p.project)}" title="move up">▲</b><b class="mvdn" data-proj="${esc(p.project)}" title="move down">▼</b></span><span class="pdel" data-proj="${esc(p.project)}" title="forget this project (cards, peers, brief) — it returns if an agent registers it again">✕</span></div>`;
|
|
845
|
+
return `<div class="idle-row" data-projname="${esc(p.project)}" data-idleproj="${esc(p.project)}" title="click to expand">💤 <span class="nm">${esc(p.project)}</span><span class="dim">· ${esc(p.phase||'')} · ${done}/${pt.length} cards</span><span class="spacer"></span><a class="popout" href="?project=${encodeURIComponent(p.project)}" target="_blank" rel="noopener" title="open this project in its own window" onclick="event.stopPropagation()">↗</a><span class="mv"><b class="mvup" data-proj="${esc(p.project)}" title="move up">▲</b><b class="mvdn" data-proj="${esc(p.project)}" title="move down">▼</b></span><span class="pdel" data-proj="${esc(p.project)}" title="forget this project (cards, peers, brief) — it returns if an agent registers it again">✕</span></div>`;
|
|
804
846
|
};
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
847
|
+
renderTabStrip(projects);
|
|
848
|
+
if(SOLO){
|
|
849
|
+
// single-project full-window: render ONLY the deep-linked project, side rails hidden via body.solo
|
|
850
|
+
document.body.classList.add('solo');
|
|
851
|
+
const only=projects.filter(p=>p.project===SOLO||p.project.toLowerCase()===SOLO.toLowerCase());
|
|
852
|
+
if(!only.length){el.innerHTML=`<div class="empty big">project “${esc(SOLO)}” not found — <a href="${esc(location.pathname)}" style="color:var(--grn)">view all projects</a></div>`;return;}
|
|
853
|
+
el.innerHTML=only.map(projBlock).join('');
|
|
854
|
+
} else {
|
|
855
|
+
const live=projects.filter(p=>p.idle!==true),idlers=projects.filter(p=>p.idle===true);
|
|
856
|
+
el.innerHTML=(ORDER.length?`<div class="sortmode" id="sortreset" title="you've ordered projects manually — click to go back to automatic (recency) ordering">sort: manual · ↺ back to auto</div>`:'')
|
|
857
|
+
+live.map(projBlock).join('')
|
|
858
|
+
+(idlers.length?`<div class="idle-head">idle projects · ${idlers.length}</div>`+idlers.map(p=>idleOpen.has(p.project)?projBlock(p):idleRow(p)).join(''):'');
|
|
859
|
+
}
|
|
809
860
|
// keep each project's chat scrolled to the latest line
|
|
810
861
|
el.querySelectorAll('.chatlog').forEach(c=>{if(c.scrollHeight-c.clientHeight<60||c.dataset.stuck!=='0')c.scrollTop=c.scrollHeight;c.onscroll=()=>{c.dataset.stuck=(c.scrollHeight-c.scrollTop-c.clientHeight<40)?'1':'0';};});
|
|
811
862
|
// click a card -> advance status todo->doing->done
|