trantor 0.18.115 → 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.115",
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,7 @@ 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";
6
7
 
7
8
  export async function unregisterSwapStage(hub, project, session) {
8
9
  const identity = loadOrCreate(session, "agent");
@@ -33,5 +34,8 @@ export async function awaitSwapRelease(env = process.env) {
33
34
  }
34
35
  const handoff = JSON.parse(readFileSync(`${stage}.release`, "utf8"));
35
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;
36
40
  await unregisterSwapStage(env.RELAY_URL, env.RELAY_PROJECT, session);
37
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,6 +1,6 @@
1
1
  import { unregisterSwapStage } from "./stage.mjs";
2
- import { updatePersona } from "../../lib/persona.mjs";
3
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { allocatePersona, updatePersona } from "../../lib/persona.mjs";
3
+ import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  import { randomUUID } from "node:crypto";
6
6
  import { setTimeout } from "node:timers/promises";
@@ -124,12 +124,15 @@ export function swapRuntime(ctx, adapters, spawn, { now = Date.now, pause = setT
124
124
  transfer: body => hubRequest(ctx, "/contracts/transfer", body),
125
125
  async stop(agent) {
126
126
  process.kill(replacementPid, 0);
127
- 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");
128
128
  signalProcesses(pids, "SIGKILL");
129
129
  },
130
130
  async cancel(stage) {
131
- await down(ctx, [stage.label], adapters);
132
- await unregisterSwapStage(ctx.hub, ctx.project, `${stage.label}:${ctx.project}`);
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");
133
136
  },
134
137
  release(stage, persona, cursor) {
135
138
  writeRows(ctx, readRows(ctx).map(row => row.project === ctx.project && row.persona === stage.label ? { ...row, persona } : row));
@@ -145,24 +148,28 @@ export async function performSwap(ctx, options, runtime) {
145
148
  let stage, frozen = false, transferred = false;
146
149
  try {
147
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;
148
154
  const label = `${options.agent}-swap-${randomUUID().slice(0, 8)}`;
149
155
  mkdirSync(ctx.seatDir, { recursive: true });
150
- 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 };
151
157
  await runtime.start(seat, stage);
152
158
  const readySession = await runtime.ready(stage);
153
159
  frozen = true;
154
160
  await runtime.freeze(options.from);
155
161
  const pending = readPending(ctx, options.from);
156
- const target = pending;
157
- 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}`;
158
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) });
159
165
  transferred = true;
160
166
  result.moved = [...new Set([...pending.wake.map(m => m.id), ...handoff.moved])];
161
167
  const wake = new Map([...target.wake, ...pending.wake, ...handoff.messages].map(m => [m.id, { ...m, to }]));
162
- 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 });
163
169
  await runtime.stop(options.from);
164
170
  frozen = false;
165
- 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));
166
173
  return { ...result, ok: true };
167
174
  } catch (error) {
168
175
  if (!transferred) {
@@ -39,14 +39,16 @@ export async function routeAdmin({ req, res, q, P, auth, ctx }) {
39
39
  if (req.method === "POST" && P === "/unregister") {
40
40
  const b = await body(req);
41
41
  if (!b.session || !b.project) return json(res, 400, { error: "session and project required" });
42
- if (AUTH_MODE !== "off" && (auth?.identity?.name !== b.session || !scopeAllows(auth.identity, b.project, "write"))) {
43
- return json(res, 403, { error: "only the session owner can unregister its peer" });
44
- }
45
42
  const peer = state.peers[b.session];
46
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
+ }
47
49
  delete state.peers[b.session];
48
50
  markDirty();
49
- return json(res, 200, { ok: true });
51
+ return json(res, 200, { ok: true, removed: !!peer });
50
52
  }
51
53
  if (req.method === "POST" && P === "/register") {
52
54
  const b = await body(req);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.115",
3
+ "version": "0.18.116",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"