trantor 0.18.32 → 0.18.34
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/README.md +9 -3
- package/bin/baton.mjs +53 -25
- package/bin/cli.mjs +3 -1
- package/bin/connect.mjs +11 -1
- package/bin/crew-runner.mjs +21 -9
- package/bin/genesis-kickoff.mjs +92 -0
- package/bin/new.mjs +25 -9
- 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
- package/skills/prd-review/SKILL.md +159 -0
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}` };
|