trantor 0.18.114 → 0.18.116

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.114",
3
+ "version": "0.18.116",
4
4
  "description": "Trantor — the hub-world for AI agent crews: live message bus, presence, project Kanban/flow board + crew orchestration for independent AI coding agents (Claude, Codex, Gemini, Kimi, DeepSeek)",
5
5
  "mcpServers": {
6
6
  "relay": {
@@ -3,6 +3,15 @@ import { setTimeout } from "node:timers/promises";
3
3
  import { loadOrCreate } from "../../lib/identity.mjs";
4
4
  import { ensureEnrolled } from "../../lib/enroll.mjs";
5
5
  import { sfetchJson } from "../../lib/signed-fetch.mjs";
6
+ import { personaName } from "../../lib/persona.mjs";
7
+
8
+ export async function unregisterSwapStage(hub, project, session) {
9
+ const identity = loadOrCreate(session, "agent");
10
+ const response = await sfetchJson(`${hub}/unregister`, {
11
+ identity, payload: { session, project }, signal: AbortSignal.timeout(8000),
12
+ });
13
+ if (!response.ok) throw new Error(`replacement deregistration failed: ${response.status}`);
14
+ }
6
15
 
7
16
  // A replacement registers before it touches the old runner's worktree or queue.
8
17
  export async function awaitSwapRelease(env = process.env) {
@@ -25,4 +34,8 @@ export async function awaitSwapRelease(env = process.env) {
25
34
  }
26
35
  const handoff = JSON.parse(readFileSync(`${stage}.release`, "utf8"));
27
36
  if (handoff.agent !== env.CREW_SWAP_AGENT) throw new Error("swap release names a different seat");
37
+ const persona = personaName(handoff.persona);
38
+ if (env.CREW_PERSONA && env.CREW_PERSONA !== persona) throw new Error("swap release names a different persona");
39
+ env.CREW_PERSONA = persona;
40
+ await unregisterSwapStage(env.RELAY_URL, env.RELAY_PROJECT, session);
28
41
  }
@@ -3,6 +3,7 @@ import { dirname, join } from "node:path";
3
3
  import { seatProcesses, signalProcesses } from "./processes.mjs";
4
4
  import { call, listPids, run } from "./core.mjs";
5
5
  import { hostId } from "../../lib/project.mjs";
6
+ import { signedPost } from "../../hooks/lib/api.mjs";
6
7
  import { statePathFor as dockerStatePathFor, sweep as dockerSweep } from "../../lib/docker-janitor.mjs";
7
8
 
8
9
  export function parseRow(line) {
@@ -122,15 +123,22 @@ function closeTmux(ctx, row, perSeat, seen) {
122
123
  run(ctx, "tmux", ["kill-session", "-t", session], { rendered: `tmux kill-session -t '${session}' 2>/dev/null` });
123
124
  }
124
125
 
125
- export async function down(ctx, args, adapters) {
126
+ async function unregisterSeat(ctx, project, persona) {
127
+ const path = project === ctx.project ? `${ctx.hub}/unregister` : "/unregister";
128
+ const response = await signedPost(path, { session: `${persona}:${project}`, project }, { project });
129
+ if (!response.ok || !response.json?.ok) throw new Error(response.json?.error || response.reason || `hub HTTP ${response.status}`);
130
+ return response.json;
131
+ }
132
+
133
+ export async function down(ctx, args, adapters, { unregister = unregisterSeat } = {}) {
126
134
  const options = parseDownArgs(ctx, args);
127
135
  if (options.help) return 0;
128
136
  if (options.error) return 1;
129
137
  const rows = readRows(ctx);
130
- if (!rows.length) { console.log("no tracked crew windows"); return 0; }
138
+ if (!rows.length && !options.agents.length) { console.log("no tracked crew windows"); return 0; }
131
139
  const scoped = rows.filter(row => (options.all || row.project === ctx.project) && (!options.agents.length || options.agents.includes(row.persona)));
132
140
  const scope = options.all ? "all projects" : `project \"${ctx.project}\"`;
133
- if (!scoped.length) { console.log(`nothing to tear down for ${scope}`); return 0; }
141
+ if (!scoped.length && !options.agents.length) { console.log(`nothing to tear down for ${scope}`); return 0; }
134
142
  console.log(`— tearing down (${scope}):`);
135
143
  for (const row of scoped) if (!["attach", "cmuxws", "herdrws", "orch"].includes(row.kind)) console.log(` • ${row.project || "<legacy>"} · ${row.persona} (${row.kind})`);
136
144
  if (options.all && !options.yes) {
@@ -144,6 +152,21 @@ export async function down(ctx, args, adapters) {
144
152
  const kept = rows.filter(row => !removed.has(`${row.project}|${row.kind}|${row.persona}|${row.handle}`) || (orchProjects.has(row.project) && ["herdrws", "cmuxws", "orch"].includes(row.kind)));
145
153
  writeRows(ctx, kept);
146
154
  const seatRows = scoped.filter(row => !["attach", "cmuxws", "herdrws", "orch"].includes(row.kind));
155
+ const staleSeats = options.agents.filter(persona => !scoped.some(row => row.persona === persona))
156
+ .map(persona => ({ project: ctx.project, persona, stale: true }));
157
+ const seats = new Map([...seatRows, ...staleSeats].map(row => [`${row.project}/${row.persona}`, row]));
158
+ const failures = [];
159
+ for (const row of seats.values()) {
160
+ if (!row.project) continue;
161
+ if (ctx.dry) { console.log(`[dry] unregister ${row.persona}:${row.project}`); continue; }
162
+ try {
163
+ const result = await unregister(ctx, row.project, row.persona);
164
+ if (row.stale) console.log(result.removed ? ` removed a stale seat row: ${row.persona}:${row.project}` : ` no hub seat row: ${row.persona}:${row.project}`);
165
+ } catch (error) {
166
+ failures.push(`${row.persona}:${row.project}: ${error.message}`);
167
+ console.error(` ${row.persona}:${row.project}: hub row removal failed: ${error.message}`);
168
+ }
169
+ }
147
170
  if (!ctx.dry) {
148
171
  const logDir = join(dirname(ctx.statePath), "logs");
149
172
  mkdirSync(logDir, { recursive: true });
@@ -152,6 +175,10 @@ export async function down(ctx, args, adapters) {
152
175
  `${JSON.stringify({ ts: Date.now(), agent: row.persona, project: row.project, stopped: true })}\n`);
153
176
  }
154
177
  }
178
+ if (failures.length) {
179
+ console.log(`— hub row removal incomplete; retry Stop: ${failures.join("; ")}`);
180
+ return 1;
181
+ }
155
182
  if (options.agents.length) {
156
183
  const stopped = [...new Set(seatRows.map(row => row.persona))];
157
184
  const remaining = [...new Set(kept.filter(row => row.project === ctx.project &&
package/bin/crew/swap.mjs CHANGED
@@ -1,5 +1,6 @@
1
- import { updatePersona } from "../../lib/persona.mjs";
2
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
1
+ import { unregisterSwapStage } from "./stage.mjs";
2
+ import { allocatePersona, updatePersona } from "../../lib/persona.mjs";
3
+ import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
3
4
  import { join } from "node:path";
4
5
  import { randomUUID } from "node:crypto";
5
6
  import { setTimeout } from "node:timers/promises";
@@ -85,7 +86,7 @@ async function hubRequest(ctx, path, body) {
85
86
  return response.json;
86
87
  }
87
88
 
88
- export function swapRuntime(ctx, adapters, spawn) {
89
+ export function swapRuntime(ctx, adapters, spawn, { now = Date.now, pause = setTimeout, requestTimeoutMs = 8000 } = {}) {
89
90
  let pids = [];
90
91
  let replacementPid;
91
92
  return {
@@ -97,14 +98,23 @@ export function swapRuntime(ctx, adapters, spawn) {
97
98
  spawn(staged, { ...seat, persona: stage.label });
98
99
  },
99
100
  async ready(stage) {
100
- const deadline = Date.now() + 30000;
101
+ const deadline = now() + 30000;
101
102
  while (!existsSync(`${stage.path}.ready`)) {
102
- if (Date.now() > deadline) throw new Error("replacement did not join the bus within 30s; old seat unchanged");
103
- await setTimeout(100);
103
+ if (now() > deadline) throw new Error("replacement did not join the bus within 30s; old seat unchanged");
104
+ await pause(100);
104
105
  }
105
106
  const ready = JSON.parse(readFileSync(`${stage.path}.ready`, "utf8"));
106
- const response = await signedGet(`${ctx.hub}/peers?project=${encodeURIComponent(ctx.project)}`, { project: ctx.project });
107
- if (!response.ok || !response.json?.peers?.some(p => p.session === ready.session)) throw new Error("replacement registration is not visible on the bus");
107
+ let failure = "status=0 reason=readiness deadline elapsed timedOut=false";
108
+ let visible = false;
109
+ while (now() < deadline) {
110
+ const response = await signedGet(`${ctx.hub}/peers?project=${encodeURIComponent(ctx.project)}`, {
111
+ project: ctx.project, timeoutMs: Math.min(requestTimeoutMs, deadline - now()),
112
+ });
113
+ if (response.ok && response.json?.peers?.some(p => p.session === ready.session)) { visible = true; break; }
114
+ failure = `status=${response.status} reason=${response.reason || response.json?.error || "replacement absent from roster"} timedOut=${response.timedOut === true}`;
115
+ await pause(Math.min(100, Math.max(0, deadline - now())));
116
+ }
117
+ if (!visible) throw new Error(`replacement registration is not visible on the bus: ${failure}`);
108
118
  process.kill(ready.pid, 0);
109
119
  replacementPid = ready.pid;
110
120
  return ready.session;
@@ -114,10 +124,16 @@ export function swapRuntime(ctx, adapters, spawn) {
114
124
  transfer: body => hubRequest(ctx, "/contracts/transfer", body),
115
125
  async stop(agent) {
116
126
  process.kill(replacementPid, 0);
117
- await down(ctx, [agent], adapters);
127
+ if (await down(ctx, [agent], adapters)) throw new Error("old seat stopped locally but its hub row could not be removed");
118
128
  signalProcesses(pids, "SIGKILL");
119
129
  },
120
- async cancel(stage) { await down(ctx, [stage.label], adapters); },
130
+ async cancel(stage) {
131
+ const code = await down(ctx, [stage.label], adapters, { unregister: async () => {
132
+ await unregisterSwapStage(ctx.hub, ctx.project, `${stage.label}:${ctx.project}`);
133
+ return { removed: true };
134
+ } });
135
+ if (code) throw new Error("replacement hub row could not be removed");
136
+ },
121
137
  release(stage, persona, cursor) {
122
138
  writeRows(ctx, readRows(ctx).map(row => row.project === ctx.project && row.persona === stage.label ? { ...row, persona } : row));
123
139
  writeFileSync(runnerRecord(ctx, ctx.project, persona), JSON.stringify({ label: stage.label, dir: ctx.dir }));
@@ -132,29 +148,36 @@ export async function performSwap(ctx, options, runtime) {
132
148
  let stage, frozen = false, transferred = false;
133
149
  try {
134
150
  const seat = await runtime.validate(options);
151
+ const occupied = [options.from, seat.agent, ...readRows(ctx).filter(row => row.project === ctx.project).map(row => row.persona)];
152
+ const { persona } = allocatePersona({ dir: join(ctx.home, ".agent-bus"), project: ctx.project, agent: seat.agent, model: seat.model, occupied });
153
+ result.to = persona;
135
154
  const label = `${options.agent}-swap-${randomUUID().slice(0, 8)}`;
136
155
  mkdirSync(ctx.seatDir, { recursive: true });
137
- stage = { label, path: join(ctx.seatDir, label), persona: options.from, agent: seat.agent, model: seat.model };
156
+ stage = { label, path: join(ctx.seatDir, label), persona, agent: seat.agent, model: seat.model };
138
157
  await runtime.start(seat, stage);
139
158
  const readySession = await runtime.ready(stage);
140
159
  frozen = true;
141
160
  await runtime.freeze(options.from);
142
161
  const pending = readPending(ctx, options.from);
143
- const target = pending;
144
- const from = `${options.from}:${ctx.project}`, to = from;
162
+ const target = readPending(ctx, persona);
163
+ const from = `${options.from}:${ctx.project}`, to = `${persona}:${ctx.project}`;
145
164
  const handoff = await runtime.transfer({ from, to, project: ctx.project, readySession, _op: `swap-${stage.label}`, heldAsk: pending.ask || 0, pendingIds: pending.wake.map(m => m.id) });
146
165
  transferred = true;
147
166
  result.moved = [...new Set([...pending.wake.map(m => m.id), ...handoff.moved])];
148
167
  const wake = new Map([...target.wake, ...pending.wake, ...handoff.messages].map(m => [m.id, { ...m, to }]));
149
- savePending(ctx, options.from, { ...target, wake: [...wake.values()], bcast: [...new Map([...target.bcast, ...pending.bcast].map(m => [m.id, m])).values()], ask: handoff.messages.some(m => m.re === pending.ask) ? 0 : pending.ask || target.ask, askTo: pending.askTo || target.askTo });
168
+ savePending(ctx, persona, { ...target, wake: [...wake.values()], bcast: [...new Map([...target.bcast, ...pending.bcast].map(m => [m.id, m])).values()], seen: [...new Set([...target.seen, ...pending.seen])], ask: handoff.messages.some(m => m.re === pending.ask) ? 0 : pending.ask || target.ask, askTo: pending.askTo || target.askTo });
150
169
  await runtime.stop(options.from);
151
170
  frozen = false;
152
- await runtime.release(stage, options.from, handoff.cursor);
171
+ await runtime.release(stage, persona, handoff.cursor);
172
+ if (existsSync(pendingPath(ctx, options.from))) unlinkSync(pendingPath(ctx, options.from));
153
173
  return { ...result, ok: true };
154
174
  } catch (error) {
155
175
  if (!transferred) {
156
176
  if (frozen) await runtime.resume();
157
- if (stage) await runtime.cancel(stage);
177
+ if (stage) {
178
+ try { await runtime.cancel(stage); }
179
+ catch (cleanupError) { error.message += `; replacement cleanup failed: ${cleanupError.message}`; }
180
+ }
158
181
  }
159
182
  result.reason = error.message;
160
183
  if (transferred) result.reason += "; handoff recorded on hub; old seat held for recovery";
@@ -36,6 +36,20 @@ export async function routeAdmin({ req, res, q, P, auth, ctx }) {
36
36
  }
37
37
  return json(res, 200, keyUsageSnapshot(state, now(), ONLINE_MS));
38
38
  }
39
+ if (req.method === "POST" && P === "/unregister") {
40
+ const b = await body(req);
41
+ if (!b.session || !b.project) return json(res, 400, { error: "session and project required" });
42
+ const peer = state.peers[b.session];
43
+ if (peer && peer.project !== canon(b.project)) return json(res, 403, { error: "peer belongs to a different project" });
44
+ const self = auth?.identity?.name === b.session && scopeAllows(auth.identity, b.project, "write");
45
+ const owner = scopeAllows(auth?.identity, b.project, "owner") && (!peer || peer.kind === "agent");
46
+ if (AUTH_MODE !== "off" && !self && !owner) {
47
+ return json(res, 403, { error: "only the session itself or a project owner removing an agent can unregister its peer" });
48
+ }
49
+ delete state.peers[b.session];
50
+ markDirty();
51
+ return json(res, 200, { ok: true, removed: !!peer });
52
+ }
39
53
  if (req.method === "POST" && P === "/register") {
40
54
  const b = await body(req);
41
55
  const cpg = crossProjectGuard(auth, P, b);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.114",
3
+ "version": "0.18.116",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"