trantor 0.18.82 → 0.18.86
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/cli.mjs +8 -0
- package/bin/crew/cmux.mjs +11 -11
- package/bin/crew/core.mjs +3 -2
- package/bin/crew/herdr.mjs +8 -8
- package/bin/crew/state.mjs +30 -15
- package/bin/crew/swap.mjs +12 -12
- package/bin/crew/tmux.mjs +11 -11
- package/bin/crew-runner.mjs +176 -63
- package/bin/crew.mjs +22 -10
- package/bin/cut-snapshot.mjs +37 -0
- package/bin/gate.mjs +30 -0
- package/bin/keys.mjs +53 -0
- package/bin/turn-watchdog.mjs +14 -9
- package/bin/wake-nudge.mjs +21 -2
- package/hooks/file-claim.mjs +19 -7
- package/hooks/file-hold.mjs +49 -0
- package/hooks/heartbeat.mjs +2 -1
- package/hooks/hooks.json +10 -1
- package/hooks/lib/git-checkout.mjs +10 -0
- package/hooks/lib/overseer-level-cache.mjs +25 -0
- package/hooks/overseer-warn.mjs +1 -1
- package/hub/auth.mjs +7 -52
- package/hub/overseer.mjs +100 -34
- package/hub/routes/admin.mjs +58 -12
- package/hub/store.mjs +3 -2
- package/hub.mjs +1 -1
- package/lib/duty-nudges.mjs +12 -0
- package/lib/identity.mjs +181 -8
- package/lib/nudge-ledger.mjs +95 -0
- package/lib/overseer.mjs +82 -24
- package/lib/persona.mjs +43 -0
- package/lib/remote-overlap.mjs +193 -0
- package/lib/seat-why.mjs +6 -1
- package/lib/session-liveness.mjs +83 -0
- package/lib/state/gate.mjs +1 -1
- package/lib/store-pg.mjs +2 -0
- package/package.json +1 -1
package/bin/crew.mjs
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { seatProcesses } from "./crew/processes.mjs";
|
|
3
|
+
import { allocatePersona } from "../lib/persona.mjs";
|
|
2
4
|
import { join } from "node:path";
|
|
3
5
|
import { call, createContext } from "./crew/core.mjs";
|
|
4
6
|
import { createCmuxAdapter, spawnCmux } from "./crew/cmux.mjs";
|
|
@@ -7,7 +9,7 @@ import { resolveSpec, reportSkipped } from "./crew/models.mjs";
|
|
|
7
9
|
import { openOrchestrator } from "./crew/open.mjs";
|
|
8
10
|
import { performSwap, swapOptions, swapRuntime } from "./crew/swap.mjs";
|
|
9
11
|
import { redactKeys } from "../lib/redact.mjs";
|
|
10
|
-
import { reapSeat, down, prune } from "./crew/state.mjs";
|
|
12
|
+
import { reapSeat, down, prune, readRows } from "./crew/state.mjs";
|
|
11
13
|
import { spawnTerminal, spawnTmux } from "./crew/tmux.mjs";
|
|
12
14
|
import { epochMs, failedAgents, verifyCrew } from "./crew/verify.mjs";
|
|
13
15
|
import { guardCrossProjectUp } from "./crew/worktrees.mjs";
|
|
@@ -46,17 +48,16 @@ function parseUpArgs(args) {
|
|
|
46
48
|
const arg = args[index];
|
|
47
49
|
if (arg === "--task") result.task = args[++index] || "code";
|
|
48
50
|
else if (arg === "--difficulty" || arg === "--diff") result.difficulty = args[++index] || "medium";
|
|
49
|
-
else
|
|
51
|
+
else if (arg === "--as") {
|
|
52
|
+
result.persona = args[++index];
|
|
53
|
+
if (!result.persona) throw new Error("--as requires a persona name");
|
|
54
|
+
} else result.specs.push(arg);
|
|
50
55
|
}
|
|
51
56
|
return result;
|
|
52
57
|
}
|
|
53
58
|
|
|
54
59
|
function spawnCrew(options, skipped) {
|
|
55
|
-
const resolve =
|
|
56
|
-
const seat = resolveSpec(ctx, spec, options.task, options.difficulty, skipped);
|
|
57
|
-
if (seat) reapSeat(ctx, seat.agent);
|
|
58
|
-
return seat;
|
|
59
|
-
};
|
|
60
|
+
const resolve = seat => { reapSeat(ctx, seat.persona); return seat; };
|
|
60
61
|
const pruneNow = () => prune(ctx, adapters);
|
|
61
62
|
if (ctx.mux === "herdr") spawnHerdr(ctx, options.specs, resolve, pruneNow);
|
|
62
63
|
else if (ctx.mux === "cmux") spawnCmux(ctx, options.specs, resolve, pruneNow);
|
|
@@ -73,6 +74,7 @@ async function runUp(args, verify = true) {
|
|
|
73
74
|
const options = parseUpArgs(args);
|
|
74
75
|
const requested = resolveAgentLaunchSpecs(options.specs, readConfig());
|
|
75
76
|
options.specs = requested.specs;
|
|
77
|
+
if (options.persona && options.specs.length !== 1) throw new Error("--as requires exactly one agent");
|
|
76
78
|
resultSeat = options.specs.map(spec => spec.split(":")[0]).join(",");
|
|
77
79
|
if (!options.specs.length) {
|
|
78
80
|
console.log("usage: crew.mjs up [--task K --difficulty D] codex glm kimi deepseek (or set a default in Settings → Agents)");
|
|
@@ -87,8 +89,18 @@ async function runUp(args, verify = true) {
|
|
|
87
89
|
console.log(`[crew] hub for ${ctx.project}: ${ctx.hub} (baked into every seat; CREW_HUB=<url> overrides)`);
|
|
88
90
|
console.log(`— bringing up crew for ${ctx.project} (${ctx.mux === "terminal" ? "Terminal windows" : ctx.mux}) —`);
|
|
89
91
|
// #7760: build once in the first seat's worktree before any seat gets a contract.
|
|
90
|
-
if (!ctx.dry) await preflightFirstSeat(ctx, options.specs[0].split(":")[0]);
|
|
91
92
|
const skipped = [];
|
|
93
|
+
const occupied = readRows(ctx).filter(row => row.project === ctx.project).map(row => row.persona);
|
|
94
|
+
options.specs = options.specs.map(spec => {
|
|
95
|
+
const resolved = resolveSpec(ctx, spec, options.task, options.difficulty, skipped);
|
|
96
|
+
if (!resolved) return null;
|
|
97
|
+
if (seatProcesses(ctx, ctx.project, resolved.agent).length && !occupied.includes(resolved.agent)) occupied.push(resolved.agent);
|
|
98
|
+
const seat = allocatePersona({ dir: join(ctx.home, ".agent-bus"), project: ctx.project, ...resolved, name: options.persona, occupied, dry: ctx.dry });
|
|
99
|
+
occupied.push(seat.persona);
|
|
100
|
+
return { ...resolved, ...seat };
|
|
101
|
+
}).filter(Boolean);
|
|
102
|
+
resultSeat = options.specs.map(seat => seat.persona).join(",");
|
|
103
|
+
if (!ctx.dry && options.specs.length) await preflightFirstSeat(ctx, options.specs[0].persona);
|
|
92
104
|
const started = epochMs();
|
|
93
105
|
spawnCrew(options, skipped);
|
|
94
106
|
if (ctx.dry || !verify) {
|
|
@@ -96,9 +108,9 @@ async function runUp(args, verify = true) {
|
|
|
96
108
|
return reportSkipped(skipped);
|
|
97
109
|
}
|
|
98
110
|
console.log("— verifying on the bus (the spawn is not the truth; the bus is) —");
|
|
99
|
-
let failed = failedAgents(verifyCrew(ctx, options.specs.map(
|
|
111
|
+
let failed = failedAgents(verifyCrew(ctx, options.specs.map(seat => seat.persona), started));
|
|
100
112
|
if (failed.length) {
|
|
101
|
-
const retry = options.specs.filter(
|
|
113
|
+
const retry = options.specs.filter(seat => failed.includes(seat.persona));
|
|
102
114
|
console.log(`— retrying failed spawns: ${retry.join(" ")} —`);
|
|
103
115
|
options.specs = retry;
|
|
104
116
|
spawnCrew(options, skipped);
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The shell calls this before every destructive turn sweep (#10497).
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { realpathSync } from "node:fs";
|
|
5
|
+
import { pathToFileURL } from "node:url";
|
|
6
|
+
|
|
7
|
+
export function snapshotBeforeCut(workDir, reason = "stall") {
|
|
8
|
+
const git = args => {
|
|
9
|
+
const r = spawnSync("git", args, { cwd: workDir, encoding: "utf8", timeout: 20000 });
|
|
10
|
+
if (r.status !== 0) throw new Error(String(r.stderr || r.error || "git failed").trim());
|
|
11
|
+
return r.stdout;
|
|
12
|
+
};
|
|
13
|
+
try {
|
|
14
|
+
// Never capture a parent checkout when a CLI was launched in a nested scratch directory.
|
|
15
|
+
const top = git(["rev-parse", "--show-toplevel"]).trim();
|
|
16
|
+
if (realpathSync(top) !== realpathSync(workDir)) return { ok: true, files: 0 };
|
|
17
|
+
const branch = git(["symbolic-ref", "--short", "HEAD"]).trim();
|
|
18
|
+
const status = git(["status", "--porcelain=v1", "-z", "--untracked-files=all"]);
|
|
19
|
+
if (!status) return { ok: true, files: 0 };
|
|
20
|
+
if (!branch.startsWith("seat/")) return { ok: false, error: `dirty branch ${branch} is not a seat branch` };
|
|
21
|
+
let files = 0;
|
|
22
|
+
const rows = status.split("\0");
|
|
23
|
+
for (let i = 0; i < rows.length; i++) {
|
|
24
|
+
if (!rows[i]) continue;
|
|
25
|
+
files++;
|
|
26
|
+
if (/^[RC]|^.[RC]/.test(rows[i])) i++;
|
|
27
|
+
}
|
|
28
|
+
git(["add", "-A"]);
|
|
29
|
+
git(["-c", "core.hooksPath=/dev/null", "-c", "commit.gpgsign=false", "commit", "-m", `wip: cut at ${reason}, ${files} files`]);
|
|
30
|
+
return { ok: true, files, sha: git(["rev-parse", "HEAD"]).trim() };
|
|
31
|
+
} catch (e) { return { ok: false, error: e.message }; }
|
|
32
|
+
}
|
|
33
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
34
|
+
const result = snapshotBeforeCut(process.argv[2], process.argv[3]);
|
|
35
|
+
if (!result.ok) console.error(`cut snapshot failed: ${result.error}`);
|
|
36
|
+
process.exit(result.ok ? 0 : 1);
|
|
37
|
+
}
|
package/bin/gate.mjs
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { loadOrCreate } from "../lib/identity.mjs";
|
|
6
|
+
import { sfetchJson } from "../lib/signed-fetch.mjs";
|
|
7
|
+
import { resolveProject } from "../lib/project.mjs";
|
|
8
|
+
import { relayUrl } from "../hooks/lib/api.mjs";
|
|
9
|
+
|
|
10
|
+
const [status, rawId] = process.argv.slice(2);
|
|
11
|
+
const id = Number(rawId);
|
|
12
|
+
if (!["go", "nogo"].includes(status) || !Number.isInteger(id) || id <= 0) {
|
|
13
|
+
console.error("usage: trantor gate go|nogo <id>");
|
|
14
|
+
process.exit(1);
|
|
15
|
+
}
|
|
16
|
+
let config = {};
|
|
17
|
+
try { config = JSON.parse(readFileSync(join(process.env.AGENT_BUS_DIR || join(homedir(), ".agent-bus"), "config.json"), "utf8")); } catch {}
|
|
18
|
+
const identity = loadOrCreate(config.ownerIdentity || "admin", "human");
|
|
19
|
+
try {
|
|
20
|
+
const project = resolveProject(process.cwd());
|
|
21
|
+
const r = await sfetchJson(`${relayUrl(project)}/hold/decide`, {
|
|
22
|
+
method: "POST", identity, payload: { id, status, project }, signal: AbortSignal.timeout(8000),
|
|
23
|
+
});
|
|
24
|
+
const result = await r.json();
|
|
25
|
+
if (!r.ok) throw new Error(result.error || `HTTP ${r.status}`);
|
|
26
|
+
console.log(`#${id}: ${status} — ${result.hold.file}`);
|
|
27
|
+
} catch (error) {
|
|
28
|
+
console.error(error.message);
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
package/bin/keys.mjs
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// trantor keys — identity private keys in the macOS Keychain instead of plaintext files (#11370).
|
|
3
|
+
// status [--json] · migrate [--dry-run] [--json]
|
|
4
|
+
// migrate copies each file-held key into the Keychain (service trantor-identity), proves a
|
|
5
|
+
// signature round-trip, and only then rewrites the file as a pubkey-only stub. Keys are never printed.
|
|
6
|
+
import { migrateKeysToKeychain, keyLocationReport, IDENTITY_SERVICE } from "../lib/identity.mjs";
|
|
7
|
+
|
|
8
|
+
const args = process.argv.slice(2);
|
|
9
|
+
const verb = args[0] || "status";
|
|
10
|
+
const JSON_MODE = args.includes("--json");
|
|
11
|
+
const C = { dim: "\x1b[2m", grn: "\x1b[32m", yel: "\x1b[33m", red: "\x1b[31m", off: "\x1b[0m" };
|
|
12
|
+
const usage = () => {
|
|
13
|
+
console.error("usage: trantor keys status [--json] | migrate [--dry-run] [--json]");
|
|
14
|
+
process.exit(1);
|
|
15
|
+
};
|
|
16
|
+
const tilde = (p) => String(p).replace(process.env.HOME || "", "~");
|
|
17
|
+
const shortPub = (p) => (p ? `${p.slice(0, 16)}…` : "(unreadable)");
|
|
18
|
+
|
|
19
|
+
function status() {
|
|
20
|
+
const rows = keyLocationReport();
|
|
21
|
+
if (JSON_MODE) { console.log(JSON.stringify({ service: IDENTITY_SERVICE, rows }, null, 2)); return; }
|
|
22
|
+
if (!rows.length) { console.log(`${C.dim}no identities yet (~/.agent-bus/keys is empty)${C.off}`); return; }
|
|
23
|
+
console.log(`identity keys (keychain service: ${IDENTITY_SERVICE}):`);
|
|
24
|
+
for (const r of rows) {
|
|
25
|
+
const mark = r.where === "keychain" ? `${C.grn}✓${C.off}` : r.fileKey ? `${C.yel}•${C.off}` : `${C.red}✗${C.off}`;
|
|
26
|
+
console.log(` ${mark} ${r.name.padEnd(28)} ${shortPub(r.pubkey).padEnd(19)} ${r.where}`);
|
|
27
|
+
}
|
|
28
|
+
const plaintext = rows.filter((r) => r.fileKey);
|
|
29
|
+
if (plaintext.length) console.log(`\n${plaintext.length} key(s) still in plaintext files → trantor keys migrate`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function migrate() {
|
|
33
|
+
const dryRun = args.includes("--dry-run");
|
|
34
|
+
const r = migrateKeysToKeychain({ dryRun });
|
|
35
|
+
if (JSON_MODE) { console.log(JSON.stringify(r, null, 2)); return; }
|
|
36
|
+
if (!r.usable) {
|
|
37
|
+
console.log("no Keychain on this platform (or TRANTOR_NO_KEYCHAIN=1) — identity keys stay in files; nothing to do");
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const did = dryRun ? "would move" : "moved";
|
|
41
|
+
console.log(`${dryRun ? `${C.dim}dry run${C.off} · ` : ""}keychain service: ${r.service}`);
|
|
42
|
+
for (const n of r.moved) console.log(` ${C.grn}✓${C.off} ${did} ${n}${dryRun ? "" : " (file is now a pubkey-only stub)"}`);
|
|
43
|
+
for (const n of r.already) console.log(` ${C.dim}already in the keychain: ${n}${C.off}`);
|
|
44
|
+
for (const f of r.failed) console.log(` ${C.red}✗${C.off} ${f.name}: ${f.reason} — the file was left untouched`);
|
|
45
|
+
if (!r.moved.length && !r.failed.length && !r.already.length) console.log(` ${C.dim}nothing to move${C.off}`);
|
|
46
|
+
if (r.failed.length) process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
switch (verb) {
|
|
50
|
+
case "status": status(); break;
|
|
51
|
+
case "migrate": migrate(); break;
|
|
52
|
+
default: usage();
|
|
53
|
+
}
|
package/bin/turn-watchdog.mjs
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// node bin/turn-watchdog.mjs <stampFile> <errFile> <windowMs> <session> <project> <hubUrl> <transcriptDir> <workDir> [stallFile] [turnStateFile]
|
|
6
6
|
import { readFileSync, writeFileSync, appendFileSync, statSync, readdirSync } from "node:fs";
|
|
7
7
|
import { join } from "node:path";
|
|
8
|
+
import { sessionActivityReader } from "../lib/session-liveness.mjs";
|
|
8
9
|
import { hostId } from "../lib/project.mjs";
|
|
9
10
|
import { signedPost } from "../hooks/lib/api.mjs";
|
|
10
11
|
import { refreshTurnLiveness } from "../lib/turnstate.mjs";
|
|
@@ -41,6 +42,7 @@ function newestMtime(dir) {
|
|
|
41
42
|
|
|
42
43
|
const armed = readStamp();
|
|
43
44
|
if (!armed) process.exit(0);
|
|
45
|
+
const sessionActivity = armed.activity ? sessionActivityReader(armed.activity) : () => 0;
|
|
44
46
|
// The runner instance that armed us (pid + boot ts, so a recycled pid cannot impersonate it).
|
|
45
47
|
// A stamp without a runner id (only possible mid-upgrade) skips the liveness check; the id
|
|
46
48
|
// match in the loop still guards it.
|
|
@@ -56,6 +58,7 @@ const describeLast = (b) => {
|
|
|
56
58
|
const parts = [];
|
|
57
59
|
if (b.wk) parts.push(`worktree ${ago(b.wk)}`);
|
|
58
60
|
if (b.tr) parts.push(`transcript ${ago(b.tr)}`);
|
|
61
|
+
if (b.cli) parts.push(`CLI session ${ago(b.cli)}`);
|
|
59
62
|
return parts.length ? parts.join(", ") : "nothing";
|
|
60
63
|
};
|
|
61
64
|
let baseErr = errSize();
|
|
@@ -109,24 +112,25 @@ if (stallFile) {
|
|
|
109
112
|
const size = errSize();
|
|
110
113
|
if (size > baseErr + 200) { baseErr = size; lastErrAt = now; } // new bytes: alive
|
|
111
114
|
const freshCut = Math.max(armedAt, now - windowMs - SLACK);
|
|
112
|
-
const tr = transcriptDir ? newestMtime(transcriptDir) : 0;
|
|
115
|
+
const tr = transcriptDir && !armed.activity ? newestMtime(transcriptDir) : 0;
|
|
116
|
+
const cli = sessionActivity();
|
|
113
117
|
const wk = workDir ? newestMtime(workDir) : 0;
|
|
114
|
-
reportLiveness(now, tr);
|
|
115
|
-
if (tr > freshCut || wk > freshCut || now - lastErrAt < windowMs) { // producing work: alive
|
|
118
|
+
reportLiveness(now, Math.max(tr, cli));
|
|
119
|
+
if (tr > freshCut || cli > freshCut || wk > freshCut || now - lastErrAt < windowMs) { // producing work: alive
|
|
116
120
|
if (box && extensions < Number(box.extensionsMax) && now + poll + SLACK >= deadline) {
|
|
117
121
|
extensions++;
|
|
118
122
|
deadline += Number(box.extendMs);
|
|
119
123
|
try { writeFileSync(box.deadlineFile, String(Math.floor(deadline / 1000))); } catch {}
|
|
120
124
|
try { appendFileSync(box.extFile, JSON.stringify({ n: extensions, at: now, until: deadline }) + "\n"); } catch {}
|
|
121
125
|
const card = Number(box.card) > 0 ? ` on #${box.card}` : "";
|
|
122
|
-
await tellExtension(`⏳ ${session} turn extended +${secsOrMins(Number(box.extendMs))} (${extensions}/${box.extensionsMax})${card} — alive: ${describeLast({ tr, wk })}${now - lastErrAt < windowMs ? ", stderr moving" : ""}; box now ${secsOrMins(deadline - armedAt)} of a ${secsOrMins(Number(box.ceilingMs))} ceiling`);
|
|
126
|
+
await tellExtension(`⏳ ${session} turn extended +${secsOrMins(Number(box.extendMs))} (${extensions}/${box.extensionsMax})${card} — alive: ${describeLast({ tr, wk, cli })}${now - lastErrAt < windowMs ? ", stderr moving" : ""}; box now ${secsOrMins(deadline - armedAt)} of a ${secsOrMins(Number(box.ceilingMs))} ceiling`);
|
|
123
127
|
}
|
|
124
128
|
continue;
|
|
125
129
|
}
|
|
126
130
|
try { writeFileSync(stallFile, ""); } catch {}
|
|
127
131
|
const mins = Math.round((now - armedAt) / 60000);
|
|
128
132
|
const orch = `${hostId()}:${project}`;
|
|
129
|
-
const text = `⏱ ${session} turn STALLED — ${mins}m with no activity (turn ${s.turn}; last seen: ${describeLast({ tr, wk })}, stderr ${baseErr > 0 ? `${baseErr}B` : "silent"}); ending the turn at the stall window, not the box.`;
|
|
133
|
+
const text = `⏱ ${session} turn STALLED — ${mins}m with no activity (turn ${s.turn}; last seen: ${describeLast({ tr, wk, cli })}, stderr ${baseErr > 0 ? `${baseErr}B` : "silent"}); ending the turn at the stall window, not the box.`;
|
|
130
134
|
// Direct = wake. The foreman first; if this seat IS the foreman's own runner, say it to all.
|
|
131
135
|
const to = orch === session ? "all" : orch;
|
|
132
136
|
try { await signedPost(`${hub}/send`, { from: session, to, text, project }, { session }); } catch {}
|
|
@@ -144,13 +148,14 @@ for (;;) {
|
|
|
144
148
|
// count (they predate arm), and absolute freshness cannot drift the way a probe-to-probe
|
|
145
149
|
// delta does when the seat writes coarsely or the machine loads (the +1130ms false alarm).
|
|
146
150
|
const freshCut = Math.max(armedAt, Date.now() - windowMs - SLACK);
|
|
147
|
-
const tr = transcriptDir ? newestMtime(transcriptDir) : 0;
|
|
151
|
+
const tr = transcriptDir && !armed.activity ? newestMtime(transcriptDir) : 0;
|
|
152
|
+
const cli = sessionActivity();
|
|
148
153
|
const wk = workDir ? newestMtime(workDir) : 0;
|
|
149
|
-
reportLiveness(Date.now(), tr);
|
|
150
|
-
if (errSize() > baseErr + 200 || tr > freshCut || wk > freshCut) continue; // producing work: alive, re-arm
|
|
154
|
+
reportLiveness(Date.now(), Math.max(tr, cli));
|
|
155
|
+
if (errSize() > baseErr + 200 || tr > freshCut || cli > freshCut || wk > freshCut) continue; // producing work: alive, re-arm
|
|
151
156
|
const mins = Math.round((Date.now() - armedAt) / 60000);
|
|
152
157
|
const orch = `${hostId()}:${project}`;
|
|
153
|
-
const text = `⏱ ${session} turn STALLED — running ${mins}m with no activity (turn ${s.turn}; last seen: ${describeLast({ tr, wk })}, stderr ${baseErr > 0 ? `${baseErr}B` : "silent"}). Not killed; check its pane, or \`trantor swap\`.`;
|
|
158
|
+
const text = `⏱ ${session} turn STALLED — running ${mins}m with no activity (turn ${s.turn}; last seen: ${describeLast({ tr, wk, cli })}, stderr ${baseErr > 0 ? `${baseErr}B` : "silent"}). Not killed; check its pane, or \`trantor swap\`.`;
|
|
154
159
|
// Direct = wake. The foreman first; if this seat IS the foreman's own runner, say it to all.
|
|
155
160
|
const to = orch === session ? "all" : orch;
|
|
156
161
|
try { await signedPost(`${hub}/send`, { from: session, to, text, project }, { session }); } catch {}
|
package/bin/wake-nudge.mjs
CHANGED
|
@@ -23,7 +23,8 @@ import { load } from "../lib/identity.mjs";
|
|
|
23
23
|
import { sfetchJson } from "../lib/signed-fetch.mjs";
|
|
24
24
|
import { busDir, hostId, readConfig } from "../lib/project.mjs";
|
|
25
25
|
import { ledgerPaths } from "../hooks/lib/inbox-ledger.mjs";
|
|
26
|
-
import { dutyEscalations, claimDutyNudges, auditDutyNudges } from "../lib/duty-nudges.mjs";
|
|
26
|
+
import { dutyEscalations, claimDutyNudges, auditDutyNudges, releaseDutyNudgeClaims } from "../lib/duty-nudges.mjs";
|
|
27
|
+
import { reserveNudgeIds, releaseNudgeIds } from "../lib/nudge-ledger.mjs";
|
|
27
28
|
import { recipientPanes } from "../lib/duty-recipient.mjs";
|
|
28
29
|
|
|
29
30
|
const SELF = fileURLToPath(import.meta.url);
|
|
@@ -107,7 +108,7 @@ export function postNudge({ socketPath, token, sid }, ids, timeoutMs = 1500) {
|
|
|
107
108
|
});
|
|
108
109
|
}
|
|
109
110
|
|
|
110
|
-
export async function wakeOnce({ api, bus = busDir(), resolver = recipient => resolveRecipient(recipient, { bus }), verifyMs = 10000, now = Date.now(), attempted = new Set(), cursor = { id: 0, ts: 0 } }) {
|
|
111
|
+
export async function wakeOnce({ api, bus = busDir(), resolver = recipient => resolveRecipient(recipient, { bus }), verifyMs = 10000, now = Date.now(), attempted = new Set(), cursor = { id: 0, ts: 0 }, log = m => console.error(m) }) {
|
|
111
112
|
const feed = await api(`/events?type=message&by=hub%3Aduty&since=${cursor.id}${cursor.id ? "" : "&limit=2000"}`);
|
|
112
113
|
if (Number.isFinite(feed.latest) && feed.latest < cursor.id) {
|
|
113
114
|
cursor.id = 0; cursor.ts = 0;
|
|
@@ -132,6 +133,18 @@ export async function wakeOnce({ api, bus = busDir(), resolver = recipient => re
|
|
|
132
133
|
const peer = await api(`/peer?session=${encodeURIComponent(recipient)}`);
|
|
133
134
|
return Number(peer.deliveredUpTo || 0) >= Number(id);
|
|
134
135
|
} });
|
|
136
|
+
// #11355: the shared pre-send ledger — the duty path stamps the same files, so of the two
|
|
137
|
+
// orchestrator-wake nudgers exactly one send per unread id lands; the loser logs and stands down.
|
|
138
|
+
const skipped = [];
|
|
139
|
+
for (const target of plan.targets) {
|
|
140
|
+
const reserve = await reserveNudgeIds({ recipient: target.recipient, ids: target.ids, owner: `wake:${process.pid}`, bus, log });
|
|
141
|
+
target.ids = reserve.allowed;
|
|
142
|
+
skipped.push(...reserve.skipped.map(s => s.id));
|
|
143
|
+
}
|
|
144
|
+
plan.items = plan.items.filter(item => !skipped.includes(item.id));
|
|
145
|
+
plan.targets = plan.targets.filter(target => target.ids.length);
|
|
146
|
+
await releaseDutyNudgeClaims({ ids: skipped, statePath, owner: plan.owner });
|
|
147
|
+
if (!plan.targets.length) return { nudged: [], missing: [] };
|
|
135
148
|
const observedIds = new Set();
|
|
136
149
|
let result;
|
|
137
150
|
try {
|
|
@@ -150,6 +163,12 @@ export async function wakeOnce({ api, bus = busDir(), resolver = recipient => re
|
|
|
150
163
|
await sleep(100);
|
|
151
164
|
}
|
|
152
165
|
}));
|
|
166
|
+
// #11355: a tick that ends with unverified sends releases their stamps — the ledger must
|
|
167
|
+
// mirror the claim release below, or a failed send would silence the duty path for the TTL.
|
|
168
|
+
for (const target of plan.targets) {
|
|
169
|
+
const unverified = target.ids.filter(id => !observedIds.has(id));
|
|
170
|
+
if (unverified.length) await releaseNudgeIds({ recipient: target.recipient, ids: unverified, owner: plan.owner, bus });
|
|
171
|
+
}
|
|
153
172
|
} finally {
|
|
154
173
|
// #7429: release unverified claims so the duty seat retains its triage path.
|
|
155
174
|
result = await auditDutyNudges({ plan, observedIds, statePath, reportFailure: async () => {} });
|
package/hooks/file-claim.mjs
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { gitCheckoutRoot } from "./lib/git-checkout.mjs";
|
|
2
3
|
// trantor PreToolUse file-claim — before every file edit the session posts a claim and the hub answers
|
|
3
4
|
// with any LIVE claim on the same file by another session, handed to the model as context.
|
|
4
5
|
// Informational, never blocking: a lock server that fails open is worse than no lock. Fail-open and
|
|
5
6
|
// cheap: tight timeout, per-(session,file) stamp throttles re-claims; the FIRST touch always goes out.
|
|
6
7
|
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
7
|
-
import { join, relative,
|
|
8
|
+
import { join, relative, resolve } from "node:path";
|
|
8
9
|
import { homedir } from "node:os";
|
|
9
10
|
import { relayUrl, sessionContext, signedPost } from "./lib/api.mjs";
|
|
11
|
+
import { writeOverseerLevel } from "./lib/overseer-level-cache.mjs";
|
|
12
|
+
import { overlapsFor } from "../lib/remote-overlap.mjs";
|
|
10
13
|
|
|
11
14
|
const FETCH_TIMEOUT_MS = Number(process.env.RELAY_CLAIM_TIMEOUT_MS || 900);
|
|
12
15
|
const RECLAIM_MS = Number(process.env.RELAY_RECLAIM_MS || 60 * 1000);
|
|
@@ -41,26 +44,35 @@ try {
|
|
|
41
44
|
const ctx = sessionContext(input.cwd);
|
|
42
45
|
if (!ctx.project) allow();
|
|
43
46
|
// claims compare by path, and absolute paths differ per machine — store repo-relative
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
+
const gitRoot = gitCheckoutRoot(ctx.projectDir);
|
|
48
|
+
const absolute = resolve(ctx.projectDir, abs);
|
|
49
|
+
const rel = relative(gitRoot || ctx.projectDir, absolute);
|
|
50
|
+
const file = !rel.startsWith("..") ? rel : absolute;
|
|
47
51
|
|
|
48
52
|
// throttle re-claims of the same file; never throttle its first touch
|
|
49
53
|
const stampDir = join(process.env.AGENT_BUS_DIR || join(homedir(), ".agent-bus"), "claims");
|
|
50
|
-
const stamp = join(stampDir, `${ctx.session} ${file}`.replace(/[^A-Za-z0-9_.-]/g, "_"));
|
|
54
|
+
const stamp = join(stampDir, `${ctx.session} ${gitRoot} ${file}`.replace(/[^A-Za-z0-9_.-]/g, "_"));
|
|
51
55
|
try {
|
|
52
56
|
const last = Number(readFileSync(stamp, "utf8"));
|
|
53
57
|
if (Date.now() - last < RECLAIM_MS) allow();
|
|
54
58
|
} catch {}
|
|
55
59
|
|
|
60
|
+
// #11311: open-PR evidence, computed HERE from this session's own checkout (the hub's gh is not
|
|
61
|
+
// logged in and its cwd is the wrong repo). Cache file makes the normal call a stat+read; off
|
|
62
|
+
// when gh is missing. Fail-open like everything else in this hook.
|
|
63
|
+
let remote = [];
|
|
64
|
+
try { remote = overlapsFor({ gitRoot, files: [file] }); } catch {}
|
|
65
|
+
|
|
56
66
|
const r = await signedPost(`${relayUrl(ctx.project)}/claim`,
|
|
57
|
-
{ project: ctx.project, file, session: ctx.session },
|
|
67
|
+
{ project: ctx.project, file, session: ctx.session, gitRoot, remote },
|
|
58
68
|
{ timeoutMs: FETCH_TIMEOUT_MS, session: ctx.session });
|
|
59
69
|
|
|
60
70
|
try { mkdirSync(stampDir, { recursive: true }); writeFileSync(stamp, String(Date.now())); } catch {}
|
|
61
71
|
|
|
72
|
+
if (r.ok) writeOverseerLevel({ project: ctx.project, hub: relayUrl(ctx.project) }, r.json?.level);
|
|
73
|
+
|
|
62
74
|
const conflicts = r.ok ? r.json?.conflicts ?? [] : [];
|
|
63
|
-
if (!conflicts.length) allow();
|
|
75
|
+
if (Number(r.json?.level ?? 2) < 2 || !conflicts.length) allow();
|
|
64
76
|
|
|
65
77
|
const who = conflicts.map(c => `${c.session} (${ago(c.agoSec)} ago)`).join(", ");
|
|
66
78
|
// NO permissionDecision on purpose: additionalContext reaches the model on its own, and an "allow"
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { gitCheckoutRoot } from "./lib/git-checkout.mjs";
|
|
3
|
+
import { relative, resolve } from "node:path";
|
|
4
|
+
import { relayUrl, sessionContext, signedPost } from "./lib/api.mjs";
|
|
5
|
+
import { readOverseerLevel, writeOverseerLevel } from "./lib/overseer-level-cache.mjs";
|
|
6
|
+
|
|
7
|
+
function deny(reason) {
|
|
8
|
+
process.stdout.write(JSON.stringify({ hookSpecificOutput: {
|
|
9
|
+
hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: reason,
|
|
10
|
+
} }));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function unavailable() {
|
|
14
|
+
process.stderr.write("trantor: file hold could not be checked; allowing this edit.\n");
|
|
15
|
+
process.stdout.write("{}");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
let raw = "";
|
|
20
|
+
for await (const chunk of process.stdin) raw += chunk;
|
|
21
|
+
const input = JSON.parse(raw || "{}");
|
|
22
|
+
const path = input.tool_name === "NotebookEdit" ? input.tool_input?.notebook_path : input.tool_input?.file_path;
|
|
23
|
+
const ctx = sessionContext(input.cwd);
|
|
24
|
+
if (!path || !ctx.project) process.stdout.write("{}");
|
|
25
|
+
else {
|
|
26
|
+
const cache = { project: ctx.project, hub: relayUrl(ctx.project) };
|
|
27
|
+
const level = readOverseerLevel(cache);
|
|
28
|
+
if (level !== null && level < 3) {
|
|
29
|
+
process.stdout.write("{}");
|
|
30
|
+
process.exit(0);
|
|
31
|
+
}
|
|
32
|
+
const gitRoot = gitCheckoutRoot(ctx.projectDir);
|
|
33
|
+
const absolute = resolve(ctx.projectDir, path);
|
|
34
|
+
const rel = relative(gitRoot || ctx.projectDir, absolute);
|
|
35
|
+
const file = !rel.startsWith("..") ? rel : absolute;
|
|
36
|
+
// Register and check atomically: parallel PreToolUse hooks cannot race the first claim.
|
|
37
|
+
const r = await signedPost(`${cache.hub}/hold/check`,
|
|
38
|
+
{ project: ctx.project, file, session: ctx.session, gitRoot },
|
|
39
|
+
{ timeoutMs: 2500, session: ctx.session });
|
|
40
|
+
if (!r.ok || !r.json?.ok) unavailable();
|
|
41
|
+
else {
|
|
42
|
+
writeOverseerLevel(cache, r.json.level);
|
|
43
|
+
if (r.json.hold) deny(r.json.hold.reason);
|
|
44
|
+
else process.stdout.write("{}");
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
} catch {
|
|
48
|
+
unavailable();
|
|
49
|
+
}
|
package/hooks/heartbeat.mjs
CHANGED
|
@@ -12,6 +12,7 @@ import { armBaton, readArm, clearArm, readConfig, contextUsage, warnFrac, alread
|
|
|
12
12
|
import { resolveProject, hostId } from "../lib/project.mjs";
|
|
13
13
|
import { installedVersion } from "./lib/update-check.mjs"; // report our hook version so the hub can flag stale sessions
|
|
14
14
|
import { signedPost } from "./lib/api.mjs";
|
|
15
|
+
import { gitCheckoutRoot } from "./lib/git-checkout.mjs";
|
|
15
16
|
|
|
16
17
|
const HEARTBEAT_MS = Number(process.env.RELAY_HEARTBEAT_MS || 60 * 1000);
|
|
17
18
|
const FETCH_TIMEOUT_MS = Number(process.env.RELAY_HEARTBEAT_TIMEOUT_MS || 1500);
|
|
@@ -161,7 +162,7 @@ async function main(stdinRaw) {
|
|
|
161
162
|
// strict test sessionstart.mjs uses), "agent" when the runner's TRANTOR_SEAT says crew seat.
|
|
162
163
|
const beatKind = process.env.TRANTOR_ORCH && process.env.TRANTOR_ORCH === project ? "orch"
|
|
163
164
|
: process.env.TRANTOR_SEAT ? "agent" : "";
|
|
164
|
-
const beatBody = { session, project,
|
|
165
|
+
const beatBody = { session, project, gitRoot: gitCheckoutRoot(projectDir),
|
|
165
166
|
llm: process.env.RELAY_LLM || (process.env.RELAY_AGENT ? process.env.RELAY_AGENT.replace(/-orch$/, "") : "claude"),
|
|
166
167
|
model: modelFromTranscript(stdinRaw),
|
|
167
168
|
hookVersion: (() => { try { return installedVersion(); } catch { return ""; } })() };
|
package/hooks/hooks.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"description": "trantor
|
|
2
|
+
"description": "trantor \u2014 auto-register each session + inject live roster + overseer landscape warning: linked projects, live peers, files in flight, collisions (SessionStart); turn each substantive user prompt into the session's live 'focus' card so a regular session's own work shows IN PROGRESS (UserPromptSubmit); post an in-flight 'doing' card when a sub-agent is dispatched + claim each file edit on the bus and hand the model a warning when ANOTHER live session is touching the same file (PreToolUse) and enrich it with the native agent_id + parent session when the sub-agent spawns (SubagentStart); heartbeat presence on every tool call + deliver unread bus messages to a busy session mid-turn + mirror the session's TodoWrite list onto the board as cards (PostToolUse); write a handoff before compaction (PreCompact); surface CC's own background/child agents (fork/--agent/subtask) on the board when they need input or complete (Notification); card each sub-agent's notional API cost when it finishes (SubagentStop); refuse to go idle while a peer's DIRECT message sits unread, handing it to the model instead (Stop)",
|
|
3
3
|
"hooks": {
|
|
4
4
|
"SessionStart": [
|
|
5
5
|
{
|
|
@@ -81,6 +81,15 @@
|
|
|
81
81
|
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/ask-sidecar.mjs"
|
|
82
82
|
}
|
|
83
83
|
]
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
"matcher": "Edit|Write|MultiEdit|NotebookEdit",
|
|
87
|
+
"hooks": [
|
|
88
|
+
{
|
|
89
|
+
"type": "command",
|
|
90
|
+
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/file-hold.mjs"
|
|
91
|
+
}
|
|
92
|
+
]
|
|
84
93
|
}
|
|
85
94
|
],
|
|
86
95
|
"PermissionRequest": [
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
3
|
+
|
|
4
|
+
export function gitCheckoutRoot(directory) {
|
|
5
|
+
try {
|
|
6
|
+
const root = execFileSync("git", ["-C", directory, "rev-parse", "--show-toplevel"],
|
|
7
|
+
{ encoding: "utf8", timeout: 1000, stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
8
|
+
return realpathSync(root);
|
|
9
|
+
} catch { return ""; }
|
|
10
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
const cacheDir = busDir => join(busDir || process.env.AGENT_BUS_DIR || join(homedir(), ".agent-bus"), "overseer-levels");
|
|
7
|
+
const cacheFile = ({ project, hub, busDir }) => join(cacheDir(busDir), createHash("sha256").update(JSON.stringify([hub, project])).digest("hex") + ".json");
|
|
8
|
+
|
|
9
|
+
export function readOverseerLevel(context) {
|
|
10
|
+
try {
|
|
11
|
+
const { level: cachedLevel, ts } = JSON.parse(readFileSync(cacheFile(context), "utf8"));
|
|
12
|
+
const level = cachedLevel ?? 2;
|
|
13
|
+
const age = Date.now() - ts;
|
|
14
|
+
return [1, 2, 3, 4].includes(level) && age >= 0 && age < 60_000 ? level : null;
|
|
15
|
+
} catch { return null; }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function writeOverseerLevel(context, level = 2) {
|
|
19
|
+
level ??= 2;
|
|
20
|
+
if (![1, 2, 3, 4].includes(level)) return;
|
|
21
|
+
try {
|
|
22
|
+
mkdirSync(cacheDir(context.busDir), { recursive: true });
|
|
23
|
+
writeFileSync(cacheFile(context), JSON.stringify({ level, ts: Date.now() }));
|
|
24
|
+
} catch { /* A cache write failure must not affect the edit decision. */ }
|
|
25
|
+
}
|
package/hooks/overseer-warn.mjs
CHANGED
|
@@ -31,7 +31,7 @@ try {
|
|
|
31
31
|
if (!r.ok || !r.json) silent();
|
|
32
32
|
const c = r.json;
|
|
33
33
|
|
|
34
|
-
const level = Number(c.level
|
|
34
|
+
const level = Number(c.level ?? 2);
|
|
35
35
|
const warnings = Array.isArray(c.warnings) ? c.warnings : [];
|
|
36
36
|
const inflight = Array.isArray(c.inflight) ? c.inflight : [];
|
|
37
37
|
const links = Array.isArray(c.links) ? c.links : [];
|
package/hub/auth.mjs
CHANGED
|
@@ -117,18 +117,7 @@ function projectFromRequest(P, q, b) {
|
|
|
117
117
|
}
|
|
118
118
|
return canon(String(b?.project || q?.project || "").slice(0, 80));
|
|
119
119
|
}
|
|
120
|
-
//
|
|
121
|
-
// scopeAllows/canRead answer "can this identity touch project P at all" — and most identities
|
|
122
|
-
// are minted project:"*" role:"owner" by defaultScopesFor (any non-agent kind: an orchestrator,
|
|
123
|
-
// genesis, a human). That is not a project fence; it is exactly the loophole the pr-os
|
|
124
|
-
// orchestrator walked through to register seats and post contracts into crebral-com from its
|
|
125
|
-
// own session, nothing it was ever working. This is the fence: does the CALLER's own project
|
|
126
|
-
// (its identity name's "kind:project" suffix, the same convention defaultScopesFor reads) match
|
|
127
|
-
// the project the request ACTS on. Only a declared `trantor policy link` (state.orgPolicy.links,
|
|
128
|
-
// the same store /policy reads and writes — POST /policy is itself OWNER_ENDPOINTS-gated) or the
|
|
129
|
-
// operator's own identity (kind "human", the key /instance/supersede already treats as owner)
|
|
130
|
-
// opens the door. Applies to the five endpoints that reach across sessions or mint access:
|
|
131
|
-
// /send, /task, /task/update, /register, /invite.
|
|
120
|
+
// Cross-project actions require a declared link or the operator identity, even with wildcard scopes (#6228).
|
|
132
121
|
const CROSS_PROJECT_ENDPOINTS = new Set(["/send", "/task", "/task/update", "/register", "/invite"]);
|
|
133
122
|
function projectsLinked(a, b) {
|
|
134
123
|
if (!a || !b || a === b) return true;
|
|
@@ -139,7 +128,7 @@ function projectsLinked(a, b) {
|
|
|
139
128
|
}
|
|
140
129
|
function overseerPolicy() {
|
|
141
130
|
const policy = state.orgPolicy && typeof state.orgPolicy === "object" ? state.orgPolicy : {};
|
|
142
|
-
return { autonomy: { "*":
|
|
131
|
+
return { autonomy: { "*": 2, ...(policy.autonomy || {}) }, links: Array.isArray(policy.links) ? policy.links : [] };
|
|
143
132
|
}
|
|
144
133
|
// "projPair-a" is an INSTANCE of "projPair", not a different project: the shorter name is a prefix
|
|
145
134
|
// of the longer and the remainder starts with a separator, not another project's first letter.
|
|
@@ -147,18 +136,7 @@ function instanceOfProject(a, b) {
|
|
|
147
136
|
const [lo, hi] = a.length <= b.length ? [a, b] : [b, a];
|
|
148
137
|
return hi.length > lo.length && hi.startsWith(lo) && !/[a-z0-9]/i.test(hi[lo.length]);
|
|
149
138
|
}
|
|
150
|
-
//
|
|
151
|
-
// uses to mint a fresh identity's default scope. An identity with no colon in its name (a bare
|
|
152
|
-
// human alias, or a tool identity never given a project) has no home to fence — nothing to check.
|
|
153
|
-
//
|
|
154
|
-
// The suffix is a CONVENTION, not a fact: session ids routinely carry an instance marker after the
|
|
155
|
-
// project — "agent:projPair-a" is session -a OF projPair, the same shape as the fleet's per-seat
|
|
156
|
-
// session ids — and reading that whole suffix as a home project fenced a session against its OWN
|
|
157
|
-
// project's register/send (#6446, red since 3e18faf). So when the identity's own scopes name
|
|
158
|
-
// exactly one concrete project (what /enroll bound at enrollment, and what /invite granted), that
|
|
159
|
-
// enrolled project is the home — unless the suffix names a DIFFERENT project, in which case the
|
|
160
|
-
// stricter suffix wins. Wildcard-scope identities (the fence's original target: a "*" owner like
|
|
161
|
-
// an orchestrator or genesis) keep the pure suffix rule unchanged.
|
|
139
|
+
// Enrollment identifies instance sessions; a conflicting suffix stays stricter. Wildcard identities keep the suffix fence (#6446).
|
|
162
140
|
function callerProject(auth) {
|
|
163
141
|
const name = String(auth?.identity?.name || "");
|
|
164
142
|
const suffix = name.includes(":") ? canon(name.slice(name.lastIndexOf(":") + 1)) : "";
|
|
@@ -197,15 +175,7 @@ function crossProjectGuard(auth, P, b) {
|
|
|
197
175
|
return { ok: false, code: 403,
|
|
198
176
|
error: `cross-project: ${home} may not act on ${target} — cross-project action is a breach unless the operator linked the projects. Run: trantor policy link ${home} ${target} --reason "<why>"` };
|
|
199
177
|
}
|
|
200
|
-
//
|
|
201
|
-
// (`exceptInstanceId`), so we defer to THAT instance only while it is still being seen. A claimant
|
|
202
|
-
// that died stops muzzling its twins; one that comes back starts again. Records claimed before
|
|
203
|
-
// `supersededBy` existed carry no claimant, so they fall back to "is any OTHER live instance of this
|
|
204
|
-
// name still carrying it?" — an orphaned flag must not outlive every possible carrier.
|
|
205
|
-
// NOTE lastSeen only advances on a request, and the heartbeat is PostToolUse, so an alive-but-idle
|
|
206
|
-
// claimant reads as gone after the grace window. That is the intended trade: the only session that
|
|
207
|
-
// ever asks this question is one a human is actively driving right now, and deferring to a claimant
|
|
208
|
-
// that has been silent for longer than the window is worse than letting the driven session work.
|
|
178
|
+
// Supersession lasts while its claimant is live, with startup grace; legacy claims defer to another live instance.
|
|
209
179
|
function supersessionActive(rec) {
|
|
210
180
|
if (!rec?.superseded) return false;
|
|
211
181
|
const cut = now() - SUPERSEDE_GRACE_MS;
|
|
@@ -231,12 +201,7 @@ async function authenticate(req, path) {
|
|
|
231
201
|
return { ok: false, code: 401, error: "signature required" };
|
|
232
202
|
}
|
|
233
203
|
const raw = req.method === "GET" || req.method === "HEAD" ? undefined : await rawBody(req);
|
|
234
|
-
//
|
|
235
|
-
// where the hub records what WOULD fail under enforce. The restarted local hub proved the
|
|
236
|
-
// failure mode: signed requests from a not-yet-enrolled identity got 401 "unknown identity"
|
|
237
|
-
// while UNSIGNED requests passed — punishing exactly the clients that already do the right
|
|
238
|
-
// thing. Under warn: bad signature, replay and unknown identity all pass with a warning;
|
|
239
|
-
// under enforce they are the hard failures they should be.
|
|
204
|
+
// Auth warn mode annotates signature, replay and enrollment failures; enforce mode rejects them.
|
|
240
205
|
const soft = (warning) => AUTH_MODE === "warn"
|
|
241
206
|
? { ok: true, mode: AUTH_MODE, trusted: false, warning }
|
|
242
207
|
: { ok: false, code: 401, error: warning };
|
|
@@ -287,17 +252,7 @@ function filterReadable(auth, rows, projectOf) {
|
|
|
287
252
|
if (AUTH_MODE !== "enforce" && !auth?.identity) return rows;
|
|
288
253
|
return rows.filter(row => canRead(auth, projectOf(row)));
|
|
289
254
|
}
|
|
290
|
-
//
|
|
291
|
-
//
|
|
292
|
-
// Sending across projects was never blocked: /send authorizes against the SENDER's project, so any
|
|
293
|
-
// session can DM any session id it happens to know. Only the ROSTER was scoped — which meant two
|
|
294
|
-
// sessions the operator had explicitly declared codependent could not learn each other's ids. The
|
|
295
|
-
// overseer would tell both of them to "coordinate over the bus" and neither could find the other,
|
|
296
|
-
// so the only remaining channel was the human. That is the exact traffic-cop role this project
|
|
297
|
-
// exists to delete.
|
|
298
|
-
//
|
|
299
|
-
// A link is an operator declaration that two projects share resources. Treating it as mutual
|
|
300
|
-
// discovery grants nothing a linked pair wasn't already told to do.
|
|
255
|
+
// Declared links permit mutual roster discovery without granting broader read or write access.
|
|
301
256
|
function canDiscover(auth, project) {
|
|
302
257
|
if (canRead(auth, project)) return true;
|
|
303
258
|
const proj = canon(project || "");
|
|
@@ -375,7 +330,7 @@ const seenNonces = new Map();
|
|
|
375
330
|
const guard = crossProjectGuard(auth, P, { scopes });
|
|
376
331
|
if (!guard.ok) return json(res, guard.code, { error: guard.error });
|
|
377
332
|
const ttlSec = Math.min(Math.max(Number(b0.ttlSec) || 86400, 1), 30 * 86400);
|
|
378
|
-
const token = randomBytes(
|
|
333
|
+
const token = randomBytes(32).toString("hex");
|
|
379
334
|
state.inviteTokens[token] = { scopes, kind: invitedKind, expiresAt: now() + ttlSec * 1000, used: false,
|
|
380
335
|
createdBy: auth.identity?.pubkey || "", createdAt: now() };
|
|
381
336
|
markDirty();
|