trantor 0.17.95 → 0.17.97
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/plugin.json +1 -1
- package/hooks/heartbeat.mjs +45 -13
- package/hooks/lib/handoff.mjs +25 -1
- package/hooks/stop-inbox.mjs +27 -1
- package/hub.mjs +19 -0
- package/package.json +2 -2
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.97",
|
|
4
4
|
"description": "Trantor \u2014 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/heartbeat.mjs
CHANGED
|
@@ -18,13 +18,14 @@ import { join, basename, dirname } from "node:path";
|
|
|
18
18
|
import { homedir, hostname } from "node:os";
|
|
19
19
|
import { spawn } from "node:child_process";
|
|
20
20
|
import { fileURLToPath } from "node:url";
|
|
21
|
-
import { readConfig, contextUsage, warnFrac, alreadyHandedOff, markHandedOff, controllingTty, terminalWindowForTty, subagentsActive } from "./lib/handoff.mjs";
|
|
21
|
+
import { armBaton, readArm, clearArm, readConfig, contextUsage, warnFrac, alreadyHandedOff, markHandedOff, controllingTty, terminalWindowForTty, subagentsActive } from "./lib/handoff.mjs";
|
|
22
22
|
import { resolveProject, hostId } from "../lib/project.mjs";
|
|
23
23
|
import { installedVersion } from "./lib/update-check.mjs"; // report our hook version so the hub can flag stale sessions
|
|
24
24
|
import { signedPost } from "./lib/api.mjs";
|
|
25
25
|
|
|
26
26
|
const HEARTBEAT_MS = Number(process.env.RELAY_HEARTBEAT_MS || 60 * 1000);
|
|
27
27
|
const FETCH_TIMEOUT_MS = Number(process.env.RELAY_HEARTBEAT_TIMEOUT_MS || 1500);
|
|
28
|
+
const ARM_MAX_MS = Number(process.env.TRANTOR_BATON_ARM_MAX_MS || 15 * 60 * 1000);
|
|
28
29
|
const INFLIGHT_MS = 5 * 60 * 1000;
|
|
29
30
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
30
31
|
|
|
@@ -83,25 +84,56 @@ async function maybeEarlyWarn(stdinRaw, session) {
|
|
|
83
84
|
|
|
84
85
|
// In-flight guard: the detached worker takes ~tens of seconds to summarize;
|
|
85
86
|
// don't launch a second one on the next heartbeat tick meanwhile.
|
|
87
|
+
// NOTE the ordering: this debounce guards the SPAWN, not the arming. It used to sit here and
|
|
88
|
+
// return before any of the logic below, which meant the arm/backstop path never ran a second
|
|
89
|
+
// time and a session that reached no turn boundary would stay armed forever. Arming is cheap
|
|
90
|
+
// and idempotent; only launching the worker needs debouncing.
|
|
86
91
|
const inflight = join(homedir(), ".agent-bus", `handoff-inflight-${String(sessionId).replace(/[^A-Za-z0-9_.-]/g, "_")}.stamp`);
|
|
87
|
-
|
|
88
|
-
|
|
92
|
+
const spawnDebounced = () => {
|
|
93
|
+
try { if (existsSync(inflight) && Date.now() - (Number(readFileSync(inflight, "utf8")) || 0) < INFLIGHT_MS) return false; } catch {}
|
|
94
|
+
try { writeFileSync(inflight, String(Date.now())); } catch {}
|
|
95
|
+
return true;
|
|
96
|
+
};
|
|
89
97
|
|
|
90
98
|
const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
91
99
|
// Detect THIS session's Terminal window NOW (the hook has the controlling tty; the detached worker
|
|
92
100
|
// won't) so the baton-close can replace this exact window once the fresh session takes over.
|
|
93
101
|
const tty = controllingTty();
|
|
94
102
|
const windowId = tty ? terminalWindowForTty(tty) : "";
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
103
|
+
// ARM, do not fire. This hook is PostToolUse: the only moment it can ever run is between two
|
|
104
|
+
// tool calls, i.e. mid-turn. Firing here produced a handoff written 36 seconds before the work
|
|
105
|
+
// it described was committed (2026-08-24), and the successor reported four finished things as
|
|
106
|
+
// still open. The Stop hook fires it at the next turn boundary, where the turn is complete.
|
|
107
|
+
//
|
|
108
|
+
// The window id and tty are captured HERE on purpose: this hook has the controlling tty and the
|
|
109
|
+
// detached worker does not, so the baton-close can still replace this exact window later.
|
|
110
|
+
const armed = readArm(sessionId);
|
|
111
|
+
const age = armed ? Date.now() - (Number(armed.ts) || 0) : 0;
|
|
112
|
+
if (armed && age < ARM_MAX_MS) {
|
|
113
|
+
process.stderr.write(`[trantor] context ${Math.round(usage.frac * 100)}% — baton already armed ${Math.round(age / 1000)}s ago, waiting for a turn boundary\n`);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (armed) {
|
|
117
|
+
// Backstop: a session that never reaches a Stop (parked, or looping without ending a turn)
|
|
118
|
+
// must still hand off rather than never. Fire it directly and say that is what happened.
|
|
119
|
+
process.stderr.write(`[trantor] baton armed ${Math.round(age / 60000)}m ago with no turn boundary — firing anyway\n`);
|
|
120
|
+
clearArm(sessionId);
|
|
121
|
+
if (spawnDebounced()) {
|
|
122
|
+
const child = spawn(process.execPath, [join(HERE, "handoff-now.mjs"), projectDir, sessionId, transcript, "context-warn", windowId, tty],
|
|
123
|
+
{ detached: true, stdio: "ignore" });
|
|
124
|
+
child.unref();
|
|
125
|
+
markHandedOff(sessionId, usage.tokens);
|
|
126
|
+
}
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
process.stderr.write(`[trantor] context ${Math.round(usage.frac * 100)}% of ${usage.window} — arming the baton for the next turn boundary (window ${windowId || "?"})\n`);
|
|
130
|
+
// Arming does NOT markHandedOff. That guard exists so a session parked above the warn line does
|
|
131
|
+
// not re-fire every tick (8 stacked handoffs ~5 min apart, once observed) — but it makes
|
|
132
|
+
// alreadyHandedOff() short-circuit this whole block, so marking at ARM time meant no later
|
|
133
|
+
// heartbeat could ever run the backstop and a session that reached no Stop stayed armed
|
|
134
|
+
// forever. It is marked where the baton actually fires: here on the backstop path, and in the
|
|
135
|
+
// Stop hook on the normal path. Re-arming every tick is prevented by the age check above.
|
|
136
|
+
armBaton(sessionId, { projectDir, transcript, reason: "context-warn", windowId, tty, tokens: usage.tokens });
|
|
105
137
|
} catch {}
|
|
106
138
|
}
|
|
107
139
|
|
package/hooks/lib/handoff.mjs
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// session that loads the handoff. The heartbeat path lets us do that BEFORE the
|
|
11
11
|
// wall when we know the window size. Both paths share a per-session guard so we
|
|
12
12
|
// never write/spawn twice for the same context window.
|
|
13
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync, openSync, readSync, fstatSync, closeSync } from "node:fs";
|
|
13
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync, openSync, readSync, fstatSync, closeSync, rmSync } from "node:fs";
|
|
14
14
|
import { join, basename, dirname } from "node:path";
|
|
15
15
|
import { homedir, hostname } from "node:os";
|
|
16
16
|
import { execSync, spawn } from "node:child_process";
|
|
@@ -116,6 +116,30 @@ function nowSec() { try { return Number(execSync("date +%s", { encoding: "utf8"
|
|
|
116
116
|
// window up (or, before the 2026-06-21 fix, kill the original) while real in-flight agent work is
|
|
117
117
|
// running. INCIDENT 2026-06-21: a 90% baton fired mid 2-agent build and the original session was
|
|
118
118
|
// SIGKILLed mid-flight. Best-effort; returns false on any error.
|
|
119
|
+
// ---- ARMING: the baton waits for a turn boundary --------------------------------------------
|
|
120
|
+
// The heartbeat runs on PostToolUse, so the only moment it can ever fire is BETWEEN TWO TOOL CALLS
|
|
121
|
+
// — the middle of a turn. On 2026-08-24 that produced a handoff written 36 seconds before the work
|
|
122
|
+
// it described was committed, and the successor reported four finished things as still open.
|
|
123
|
+
// subagentsActive() was the only mid-flight guard and it only sees spawned sub-agents, not a
|
|
124
|
+
// session driving tool calls in its own loop.
|
|
125
|
+
//
|
|
126
|
+
// So the threshold ARMS and the Stop hook FIRES: at a Stop the turn is complete, which is the only
|
|
127
|
+
// point where a summary can describe something finished. One resolver for the marker path, used by
|
|
128
|
+
// both hooks, because two hooks disagreeing about a file path is its own recurring bug here.
|
|
129
|
+
export function armPath(sessionId) {
|
|
130
|
+
const safe = String(sessionId || "s").replace(/[^A-Za-z0-9_.-]/g, "_");
|
|
131
|
+
return join(process.env.AGENT_BUS_DIR || process.env.RELAY_DATA_DIR || join(homedir(), ".agent-bus"), `handoff-armed-${safe}.json`);
|
|
132
|
+
}
|
|
133
|
+
export function armBaton(sessionId, payload) {
|
|
134
|
+
try { writeFileSync(armPath(sessionId), JSON.stringify({ ts: Date.now(), ...payload })); return true; } catch { return false; }
|
|
135
|
+
}
|
|
136
|
+
export function readArm(sessionId) {
|
|
137
|
+
try { const p = armPath(sessionId); if (!existsSync(p)) return null; return JSON.parse(readFileSync(p, "utf8")); } catch { return null; }
|
|
138
|
+
}
|
|
139
|
+
export function clearArm(sessionId) {
|
|
140
|
+
try { rmSync(armPath(sessionId), { force: true }); } catch {}
|
|
141
|
+
}
|
|
142
|
+
|
|
119
143
|
export function subagentsActive(transcriptPath, withinMs = 90_000) {
|
|
120
144
|
try {
|
|
121
145
|
if (!transcriptPath) return false;
|
package/hooks/stop-inbox.mjs
CHANGED
|
@@ -22,11 +22,16 @@
|
|
|
22
22
|
// delivered and then letting the stop through would hide it from the waker too — a silent hole.
|
|
23
23
|
// * Any error, or a hub that is down -> allow the stop. Never trap a session because of us.
|
|
24
24
|
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
25
|
-
import { join } from "node:path";
|
|
25
|
+
import { join, dirname } from "node:path";
|
|
26
|
+
import { spawn } from "node:child_process";
|
|
27
|
+
import { fileURLToPath } from "node:url";
|
|
26
28
|
import { homedir } from "node:os";
|
|
27
29
|
import { resolveProject, hostId } from "../lib/project.mjs";
|
|
28
30
|
import { signedGet } from "./lib/api.mjs"; // signed: enforce hubs 401 unsigned reads — unsigned, T2 delivery is silently dead
|
|
29
31
|
import { ledgerPaths, ensureStart, anchorCursor, writeCursor } from "./lib/inbox-ledger.mjs";
|
|
32
|
+
import { readArm, clearArm, markHandedOff } from "./lib/handoff.mjs";
|
|
33
|
+
|
|
34
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
30
35
|
|
|
31
36
|
const FETCH_TIMEOUT_MS = Number(process.env.RELAY_STOP_TIMEOUT_MS || 1500);
|
|
32
37
|
|
|
@@ -103,6 +108,27 @@ async function main() {
|
|
|
103
108
|
if (process.env.RELAY_STOP_INBOX === "0") return allow();
|
|
104
109
|
|
|
105
110
|
const projectDir = input.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
111
|
+
|
|
112
|
+
// A Stop IS the turn boundary. If the heartbeat armed a baton while this session was mid-turn,
|
|
113
|
+
// this is the first honest moment to fire it: the turn is complete, so the summary describes
|
|
114
|
+
// finished work rather than a session thirty seconds from its own conclusions. Fired detached and
|
|
115
|
+
// never awaited, and the arming is cleared FIRST so a crash in the worker cannot re-fire it on
|
|
116
|
+
// every subsequent Stop.
|
|
117
|
+
try {
|
|
118
|
+
const armed = readArm(input.session_id || "");
|
|
119
|
+
if (armed) {
|
|
120
|
+
clearArm(input.session_id || "");
|
|
121
|
+
const kid = spawn(process.execPath, [join(HERE, "handoff-now.mjs"),
|
|
122
|
+
armed.projectDir || projectDir, String(input.session_id || ""), armed.transcript || "",
|
|
123
|
+
armed.reason || "context-warn", armed.windowId || "", armed.tty || ""],
|
|
124
|
+
{ detached: true, stdio: "ignore" });
|
|
125
|
+
kid.unref();
|
|
126
|
+
// Mark it here, on the path that actually fired, so a session parked above the warn line
|
|
127
|
+
// cannot re-arm and re-fire every tick.
|
|
128
|
+
try { markHandedOff(String(input.session_id || ""), Number(armed.tokens) || 0); } catch {}
|
|
129
|
+
process.stderr.write("[trantor] turn boundary reached — firing the armed baton\n");
|
|
130
|
+
}
|
|
131
|
+
} catch {}
|
|
106
132
|
// Mirror the other hooks: a home-directory session isn't project work and isn't on the bus.
|
|
107
133
|
if (!process.env.RELAY_SESSION && !process.env.RELAY_PROJECT && projectDir === homedir()) return allow();
|
|
108
134
|
|
package/hub.mjs
CHANGED
|
@@ -2266,6 +2266,25 @@ const server = http.createServer(async (req, res) => {
|
|
|
2266
2266
|
// outcome: strictly by `re`, or, for seats that predate it, oldest-open-first. Broadcasts are
|
|
2267
2267
|
// never contracts. Each open one carries the assignee's presence, because the actionable half
|
|
2268
2268
|
// of "still waiting" is whether anyone is still on the other end.
|
|
2269
|
+
// ---- /delivered: an endpoint that has actually READ its mail says so ----------------------
|
|
2270
|
+
// The desktop app lists with peek=1 on purpose, so it never steals a message from a session's
|
|
2271
|
+
// delivery hooks. For a HUMAN endpoint there are no hooks — the app is the only reader — so
|
|
2272
|
+
// sasha@mac's deliveredUpTo sat at 0 forever while mail piled up. dutyTick then escalated every
|
|
2273
|
+
// message the human had already read, told the duty seat about it, the seat messaged the human,
|
|
2274
|
+
// and that was undelivered too: about six escalations a minute, all about mail already read.
|
|
2275
|
+
// Peeking stays the default; this lets a reader record delivery explicitly instead.
|
|
2276
|
+
if (req.method === "POST" && P === "/delivered") {
|
|
2277
|
+
const b = await body(req);
|
|
2278
|
+
const session = String(b.session || "");
|
|
2279
|
+
if (!session) return json(res, 400, { error: "session required" });
|
|
2280
|
+
if (auth?.identity && String(auth.identity.name || "") !== session) {
|
|
2281
|
+
return json(res, 403, { error: "session must match signer" });
|
|
2282
|
+
}
|
|
2283
|
+
touch(session, undefined, undefined, undefined, auth);
|
|
2284
|
+
markDelivered(session, Number(b.upTo || 0));
|
|
2285
|
+
return json(res, 200, { ok: true, deliveredUpTo: state.peers[session]?.deliveredUpTo || 0 });
|
|
2286
|
+
}
|
|
2287
|
+
|
|
2269
2288
|
if (req.method === "GET" && P === "/contracts") {
|
|
2270
2289
|
const session = String(q.session || "");
|
|
2271
2290
|
if (!session) return json(res, 400, { error: "session required" });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.97",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"trantor": "bin/cli.mjs"
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"zod": "^4.4.3"
|
|
12
12
|
},
|
|
13
13
|
"scripts": {
|
|
14
|
-
"test": "node bin/slop-gate.mjs --surface desktop/src && node test.mjs && node test-scenarios.mjs && node test-failure.mjs && node test-redelivery.mjs && node test-crew-completion.mjs && node test-contracts.mjs && node test-handoff.mjs && node test-handoff-summarizer.mjs && node test-agents.mjs && node test-update.mjs && node test-handoff-guard.mjs && node test-baton-latest.mjs && node test-balances.mjs && node test-subagent-cost.mjs && node test-inbox.mjs && node test-inflight.mjs && node test-notify.mjs && node test-focus.mjs && node test-cardlog.mjs && node test-relay-note.mjs && node test-reaper.mjs && node test-events.mjs && node test-proposals.mjs && node test-scrub.mjs && node test-store-delta.mjs && node test-message-re.mjs && node test-claims.mjs && node test-adopt.mjs && node test-summarize.mjs && node test-identity-core.mjs && node test-identity.mjs && node test-identity-instances.mjs && node test-duty.mjs && node test-duty-seat.mjs && node test-discovery.mjs && node test-doctor.mjs && node test-splitbrain.mjs && node test-overseer.mjs && node test-overseer-lib.mjs && node test-overseer-warn.mjs && node test-provider-keys.mjs && node test-inbox-delivery.mjs && node test-identity-drift.mjs && node test-inbox-staleness.mjs && node test-seats.mjs && node test-seat-identity.mjs && node test-hub-routing.mjs && node test-hook-routing.mjs && node test-relay-wait.mjs && node test-dsh-seat.mjs && node test-kimi-bridge.mjs && node test-kimi-events.mjs && python3 engine/test-routing.py && node test-reconcile.mjs && node test-resources.mjs && node test-patrol.mjs && node test-bridge.mjs && bash test-crew.sh"
|
|
14
|
+
"test": "node bin/slop-gate.mjs --surface desktop/src && node test.mjs && node test-scenarios.mjs && node test-failure.mjs && node test-redelivery.mjs && node test-crew-completion.mjs && node test-contracts.mjs && node test-handoff.mjs && node test-handoff-summarizer.mjs && node test-baton-turn-boundary.mjs && node test-agents.mjs && node test-update.mjs && node test-handoff-guard.mjs && node test-baton-latest.mjs && node test-balances.mjs && node test-subagent-cost.mjs && node test-inbox.mjs && node test-inflight.mjs && node test-notify.mjs && node test-focus.mjs && node test-cardlog.mjs && node test-relay-note.mjs && node test-reaper.mjs && node test-events.mjs && node test-proposals.mjs && node test-scrub.mjs && node test-store-delta.mjs && node test-message-re.mjs && node test-claims.mjs && node test-adopt.mjs && node test-summarize.mjs && node test-identity-core.mjs && node test-identity.mjs && node test-identity-instances.mjs && node test-duty.mjs && node test-duty-seat.mjs && node test-discovery.mjs && node test-doctor.mjs && node test-splitbrain.mjs && node test-overseer.mjs && node test-overseer-lib.mjs && node test-overseer-warn.mjs && node test-provider-keys.mjs && node test-inbox-delivery.mjs && node test-identity-drift.mjs && node test-inbox-staleness.mjs && node test-seats.mjs && node test-seat-identity.mjs && node test-hub-routing.mjs && node test-hook-routing.mjs && node test-relay-wait.mjs && node test-dsh-seat.mjs && node test-kimi-bridge.mjs && node test-kimi-events.mjs && python3 engine/test-routing.py && node test-reconcile.mjs && node test-resources.mjs && node test-patrol.mjs && node test-bridge.mjs && bash test-crew.sh"
|
|
15
15
|
},
|
|
16
16
|
"description": "The hub-world for AI agent crews \u2014 orchestrate Claude Code, Codex, GLM, Kimi, DeepSeek & any OpenRouter model as live crews with a plan-aware Advisor, a Kanban/flow command center, a testing gate, and an economics brain (Scrooge).",
|
|
17
17
|
"files": [
|