trantor 0.18.77 → 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.77",
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
+ }
@@ -16,13 +16,13 @@ import { applyWorktreeDeclaration, ensureSeatWorktree, provisioningLines, readWo
16
16
  import {
17
17
  AUTH_MARKER_RE, classifyFailure, looksLikeAuthDeath,
18
18
  verdictFor, substantiveOutput, stallVerdict, cutSignalFor,
19
- BILLING_RE, looksLikeBillingDeath, usageSaysNoWork, zeroUsageVerdict,
19
+ BILLING_RE, looksLikeBillingDeath, usageSaysNoWork, zeroUsageVerdict, noDeliveryVerdict,
20
20
  permissionRejection, looksLikePermissionDeath,
21
21
  readPromptText, stripPromptEcho,
22
22
  } from "../lib/classify-failure.mjs";
23
23
  import { capWake, capBcast, pickLessons, composePrompt, contractBase, baseLine } from "./crew-payload.mjs";
24
24
  import {
25
- cardRefs, wakeCard, carriesWork, parseTurnTokens, parseResetAt, reasonWithBalances, quotaResetAt, PARKING_REASONS,
25
+ cardRefs, servedContractCard, carriesWork, parseTurnTokens, parseResetAt, reasonWithBalances, quotaResetAt, PARKING_REASONS,
26
26
  senderProjectOf, isLinkedProject, stateSkipReason, isMessageCardTitle, OPEN_CARD_STATUSES,
27
27
  CUT_CHAIN_PARK_MIN, cutChainEvidence, isBoundedPark,
28
28
  } from "../lib/turn-policy.mjs";
@@ -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".
@@ -279,6 +283,11 @@ let lastEmptyOutput = false;
279
283
  // the CLI printed its banner and quit. The both-streams-silent rule above stays; this is the
280
284
  // second, harder-to-see empty shape.
281
285
  let lastEmptyTurn = false;
286
+ // #10197: exit 0 on a turn BOUND to a card contract with neither a new commit nor a card move to
287
+ // testing/done — the ibkr shape (48s, prose, card still todo, announced done). The card's status
288
+ // at turn end rides along so the assigner's notice can name it.
289
+ let lastNoDelivery = false;
290
+ let lastNoDeliveryStatus = "";
282
291
  const ERRF = join(homedir(), ".agent-bus", `err-${AGENT}-${PROJ}.txt`);
283
292
  const DUTY_NUDGES = process.env.RUNNER_DUTY_NUDGES === "1";
284
293
  const DUTY_NUDGE_STATE = process.env.RUNNER_DUTY_NUDGE_STATE
@@ -612,6 +621,7 @@ const STATE_SCHEMA_FILE = join(homedir(), ".agent-bus", `state-schema-${AGENT}-$
612
621
  const STATE_MODE = (() => {
613
622
  if (!STATE_FLAG_ON) return false;
614
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;
615
625
  const probe = hasJsonSchemaFlag((bin, args) => spawnSync(bin, args, { encoding: "utf8", timeout: 20000 }));
616
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; }
617
627
  try {
@@ -742,6 +752,16 @@ async function runTurn(prompt, isFirst, trigger = "kickoff", opts = {}) {
742
752
  // #7759: the worktree snapshot the turn is judged against at its end — porcelain covers edits
743
753
  // AND new untracked files, which a HEAD-only comparison misses.
744
754
  const statusBefore = gitOut(["status", "--porcelain"], TURN_DIR);
755
+ // #10197: the card status snapshot the turn is judged against at its end — a move to
756
+ // testing/done under the turn is delivery even without a commit. null = the hub read failed:
757
+ // the delivery check stands down rather than claim no-delivery on a transport error.
758
+ let cardStatusBefore = null;
759
+ if (!opts.state && sessionCard > 0) {
760
+ try {
761
+ const { task } = await api(`/card?project=${encodeURIComponent(PROJ)}&id=${sessionCard}`);
762
+ cardStatusBefore = task ? String(task.status || "") : null;
763
+ } catch { cardStatusBefore = null; }
764
+ }
745
765
  // #9778: the docker snapshot the turn is judged against — containers the CLI spawns are recorded
746
766
  // against the seat's card at turn end, then stopped when the card closes or the seat parks.
747
767
  const dockerBefore = listContainers();
@@ -1011,16 +1031,38 @@ exit $turn_exit`;
1011
1031
  effExit = 1;
1012
1032
  log("\x1b[31mexit 0 but the session usage record resolved to all zeros — no model work happened; treating as FAILED\x1b[0m");
1013
1033
  }
1034
+ // #10197: a turn BOUND to a card contract reads done only on DELIVERY — a NEW commit since the
1035
+ // turn started, or the card moved to testing/done under it. The ibkr shape (exit 0, output, no
1036
+ // commit, card still todo) is NO-DELIVERY: the ledger names it and the contract stays owed.
1037
+ // Judged on hub reads of the card status; a failed read keeps today's label, never no-delivery.
1038
+ let cardStatusAtEnd = "", noDelivery = false;
1039
+ if (!cut && !opts.state && realExit === 0 && effExit === 0 && sessionCard > 0 && cardStatusBefore !== null) {
1040
+ let endStatus = null;
1041
+ try {
1042
+ const { task } = await api(`/card?project=${encodeURIComponent(PROJ)}&id=${sessionCard}`);
1043
+ endStatus = task ? String(task.status || "") : null;
1044
+ } catch { endStatus = null; }
1045
+ if (endStatus !== null) {
1046
+ cardStatusAtEnd = endStatus;
1047
+ const moved = ["testing", "done"].includes(endStatus) && !["testing", "done"].includes(cardStatusBefore);
1048
+ noDelivery = !newCommit && !moved;
1049
+ }
1050
+ }
1051
+ lastNoDelivery = noDelivery;
1052
+ lastNoDeliveryStatus = cardStatusAtEnd;
1053
+ if (noDelivery) log(`\x1b[33mexit 0 but the turn DELIVERED NOTHING on #${sessionCard} — no new commit, card still ${cardStatusAtEnd || "unknown"}\x1b[0m`);
1014
1054
  // #5868: the verdict rides the telemetry row so a classification survives the pane scrolling
1015
1055
  // away — the same "classified X because Y" shape the runner logs. A silent cut names its own
1016
1056
  // verdict (#7752): the exit under it is the sweep's SIGPIPE. A box cut passes `cut` so its 137
1017
1057
  // reads as the sweep's SIGKILL (#7099); a zero-usage turn names its own verdict too (#9724).
1018
- const verdict = stallCut ? stallVerdict() : zeroUsage ? zeroUsageVerdict() : verdictFor(realExit, effExit, lastEmptyOutput, ownOut, lastEmptyTurn, cut);
1058
+ const verdict = stallCut ? stallVerdict() : zeroUsage ? zeroUsageVerdict()
1059
+ : noDelivery ? noDeliveryVerdict(sessionCard, cardStatusAtEnd || "unknown")
1060
+ : verdictFor(realExit, effExit, lastEmptyOutput, ownOut, lastEmptyTurn, cut);
1019
1061
  // #6289: every ledger row names in ONE field what happened to the turn — cut, stalled (#7752),
1020
1062
  // api-error, completed — and what it cost (0 means "not reported", never "free"). #7762: `card`
1021
1063
  // binds the row to the card the turn worked (0 = kickoff/pulse) so the seat record attributes
1022
1064
  // empty/stalled turns to the card that produced nothing. `cut` stays too: the drills read it.
1023
- const outcome = cut ? (stallCut ? "stalled" : "cut") : (effExit !== 0 ? "api-error" : lastEmptyTurn ? "empty" : "completed");
1065
+ const outcome = cut ? (stallCut ? "stalled" : "cut") : (effExit !== 0 ? "api-error" : noDelivery ? "no-delivery" : lastEmptyTurn ? "empty" : "completed");
1024
1066
  // #7756: a clean turn that ASKED its assigner is demoted-but-owed, not "completed". The judge
1025
1067
  // (deliverWake's /contracts read) renames the ledger row, so "asked" is what the log keeps.
1026
1068
  let finalOutcome = outcome;
@@ -1251,7 +1293,9 @@ async function newestOwnCard(session, why) {
1251
1293
  } catch { return 0; } // board unreadable: no binding beats a guessed one
1252
1294
  }
1253
1295
  async function resolveWakeCard(messages, { session }) {
1254
- const cited = wakeCard(messages, { session });
1296
+ // #10824: the contract message BEING SERVED names the card — the newest direct message's own
1297
+ // binding — never a contract still queued behind it and never the session's previous card.
1298
+ const cited = servedContractCard(messages, { session });
1255
1299
  if (!(cited > 0)) return 0;
1256
1300
  try {
1257
1301
  const { task } = await api(`/card?project=${encodeURIComponent(PROJ)}&id=${cited}`);
@@ -1265,7 +1309,8 @@ async function resolveWakeCard(messages, { session }) {
1265
1309
  await loadLessons();
1266
1310
  // start cursor at the CURRENT tip so we don't replay history
1267
1311
  let cursor = 0;
1268
- 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 {}
1269
1314
  // kind "agent" on every beat (#6075): the peer row's kind is the hub's OWN record of what a
1270
1315
  // session is — the overseer's declared-crew exemption reads it, and on the remote hub there is
1271
1316
  // no crew-windows.txt to fall back to. /register preserves absent fields, so a seat running an
@@ -1312,6 +1357,7 @@ async function resolveWakeCard(messages, { session }) {
1312
1357
  }
1313
1358
  let retryAt = 0; // 0 = deliver at the next opportunity
1314
1359
  let deliveryFails = 0; // consecutive failed attempts at the SAME pending batch
1360
+ let noDeliveryRetry = false;
1315
1361
  if (pendingWake.length) log(`\x1b[33m${pendingWake.length} message(s) survived from a previous run — redelivering\x1b[0m`);
1316
1362
 
1317
1363
  // #7060: the turn the boot line was read as a promise about. It is a transcript turn by
@@ -1455,6 +1501,12 @@ async function resolveWakeCard(messages, { session }) {
1455
1501
  // a bounded park instead of queueing behind the window; a mention does not, because the room
1456
1502
  // talking is not a request. An exhausted or auth park is a wall and stands anyway (#6134).
1457
1503
  const directWake = wake.filter(m => m.to === SESSION);
1504
+ // #10197: no-delivery owes the old contract, but its retry delay must not batch away
1505
+ // fresh contracts. Only actual CLI failures impose backoff on a new wake.
1506
+ if (noDeliveryRetry) {
1507
+ unpark("new wake after no-delivery");
1508
+ retryAt = 0; deliveryFails = 0; noDeliveryRetry = false;
1509
+ }
1458
1510
  if (parkState && directWake.length) {
1459
1511
  if (isBoundedPark(parkState.reason)) {
1460
1512
  unpark(`direct message from ${directWake[0].from || "?"}`);
@@ -1491,6 +1543,7 @@ async function resolveWakeCard(messages, { session }) {
1491
1543
  } catch { return 0; }
1492
1544
  }
1493
1545
  async function deliverWake() {
1546
+ noDeliveryRetry = false;
1494
1547
  // #7756: captured and cleared HERE so an early return below never leaks the flag into a
1495
1548
  // later turn — only the turn the answer released reads it.
1496
1549
  const resumedAfterAsk = askReleased;
@@ -1560,12 +1613,13 @@ async function resolveWakeCard(messages, { session }) {
1560
1613
  // #6134: ONE SESSION PER CARD; a different card starts a fresh CLI session and the seat is told.
1561
1614
  // #7061: bound by SHAPE, not position, so an order opening with what shipped binds the right card.
1562
1615
  // #7763: a message-card (or phantom) citation never binds — the seat's own newest card does.
1563
- const card = await resolveWakeCard(wakeForTurn, { session: SESSION });
1616
+ // #10824: the binding is the SERVED contract's; #7756's answer-released turn keeps the held card.
1617
+ const card = resumedAfterAsk ? sessionCard : await resolveWakeCard(wakeForTurn, { session: SESSION });
1564
1618
  // #7754: a `base:` sha this worktree cannot resolve is never worked from origin/main — the card
1565
1619
  // goes to blocked, the assigner is told, and no model turn is spent. The check keeps the id the
1566
- // wake cites in its text: #7763's rebinding covers confirmed message-card ids only, never a
1620
+ // served contract cites: #7763's rebinding covers confirmed message-card ids only, never a
1567
1621
  // card cited in the text, so the unresolvable-base branch blocks the cited card.
1568
- const baseCard = card || wakeCard(wakeForTurn, { session: SESSION });
1622
+ const baseCard = card || servedContractCard(wakeForTurn, { session: SESSION });
1569
1623
  const base = contractBase(wakeForTurn) || localMainHead();
1570
1624
  if (base && baseCard && !resolvesHere(base)) {
1571
1625
  const text = `cannot resolve base ${base} in ${TURN_DIR} (git cat-file -e failed) — #${baseCard} moved to blocked; push or fetch that sha, or name one this worktree can reach`;
@@ -1579,13 +1633,13 @@ async function resolveWakeCard(messages, { session }) {
1579
1633
  return;
1580
1634
  }
1581
1635
  const baseText = base ? `\n${baseLine(base)}\n` : "";
1582
- // #6289: the card the SESSION belongs to reads the citation when the rebinding binds nothing —
1583
- // #7763 decides WHICH card work binds to, never the turn shape. #7756: the turn an ask's
1584
- // ANSWER releases is the exception — it resumes the session the held contract already owns,
1585
- // whatever card the batch cites; only a new contract for a different card opens fresh.
1636
+ // #6289: the session SHAPE (fresh vs resume) reads the served contract's raw citation — a wake
1637
+ // whose card the board lacks still opens fresh, never a bare resume of the kickoff's CLI.
1638
+ // #10824: the LABEL is the binding, 0 included — a wake that binds nothing labels its turn 0
1639
+ // and never inherits the previous contract's card (#10197's judge then reads the right card).
1586
1640
  const sessionCardForTurn = card || baseCard;
1587
1641
  const fresh = !resumedAfterAsk && sessionCardForTurn > 0 && sessionCardForTurn !== sessionCard;
1588
- if (card) sessionCard = card;
1642
+ sessionCard = card;
1589
1643
  const cited = [...new Set(wakeForTurn.flatMap(m => cardRefs(m.text)))];
1590
1644
  const freshText = fresh
1591
1645
  ? `\n(FRESH SESSION for card #${card} — you are not the session that worked earlier cards and you remember none of them. Read your card first: relay_board with card:${card}.)\n`
@@ -1602,6 +1656,7 @@ async function resolveWakeCard(messages, { session }) {
1602
1656
  const stopDutyNudgeWatcher = startDutyNudgeWatcher(dutyPlan, tStart);
1603
1657
  let ec;
1604
1658
  turnAsk = null; // #7756: the judge reads THIS turn's ending, never a previous turn's ask
1659
+ lastNoDelivery = false; lastNoDeliveryStatus = ""; // #10197: runTurn judges THIS turn only
1605
1660
  const stateStep = !stateSkip("wake", card);
1606
1661
  try {
1607
1662
  ec = stateStep
@@ -1723,6 +1778,27 @@ async function resolveWakeCard(messages, { session }) {
1723
1778
  savePending(pendingWake, pendingBcast);
1724
1779
  await reportHealthy();
1725
1780
  log(`turn asked its assigner — holding the contract until ask #${awaitingAsk.id} is answered`);
1781
+ } else if (lastNoDelivery) {
1782
+ // #10197: the turn exited 0 but DELIVERED NOTHING on its card — no new commit, the card
1783
+ // still short of testing/done. "⚠ no commit" is not a receipt, so the sender's ledger keeps
1784
+ // the contract owed; the queue is kept and the ladder retries — a second no-delivery
1785
+ // attempt parks, exactly like the empty-turn ladder it sits beside.
1786
+ deliveryFails++;
1787
+ noDeliveryRetry = true;
1788
+ savePending(pendingWake, pendingBcast);
1789
+ const still = lastNoDeliveryStatus || "unresolved";
1790
+ if (deliveryFails >= 2) {
1791
+ retryAt = await parkSeat("no-delivery", pendingWake.length);
1792
+ await notifyAssigners(assigners,
1793
+ `⛔ your contract is PARKED on ${SESSION} (no-delivery: two exit-0 turns with no commit and card #${sessionCard} unmoved) · asked: "${asked}"`);
1794
+ lastTurnAt = Date.now();
1795
+ return;
1796
+ }
1797
+ const wait = RETRY_MS[Math.min(deliveryFails - 1, RETRY_MS.length - 1)];
1798
+ retryAt = Date.now() + wait;
1799
+ log(`\x1b[33mno commit, card #${sessionCard} still ${still} — wake not consumed, ${pendingWake.length} message(s) stay owed; retrying in ${Math.round(wait / 1000)}s\x1b[0m`);
1800
+ await notifyAssigners(assigners,
1801
+ `⚠ no commit, card #${sessionCard} still ${still} on ${SESSION} (exit 0, ${secs}s) · asked: "${asked}"`);
1726
1802
  } else if (lastEmptyTurn) {
1727
1803
  // #7759: the turn exited 0 but was HOLLOW — a banner is not work. The wake is NOT
1728
1804
  // consumed: the queue is kept and the ladder retries, and the assigner hears EMPTY,
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);
package/bin/summarize.mjs CHANGED
@@ -1,18 +1,8 @@
1
1
  #!/usr/bin/env node
2
- // trantor summarize — narrative cards: what was ASSIGNED and what was DONE, in plain language.
3
- //
4
- // trantor summarize [--project <p>] [--limit N] [--all] [--dry] [--quiet]
5
- //
6
- // Title-cleaning makes machine-generated cards readable; it cannot make them MEAN anything. This
7
- // gives each card a one-line narrative ("assigned — did") written by a CHEAP model from the card's
8
- // own thread (its events + the messages that cite it). The board then reads as a story a human can
9
- // follow, which was the ask: "what the agent was assigned, what the agent did, what the flow was."
10
- //
11
- // Economics by design (the Scrooge doctrine): candidates are MACHINE-TITLED cards without a
12
- // summary (--all widens to every unsummarized card), batched into ONE cheap-model call per hub,
13
- // difficulty easy, capped per run. The summary lands via /task/update and rides the tasks.extra
14
- // column — permanent, never recomputed. Runs ambiently from the heartbeat (hourly, detached) and
15
- // on demand as `trantor summarize`.
2
+ // trantor summarize [--project <p>] [--limit N] [--all] [--dry] [--quiet] — gives each machine-titled
3
+ // card a one-line "assigned — did" narrative written by ONE cheap-model call per hub from the card's
4
+ // own thread; lands via /task/update on tasks.extra (permanent, never recomputed). Runs ambiently
5
+ // from the heartbeat (hourly, detached) and on demand as `trantor summarize`.
16
6
  import { execSync, spawnSync } from "node:child_process";
17
7
  import { readFileSync, existsSync } from "node:fs";
18
8
  import { join } from "node:path";
@@ -40,7 +30,7 @@ const scroogeBin = () => process.env.SCROOGE_BIN
40
30
  // A title no human wrote: protocol frames, dumped prompts, id soup. These are the cards whose
41
31
  // board presence is noise until a narrative replaces them.
42
32
  const machineTitled = (t) =>
43
- /^\s*[<{[]/.test(t) || /<task-notification>|<system-reminder>|toolu_[A-Za-z0-9]/.test(t) ||
33
+ /^\s*[<{[]/.test(t) || /<task-notification>|<system-reminder>|<trantor-succession>|<trantor-takeover>|toolu_[A-Za-z0-9]/.test(t) ||
44
34
  /^\s*(subagent|general-purpose|Explore|Task|Plan):/i.test(t) || t.length > 130;
45
35
 
46
36
  const hubs = new Set([config.url || "http://127.0.0.1:4477", ...Object.values(config.hubs || {})]);
@@ -119,10 +119,12 @@ async function maybeEarlyWarn(stdinRaw, session) {
119
119
  // It is marked where the baton fires (here on the backstop, in Stop on the normal path).
120
120
  armBaton(sessionId, { projectDir, transcript, reason: "context-warn", windowId, tty, tokens: usage.tokens });
121
121
  // Tell the RUNNING agent (once, at arm time): wrap up and author the boundary handoff yourself.
122
- ARM_CTX = `<system-reminder>TRANTOR SUCCESSION ARMED — this session is at ${Math.round(usage.frac * 100)}% of its context window, and the baton is armed: your NEXT STOP fires the handoff. You are now responsible for your own succession:\n`
122
+ // NOT <system-reminder>: CC 2.1.292 escapes that tag in hook output, so the notice would
123
+ // arrive as literal text (#11110). Our own <trantor-…> tags pass through like the other hooks'.
124
+ ARM_CTX = `<trantor-succession>TRANTOR SUCCESSION ARMED — this session is at ${Math.round(usage.frac * 100)}% of its context window, and the baton is armed: your NEXT STOP fires the handoff. You are now responsible for your own succession:\n`
123
125
  + `1. Reach a real task boundary — finish, pause, or checkpoint your in-flight work NOW; do not start new work.\n`
124
126
  + `2. Author (or refresh) the rich handoff from your CURRENT state — via the handoff skill or the relay_handoff MCP tool — so the successor gets the freshest picture, not a digest of a session 36 seconds stale. A fresh model-authored handoff supersedes the auto-digest.\n`
125
- + `3. Then end your turn. The Stop hook fires the baton at that boundary.</system-reminder>`;
127
+ + `3. Then end your turn. The Stop hook fires the baton at that boundary.</trantor-succession>`;
126
128
  } catch {}
127
129
  }
128
130
 
@@ -18,6 +18,25 @@ function readStdin() {
18
18
  }
19
19
  // A prompt that is JUST an acknowledgement/continuation (anchored to end) — not a new focus.
20
20
  const ACK = /^(y|yes|yep|yeah|ok|okay|sure|go|go ahead|continue|proceed|do it|please|thanks|thank you|ty|next|k|cool|nice|great|perfect|sounds good|👍)[\s.!]*$/i;
21
+ // A paste arrives wrapped in `<pasted_content id="…">…</pasted_content>`: the wrapper is transport,
22
+ // not content — the operator typed what is inside (#10027's chat.rs rule, same channel). Peel it so
23
+ // the marker check and the focus title see the words, never the tag. Unwrapped text passes through.
24
+ function unwrapPasted(t) {
25
+ const s = String(t || "").trim();
26
+ if (!s.startsWith("<pasted_content")) return s;
27
+ const gt = s.indexOf(">");
28
+ if (gt < 0) return s;
29
+ const close = s.lastIndexOf("</pasted_content>");
30
+ return (close > gt ? s.slice(gt + 1, close) : s.slice(gt + 1)).trim();
31
+ }
32
+ // HARNESS-INJECTED prompts are not a human's focus. A CLOSED list of known prefixes, not "starts
33
+ // with <": a paste merely happens to start with a tag, and a human quoting JSON is still a human.
34
+ // (The old ^\s*[<{[] heuristic silently dropped every pasted operator prompt, #11110.)
35
+ const HARNESS_MARKERS = [
36
+ "<task-notification", "<system-reminder", "<teammate-message",
37
+ "<command-name", "<environment_context", "[SYSTEM NOTIFICATION",
38
+ ];
39
+ const isHarnessInjection = (t) => HARNESS_MARKERS.some(m => t.startsWith(m));
21
40
  function titleFrom(prompt) {
22
41
  let s = String(prompt || "").replace(/\s+/g, " ").trim();
23
42
  // strip a leading politeness/imperative wrapper so the card reads as the WORK, not "can you please…"
@@ -42,7 +61,7 @@ function loadRecapCtx(sessionId) {
42
61
  const mandate = rec.mode === "unattended"
43
62
  ? " Then RESUME the handoff's OPEN THREADS immediately — they are your work order; this succession is unattended, do NOT wait for the user."
44
63
  : " Then WAIT for the user.";
45
- return `<system-reminder>You took over via handoff ${rec.handoffId}. If you have not yet recapped it, your reply MUST begin with the ≤3-sentence recap (task, state, next step) before anything else — including before answering this message.${mandate}</system-reminder>`;
64
+ return `<trantor-takeover>You took over via handoff ${rec.handoffId}. If you have not yet recapped it, your reply MUST begin with the ≤3-sentence recap (task, state, next step) before anything else — including before answering this message.${mandate}</trantor-takeover>`;
46
65
  } catch { return ""; }
47
66
  }
48
67
  function emitAndExit() {
@@ -61,17 +80,16 @@ try {
61
80
  // don't card home-dir sessions (matches sessionstart's phantom-project guard)
62
81
  if (!process.env.RELAY_SESSION && !process.env.RELAY_PROJECT && cwd === homedir()) { emitAndExit(); }
63
82
  const trimmed = prompt.replace(/\s+/g, " ").trim();
83
+ const text = unwrapPasted(trimmed); // a paste's wrapper is transport; judge the words inside
64
84
  // skip empties, tiny continuations, and pure acks — they're not a new focus
65
- if (!trimmed || trimmed.length < 12 || ACK.test(trimmed)) { emitAndExit(); }
85
+ if (!text || text.length < 12 || ACK.test(text)) { emitAndExit(); }
66
86
  // The Accounts ask drill launches a real Claude session, so its scripted prompt traverses this
67
87
  // hook just like operator work. It is harness traffic, though, and must never become a focus card.
68
- if (/\bTRANTOR ASK DRILL\b/.test(trimmed)) { emitAndExit(); }
69
- // HARNESS-INJECTED prompts are not a human's focus. Task notifications, hook system-reminders and
70
- // protocol frames arrive through the same UserPromptSubmit channel, and carding one titled a board
71
- // card "<task-notification> <task-id>bavlqfmzq</task-id>…" — pure noise a human cannot read.
72
- if (/^\s*[<{[]/.test(trimmed) || /<task-notification>|<system-reminder>|<teammate-message/i.test(trimmed)) {
73
- emitAndExit();
74
- }
88
+ if (/\bTRANTOR ASK DRILL\b/.test(text)) { emitAndExit(); }
89
+ // Task notifications, hook reminders and protocol frames arrive through the same UserPromptSubmit
90
+ // channel, and carding one titled a board card "<task-notification> <task-id>bavlqfmzq</task-id>…"
91
+ // — pure noise a human cannot read.
92
+ if (isHarnessInjection(text)) { emitAndExit(); }
75
93
  const project = resolveProject(cwd);
76
94
  const session = process.env.RELAY_SESSION
77
95
  || (process.env.RELAY_AGENT ? `${process.env.RELAY_AGENT}:${project}` : `${hostId()}:${project}`);
@@ -79,18 +97,18 @@ try {
79
97
  // two Claude sessions in one project share (and fight over) a single focus card, and sub-agent
80
98
  // cards, whose `parent` is exactly this UUID, have nothing to nest under.
81
99
  const cc = String(input.session_id || "").slice(0, 120);
82
- const r = await signedPost("/focus", { session, project, title: titleFrom(trimmed), by: session, cc }, { session });
100
+ const r = await signedPost("/focus", { session, project, title: titleFrom(text), by: session, cc }, { session });
83
101
 
84
102
  // Only pay a model when the heuristic actually mangles the prompt. A short, already-clear ask
85
103
  // ("fix the login redirect") reads fine as-is and buying a rewrite for it is exactly the kind of
86
104
  // reflexive spend the economics doctrine exists to stop.
87
105
  const id = r?.json?.id;
88
- if (id && trimmed.length > 90 && process.env.TRANTOR_NO_SCROOGE_TITLES !== "1") {
106
+ if (id && text.length > 90 && process.env.TRANTOR_NO_SCROOGE_TITLES !== "1") {
89
107
  try {
90
108
  const busDir = process.env.AGENT_BUS_DIR || join(homedir(), ".agent-bus");
91
109
  mkdirSync(busDir, { recursive: true });
92
110
  const pf = join(busDir, `focus-prompt-${String(cc || session).replace(/[^A-Za-z0-9_.-]/g, "_")}.txt`);
93
- writeFileSync(pf, trimmed);
111
+ writeFileSync(pf, text);
94
112
  const worker = join(dirname(dirname(fileURLToPath(import.meta.url))), "bin", "focus-title.mjs");
95
113
  // Detached + unref'd + stdio ignored: the hook returns NOW. Nothing downstream waits on this,
96
114
  // and a worker that dies takes the heuristic title with it, which is a fine outcome.
@@ -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,
@@ -200,6 +200,13 @@ export function zeroUsageVerdict() {
200
200
  return "classified zero-usage because the session usage record resolved to all zeros — no model work happened";
201
201
  }
202
202
 
203
+ /** #10197: the verdict line for a card-bound turn that DELIVERED NOTHING — exit 0 with output but
204
+ * no new commit since turn start and the card unmoved (the ibkr specimen: 48s, prose, card still
205
+ * todo, announced done). Named here so the drill pins the string. */
206
+ export function noDeliveryVerdict(card, status) {
207
+ return `classified no-delivery because exit 0 with no new commit and card #${card} still ${status}`;
208
+ }
209
+
203
210
  /** Classify one failed turn. Returns { reason, matched } — matched is the evidence excerpt the
204
211
  * runner logs, so the next misclassification is diagnosable from the seat log alone. `cut` is the
205
212
  * runner's box/stall marker: 137 reads as the sweep's SIGKILL only under it — without the marker
@@ -53,6 +53,29 @@ export function assignedCardRef(text) {
53
53
  return m ? Number(m.slice(1).find((g) => g != null)) : 0;
54
54
  }
55
55
 
56
+ /// #10824: the CONTRACT MESSAGE BEING SERVED decides the card — the newest direct message's own
57
+ /// binding: a hub-stamped `card` field, else the id its own text ASSIGNS (#7061 shape), else its
58
+ /// first citation — never a contract queued behind it or the previous turn's card. Deference: a
59
+ /// newest message that re-cites an older assignment is commentary on it, and #7061's reading stands.
60
+ export function servedContractCard(messages, { session = "" } = {}) {
61
+ const all = (Array.isArray(messages) ? messages : []).filter(Boolean);
62
+ if (!all.length) return 0;
63
+ const direct = session ? all.filter((m) => m.to === session) : [];
64
+ if (!direct.length) return wakeCard(all, { session });
65
+ const sorted = newestLast(direct);
66
+ const served = sorted[sorted.length - 1];
67
+ const stamped = Number(served?.card);
68
+ if (Number.isFinite(stamped) && stamped > 0) return stamped;
69
+ const own = assignedCardRef(served?.text);
70
+ if (own) return own;
71
+ const named = cardRefs(served?.text);
72
+ if (named.length) {
73
+ const olderAssigned = new Set(sorted.slice(0, -1).map((m) => assignedCardRef(m?.text)).filter(Boolean));
74
+ if (!named.some((c) => olderAssigned.has(c))) return named[0];
75
+ }
76
+ return wakeCard(all, { session });
77
+ }
78
+
56
79
  /// #7061: the card a wake batch binds to. Direct messages outrank @mentions, the NEWEST assignment wins,
57
80
  /// and only an assignment-free batch falls back to the newest message's first citation.
58
81
  export function wakeCard(messages, { session = "" } = {}) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.77",
3
+ "version": "0.18.79",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"