trantor 0.18.32 → 0.18.33
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/bin/baton.mjs +53 -25
- package/bin/connect.mjs +11 -1
- package/bin/crew-runner.mjs +14 -5
- package/bin/new.mjs +7 -2
- package/bin/write-handoff.mjs +9 -3
- package/deploy/restart-hub.sh +50 -0
- package/deploy/setup-netcup.sh +7 -2
- package/hooks/lib/api.mjs +2 -2
- package/hooks/lib/handoff.mjs +78 -7
- package/hooks/sessionstart.mjs +9 -2
- package/hooks/stop-inbox.mjs +41 -4
- package/hub.mjs +188 -23
- package/lib/enroll.mjs +2 -2
- package/lib/persist-health.mjs +56 -0
- package/lib/same-project.mjs +65 -0
- package/lib/store-pg.mjs +20 -2
- package/mcp.mjs +5 -3
- package/package.json +2 -2
- package/skills/handoff/SKILL.md +14 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.33",
|
|
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/bin/baton.mjs
CHANGED
|
@@ -1,16 +1,42 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// `trantor handoff` — one-command manual baton
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
import { readdirSync, statSync } from "node:fs";
|
|
7
|
-
import { join, basename } from "node:path";
|
|
2
|
+
// `trantor handoff` — one-command manual baton. Discovers the current session's transcript, writes
|
|
3
|
+
// a whole-session handoff (auto-summary + verbatim in-flight tail), opens a fresh self-announcing
|
|
4
|
+
// session, and closes THIS window once it takes over. Run from inside the session you want to hand
|
|
5
|
+
// off. (The richer MODEL-authored handoff is the /trantor:handoff skill.)
|
|
6
|
+
import { readdirSync, statSync, fstatSync } from "node:fs";
|
|
7
|
+
import { join, basename, dirname } from "node:path";
|
|
8
8
|
import { homedir } from "node:os";
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
9
|
+
import { spawn } from "node:child_process";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { writeHandoff, spawnBaton, resolveHandoffSurface } from "../hooks/lib/handoff.mjs";
|
|
11
12
|
|
|
13
|
+
// #6074: the skill path (write-handoff.mjs) and this CLI path must share ONE resolution of which
|
|
14
|
+
// project this is and where the session lives. Both call resolveHandoffSurface; the name comes
|
|
15
|
+
// from the session's registration (TRANTOR_ORCH / RELAY_PROJECT / orch-sessions.txt) before the
|
|
16
|
+
// cwd — a subfolder cwd never renames the project.
|
|
12
17
|
const cwd = process.cwd();
|
|
13
|
-
const
|
|
18
|
+
const resolved = resolveHandoffSurface({ projectDir: process.env.CLAUDE_PROJECT_DIR || cwd, sessionId: process.env.CLAUDE_SESSION_ID || "" });
|
|
19
|
+
const project = resolved.project;
|
|
20
|
+
|
|
21
|
+
// Model-authored handoffs ride THIS binary too (`trantor handoff` — always the global install's
|
|
22
|
+
// CURRENT code, never the plugin-cache copy a session booted with, the stale-0.18.20 bug). A piped
|
|
23
|
+
// stdin (a heredoc, a cat, `<<HANDOFF`) means "here is the handoff markdown" — exactly the skill's
|
|
24
|
+
// contract — so forward to write-handoff.mjs in the SAME package (same version, same resolution)
|
|
25
|
+
// and exit with its status. --latest forwards too. A true pipe is a FIFO; a TTY and /dev/null are
|
|
26
|
+
// character devices, so a plain `trantor handoff` typed at a prompt (or run by a hook with stdin
|
|
27
|
+
// at /dev/null) keeps the auto-summary behavior. Detection must NOT be `!process.stdin.isTTY` —
|
|
28
|
+
// that misreads /dev/null as a handoff and errors where auto-summary used to work.
|
|
29
|
+
function stdinIsPipe() {
|
|
30
|
+
try { return fstatSync(0).isFIFO(); } catch { return false; }
|
|
31
|
+
}
|
|
32
|
+
if (stdinIsPipe() || process.argv.includes("--latest")) {
|
|
33
|
+
const helper = join(dirname(fileURLToPath(import.meta.url)), "write-handoff.mjs");
|
|
34
|
+
const child = spawn(process.execPath, [helper, ...process.argv.slice(2)], { stdio: ["inherit", "inherit", "inherit"] });
|
|
35
|
+
child.on("exit", (c) => process.exit(c ?? 1));
|
|
36
|
+
child.on("error", () => process.exit(1));
|
|
37
|
+
} else {
|
|
38
|
+
autoBaton();
|
|
39
|
+
}
|
|
14
40
|
|
|
15
41
|
// The active session's transcript = newest *.jsonl directly in this project's Claude dir
|
|
16
42
|
// (~/.claude/projects/<cwd-with-slashes-as-dashes>/), excluding the subagents/ subtree.
|
|
@@ -29,20 +55,22 @@ function findTranscript() {
|
|
|
29
55
|
return best;
|
|
30
56
|
}
|
|
31
57
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
//
|
|
35
|
-
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
58
|
+
function autoBaton() {
|
|
59
|
+
const transcript = findTranscript();
|
|
60
|
+
// The transcript's filename IS the writing session's id — record it, or an orchestrator-thread
|
|
61
|
+
// handoff carries no writer and the baton-hold + map-follow logic in sessionstart.mjs can't fire.
|
|
62
|
+
const sessionId = transcript ? basename(transcript, ".jsonl") : "";
|
|
63
|
+
const { file } = writeHandoff({ projectDir: cwd, sessionId, transcript, trigger: "manual-cli", force: true, projectName: project }); // manual = intentional, bypass the storm guard
|
|
64
|
+
console.log(`📋 handoff saved for ${project}: ${file}`);
|
|
65
|
+
// --write-only: the in-app flow (#5509). The app ends the pane's session itself and reopens it
|
|
66
|
+
// through `trantor open`, which claims this handoff — a Terminal window here would be exactly the
|
|
67
|
+
// wrong surface, so the flag writes, announces, and stops.
|
|
68
|
+
if (process.argv.includes("--write-only")) {
|
|
69
|
+
console.log(`🔄 write-only: no window spawned — the pane takeover (trantor open) claims it next.`);
|
|
70
|
+
process.exit(0);
|
|
71
|
+
}
|
|
72
|
+
const { spawned, armed, windowId } = spawnBaton({ projectDir: cwd, handoffFile: file });
|
|
73
|
+
console.log(spawned
|
|
74
|
+
? `🔄 baton: a fresh session is opening (it'll recap the handoff)${armed ? ` — this window (${windowId}) closes once it takes over` : " — couldn't detect this window; close it yourself once the new one is up"}`
|
|
75
|
+
: `handoff saved, but couldn't spawn a fresh session (non-macOS or spawn disabled) — open a new session here to take over`);
|
|
44
76
|
}
|
|
45
|
-
const { spawned, armed, windowId } = spawnBaton({ projectDir: cwd, handoffFile: file });
|
|
46
|
-
console.log(spawned
|
|
47
|
-
? `🔄 baton: a fresh session is opening (it'll recap the handoff)${armed ? ` — this window (${windowId}) closes once it takes over` : " — couldn't detect this window; close it yourself once the new one is up"}`
|
|
48
|
-
: `handoff saved, but couldn't spawn a fresh session (non-macOS or spawn disabled) — open a new session here to take over`);
|
package/bin/connect.mjs
CHANGED
|
@@ -40,6 +40,10 @@ function patchJson(path, mutate) {
|
|
|
40
40
|
// while its runner sat on the pinned one — the residual split-brain mechanism (2026-08-20).
|
|
41
41
|
// mcp.mjs resolves the hub from the session's project pin; that resolution must stay in charge.
|
|
42
42
|
const relayEnv = (agent) => ({ RELAY_AGENT: agent });
|
|
43
|
+
// OpenCode hosts several differently-named seats. Its global MCP environment must not stamp all
|
|
44
|
+
// of them "opencode": ambient runner identity wins, while this fallback names a normal interactive
|
|
45
|
+
// OpenCode session that has no RELAY_AGENT/RELAY_SESSION of its own.
|
|
46
|
+
const hostedRelayEnv = (agent) => ({ RELAY_AGENT_FALLBACK: agent });
|
|
43
47
|
|
|
44
48
|
// ---- Claude Code: plugin handles it; verify only ----
|
|
45
49
|
if (has("claude")) {
|
|
@@ -87,7 +91,13 @@ if (has("opencode")) {
|
|
|
87
91
|
report("opencode", patchJson(p, d => {
|
|
88
92
|
d.$schema ||= "https://opencode.ai/config.json";
|
|
89
93
|
d.mcp ||= {};
|
|
90
|
-
d.mcp.relay ||= { type: "local", command: ["node", MCP], enabled: true
|
|
94
|
+
d.mcp.relay ||= { type: "local", command: ["node", MCP], enabled: true };
|
|
95
|
+
d.mcp.relay.environment ||= {};
|
|
96
|
+
// Migrate the old generated pin too: `||=` alone left RELAY_AGENT=opencode in every existing
|
|
97
|
+
// config forever, where OpenCode overlaid it on the qwen/glm/deepseek runner environment.
|
|
98
|
+
delete d.mcp.relay.environment.RELAY_AGENT;
|
|
99
|
+
delete d.mcp.relay.environment.RELAY_SESSION;
|
|
100
|
+
Object.assign(d.mcp.relay.environment, hostedRelayEnv("opencode"));
|
|
91
101
|
}), p);
|
|
92
102
|
}
|
|
93
103
|
|
package/bin/crew-runner.mjs
CHANGED
|
@@ -358,7 +358,7 @@ async function reportFailure(exit, trigger, undelivered = 0) {
|
|
|
358
358
|
const reason = classify(exit);
|
|
359
359
|
const down = consecFails >= 2;
|
|
360
360
|
const status = down ? `down: ${reason} · ${consecFails} fails` : `errored: ${reason}`;
|
|
361
|
-
await api("/register", { session: SESSION, project: PROJ, status, llm: AGENT, model: MODEL }).catch(() => {});
|
|
361
|
+
await api("/register", { session: SESSION, project: PROJ, status, llm: AGENT, model: MODEL, kind: "agent" }).catch(() => {});
|
|
362
362
|
const hint = reason === "exhausted" ? " — needs `trantor swap`"
|
|
363
363
|
: reason === "auth" ? " — check credentials"
|
|
364
364
|
: reason === "backend-error" ? " — provider backend error (NOT quota): retry, or `trantor swap` to another provider"
|
|
@@ -446,7 +446,7 @@ async function reportHealthy() {
|
|
|
446
446
|
consecFails = 0;
|
|
447
447
|
// Recovery is a change too, so the next failure is news again.
|
|
448
448
|
announced = "";
|
|
449
|
-
await api("/register", { session: SESSION, project: PROJ, status: `active in ${PROJ}`, llm: AGENT, model: MODEL }).catch(() => {});
|
|
449
|
+
await api("/register", { session: SESSION, project: PROJ, status: `active in ${PROJ}`, llm: AGENT, model: MODEL, kind: "agent" }).catch(() => {});
|
|
450
450
|
await api("/send", { from: SESSION, to: "all", text: `✅ ${SESSION} recovered`, project: PROJ, kind: "status" }).catch(() => {});
|
|
451
451
|
cmuxStatus("ok", "#14b8a6", "check"); herdrAgent("idle");
|
|
452
452
|
}
|
|
@@ -512,7 +512,7 @@ async function runTurn(prompt, isFirst, trigger = "kickoff") {
|
|
|
512
512
|
} catch {}
|
|
513
513
|
const r = spawnSync("/bin/bash", ["-c", `set -o pipefail; { ${inner} ; } 2> >(${SCRUB} --tee2 ${ERRF})`], {
|
|
514
514
|
cwd: TURN_DIR, encoding: "utf8", stdio: cli.sid ? ["ignore", "pipe", "inherit"] : "inherit",
|
|
515
|
-
env: { ...process.env, RELAY_URL: HUB, RELAY_AGENT: AGENT, RELAY_PROJECT: PROJ,
|
|
515
|
+
env: { ...process.env, RELAY_URL: HUB, RELAY_AGENT: AGENT, RELAY_SESSION: SESSION, RELAY_PROJECT: PROJ,
|
|
516
516
|
// A RUNNER-MANAGED SEAT MUST NEVER HAND ITSELF A BATON.
|
|
517
517
|
//
|
|
518
518
|
// The handoff machinery exists for an INTERACTIVE session: near its context limit it writes a
|
|
@@ -673,7 +673,11 @@ function askedExcerpt(message) {
|
|
|
673
673
|
// start cursor at the CURRENT tip so we don't replay history
|
|
674
674
|
let cursor = 0;
|
|
675
675
|
try { const r = await api(`/inbox?session=${encodeURIComponent(SESSION)}&since=0`); cursor = r.cursor || 0; } catch {}
|
|
676
|
-
|
|
676
|
+
// kind "agent" on every beat (#6075): the peer row's kind is the hub's OWN record of what a
|
|
677
|
+
// session is — the overseer's declared-crew exemption reads it, and on the remote hub there is
|
|
678
|
+
// no crew-windows.txt to fall back to. /register preserves absent fields, so a seat running an
|
|
679
|
+
// older runner never loses a kind an updated one stamped.
|
|
680
|
+
await api("/register", { session: SESSION, project: PROJ, status: "crew member booting", llm: AGENT, model: MODEL, kind: "agent" }).catch(() => {});
|
|
677
681
|
// Announce runner-side, signed as THIS seat. Asking the seat to announce itself sent glm's hello
|
|
678
682
|
// out under deepseek's identity whenever opencode seats shared one MCP daemon (lesson on the bus,
|
|
679
683
|
// 2026-07-29): the runner process is per-seat by construction, so its signature cannot be borrowed.
|
|
@@ -726,7 +730,12 @@ function askedExcerpt(message) {
|
|
|
726
730
|
let msgs = [];
|
|
727
731
|
try {
|
|
728
732
|
const r = await api(`/poll?session=${encodeURIComponent(SESSION)}&since=${cursor}&wait=${holdS}`);
|
|
729
|
-
msgs = r.messages || [];
|
|
733
|
+
msgs = r.messages || [];
|
|
734
|
+
if (r.cursor !== undefined && r.cursor !== null && Number.isFinite(Number(r.cursor))) {
|
|
735
|
+
const reportedCursor = Number(r.cursor);
|
|
736
|
+
if (reportedCursor < cursor) log(`cursor rewound by hub ${cursor} -> ${reportedCursor}`);
|
|
737
|
+
cursor = reportedCursor;
|
|
738
|
+
}
|
|
730
739
|
} catch (e) {
|
|
731
740
|
// Deadline-abort on the LONG-POLL is not an outage — it means the hold expired with no hub
|
|
732
741
|
// response (stalled event loop, napped machine, dead socket). Reconnect immediately and say
|
package/bin/new.mjs
CHANGED
|
@@ -129,9 +129,14 @@ try {
|
|
|
129
129
|
// way crew seats do: the operator's owner key mints a project-scoped write invite and the genesis
|
|
130
130
|
// identity spends it. Only when NO owner key is configured (a loopback hub with no owner identity)
|
|
131
131
|
// do we fall back to the plain TOFU enroll.
|
|
132
|
-
const viaOwner = await enrollViaOwnerInvite(hub, identity, name, { timeoutMs: 8000 });
|
|
133
|
-
if (!viaOwner.ok && viaOwner.reason === "no-owner-key") await enrollTofu(session, identity, name);
|
|
132
|
+
const viaOwner = await enrollViaOwnerInvite(hub, identity, name, { timeoutMs: 8000, kind: "tool" });
|
|
133
|
+
if (!viaOwner.ok && viaOwner.reason === "no-owner-key") await enrollTofu(session, identity, name, { kind: "tool" });
|
|
134
134
|
else if (!viaOwner.ok) console.error(`genesis: enrollment via owner invite failed: ${viaOwner.reason}`);
|
|
135
|
+
// #6068: say WHAT this session is. The genesis identity exists to post the brief — without a
|
|
136
|
+
// kind on its peer row the app's seat strip renders it as a seat ("no terminal pane — start it
|
|
137
|
+
// with trantor up genesis"). /register is presence, not speech: no message rides it, nobody
|
|
138
|
+
// wakes. The hub stamps kind on the session row and /peers returns it to the app.
|
|
139
|
+
await signedPost("/register", { session, kind: "tool" }, { session, project: name, timeoutMs: 8000 }).catch(() => {});
|
|
135
140
|
const briefForHub = (brief || `Genesis of ${name} — created by trantor new.`).slice(0, 600);
|
|
136
141
|
const r1 = await signedPost("/project", { project: name, brief: briefForHub, by: session }, { session, project: name, timeoutMs: 8000 });
|
|
137
142
|
if (!r1.ok) throw new Error(`hub ${r1.status} on /project${r1.json?.error ? `: ${r1.json.error}` : ""}`);
|
package/bin/write-handoff.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import { writeFileSync, readFileSync, existsSync, mkdirSync, readdirSync } from
|
|
|
6
6
|
import { join, basename } from "node:path";
|
|
7
7
|
import { homedir, hostname } from "node:os";
|
|
8
8
|
import { execSync } from "node:child_process";
|
|
9
|
-
import { spawnBaton, handoffMode } from "../hooks/lib/handoff.mjs";
|
|
9
|
+
import { spawnBaton, handoffMode, resolveHandoffSurface } from "../hooks/lib/handoff.mjs";
|
|
10
10
|
import { handoffDir } from "../lib/project.mjs";
|
|
11
11
|
|
|
12
12
|
const baton = process.argv.includes("--baton");
|
|
@@ -14,8 +14,14 @@ const baton = process.argv.includes("--baton");
|
|
|
14
14
|
// fresh one on stdin", so a session that had just written a 5KB handoff had to write it again to
|
|
15
15
|
// hand it over — 2m28s of regenerated prose on a live scribe session, 2026-08-24.
|
|
16
16
|
const latest = process.argv.includes("--latest");
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
// #6074: ONE resolver for which project this is and where the session lives — shared with
|
|
18
|
+
// bin/baton.mjs (the `trantor handoff` CLI path) so the two cannot diverge. The name comes from
|
|
19
|
+
// the session's registration (TRANTOR_ORCH / RELAY_PROJECT / orch-sessions.txt) before the cwd;
|
|
20
|
+
// the witnessed crebral-scribe/ios handoff recorded project "ios" from a subfolder cwd and never
|
|
21
|
+
// found its pane.
|
|
22
|
+
const resolved = resolveHandoffSurface({ sessionId: process.env.CLAUDE_SESSION_ID || "" });
|
|
23
|
+
const project = resolved.projectDir;
|
|
24
|
+
const name = resolved.project;
|
|
19
25
|
let summary = "";
|
|
20
26
|
if (!latest) {
|
|
21
27
|
process.stdin.setEncoding("utf8");
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Guarded production restart: an unhealthy durable writer means RAM contains the only current copy.
|
|
3
|
+
set -euo pipefail
|
|
4
|
+
|
|
5
|
+
FORCE=0
|
|
6
|
+
if [ "${1:-}" = "--force" ]; then
|
|
7
|
+
FORCE=1
|
|
8
|
+
shift
|
|
9
|
+
fi
|
|
10
|
+
if [ "$#" -ne 0 ]; then
|
|
11
|
+
echo "usage: deploy/restart-hub.sh [--force]" >&2
|
|
12
|
+
exit 2
|
|
13
|
+
fi
|
|
14
|
+
|
|
15
|
+
# The hub binds RELAY_HOST (the tailnet IP on netcup, where 127.0.0.1 refuses), so the guard reads the
|
|
16
|
+
# same env the service does; RELAY_HUB_URL still overrides.
|
|
17
|
+
ENV_FILE="${RELAY_ENV_FILE:-$(dirname "$0")/hub.env}"
|
|
18
|
+
if [ -f "$ENV_FILE" ]; then
|
|
19
|
+
# shellcheck disable=SC1090
|
|
20
|
+
set -a; . "$ENV_FILE"; set +a
|
|
21
|
+
fi
|
|
22
|
+
HUB_URL="${RELAY_HUB_URL:-http://${RELAY_HOST:-127.0.0.1}:${RELAY_PORT:-4477}}"
|
|
23
|
+
HEALTH=""
|
|
24
|
+
if HEALTH="$(curl --fail --silent --show-error --max-time 5 "$HUB_URL/health")"; then
|
|
25
|
+
REFUSAL="$(node --input-type=module -e '
|
|
26
|
+
const chunks = [];
|
|
27
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
28
|
+
let health;
|
|
29
|
+
try { health = JSON.parse(Buffer.concat(chunks).toString("utf8")); }
|
|
30
|
+
catch { process.stdout.write("hub health response was not valid JSON"); process.exit(0); }
|
|
31
|
+
const p = health.persist;
|
|
32
|
+
if (!p || p.ok !== false) process.exit(0);
|
|
33
|
+
const age = Number(p.failingSinceMs || 0);
|
|
34
|
+
const span = age >= 60000 ? `${Math.floor(age / 60000)}m ${Math.floor((age % 60000) / 1000)}s` : `${Math.floor(age / 1000)}s`;
|
|
35
|
+
process.stdout.write(`hub persistence has failed for ${span} (${Number(p.retries || 0)} retries): ${String(p.lastError || "unknown error")}. Restarting risks losing every state change since persistence stopped.`);
|
|
36
|
+
' <<<"$HEALTH")"
|
|
37
|
+
if [ -n "$REFUSAL" ] && [ "$FORCE" -ne 1 ]; then
|
|
38
|
+
echo "REFUSED: $REFUSAL" >&2
|
|
39
|
+
echo "Resolve persistence first, or rerun with --force to accept the data-loss risk." >&2
|
|
40
|
+
exit 1
|
|
41
|
+
fi
|
|
42
|
+
if [ -n "$REFUSAL" ]; then
|
|
43
|
+
echo "WARNING: --force accepted: $REFUSAL" >&2
|
|
44
|
+
fi
|
|
45
|
+
else
|
|
46
|
+
echo "WARNING: $HUB_URL/health was unreachable; proceeding because no running hub state can be inspected." >&2
|
|
47
|
+
fi
|
|
48
|
+
|
|
49
|
+
systemctl restart trantor-hub
|
|
50
|
+
echo "restarted trantor-hub"
|
package/deploy/setup-netcup.sh
CHANGED
|
@@ -72,10 +72,15 @@ echo " schema applied"
|
|
|
72
72
|
# systemd unit
|
|
73
73
|
cp deploy/trantor-hub.service /etc/systemd/system/trantor-hub.service
|
|
74
74
|
systemctl daemon-reload
|
|
75
|
-
systemctl enable
|
|
75
|
+
systemctl enable trantor-hub
|
|
76
|
+
if systemctl is-active --quiet trantor-hub; then
|
|
77
|
+
bash deploy/restart-hub.sh
|
|
78
|
+
else
|
|
79
|
+
systemctl start trantor-hub
|
|
80
|
+
fi
|
|
76
81
|
|
|
77
82
|
# crons: daily backup + nightly retention
|
|
78
|
-
chmod +x deploy/backup.sh deploy/retention.sh deploy/restore.sh
|
|
83
|
+
chmod +x deploy/backup.sh deploy/retention.sh deploy/restore.sh deploy/restart-hub.sh
|
|
79
84
|
echo "0 2 * * * root /opt/trantor/deploy/backup.sh" > /etc/cron.d/trantor
|
|
80
85
|
echo "0 3 * * * root /opt/trantor/deploy/retention.sh" >> /etc/cron.d/trantor
|
|
81
86
|
|
package/hooks/lib/api.mjs
CHANGED
|
@@ -112,7 +112,7 @@ function enrolledPath(session) {
|
|
|
112
112
|
const busDir = process.env.AGENT_BUS_DIR || join(homedir(), ".agent-bus");
|
|
113
113
|
return join(busDir, "keys", `${String(session).replace(/[^A-Za-z0-9_.-]/g, "_")}.enrolled`);
|
|
114
114
|
}
|
|
115
|
-
export async function ensureEnrolled(session, identity, project) {
|
|
115
|
+
export async function ensureEnrolled(session, identity, project, { kind = "agent" } = {}) {
|
|
116
116
|
if (!identity?.pubkey) return;
|
|
117
117
|
const hub = relayUrl(project);
|
|
118
118
|
const stamp = enrolledPath(session);
|
|
@@ -126,7 +126,7 @@ export async function ensureEnrolled(session, identity, project) {
|
|
|
126
126
|
// payload, sets content-type, and signs — so every hook signs identically with zero hand-rolling.
|
|
127
127
|
const r = await sfetchJson(`${hub}/enroll`, {
|
|
128
128
|
method: "POST",
|
|
129
|
-
payload: { pubkey: identity.pubkey, name: session, kind
|
|
129
|
+
payload: { pubkey: identity.pubkey, name: session, kind },
|
|
130
130
|
identity,
|
|
131
131
|
signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
|
|
132
132
|
});
|
package/hooks/lib/handoff.mjs
CHANGED
|
@@ -18,6 +18,7 @@ import { fileURLToPath } from "node:url";
|
|
|
18
18
|
import { deriveSubagentManifest } from "../../lib/subagent-manifest.mjs";
|
|
19
19
|
import { signedPost } from "./api.mjs";
|
|
20
20
|
import { loadAutonomy, resolveAutonomy } from "../../lib/autonomy.mjs";
|
|
21
|
+
import { resolveProject, orchSessionsPath } from "../../lib/project.mjs";
|
|
21
22
|
|
|
22
23
|
// Writer and reader MUST resolve the same directory — see lib/project.mjs busDir(). This used to
|
|
23
24
|
// honour only RELAY_DATA_DIR while the reader honoured neither override.
|
|
@@ -389,8 +390,10 @@ export function appendHandoffState(id, state, by = "") {
|
|
|
389
390
|
} catch { return false; }
|
|
390
391
|
}
|
|
391
392
|
|
|
392
|
-
export function writeHandoff({ projectDir, sessionId, transcript, trigger, summary, force = false }) {
|
|
393
|
-
|
|
393
|
+
export function writeHandoff({ projectDir, sessionId, transcript, trigger, summary, force = false, projectName: projectNameArg }) {
|
|
394
|
+
// #6074: the NAME may come from the session's registration (resolveHandoffSurface), not from
|
|
395
|
+
// this directory's basename — a subfolder cwd must not rename the project on the record.
|
|
396
|
+
const projectName = projectNameArg || basename(projectDir);
|
|
394
397
|
// Server-side storm guard: a session running OLD hooks (before the local markHandedOff guard) re-fires
|
|
395
398
|
// context-warn handoffs every few minutes — the crebral-cortex storm (9 in 49 min, each spawning a
|
|
396
399
|
// window). Ask the hub for clearance (rate-limit per project+session); a non-forced handoff inside the
|
|
@@ -541,6 +544,12 @@ export function maybeSpawn(projectDir, conf = readConfig()) {
|
|
|
541
544
|
try {
|
|
542
545
|
if (process.platform !== "darwin") return false;
|
|
543
546
|
if (process.env.TRANTOR_NO_HANDOFF_SPAWN === "1") return false;
|
|
547
|
+
// #6074: a session in a hosted pane never gets a Terminal window — the pane is the successor
|
|
548
|
+
// surface, and the pane claims the handoff (trantor open) on its own.
|
|
549
|
+
if (paneSurfaceEnv()) {
|
|
550
|
+
process.stderr.write(`[trantor] session lives in herdr pane ${paneSurfaceEnv()} — no Terminal window; the pane claims the handoff\n`);
|
|
551
|
+
return false;
|
|
552
|
+
}
|
|
544
553
|
if (conf.autoHandoffPrompt === false) return false;
|
|
545
554
|
if (hasOrchPane(basename(projectDir))) {
|
|
546
555
|
process.stderr.write(`[trantor] orch pane hosts ${basename(projectDir)} — no Terminal window; the pane claims the handoff on its next open\n`);
|
|
@@ -585,9 +594,52 @@ export function hasOrchPane(projectName) {
|
|
|
585
594
|
} catch { return false; }
|
|
586
595
|
}
|
|
587
596
|
|
|
597
|
+
// ---- #6074: WHERE the session lives, and WHICH project it is ----------------
|
|
598
|
+
// Witnessed 2026-09-02 (crebral-scribe/ios): a pane session ran the handoff skill with its shell
|
|
599
|
+
// cwd in a subfolder. The project name came from basename(cwd) = "ios", the pane lookup found no
|
|
600
|
+
// "ios" row, and the baton fell to the window leg — whose front-Terminal-window fallback picked a
|
|
601
|
+
// window the pane session never owned, spawned a stray Terminal session, and armed a close on a
|
|
602
|
+
// stranger's window. Two facts the code ignored: a session's OWN env knows where it lives
|
|
603
|
+
// (HERDR_PANE_ID — herdr sets it in every pane), and the project's REGISTRATION knows its name
|
|
604
|
+
// (the `trantor open` badge, RELAY_PROJECT, the orch-sessions map) long before cwd is worth
|
|
605
|
+
// consulting. One resolver, shared by write-handoff.mjs --baton AND bin/baton.mjs, so the skill
|
|
606
|
+
// path and the CLI path cannot diverge.
|
|
607
|
+
export function paneSurfaceEnv(env = process.env) {
|
|
608
|
+
return String(env.HERDR_PANE_ID || "").trim();
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// Reverse orch-sessions.txt lookup: which project recorded THIS session id as its orchestrator
|
|
612
|
+
// thread. One row per project, "<project>\t<sid>" — written by `trantor open` / adopt / claim.
|
|
613
|
+
export function orchProjectForSession(sid) {
|
|
614
|
+
try {
|
|
615
|
+
if (!sid) return "";
|
|
616
|
+
for (const line of readFileSync(orchSessionsPath(), "utf8").split("\n")) {
|
|
617
|
+
const [p, s] = line.split("\t");
|
|
618
|
+
if (p && s && s.trim() === String(sid).trim()) return p.trim();
|
|
619
|
+
}
|
|
620
|
+
} catch {}
|
|
621
|
+
return "";
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// The one resolver. Returns { project, projectDir, pane, surface }:
|
|
625
|
+
// pane — the session's own pane id ("" when it is not a hosted pane)
|
|
626
|
+
// surface — "pane" (the baton MUST take the pane leg) or "window" (today's behavior)
|
|
627
|
+
// project — the REGISTERED project name; a subfolder cwd never renames it
|
|
628
|
+
export function resolveHandoffSurface({ projectDir, sessionId, env = process.env } = {}) {
|
|
629
|
+
const dir = projectDir || env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
630
|
+
const badge = String(env.TRANTOR_ORCH || "").trim();
|
|
631
|
+
let project = "";
|
|
632
|
+
if (badge && badge !== "1") project = badge; // `trantor open` badge carries the name
|
|
633
|
+
if (!project && env.RELAY_PROJECT) project = String(env.RELAY_PROJECT).trim();
|
|
634
|
+
if (!project) project = orchProjectForSession(sessionId);
|
|
635
|
+
if (!project) project = resolveProject(dir); // last resort: cwd (git-root aware)
|
|
636
|
+
return { project, projectDir: dir, pane: paneSurfaceEnv(env), surface: paneSurfaceEnv(env) ? "pane" : "window" };
|
|
637
|
+
}
|
|
638
|
+
|
|
588
639
|
export function spawnFresh(projectDir) {
|
|
589
640
|
try {
|
|
590
641
|
if (process.platform !== "darwin" || spawnSuppressed()) return false;
|
|
642
|
+
if (paneSurfaceEnv()) return false; // #6074: pane sessions never open windows
|
|
591
643
|
if (hasOrchPane(basename(projectDir))) return false; // the pane is the successor surface (#5509)
|
|
592
644
|
const script = join(HERE, "..", "..", "bin", "open-session.sh");
|
|
593
645
|
if (!existsSync(script)) return false;
|
|
@@ -624,11 +676,15 @@ export function resolveOriginalWindow() {
|
|
|
624
676
|
// window machinery (resolve/arm-close) is deliberately absent here: there is no window to close,
|
|
625
677
|
// and the old path's answer ("spawn disabled — open manually") left the operator doing the
|
|
626
678
|
// machine's job by hand.
|
|
627
|
-
export function spawnPaneBaton(projectDir, handoffFile) {
|
|
679
|
+
export function spawnPaneBaton(projectDir, handoffFile, paneId = "") {
|
|
628
680
|
try {
|
|
629
681
|
const script = join(HERE, "..", "..", "bin", "baton-pane.mjs");
|
|
630
682
|
if (!existsSync(script)) return false;
|
|
631
|
-
|
|
683
|
+
// #6074: when the dying session KNOWS its pane (HERDR_PANE_ID), pass it — the driver must
|
|
684
|
+
// replace THAT pane, not guess one from a crew-windows row keyed by a cwd-derived name.
|
|
685
|
+
const args = [script, "--project", projectDir, "--handoff", handoffFile];
|
|
686
|
+
if (paneId) args.push("--pane", paneId);
|
|
687
|
+
const child = spawn(process.execPath, args, { detached: true, stdio: "ignore" });
|
|
632
688
|
child.unref();
|
|
633
689
|
return true;
|
|
634
690
|
} catch { return false; }
|
|
@@ -643,15 +699,30 @@ export function spawnPaneBaton(projectDir, handoffFile) {
|
|
|
643
699
|
// regression-tested headlessly.
|
|
644
700
|
export function spawnBaton({ projectDir, handoffFile, conf = readConfig(),
|
|
645
701
|
_resolveWindow = resolveOriginalWindow, _spawnFresh = spawnFresh, _armClose = armBatonClose,
|
|
646
|
-
_hasPane = hasOrchPane, _spawnPane = spawnPaneBaton }) {
|
|
702
|
+
_hasPane = hasOrchPane, _spawnPane = spawnPaneBaton, _env = process.env }) {
|
|
647
703
|
// A DRILL MUST BE ABLE TO SAY NO. There was no such switch, so exercising the baton path in a
|
|
648
704
|
// test opened real Terminal windows running real `claude` sessions in temp directories the test
|
|
649
705
|
// then deleted, each parked on a "do you trust this folder?" prompt. Five of them were found by
|
|
650
706
|
// the operator on 2026-08-24. A code path that spawns windows needs an off switch, or it cannot
|
|
651
|
-
// be tested honestly and someone will fake one that does not exist.
|
|
652
|
-
|
|
707
|
+
// be tested honestly and someone will fake one that does not exist. The check reads the REAL env
|
|
708
|
+
// (spawnSuppressed) AND the injected one (_env) — a crew runner exports TRANTOR_NO_*_SPAWN for
|
|
709
|
+
// its seats, and a drill that inherits that must still be able to exercise each branch by
|
|
710
|
+
// passing its own env, not by mutating the runner's.
|
|
711
|
+
const suppressed = spawnSuppressed()
|
|
712
|
+
|| String(_env.TRANTOR_NO_HANDOFF_SPAWN || "") === "1" || String(_env.TRANTOR_NO_BATON_SPAWN || "") === "1";
|
|
713
|
+
if (suppressed || conf.batonSpawn === false) {
|
|
653
714
|
return { spawned: false, armed: false, windowId: "", suppressed: true };
|
|
654
715
|
}
|
|
716
|
+
// #6074, checked FIRST: the session's OWN env is the truth about where it lives. HERDR_PANE_ID
|
|
717
|
+
// set means the pane leg, keyed by THAT pane id — regardless of cwd or project name — and the
|
|
718
|
+
// whole window machinery (resolve + spawn + arm-close) is forbidden here: a pane session has no
|
|
719
|
+
// Terminal window, so the front-window fallback can only ever pick a stranger's (the witnessed
|
|
720
|
+
// crebral-scribe/ios incident armed a close on a window this session never owned).
|
|
721
|
+
const paneId = paneSurfaceEnv(_env);
|
|
722
|
+
if (paneId) {
|
|
723
|
+
const spawned = _spawnPane(projectDir, handoffFile, paneId);
|
|
724
|
+
return { spawned, armed: false, windowId: "", pane: true, paneId };
|
|
725
|
+
}
|
|
655
726
|
// Hosted pane (#5643): the pane IS the successor surface — no window is resolved, spawned, or
|
|
656
727
|
// armed for closing. The detached driver replaces the session at the turn boundary.
|
|
657
728
|
if (_hasPane(basename(projectDir))) {
|
package/hooks/sessionstart.mjs
CHANGED
|
@@ -221,8 +221,15 @@ try {
|
|
|
221
221
|
// instead of silently routing to the default.
|
|
222
222
|
const { url, via: hubVia } = resolveHubInfo(project);
|
|
223
223
|
|
|
224
|
-
// register self + post an initial presence status (no LLM turn — instant for others to read)
|
|
225
|
-
|
|
224
|
+
// register self + post an initial presence status (no LLM turn — instant for others to read).
|
|
225
|
+
// kind "orch" when `trantor open` badged THIS session as the project's orchestrator pane
|
|
226
|
+
// (#6075): the peer row's kind is the hub's own record of what a session is — the overseer's
|
|
227
|
+
// declared-crew exemption reads it, and on the remote hub there is no local crew-windows.txt.
|
|
228
|
+
// Strict match (badge === project), same rule the doctrine gate below uses; a badge of "1"
|
|
229
|
+
// carries no name, so it stamps nothing. Absent kind is preserved by /register, so the MCP's
|
|
230
|
+
// kindless heartbeats never erase this.
|
|
231
|
+
const orchBadge = process.env.TRANTOR_ORCH || "";
|
|
232
|
+
await jpost(`${url}/register`, { session, project, status: `active in ${project}`, ...(orchBadge === project ? { kind: "orch" } : {}) }, session).catch(() => {});
|
|
226
233
|
|
|
227
234
|
// fetch roster of OTHER online sessions
|
|
228
235
|
let peers = [], hubAnswered = false;
|
package/hooks/stop-inbox.mjs
CHANGED
|
@@ -21,12 +21,12 @@
|
|
|
21
21
|
// * Only claim delivery once we have actually decided to surface it (peek first). Marking a message
|
|
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
|
-
import { readFileSync, writeFileSync, existsSync, unlinkSync } from "node:fs";
|
|
24
|
+
import { readFileSync, writeFileSync, existsSync, unlinkSync, mkdirSync, renameSync } from "node:fs";
|
|
25
25
|
import { join, dirname } from "node:path";
|
|
26
26
|
import { spawn } from "node:child_process";
|
|
27
27
|
import { fileURLToPath } from "node:url";
|
|
28
28
|
import { homedir } from "node:os";
|
|
29
|
-
import { resolveProject, hostId, handoffDir } from "../lib/project.mjs";
|
|
29
|
+
import { resolveProject, hostId, handoffDir, busDir } from "../lib/project.mjs";
|
|
30
30
|
import { signedGet } from "./lib/api.mjs"; // signed: enforce hubs 401 unsigned reads — unsigned, T2 delivery is silently dead
|
|
31
31
|
import { ledgerPaths, ensureStart, anchorCursor, writeCursor } from "./lib/inbox-ledger.mjs";
|
|
32
32
|
import { readArm, clearArm, markHandedOff, appendHandoffState } from "./lib/handoff.mjs";
|
|
@@ -71,6 +71,32 @@ const OVERDUE_MS = (() => {
|
|
|
71
71
|
return Number.isFinite(n) && n >= 0 ? n : 10 * 60 * 1000;
|
|
72
72
|
})();
|
|
73
73
|
|
|
74
|
+
function stalledSeenPath(session) {
|
|
75
|
+
const safe = String(session).replace(/[^A-Za-z0-9_.-]/g, "_");
|
|
76
|
+
return join(busDir(), `stop-stalled-seen-${safe}.json`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function readStalledSeen(session) {
|
|
80
|
+
try {
|
|
81
|
+
const ids = JSON.parse(readFileSync(stalledSeenPath(session), "utf8"));
|
|
82
|
+
return new Set(Array.isArray(ids) ? ids.map(String) : []);
|
|
83
|
+
} catch { return new Set(); }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function writeStalledSeen(session, seen) {
|
|
87
|
+
const path = stalledSeenPath(session);
|
|
88
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
89
|
+
try {
|
|
90
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
91
|
+
writeFileSync(tmp, JSON.stringify([...seen]) + "\n");
|
|
92
|
+
renameSync(tmp, path);
|
|
93
|
+
return true;
|
|
94
|
+
} catch {
|
|
95
|
+
try { unlinkSync(tmp); } catch {}
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
74
100
|
async function stalledContractCheck({ session, project, instanceId }) {
|
|
75
101
|
if (process.env.RELAY_STOP_CONTRACTS === "0") return allow();
|
|
76
102
|
let contracts = [];
|
|
@@ -89,8 +115,19 @@ async function stalledContractCheck({ session, project, instanceId }) {
|
|
|
89
115
|
const abandoned = contracts.filter(c => c.disposition === "abandoned");
|
|
90
116
|
if (!stalled.length) return allow();
|
|
91
117
|
|
|
118
|
+
const seen = readStalledSeen(session);
|
|
119
|
+
const fresh = stalled.filter(c => !seen.has(String(c.id)));
|
|
120
|
+
if (!fresh.length) {
|
|
121
|
+
process.stderr.write(`[trantor] ${stalled.length} stalled contract(s) already surfaced for ${sanitize(session)}; allowing stop\n`);
|
|
122
|
+
return allow();
|
|
123
|
+
}
|
|
124
|
+
for (const contract of stalled) seen.add(String(contract.id));
|
|
125
|
+
// Persist before blocking: if the harness stops this process immediately after reading stdout,
|
|
126
|
+
// the next Stop must still know this exact stalled episode was already surfaced.
|
|
127
|
+
if (!writeStalledSeen(session, seen)) return allow();
|
|
128
|
+
|
|
92
129
|
const mins = (ms) => (ms >= 60000 ? `${Math.round(ms / 60000)}m` : `${Math.round(ms / 1000)}s`);
|
|
93
|
-
const lines =
|
|
130
|
+
const lines = fresh.slice(0, 6).map(c => {
|
|
94
131
|
const health = c.assigneeOnline ? `online, status "${sanitize(c.assigneeStatus || "?")}"`
|
|
95
132
|
: c.assigneeLastSeenMs == null ? "never seen on the bus"
|
|
96
133
|
: `LAST SEEN ${mins(c.assigneeLastSeenMs)} ago`;
|
|
@@ -104,7 +141,7 @@ async function stalledContractCheck({ session, project, instanceId }) {
|
|
|
104
141
|
: "";
|
|
105
142
|
|
|
106
143
|
const reason =
|
|
107
|
-
`You dispatched ${
|
|
144
|
+
`You dispatched ${fresh.length} NEW contract(s) that have gone quiet, and you were about to go idle:\n` +
|
|
108
145
|
lines + ghosts + `\n\n` +
|
|
109
146
|
`Do not just wait, and do not ask the human to check. Use relay_contracts to see everything you are owed, ` +
|
|
110
147
|
`relay_peers to see whether the seat is alive, and relay_send to ask it directly. If a seat is down, say so and ` +
|
package/hub.mjs
CHANGED
|
@@ -6,12 +6,13 @@
|
|
|
6
6
|
// private network, or add auth first. See "Always-on / remote hub" in the README (roadmap).
|
|
7
7
|
import http from "node:http";
|
|
8
8
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync, readdirSync } from "node:fs";
|
|
9
|
-
import { homedir } from "node:os";
|
|
9
|
+
import { homedir, hostname } from "node:os";
|
|
10
10
|
import { join } from "node:path";
|
|
11
11
|
import { timingSafeEqual, randomBytes } from "node:crypto";
|
|
12
12
|
import { verifyRequest, verifyEndorsement, publicView } from "./lib/identity.mjs";
|
|
13
13
|
import { DEFAULT_ORG } from "./lib/store-contract.mjs";
|
|
14
14
|
import { assertNoSecrets } from "./lib/scrub.mjs";
|
|
15
|
+
import { createPersistHealth } from "./lib/persist-health.mjs";
|
|
15
16
|
|
|
16
17
|
const PORT = Number(process.env.RELAY_PORT || 4477);
|
|
17
18
|
const HOST = process.env.RELAY_HOST || "127.0.0.1";
|
|
@@ -125,6 +126,7 @@ function emptyState() {
|
|
|
125
126
|
const CARD_LOG_MAX = 40;
|
|
126
127
|
const CARD_LOG_TEXT_MAX = 2000;
|
|
127
128
|
const CARD_LOG_BY_MAX = 120;
|
|
129
|
+
const stripNulText = (value) => String(value ?? "").replace(/\u0000/g, "");
|
|
128
130
|
function normalizeTaskLog(t) {
|
|
129
131
|
if (!Array.isArray(t.log)) { if (t.log !== undefined) delete t.log; return false; }
|
|
130
132
|
const before = JSON.stringify(t.log);
|
|
@@ -133,7 +135,7 @@ function normalizeTaskLog(t) {
|
|
|
133
135
|
.map(e => ({
|
|
134
136
|
ts: Number.isFinite(Number(e.ts)) && Number(e.ts) > 0 ? Math.floor(Number(e.ts)) : Date.now(),
|
|
135
137
|
by: String(e.by || "").slice(0, CARD_LOG_BY_MAX),
|
|
136
|
-
text:
|
|
138
|
+
text: stripNulText(e.text).slice(0, CARD_LOG_TEXT_MAX),
|
|
137
139
|
}))
|
|
138
140
|
.slice(-CARD_LOG_MAX);
|
|
139
141
|
if (!t.log.length) delete t.log;
|
|
@@ -144,7 +146,7 @@ function appendTaskLog(t, by, text, ts = Date.now()) {
|
|
|
144
146
|
const entry = {
|
|
145
147
|
ts: Number.isFinite(Number(ts)) && Number(ts) > 0 ? Math.floor(Number(ts)) : Date.now(),
|
|
146
148
|
by: String(by || "").slice(0, CARD_LOG_BY_MAX),
|
|
147
|
-
text:
|
|
149
|
+
text: stripNulText(text).slice(0, CARD_LOG_TEXT_MAX),
|
|
148
150
|
};
|
|
149
151
|
const log = Array.isArray(t.log) ? t.log : [];
|
|
150
152
|
log.push(entry);
|
|
@@ -251,6 +253,11 @@ let dirty = false;
|
|
|
251
253
|
// `cardEvents` key — so downgrading to a pre-0.17.54 hub still boots with its full TIMELINE
|
|
252
254
|
// instead of a silently empty history. Cheap insurance on a live hub; drop the mirror later.
|
|
253
255
|
let persisting = false;
|
|
256
|
+
const persistHealth = createPersistHealth({
|
|
257
|
+
baseMs: process.env.RELAY_PERSIST_RETRY_BASE_MS,
|
|
258
|
+
maxMs: process.env.RELAY_PERSIST_RETRY_MAX_MS,
|
|
259
|
+
logIntervalMs: process.env.RELAY_PERSIST_LOG_INTERVAL_MS,
|
|
260
|
+
});
|
|
254
261
|
const snapshotState = () => JSON.parse(JSON.stringify({ ...state, cardEvents: state.events.filter(e => ["created", "moved", "updated"].includes(e?.type)) }));
|
|
255
262
|
// This hub's writer id: saveDelta stamps it into every NOTIFY so we can tell our own change
|
|
256
263
|
// notifications apart from a second writer's (importer, admin psql, another hub instance).
|
|
@@ -260,25 +267,38 @@ const HUB_SRC = `hub-${process.pid}-${randomBytes(4).toString("hex")}`;
|
|
|
260
267
|
// survive our persist ticks (the old saveSnapshot wholesale delete+rewrite destroyed them).
|
|
261
268
|
let lastPersisted = durableStore ? snapshotState() : null;
|
|
262
269
|
if (runTaskBootMigrations()) dirty = true;
|
|
270
|
+
const recordPersistFailure = (kind, error) => {
|
|
271
|
+
dirty = true;
|
|
272
|
+
const failure = persistHealth.failed(error);
|
|
273
|
+
if (failure.shouldLog) {
|
|
274
|
+
process.stderr.write(`[trantor] ${kind} persist failing: ${failure.health.retries} retries over ${failure.health.failingSinceMs}ms; last error: ${failure.health.lastError}\n`);
|
|
275
|
+
}
|
|
276
|
+
};
|
|
263
277
|
const persist = () => {
|
|
264
|
-
if (!dirty || persisting) return;
|
|
278
|
+
if (!dirty || persisting || !persistHealth.canAttempt()) return;
|
|
265
279
|
if (durableStore) {
|
|
266
280
|
const snapshot = snapshotState();
|
|
267
281
|
dirty = false; persisting = true;
|
|
268
282
|
durableStore.saveDelta(ORG_ID, lastPersisted, snapshot, { src: HUB_SRC }).then(() => {
|
|
269
283
|
lastPersisted = snapshot;
|
|
284
|
+
persistHealth.succeeded();
|
|
270
285
|
}).catch(e => {
|
|
271
|
-
|
|
272
|
-
process.stderr.write(`[trantor] Postgres persist failed: ${e.message || e}\n`);
|
|
286
|
+
recordPersistFailure("Postgres", e);
|
|
273
287
|
}).finally(() => {
|
|
274
288
|
persisting = false;
|
|
275
|
-
if (dirty) persist();
|
|
276
289
|
});
|
|
277
290
|
return;
|
|
278
291
|
}
|
|
279
|
-
try {
|
|
292
|
+
try {
|
|
293
|
+
writeFileSync(DATA, JSON.stringify(snapshotState()));
|
|
294
|
+
dirty = false;
|
|
295
|
+
persistHealth.succeeded();
|
|
296
|
+
} catch (e) {
|
|
297
|
+
recordPersistFailure("JSON", e);
|
|
298
|
+
}
|
|
280
299
|
};
|
|
281
|
-
|
|
300
|
+
const persistTickMs = Math.min(1000, Math.max(10, Number(process.env.RELAY_PERSIST_RETRY_BASE_MS) || 1000));
|
|
301
|
+
setInterval(persist, persistTickMs).unref?.();
|
|
282
302
|
|
|
283
303
|
// --- reload on external change (the boot-cache fix) -------------------------------------------
|
|
284
304
|
// A NOTIFY from any writer that isn't us means Postgres has rows our in-memory projection has
|
|
@@ -320,6 +340,14 @@ async function reloadFromStore() {
|
|
|
320
340
|
// not spam the feed; at level >= 3 a file-conflict opens a verify gate (the go/no-go primitive).
|
|
321
341
|
let _overseer = null;
|
|
322
342
|
import("./lib/overseer.mjs").then(m => { _overseer = m; }).catch(() => {});
|
|
343
|
+
// #5760: the same-project episode rule (lib/same-project.mjs, pure) — lazy like the detector, so
|
|
344
|
+
// a hub booted before the module lands still runs (the same-project branch fails QUIET until it
|
|
345
|
+
// arrives: a missing rule must never re-instate the hourly metronome).
|
|
346
|
+
let _sameProject = null;
|
|
347
|
+
import("./lib/same-project.mjs").then(m => { _sameProject = m; }).catch(() => {});
|
|
348
|
+
// This hub runs on the operator's machine, so the machine's own sessions ("<host>:<project>") ARE
|
|
349
|
+
// the orchestrator side of every declared crew.
|
|
350
|
+
const HOST_NAME = hostname().split(".")[0];
|
|
323
351
|
const OVERSEER_TICK_MS = Number(process.env.RELAY_OVERSEER_TICK_MS || 30 * 1000);
|
|
324
352
|
// How long a condition must be ABSENT before we consider the episode over. This is NOT a re-warn
|
|
325
353
|
// timer: see overseerTick.
|
|
@@ -353,6 +381,37 @@ function overseerInputs() {
|
|
|
353
381
|
now: now(),
|
|
354
382
|
};
|
|
355
383
|
}
|
|
384
|
+
|
|
385
|
+
// --- #5760: the same-project warning is an EPISODE keyed by the MEMBER SET -------------------
|
|
386
|
+
// The night of 08-31 the same-project DM re-fired hourly for a membership that never changed and
|
|
387
|
+
// woke every seat into metered chatter turns. The rule (lib/same-project.mjs, pure) decides
|
|
388
|
+
// fire-or-not from (previous set, current set, declared crew, last-fired-at): a declared crew is
|
|
389
|
+
// the NORMAL state of a project and is not a collision at all; a set that never changed re-warns
|
|
390
|
+
// never. The warn itself rides the SAME episode machinery as every other kind (#5350: one warn at
|
|
391
|
+
// open, newcomer-only intros while standing, a genuine clear ends it) — the record below only
|
|
392
|
+
// feeds the pure rule the set it judged last, so "unchanged" is one hash comparison and the
|
|
393
|
+
// record line reports DURATION ("same-project for 6h"), never a count of warnings.
|
|
394
|
+
const sameProjectFired = new Map(); // project -> { hash, sessions, ts } — the set as of the last verdict
|
|
395
|
+
|
|
396
|
+
// The declared crew: HUB state, never a file on the operator's machine (#6075). The production
|
|
397
|
+
// hub runs on netcup, where ~/.agent-bus/crew-windows.txt does not exist — the file describes the
|
|
398
|
+
// OPERATOR'S machine (it is written by `trantor up` there), so on the remote hub the old reader
|
|
399
|
+
// found nothing and every same-project set looked like intruders: the crew-only exemption simply
|
|
400
|
+
// never held remotely. What the hub itself knows is the peer row's `kind` (#6148): "agent" is a
|
|
401
|
+
// crew seat — crew-runner stamps it on every /register its seats make — and "orch" is the
|
|
402
|
+
// project's orchestrator pane (sessionstart stamps it when TRANTOR_ORCH names this project).
|
|
403
|
+
// The HOST_NAME exemption is gone with the file: the hub's hostname is the hub machine's
|
|
404
|
+
// (netcup), never the operator's, so `<HOST_NAME>:<project>` exempted a session that cannot
|
|
405
|
+
// exist. Genesis is deliberately NOT crew (#6068: a bookkeeping identity, not a seat).
|
|
406
|
+
function declaredCrewFor(project) {
|
|
407
|
+
const crew = new Set();
|
|
408
|
+
for (const [sid, p] of Object.entries(state.peers)) {
|
|
409
|
+
if ((p.project || "") !== project) continue;
|
|
410
|
+
if (p.kind === "agent" || p.kind === "orch") crew.add(sid);
|
|
411
|
+
}
|
|
412
|
+
return [...crew];
|
|
413
|
+
}
|
|
414
|
+
|
|
356
415
|
function overseerTick() {
|
|
357
416
|
if (!_overseer?.detectCollisions) return;
|
|
358
417
|
let collisions = [];
|
|
@@ -365,8 +424,8 @@ function overseerTick() {
|
|
|
365
424
|
// sessions to "coordinate over the bus" is useless if neither knows the other's id, and the
|
|
366
425
|
// warning alone went only to the duty seat and the log — so coordination needed a human to carry
|
|
367
426
|
// the ids across. Shared by the episode-start branch (all parties) and the standing branch
|
|
368
|
-
// (newcomers only): existing members never
|
|
369
|
-
// every party every tick.
|
|
427
|
+
// (newcomers only, same-project included): existing members never
|
|
428
|
+
// re-hear it, so a standing condition must not re-wake every party every tick.
|
|
370
429
|
const intro = (c, me, others) => {
|
|
371
430
|
const rest = others.filter(p => p !== me);
|
|
372
431
|
if (rest.length === 0) return;
|
|
@@ -374,7 +433,58 @@ function overseerTick() {
|
|
|
374
433
|
`🤝 OVERSEER ${c.kind}: you and ${rest.join(", ")} are working on overlapping ground${c.files?.length ? ` (${c.files.slice(0, 3).join(", ")})` : ""}. ${c.detail || ""} Coordinate directly — relay_send to ${rest[0]} — and split the work between you. No human needs to relay this.`,
|
|
375
434
|
c.project);
|
|
376
435
|
};
|
|
436
|
+
// #5760: same-project sets judged crew-only are dropped entirely — the normal state of a
|
|
437
|
+
// project, not a collision — so not even the context feed narrates them.
|
|
438
|
+
const kept = [];
|
|
377
439
|
for (const c of collisions) {
|
|
440
|
+
// #5760: same-project gets the pure episode rule (lib/same-project.mjs) ON TOP of the shared
|
|
441
|
+
// episode machinery below: a crew-only set is not a collision at all (dropped — no warn, no
|
|
442
|
+
// context, no state); a standing set re-warns never (a liveness flap replays the SAME set —
|
|
443
|
+
// 08-31's metronome — and must stay silent, only genuine newcomers hear the intro once); and
|
|
444
|
+
// the record line reports DURATION. Without the rule module this branch is invisible and
|
|
445
|
+
// same-project rides the generic loop exactly as before — a missing rule must never
|
|
446
|
+
// re-instate the hourly metronome, so the fallback is the pre-#5760 behavior, never stricter.
|
|
447
|
+
if (c.kind === "same-project-sessions" && _sameProject?.sameProjectDecision) {
|
|
448
|
+
const prior = sameProjectFired.get(c.project) || null;
|
|
449
|
+
const d = _sameProject.sameProjectDecision({
|
|
450
|
+
previous: prior?.sessions ?? null,
|
|
451
|
+
current: c.sessions,
|
|
452
|
+
declaredCrew: declaredCrewFor(c.project),
|
|
453
|
+
lastFiredAt: prior?.ts ?? null,
|
|
454
|
+
now: t,
|
|
455
|
+
});
|
|
456
|
+
if (d.reason === "crew-only") continue;
|
|
457
|
+
const key = `${c.project} ${c.kind}`;
|
|
458
|
+
c.key = key;
|
|
459
|
+
seen.add(key);
|
|
460
|
+
kept.push(c);
|
|
461
|
+
const parties = [...new Set(c.sessions || [])].filter(s => s && s !== DUTY_SESSION);
|
|
462
|
+
const standing = overseerActive.get(key);
|
|
463
|
+
if (standing) {
|
|
464
|
+
// The episode HOLDS: no new warn, whoever was introed once is never re-heard.
|
|
465
|
+
standing.lastTick = t;
|
|
466
|
+
c.since = standing.since;
|
|
467
|
+
if (_sameProject.durationLabel) c.detail = `${c.detail || ""} (same-project for ${_sameProject.durationLabel(t - standing.since)})`.trim();
|
|
468
|
+
for (const me of parties) if (!standing.sessions.has(me)) intro(c, me, parties);
|
|
469
|
+
for (const me of parties) standing.sessions.add(me);
|
|
470
|
+
// The record tracks the live membership (ts stays at the last warn) so a later open
|
|
471
|
+
// judges the true previous set and can say how long the old one held.
|
|
472
|
+
if (d.fire && prior) sameProjectFired.set(c.project, { hash: _sameProject.memberSetHash(c.sessions), sessions: c.sessions, ts: prior.ts });
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
// The episode OPENS and the pure rule said fire — first sighting, or a membership change
|
|
476
|
+
// on a remembered set; the record line states how long the previous state held.
|
|
477
|
+
overseerActive.set(key, { since: t, lastTick: t, sessions: new Set(parties) });
|
|
478
|
+
c.since = t;
|
|
479
|
+
if (d.reason === "membership-changed") c.detail = `${c.detail || ""} (same-project for ${_sameProject.durationLabel(d.durationMs)})`.trim();
|
|
480
|
+
sameProjectFired.set(c.project, { hash: _sameProject.memberSetHash(c.sessions), sessions: c.sessions, ts: t });
|
|
481
|
+
appendEvent("overseer.warn", c.project, "overseer",
|
|
482
|
+
{ kind: c.kind, sessions: c.sessions || [], files: c.files || [], detail: c.detail || "", narrated: false });
|
|
483
|
+
if (DUTY_SESSION) hubSend(DUTY_SESSION, `⚠️ OVERSEER ${c.kind} [${c.project}]: ${c.detail || ""} — if the parties are not already coordinating, message them.`, c.project);
|
|
484
|
+
if (parties.length > 1) for (const me of parties) intro(c, me, parties);
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
kept.push(c);
|
|
378
488
|
// Episode identity is the CONDITION (project+kind+files), never the session list (#5350):
|
|
379
489
|
// membership is volatile — a third seat bouncing in and out of a standing collision minted a
|
|
380
490
|
// fresh key, so a fresh episode, so a fresh warn (+ duty wake + party intros) per permutation.
|
|
@@ -413,9 +523,14 @@ function overseerTick() {
|
|
|
413
523
|
// Episode end: a condition gone for the whole clear window is over, so a LATER recurrence is a
|
|
414
524
|
// new episode and warns again. Without this the map would grow forever and nothing could re-fire.
|
|
415
525
|
for (const [k, v] of overseerActive) {
|
|
416
|
-
if (!seen.has(k) && t - v.lastTick > OVERSEER_CLEAR_MS)
|
|
526
|
+
if (!seen.has(k) && t - v.lastTick > OVERSEER_CLEAR_MS) {
|
|
527
|
+
overseerActive.delete(k);
|
|
528
|
+
// #5760: the same-project verdict record dies WITH its episode — a set that returns after a
|
|
529
|
+
// genuine clear is a new episode (it warns again, first sighting), not the old one continuing.
|
|
530
|
+
if (k.endsWith(" same-project-sessions")) sameProjectFired.delete(k.slice(0, -" same-project-sessions".length));
|
|
531
|
+
}
|
|
417
532
|
}
|
|
418
|
-
overseerLastCollisions =
|
|
533
|
+
overseerLastCollisions = kept;
|
|
419
534
|
}
|
|
420
535
|
setInterval(overseerTick, OVERSEER_TICK_MS).unref?.();
|
|
421
536
|
// setInterval waits a FULL period before its first call, so for 30s after every restart the watcher
|
|
@@ -1173,6 +1288,22 @@ function markDelivered(session, upTo) {
|
|
|
1173
1288
|
function pushToStreams(msg) {
|
|
1174
1289
|
for (const s of streams) if (deliverable(msg, s.session)) { try { s.res.write(`data: ${JSON.stringify(msg)}\n\n`); } catch {} }
|
|
1175
1290
|
}
|
|
1291
|
+
// A live runner can outlast the durable snapshot it was polling. If its cursor is now beyond the
|
|
1292
|
+
// message high-water mark, echoing that impossible value leaves it deaf forever. Clamp it to the
|
|
1293
|
+
// current tip and say explicitly that time moved backwards so clients can adopt the lower cursor.
|
|
1294
|
+
function inboxWindow(value) {
|
|
1295
|
+
const parsed = Number(value || 0);
|
|
1296
|
+
const requested = Number.isFinite(parsed) ? Math.max(0, parsed) : 0;
|
|
1297
|
+
const tip = Math.max(Number(state.seq || 0), Number(state.messages[state.messages.length - 1]?.id || 0));
|
|
1298
|
+
const rewound = requested > tip;
|
|
1299
|
+
return { since: rewound ? tip : requested, tip, rewound };
|
|
1300
|
+
}
|
|
1301
|
+
function inboxResponse(auth, messages, cursor, rewound = false) {
|
|
1302
|
+
const response = { messages, cursor };
|
|
1303
|
+
if (rewound) response.rewound = true;
|
|
1304
|
+
if (auth?.superseded) response.superseded = true;
|
|
1305
|
+
return response;
|
|
1306
|
+
}
|
|
1176
1307
|
// Live push for the FEED. Sent ONLY to streams that opted in with /stream?events=1, and as a NAMED
|
|
1177
1308
|
// SSE event ("event: ev") so an existing consumer's default onmessage handler — which expects a bus
|
|
1178
1309
|
// message and nothing else — can never see it. Backwards-safe by construction.
|
|
@@ -1390,7 +1521,9 @@ const server = http.createServer(async (req, res) => {
|
|
|
1390
1521
|
// WHO is this, really: the LLM brand + the exact model currently loaded. In-memory like the
|
|
1391
1522
|
// rest of presence — the next heartbeat re-supplies it after a restart.
|
|
1392
1523
|
const pr = state.peers[b.session];
|
|
1393
|
-
|
|
1524
|
+
// #6148: WHAT a session is rides its peer row (kind "genesis" = the CLI's brief-poster,
|
|
1525
|
+
// "agent" = a crew seat) — /peers hands it to the app so the seat strip can tell them apart.
|
|
1526
|
+
if (pr) { if (b.model) pr.model = String(b.model).slice(0, 80); if (b.llm) pr.llm = String(b.llm).slice(0, 40); if (b.kind) pr.kind = String(b.kind).slice(0, 40); }
|
|
1394
1527
|
return json(res, 200, { ok: true, session: b.session, peers: Object.keys(state.peers) });
|
|
1395
1528
|
}
|
|
1396
1529
|
if (req.method === "POST" && P === "/status") { const b = await body(req); touch(b.session, b.status ?? "", b.project, b.hookVersion, auth); return json(res, 200, { ok: true }); }
|
|
@@ -1558,8 +1691,29 @@ const server = http.createServer(async (req, res) => {
|
|
|
1558
1691
|
const inflight = [...fileClaims.values()].filter(c => c.project === proj)
|
|
1559
1692
|
.map(c => ({ file: c.file, session: c.session, agoSec: Math.round((now() - c.ts) / 1000) }));
|
|
1560
1693
|
let warnings = [];
|
|
1561
|
-
try {
|
|
1562
|
-
|
|
1694
|
+
try {
|
|
1695
|
+
warnings = (_overseer?.detectCollisions ? _overseer.detectCollisions(overseerInputs()) : [])
|
|
1696
|
+
.filter(c => c.project === proj || linked.has(c.project));
|
|
1697
|
+
// #5760: a declared crew is the NORMAL state of a project, not a collision — the tick
|
|
1698
|
+
// loop drops crew-only sets before they ever become episodes, and this live view must
|
|
1699
|
+
// agree with it: the SessionStart hook narrates exactly these lines, so a crew-only leak
|
|
1700
|
+
// here would re-wake every booting seat into a metered turn for no membership change.
|
|
1701
|
+
warnings = warnings.filter(c => !(c.kind === "same-project-sessions" && _sameProject?.sameProjectDecision &&
|
|
1702
|
+
_sameProject.sameProjectDecision({
|
|
1703
|
+
current: c.sessions,
|
|
1704
|
+
declaredCrew: declaredCrewFor(c.project),
|
|
1705
|
+
now: now(),
|
|
1706
|
+
}).reason === "crew-only"));
|
|
1707
|
+
// The record line reports DURATION ("same-project for 6h"), never a count of warnings.
|
|
1708
|
+
for (const c of warnings) {
|
|
1709
|
+
if (c.kind !== "same-project-sessions") continue;
|
|
1710
|
+
const ep = overseerActive.get(`${c.project} same-project-sessions`);
|
|
1711
|
+
if (ep) {
|
|
1712
|
+
c.since = ep.since;
|
|
1713
|
+
if (_sameProject?.durationLabel) c.detail = `${c.detail || ""} (same-project for ${_sameProject.durationLabel(now() - ep.since)})`.trim();
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
} catch {}
|
|
1563
1717
|
return json(res, 200, { level, links: links.map(l => ({ projects: l.projects, reason: l.reason })), peers: peersOut, inflight, warnings });
|
|
1564
1718
|
}
|
|
1565
1719
|
// Supersession (docs/INSTANCE-KEYS-CONTRACT.md): EXPLICIT, never automatic — the baton-claim
|
|
@@ -1639,7 +1793,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1639
1793
|
const peerRows = filterDiscoverable(auth, Object.entries(state.peers), ([, v]) => v.project || "");
|
|
1640
1794
|
return json(res, 200, { hubVersion: HUB_VERSION, authMode: AUTH_MODE, peers: peerRows.map(([s, v]) => ({ session: s, lastSeen: v.lastSeen, online: v.lastSeen > cutoff, status: v.status || "", health: healthOf(v.status), project: v.project || "",
|
|
1641
1795
|
pubkey: v.pubkey || "", identity: v.identity || null, authWarning: v.authWarning || "",
|
|
1642
|
-
llm: v.llm || "", model: v.model || "", hookVersion: v.hookVersion || "", staleHooks: !!(v.lastSeen > cutoff && v.hookVersion && HUB_VERSION && cmpSemver(v.hookVersion, HUB_VERSION) < 0) })) });
|
|
1796
|
+
kind: v.kind || v.identity?.kind || "", llm: v.llm || "", model: v.model || "", hookVersion: v.hookVersion || "", staleHooks: !!(v.lastSeen > cutoff && v.hookVersion && HUB_VERSION && cmpSemver(v.hookVersion, HUB_VERSION) < 0) })) });
|
|
1643
1797
|
}
|
|
1644
1798
|
// --- Provider balances (prepaid credit) ---
|
|
1645
1799
|
// The hub runs under launchd with no provider keys, so it can't fetch balances itself. Env-having
|
|
@@ -1766,6 +1920,8 @@ const server = http.createServer(async (req, res) => {
|
|
|
1766
1920
|
// --- Kanban tasks ---
|
|
1767
1921
|
if (req.method === "POST" && P === "/task") { // create a card
|
|
1768
1922
|
const b = await body(req); touch(b.by, undefined, b.project, undefined, auth);
|
|
1923
|
+
if (b.title !== undefined) b.title = stripNulText(b.title);
|
|
1924
|
+
if (b.note !== undefined) b.note = stripNulText(b.note);
|
|
1769
1925
|
const st0 = ["todo","doing","testing","failed","done","blocked"].includes(b.status) ? b.status : "todo";
|
|
1770
1926
|
// optional historical ts (backfill from git/import) — accept a past epoch-ms; else now().
|
|
1771
1927
|
const ts0 = (Number.isFinite(b.ts) && b.ts > 0 && b.ts <= now() + 864e5) ? Math.floor(b.ts) : now();
|
|
@@ -1919,6 +2075,8 @@ const server = http.createServer(async (req, res) => {
|
|
|
1919
2075
|
if (req.method === "POST" && P === "/task/update") { // move/edit a card
|
|
1920
2076
|
const b = await body(req); const t = state.tasks.find(x => x.id === Number(b.id));
|
|
1921
2077
|
if (!t) return json(res, 404, { error: "no such task" });
|
|
2078
|
+
if (b.title !== undefined) b.title = stripNulText(b.title);
|
|
2079
|
+
if (b.note !== undefined) b.note = stripNulText(b.note);
|
|
1922
2080
|
// Board integrity (#5406): a card can never change hands silently. The assignee is frozen once
|
|
1923
2081
|
// set; a mutation is legitimate only as a HANDOFF (the current assignee reassigning to someone
|
|
1924
2082
|
// else) or an EXPLICIT reassign (reassign:true — e.g. the orchestrator re-routing work after a
|
|
@@ -2279,7 +2437,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
2279
2437
|
// --- lessons: cross-agent learning from failures. scope = "global" or an agent brand ("kimi") ---
|
|
2280
2438
|
if (req.method === "POST" && P === "/lesson") {
|
|
2281
2439
|
const b = await body(req);
|
|
2282
|
-
const text =
|
|
2440
|
+
const text = stripNulText(b.text).trim().slice(0, 400);
|
|
2283
2441
|
const scope = String(b.scope || "global").toLowerCase().slice(0, 40);
|
|
2284
2442
|
if (!text) return json(res, 400, { error: "text required" });
|
|
2285
2443
|
if (state.lessons.some(l => l.scope === scope && l.text === text)) return json(res, 200, { ok: true, dedup: true });
|
|
@@ -2614,7 +2772,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
2614
2772
|
}
|
|
2615
2773
|
if (req.method === "POST" && P === "/send") {
|
|
2616
2774
|
const b = await body(req);
|
|
2617
|
-
const text =
|
|
2775
|
+
const text = stripNulText(b.text);
|
|
2618
2776
|
if (!b.from || !text.trim()) return json(res, 400, { error: "from and non-empty text required" });
|
|
2619
2777
|
const secretCheck = assertNoSecrets(text);
|
|
2620
2778
|
if (!secretCheck.ok) return json(res, 400, { error: "secret detected", kinds: secretCheck.kinds || [] });
|
|
@@ -2693,7 +2851,8 @@ const server = http.createServer(async (req, res) => {
|
|
|
2693
2851
|
|
|
2694
2852
|
if (req.method === "GET" && P === "/inbox") {
|
|
2695
2853
|
if (!canUseInboxSession(auth, q.session)) return json(res, 403, { error: "forbidden" });
|
|
2696
|
-
touch(q.session, undefined, undefined, undefined, auth); const
|
|
2854
|
+
touch(q.session, undefined, undefined, undefined, auth); const window = inboxWindow(q.since);
|
|
2855
|
+
const { since, rewound } = window;
|
|
2697
2856
|
const msgs = state.messages.filter(m => m.id > since && deliverable(m, q.session) && inboxReadable(auth, m, q.session));
|
|
2698
2857
|
const cursor = msgs.length ? msgs[msgs.length - 1].id : since;
|
|
2699
2858
|
// peek=1 -> LOOK without claiming delivery. The Stop hook has to ask "is anything waiting?" before
|
|
@@ -2703,16 +2862,21 @@ const server = http.createServer(async (req, res) => {
|
|
|
2703
2862
|
if (q.peek !== "1") markDelivered(q.session, cursor);
|
|
2704
2863
|
// superseded (instance-keys contract): a baton twin that lost the claim learns it HERE, via
|
|
2705
2864
|
// its own read — its hooks turn this into a stand-down note for the model. Never a block.
|
|
2706
|
-
return json(res, 200, auth
|
|
2865
|
+
return json(res, 200, inboxResponse(auth, msgs, cursor, rewound));
|
|
2707
2866
|
}
|
|
2708
2867
|
if (req.method === "GET" && P === "/poll") {
|
|
2709
2868
|
if (!canUseInboxSession(auth, q.session)) return json(res, 403, { error: "forbidden" });
|
|
2710
|
-
touch(q.session, undefined, undefined, undefined, auth); const
|
|
2869
|
+
touch(q.session, undefined, undefined, undefined, auth); const window = inboxWindow(q.since);
|
|
2870
|
+
const { since, rewound } = window;
|
|
2871
|
+
if (rewound) {
|
|
2872
|
+
markDelivered(q.session, window.tip);
|
|
2873
|
+
return json(res, 200, inboxResponse(auth, [], window.tip, true));
|
|
2874
|
+
}
|
|
2711
2875
|
const waitMs = Math.min(Number(q.wait || 25), 290) * 1000; // allow long idle-park
|
|
2712
2876
|
const deadline = now() + waitMs;
|
|
2713
2877
|
const tick = () => {
|
|
2714
2878
|
const msgs = state.messages.filter(m => m.id > since && deliverable(m, q.session) && inboxReadable(auth, m, q.session));
|
|
2715
|
-
if (msgs.length || now() >= deadline) { touch(q.session, undefined, undefined, undefined, auth); const cursor = msgs.length ? msgs[msgs.length - 1].id : since; markDelivered(q.session, cursor); return json(res, 200, auth
|
|
2879
|
+
if (msgs.length || now() >= deadline) { touch(q.session, undefined, undefined, undefined, auth); const cursor = msgs.length ? msgs[msgs.length - 1].id : since; markDelivered(q.session, cursor); return json(res, 200, inboxResponse(auth, msgs, cursor)); }
|
|
2716
2880
|
setTimeout(tick, 300);
|
|
2717
2881
|
};
|
|
2718
2882
|
return tick();
|
|
@@ -2738,6 +2902,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
2738
2902
|
res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); return res.end(UI || "<h1>trantor</h1><p>dashboard unavailable</p>");
|
|
2739
2903
|
}
|
|
2740
2904
|
if (P === "/health") return json(res, 200, { ok: true, authMode: AUTH_MODE, peers: Object.keys(state.peers).length, messages: state.messages.length, streams: streams.length,
|
|
2905
|
+
persist: persistHealth.view(),
|
|
2741
2906
|
// #5686: duty liveness rides /health so the app's Home strip and doctor read one truth.
|
|
2742
2907
|
duty: { ...dutyLiveness(), darkSinceMs: dutyDarkSince ? now() - dutyDarkSince : 0, queuedEscalations: dutyQueuedEscalations() } });
|
|
2743
2908
|
json(res, 404, { error: "not found" });
|
package/lib/enroll.mjs
CHANGED
|
@@ -32,7 +32,7 @@ export function ownerIdentity() {
|
|
|
32
32
|
|
|
33
33
|
// Returns { ok, reason }. NEVER throws and never blocks a turn — a hub that is down, or an operator
|
|
34
34
|
// key that is absent, must degrade to "unenrolled" rather than take the seat with it.
|
|
35
|
-
export async function ensureEnrolled(hubUrl, identity, project, { timeoutMs = 4000 } = {}) {
|
|
35
|
+
export async function ensureEnrolled(hubUrl, identity, project, { timeoutMs = 4000, kind } = {}) {
|
|
36
36
|
if (!hubUrl || !identity?.pubkey) return { ok: false, reason: "no-identity" };
|
|
37
37
|
try {
|
|
38
38
|
// Cheapest possible probe that the hub authorises: if we are already known this is a no-op.
|
|
@@ -55,7 +55,7 @@ export async function ensureEnrolled(hubUrl, identity, project, { timeoutMs = 40
|
|
|
55
55
|
|
|
56
56
|
const en = await sfetch(`${hubUrl}/enroll`, {
|
|
57
57
|
method: "POST", headers: { "content-type": "application/json" },
|
|
58
|
-
body: JSON.stringify({ name: identity.name, kind: identity.kind || "agent", token }),
|
|
58
|
+
body: JSON.stringify({ name: identity.name, kind: kind || identity.kind || "agent", token }),
|
|
59
59
|
signal: AbortSignal.timeout(timeoutMs),
|
|
60
60
|
}, identity);
|
|
61
61
|
return en.ok ? { ok: true, reason: "enrolled" } : { ok: false, reason: `enroll-${en.status}` };
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Persistence is a standing health condition, not a stream of retry errors. This tracker keeps the
|
|
2
|
+
// retry schedule and the operator-facing state together so they cannot disagree.
|
|
3
|
+
export const PERSIST_RETRY_BASE_MS = 1000;
|
|
4
|
+
export const PERSIST_RETRY_MAX_MS = 60_000;
|
|
5
|
+
export const PERSIST_LOG_INTERVAL_MS = 60_000;
|
|
6
|
+
|
|
7
|
+
const positive = (value, fallback) => {
|
|
8
|
+
const n = Number(value);
|
|
9
|
+
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export function createPersistHealth(options = {}) {
|
|
13
|
+
const baseMs = positive(options.baseMs, PERSIST_RETRY_BASE_MS);
|
|
14
|
+
const maxMs = Math.max(baseMs, positive(options.maxMs, PERSIST_RETRY_MAX_MS));
|
|
15
|
+
const logIntervalMs = positive(options.logIntervalMs, PERSIST_LOG_INTERVAL_MS);
|
|
16
|
+
let failedAt = 0;
|
|
17
|
+
let lastError = "";
|
|
18
|
+
let retries = 0;
|
|
19
|
+
let retryMs = baseMs;
|
|
20
|
+
let nextAttemptAt = 0;
|
|
21
|
+
let lastLogAt = -Infinity;
|
|
22
|
+
|
|
23
|
+
const view = (at = Date.now()) => ({
|
|
24
|
+
ok: retries === 0,
|
|
25
|
+
failingSinceMs: retries === 0 ? 0 : Math.max(0, Number(at) - failedAt),
|
|
26
|
+
lastError,
|
|
27
|
+
retries,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
canAttempt(at = Date.now()) {
|
|
32
|
+
return retries === 0 || Number(at) >= nextAttemptAt;
|
|
33
|
+
},
|
|
34
|
+
failed(error, at = Date.now()) {
|
|
35
|
+
const when = Number(at);
|
|
36
|
+
if (retries === 0) failedAt = when;
|
|
37
|
+
retries += 1;
|
|
38
|
+
lastError = String(error?.message || error || "persist failed").replace(/\u0000/g, "").replace(/\s+/g, " ").slice(0, 500);
|
|
39
|
+
const delayMs = retryMs;
|
|
40
|
+
nextAttemptAt = when + delayMs;
|
|
41
|
+
retryMs = Math.min(maxMs, retryMs * 2);
|
|
42
|
+
const shouldLog = when - lastLogAt >= logIntervalMs;
|
|
43
|
+
if (shouldLog) lastLogAt = when;
|
|
44
|
+
return { delayMs, shouldLog, health: view(when) };
|
|
45
|
+
},
|
|
46
|
+
succeeded() {
|
|
47
|
+
failedAt = 0;
|
|
48
|
+
lastError = "";
|
|
49
|
+
retries = 0;
|
|
50
|
+
retryMs = baseMs;
|
|
51
|
+
nextAttemptAt = 0;
|
|
52
|
+
lastLogAt = -Infinity;
|
|
53
|
+
},
|
|
54
|
+
view,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// #5760 — the same-project OVERSEER warning is an EPISODE, not a timer. The night of 08-31 the
|
|
2
|
+
// "🤝 OVERSEER same-project-sessions" DM re-fired hourly and woke every seat into metered chatter
|
|
3
|
+
// turns for a membership that never changed. The monitoring doctrine this project holds everyone
|
|
4
|
+
// else to — report duration, not repetition — applied to its own emitter:
|
|
5
|
+
//
|
|
6
|
+
// 1. The warning fires ONCE when the membership set CHANGES — a session joins or leaves —
|
|
7
|
+
// keyed by a stable hash of the member set. Never on a clock.
|
|
8
|
+
// 2. An unchanged set re-warns never. When a change does fire, the record line reports
|
|
9
|
+
// DURATION ("same-project for 6h"), not repetition.
|
|
10
|
+
// 3. An operator-declared crew (the seats `trantor up` spawned plus the orchestrator) is the
|
|
11
|
+
// NORMAL state of a project, not a collision: only sessions OUTSIDE the declared crew
|
|
12
|
+
// trigger the warning at all.
|
|
13
|
+
//
|
|
14
|
+
// Pure module: no I/O, no imports. The hub owns persistence (the previously-fired set and when)
|
|
15
|
+
// and the crew declaration; this file only decides.
|
|
16
|
+
const clean = (v) => String(v ?? "").trim();
|
|
17
|
+
|
|
18
|
+
// The member SET: sorted, deduped — so set comparison and hashing never see order or repeats.
|
|
19
|
+
export function memberSet(sessions) {
|
|
20
|
+
return [...new Set((sessions ?? []).map(clean).filter(Boolean))].sort((a, b) => a.localeCompare(b));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// A stable hash of the member set, so "did the membership change" is one comparison that
|
|
24
|
+
// survives process restarts and set reordering. FNV-1a over the joined members: deterministic
|
|
25
|
+
// everywhere, no dependencies.
|
|
26
|
+
export function memberSetHash(sessions) {
|
|
27
|
+
let h = 0x811c9dc5;
|
|
28
|
+
for (const ch of memberSet(sessions).join("\u0000")) {
|
|
29
|
+
h ^= ch.codePointAt(0);
|
|
30
|
+
h = Math.imul(h, 0x01000193) >>> 0;
|
|
31
|
+
}
|
|
32
|
+
return h.toString(16);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// "6h" / "12m" / "<1m" — the record line states how long the episode has held, never a count
|
|
36
|
+
// of warnings. An unchanged state is not news; its DURATION is.
|
|
37
|
+
export function durationLabel(ms) {
|
|
38
|
+
const m = Math.floor(Math.max(0, Number(ms) || 0) / 60000);
|
|
39
|
+
if (m < 1) return "<1m";
|
|
40
|
+
if (m < 60) return `${m}m`;
|
|
41
|
+
return `${Math.floor(m / 60)}h`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Fire-or-not. previous = the member set as of the LAST fire (null = never fired); current = the
|
|
45
|
+
// live sessions on the project now; declaredCrew = the operator's crew (seats + orchestrator);
|
|
46
|
+
// lastFiredAt = when the last fire happened (null = never). The clock inputs exist ONLY to state
|
|
47
|
+
// duration — there is no elapsed-time threshold anywhere: a change fires, a hold never does.
|
|
48
|
+
export function sameProjectDecision({ previous = null, current = [], declaredCrew = [], lastFiredAt = null, now = 0 } = {}) {
|
|
49
|
+
const cur = memberSet(current);
|
|
50
|
+
const crew = new Set(memberSet(declaredCrew));
|
|
51
|
+
const intruders = cur.filter((s) => !crew.has(s));
|
|
52
|
+
if (intruders.length === 0) return { fire: false, reason: "crew-only", intruders: [], durationMs: 0 };
|
|
53
|
+
if (previous == null || lastFiredAt == null) {
|
|
54
|
+
return { fire: true, reason: "first-sighting", intruders, durationMs: 0 };
|
|
55
|
+
}
|
|
56
|
+
if (memberSetHash(previous) === memberSetHash(cur)) {
|
|
57
|
+
return { fire: false, reason: "unchanged", intruders, durationMs: 0 };
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
fire: true,
|
|
61
|
+
reason: "membership-changed",
|
|
62
|
+
intruders,
|
|
63
|
+
durationMs: Math.max(0, (Number(now) || 0) - (Number(lastFiredAt) || 0)),
|
|
64
|
+
};
|
|
65
|
+
}
|
package/lib/store-pg.mjs
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
// trantor Phase 1 Postgres store.
|
|
2
2
|
// Implements the frozen STORE_API from ./store-contract.mjs. The hub still keeps its existing
|
|
3
3
|
// in-memory projection while running; this store is the durable backing when RELAY_STORE=pg.
|
|
4
|
+
// SAFETY: This module is the Postgres I/O decoder/encoder boundary. Its runtime `typeof` checks
|
|
5
|
+
// validate untrusted jsonb rows and caller-supplied JSON before those values enter the hub domain.
|
|
6
|
+
/* oxlint-disable anti-slop/no-runtime-typeof */
|
|
4
7
|
import { SCHEMA_SQL, SCHEMA_VERSION, KV_KEYS, DEFAULT_ORG, CHANGE_CHANNEL } from "./store-contract.mjs";
|
|
5
8
|
|
|
6
9
|
const asJson = (v, fallback) => (v === undefined ? fallback : v);
|
|
@@ -10,9 +13,21 @@ const ms = (v, fallback = Date.now()) => {
|
|
|
10
13
|
};
|
|
11
14
|
const num = (v) => (v == null ? v : Number(v));
|
|
12
15
|
|
|
16
|
+
// PostgreSQL rejects U+0000 in both text and jsonb. Sanitize the complete row projection here,
|
|
17
|
+
// below every caller, so an old client or an imported nested payload cannot poison the delta.
|
|
18
|
+
export function stripNulDeep(value) {
|
|
19
|
+
if (typeof value === "string") return value.replace(/\u0000/g, "");
|
|
20
|
+
if (Array.isArray(value)) return value.map(stripNulDeep);
|
|
21
|
+
if (value && typeof value === "object") {
|
|
22
|
+
return Object.fromEntries(Object.entries(value).map(([key, child]) => [key.replace(/\u0000/g, ""), stripNulDeep(child)]));
|
|
23
|
+
}
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
26
|
+
|
|
13
27
|
const EVENT_COLUMN_KEYS = ["id", "ts", "type", "project", "by", "by_session", "taskId", "task_id", "payload"];
|
|
14
28
|
function stripEventPayload(evt = {}) {
|
|
15
|
-
const payload = {
|
|
29
|
+
const payload = {};
|
|
30
|
+
if (evt.payload && typeof evt.payload === "object") Object.assign(payload, evt.payload);
|
|
16
31
|
// The nested payload gets the same treatment as the top level. It used to be copied in wholesale,
|
|
17
32
|
// so a column key riding inside it (an `id` from an imported event) was stored and then clobbered
|
|
18
33
|
// the real column on the way back out.
|
|
@@ -512,7 +527,10 @@ export class PgStore {
|
|
|
512
527
|
// Emits NOTIFY on CHANGE_CHANNEL inside the transaction (delivered on commit) so other hubs
|
|
513
528
|
// reload; `src` lets a hub ignore its own notifications.
|
|
514
529
|
async saveDelta(orgId, prev, next, { src = "" } = {}) {
|
|
515
|
-
|
|
530
|
+
orgId = String(stripNulDeep(orgId));
|
|
531
|
+
src = String(stripNulDeep(src));
|
|
532
|
+
prev = stripNulDeep(prev || {});
|
|
533
|
+
next = stripNulDeep(next || {});
|
|
516
534
|
const tasks = diffById(prev.tasks, next.tasks, t => Number(t.id));
|
|
517
535
|
const events = diffById(prev.events, next.events, e => Number(e.id));
|
|
518
536
|
const messages = diffById(prev.messages, next.messages, m => Number(m.id));
|
package/mcp.mjs
CHANGED
|
@@ -68,10 +68,12 @@ const PROJECT = resolveProject(process.env.CLAUDE_PROJECT_DIR || process.cwd());
|
|
|
68
68
|
// global `url` → local default. A project lives on exactly one hub; codependent projects
|
|
69
69
|
// must share one, so both are pinned to the same hub via `trantor hub set`.
|
|
70
70
|
const URL_BASE = resolveHub(PROJECT); // boot-time snapshot: startup log only — every api() call re-resolves
|
|
71
|
-
// Identity: RELAY_SESSION wins
|
|
72
|
-
//
|
|
71
|
+
// Identity: the runner's exact RELAY_SESSION wins, then its RELAY_AGENT. A multi-seat host such as
|
|
72
|
+
// OpenCode contributes only RELAY_AGENT_FALLBACK in its global MCP config, so qwen/glm/deepseek do
|
|
73
|
+
// not get rebranded "opencode" when that config is overlaid on the runner environment.
|
|
74
|
+
const SESSION_AGENT = process.env.RELAY_AGENT || process.env.RELAY_AGENT_FALLBACK;
|
|
73
75
|
const SESSION = process.env.RELAY_SESSION
|
|
74
|
-
|| (
|
|
76
|
+
|| (SESSION_AGENT ? `${SESSION_AGENT}:${PROJECT}` : `${hostId()}:${PROJECT}`);
|
|
75
77
|
let cursor = 0;
|
|
76
78
|
// First-call guard: a brand-new MCP process must NOT replay the entire historical backlog (observed:
|
|
77
79
|
// 2,379 msgs / 520KB back to an old asteroids project) the instant relay_inbox/relay_wait is called.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.33",
|
|
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-autonomy.mjs && node test-integrate.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-usage-live.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-checklist.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-dark.mjs && node test-duty-seat.mjs && node test-discovery.mjs && node test-doctor.mjs && node test-crew-env.mjs && node test-desktop-transport.mjs && node test-crew-worktree.mjs && node test-crew-redact.mjs && node test-crew-classify.mjs && bash test-crew-herdr.sh && npm --prefix desktop run test --silent && 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-provider-cli.mjs && node test-crew-model-defaults.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-new.mjs && 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-autonomy.mjs && node test-integrate.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-usage-live.mjs && node test-subagent-cost.mjs && node test-inbox.mjs && node test-cursor-rewind.mjs && node test-inflight.mjs && node test-notify.mjs && node test-focus.mjs && node test-cardlog.mjs && node test-checklist.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-persist-safety.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-dark.mjs && node test-duty-seat.mjs && node test-discovery.mjs && node test-doctor.mjs && node test-crew-env.mjs && node test-desktop-transport.mjs && node test-crew-worktree.mjs && node test-crew-redact.mjs && node test-crew-classify.mjs && bash test-crew-herdr.sh && npm --prefix desktop run test --silent && 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-provider-cli.mjs && node test-crew-model-defaults.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-new.mjs && 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 — 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": [
|
package/skills/handoff/SKILL.md
CHANGED
|
@@ -20,8 +20,19 @@ only to plain Terminal sessions.
|
|
|
20
20
|
|
|
21
21
|
## Instructions
|
|
22
22
|
|
|
23
|
+
PREFER THE GLOBAL `trantor` BINARY when it is on PATH (`command -v trantor`): it runs the
|
|
24
|
+
INSTALLED version's code, while `${CLAUDE_PLUGIN_ROOT}` is the plugin-cache copy pinned when this
|
|
25
|
+
session booted — a session opened before an update runs stale logic (witnessed 2026-09-02: a
|
|
26
|
+
0.18.20 cache copy mis-resolved the project from a subfolder cwd and closed a Terminal window that
|
|
27
|
+
was not its own). Use `trantor handoff …` for both commands below; fall back to the
|
|
28
|
+
`node "${CLAUDE_PLUGIN_ROOT}/bin/write-handoff.mjs" …` form only when `trantor` is absent.
|
|
29
|
+
`trantor handoff` takes piped markdown (a heredoc) and `--latest` exactly like the helper, because
|
|
30
|
+
it forwards to the same package's `write-handoff.mjs`.
|
|
31
|
+
|
|
23
32
|
0. **Already written one this session?** Then do NOT write it again — pass the baton on it:
|
|
24
33
|
```bash
|
|
34
|
+
trantor handoff --latest
|
|
35
|
+
# or, without the global binary:
|
|
25
36
|
node "${CLAUDE_PLUGIN_ROOT}/bin/write-handoff.mjs" --baton --latest
|
|
26
37
|
```
|
|
27
38
|
That picks this project's newest unconsumed handoff and hands it over untouched, and exits
|
|
@@ -39,10 +50,12 @@ only to plain Terminal sessions.
|
|
|
39
50
|
|
|
40
51
|
2. Save it AND pass the baton in one shot — pipe the markdown to the helper with `--baton`:
|
|
41
52
|
```bash
|
|
42
|
-
|
|
53
|
+
trantor handoff --baton << 'HANDOFF'
|
|
43
54
|
<your handoff markdown>
|
|
44
55
|
HANDOFF
|
|
45
56
|
```
|
|
57
|
+
(or `cat << 'HANDOFF' | node "${CLAUDE_PLUGIN_ROOT}/bin/write-handoff.mjs" --baton` without the
|
|
58
|
+
global binary.)
|
|
46
59
|
`--baton` writes the handoff, opens a FRESH session that takes over (it auto-recaps the handoff
|
|
47
60
|
on open), and closes THIS Terminal window once the fresh session has consumed it — a true baton
|
|
48
61
|
pass, one session at a time. (Omit `--baton` to only write the handoff without spawning/closing.)
|