trantor 0.18.78 → 0.18.80
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/bin/crew/cmux.mjs +2 -0
- package/bin/crew/core.mjs +2 -1
- package/bin/crew/herdr.mjs +1 -0
- package/bin/crew/models.mjs +2 -3
- package/bin/crew/processes.mjs +29 -0
- package/bin/crew/stage.mjs +28 -0
- package/bin/crew/state.mjs +2 -0
- package/bin/crew/swap.mjs +163 -0
- package/bin/crew-runner.mjs +28 -19
- package/bin/crew.mjs +56 -21
- package/hooks/lib/handoff-claims.mjs +121 -0
- package/hooks/sessionstart.mjs +3 -55
- package/hooks/stop-inbox.mjs +11 -40
- package/hub/routes/cards.mjs +13 -4
- package/hub/routes/contract-transfer.mjs +58 -0
- package/hub/routes/messages.mjs +2 -0
- package/lib/turn-policy.mjs +12 -0
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.80",
|
|
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) {
|
package/bin/crew/herdr.mjs
CHANGED
|
@@ -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
|
package/bin/crew/models.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/bin/crew/state.mjs
CHANGED
|
@@ -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
|
+
}
|
package/bin/crew-runner.mjs
CHANGED
|
@@ -24,7 +24,7 @@ import { capWake, capBcast, pickLessons, composePrompt, contractBase, baseLine }
|
|
|
24
24
|
import {
|
|
25
25
|
cardRefs, servedContractCard, carriesWork, parseTurnTokens, parseResetAt, reasonWithBalances, quotaResetAt, PARKING_REASONS,
|
|
26
26
|
senderProjectOf, isLinkedProject, stateSkipReason, isMessageCardTitle, OPEN_CARD_STATUSES,
|
|
27
|
-
CUT_CHAIN_PARK_MIN, cutChainEvidence, isBoundedPark,
|
|
27
|
+
CUT_CHAIN_PARK_MIN, cutChainEvidence, isBoundedPark, askDuringTurn, askedVerdict,
|
|
28
28
|
} from "../lib/turn-policy.mjs";
|
|
29
29
|
import { ocTurnUsage, dshTurnUsage, usageTotal } from "../lib/turn-usage.mjs";
|
|
30
30
|
import { listContainers, newContainers, recordContainers, statePathFor as dockerStatePathFor, sweep as dockerSweep } from "../lib/docker-janitor.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
|
-
|
|
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
|
|
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(
|
|
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".
|
|
@@ -352,17 +356,17 @@ let askReleased = false;
|
|
|
352
356
|
// session. deliverWake judges each clean wake turn against /contracts — an open ask renames the
|
|
353
357
|
// ledger row "asked" and HOLDS the wake (no failure count, no backoff, no park) until the answer.
|
|
354
358
|
let turnAsk = null;
|
|
355
|
-
async function askJudge() {
|
|
359
|
+
async function askJudge(startedAt) {
|
|
356
360
|
try {
|
|
357
361
|
const r = await api(`/contracts?session=${encodeURIComponent(SESSION)}&project=${encodeURIComponent(PROJ)}`);
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
const
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
return turnAsk ? "asked" : null;
|
|
362
|
+
const rows = [...(r?.contracts || []), ...(r?.abandonedContracts || []),
|
|
363
|
+
...(r?.supersededContracts || []), ...(r?.ackContracts || [])];
|
|
364
|
+
const ask = askDuringTurn(rows, startedAt);
|
|
365
|
+
turnAsk = ask && !ask.answered ? { id: ask.id, to: String(ask.to || "") } : null;
|
|
366
|
+
return ask ? redactKeys(askedVerdict(ask)) : null;
|
|
364
367
|
} catch { return null; }
|
|
365
368
|
}
|
|
369
|
+
|
|
366
370
|
function savePending(wake, bcast) {
|
|
367
371
|
try {
|
|
368
372
|
// the seen-set (#7778) and a held ask (#7756) keep the file alive even with both queues empty:
|
|
@@ -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 {
|
|
@@ -1026,6 +1031,12 @@ exit $turn_exit`;
|
|
|
1026
1031
|
effExit = 1;
|
|
1027
1032
|
log("\x1b[31mexit 0 but the session usage record resolved to all zeros — no model work happened; treating as FAILED\x1b[0m");
|
|
1028
1033
|
}
|
|
1034
|
+
// #11230: asking is delivery even without a commit or a completed card move.
|
|
1035
|
+
let askVerdict = null;
|
|
1036
|
+
if (!cut && realExit === 0 && effExit === 0 && opts.judgeOutcome) {
|
|
1037
|
+
askVerdict = await opts.judgeOutcome(t0);
|
|
1038
|
+
if (askVerdict) lastEmptyTurn = false;
|
|
1039
|
+
}
|
|
1029
1040
|
// #10197: a turn BOUND to a card contract reads done only on DELIVERY — a NEW commit since the
|
|
1030
1041
|
// turn started, or the card moved to testing/done under it. The ibkr shape (exit 0, output, no
|
|
1031
1042
|
// commit, card still todo) is NO-DELIVERY: the ledger names it and the contract stays owed.
|
|
@@ -1040,7 +1051,7 @@ exit $turn_exit`;
|
|
|
1040
1051
|
if (endStatus !== null) {
|
|
1041
1052
|
cardStatusAtEnd = endStatus;
|
|
1042
1053
|
const moved = ["testing", "done"].includes(endStatus) && !["testing", "done"].includes(cardStatusBefore);
|
|
1043
|
-
noDelivery = !newCommit && !moved;
|
|
1054
|
+
noDelivery = !newCommit && !moved && !askVerdict;
|
|
1044
1055
|
}
|
|
1045
1056
|
}
|
|
1046
1057
|
lastNoDelivery = noDelivery;
|
|
@@ -1051,6 +1062,7 @@ exit $turn_exit`;
|
|
|
1051
1062
|
// verdict (#7752): the exit under it is the sweep's SIGPIPE. A box cut passes `cut` so its 137
|
|
1052
1063
|
// reads as the sweep's SIGKILL (#7099); a zero-usage turn names its own verdict too (#9724).
|
|
1053
1064
|
const verdict = stallCut ? stallVerdict() : zeroUsage ? zeroUsageVerdict()
|
|
1065
|
+
: askVerdict ? askVerdict
|
|
1054
1066
|
: noDelivery ? noDeliveryVerdict(sessionCard, cardStatusAtEnd || "unknown")
|
|
1055
1067
|
: verdictFor(realExit, effExit, lastEmptyOutput, ownOut, lastEmptyTurn, cut);
|
|
1056
1068
|
// #6289: every ledger row names in ONE field what happened to the turn — cut, stalled (#7752),
|
|
@@ -1058,12 +1070,7 @@ exit $turn_exit`;
|
|
|
1058
1070
|
// binds the row to the card the turn worked (0 = kickoff/pulse) so the seat record attributes
|
|
1059
1071
|
// empty/stalled turns to the card that produced nothing. `cut` stays too: the drills read it.
|
|
1060
1072
|
const outcome = cut ? (stallCut ? "stalled" : "cut") : (effExit !== 0 ? "api-error" : noDelivery ? "no-delivery" : lastEmptyTurn ? "empty" : "completed");
|
|
1061
|
-
|
|
1062
|
-
// (deliverWake's /contracts read) renames the ledger row, so "asked" is what the log keeps.
|
|
1063
|
-
let finalOutcome = outcome;
|
|
1064
|
-
if (outcome === "completed" && opts.judgeOutcome) {
|
|
1065
|
-
try { finalOutcome = (await opts.judgeOutcome()) || outcome; } catch {}
|
|
1066
|
-
}
|
|
1073
|
+
const finalOutcome = askVerdict ? "asked" : outcome;
|
|
1067
1074
|
const telemetryRow = { ts: Date.now(), agent: AGENT, project: PROJ, turn: TURN, trigger, card: sessionCard || 0, model: MODEL || "cli-default", duration_ms: Date.now() - t0, exit: realExit, effExit, authFailed: effExit !== realExit, emptyOutput: lastEmptyOutput, emptyTurn: lastEmptyTurn, verdict, outcome: finalOutcome, tokens };
|
|
1068
1075
|
if (usage) telemetryRow.usage = usage;
|
|
1069
1076
|
if (dockerNew.length) telemetryRow.containers = dockerNew.map(c => c.name || c.id);
|
|
@@ -1304,7 +1311,8 @@ async function resolveWakeCard(messages, { session }) {
|
|
|
1304
1311
|
await loadLessons();
|
|
1305
1312
|
// start cursor at the CURRENT tip so we don't replay history
|
|
1306
1313
|
let cursor = 0;
|
|
1307
|
-
|
|
1314
|
+
if (process.env.CREW_SWAP_STAGE) cursor = JSON.parse(readFileSync(`${process.env.CREW_SWAP_STAGE}.release`, "utf8")).cursor;
|
|
1315
|
+
else try { const r = await api(`/inbox?session=${encodeURIComponent(SESSION)}&since=0`); cursor = r.cursor || 0; } catch {}
|
|
1308
1316
|
// kind "agent" on every beat (#6075): the peer row's kind is the hub's OWN record of what a
|
|
1309
1317
|
// session is — the overseer's declared-crew exemption reads it, and on the remote hub there is
|
|
1310
1318
|
// no crew-windows.txt to fall back to. /register preserves absent fields, so a seat running an
|
|
@@ -1769,6 +1777,7 @@ async function resolveWakeCard(messages, { session }) {
|
|
|
1769
1777
|
// the ask's id, so a restart keeps holding — and the failure ladder never sees it. The
|
|
1770
1778
|
// assigner is NOT notified here: the kind:ask message itself is already that notification.
|
|
1771
1779
|
awaitingAsk = turnAsk; turnAsk = null;
|
|
1780
|
+
deliveryFails = 0; retryAt = 0; noDeliveryRetry = false;
|
|
1772
1781
|
savePending(pendingWake, pendingBcast);
|
|
1773
1782
|
await reportHealthy();
|
|
1774
1783
|
log(`turn asked its assigner — holding the contract until ask #${awaitingAsk.id} is answered`);
|
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 {
|
|
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", ...
|
|
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 =>
|
|
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
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
else if (command === "
|
|
117
|
-
else if (command === "
|
|
118
|
-
else {
|
|
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);
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { asString, asRecord } from "../../lib/decode.mjs";
|
|
2
|
+
import { readFileSync, writeFileSync, renameSync, symlinkSync, readlinkSync, unlinkSync, statSync, readdirSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { handoffDir } from "../../lib/project.mjs";
|
|
6
|
+
import { readFirstPaths, pathsReadIn } from "./handoff.mjs";
|
|
7
|
+
|
|
8
|
+
export const CLAIM_TTL_SECONDS = 600;
|
|
9
|
+
|
|
10
|
+
export function claimHandoff(record, session, now, transcriptBytes = 0, token = "") {
|
|
11
|
+
if (record.consumed || !session?.session_id) return null;
|
|
12
|
+
if (record.claim && record.claim.expiresAt > now) return null;
|
|
13
|
+
const claim = { ...session, at: now, expiresAt: now + CLAIM_TTL_SECONDS, transcriptBytes, token };
|
|
14
|
+
return { ...record, consumed: false, claim,
|
|
15
|
+
states: [...(record.states || []), { state: "claimed", ts: now, by: session.session_id }] };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function recapHandoff(record, { sessionId, token, replied, missed }, now) {
|
|
19
|
+
if (record.consumed || record.claim?.session_id !== sessionId || record.claim?.token !== token) return null;
|
|
20
|
+
if (!replied || missed.length) return null;
|
|
21
|
+
return { ...record, consumed: true, consumedAt: now, consumedBy: record.claim,
|
|
22
|
+
states: [...(record.states || []), { state: "recapped", ts: now, by: sessionId }] };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function hasAssistantReply(body, claimedAt) {
|
|
26
|
+
return body.split("\n").some(line => {
|
|
27
|
+
let row;
|
|
28
|
+
try { row = asRecord(JSON.parse(line)); } catch { return false; }
|
|
29
|
+
if (row?.type !== "assistant" || row.isSidechain) return false;
|
|
30
|
+
const timestamp = asString(row.timestamp);
|
|
31
|
+
if (timestamp && Date.parse(timestamp) < claimedAt * 1000) return false;
|
|
32
|
+
const content = asRecord(row.message)?.content;
|
|
33
|
+
const text = asString(content);
|
|
34
|
+
if (text !== null) return text.trim().length > 0;
|
|
35
|
+
return Array.isArray(content) && content.some(block => {
|
|
36
|
+
const b = asRecord(block);
|
|
37
|
+
return b?.type === "text" && !!asString(b.text)?.trim();
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Serialize claim and recap writes; a killed hook's PID lock can be reclaimed (#11223).
|
|
43
|
+
function updateRecord(path, update) {
|
|
44
|
+
const lock = `${path}.lock`;
|
|
45
|
+
try { symlinkSync(String(process.pid), lock); }
|
|
46
|
+
catch {
|
|
47
|
+
try {
|
|
48
|
+
const owner = readlinkSync(lock);
|
|
49
|
+
try { process.kill(Number(owner), 0); return null; }
|
|
50
|
+
catch (error) { if (error.code !== "ESRCH") return null; }
|
|
51
|
+
if (readlinkSync(lock) !== owner) return null;
|
|
52
|
+
unlinkSync(lock);
|
|
53
|
+
symlinkSync(String(process.pid), lock);
|
|
54
|
+
} catch { return null; }
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
const next = update(JSON.parse(readFileSync(path, "utf8")));
|
|
58
|
+
if (!next) return null;
|
|
59
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
60
|
+
writeFileSync(tmp, JSON.stringify(next, null, 2));
|
|
61
|
+
renameSync(tmp, path);
|
|
62
|
+
return next;
|
|
63
|
+
} finally { unlinkSync(lock); }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function claimHandoffFile(path, session, now = Math.floor(Date.now() / 1000)) {
|
|
67
|
+
let bytes = 0;
|
|
68
|
+
try { bytes = statSync(session.transcript_path).size; } catch {}
|
|
69
|
+
return updateRecord(path, record => claimHandoff(record, session, now, bytes, randomUUID()));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function loadPendingHandoff(project, { claim = true, freshSession = null, dir = handoffDir(), now = Math.floor(Date.now() / 1000) } = {}) {
|
|
73
|
+
const prefix = `${project}-`;
|
|
74
|
+
let files;
|
|
75
|
+
try {
|
|
76
|
+
files = readdirSync(dir).filter(f => f.startsWith(prefix) && /^\d+\.json$/.test(f.slice(prefix.length)))
|
|
77
|
+
.sort((a, b) => Number(b.slice(prefix.length, -5)) - Number(a.slice(prefix.length, -5)));
|
|
78
|
+
} catch { return null; }
|
|
79
|
+
for (const file of files) {
|
|
80
|
+
try {
|
|
81
|
+
const path = join(dir, file);
|
|
82
|
+
const record = JSON.parse(readFileSync(path, "utf8"));
|
|
83
|
+
if (record.consumed) continue;
|
|
84
|
+
if (record.claim?.expiresAt > now) return null;
|
|
85
|
+
if (!claim) return record;
|
|
86
|
+
const next = claimHandoffFile(path, freshSession, now);
|
|
87
|
+
if (!next) return null;
|
|
88
|
+
const readFirst = readFirstPaths(next.summary || "");
|
|
89
|
+
writeFileSync(recapStampPath(dir, freshSession.session_id), JSON.stringify({
|
|
90
|
+
handoffId: next.id, file, token: next.claim.token, ts: now,
|
|
91
|
+
mode: next.mode === "unattended" ? "unattended" : "attended", readFirst,
|
|
92
|
+
}));
|
|
93
|
+
return next;
|
|
94
|
+
} catch { return null; }
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function recapStampPath(dir, sid) {
|
|
100
|
+
return join(dir, `recap-pending-${String(sid).replace(/[^A-Za-z0-9_.-]/g, "_")}.json`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function completeHandoffRecap({ sessionId, transcriptPath, dir = handoffDir(), now = Math.floor(Date.now() / 1000) }) {
|
|
104
|
+
const stampPath = recapStampPath(dir, sessionId);
|
|
105
|
+
try {
|
|
106
|
+
const stamp = JSON.parse(readFileSync(stampPath, "utf8"));
|
|
107
|
+
const file = stamp.file || `${stamp.handoffId}.json`;
|
|
108
|
+
if (file.includes("/") || file.includes("\\")) return null;
|
|
109
|
+
let missed = [];
|
|
110
|
+
const next = updateRecord(join(dir, file), record => {
|
|
111
|
+
if (record.claim?.session_id !== sessionId || record.claim?.token !== stamp.token) return null;
|
|
112
|
+
if (record.claim.transcript_path !== transcriptPath) return null;
|
|
113
|
+
const body = readFileSync(transcriptPath).subarray(record.claim.transcriptBytes).toString("utf8");
|
|
114
|
+
missed = pathsReadIn(transcriptPath, stamp.readFirst || []).missed;
|
|
115
|
+
return recapHandoff(record, { sessionId, token: stamp.token, missed,
|
|
116
|
+
replied: hasAssistantReply(body, record.claim.at) }, now);
|
|
117
|
+
});
|
|
118
|
+
if (next) unlinkSync(stampPath);
|
|
119
|
+
return { recapped: !!next, missed };
|
|
120
|
+
} catch { return null; }
|
|
121
|
+
}
|
package/hooks/sessionstart.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// trantor SessionStart hook — every session registers with the hub and gets a roster of OTHER live
|
|
3
3
|
// sessions injected, so independent sessions discover each other. Config: env RELAY_URL →
|
|
4
4
|
// ~/.agent-bus/config.json → http://127.0.0.1:4477. Identity: RELAY_SESSION → "<hostname>:<basename(cwd)>".
|
|
5
|
-
import { readFileSync,
|
|
5
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
6
6
|
import { join, basename, dirname } from "node:path";
|
|
7
7
|
import { homedir, hostname } from "node:os";
|
|
8
8
|
import { execSync, spawn } from "node:child_process";
|
|
@@ -10,7 +10,8 @@ import { fileURLToPath } from "node:url";
|
|
|
10
10
|
import { resolveProject, hostId, resolveHubInfo, knownProjects, nonSeatReason, nestedProjects, handoffDir, readOrchSession, writeOrchSession } from "../lib/project.mjs";
|
|
11
11
|
import { formatSubagentManifest } from "../lib/subagent-manifest.mjs";
|
|
12
12
|
import { updateAvailable, maybeNotifyDesktop, readConfig } from "./lib/update-check.mjs";
|
|
13
|
-
import { renderStateBlock,
|
|
13
|
+
import { renderStateBlock, capSummary } from "./lib/handoff.mjs";
|
|
14
|
+
import { loadPendingHandoff } from "./lib/handoff-claims.mjs";
|
|
14
15
|
import { maybeCheckBalances } from "./lib/balance-check.mjs";
|
|
15
16
|
import { getJSON, signedGet, signedPost, loadIdentity } from "./lib/api.mjs";
|
|
16
17
|
import { ledgerPaths, ensureStart, anchorCursor, writeCursor } from "./lib/inbox-ledger.mjs";
|
|
@@ -40,59 +41,6 @@ function buildDoctrineShortForm() {
|
|
|
40
41
|
`</trantor-build-doctrine>\n`;
|
|
41
42
|
}
|
|
42
43
|
|
|
43
|
-
// Load the most recent UNCONSUMED handoff for this project; `claim` marks it consumed so exactly one
|
|
44
|
-
// session takes it. A compaction-triggered start may show it but must NOT claim it from the new window.
|
|
45
|
-
function loadPendingHandoff(projectName, { claim = true, freshSession = null } = {}) {
|
|
46
|
-
try {
|
|
47
|
-
const dir = handoffDir(); // NEVER join(homedir(), …) here: a drill pointed at a temp bus dir must not claim the real handoffs
|
|
48
|
-
if (!existsSync(dir)) return null;
|
|
49
|
-
// Match ONLY "<projectName>-<numeric stamp>.json" (a loose startsWith caught leaked fixtures), and
|
|
50
|
-
// sort by the numeric stamp, newest first, since a string sort could pick a stale handoff.
|
|
51
|
-
const re = new RegExp("^" + projectName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "-(\\d+)\\.json$");
|
|
52
|
-
const files = readdirSync(dir)
|
|
53
|
-
.map(f => { const m = re.exec(f); return m ? { f, stamp: Number(m[1]) } : null; })
|
|
54
|
-
.filter(Boolean)
|
|
55
|
-
.sort((a, b) => b.stamp - a.stamp)
|
|
56
|
-
.map(x => x.f);
|
|
57
|
-
for (const f of files) {
|
|
58
|
-
const p = join(dir, f);
|
|
59
|
-
const rec = JSON.parse(readFileSync(p, "utf8"));
|
|
60
|
-
if (!rec.consumed) {
|
|
61
|
-
if (claim) {
|
|
62
|
-
// `consumed` is set at hook time, BEFORE the model has read anything, so also record WHO is
|
|
63
|
-
// taking over: baton-close waits for the successor's first assistant turn before closing the original.
|
|
64
|
-
rec.consumed = true;
|
|
65
|
-
rec.consumedAt = nowSec();
|
|
66
|
-
if (freshSession && (freshSession.session_id || freshSession.transcript_path)) {
|
|
67
|
-
rec.consumedBy = {
|
|
68
|
-
session_id: freshSession.session_id || "",
|
|
69
|
-
transcript_path: freshSession.transcript_path || "",
|
|
70
|
-
};
|
|
71
|
-
}
|
|
72
|
-
// §5 CLAIMED on the machine's ledger (SYSTEM-CONTRACT) and the recap net armed: a recap-pending
|
|
73
|
-
// stamp names this successor, so a successor that never recaps is mechanically impossible.
|
|
74
|
-
if (!Array.isArray(rec.states)) rec.states = [];
|
|
75
|
-
rec.states.push({ state: "claimed", ts: nowSec(), by: freshSession?.session_id || "" });
|
|
76
|
-
writeFileSync(p, JSON.stringify(rec, null, 2));
|
|
77
|
-
if (freshSession?.session_id) {
|
|
78
|
-
try {
|
|
79
|
-
// #5645: the mandate rides the stamp too, so prompt-focus's recap reminder pins
|
|
80
|
-
// the SAME rec.mode the injection below announces (attended=WAIT / unattended=RESUME).
|
|
81
|
-
// #8162: the READ-FIRST list rides the stamp as DATA, so the Stop hook can check the
|
|
82
|
-
// successor opened it instead of accepting a reply as proof it understood anything.
|
|
83
|
-
const readFirst = readFirstPaths(rec.summary || "");
|
|
84
|
-
writeFileSync(join(dir, `recap-pending-${String(freshSession.session_id).replace(/[^A-Za-z0-9_.-]/g, "_")}.json`),
|
|
85
|
-
JSON.stringify({ handoffId: rec.id, ts: nowSec(), mode: rec.mode === "unattended" ? "unattended" : "attended", readFirst }));
|
|
86
|
-
} catch {}
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
return rec;
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
} catch {}
|
|
93
|
-
return null;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
44
|
function nowSec() { try { return Number(execSync("date +%s", { encoding: "utf8" }).trim()) || 0; } catch { return 0; } }
|
|
97
45
|
|
|
98
46
|
function readStdin() {
|
package/hooks/stop-inbox.mjs
CHANGED
|
@@ -8,10 +8,12 @@ import { join, dirname } from "node:path";
|
|
|
8
8
|
import { spawn } from "node:child_process";
|
|
9
9
|
import { fileURLToPath } from "node:url";
|
|
10
10
|
import { homedir } from "node:os";
|
|
11
|
-
import { resolveProject, hostId,
|
|
11
|
+
import { resolveProject, hostId, busDir } from "../lib/project.mjs";
|
|
12
12
|
import { signedGet } from "./lib/api.mjs"; // signed: enforce hubs 401 unsigned reads — unsigned, T2 delivery is silently dead
|
|
13
13
|
import { ledgerPaths, ensureStart, anchorCursor, writeCursor } from "./lib/inbox-ledger.mjs";
|
|
14
|
-
import { readArm, clearArm, markHandedOff,
|
|
14
|
+
import { readArm, clearArm, markHandedOff, subagentsActive } from "./lib/handoff.mjs";
|
|
15
|
+
|
|
16
|
+
import { completeHandoffRecap } from "./lib/handoff-claims.mjs";
|
|
15
17
|
|
|
16
18
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
17
19
|
|
|
@@ -125,10 +127,14 @@ async function main() {
|
|
|
125
127
|
let input = {};
|
|
126
128
|
try { input = JSON.parse(raw || "{}"); } catch {}
|
|
127
129
|
|
|
128
|
-
// Loop guard FIRST, before any work: if we already blocked once this stop-cycle, the model has had
|
|
129
|
-
// its chance to deal with the inbox and is entitled to stop.
|
|
130
|
-
if (input.stop_hook_active) return allow();
|
|
131
130
|
if (process.env.RELAY_STOP_INBOX === "0") return allow();
|
|
131
|
+
const recap = completeHandoffRecap({ sessionId: String(input.session_id || ""), transcriptPath: input.transcript_path || "" });
|
|
132
|
+
if (recap?.missed.length && !input.stop_hook_active) {
|
|
133
|
+
process.stdout.write(JSON.stringify({ decision: "block", reason: `Read these handoff files before recapping: ${recap.missed.join(", ")}` }));
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
// A retry may satisfy the recap evidence, but must never loop the inbox block (#11223).
|
|
137
|
+
if (input.stop_hook_active) return allow();
|
|
132
138
|
|
|
133
139
|
const projectDir = input.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
134
140
|
|
|
@@ -151,41 +157,6 @@ async function main() {
|
|
|
151
157
|
process.stderr.write("[trantor] turn boundary reached — firing the armed baton\n");
|
|
152
158
|
}
|
|
153
159
|
} catch {}
|
|
154
|
-
// §5 RECAPPED (SYSTEM-CONTRACT): this session's FIRST turn boundary after claiming a handoff.
|
|
155
|
-
// By Stop time an assistant reply exists, and every prompt of that first turn carried the
|
|
156
|
-
// recap reminder (prompt-focus) — so the reply had the instruction in front of it. Record the
|
|
157
|
-
// transition on the handoff's own ledger and disarm the net.
|
|
158
|
-
try {
|
|
159
|
-
const sid = String(input.session_id || "");
|
|
160
|
-
if (sid) {
|
|
161
|
-
const stampPath = join(handoffDir(), `recap-pending-${sid.replace(/[^A-Za-z0-9_.-]/g, "_")}.json`);
|
|
162
|
-
if (existsSync(stampPath)) {
|
|
163
|
-
try {
|
|
164
|
-
const stamp = JSON.parse(readFileSync(stampPath, "utf8"));
|
|
165
|
-
// #8162: RECAPPED used to mean "a reply exists by Stop time", which a successor satisfies
|
|
166
|
-
// from the injected summary alone — so a handoff naming read-first files certified a
|
|
167
|
-
// takeover that opened none of them, three days running. Now the ledger asks the same
|
|
168
|
-
// question the state gate learned to ask: ground truth, not testimony. A Read/Grep tool
|
|
169
|
-
// call naming the path is evidence; the successor's own say-so is not.
|
|
170
|
-
const want = Array.isArray(stamp.readFirst) ? stamp.readFirst : [];
|
|
171
|
-
const { missed } = want.length
|
|
172
|
-
? pathsReadIn(input.transcript_path || "", want)
|
|
173
|
-
: { missed: [] };
|
|
174
|
-
if (missed.length) {
|
|
175
|
-
// The stamp STAYS. Not recapped, so the next boundary asks again — and the successor is
|
|
176
|
-
// told exactly what it skipped rather than left to discover it when the operator does.
|
|
177
|
-
process.stderr.write(
|
|
178
|
-
`[trantor] handoff ${stamp.handoffId}: NOT recapped — the handoff named ${want.length} file(s) to read first and ${missed.length} ${missed.length === 1 ? "was" : "were"} never opened: ${missed.join(", ")}. Read them before going further; this is the context the handoff exists to carry.\n`);
|
|
179
|
-
} else {
|
|
180
|
-
appendHandoffState(stamp.handoffId, "recapped", sid);
|
|
181
|
-
try { unlinkSync(stampPath); } catch {}
|
|
182
|
-
}
|
|
183
|
-
} catch {
|
|
184
|
-
try { unlinkSync(stampPath); } catch {}
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
} catch {}
|
|
189
160
|
// Mirror the other hooks: a home-directory session isn't project work and isn't on the bus.
|
|
190
161
|
if (!process.env.RELAY_SESSION && !process.env.RELAY_PROJECT && projectDir === homedir()) return allow();
|
|
191
162
|
|
package/hub/routes/cards.mjs
CHANGED
|
@@ -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);
|
|
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);
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/hub/routes/messages.mjs
CHANGED
|
@@ -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/lib/turn-policy.mjs
CHANGED
|
@@ -287,3 +287,15 @@ export function cutChainEvidence(rows) {
|
|
|
287
287
|
export function isBoundedPark(reason) {
|
|
288
288
|
return !PARKING_REASONS.has(reason);
|
|
289
289
|
}
|
|
290
|
+
|
|
291
|
+
// #11230: only an ask posted in this turn is delivery; an older unanswered ask cannot excuse it.
|
|
292
|
+
export function askDuringTurn(rows, startedAt) {
|
|
293
|
+
const asks = rows.filter(row => Number(row.ts) >= startedAt
|
|
294
|
+
&& (row.kind === "ask" || /^❓\s/.test(String(row.text || ""))));
|
|
295
|
+
return asks.sort((a, b) => Number(b.id) - Number(a.id))[0] || null;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export function askedVerdict(ask) {
|
|
299
|
+
const firstLine = String(ask.text || "").trim().split(/\r?\n/)[0];
|
|
300
|
+
return `asked: ${firstLine.replace(/^❓\s*(?:ask(?:s)?(?: on #[0-9]+)?\s*:\s*)?/, "")}`;
|
|
301
|
+
}
|