trantor 0.17.7 → 0.17.9

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.
@@ -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.7"
9
+ "version": "0.17.9"
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.7",
16
+ "version": "0.17.9",
17
17
  "author": {
18
18
  "name": "Sasha Bogojevic"
19
19
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.17.7",
3
+ "version": "0.17.9",
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,12 @@
1
1
  {
2
- "description": "trantor — auto-register each session + inject live roster (SessionStart); heartbeat presence on every tool call so live sessions stay green and recover after sleep (PostToolUse); write a handoff before compaction (PreCompact)",
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)",
3
3
  "hooks": {
4
4
  "SessionStart": [
5
5
  { "matcher": "", "hooks": [ { "type": "command", "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/sessionstart.mjs" } ] }
6
6
  ],
7
7
  "PostToolUse": [
8
- { "matcher": "", "hooks": [ { "type": "command", "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/heartbeat.mjs" } ] }
8
+ { "matcher": "", "hooks": [ { "type": "command", "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/heartbeat.mjs" } ] },
9
+ { "matcher": "TodoWrite", "hooks": [ { "type": "command", "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/todo-sync.mjs" } ] }
9
10
  ],
10
11
  "PreCompact": [
11
12
  { "matcher": "", "hooks": [ { "type": "command", "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/precompact.mjs" } ] }
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env node
2
+ // trantor PostToolUse(TodoWrite) — mirror the session's todo list onto its project board as cards,
3
+ // so SOLO work (no crew fired up) shows up live and accrues timeline history. The hub reconciles by
4
+ // todo text (pending/in_progress/completed -> todo/doing/done). Fail-silent by contract: a bad hub,
5
+ // a home-dir session, or any error must never block or break the tool flow.
6
+ import { readFileSync, existsSync } from "node:fs";
7
+ import { join, basename } from "node:path";
8
+ import { homedir, hostname } from "node:os";
9
+
10
+ function relayUrl() {
11
+ if (process.env.RELAY_URL) return process.env.RELAY_URL;
12
+ try {
13
+ const cfg = join(homedir(), ".agent-bus", "config.json");
14
+ if (existsSync(cfg)) { const u = JSON.parse(readFileSync(cfg, "utf8")).url; if (u) return u; }
15
+ } catch {}
16
+ return "http://127.0.0.1:4477";
17
+ }
18
+ function readStdin() {
19
+ return new Promise(res => { let d = ""; process.stdin.setEncoding("utf8");
20
+ process.stdin.on("data", c => (d += c)); process.stdin.on("end", () => res(d));
21
+ setTimeout(() => res(d), 200); });
22
+ }
23
+
24
+ async function main() {
25
+ const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
26
+ // Mirror sessionstart/heartbeat: home-directory sessions aren't project work — don't card them
27
+ // (would spawn a phantom "<username>" board). Opt in with RELAY_SESSION/RELAY_PROJECT.
28
+ if (!process.env.RELAY_SESSION && !process.env.RELAY_PROJECT && projectDir === homedir()) return;
29
+
30
+ let input = {};
31
+ try { input = JSON.parse((await readStdin()) || "{}"); } catch { return; }
32
+ if (input.tool_name && input.tool_name !== "TodoWrite") return; // the matcher should scope us, but be safe
33
+ const todos = input.tool_input?.todos;
34
+ if (!Array.isArray(todos) || !todos.length) return;
35
+
36
+ // Identity EXACTLY as mcp.mjs/heartbeat resolve it, so we card the same peer the relay registered.
37
+ const project = process.env.RELAY_PROJECT || basename(projectDir);
38
+ const session = process.env.RELAY_SESSION
39
+ || (process.env.RELAY_AGENT ? `${process.env.RELAY_AGENT}:${project}` : `${hostname()}:${project}`);
40
+
41
+ try {
42
+ await fetch(`${relayUrl()}/todos`, {
43
+ method: "POST",
44
+ headers: { "content-type": "application/json" },
45
+ body: JSON.stringify({ session, project, by: session, todos: todos.map(t => ({ content: t.content, status: t.status })) }),
46
+ signal: AbortSignal.timeout(1500),
47
+ });
48
+ } catch {}
49
+ }
50
+
51
+ // Never block or break the tool flow: swallow everything, always exit clean.
52
+ main().catch(() => {}).finally(() => process.exit(0));
package/hub.mjs CHANGED
@@ -50,11 +50,11 @@ function scanTelemetry() {
50
50
 
51
51
  // peers: { session: { lastSeen, status, project } } ; tasks: kanban cards
52
52
  // projectMeta: { project: { brief, by, updated } } — the "what & why" blurb per project
53
- let state = { messages: [], peers: {}, seq: 0, tasks: [], taskSeq: 0, projectMeta: {}, lessons: [], cardEvents: [] };
53
+ let state = { messages: [], peers: {}, seq: 0, tasks: [], taskSeq: 0, projectMeta: {}, lessons: [], cardEvents: [], cardEventsBackfilled: false };
54
54
  try {
55
55
  if (existsSync(DATA)) {
56
56
  const loaded = JSON.parse(readFileSync(DATA, "utf8"));
57
- state = { messages: loaded.messages || [], peers: {}, seq: loaded.seq || 0, tasks: loaded.tasks || [], taskSeq: loaded.taskSeq || 0, projectMeta: loaded.projectMeta || {}, lessons: loaded.lessons || [], cardEvents: Array.isArray(loaded.cardEvents) ? loaded.cardEvents : [] };
57
+ state = { messages: loaded.messages || [], peers: {}, seq: loaded.seq || 0, tasks: loaded.tasks || [], taskSeq: loaded.taskSeq || 0, projectMeta: loaded.projectMeta || {}, lessons: loaded.lessons || [], cardEvents: Array.isArray(loaded.cardEvents) ? loaded.cardEvents : [], cardEventsBackfilled: !!loaded.cardEventsBackfilled };
58
58
  for (const [s, v] of Object.entries(loaded.peers || {})) // migrate old numeric form
59
59
  state.peers[s] = typeof v === "number" ? { lastSeen: v, status: "", project: "" } : { lastSeen: v.lastSeen || 0, status: v.status || "", project: v.project || "" };
60
60
  }
@@ -62,6 +62,28 @@ try {
62
62
  let dirty = false;
63
63
  const persist = () => { if (dirty) { try { writeFileSync(DATA, JSON.stringify(state)); dirty = false; } catch {} } };
64
64
  setInterval(persist, 1000).unref?.();
65
+ // One-time backfill: reconstruct the cardEvents history log from each card's authoritative per-card
66
+ // `history` trail, so projects that existed BEFORE the cardEvents log show their FULL past in the
67
+ // TIMELINE view (not just events from now on). Guarded by a flag so it runs once where cardEvents
68
+ // persists; in team mode cardEvents is in-memory, so this re-derives from the persisted task.history
69
+ // on every boot — which is exactly right.
70
+ function backfillCardEvents() {
71
+ if (state.cardEventsBackfilled && state.cardEvents.length) return;
72
+ const events = [];
73
+ for (const t of (state.tasks || [])) for (const h of (t.history || [])) {
74
+ events.push({ ts: h.ts || 0, type: h.from ? "moved" : "created", taskId: t.id, project: t.project,
75
+ title: t.title, from: h.from || null, to: h.to || null, by: h.by || "",
76
+ difficulty: t.difficulty || null, assignee: t.assignee || null });
77
+ }
78
+ if (events.length) {
79
+ events.sort((a, b) => (a.ts || 0) - (b.ts || 0));
80
+ if (events.length > 5000) events.splice(0, events.length - 5000);
81
+ events.forEach((e, i) => { e.id = i + 1; });
82
+ state.cardEvents = events; dirty = true;
83
+ }
84
+ state.cardEventsBackfilled = true; dirty = true;
85
+ }
86
+ backfillCardEvents();
65
87
  function prunePeers() {
66
88
  const cutoff = now() - PEER_TTL_MS;
67
89
  let removed = false;
@@ -167,6 +189,44 @@ const server = http.createServer(async (req, res) => {
167
189
  appendCardEvent(eventType, t, b.by, eventFrom, eventTo);
168
190
  t.updated = now(); dirty = true; return json(res, 200, { ok: true, task: t });
169
191
  }
192
+ // Mirror a session's TodoWrite list onto its board as cards, so SOLO work (no crew) shows up live
193
+ // and accrues timeline history. pending/in_progress/completed -> todo/doing/done. Reconciled by
194
+ // todo text per session: present todos create/update; a vanished todo's card is deleted UNLESS it
195
+ // was already done (accomplished work stays in the DONE column). Posted by hooks/todo-sync.mjs.
196
+ if (req.method === "POST" && P === "/todos") {
197
+ const b = await body(req);
198
+ const session = String(b.session || b.by || "").slice(0, 120);
199
+ const project = String(b.project || "").slice(0, 80);
200
+ if (!session || !project) return json(res, 400, { error: "session and project required" });
201
+ touch(session, undefined, project);
202
+ const ST = { pending: "todo", in_progress: "doing", completed: "done" };
203
+ const todos = Array.isArray(b.todos) ? b.todos : [];
204
+ const mine = state.tasks.filter(t => t.source === "todo" && t.assignee === session && t.project === project);
205
+ const seen = new Set();
206
+ for (const todo of todos) {
207
+ const key = String(todo?.content || "").trim().slice(0, 200);
208
+ if (!key) continue;
209
+ seen.add(key);
210
+ const want = ST[todo.status] || "todo";
211
+ let t = mine.find(c => c.todoKey === key);
212
+ if (!t) {
213
+ t = { id: ++state.taskSeq, project, title: key, assignee: session, status: want, difficulty: "", model: "",
214
+ deps: [], by: session, ts: now(), updated: now(), source: "todo", todoKey: key,
215
+ history: [{ to: want, by: session, ts: now() }] };
216
+ state.tasks.push(t); appendCardEvent("created", t, session, null, want); dirty = true;
217
+ } else if (t.status !== want) {
218
+ (t.history ||= []).push({ from: t.status, to: want, by: session, ts: now() });
219
+ if (t.history.length > 40) t.history.splice(0, 10);
220
+ appendCardEvent("moved", t, session, t.status, want); t.status = want; t.updated = now(); dirty = true;
221
+ }
222
+ }
223
+ for (const t of mine) {
224
+ if (seen.has(t.todoKey) || t.status === "done") continue; // keep accomplished work on the board
225
+ state.tasks = state.tasks.filter(x => x.id !== t.id); appendCardEvent("deleted", t, session, null, null); dirty = true;
226
+ }
227
+ if (state.tasks.length > 2000) state.tasks.splice(0, state.tasks.length - 2000);
228
+ return json(res, 200, { ok: true, count: todos.length });
229
+ }
170
230
  if (req.method === "GET" && P === "/tasks") {
171
231
  const proj = q.project; const ts = proj ? state.tasks.filter(t => t.project === proj) : state.tasks;
172
232
  return json(res, 200, { tasks: ts });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.17.7",
3
+ "version": "0.17.9",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"