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/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 result.specs.push(arg);
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 = spec => {
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(spec => spec.split(":")[0]), started));
111
+ let failed = failedAgents(verifyCrew(ctx, options.specs.map(seat => seat.persona), started));
100
112
  if (failed.length) {
101
- const retry = options.specs.filter(spec => failed.includes(spec.split(":")[0]));
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
+ }
@@ -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 {}
@@ -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 () => {} });
@@ -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, isAbsolute } from "node:path";
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 file = isAbsolute(abs) && !relative(ctx.projectDir, abs).startsWith("..")
45
- ? relative(ctx.projectDir, abs)
46
- : abs;
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
+ }
@@ -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 — 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)",
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
+ }
@@ -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 || 1);
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
- // --- Cross-project guard (#6228) -----------------------------------------------------------
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: { "*": 1, ...(policy.autonomy || {}) }, links: Array.isArray(policy.links) ? policy.links : [] };
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
- // The caller's home project, by the SAME "name suffix after the colon" rule defaultScopesFor
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
- // Is a stored baton claim still worth honouring? The claim names the instance it spared
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
- // WARN MODE NEVER BLOCKS — it annotates. That is its entire contract: an observation period
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
- // DISCOVERY follows declared links, and is deliberately wider than read.
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(24).toString("hex");
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();