trantor 0.18.78 → 0.18.79

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.78",
3
+ "version": "0.18.79",
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": {
package/bin/crew/cmux.mjs CHANGED
@@ -55,6 +55,7 @@ function tracked(ctx) {
55
55
 
56
56
  function prepare(ctx, prune) {
57
57
  const result = tracked(ctx);
58
+ if (ctx.swap) return result.reuse;
58
59
  for (const id of result.stale) {
59
60
  console.log(` → closing stale stacked crew workspace for ${ctx.project} (${id})`);
60
61
  closeWorkspace(ctx, id);
@@ -178,6 +179,7 @@ function spawnAppleScript(ctx, specs, resolve) {
178
179
  function prepareAppleScriptWorkspace(ctx) {
179
180
  const ids = readRows(ctx).filter(row => row.project === ctx.project && row.kind === "cmuxws").map(row => row.handle);
180
181
  const reuse = ids.at(-1) || "";
182
+ if (ctx.swap) return reuse;
181
183
  for (const id of ids.slice(0, -1)) {
182
184
  console.log(` → closing stale stacked crew workspace for ${ctx.project} (${id})`);
183
185
  closeWorkspace(ctx, id);
package/bin/crew/core.mjs CHANGED
@@ -146,8 +146,9 @@ export function runnerCommand(ctx, agent, model = "", effort = null) {
146
146
  .join("");
147
147
  // #7777: the launcher resolved the catalog's per-difficulty effort for this seat; the runner
148
148
  // reads CREW_EFFORT, applies what the CLI accepts and logs the one-line summary.
149
+ const swapEnv = ctx.swap ? ` CREW_SWAP_STAGE=${shellQuote(ctx.swap.path)} CREW_SWAP_LABEL=${shellQuote(ctx.swap.label)} CREW_SWAP_AGENT=${shellQuote(ctx.swap.agent)} CREW_SWAP_DRIVER=${shellQuote(ctx.swap.driver || "")}` : "";
149
150
  const effortEnv = effort ? ` CREW_EFFORT=${shellQuote(JSON.stringify(effort))}` : "";
150
- return `cd ${shellQuote(ctx.dir)} && ${forwarded}CREW_MODEL=${shellQuote(model)}${effortEnv} RELAY_PROJECT=${shellQuote(ctx.project)} RELAY_URL=${shellQuote(ctx.hub)} node ${shellQuote(join(ROOT, "bin/crew-runner.mjs"))} ${shellQuote(agent)} ${shellQuote(ctx.dir)}`;
151
+ return `cd ${shellQuote(ctx.dir)} && ${forwarded}CREW_MODEL=${shellQuote(model)}${effortEnv}${swapEnv} RELAY_PROJECT=${shellQuote(ctx.project)} RELAY_URL=${shellQuote(ctx.hub)} node ${shellQuote(join(ROOT, "bin/crew-runner.mjs"))} ${shellQuote(agent)} ${shellQuote(ctx.dir)}`;
151
152
  }
152
153
 
153
154
  export function listPids(pattern) {
@@ -121,6 +121,7 @@ function orchPaneWorkspaces(ctx) {
121
121
 
122
122
  function prepareWorkspace(ctx, prune) {
123
123
  const tracked = trackedWorkspace(ctx);
124
+ if (ctx.swap) return tracked.reuse;
124
125
  const orchSpaces = orchPaneWorkspaces(ctx);
125
126
  for (const id of tracked.stale) {
126
127
  // #7285: never close reuse itself, nor a workspace hosting the tracked orch pane — `up` once
@@ -1,7 +1,6 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { call } from "./core.mjs";
4
- import { reapSeat } from "./state.mjs";
5
4
  import { resolveEffort } from "../../lib/model-catalog.mjs";
6
5
 
7
6
  function routeModel(ctx, provider, candidates, task, difficulty) {
@@ -21,7 +20,8 @@ export function resolveModel(ctx, agent, provider, task, difficulty) {
21
20
  const candidates = result.ok ? result.stdout.split(/\s+/).filter(Boolean).join(" ") : "";
22
21
  let model = routeModel(ctx, provider, candidates, task, difficulty);
23
22
  if (!model && candidates) {
24
- model = `${provider}/${candidates.split(" ")[0]}`;
23
+ const first = candidates.split(" ")[0];
24
+ model = first.startsWith(`${provider}/`) ? first : `${provider}/${first}`;
25
25
  console.error(`[crew] router unavailable for ${agent}:${provider} — using the provider's own catalog head (${model})`);
26
26
  }
27
27
  if (!model) throw new Error(`[crew] live model selection failed for ${agent}:${provider} — no router and no catalog; refusing opencode global default`);
@@ -31,7 +31,6 @@ export function resolveModel(ctx, agent, provider, task, difficulty) {
31
31
 
32
32
  export function resolveSpec(ctx, spec, task, difficulty, skipped) {
33
33
  const [agent, ...rest] = spec.split(":");
34
- reapSeat(ctx, agent);
35
34
  let field = rest.join(":");
36
35
  if (!field) {
37
36
  if (agent === "glm") field = "zai-coding-plan";
@@ -0,0 +1,29 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { call } from "./core.mjs";
4
+
5
+ export const runnerRecord = (ctx, project, agent) => join(ctx.seatDir, `${project}-${agent}.runner.json`);
6
+
7
+ export function seatProcesses(ctx, project, agent) {
8
+ if (ctx.env.CREW_NO_PROC_KILL === "1") return [];
9
+ let label = agent, dir = ctx.dir;
10
+ try {
11
+ const record = JSON.parse(readFileSync(runnerRecord(ctx, project, agent), "utf8"));
12
+ label = record.label;
13
+ dir = record.dir;
14
+ } catch (error) { if (error.code !== "ENOENT") throw error; }
15
+ const rows = call("ps", ["-axo", "pid=,ppid=,command="]).stdout.split("\n").map(line => {
16
+ const match = line.trim().match(/^(\d+)\s+(\d+)\s+(.+)$/);
17
+ return match ? { pid: Number(match[1]), parent: Number(match[2]), command: match[3] } : null;
18
+ }).filter(Boolean);
19
+ const ids = new Set(rows.filter(row => row.command.endsWith(`crew-runner.mjs ${label} ${dir}`)).map(row => row.pid));
20
+ let before;
21
+ do { before = ids.size; for (const row of rows) if (ids.has(row.parent)) ids.add(row.pid); } while (ids.size > before);
22
+ return [...ids];
23
+ }
24
+
25
+ export function signalProcesses(pids, signal) {
26
+ for (const pid of pids) {
27
+ try { process.kill(pid, signal); } catch (error) { if (error.code !== "ESRCH") throw error; }
28
+ }
29
+ }
@@ -0,0 +1,28 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { setTimeout } from "node:timers/promises";
3
+ import { loadOrCreate } from "../../lib/identity.mjs";
4
+ import { ensureEnrolled } from "../../lib/enroll.mjs";
5
+ import { sfetchJson } from "../../lib/signed-fetch.mjs";
6
+
7
+ // A replacement registers before it touches the old runner's worktree or queue.
8
+ export async function awaitSwapRelease(env = process.env) {
9
+ if (!env.CREW_SWAP_STAGE) return;
10
+ const stage = env.CREW_SWAP_STAGE;
11
+ const session = `${env.CREW_SWAP_LABEL}:${env.RELAY_PROJECT}`;
12
+ const identity = loadOrCreate(session, "agent");
13
+ const enrollment = await ensureEnrolled(env.RELAY_URL, identity, env.RELAY_PROJECT);
14
+ if (!enrollment.ok) throw new Error(`replacement enrollment failed: ${enrollment.reason}`);
15
+ const response = await sfetchJson(`${env.RELAY_URL}/register`, {
16
+ identity, payload: { session, project: env.RELAY_PROJECT, kind: "agent", status: "replacement ready; awaiting handoff" },
17
+ signal: AbortSignal.timeout(8000),
18
+ });
19
+ if (!response.ok) throw new Error(`replacement registration failed: ${response.status}`);
20
+ writeFileSync(`${stage}.ready`, JSON.stringify({ pid: process.pid, session }));
21
+ const deadline = Date.now() + 120000;
22
+ while (!existsSync(`${stage}.release`)) {
23
+ if (Date.now() > deadline) throw new Error("swap handoff timed out; replacement stopped before taking work");
24
+ await setTimeout(100);
25
+ }
26
+ const handoff = JSON.parse(readFileSync(`${stage}.release`, "utf8"));
27
+ if (handoff.agent !== env.CREW_SWAP_AGENT) throw new Error("swap release names a different seat");
28
+ }
@@ -1,4 +1,5 @@
1
1
  import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { seatProcesses, signalProcesses } from "./processes.mjs";
2
3
  import { call, listPids, run } from "./core.mjs";
3
4
  import { hostId } from "../../lib/project.mjs";
4
5
  import { statePathFor as dockerStatePathFor, sweep as dockerSweep } from "../../lib/docker-janitor.mjs";
@@ -61,6 +62,7 @@ async function stopSeatContainers(ctx, project, agent) {
61
62
  async function killSeatProcesses(ctx, project, agent) {
62
63
  if (ctx.env.CREW_NO_PROC_KILL === "1" || !project || !agent) return;
63
64
  await stopSeatContainers(ctx, project, agent);
65
+ if (!ctx.dry) signalProcesses(seatProcesses(ctx, project, agent), "SIGKILL");
64
66
  const patterns = [`seats/${project}-${agent}\\.sh`, `crew-runner\\.mjs ${agent} .*/${project}$`];
65
67
  for (const pattern of patterns) {
66
68
  for (const pid of listPids(pattern)) run(ctx, "kill", ["-9", pid], { rendered: `kill -9 ${pid} 2>/dev/null` });
@@ -0,0 +1,163 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ import { setTimeout } from "node:timers/promises";
5
+ import { call, commandExists } from "./core.mjs";
6
+ import { resolveSpec } from "./models.mjs";
7
+ import { runnerRecord, seatProcesses, signalProcesses } from "./processes.mjs";
8
+ import { down, readRows, writeRows } from "./state.mjs";
9
+ import { providerStatus } from "../../lib/providers.mjs";
10
+ import { lookup } from "../../lib/model-catalog.mjs";
11
+ import { resolveKeys } from "../../lib/provider-keys.mjs";
12
+ import { resolveSecrets } from "../../lib/secrets.mjs";
13
+ import { signedGet, signedPost } from "../../hooks/lib/api.mjs";
14
+
15
+ const NATIVE = new Set(["codex", "kimi", "claude", "gemini", "dsh"]);
16
+
17
+ export function swapOptions(args) {
18
+ const positional = [];
19
+ const options = { task: "code", difficulty: "medium", provider: "", model: "" };
20
+ for (let i = 0; i < args.length; i++) {
21
+ const key = { "--task": "task", "--difficulty": "difficulty", "--diff": "difficulty", "--provider": "provider", "--model": "model" }[args[i]];
22
+ if (key) {
23
+ if (!args[i + 1] || args[i + 1].startsWith("--")) throw new Error(`missing value for ${args[i]}`);
24
+ options[key] = args[++i];
25
+ } else if (args[i].startsWith("--")) throw new Error(`unknown swap flag ${args[i]}`);
26
+ else positional.push(args[i]);
27
+ }
28
+ const [from, replacement = options.provider ? from : ""] = positional;
29
+ if (!from || !replacement || positional.length > 2) throw new Error("usage: trantor swap <old> [new[:provider/model]] [--provider P] [--model M] [--json]");
30
+ const agent = replacement.split(":")[0];
31
+ if (![from, agent].every(value => /^[A-Za-z0-9_-]+$/.test(value))) throw new Error("invalid seat name");
32
+ return { ...options, from, replacement, agent };
33
+ }
34
+
35
+ export async function validateReplacement(ctx, options, status = providerStatus) {
36
+ const explicit = options.provider;
37
+ const field = options.replacement.split(":").slice(1).join(":");
38
+ const native = NATIVE.has(options.agent) && !explicit;
39
+ const provider = native ? options.agent : (explicit || field.split("/")[0] || (options.agent === "glm" ? "zai-coding-plan" : options.agent));
40
+ const binary = native ? options.agent : "opencode";
41
+ if (!commandExists(binary, ctx.env)) throw new Error(`${binary} CLI not found on PATH; old seat unchanged`);
42
+ const registryName = provider === "zai-coding-plan" ? "zai" : provider;
43
+ const env = resolveKeys(ctx.env, [join(ctx.home, ".token-scrooge/.env"), join(ctx.home, ".agent-bus/.env")], resolveSecrets(ctx.env));
44
+ const rows = await status({ env, path: env.PATH, only: [registryName] });
45
+ const row = rows[0];
46
+ if (!row || row.state !== "connected") throw new Error(`${provider}: ${row?.reason || "no credential validator available"}; old seat unchanged`);
47
+ const spec = explicit ? `${options.agent}-provider:${provider}${options.model ? `/${options.model}` : ""}` : options.model ? `${options.agent}:${options.model}` : options.replacement;
48
+ const skipped = [];
49
+ const seat = resolveSpec({ ...ctx, env }, spec, options.task, options.difficulty, skipped);
50
+ if (!seat) throw new Error(skipped.join("; "));
51
+ if (native && seat.model && !lookup(seat.model).found) {
52
+ let models = [];
53
+ const cache = join(ctx.home, ".codex/models_cache.json");
54
+ if (options.agent === "codex" && existsSync(cache)) models = JSON.parse(readFileSync(cache, "utf8")).models || [];
55
+ if (!models.some(model => model.slug === seat.model)) throw new Error(`cannot validate native model ${seat.model}; old seat unchanged`);
56
+ }
57
+ if (!native) {
58
+ const catalog = call("opencode", ["models", provider], { env });
59
+ const models = catalog.stdout.split(/\s+/).filter(Boolean).map(m => m.startsWith(`${provider}/`) ? m : `${provider}/${m}`);
60
+ if (!catalog.ok || !models.includes(seat.model)) throw new Error(`model ${seat.model} is not in ${provider}'s available catalog; old seat unchanged`);
61
+ }
62
+ return { ...seat, agent: options.agent, driver: native ? "" : "opencode" };
63
+ }
64
+
65
+ export function pendingPath(ctx, agent) { return join(ctx.home, ".agent-bus", `pending-${agent}-${ctx.project}.json`); }
66
+
67
+ function readPending(ctx, agent) {
68
+ const file = pendingPath(ctx, agent);
69
+ if (!existsSync(file)) return { wake: [], bcast: [], seen: [], ask: 0, askTo: "" };
70
+ return JSON.parse(readFileSync(file, "utf8"));
71
+ }
72
+
73
+ function savePending(ctx, agent, pending) {
74
+ const file = pendingPath(ctx, agent);
75
+ writeFileSync(`${file}.swap`, JSON.stringify({ ...pending, agent, project: ctx.project, ts: Date.now() }));
76
+ renameSync(`${file}.swap`, file);
77
+ }
78
+
79
+ async function hubRequest(ctx, path, body) {
80
+ const response = body
81
+ ? await signedPost(`${ctx.hub}${path}`, body, { project: ctx.project })
82
+ : await signedGet(`${ctx.hub}${path}`, { project: ctx.project });
83
+ if (!response.ok || !response.json?.ok) throw new Error(response.json?.error || response.reason || `hub handoff failed (${response.status})`);
84
+ return response.json;
85
+ }
86
+
87
+ export function swapRuntime(ctx, adapters, spawn) {
88
+ let pids = [];
89
+ let replacementPid;
90
+ return {
91
+ validate: options => {
92
+ if (options.agent !== options.from && readRows(ctx).some(row => row.project === ctx.project && row.agent === options.agent)) throw new Error(`replacement seat ${options.agent} already exists; old seat unchanged`);
93
+ return validateReplacement(ctx, options);
94
+ },
95
+ async start(seat, stage) {
96
+ const staged = { ...ctx, swap: { ...stage, agent: seat.agent, driver: seat.driver } };
97
+ spawn(staged, { ...seat, agent: stage.label });
98
+ },
99
+ async ready(stage) {
100
+ const deadline = Date.now() + 30000;
101
+ 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);
104
+ }
105
+ 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");
108
+ process.kill(ready.pid, 0);
109
+ replacementPid = ready.pid;
110
+ return ready.session;
111
+ },
112
+ freeze(agent) { pids = seatProcesses(ctx, ctx.project, agent); signalProcesses(pids, "SIGSTOP"); },
113
+ resume() { signalProcesses(pids, "SIGCONT"); },
114
+ transfer: body => hubRequest(ctx, "/contracts/transfer", body),
115
+ async stop(agent) {
116
+ process.kill(replacementPid, 0);
117
+ await down(ctx, [agent], adapters);
118
+ signalProcesses(pids, "SIGKILL");
119
+ },
120
+ async cancel(stage) { await down(ctx, [stage.label], adapters); },
121
+ release(stage, agent, cursor) {
122
+ writeRows(ctx, readRows(ctx).map(row => row.project === ctx.project && row.agent === stage.label ? { ...row, agent } : row));
123
+ writeFileSync(runnerRecord(ctx, ctx.project, agent), JSON.stringify({ label: stage.label, dir: ctx.dir }));
124
+ writeFileSync(`${stage.path}.release`, JSON.stringify({ agent, cursor }));
125
+ },
126
+ };
127
+ }
128
+
129
+ export async function performSwap(ctx, options, runtime) {
130
+ const result = { ok: false, action: "swap", seat: options.from, to: options.agent, moved: [] };
131
+ let stage, frozen = false, transferred = false;
132
+ try {
133
+ const seat = await runtime.validate(options);
134
+ const label = `${options.agent}-swap-${randomUUID().slice(0, 8)}`;
135
+ mkdirSync(ctx.seatDir, { recursive: true });
136
+ stage = { label, path: join(ctx.seatDir, label) };
137
+ await runtime.start(seat, stage);
138
+ const readySession = await runtime.ready(stage);
139
+ frozen = true;
140
+ await runtime.freeze(options.from);
141
+ const pending = readPending(ctx, options.from);
142
+ const target = options.agent === options.from ? pending : readPending(ctx, options.agent);
143
+ const from = `${options.from}:${ctx.project}`, to = `${options.agent}:${ctx.project}`;
144
+ 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) });
145
+ transferred = true;
146
+ result.moved = [...new Set([...pending.wake.map(m => m.id), ...handoff.moved])];
147
+ const wake = new Map([...target.wake, ...pending.wake, ...handoff.messages].map(m => [m.id, { ...m, to }]));
148
+ savePending(ctx, options.agent, { ...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 });
149
+ await runtime.stop(options.from);
150
+ frozen = false;
151
+ if (options.agent !== options.from) savePending(ctx, options.from, { ...pending, wake: [], bcast: [], ask: 0, askTo: "" });
152
+ await runtime.release(stage, options.agent, handoff.cursor);
153
+ return { ...result, ok: true };
154
+ } catch (error) {
155
+ if (!transferred) {
156
+ if (frozen) await runtime.resume();
157
+ if (stage) await runtime.cancel(stage);
158
+ }
159
+ result.reason = error.message;
160
+ if (transferred) result.reason += "; handoff recorded on hub; old seat held for recovery";
161
+ return result;
162
+ }
163
+ }
@@ -41,7 +41,10 @@ import {
41
41
  import { costLine } from "../lib/state/cost.mjs";
42
42
  import { resolveStateFlags } from "../lib/state/flags.mjs";
43
43
 
44
- const AGENT = process.argv[2];
44
+ import { awaitSwapRelease } from "./crew/stage.mjs";
45
+
46
+ await awaitSwapRelease();
47
+ const AGENT = process.env.CREW_SWAP_AGENT || process.argv[2];
45
48
  const DIR = process.argv[3] || process.cwd();
46
49
  // Crew agents MUST share the orchestrator's project key (one repo = one lane).
47
50
  // RELAY_PROJECT is inherited from crew.mjs (the host's resolved key); else fall
@@ -241,7 +244,8 @@ const CLI = {
241
244
  // <label>:<provider>` for any opencode vendor the user configured — run with no per-provider code
242
245
  // here; its model id arrives pre-qualified (`<provider>/<model>`) as CREW_MODEL.
243
246
  const NATIVE = new Set(["codex", "gemini", "kimi", "claude", "dsh"]);
244
- const cli = CLI[AGENT] || (NATIVE.has(AGENT) ? null : CLI.opencode);
247
+ const DRIVER = process.env.CREW_SWAP_DRIVER || AGENT;
248
+ const cli = CLI[DRIVER] || (NATIVE.has(AGENT) ? null : CLI.opencode);
245
249
  if (!cli) { console.error(`unknown agent '${AGENT}' (native: ${[...NATIVE].join(", ")}; any other name = an opencode provider seat)`); process.exit(1); }
246
250
  if (!CLI[AGENT]) log(`'${AGENT}' is not a built-in seat — running it as an opencode provider (BYOM)`);
247
251
 
@@ -250,7 +254,7 @@ if (!CLI[AGENT]) log(`'${AGENT}' is not a built-in seat — running it as an ope
250
254
  // parameters the CLI can carry (codex -c model_reasoning_effort / claude --effort / opencode
251
255
  // --variant) and logs ONE line saying what it set — or that the model is uncatalogued.
252
256
  const EFFORT = (() => { try { return JSON.parse(process.env.CREW_EFFORT || "null"); } catch { return null; } })();
253
- const EFFORT_FLAG = EFFORT ? cliEffortFlag(AGENT, EFFORT) : { flag: "", text: "" };
257
+ const EFFORT_FLAG = EFFORT ? cliEffortFlag(DRIVER, EFFORT) : { flag: "", text: "" };
254
258
 
255
259
  // RUNNER_RULES / RUNNER_KICKOFF env overrides: the runner is also the substrate for non-crew
256
260
  // always-on seats (the fleet DUTY agent, bin/duty.mjs) whose doctrine is not "work your card".
@@ -617,6 +621,7 @@ const STATE_SCHEMA_FILE = join(homedir(), ".agent-bus", `state-schema-${AGENT}-$
617
621
  const STATE_MODE = (() => {
618
622
  if (!STATE_FLAG_ON) return false;
619
623
  if (AGENT !== "claude") { log(`${STATE_ENV}=1 but this seat is '${AGENT}' — state mode is claude-only (TDD §7.3); staying on the transcript path`); return false; }
624
+ if (DRIVER !== "claude") return false;
620
625
  const probe = hasJsonSchemaFlag((bin, args) => spawnSync(bin, args, { encoding: "utf8", timeout: 20000 }));
621
626
  if (!probe) { log(`\x1b[33m${STATE_ENV}=1 but this claude CLI has no --json-schema — state mode stays OFF (TDD §6)\x1b[0m`); return false; }
622
627
  try {
@@ -1304,7 +1309,8 @@ async function resolveWakeCard(messages, { session }) {
1304
1309
  await loadLessons();
1305
1310
  // start cursor at the CURRENT tip so we don't replay history
1306
1311
  let cursor = 0;
1307
- try { const r = await api(`/inbox?session=${encodeURIComponent(SESSION)}&since=0`); cursor = r.cursor || 0; } catch {}
1312
+ if (process.env.CREW_SWAP_STAGE) cursor = JSON.parse(readFileSync(`${process.env.CREW_SWAP_STAGE}.release`, "utf8")).cursor;
1313
+ else try { const r = await api(`/inbox?session=${encodeURIComponent(SESSION)}&since=0`); cursor = r.cursor || 0; } catch {}
1308
1314
  // kind "agent" on every beat (#6075): the peer row's kind is the hub's OWN record of what a
1309
1315
  // session is — the overseer's declared-crew exemption reads it, and on the remote hub there is
1310
1316
  // no crew-windows.txt to fall back to. /register preserves absent fields, so a seat running an
package/bin/crew.mjs CHANGED
@@ -5,7 +5,9 @@ import { createCmuxAdapter, spawnCmux } from "./crew/cmux.mjs";
5
5
  import { createHerdrAdapter, spawnHerdr } from "./crew/herdr.mjs";
6
6
  import { resolveSpec, reportSkipped } from "./crew/models.mjs";
7
7
  import { openOrchestrator } from "./crew/open.mjs";
8
- import { down, prune } from "./crew/state.mjs";
8
+ import { performSwap, swapOptions, swapRuntime } from "./crew/swap.mjs";
9
+ import { redactKeys } from "../lib/redact.mjs";
10
+ import { reapSeat, down, prune } from "./crew/state.mjs";
9
11
  import { spawnTerminal, spawnTmux } from "./crew/tmux.mjs";
10
12
  import { epochMs, failedAgents, verifyCrew } from "./crew/verify.mjs";
11
13
  import { guardCrossProjectUp } from "./crew/worktrees.mjs";
@@ -13,10 +15,21 @@ import { preflightFirstSeat } from "./crew/preflight.mjs";
13
15
  import { resolveAgentLaunchSpecs } from "../lib/agent-preferences.mjs";
14
16
  import { readConfig } from "../lib/project.mjs";
15
17
 
16
- const [command = "up", ...rawArgs] = process.argv.slice(2);
18
+ const [command = "up", ...inputArgs] = process.argv.slice(2);
19
+ const jsonMode = inputArgs.includes("--json");
20
+ const rawArgs = inputArgs.filter(arg => arg !== "--json");
21
+ const output = console.log.bind(console);
22
+ const diagnostics = [];
23
+ if (jsonMode) console.log = (...parts) => {
24
+ const line = redactKeys(parts.join(" "));
25
+ diagnostics.push(line);
26
+ console.error(line);
27
+ };
28
+ const jsonResult = (code, extra = {}) => ({ ok: code === 0, action: command, seat: resultSeat, moved: [], ...extra });
29
+ let resultSeat = rawArgs.filter(a => !a.startsWith("--")).join(",");
17
30
  let ctx;
18
31
  try { ctx = createContext(command); }
19
- catch (error) { console.error(error.message); process.exit(1); }
32
+ catch (error) { if (jsonMode) output(JSON.stringify(jsonResult(1, { reason: redactKeys(error.message) }))); else console.error(error.message); process.exit(1); }
20
33
 
21
34
  const adapters = {
22
35
  cmux: createCmuxAdapter(ctx),
@@ -39,7 +52,11 @@ function parseUpArgs(args) {
39
52
  }
40
53
 
41
54
  function spawnCrew(options, skipped) {
42
- const resolve = spec => resolveSpec(ctx, spec, options.task, options.difficulty, 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
+ };
43
60
  const pruneNow = () => prune(ctx, adapters);
44
61
  if (ctx.mux === "herdr") spawnHerdr(ctx, options.specs, resolve, pruneNow);
45
62
  else if (ctx.mux === "cmux") spawnCmux(ctx, options.specs, resolve, pruneNow);
@@ -56,6 +73,7 @@ async function runUp(args, verify = true) {
56
73
  const options = parseUpArgs(args);
57
74
  const requested = resolveAgentLaunchSpecs(options.specs, readConfig());
58
75
  options.specs = requested.specs;
76
+ resultSeat = options.specs.map(spec => spec.split(":")[0]).join(",");
59
77
  if (!options.specs.length) {
60
78
  console.log("usage: crew.mjs up [--task K --difficulty D] codex glm kimi deepseek (or set a default in Settings → Agents)");
61
79
  return 1;
@@ -95,25 +113,42 @@ async function runUp(args, verify = true) {
95
113
  return reportSkipped(skipped);
96
114
  }
97
115
 
116
+ function spawnReplacement(context, seat) {
117
+ const resolve = () => seat;
118
+ const specs = [seat.agent];
119
+ const noPrune = () => {};
120
+ if (context.mux === "herdr") spawnHerdr(context, specs, resolve, noPrune);
121
+ else if (context.mux === "cmux") spawnCmux(context, specs, resolve, noPrune);
122
+ else if (context.mux === "tmux") spawnTmux(context, specs, resolve);
123
+ else spawnTerminal(context, specs, resolve);
124
+ }
125
+
98
126
  async function swap(args) {
99
- const [oldAgent, replacement, ...flags] = args;
100
- if (!oldAgent || !replacement) {
101
- console.log("usage: trantor swap <oldAgent> <newAgent[:provider[/model]]> [--task K --difficulty D]");
102
- return 1;
103
- }
104
- console.log(`— tearing down old seat '${oldAgent}' in ${ctx.project} —`);
105
- await down(ctx, [oldAgent], adapters);
106
- console.log(`— spawning replacement: ${replacement} —`);
107
- const code = await runUp([...flags, replacement]);
108
- if (!code) console.log(`— swapped. RESEND the contract to '${replacement.split(":")[0]}' (it joined fresh with no context). —`);
109
- return code;
127
+ const options = swapOptions(args);
128
+ resultSeat = options.from;
129
+ if (resolveAgentLaunchSpecs([options.replacement], readConfig()).disabled.length) throw new Error(`replacement ${options.agent} is disabled in agent settings`);
130
+ if (!guardCrossProjectUp(ctx)) throw new Error("cross-project swap refused");
131
+ if (ctx.dry) throw new Error("swap requires a live bus; dry-run cannot verify a handoff");
132
+ return performSwap(ctx, options, swapRuntime(ctx, adapters, spawnReplacement));
110
133
  }
111
134
 
112
135
  let code = 0;
113
- if (command === "down") code = await down(ctx, rawArgs, adapters);
114
- else if (command === "prune") { prune(ctx, adapters); console.log(`— pruned dead crew rows (${ctx.statePath}) —`); }
115
- else if (command === "open") code = openOrchestrator(ctx, rawArgs);
116
- else if (command === "swap") code = await swap(rawArgs);
117
- else if (command === "up") code = guardCrossProjectUp(ctx) ? await runUp(rawArgs) : 1;
118
- else { usage(); code = 1; }
136
+ let result;
137
+ try {
138
+ if (command === "down") code = await down(ctx, rawArgs, adapters);
139
+ else if (command === "prune") { prune(ctx, adapters); console.log(`— pruned dead crew rows (${ctx.statePath}) —`); }
140
+ else if (command === "open") code = openOrchestrator(ctx, rawArgs);
141
+ else if (command === "swap") { result = await swap(rawArgs); code = result.ok ? 0 : 1; }
142
+ else if (command === "up") code = guardCrossProjectUp(ctx) ? await runUp(rawArgs) : 1;
143
+ else { usage(); code = 1; }
144
+ } catch (error) {
145
+ code = 1;
146
+ result = jsonResult(code, { reason: redactKeys(error.message) });
147
+ }
148
+ if (!result) result = jsonResult(code);
149
+ if (!result.ok && !result.reason) result.reason = diagnostics.at(-1) || `${command} failed; see stderr`;
150
+ if (result.reason) result.reason = redactKeys(result.reason);
151
+ if (jsonMode) output(JSON.stringify(result));
152
+ else if (result.reason) console.error(result.reason);
153
+ else if (command === "swap") console.log(`— swapped ${result.seat} → ${result.to}; moved contracts: ${result.moved.join(", ") || "none"} —`);
119
154
  process.exit(code);
@@ -6,6 +6,15 @@ const SLIM_DROP = new Set(["log", "history", "checklist"]);
6
6
  // #7968: the shape hollow-move.mjs posts — { base, changed[], unindexed[], dependents } or
7
7
  // { unavailable: true }; anything else is dropped so a stray payload cannot bloat the log.
8
8
  const BLAST_PATHS_MAX = 40;
9
+ // #11142: the fleet-wide cap evicts done/stale cards oldest-first, never an open one.
10
+ const TASK_CAP = 2000, TASK_TRIM_TO = 1750;
11
+ const EVICTABLE = new Set(["done", "stale"]);
12
+ function trimTasks(state) {
13
+ if (state.tasks.length <= TASK_CAP) return;
14
+ let drop = state.tasks.length - TASK_TRIM_TO;
15
+ state.tasks = state.tasks.filter(t => drop <= 0 || !EVICTABLE.has(t.status) || (drop--, false));
16
+ }
17
+
9
18
  function cleanBlast(v) {
10
19
  if (!v || typeof v !== "object" || Array.isArray(v)) return null;
11
20
  if (v.unavailable) return { unavailable: true };
@@ -153,7 +162,7 @@ export async function routeCards({ req, res, q, P, auth, ctx }) {
153
162
  by: b.by || "", ts: ts0, updated: ts0, history: [{ to: target, by: b.by || "", ts: ts0 }] };
154
163
  if (bgId) bt._aid = bgId; if (b.agentType) bt._atype = String(b.agentType).slice(0, 40);
155
164
  appendTaskNote(bt, b, ts0);
156
- state.tasks.push(bt); if (state.tasks.length > 2000) state.tasks.splice(0, 500);
165
+ state.tasks.push(bt); trimTasks(state);
157
166
  appendCardEvent("created", bt, b.by, null, target);
158
167
  markDirty(); return json(res, 200, { ok: true, task: bt, created: true });
159
168
  }
@@ -180,7 +189,7 @@ export async function routeCards({ req, res, q, P, auth, ctx }) {
180
189
  { const dr = cleanDrill(b.drill); if (dr) t.drill = dr; } // #6452 — the card's drill line, rides `extra`
181
190
  if (b.source === "cc-subagent") { t._fp = subFp(b.title); if (b.agentType) t._atype = String(b.agentType).slice(0, 40); if (b.agentId) t._aid = String(b.agentId).slice(0, 80); if (b.parent) t.parent = String(b.parent).slice(0, 120); t.count = 1; if (t.status === "doing") { t._everStarted = true; t._inflight = 1; } }
182
191
  appendTaskNote(t, b, ts0);
183
- state.tasks.push(t); if (state.tasks.length > 2000) state.tasks.splice(0, 500);
192
+ state.tasks.push(t); trimTasks(state);
184
193
  appendCardEvent("created", t, b.by, null, st0);
185
194
  // A COMMIT closes the focus. A focus card says "this session is working on X right now"; the
186
195
  // commit is X arriving, so the card that was rolling forever now completes with the commit
@@ -330,7 +339,7 @@ export async function routeCards({ req, res, q, P, auth, ctx }) {
330
339
  if (seen.has(t.todoKey) || t.status === "done") continue; // keep accomplished work on the board
331
340
  state.tasks = state.tasks.filter(x => x.id !== t.id); appendCardEvent("deleted", t, session, null, null); markDirty();
332
341
  }
333
- if (state.tasks.length > 2000) state.tasks.splice(0, state.tasks.length - 2000);
342
+ trimTasks(state);
334
343
  return json(res, 200, { ok: true, count: todos.length });
335
344
  }
336
345
  // A REGULAR session's live "focus" card — what THIS session is working on right now, set from each
@@ -375,7 +384,7 @@ export async function routeCards({ req, res, q, P, auth, ctx }) {
375
384
  history: [{ to: "doing", by: session, ts: now() }] };
376
385
  state.tasks.push(t); appendCardEvent("created", t, session, null, "doing");
377
386
  appendEvent("focus", project, session, { taskId: t.id, title, shift: false });
378
- if (state.tasks.length > 2000) state.tasks.splice(0, state.tasks.length - 2000);
387
+ trimTasks(state);
379
388
  }
380
389
  markDirty(); return json(res, 200, { ok: true, id: t.id, task: t });
381
390
  }
@@ -0,0 +1,58 @@
1
+ function heldMessages(ctx, source, project, pendingIds) {
2
+ const ids = new Set(pendingIds);
3
+ const senders = new Set(ctx.state.messages.filter(m => m.to === source && m.project === project).map(m => m.from));
4
+ for (const sender of senders) {
5
+ for (const contract of ctx.contractsFor(sender, { project, windowMs: Number.MAX_SAFE_INTEGER })) {
6
+ if (contract.to !== source) continue;
7
+ if (!contract.answered && !["ack", "superseded"].includes(contract.disposition)) ids.add(contract.id);
8
+ }
9
+ }
10
+ return ctx.state.messages.filter(m => ids.has(m.id) && m.to === source && m.project === project);
11
+ }
12
+
13
+ export async function routeContractTransfer({ req, res, q, P, auth, ctx }) {
14
+ if (P !== "/contracts/transfer") return false;
15
+ if (!["GET", "POST"].includes(req.method)) return ctx.json(res, 405, { error: "GET or POST required" });
16
+ const b = req.method === "POST" ? await ctx.body(req) : q;
17
+ const { from, to, project } = b;
18
+ if (!project || !from || !to || !from.endsWith(`:${project}`) || !to.endsWith(`:${project}`)) {
19
+ return ctx.json(res, 400, { error: "source and replacement must belong to the named project" });
20
+ }
21
+ const signer = auth?.identity?.name;
22
+ if (signer && !signer.endsWith(`:${project}`) && auth.identity.kind !== "human") {
23
+ return ctx.json(res, 403, { error: "swap must be requested from its project" });
24
+ }
25
+ // #11221: a seat may never move another seat's work; only the orchestrator or a human swaps.
26
+ if (signer && auth.identity.kind !== "human" && ctx.state.peers[signer]?.kind !== "orch") {
27
+ return ctx.json(res, 403, { error: "only the project's orchestrator or a human can swap seats" });
28
+ }
29
+ const messages = heldMessages(ctx, from, project, b.pendingIds || []);
30
+ if (req.method === "GET") return ctx.json(res, 200, { ok: true, messages });
31
+ if (!ctx.state.peers[b.readySession]?.lastSeen || ctx.state.peers[b.readySession].project !== project) {
32
+ return ctx.json(res, 409, { error: "replacement has not registered on this project's bus" });
33
+ }
34
+ // IDs and original senders survive the move, so replies settle the assigner's original ledger.
35
+ for (const message of messages) message.to = to;
36
+ const ids = new Set(messages.map(m => m.id));
37
+ for (const message of ctx.state.messages) {
38
+ if (message.project !== project) continue;
39
+ if (message.kind === "ask" && message.from === from && (ids.has(message.re) || message.id === b.heldAsk)) {
40
+ message.from = to;
41
+ for (const answer of ctx.state.messages) {
42
+ if (answer.re !== message.id || answer.to !== from) continue;
43
+ answer.to = to;
44
+ if (!ids.has(answer.id)) { messages.push(answer); ids.add(answer.id); }
45
+ }
46
+ }
47
+ }
48
+ for (const card of ctx.state.tasks) {
49
+ if (card.project !== project || card.assignee !== from || !["todo", "doing", "testing", "blocked", "failed"].includes(card.status)) continue;
50
+ card.assignee = to;
51
+ card.updated = ctx.now();
52
+ ctx.appendTaskLog(card, signer || from, `swap reassigned ${from} → ${to}`);
53
+ ctx.appendCardEvent("updated", card, signer || from);
54
+ }
55
+ ctx.appendEvent("crew.swap", project, signer || from, { from, to, moved: [...ids] });
56
+ ctx.markDirty();
57
+ return ctx.json(res, 200, { ok: true, moved: [...ids], messages, cursor: ctx.state.seq });
58
+ }
@@ -1,7 +1,9 @@
1
1
  /* oxlint-disable anti-slop/no-conditional-empty-object-spread -- SAFETY: Optional message fields must remain absent, not undefined; preserving the existing spread keeps the signed wire shape identical. */
2
+ import { routeContractTransfer } from "./contract-transfer.mjs";
2
3
  import { setTimeout, setInterval, clearInterval } from "node:timers";
3
4
 
4
5
  export async function routeMessages({ req, res, q, P, auth, ctx }) {
6
+ if (await routeContractTransfer({ req, res, q, P, auth, ctx })) return true;
5
7
  const {
6
8
  state, body, json, stripNulText, crossProjectGuard, touch, pushToStreams,
7
9
  appendEvent, appendCardEvent, appendTaskLog, markDelivered, contractsFor, canUseInboxSession, inboxWindow,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.78",
3
+ "version": "0.18.79",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"