trantor 0.18.85 → 0.18.86
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/cli.mjs +6 -0
- package/bin/crew/cmux.mjs +11 -11
- package/bin/crew/core.mjs +3 -2
- package/bin/crew/herdr.mjs +8 -8
- package/bin/crew/state.mjs +18 -18
- package/bin/crew/swap.mjs +12 -12
- package/bin/crew/tmux.mjs +11 -11
- package/bin/crew-runner.mjs +36 -34
- package/bin/crew.mjs +22 -10
- package/bin/keys.mjs +53 -0
- package/hub/auth.mjs +1 -1
- package/hub.mjs +1 -1
- package/lib/identity.mjs +181 -8
- package/lib/persona.mjs +43 -0
- package/lib/session-liveness.mjs +6 -4
- package/lib/state/gate.mjs +1 -1
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.86",
|
|
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/cli.mjs
CHANGED
|
@@ -24,6 +24,7 @@ switch (cmd) {
|
|
|
24
24
|
case "profile": run("bin/profile.mjs"); break;
|
|
25
25
|
case "provider": case "providers": run("bin/provider.mjs"); break;
|
|
26
26
|
case "secrets": run("bin/secrets.mjs"); break;
|
|
27
|
+
case "keys": run("bin/keys.mjs"); break;
|
|
27
28
|
case "models": run("bin/models.mjs"); break;
|
|
28
29
|
case "advise": run("bin/advise.mjs"); break;
|
|
29
30
|
case "verify": run("bin/crew-verify.mjs"); break;
|
|
@@ -70,6 +71,10 @@ switch (cmd) {
|
|
|
70
71
|
}
|
|
71
72
|
break;
|
|
72
73
|
}
|
|
74
|
+
// Local throwaway: the hub's default is RELAY_AUTH=enforce (#11368), but a foreground
|
|
75
|
+
// `trantor hub` on loopback is a dev convenience — keep it usable (warn) unless the
|
|
76
|
+
// operator asked for a mode explicitly.
|
|
77
|
+
if (!process.env.RELAY_AUTH) process.env.RELAY_AUTH = "warn";
|
|
73
78
|
run("hub.mjs"); break;
|
|
74
79
|
}
|
|
75
80
|
case "project": {
|
|
@@ -235,6 +240,7 @@ switch (cmd) {
|
|
|
235
240
|
trantor profile declare your plans: trantor profile set claude=max codex=plus deepseek=api
|
|
236
241
|
trantor provider bring ANY model (BYOM): list · status [--json] · verify <name> --key … · add <name> --key … · remove <name>
|
|
237
242
|
trantor secrets provider keys in the OS keychain: list · set <NAME> (stdin) · remove <NAME> · migrate [--dry-run]
|
|
243
|
+
trantor keys identity keys in the OS keychain: status · migrate [--dry-run] — ~/.agent-bus/keys keeps public material only
|
|
238
244
|
trantor models browse live models behind each seat + the router's pick per difficulty
|
|
239
245
|
trantor up … spawn a crew here: trantor up codex kimi deepseek:deepseek glm:zai-coding-plan
|
|
240
246
|
trantor open host THIS session as the project's orchestrator pane (trantor down spares it)
|
package/bin/crew/cmux.mjs
CHANGED
|
@@ -110,13 +110,13 @@ export function spawnCmux(ctx, specs, resolve, prune) {
|
|
|
110
110
|
for (let index = 0; index < specs.length; index += 1) {
|
|
111
111
|
const seat = resolve(specs[index]);
|
|
112
112
|
if (!seat) continue;
|
|
113
|
-
const launcher = seatLauncher(ctx, seat.agent, runnerCommand(ctx, seat.agent, seat.model, seat.effort));
|
|
114
|
-
const old = reuse ? readRows(ctx).filter(row => row.project === ctx.project && row.kind === "cmux" && row.
|
|
113
|
+
const launcher = seatLauncher(ctx, (seat.persona || seat.agent), runnerCommand(ctx, seat.agent, seat.model, seat.effort, seat.persona));
|
|
114
|
+
const old = reuse ? readRows(ctx).filter(row => row.project === ctx.project && row.kind === "cmux" && row.persona === (seat.persona || seat.agent)).at(-1)?.handle || "" : "";
|
|
115
115
|
let pane = "";
|
|
116
116
|
if (reuse) {
|
|
117
|
-
console.log(ctx.dry ? `[dry] cmux: reuse workspace ${workspace} — new-split for ${seat.agent}${old ? ` (replacing ${old})` : ""}` : ` → reusing workspace ${workspace}`);
|
|
117
|
+
console.log(ctx.dry ? `[dry] cmux: reuse workspace ${workspace} — new-split for ${(seat.persona || seat.agent)}${old ? ` (replacing ${old})` : ""}` : ` → reusing workspace ${workspace}`);
|
|
118
118
|
pane = ctx.dry ? `%DRYT${index}` : split(ctx, workspace, "right", old || panes.at(-1) || "", launcher);
|
|
119
|
-
if (old) { closePane(ctx, old); dropState(ctx, ctx.project, "cmux", seat.agent); }
|
|
119
|
+
if (old) { closePane(ctx, old); dropState(ctx, ctx.project, "cmux", (seat.persona || seat.agent)); }
|
|
120
120
|
} else if (index === 0) {
|
|
121
121
|
if (ctx.dry) {
|
|
122
122
|
console.log(`[dry] cmux: new-workspace (cwd ${ctx.dir}) --command 'bash ${launcher}' → rename 'trantor:${ctx.project}'`);
|
|
@@ -133,8 +133,8 @@ export function spawnCmux(ctx, specs, resolve, prune) {
|
|
|
133
133
|
} else pane = split(ctx, workspace, direction, target, launcher);
|
|
134
134
|
}
|
|
135
135
|
panes.push(pane);
|
|
136
|
-
recordState(ctx, ctx.project, "cmux", seat.agent, pane);
|
|
137
|
-
console.log(` → ${seat.agent} seat in cmux workspace (${ctx.project})`);
|
|
136
|
+
recordState(ctx, ctx.project, "cmux", (seat.persona || seat.agent), pane, seat);
|
|
137
|
+
console.log(` → ${(seat.persona || seat.agent)} seat in cmux workspace (${ctx.project})`);
|
|
138
138
|
}
|
|
139
139
|
console.log(`— crew grouped in cmux: ONE workspace tab for ${ctx.project}, seats tiled + sidebar status. Teardown (this project only): trantor down —`);
|
|
140
140
|
}
|
|
@@ -150,8 +150,8 @@ function spawnAppleScript(ctx, specs, resolve) {
|
|
|
150
150
|
for (let index = 0; index < specs.length; index += 1) {
|
|
151
151
|
const seat = resolve(specs[index]);
|
|
152
152
|
if (!seat) continue;
|
|
153
|
-
const launcher = seatLauncher(ctx, seat.agent, runnerCommand(ctx, seat.agent, seat.model, seat.effort));
|
|
154
|
-
const old = reuse ? readRows(ctx).filter(row => row.project === ctx.project && row.kind === "cmux" && row.
|
|
153
|
+
const launcher = seatLauncher(ctx, (seat.persona || seat.agent), runnerCommand(ctx, seat.agent, seat.model, seat.effort, seat.persona));
|
|
154
|
+
const old = reuse ? readRows(ctx).filter(row => row.project === ctx.project && row.kind === "cmux" && row.persona === (seat.persona || seat.agent)).at(-1)?.handle || "" : "";
|
|
155
155
|
let pane;
|
|
156
156
|
if (!tab && index === 0) {
|
|
157
157
|
if (ctx.dry) {
|
|
@@ -167,11 +167,11 @@ function spawnAppleScript(ctx, specs, resolve) {
|
|
|
167
167
|
console.log(`[dry] cmux(AppleScript): split ${direction} from ${target || "<focused>"} + run 'bash ${launcher}'`);
|
|
168
168
|
pane = `%DRYT${index}`;
|
|
169
169
|
} else pane = splitAppleScriptPane(ctx, tab, target, direction, launcher);
|
|
170
|
-
if (old && pane && pane !== "ERR") { closePane(ctx, old); dropState(ctx, ctx.project, "cmux", seat.agent); }
|
|
170
|
+
if (old && pane && pane !== "ERR") { closePane(ctx, old); dropState(ctx, ctx.project, "cmux", (seat.persona || seat.agent)); }
|
|
171
171
|
}
|
|
172
172
|
panes.push(pane);
|
|
173
|
-
recordState(ctx, ctx.project, "cmux", seat.agent, pane);
|
|
174
|
-
console.log(` → ${seat.agent} seat in cmux workspace (${ctx.project})`);
|
|
173
|
+
recordState(ctx, ctx.project, "cmux", (seat.persona || seat.agent), pane, seat);
|
|
174
|
+
console.log(` → ${(seat.persona || seat.agent)} seat in cmux workspace (${ctx.project})`);
|
|
175
175
|
}
|
|
176
176
|
console.log(`— crew grouped in cmux (AppleScript): ONE workspace tab for ${ctx.project}, seats tiled. Teardown: trantor down —`);
|
|
177
177
|
}
|
package/bin/crew/core.mjs
CHANGED
|
@@ -139,7 +139,7 @@ export function gridColumns(size) {
|
|
|
139
139
|
// flag documented there finally arms the seat that reads it.
|
|
140
140
|
const FORWARDED_ENV = STATE_FLAGS;
|
|
141
141
|
|
|
142
|
-
export function runnerCommand(ctx, agent, model = "", effort = null) {
|
|
142
|
+
export function runnerCommand(ctx, agent, model = "", effort = null, persona = agent) {
|
|
143
143
|
const forwarded = FORWARDED_ENV
|
|
144
144
|
.filter((name) => process.env[name])
|
|
145
145
|
.map((name) => `${name}=${shellQuote(process.env[name])} `)
|
|
@@ -147,8 +147,9 @@ export function runnerCommand(ctx, agent, model = "", effort = null) {
|
|
|
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
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 || "")}` : "";
|
|
150
|
+
const personaEnv = ` CREW_AGENT=${shellQuote(agent)} CREW_PERSONA=${shellQuote(ctx.swap?.persona || persona)}`;
|
|
150
151
|
const effortEnv = effort ? ` CREW_EFFORT=${shellQuote(JSON.stringify(effort))}` : "";
|
|
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(
|
|
152
|
+
return `cd ${shellQuote(ctx.dir)} && ${forwarded}CREW_MODEL=${shellQuote(model)}${effortEnv}${swapEnv}${personaEnv} RELAY_PROJECT=${shellQuote(ctx.project)} RELAY_URL=${shellQuote(ctx.hub)} node ${shellQuote(join(ROOT, "bin/crew-runner.mjs"))} ${shellQuote(persona)} ${shellQuote(ctx.dir)}`;
|
|
152
153
|
}
|
|
153
154
|
|
|
154
155
|
export function listPids(pattern) {
|
package/bin/crew/herdr.mjs
CHANGED
|
@@ -159,18 +159,18 @@ function prepareWorkspace(ctx, prune) {
|
|
|
159
159
|
function replacementPane(ctx, workspace, spec, hostPane, resolve) {
|
|
160
160
|
const seat = resolve(spec);
|
|
161
161
|
if (!seat) return null;
|
|
162
|
-
const old = readRows(ctx).filter(row => row.project === ctx.project && row.kind === "herdr" && row.
|
|
162
|
+
const old = readRows(ctx).filter(row => row.project === ctx.project && row.kind === "herdr" && row.persona === (seat.persona || seat.agent)).at(-1)?.handle || "";
|
|
163
163
|
let pane;
|
|
164
164
|
if (ctx.dry) {
|
|
165
|
-
console.log(`[dry] herdr: reuse workspace ${workspace} — pane split for ${seat.agent}${old ? ` (replacing ${old})` : ""}`);
|
|
165
|
+
console.log(`[dry] herdr: reuse workspace ${workspace} — pane split for ${(seat.persona || seat.agent)}${old ? ` (replacing ${old})` : ""}`);
|
|
166
166
|
pane = `%DRYT${spec.index}`;
|
|
167
167
|
} else {
|
|
168
168
|
pane = splitPane(ctx, hostPane, "right", ctx.dir);
|
|
169
|
-
runSeat(ctx, pane, seat.agent, runnerCommand(ctx, seat.agent, seat.model, seat.effort));
|
|
169
|
+
runSeat(ctx, pane, (seat.persona || seat.agent), runnerCommand(ctx, seat.agent, seat.model, seat.effort, seat.persona));
|
|
170
170
|
}
|
|
171
171
|
if (old) {
|
|
172
172
|
closePane(ctx, old);
|
|
173
|
-
dropState(ctx, ctx.project, "herdr", seat.agent);
|
|
173
|
+
dropState(ctx, ctx.project, "herdr", (seat.persona || seat.agent));
|
|
174
174
|
}
|
|
175
175
|
return { ...seat, pane };
|
|
176
176
|
}
|
|
@@ -194,8 +194,8 @@ export function spawnHerdr(ctx, specs, resolve, prune) {
|
|
|
194
194
|
workspace = seat.workspace || workspace;
|
|
195
195
|
panes.push(seat.pane);
|
|
196
196
|
reportAgent(ctx, seat.pane, seat.agent);
|
|
197
|
-
recordState(ctx, ctx.project, "herdr", seat.agent, seat.pane);
|
|
198
|
-
console.log(` → ${seat.agent} seat in herdr workspace (${ctx.project})`);
|
|
197
|
+
recordState(ctx, ctx.project, "herdr", (seat.persona || seat.agent), seat.pane, seat);
|
|
198
|
+
console.log(` → ${(seat.persona || seat.agent)} seat in herdr workspace (${ctx.project})`);
|
|
199
199
|
}
|
|
200
200
|
console.log(`— crew grouped in herdr: ONE workspace for ${ctx.project}, seats as named panes in its server. Teardown (this project only): trantor down —`);
|
|
201
201
|
}
|
|
@@ -203,7 +203,7 @@ export function spawnHerdr(ctx, specs, resolve, prune) {
|
|
|
203
203
|
function freshPane(ctx, workspace, spec, panes, columns, resolve) {
|
|
204
204
|
const seat = resolve(spec.value);
|
|
205
205
|
if (!seat) return null;
|
|
206
|
-
const command = runnerCommand(ctx, seat.agent, seat.model, seat.effort);
|
|
206
|
+
const command = runnerCommand(ctx, seat.agent, seat.model, seat.effort, seat.persona);
|
|
207
207
|
let pane = "";
|
|
208
208
|
if (spec.index === 0) {
|
|
209
209
|
if (ctx.dry) {
|
|
@@ -221,7 +221,7 @@ function freshPane(ctx, workspace, spec, panes, columns, resolve) {
|
|
|
221
221
|
pane = `%DRYT${spec.index}`;
|
|
222
222
|
} else pane = splitPane(ctx, target, direction, ctx.dir);
|
|
223
223
|
}
|
|
224
|
-
runSeat(ctx, pane, seat.agent, command);
|
|
224
|
+
runSeat(ctx, pane, (seat.persona || seat.agent), command);
|
|
225
225
|
return { ...seat, pane, workspace };
|
|
226
226
|
}
|
|
227
227
|
|
package/bin/crew/state.mjs
CHANGED
|
@@ -7,9 +7,9 @@ import { statePathFor as dockerStatePathFor, sweep as dockerSweep } from "../../
|
|
|
7
7
|
|
|
8
8
|
export function parseRow(line) {
|
|
9
9
|
const fields = line.split("\t");
|
|
10
|
-
if (fields.length >= 4) return { project: fields[0], kind: fields[1], agent: fields[2], handle: fields[3] };
|
|
11
|
-
if (fields.length === 2) return { project: "", kind: "win", agent: fields[0], handle: fields[1] };
|
|
12
|
-
return { project: "", kind: "win", agent: fields[0] || "", handle: fields[1] || "" };
|
|
10
|
+
if (fields.length >= 4) return { project: fields[0], kind: fields[1], persona: fields[2], agent: fields[4] || fields[2], model: fields[5] || "", handle: fields[3] };
|
|
11
|
+
if (fields.length === 2) return { project: "", kind: "win", persona: fields[0], model: "", agent: fields[0], handle: fields[1] };
|
|
12
|
+
return { project: "", kind: "win", persona: fields[0], model: "", agent: fields[0] || "", handle: fields[1] || "" };
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
export function readRows(ctx) {
|
|
@@ -24,24 +24,24 @@ export function writeRows(ctx, rows) {
|
|
|
24
24
|
return;
|
|
25
25
|
}
|
|
26
26
|
const tmp = `${ctx.statePath}.tmp`;
|
|
27
|
-
writeFileSync(tmp, `${rows.map(row => [row.project, row.kind, row.agent, row.handle].join("\t")).join("\n")}\n`);
|
|
27
|
+
writeFileSync(tmp, `${rows.map(row => [row.project, row.kind, row.persona || row.agent, row.handle, ...((row.persona && row.persona !== row.agent) || row.model ? [row.agent, row.model || ""] : [])].join("\t")).join("\n")}\n`);
|
|
28
28
|
renameSync(tmp, ctx.statePath);
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
export function recordState(ctx, project, kind,
|
|
31
|
+
export function recordState(ctx, project, kind, persona, handle, metadata = {}) {
|
|
32
32
|
if (ctx.dry) return;
|
|
33
33
|
const rows = readRows(ctx);
|
|
34
34
|
// #7285: an exact duplicate carries no new fact, and a doubled herdrws row is how `up` came to
|
|
35
35
|
// see the live workspace as its own stale copy. One row per fact — refuse the push.
|
|
36
|
-
if (rows.some(row => row.project === project && row.kind === kind && row.
|
|
37
|
-
rows.push({ project, kind, agent, handle });
|
|
36
|
+
if (rows.some(row => row.project === project && row.kind === kind && row.persona === persona && row.handle === handle)) return;
|
|
37
|
+
rows.push({ project, kind, persona, agent: metadata.agent || persona, model: metadata.model || "", handle });
|
|
38
38
|
writeRows(ctx, rows);
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
export function dropState(ctx, project, kind, agent = "", handle = "") {
|
|
42
42
|
const rows = readRows(ctx).filter(row => !(
|
|
43
43
|
row.project === project && row.kind === kind &&
|
|
44
|
-
(!agent || row.
|
|
44
|
+
(!agent || row.persona === agent) && (!handle || row.handle === handle)
|
|
45
45
|
));
|
|
46
46
|
writeRows(ctx, rows);
|
|
47
47
|
}
|
|
@@ -108,7 +108,7 @@ async function closeRow(ctx, row, selected, orchProjects, adapters, tmuxClosed)
|
|
|
108
108
|
if (row.kind === "herdr" && (perSeat || hasOrch)) adapters.herdr.closePane(row.handle);
|
|
109
109
|
if (row.kind === "tmux") closeTmux(ctx, row, perSeat, tmuxClosed);
|
|
110
110
|
if ((row.kind === "win" || row.kind === "attach") && !(perSeat && row.kind === "attach")) killTerminal(ctx, row.handle);
|
|
111
|
-
if (!["cmuxws", "attach", "herdrws", "orch"].includes(row.kind)) await killSeatProcesses(ctx, row.project, row.
|
|
111
|
+
if (!["cmuxws", "attach", "herdrws", "orch"].includes(row.kind)) await killSeatProcesses(ctx, row.project, row.persona);
|
|
112
112
|
}
|
|
113
113
|
|
|
114
114
|
function closeTmux(ctx, row, perSeat, seen) {
|
|
@@ -128,11 +128,11 @@ export async function down(ctx, args, adapters) {
|
|
|
128
128
|
if (options.error) return 1;
|
|
129
129
|
const rows = readRows(ctx);
|
|
130
130
|
if (!rows.length) { console.log("no tracked crew windows"); return 0; }
|
|
131
|
-
const scoped = rows.filter(row => (options.all || row.project === ctx.project) && (!options.agents.length || options.agents.includes(row.
|
|
131
|
+
const scoped = rows.filter(row => (options.all || row.project === ctx.project) && (!options.agents.length || options.agents.includes(row.persona)));
|
|
132
132
|
const scope = options.all ? "all projects" : `project \"${ctx.project}\"`;
|
|
133
133
|
if (!scoped.length) { console.log(`nothing to tear down for ${scope}`); return 0; }
|
|
134
134
|
console.log(`— tearing down (${scope}):`);
|
|
135
|
-
for (const row of scoped) if (!["attach", "cmuxws", "herdrws", "orch"].includes(row.kind)) console.log(` • ${row.project || "<legacy>"} · ${row.
|
|
135
|
+
for (const row of scoped) if (!["attach", "cmuxws", "herdrws", "orch"].includes(row.kind)) console.log(` • ${row.project || "<legacy>"} · ${row.persona} (${row.kind})`);
|
|
136
136
|
if (options.all && !options.yes) {
|
|
137
137
|
console.log("— this is EVERY project's crew. Re-run with --yes to confirm: trantor down --all --yes");
|
|
138
138
|
return 0;
|
|
@@ -140,22 +140,22 @@ export async function down(ctx, args, adapters) {
|
|
|
140
140
|
const orchProjects = new Set(rows.filter(row => row.kind === "orch").map(row => row.project));
|
|
141
141
|
const tmuxClosed = new Set();
|
|
142
142
|
for (const row of scoped) await closeRow(ctx, row, options.agents, orchProjects, adapters, tmuxClosed);
|
|
143
|
-
const removed = new Set(scoped.map(row => `${row.project}|${row.kind}|${row.
|
|
144
|
-
const kept = rows.filter(row => !removed.has(`${row.project}|${row.kind}|${row.
|
|
143
|
+
const removed = new Set(scoped.map(row => `${row.project}|${row.kind}|${row.persona}|${row.handle}`));
|
|
144
|
+
const kept = rows.filter(row => !removed.has(`${row.project}|${row.kind}|${row.persona}|${row.handle}`) || (orchProjects.has(row.project) && ["herdrws", "cmuxws", "orch"].includes(row.kind)));
|
|
145
145
|
writeRows(ctx, kept);
|
|
146
146
|
const seatRows = scoped.filter(row => !["attach", "cmuxws", "herdrws", "orch"].includes(row.kind));
|
|
147
147
|
if (!ctx.dry) {
|
|
148
148
|
const logDir = join(dirname(ctx.statePath), "logs");
|
|
149
149
|
mkdirSync(logDir, { recursive: true });
|
|
150
|
-
for (const row of new Map(seatRows.map(row => [`${row.project}/${row.
|
|
151
|
-
appendFileSync(join(logDir, `${row.
|
|
152
|
-
`${JSON.stringify({ ts: Date.now(), agent: row.
|
|
150
|
+
for (const row of new Map(seatRows.map(row => [`${row.project}/${row.persona}`, row])).values()) {
|
|
151
|
+
appendFileSync(join(logDir, `${row.persona}-${row.project}.jsonl`),
|
|
152
|
+
`${JSON.stringify({ ts: Date.now(), agent: row.persona, project: row.project, stopped: true })}\n`);
|
|
153
153
|
}
|
|
154
154
|
}
|
|
155
155
|
if (options.agents.length) {
|
|
156
|
-
const stopped = [...new Set(seatRows.map(row => row.
|
|
156
|
+
const stopped = [...new Set(seatRows.map(row => row.persona))];
|
|
157
157
|
const remaining = [...new Set(kept.filter(row => row.project === ctx.project &&
|
|
158
|
-
!["attach", "cmuxws", "herdrws", "orch"].includes(row.kind)).map(row => row.
|
|
158
|
+
!["attach", "cmuxws", "herdrws", "orch"].includes(row.kind)).map(row => row.persona))];
|
|
159
159
|
console.log(`— ${stopped.join(", ") || options.agents.join(", ")} stopped${remaining.length ? ` (${remaining.join(", ")} still running)` : ""} (${scope})`);
|
|
160
160
|
} else console.log(`— crew torn down (${scope})`);
|
|
161
161
|
return 0;
|
package/bin/crew/swap.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { updatePersona } from "../../lib/persona.mjs";
|
|
1
2
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
3
|
import { join } from "node:path";
|
|
3
4
|
import { randomUUID } from "node:crypto";
|
|
@@ -89,12 +90,11 @@ export function swapRuntime(ctx, adapters, spawn) {
|
|
|
89
90
|
let replacementPid;
|
|
90
91
|
return {
|
|
91
92
|
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
93
|
return validateReplacement(ctx, options);
|
|
94
94
|
},
|
|
95
95
|
async start(seat, stage) {
|
|
96
96
|
const staged = { ...ctx, swap: { ...stage, agent: seat.agent, driver: seat.driver } };
|
|
97
|
-
spawn(staged, { ...seat,
|
|
97
|
+
spawn(staged, { ...seat, persona: stage.label });
|
|
98
98
|
},
|
|
99
99
|
async ready(stage) {
|
|
100
100
|
const deadline = Date.now() + 30000;
|
|
@@ -118,10 +118,11 @@ export function swapRuntime(ctx, adapters, spawn) {
|
|
|
118
118
|
signalProcesses(pids, "SIGKILL");
|
|
119
119
|
},
|
|
120
120
|
async cancel(stage) { await down(ctx, [stage.label], adapters); },
|
|
121
|
-
release(stage,
|
|
122
|
-
writeRows(ctx, readRows(ctx).map(row => row.project === ctx.project && row.
|
|
123
|
-
writeFileSync(runnerRecord(ctx, ctx.project,
|
|
124
|
-
|
|
121
|
+
release(stage, persona, cursor) {
|
|
122
|
+
writeRows(ctx, readRows(ctx).map(row => row.project === ctx.project && row.persona === stage.label ? { ...row, persona } : row));
|
|
123
|
+
writeFileSync(runnerRecord(ctx, ctx.project, persona), JSON.stringify({ label: stage.label, dir: ctx.dir }));
|
|
124
|
+
updatePersona({ dir: join(ctx.home, ".agent-bus"), project: ctx.project, persona, agent: stage.agent, model: stage.model });
|
|
125
|
+
writeFileSync(`${stage.path}.release`, JSON.stringify({ agent: stage.agent, persona, cursor }));
|
|
125
126
|
},
|
|
126
127
|
};
|
|
127
128
|
}
|
|
@@ -133,23 +134,22 @@ export async function performSwap(ctx, options, runtime) {
|
|
|
133
134
|
const seat = await runtime.validate(options);
|
|
134
135
|
const label = `${options.agent}-swap-${randomUUID().slice(0, 8)}`;
|
|
135
136
|
mkdirSync(ctx.seatDir, { recursive: true });
|
|
136
|
-
stage = { label, path: join(ctx.seatDir, label) };
|
|
137
|
+
stage = { label, path: join(ctx.seatDir, label), persona: options.from, agent: seat.agent, model: seat.model };
|
|
137
138
|
await runtime.start(seat, stage);
|
|
138
139
|
const readySession = await runtime.ready(stage);
|
|
139
140
|
frozen = true;
|
|
140
141
|
await runtime.freeze(options.from);
|
|
141
142
|
const pending = readPending(ctx, options.from);
|
|
142
|
-
const target =
|
|
143
|
-
const from = `${options.from}:${ctx.project}`, to =
|
|
143
|
+
const target = pending;
|
|
144
|
+
const from = `${options.from}:${ctx.project}`, to = from;
|
|
144
145
|
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
146
|
transferred = true;
|
|
146
147
|
result.moved = [...new Set([...pending.wake.map(m => m.id), ...handoff.moved])];
|
|
147
148
|
const wake = new Map([...target.wake, ...pending.wake, ...handoff.messages].map(m => [m.id, { ...m, to }]));
|
|
148
|
-
savePending(ctx, options.
|
|
149
|
+
savePending(ctx, options.from, { ...target, wake: [...wake.values()], bcast: [...new Map([...target.bcast, ...pending.bcast].map(m => [m.id, m])).values()], ask: handoff.messages.some(m => m.re === pending.ask) ? 0 : pending.ask || target.ask, askTo: pending.askTo || target.askTo });
|
|
149
150
|
await runtime.stop(options.from);
|
|
150
151
|
frozen = false;
|
|
151
|
-
|
|
152
|
-
await runtime.release(stage, options.agent, handoff.cursor);
|
|
152
|
+
await runtime.release(stage, options.from, handoff.cursor);
|
|
153
153
|
return { ...result, ok: true };
|
|
154
154
|
} catch (error) {
|
|
155
155
|
if (!transferred) {
|
package/bin/crew/tmux.mjs
CHANGED
|
@@ -7,7 +7,7 @@ export function spawnTmux(ctx, specs, resolve) {
|
|
|
7
7
|
for (const spec of specs) {
|
|
8
8
|
const seat = resolve(spec);
|
|
9
9
|
if (!seat) continue;
|
|
10
|
-
const command = runnerCommand(ctx, seat.agent, seat.model, seat.effort);
|
|
10
|
+
const command = runnerCommand(ctx, seat.agent, seat.model, seat.effort, seat.persona);
|
|
11
11
|
let pane = ctx.dry ? "%DRY" : "";
|
|
12
12
|
if (first) {
|
|
13
13
|
run(ctx, "tmux", ["new-session", "-d", "-s", session, "-n", "crew", "-x", "260", "-y", "60"], { rendered: `tmux new-session -d -s '${session}' -n crew -x 260 -y 60` });
|
|
@@ -18,9 +18,9 @@ export function spawnTmux(ctx, specs, resolve) {
|
|
|
18
18
|
run(ctx, "tmux", ["select-layout", "-t", session, "tiled"], { rendered: `tmux select-layout -t '${session}' tiled >/dev/null 2>&1` });
|
|
19
19
|
}
|
|
20
20
|
if (!ctx.dry) pane = call("tmux", ["display-message", "-p", "-t", session, "#{pane_id}"], { env: ctx.env }).stdout;
|
|
21
|
-
run(ctx, "tmux", ["select-pane", "-t", pane || session, "-T", seat.agent.toUpperCase()], { rendered: `tmux select-pane -t '${pane || session}' -T ${seat.agent.toUpperCase()}` });
|
|
22
|
-
recordState(ctx, ctx.project, "tmux", seat.agent, pane || "%?");
|
|
23
|
-
console.log(` → ${seat.agent} pane in ${session}`);
|
|
21
|
+
run(ctx, "tmux", ["select-pane", "-t", pane || session, "-T", (seat.persona || seat.agent).toUpperCase()], { rendered: `tmux select-pane -t '${pane || session}' -T ${(seat.persona || seat.agent).toUpperCase()}` });
|
|
22
|
+
recordState(ctx, ctx.project, "tmux", (seat.persona || seat.agent), pane || "%?", seat);
|
|
23
|
+
console.log(` → ${(seat.persona || seat.agent)} pane in ${session}`);
|
|
24
24
|
}
|
|
25
25
|
run(ctx, "tmux", ["set-option", "-t", session, "pane-border-status", "top"], { rendered: `tmux set-option -t '${session}' pane-border-status top >/dev/null 2>&1` });
|
|
26
26
|
run(ctx, "tmux", ["set-option", "-t", session, "pane-border-format", " #{pane_title} "], { rendered: `tmux set-option -t '${session}' pane-border-format ' #{pane_title} ' >/dev/null 2>&1` });
|
|
@@ -48,17 +48,17 @@ export function spawnTerminal(ctx, specs, resolve) {
|
|
|
48
48
|
const x = rect.x + (index % columns) * width;
|
|
49
49
|
const y = rect.y + Math.floor(index / columns) * height;
|
|
50
50
|
if (ctx.dry) {
|
|
51
|
-
console.log(`[dry] Terminal window for ${seat.agent} at ${x},${y}`);
|
|
52
|
-
recordState(ctx, ctx.project, "win", seat.agent, `%DRYWIN${index}
|
|
51
|
+
console.log(`[dry] Terminal window for ${(seat.persona || seat.agent)} at ${x},${y}`);
|
|
52
|
+
recordState(ctx, ctx.project, "win", (seat.persona || seat.agent), `%DRYWIN${index}`, seat);
|
|
53
53
|
continue;
|
|
54
54
|
}
|
|
55
|
-
const command = `clear && ${runnerCommand(ctx, seat.agent, seat.model, seat.effort)}`;
|
|
56
|
-
const title = `${ctx.project} · ${seat.agent.toUpperCase()}`;
|
|
55
|
+
const command = `clear && ${runnerCommand(ctx, seat.agent, seat.model, seat.effort, seat.persona)}`;
|
|
56
|
+
const title = `${ctx.project} · ${(seat.persona || seat.agent).toUpperCase()}`;
|
|
57
57
|
const result = appleScript(`tell application "Terminal"\nset w to do script "${appleScriptString(command)}"\nset custom title of w to "${appleScriptString(title)}"\nset theWin to first window whose tabs contains w\nset bounds of theWin to {${x}, ${y}, ${x + width}, ${y + height}}\nreturn id of theWin\nend tell\n`);
|
|
58
58
|
if (result.stdout) {
|
|
59
|
-
recordState(ctx, ctx.project, "win", seat.agent, result.stdout);
|
|
60
|
-
console.log(` → ${seat.agent} window spawned (${ctx.project} · ${seat.agent})`);
|
|
61
|
-
} else console.log(` ✗ ${seat.agent} osascript spawn ERROR`);
|
|
59
|
+
recordState(ctx, ctx.project, "win", (seat.persona || seat.agent), result.stdout, seat);
|
|
60
|
+
console.log(` → ${(seat.persona || seat.agent)} window spawned (${ctx.project} · ${(seat.persona || seat.agent)})`);
|
|
61
|
+
} else console.log(` ✗ ${(seat.persona || seat.agent)} osascript spawn ERROR`);
|
|
62
62
|
call("sleep", ["1.2"]);
|
|
63
63
|
}
|
|
64
64
|
}
|
package/bin/crew-runner.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import { join, basename } from "node:path";
|
|
|
7
7
|
import { homedir } from "node:os";
|
|
8
8
|
import { resolveProject, resolveHub, withEnvFiles, hostId } from "../lib/project.mjs";
|
|
9
9
|
import { resolveSecrets, shadowEnv, withSecretExports } from "../lib/secrets.mjs";
|
|
10
|
+
import { runnerPersona } from "../lib/persona.mjs";
|
|
10
11
|
import { loadOrCreate } from "../lib/identity.mjs";
|
|
11
12
|
import { signedHeaders } from "../lib/signed-fetch.mjs";
|
|
12
13
|
import { ensureEnrolled } from "../lib/enroll.mjs";
|
|
@@ -74,7 +75,8 @@ function newerVersion(next, previous) {
|
|
|
74
75
|
return false;
|
|
75
76
|
}
|
|
76
77
|
if (!releaseState) await awaitSwapRelease();
|
|
77
|
-
const AGENT = process.env.CREW_SWAP_AGENT || process.argv[2];
|
|
78
|
+
const AGENT = process.env.CREW_SWAP_AGENT || process.env.CREW_AGENT || process.argv[2];
|
|
79
|
+
const PERSONA = runnerPersona(process.env, AGENT);
|
|
78
80
|
const DIR = process.argv[3] || process.cwd();
|
|
79
81
|
// Crew agents MUST share the orchestrator's project key (one repo = one lane).
|
|
80
82
|
// RELAY_PROJECT is inherited from crew.mjs (the host's resolved key); else fall
|
|
@@ -88,7 +90,7 @@ function gitOut(args, cwd = DIR) {
|
|
|
88
90
|
}
|
|
89
91
|
|
|
90
92
|
function seatWorktree(sourceDir) {
|
|
91
|
-
const wt = ensureSeatWorktree({ sourceDir, project: PROJ, agent:
|
|
93
|
+
const wt = ensureSeatWorktree({ sourceDir, project: PROJ, agent: PERSONA });
|
|
92
94
|
// #7760: a worktree is a git checkout — it lacks the gitignored files the build needs and cannot
|
|
93
95
|
// resolve relative sibling packages. The project's declaration provides both before the first turn.
|
|
94
96
|
if (wt.root) {
|
|
@@ -129,7 +131,7 @@ function ocSid(dir) {
|
|
|
129
131
|
// RUNNER_SESSION override: an orchestrator seat (bin/orchestrate.mjs) runs the same CLI as a crew
|
|
130
132
|
// seat but must live on the bus under its own name (claude-orch:proj), or it would collide with a
|
|
131
133
|
// plain claude crew seat on the same project.
|
|
132
|
-
const SESSION = process.env.RUNNER_SESSION || `${
|
|
134
|
+
const SESSION = process.env.RUNNER_SESSION || `${PERSONA}:${PROJ}`;
|
|
133
135
|
// One keypair per seat, so `deepseek:crebral` and `deepseek:trantor` are genuinely different
|
|
134
136
|
// identities on the bus rather than one shared string label.
|
|
135
137
|
const identity = loadOrCreate(SESSION, "agent");
|
|
@@ -157,9 +159,9 @@ const log = (s) => console.log(`\x1b[38;5;43m[runner]\x1b[0m ${redactKeys(String
|
|
|
157
159
|
const LOGDIR = join(homedir(), ".agent-bus", "logs");
|
|
158
160
|
try { mkdirSync(LOGDIR, { recursive: true }); } catch {}
|
|
159
161
|
let TURN = releaseState?.turn || 0;
|
|
160
|
-
const telemetry = (rec) => { try { appendFileSync(join(LOGDIR, `${
|
|
162
|
+
const telemetry = (rec) => { try { appendFileSync(join(LOGDIR, `${PERSONA}-${PROJ}.jsonl`), JSON.stringify(rec) + "\n"); } catch {} };
|
|
161
163
|
// The boot line records the HUB this runner bound to, so a split-brain is diagnosable from disk.
|
|
162
|
-
telemetry({ ts: Date.now(), agent: AGENT, project: PROJ, boot: true, hub: HUB, pid: process.pid, version: bootVersion });
|
|
164
|
+
telemetry({ ts: Date.now(), persona: PERSONA, agent: AGENT, project: PROJ, boot: true, hub: HUB, pid: process.pid, version: bootVersion });
|
|
163
165
|
// A seat can open a terminal window on a machine whose owner never asked for one and does not know
|
|
164
166
|
// what they are looking at. "◤ CLAUDE ◢ trantor crew · fleet" tells that person nothing: not what
|
|
165
167
|
// started, not what it will do, not how to stop it. RUNNER_TITLE names it in full and RUNNER_ABOUT
|
|
@@ -206,7 +208,7 @@ function cmuxStatus(value, color, icon = "robot", opts = {}) {
|
|
|
206
208
|
// Label with the REAL seat identity (this is the DISPLAY path, distinct from the bus identity).
|
|
207
209
|
// Pill = "<agent> · <state>" in the agent's brand color; alerts keep their alarm color.
|
|
208
210
|
const col = opts.alert ? color : (BRAND_HEX[AGENT.toLowerCase()] || color);
|
|
209
|
-
try { spawnSync(CMUX_BIN, ["set-status", SESSION, `${AGENT} · ${value}`, "--color", col, "--icon", icon, "--priority", String(opts.priority ?? 0)], { stdio: "ignore", timeout: 1500, env: { ...process.env, CMUX_QUIET: "1" } }); } catch {}
|
|
211
|
+
try { spawnSync(CMUX_BIN, ["set-status", SESSION, `${PERSONA} · ${AGENT} · ${value}`, "--color", col, "--icon", icon, "--priority", String(opts.priority ?? 0)], { stdio: "ignore", timeout: 1500, env: { ...process.env, CMUX_QUIET: "1" } }); } catch {}
|
|
210
212
|
}
|
|
211
213
|
// herdr drops a pane's agent registration when the process inside exits, and a seat's CLI exits
|
|
212
214
|
// every turn, so re-report at each turn boundary. Argument order: pane id FIRST, then the flags.
|
|
@@ -216,7 +218,7 @@ function herdrAgent(state) {
|
|
|
216
218
|
if (!existsSync(f)) return;
|
|
217
219
|
const row = readFileSync(f, "utf8").split("\n")
|
|
218
220
|
.map(l => l.split("\t"))
|
|
219
|
-
.filter(c => c.length >= 4 && c[0] === PROJ && c[1] === "herdr" && c[2] ===
|
|
221
|
+
.filter(c => c.length >= 4 && c[0] === PROJ && c[1] === "herdr" && c[2] === PERSONA)
|
|
220
222
|
.pop();
|
|
221
223
|
if (!row || !row[3]) return;
|
|
222
224
|
spawnSync("herdr", ["pane", "report-agent", row[3], "--source", "crew", "--agent", AGENT, "--state", state],
|
|
@@ -318,7 +320,7 @@ let lastEmptyTurn = false;
|
|
|
318
320
|
// at turn end rides along so the assigner's notice can name it.
|
|
319
321
|
let lastNoDelivery = false;
|
|
320
322
|
let lastNoDeliveryStatus = "";
|
|
321
|
-
const ERRF = join(homedir(), ".agent-bus", `err-${
|
|
323
|
+
const ERRF = join(homedir(), ".agent-bus", `err-${PERSONA}-${PROJ}.txt`);
|
|
322
324
|
const DUTY_NUDGES = process.env.RUNNER_DUTY_NUDGES === "1";
|
|
323
325
|
const DUTY_NUDGE_STATE = process.env.RUNNER_DUTY_NUDGE_STATE
|
|
324
326
|
|| join(homedir(), ".agent-bus", "duty-nudged.json");
|
|
@@ -339,15 +341,15 @@ function startDutyNudgeWatcher(plan, sinceMs) {
|
|
|
339
341
|
// ---- undelivered wake messages (the runner owns delivery, not the hub) ----
|
|
340
342
|
// The hub hands a message out exactly once, so a turn that died took its wake with it. Here a
|
|
341
343
|
// message is consumed only when a turn exits 0; the queue lives on disk and retries on backoff.
|
|
342
|
-
const PENDF = join(homedir(), ".agent-bus", `pending-${
|
|
344
|
+
const PENDF = join(homedir(), ".agent-bus", `pending-${PERSONA}-${PROJ}.json`);
|
|
343
345
|
// #7914: the park, on disk, for everyone who is not on the bus. A park announces itself once and
|
|
344
346
|
// then the seat looks exactly like an idle one — which is how three seats sat 46 minutes while
|
|
345
347
|
// `trantor seat-why` read "live, last turn failed". This file is the state behind that event, so a
|
|
346
348
|
// reader who missed the message still finds the reason, the evidence and when it lifts.
|
|
347
|
-
const PARKF = join(homedir(), ".agent-bus", `park-${
|
|
349
|
+
const PARKF = join(homedir(), ".agent-bus", `park-${PERSONA}-${PROJ}.json`);
|
|
348
350
|
// #9778: the seat's recorded containers, persisted per seat so a card close, a park or
|
|
349
351
|
// `trantor down <seat>` can stop what the seat started even after this runner is gone.
|
|
350
|
-
const DOCKERF = dockerStatePathFor(
|
|
352
|
+
const DOCKERF = dockerStatePathFor(PERSONA, PROJ);
|
|
351
353
|
// One bus line per sweep, naming what was stopped; docker absent sweeps stay silent by contract.
|
|
352
354
|
async function janitorStop(why, card) {
|
|
353
355
|
const r = dockerSweep({ path: DOCKERF, card });
|
|
@@ -403,7 +405,7 @@ function savePending(wake, bcast) {
|
|
|
403
405
|
// the dedupe ledger outlives the deliveries it guards, and a restarted runner must wait for
|
|
404
406
|
// the answer instead of redelivering the very contract the seat already asked about.
|
|
405
407
|
if (!wake.length && !bcast.length && !seenLedger.length && !awaitingAsk) { try { unlinkSync(PENDF); } catch {} return; }
|
|
406
|
-
writeFileSync(PENDF, JSON.stringify({ agent: AGENT, project: PROJ, ts: Date.now(), wake, bcast, seen: seenLedger, ask: awaitingAsk?.id || 0, askTo: awaitingAsk?.to || "" }));
|
|
408
|
+
writeFileSync(PENDF, JSON.stringify({ persona: PERSONA, agent: AGENT, project: PROJ, ts: Date.now(), wake, bcast, seen: seenLedger, ask: awaitingAsk?.id || 0, askTo: awaitingAsk?.to || "" }));
|
|
407
409
|
} catch {}
|
|
408
410
|
}
|
|
409
411
|
function loadPending() {
|
|
@@ -435,7 +437,7 @@ async function reportFailure(exit, trigger, undelivered = 0, reasonOverride = ""
|
|
|
435
437
|
const reason = reasonOverride || classify(exit);
|
|
436
438
|
const down = consecFails >= 2;
|
|
437
439
|
const status = down ? `down: ${reason} · ${consecFails} fails` : `errored: ${reason}`;
|
|
438
|
-
await api("/register", { session: SESSION, project: PROJ, status, llm: AGENT, model: MODEL, kind: "agent" }).catch(() => {});
|
|
440
|
+
await api("/register", { session: SESSION, project: PROJ, status, persona: PERSONA, agent: AGENT, llm: AGENT, model: MODEL, kind: "agent" }).catch(() => {});
|
|
439
441
|
const hint = reason === "exhausted" ? " — needs `trantor swap`"
|
|
440
442
|
: reason === "stalled" ? " — CLI silent for the whole watchdog window: check the provider, or `trantor swap`"
|
|
441
443
|
: reason === "auth" ? " — check credentials"
|
|
@@ -497,7 +499,7 @@ async function parkSeat(reason, undelivered, resetHint = 0, { evidence = "" } =
|
|
|
497
499
|
parkState = { reason, until, evidence, held: undelivered, ts: Date.now(), bounded };
|
|
498
500
|
// The park is a STATE, and every reader of it is off the bus: `trantor seat-why`, the app, the
|
|
499
501
|
// operator at a terminal an hour later. The message below is the event; this file is the state.
|
|
500
|
-
try { writeFileSync(PARKF, JSON.stringify({ session: SESSION, agent: AGENT, project: PROJ, ...parkState })); } catch {}
|
|
502
|
+
try { writeFileSync(PARKF, JSON.stringify({ session: SESSION, persona: PERSONA, agent: AGENT, project: PROJ, ...parkState })); } catch {}
|
|
501
503
|
if (!parkAnnounced) {
|
|
502
504
|
parkAnnounced = true;
|
|
503
505
|
// #7752: a stalled park names the CLI and its model — the swap decision needs both, and this
|
|
@@ -518,7 +520,7 @@ async function parkSeat(reason, undelivered, resetHint = 0, { evidence = "" } =
|
|
|
518
520
|
`${undelivered} message(s) held${when ? ` — retrying after ${when}` : ` — needs \`trantor up ${AGENT}\``}`);
|
|
519
521
|
// #7749: parked is a first-class phase — the file and the peer row both say so, so an
|
|
520
522
|
// orchestrator stops confusing a quota-held seat with a working or a dead one.
|
|
521
|
-
writeTurnState(
|
|
523
|
+
writeTurnState(PERSONA, PROJ, { phase: "parked", since: Date.now() });
|
|
522
524
|
await registerStatus(`parked (${reason})`, { phase: "parked", phaseSince: Date.now() });
|
|
523
525
|
// #9778: a parked seat is done working for now — its recorded containers go down with it.
|
|
524
526
|
await janitorStop(`seat parked (${reason})`);
|
|
@@ -581,7 +583,7 @@ async function balanceRows() {
|
|
|
581
583
|
// #7749: the live phase + its start ride the same call, so /peers answers what the seat does NOW.
|
|
582
584
|
async function registerStatus(status, extra = {}) {
|
|
583
585
|
const url = HUB + "/register";
|
|
584
|
-
const body = JSON.stringify({ session: SESSION, project: PROJ, status, llm: AGENT, model: MODEL, ...extra });
|
|
586
|
+
const body = JSON.stringify({ session: SESSION, project: PROJ, status, persona: PERSONA, agent: AGENT, llm: AGENT, model: MODEL, ...extra });
|
|
585
587
|
try {
|
|
586
588
|
const opts = { method: "POST", headers: { "content-type": "application/json", connection: "close" }, body };
|
|
587
589
|
await fetch(url, { ...opts, headers: { ...opts.headers, ...signedHeaders(identity, url, opts) }, signal: AbortSignal.timeout(5000) });
|
|
@@ -633,7 +635,7 @@ async function reportHealthy() {
|
|
|
633
635
|
// Recovery is a change too, so the next failure is news again — a park included.
|
|
634
636
|
announced = "";
|
|
635
637
|
parkAnnounced = false;
|
|
636
|
-
await api("/register", { session: SESSION, project: PROJ, status: `active in ${PROJ}`, llm: AGENT, model: MODEL, kind: "agent" }).catch(() => {});
|
|
638
|
+
await api("/register", { session: SESSION, project: PROJ, status: `active in ${PROJ}`, persona: PERSONA, agent: AGENT, llm: AGENT, model: MODEL, kind: "agent" }).catch(() => {});
|
|
637
639
|
await api("/send", { from: SESSION, to: "all", text: `✅ ${SESSION} recovered`, project: PROJ, kind: "status" }).catch(() => {});
|
|
638
640
|
cmuxStatus("ok", "#14b8a6", "check"); herdrAgent("idle");
|
|
639
641
|
}
|
|
@@ -647,7 +649,7 @@ for (const [name, flag] of Object.entries(stateFlags)) {
|
|
|
647
649
|
if (flag.value && process.env[name] === undefined) process.env[name] = flag.value;
|
|
648
650
|
}
|
|
649
651
|
const STATE_FLAG_ON = process.env[STATE_ENV] === "1";
|
|
650
|
-
const STATE_SCHEMA_FILE = join(homedir(), ".agent-bus", `state-schema-${
|
|
652
|
+
const STATE_SCHEMA_FILE = join(homedir(), ".agent-bus", `state-schema-${PERSONA}-${PROJ}.json`);
|
|
651
653
|
const STATE_MODE = (() => {
|
|
652
654
|
if (!STATE_FLAG_ON) return false;
|
|
653
655
|
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; }
|
|
@@ -771,8 +773,8 @@ async function runTurn(prompt, isFirst, trigger = "kickoff", opts = {}) {
|
|
|
771
773
|
await registerStatus(`working · ${trigger}`, { phase: "working", phaseSince: t0 });
|
|
772
774
|
// #7749: the same boundary on disk — the watchdog refreshes lastBytesAt/lastTranscriptAt while
|
|
773
775
|
// the turn runs, so a live reader sees the turn advance without waiting for its end.
|
|
774
|
-
writeTurnState(
|
|
775
|
-
const pf = join(homedir(), ".agent-bus", `turn-${
|
|
776
|
+
writeTurnState(PERSONA, PROJ, { turn: TURN, phase: "working", since: t0, card: sessionCard || 0, lastBytesAt: t0, lastTranscriptAt: 0 });
|
|
777
|
+
const pf = join(homedir(), ".agent-bus", `turn-${PERSONA}-${PROJ}.txt`);
|
|
776
778
|
appendFileSync(pf, "", { flag: "w" }); // truncate
|
|
777
779
|
appendFileSync(pf, prompt);
|
|
778
780
|
// #5868: where HEAD stood when the turn began. A turn that moved it shipped real work, and an
|
|
@@ -830,7 +832,7 @@ async function runTurn(prompt, isFirst, trigger = "kickoff", opts = {}) {
|
|
|
830
832
|
// instead of prose, and the operator watches that window. So on a state step stdout goes to a
|
|
831
833
|
// file and the runner prints the result line and the cost line itself. stderr still streams to
|
|
832
834
|
// the window through the process substitution below, unchanged.
|
|
833
|
-
const ENVF = join(homedir(), ".agent-bus", `envelope-${
|
|
835
|
+
const ENVF = join(homedir(), ".agent-bus", `envelope-${PERSONA}-${PROJ}.json`);
|
|
834
836
|
if (opts.state) { try { unlinkSync(ENVF); } catch {} }
|
|
835
837
|
const inner = opts.state
|
|
836
838
|
? `${cmd} > ${ENVF}`
|
|
@@ -838,20 +840,20 @@ async function runTurn(prompt, isFirst, trigger = "kickoff", opts = {}) {
|
|
|
838
840
|
// #5684: runTurn is spawnSync, so a DETACHED watchdog (stamp-armed) watches the turn and sends
|
|
839
841
|
// ONE stall report, never a kill. #6206: floor 10 min, never derived from TRANTOR_TURN_MAX_MS.
|
|
840
842
|
const WD_MS = Number(process.env.TRANTOR_TURN_WATCHDOG_MS) || 10 * 60 * 1000;
|
|
841
|
-
const STAMPF = join(homedir(), ".agent-bus", `turnstamp-${
|
|
843
|
+
const STAMPF = join(homedir(), ".agent-bus", `turnstamp-${PERSONA}-${PROJ}.json`);
|
|
842
844
|
// #6206: the CLI's transcript dir (a missing dir is a quiet channel), exported so a drill's fake
|
|
843
845
|
// CLI can write lines the watchdog sees. CUTF is written by the shell's time box: cut, not crashed.
|
|
844
|
-
const CUTF = join(homedir(), ".agent-bus", `turncut-${
|
|
846
|
+
const CUTF = join(homedir(), ".agent-bus", `turncut-${PERSONA}-${PROJ}`);
|
|
845
847
|
try { unlinkSync(CUTF); } catch {}
|
|
846
848
|
// #7752: written by the watchdog's kill mode when EVERY liveness channel has been quiet for
|
|
847
849
|
// the whole window; the box sweeps on the marker, so a silent turn ends at the stall window
|
|
848
850
|
// instead of burning the box. Unlinked at turn start and again with the cut cleanup.
|
|
849
|
-
const STALLF = join(homedir(), ".agent-bus", `turnstall-${
|
|
851
|
+
const STALLF = join(homedir(), ".agent-bus", `turnstall-${PERSONA}-${PROJ}`);
|
|
850
852
|
try { unlinkSync(STALLF); } catch {}
|
|
851
853
|
// #7761: the watchdog writes the extended deadline (epoch seconds) here for the shell box to
|
|
852
854
|
// re-read, and one row per extension to EXTF for the ledger. Both unlinked at turn start.
|
|
853
|
-
const DEADLINEF = join(homedir(), ".agent-bus", `turndeadline-${
|
|
854
|
-
const EXTF = join(homedir(), ".agent-bus", `turnext-${
|
|
855
|
+
const DEADLINEF = join(homedir(), ".agent-bus", `turndeadline-${PERSONA}-${PROJ}`);
|
|
856
|
+
const EXTF = join(homedir(), ".agent-bus", `turnext-${PERSONA}-${PROJ}`);
|
|
855
857
|
try { unlinkSync(DEADLINEF); } catch {}
|
|
856
858
|
try { unlinkSync(EXTF); } catch {}
|
|
857
859
|
const boxPlan = TURN_EXTENSIONS_MAX ? {
|
|
@@ -861,7 +863,7 @@ async function runTurn(prompt, isFirst, trigger = "kickoff", opts = {}) {
|
|
|
861
863
|
} : undefined;
|
|
862
864
|
// Touched by the stderr scrubber as its LAST act (the shell below); node waits for it after
|
|
863
865
|
// spawnSync before reading ERRF — see the drain note at the spawnSync call.
|
|
864
|
-
const DRAINF = join(homedir(), ".agent-bus", `turndrain-${
|
|
866
|
+
const DRAINF = join(homedir(), ".agent-bus", `turndrain-${PERSONA}-${PROJ}`);
|
|
865
867
|
try { unlinkSync(DRAINF); } catch {}
|
|
866
868
|
try {
|
|
867
869
|
const activity = cli.pinned || ["kimi", "claude"].includes(AGENT) ? {
|
|
@@ -874,7 +876,7 @@ async function runTurn(prompt, isFirst, trigger = "kickoff", opts = {}) {
|
|
|
874
876
|
// end the turn, so the watchdog stays report-only (reporting is the whole job, boxless).
|
|
875
877
|
TURN_MAX_MS ? STALLF : "",
|
|
876
878
|
// #7749: the turn-state file the watchdog refreshes mid-turn (lastBytesAt/lastTranscriptAt).
|
|
877
|
-
join(homedir(), ".agent-bus", `turnstate-${
|
|
879
|
+
join(homedir(), ".agent-bus", `turnstate-${PERSONA}-${PROJ}.json`)],
|
|
878
880
|
{ detached: true, stdio: "ignore" });
|
|
879
881
|
WD_CHILD = wd;
|
|
880
882
|
wd.unref();
|
|
@@ -982,7 +984,7 @@ exit $turn_exit`;
|
|
|
982
984
|
// #7749: the cut boundary lands on disk and on the peer row BEFORE the follow-up turn overwrites
|
|
983
985
|
// it — a reader sampling mid-chain sees what actually just happened, not a stale "working".
|
|
984
986
|
if (cut) {
|
|
985
|
-
writeTurnState(
|
|
987
|
+
writeTurnState(PERSONA, PROJ, { phase: stallCut ? "stalled" : "cut", since: Date.now() });
|
|
986
988
|
await registerStatus(stallCut ? "stalled · no activity" : "cut at the time box", { phase: stallCut ? "stalled" : "cut", phaseSince: Date.now() });
|
|
987
989
|
}
|
|
988
990
|
// #5869: scrub AT REST, synchronously, before anything reads the file back. The explicit shell
|
|
@@ -1130,7 +1132,7 @@ exit $turn_exit`;
|
|
|
1130
1132
|
// empty/stalled turns to the card that produced nothing. `cut` stays too: the drills read it.
|
|
1131
1133
|
const outcome = cut ? (stallCut ? "stalled" : "cut") : (effExit !== 0 ? "api-error" : noDelivery ? "no-delivery" : lastEmptyTurn ? "empty" : "completed");
|
|
1132
1134
|
const finalOutcome = askVerdict ? "asked" : blockedVerdict ? "blocked" : outcome;
|
|
1133
|
-
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 };
|
|
1135
|
+
const telemetryRow = { ts: Date.now(), persona: PERSONA, 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 };
|
|
1134
1136
|
if (usage) telemetryRow.usage = usage;
|
|
1135
1137
|
if (dockerNew.length) telemetryRow.containers = dockerNew.map(c => c.name || c.id);
|
|
1136
1138
|
if (cut) telemetryRow.cut = true;
|
|
@@ -1149,7 +1151,7 @@ exit $turn_exit`;
|
|
|
1149
1151
|
// #5965 — TURN END. A clean exit means the seat is idle again; say so right away so the app stops
|
|
1150
1152
|
// pulsing it even before the next /poll heartbeat. Failure keeps reportFailure's down/errored.
|
|
1151
1153
|
if (realExit === 0 && effExit === 0) {
|
|
1152
|
-
writeTurnState(
|
|
1154
|
+
writeTurnState(PERSONA, PROJ, { phase: "idle", since: Date.now() });
|
|
1153
1155
|
await registerStatus("idle", { phase: "idle", phaseSince: Date.now() });
|
|
1154
1156
|
}
|
|
1155
1157
|
// The follow-up rides the SAME session, exactly once. A state step gets none: TIME_BOX_PROMPT
|
|
@@ -1314,7 +1316,7 @@ function shouldWake(message) {
|
|
|
1314
1316
|
}
|
|
1315
1317
|
return message?.to === "all"
|
|
1316
1318
|
&& isContract(message)
|
|
1317
|
-
&& (message.text.includes(`@${
|
|
1319
|
+
&& (message.text.includes(`@${PERSONA}`) || message.text.toLowerCase().includes(`${PERSONA.toLowerCase()}:`));
|
|
1318
1320
|
}
|
|
1319
1321
|
|
|
1320
1322
|
// #6228: the operator's declared project links, cached briefly so a burst of wakes doesn't hit
|
|
@@ -1377,7 +1379,7 @@ async function resolveWakeCard(messages, { session }) {
|
|
|
1377
1379
|
// session is — the overseer's declared-crew exemption reads it, and on the remote hub there is
|
|
1378
1380
|
// no crew-windows.txt to fall back to. /register preserves absent fields, so a seat running an
|
|
1379
1381
|
// older runner never loses a kind an updated one stamped.
|
|
1380
|
-
await api("/register", { session: SESSION, project: PROJ, status: "crew member booting", llm: AGENT, model: MODEL, kind: "agent" }).catch(() => {});
|
|
1382
|
+
await api("/register", { session: SESSION, project: PROJ, status: "crew member booting", persona: PERSONA, agent: AGENT, llm: AGENT, model: MODEL, kind: "agent" }).catch(() => {});
|
|
1381
1383
|
// Announce runner-side, signed as THIS seat: the runner process is per-seat by construction, so
|
|
1382
1384
|
// its signature cannot be borrowed the way a shared opencode MCP daemon once borrowed identities.
|
|
1383
1385
|
try {
|
|
@@ -1452,7 +1454,7 @@ async function resolveWakeCard(messages, { session }) {
|
|
|
1452
1454
|
}
|
|
1453
1455
|
try {
|
|
1454
1456
|
// Persist before exec: a held contract and a released ask both survive the boundary.
|
|
1455
|
-
writeFileSync(PENDF, JSON.stringify({ agent: AGENT, project: PROJ, ts: Date.now(),
|
|
1457
|
+
writeFileSync(PENDF, JSON.stringify({ persona: PERSONA, agent: AGENT, project: PROJ, ts: Date.now(),
|
|
1456
1458
|
wake: pendingWake, bcast: pendingBcast, seen: seenLedger, ask: awaitingAsk?.id || 0, askTo: awaitingAsk?.to || "" }));
|
|
1457
1459
|
const state = { cursor, turn: TURN, sid, sessionCard, retryAt, deliveryFails,
|
|
1458
1460
|
noDeliveryRetry, askReleased, lastTurnAt, attempted: [...attemptedVersions] };
|
package/bin/crew.mjs
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { seatProcesses } from "./crew/processes.mjs";
|
|
3
|
+
import { allocatePersona } from "../lib/persona.mjs";
|
|
2
4
|
import { join } from "node:path";
|
|
3
5
|
import { call, createContext } from "./crew/core.mjs";
|
|
4
6
|
import { createCmuxAdapter, spawnCmux } from "./crew/cmux.mjs";
|
|
@@ -7,7 +9,7 @@ import { resolveSpec, reportSkipped } from "./crew/models.mjs";
|
|
|
7
9
|
import { openOrchestrator } from "./crew/open.mjs";
|
|
8
10
|
import { performSwap, swapOptions, swapRuntime } from "./crew/swap.mjs";
|
|
9
11
|
import { redactKeys } from "../lib/redact.mjs";
|
|
10
|
-
import { reapSeat, down, prune } from "./crew/state.mjs";
|
|
12
|
+
import { reapSeat, down, prune, readRows } from "./crew/state.mjs";
|
|
11
13
|
import { spawnTerminal, spawnTmux } from "./crew/tmux.mjs";
|
|
12
14
|
import { epochMs, failedAgents, verifyCrew } from "./crew/verify.mjs";
|
|
13
15
|
import { guardCrossProjectUp } from "./crew/worktrees.mjs";
|
|
@@ -46,17 +48,16 @@ function parseUpArgs(args) {
|
|
|
46
48
|
const arg = args[index];
|
|
47
49
|
if (arg === "--task") result.task = args[++index] || "code";
|
|
48
50
|
else if (arg === "--difficulty" || arg === "--diff") result.difficulty = args[++index] || "medium";
|
|
49
|
-
else
|
|
51
|
+
else if (arg === "--as") {
|
|
52
|
+
result.persona = args[++index];
|
|
53
|
+
if (!result.persona) throw new Error("--as requires a persona name");
|
|
54
|
+
} else result.specs.push(arg);
|
|
50
55
|
}
|
|
51
56
|
return result;
|
|
52
57
|
}
|
|
53
58
|
|
|
54
59
|
function spawnCrew(options, skipped) {
|
|
55
|
-
const resolve =
|
|
56
|
-
const seat = resolveSpec(ctx, spec, options.task, options.difficulty, skipped);
|
|
57
|
-
if (seat) reapSeat(ctx, seat.agent);
|
|
58
|
-
return seat;
|
|
59
|
-
};
|
|
60
|
+
const resolve = seat => { reapSeat(ctx, seat.persona); return seat; };
|
|
60
61
|
const pruneNow = () => prune(ctx, adapters);
|
|
61
62
|
if (ctx.mux === "herdr") spawnHerdr(ctx, options.specs, resolve, pruneNow);
|
|
62
63
|
else if (ctx.mux === "cmux") spawnCmux(ctx, options.specs, resolve, pruneNow);
|
|
@@ -73,6 +74,7 @@ async function runUp(args, verify = true) {
|
|
|
73
74
|
const options = parseUpArgs(args);
|
|
74
75
|
const requested = resolveAgentLaunchSpecs(options.specs, readConfig());
|
|
75
76
|
options.specs = requested.specs;
|
|
77
|
+
if (options.persona && options.specs.length !== 1) throw new Error("--as requires exactly one agent");
|
|
76
78
|
resultSeat = options.specs.map(spec => spec.split(":")[0]).join(",");
|
|
77
79
|
if (!options.specs.length) {
|
|
78
80
|
console.log("usage: crew.mjs up [--task K --difficulty D] codex glm kimi deepseek (or set a default in Settings → Agents)");
|
|
@@ -87,8 +89,18 @@ async function runUp(args, verify = true) {
|
|
|
87
89
|
console.log(`[crew] hub for ${ctx.project}: ${ctx.hub} (baked into every seat; CREW_HUB=<url> overrides)`);
|
|
88
90
|
console.log(`— bringing up crew for ${ctx.project} (${ctx.mux === "terminal" ? "Terminal windows" : ctx.mux}) —`);
|
|
89
91
|
// #7760: build once in the first seat's worktree before any seat gets a contract.
|
|
90
|
-
if (!ctx.dry) await preflightFirstSeat(ctx, options.specs[0].split(":")[0]);
|
|
91
92
|
const skipped = [];
|
|
93
|
+
const occupied = readRows(ctx).filter(row => row.project === ctx.project).map(row => row.persona);
|
|
94
|
+
options.specs = options.specs.map(spec => {
|
|
95
|
+
const resolved = resolveSpec(ctx, spec, options.task, options.difficulty, skipped);
|
|
96
|
+
if (!resolved) return null;
|
|
97
|
+
if (seatProcesses(ctx, ctx.project, resolved.agent).length && !occupied.includes(resolved.agent)) occupied.push(resolved.agent);
|
|
98
|
+
const seat = allocatePersona({ dir: join(ctx.home, ".agent-bus"), project: ctx.project, ...resolved, name: options.persona, occupied, dry: ctx.dry });
|
|
99
|
+
occupied.push(seat.persona);
|
|
100
|
+
return { ...resolved, ...seat };
|
|
101
|
+
}).filter(Boolean);
|
|
102
|
+
resultSeat = options.specs.map(seat => seat.persona).join(",");
|
|
103
|
+
if (!ctx.dry && options.specs.length) await preflightFirstSeat(ctx, options.specs[0].persona);
|
|
92
104
|
const started = epochMs();
|
|
93
105
|
spawnCrew(options, skipped);
|
|
94
106
|
if (ctx.dry || !verify) {
|
|
@@ -96,9 +108,9 @@ async function runUp(args, verify = true) {
|
|
|
96
108
|
return reportSkipped(skipped);
|
|
97
109
|
}
|
|
98
110
|
console.log("— verifying on the bus (the spawn is not the truth; the bus is) —");
|
|
99
|
-
let failed = failedAgents(verifyCrew(ctx, options.specs.map(
|
|
111
|
+
let failed = failedAgents(verifyCrew(ctx, options.specs.map(seat => seat.persona), started));
|
|
100
112
|
if (failed.length) {
|
|
101
|
-
const retry = options.specs.filter(
|
|
113
|
+
const retry = options.specs.filter(seat => failed.includes(seat.persona));
|
|
102
114
|
console.log(`— retrying failed spawns: ${retry.join(" ")} —`);
|
|
103
115
|
options.specs = retry;
|
|
104
116
|
spawnCrew(options, skipped);
|
package/bin/keys.mjs
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// trantor keys — identity private keys in the macOS Keychain instead of plaintext files (#11370).
|
|
3
|
+
// status [--json] · migrate [--dry-run] [--json]
|
|
4
|
+
// migrate copies each file-held key into the Keychain (service trantor-identity), proves a
|
|
5
|
+
// signature round-trip, and only then rewrites the file as a pubkey-only stub. Keys are never printed.
|
|
6
|
+
import { migrateKeysToKeychain, keyLocationReport, IDENTITY_SERVICE } from "../lib/identity.mjs";
|
|
7
|
+
|
|
8
|
+
const args = process.argv.slice(2);
|
|
9
|
+
const verb = args[0] || "status";
|
|
10
|
+
const JSON_MODE = args.includes("--json");
|
|
11
|
+
const C = { dim: "\x1b[2m", grn: "\x1b[32m", yel: "\x1b[33m", red: "\x1b[31m", off: "\x1b[0m" };
|
|
12
|
+
const usage = () => {
|
|
13
|
+
console.error("usage: trantor keys status [--json] | migrate [--dry-run] [--json]");
|
|
14
|
+
process.exit(1);
|
|
15
|
+
};
|
|
16
|
+
const tilde = (p) => String(p).replace(process.env.HOME || "", "~");
|
|
17
|
+
const shortPub = (p) => (p ? `${p.slice(0, 16)}…` : "(unreadable)");
|
|
18
|
+
|
|
19
|
+
function status() {
|
|
20
|
+
const rows = keyLocationReport();
|
|
21
|
+
if (JSON_MODE) { console.log(JSON.stringify({ service: IDENTITY_SERVICE, rows }, null, 2)); return; }
|
|
22
|
+
if (!rows.length) { console.log(`${C.dim}no identities yet (~/.agent-bus/keys is empty)${C.off}`); return; }
|
|
23
|
+
console.log(`identity keys (keychain service: ${IDENTITY_SERVICE}):`);
|
|
24
|
+
for (const r of rows) {
|
|
25
|
+
const mark = r.where === "keychain" ? `${C.grn}✓${C.off}` : r.fileKey ? `${C.yel}•${C.off}` : `${C.red}✗${C.off}`;
|
|
26
|
+
console.log(` ${mark} ${r.name.padEnd(28)} ${shortPub(r.pubkey).padEnd(19)} ${r.where}`);
|
|
27
|
+
}
|
|
28
|
+
const plaintext = rows.filter((r) => r.fileKey);
|
|
29
|
+
if (plaintext.length) console.log(`\n${plaintext.length} key(s) still in plaintext files → trantor keys migrate`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function migrate() {
|
|
33
|
+
const dryRun = args.includes("--dry-run");
|
|
34
|
+
const r = migrateKeysToKeychain({ dryRun });
|
|
35
|
+
if (JSON_MODE) { console.log(JSON.stringify(r, null, 2)); return; }
|
|
36
|
+
if (!r.usable) {
|
|
37
|
+
console.log("no Keychain on this platform (or TRANTOR_NO_KEYCHAIN=1) — identity keys stay in files; nothing to do");
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const did = dryRun ? "would move" : "moved";
|
|
41
|
+
console.log(`${dryRun ? `${C.dim}dry run${C.off} · ` : ""}keychain service: ${r.service}`);
|
|
42
|
+
for (const n of r.moved) console.log(` ${C.grn}✓${C.off} ${did} ${n}${dryRun ? "" : " (file is now a pubkey-only stub)"}`);
|
|
43
|
+
for (const n of r.already) console.log(` ${C.dim}already in the keychain: ${n}${C.off}`);
|
|
44
|
+
for (const f of r.failed) console.log(` ${C.red}✗${C.off} ${f.name}: ${f.reason} — the file was left untouched`);
|
|
45
|
+
if (!r.moved.length && !r.failed.length && !r.already.length) console.log(` ${C.dim}nothing to move${C.off}`);
|
|
46
|
+
if (r.failed.length) process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
switch (verb) {
|
|
50
|
+
case "status": status(); break;
|
|
51
|
+
case "migrate": migrate(); break;
|
|
52
|
+
default: usage();
|
|
53
|
+
}
|
package/hub/auth.mjs
CHANGED
|
@@ -330,7 +330,7 @@ const seenNonces = new Map();
|
|
|
330
330
|
const guard = crossProjectGuard(auth, P, { scopes });
|
|
331
331
|
if (!guard.ok) return json(res, guard.code, { error: guard.error });
|
|
332
332
|
const ttlSec = Math.min(Math.max(Number(b0.ttlSec) || 86400, 1), 30 * 86400);
|
|
333
|
-
const token = randomBytes(
|
|
333
|
+
const token = randomBytes(32).toString("hex");
|
|
334
334
|
state.inviteTokens[token] = { scopes, kind: invitedKind, expiresAt: now() + ttlSec * 1000, used: false,
|
|
335
335
|
createdBy: auth.identity?.pubkey || "", createdAt: now() };
|
|
336
336
|
markDirty();
|
package/hub.mjs
CHANGED
|
@@ -27,7 +27,7 @@ const PG_URL = process.env.RELAY_DATABASE_URL || process.env.POSTGRES_URL ||
|
|
|
27
27
|
((RAW_STORE_KIND === "pg" || RAW_STORE_KIND === "postgres") ? process.env.DATABASE_URL : "");
|
|
28
28
|
const STORE_KIND = RAW_STORE_KIND || (PG_URL ? "pg" : "json");
|
|
29
29
|
const ORG_ID = process.env.RELAY_ORG_ID || DEFAULT_ORG;
|
|
30
|
-
const AUTH_MODE = ["off", "warn", "enforce"].includes(process.env.RELAY_AUTH) ? process.env.RELAY_AUTH : "
|
|
30
|
+
const AUTH_MODE = ["off", "warn", "enforce"].includes(process.env.RELAY_AUTH) ? process.env.RELAY_AUTH : "enforce";
|
|
31
31
|
const ENROLL_MODE = process.env.RELAY_ENROLL || "tofu";
|
|
32
32
|
const ONLINE_MS = Number(process.env.RELAY_ONLINE_MS || 5 * 60 * 1000);
|
|
33
33
|
const PEER_TTL_DEFAULT_MS = 21600000;
|
package/lib/identity.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// trantor — cryptographic identity, THE INTERFACE CONTRACT for Phase 0 (docs/TDD-trantor-platform.md §7).
|
|
2
2
|
// An identity is an Ed25519 keypair and the label is cosmetic; every primitive lives behind this module.
|
|
3
3
|
import { generateKeyPairSync, createPublicKey, createPrivateKey, sign as cryptoSign, verify as cryptoVerify, createHash, randomBytes } from "node:crypto";
|
|
4
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, chmodSync } from "node:fs";
|
|
4
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, chmodSync, readdirSync, unlinkSync } from "node:fs";
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
5
6
|
import { join } from "node:path";
|
|
6
7
|
import { homedir } from "node:os";
|
|
7
8
|
import { asFunction, asString } from "./decode.mjs";
|
|
@@ -64,23 +65,120 @@ function privKeyObject(identity) {
|
|
|
64
65
|
});
|
|
65
66
|
}
|
|
66
67
|
|
|
68
|
+
// --- the keychain layer (#11370) -----------------------------------------------------------------
|
|
69
|
+
// The private half moves OFF disk into the macOS Keychain (service below, account = the raw
|
|
70
|
+
// identity name); `trantor keys migrate` rewrites the file as a pubkey-only stub. Reads go
|
|
71
|
+
// Keychain-first through a per-process cache, with a one-line file fallback when locked/absent.
|
|
72
|
+
export const IDENTITY_SERVICE = "trantor-identity";
|
|
73
|
+
const SECURITY = "/usr/bin/security";
|
|
74
|
+
// A drill points this at a throwaway keychain so the operator's real one is never touched.
|
|
75
|
+
const kcArgs = () => process.env.TRANTOR_IDENTITY_KEYCHAIN ? [process.env.TRANTOR_IDENTITY_KEYCHAIN] : [];
|
|
76
|
+
|
|
77
|
+
// Test seam: drills inject a fake ({ get, set, remove } — get returns the stored string | null and
|
|
78
|
+
// may THROW to simulate a locked keychain) so no test needs the real thing.
|
|
79
|
+
let injectedKeychain = null;
|
|
80
|
+
export function injectIdentityKeychain(fake) {
|
|
81
|
+
injectedKeychain = fake || null;
|
|
82
|
+
processCache.clear();
|
|
83
|
+
warned = false;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const keychainUsable = () =>
|
|
87
|
+
!!injectedKeychain ||
|
|
88
|
+
(process.platform === "darwin" && process.env.TRANTOR_NO_KEYCHAIN !== "1" && existsSync(SECURITY));
|
|
89
|
+
|
|
90
|
+
let warned = false;
|
|
91
|
+
function warnOnce(msg) {
|
|
92
|
+
if (warned) return;
|
|
93
|
+
warned = true;
|
|
94
|
+
console.error(`trantor identity: ${msg}`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Three outcomes matter: a hit, a MISS (never stored — normal before migrate), and UNAVAILABLE
|
|
98
|
+
// (locked, headless, pre-wake). Only the last one ever warns.
|
|
99
|
+
function keychainGet(account) {
|
|
100
|
+
if (injectedKeychain) {
|
|
101
|
+
try { return { value: injectedKeychain.get(account) || null }; }
|
|
102
|
+
catch { return { error: true }; }
|
|
103
|
+
}
|
|
104
|
+
const r = spawnSync(SECURITY, ["find-generic-password", "-a", account, "-s", IDENTITY_SERVICE, "-w", ...kcArgs()], { encoding: "utf8", timeout: 8000 });
|
|
105
|
+
if (r.status === 0) return { value: String(r.stdout).replace(/\n$/, "") };
|
|
106
|
+
return r.status === 44 ? {} : { error: true }; // 44 = errSecItemNotFound
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function keychainSet(account, value) {
|
|
110
|
+
if (injectedKeychain) { injectedKeychain.set(account, value); return; }
|
|
111
|
+
// The value travels on stdin in `security -i` syntax, so it never shows in the process table;
|
|
112
|
+
// only the exit code comes back (security echoes the failing line, so stderr is not relayed).
|
|
113
|
+
const quote = (s) => `"${String(s).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
114
|
+
const kc = kcArgs();
|
|
115
|
+
const line = `add-generic-password -a ${quote(account)} -s ${quote(IDENTITY_SERVICE)} -U -w ${quote(value)}${kc.length ? ` ${quote(kc[0])}` : ""}\n`;
|
|
116
|
+
const r = spawnSync(SECURITY, ["-i"], { encoding: "utf8", input: line, timeout: 8000 });
|
|
117
|
+
if (r.status !== 0) throw new Error(`keychain write failed for ${account} (security exited ${r.status ?? "signal"})`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// A keychain entry is the whole identity as JSON, so it stays self-sufficient even if the stub
|
|
121
|
+
// file is lost. Anything that does not parse to a well-formed Ed25519 pair is not a key.
|
|
122
|
+
const HEX64 = /^[0-9a-f]{64}$/i;
|
|
123
|
+
function parseStored(raw) {
|
|
124
|
+
try {
|
|
125
|
+
const id = JSON.parse(raw);
|
|
126
|
+
return HEX64.test(id?.pubkey || "") && HEX64.test(id?.privkey || "") ? id : null;
|
|
127
|
+
} catch { return null; }
|
|
128
|
+
}
|
|
129
|
+
|
|
67
130
|
// --- key file ----------------------------------------------------------------------------------
|
|
131
|
+
// One read per process: hooks sign often, and the Keychain read is the expensive half.
|
|
132
|
+
const processCache = new Map();
|
|
133
|
+
const cacheKey = (name) => `${keysDir()}\x00${name}`;
|
|
68
134
|
export function load(name) {
|
|
135
|
+
const ck = cacheKey(String(name));
|
|
136
|
+
if (processCache.has(ck)) return processCache.get(ck);
|
|
137
|
+
const id = resolveIdentity(String(name));
|
|
138
|
+
processCache.set(ck, id);
|
|
139
|
+
return id;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function resolveIdentity(name) {
|
|
69
143
|
const f = keyPath(name);
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
144
|
+
let fileId = null;
|
|
145
|
+
if (existsSync(f)) {
|
|
146
|
+
try {
|
|
147
|
+
const parsed = JSON.parse(readFileSync(f, "utf8"));
|
|
148
|
+
if (parsed?.pubkey) fileId = parsed;
|
|
149
|
+
} catch { fileId = null; }
|
|
150
|
+
}
|
|
151
|
+
// A file still carrying its privkey is the live record: a rotate or a fresh mint lands in the
|
|
152
|
+
// file first, so it must win over any older keychain copy under the same name.
|
|
153
|
+
if (fileId?.privkey) return fileId;
|
|
154
|
+
if (keychainUsable()) {
|
|
155
|
+
const kc = keychainGet(String(name));
|
|
156
|
+
const stored = kc.value ? parseStored(kc.value) : null;
|
|
157
|
+
// The keychain holds a self-consistent pair; the stub contributes display fields only. Taking
|
|
158
|
+
// the stub's pubkey with the keychain's privkey would mint an identity whose signatures never
|
|
159
|
+
// verify — so the crypto pair always comes from the same store.
|
|
160
|
+
if (stored) return fileId ? { ...stored, ...fileId, privkey: stored.privkey, pubkey: stored.pubkey } : stored;
|
|
161
|
+
if (fileId) {
|
|
162
|
+
warnOnce(kc.error
|
|
163
|
+
? `the Keychain is unavailable (locked?) and "${name}" has no key file to fall back on — signing fails until it unlocks`
|
|
164
|
+
: `"${name}": the key file is a pubkey-only stub but the Keychain has no entry — re-run \`trantor keys migrate\` or restore the key`);
|
|
165
|
+
}
|
|
166
|
+
} else if (fileId) {
|
|
167
|
+
warnOnce(`the Keychain is disabled on this platform and "${name}" has no key file to fall back on — signing fails`);
|
|
168
|
+
}
|
|
169
|
+
return null;
|
|
76
170
|
}
|
|
77
171
|
|
|
78
172
|
// Create-if-missing, safe against two hooks racing at session start: write to a unique temp file,
|
|
79
173
|
// rename into place (atomic on the same filesystem), and if we lost the race, use the winner's key.
|
|
80
174
|
// Two identities for one name would silently split a session's history in half.
|
|
175
|
+
// New keys are born as FILES even when the Keychain is up — `trantor keys migrate` moves them.
|
|
81
176
|
export function loadOrCreate(name, kind = "agent") {
|
|
82
177
|
const existing = load(name);
|
|
83
178
|
if (existing) return existing;
|
|
179
|
+
// A record exists (a pubkey-only stub, or a keychain entry the read above could not use) but its
|
|
180
|
+
// private half is unreachable right now: minting a new key would split the history, so refuse.
|
|
181
|
+
if (existsSync(keyPath(name))) return null;
|
|
84
182
|
mkdirSync(keysDir(), { recursive: true, mode: 0o700 });
|
|
85
183
|
try { chmodSync(keysDir(), 0o700); } catch {}
|
|
86
184
|
const { pubkey, privkey } = generate();
|
|
@@ -89,11 +187,16 @@ export function loadOrCreate(name, kind = "agent") {
|
|
|
89
187
|
const tmp = `${f}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
90
188
|
try {
|
|
91
189
|
writeFileSync(tmp, JSON.stringify(id), { mode: 0o600 });
|
|
92
|
-
if (existsSync(f)) {
|
|
190
|
+
if (existsSync(f)) { // someone beat us between check and write
|
|
191
|
+
try { unlinkSync(tmp); } catch {} // the loser's tmp still holds a private key
|
|
192
|
+
return load(name) || id;
|
|
193
|
+
}
|
|
93
194
|
renameSync(tmp, f);
|
|
94
195
|
chmodSync(f, 0o600);
|
|
196
|
+
processCache.set(cacheKey(String(name)), id);
|
|
95
197
|
return id;
|
|
96
198
|
} catch {
|
|
199
|
+
try { unlinkSync(tmp); } catch {}
|
|
97
200
|
return load(name) || id; // lost the race, or unwritable — never throw
|
|
98
201
|
}
|
|
99
202
|
}
|
|
@@ -103,6 +206,76 @@ export function publicView(identity) {
|
|
|
103
206
|
return { name: identity.name, kind: identity.kind, pubkey: identity.pubkey, createdAt: identity.createdAt };
|
|
104
207
|
}
|
|
105
208
|
|
|
209
|
+
// --- migrate + status (`trantor keys …`) ---------------------------------------------------------
|
|
210
|
+
// One <safe-name>.json per durable identity; the instances/ subdir holds per-session subkeys that
|
|
211
|
+
// die with the session and stay files by design.
|
|
212
|
+
export function listKeyNames() {
|
|
213
|
+
let files = [];
|
|
214
|
+
try { files = readdirSync(keysDir()); } catch { return []; }
|
|
215
|
+
return files.filter((f) => f.endsWith(".json")).map((f) => f.slice(0, -".json".length)).sort();
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// The stub keeps everything a peer may see plus a pointer at where the private half now lives.
|
|
219
|
+
function writeKeyStub(f, id) {
|
|
220
|
+
const stub = { name: id.name, kind: id.kind, pubkey: id.pubkey, createdAt: id.createdAt, keychain: IDENTITY_SERVICE };
|
|
221
|
+
const tmp = `${f}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
222
|
+
writeFileSync(tmp, JSON.stringify(stub), { mode: 0o600 });
|
|
223
|
+
renameSync(tmp, f);
|
|
224
|
+
try { chmodSync(f, 0o600); } catch {}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Copy each file-held private key into the Keychain, prove the stored copy SIGNS (a round-trip
|
|
228
|
+
// against the file's pubkey), and only then rewrite the file as a pubkey-only stub. A key that
|
|
229
|
+
// fails to store or to verify keeps its file untouched.
|
|
230
|
+
export function migrateKeysToKeychain({ dryRun = false } = {}) {
|
|
231
|
+
const result = { service: IDENTITY_SERVICE, usable: keychainUsable(), moved: [], already: [], failed: [], dryRun };
|
|
232
|
+
if (!result.usable) return result;
|
|
233
|
+
for (const fileBase of listKeyNames()) {
|
|
234
|
+
let id = null;
|
|
235
|
+
try { id = JSON.parse(readFileSync(keyPath(fileBase), "utf8")); } catch {}
|
|
236
|
+
// The keychain account is the IDENTITY's name (card #11370), i.e. the raw name the JSON
|
|
237
|
+
// carries — the file's name is only the sanitised lookup key and load() reads back by the
|
|
238
|
+
// raw name, so storing under "name_proj" while hooks look for "name:proj" would orphan
|
|
239
|
+
// every migrated key on its first read.
|
|
240
|
+
const name = id?.name ?? fileBase;
|
|
241
|
+
if (!id?.pubkey) { result.failed.push({ name, reason: "unreadable key file" }); continue; }
|
|
242
|
+
if (!id.privkey) { result.already.push(name); continue; }
|
|
243
|
+
if (dryRun) { result.moved.push(name); continue; }
|
|
244
|
+
try {
|
|
245
|
+
keychainSet(name, JSON.stringify(id));
|
|
246
|
+
const stored = parseStored(keychainGet(name).value || "");
|
|
247
|
+
if (!stored || stored.privkey !== id.privkey || stored.pubkey !== id.pubkey) throw new Error("read-back mismatch");
|
|
248
|
+
const probe = Buffer.from(`trantor-keychain-migrate\n${id.pubkey}`, "utf8");
|
|
249
|
+
const sig = cryptoSign(null, probe, privKeyObject(stored));
|
|
250
|
+
if (!cryptoVerify(null, probe, pubFromHex(id.pubkey), sig)) throw new Error("signature round-trip failed");
|
|
251
|
+
writeKeyStub(keyPath(name), id);
|
|
252
|
+
processCache.clear(); // this process now reads stub + keychain
|
|
253
|
+
result.moved.push(name);
|
|
254
|
+
} catch (e) {
|
|
255
|
+
result.failed.push({ name, reason: String(e?.message || e) });
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return result;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Where does each identity's PRIVATE half live? Presence booleans only — key material is never
|
|
262
|
+
// read into the report.
|
|
263
|
+
export function keyLocationReport() {
|
|
264
|
+
const usable = keychainUsable();
|
|
265
|
+
return listKeyNames().map((fileBase) => {
|
|
266
|
+
let fileKey = false, pubkey = null, name = fileBase;
|
|
267
|
+
try {
|
|
268
|
+
const id = JSON.parse(readFileSync(keyPath(fileBase), "utf8"));
|
|
269
|
+
fileKey = !!id?.privkey;
|
|
270
|
+
pubkey = id?.pubkey || null;
|
|
271
|
+
name = id?.name ?? fileBase; // the identity's real name is what the keychain is keyed on
|
|
272
|
+
} catch {}
|
|
273
|
+
const inKeychain = usable ? !!parseStored(keychainGet(name).value || "") : false;
|
|
274
|
+
const where = fileKey && inKeychain ? "file + keychain" : fileKey ? "file (plaintext)" : inKeychain ? "keychain" : "NO USABLE PRIVATE KEY";
|
|
275
|
+
return { name, pubkey, where, keychain: inKeychain, fileKey };
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
106
279
|
// --- canonical request -------------------------------------------------------------------------
|
|
107
280
|
export function bodyHash(body) {
|
|
108
281
|
if (body === undefined || body === null || body === "") return "";
|
package/lib/persona.mjs
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
export function personaName(value) {
|
|
5
|
+
if (!/^[A-Za-z0-9_-]+$/.test(value || "")) throw new Error("persona must contain only letters, digits, underscores or hyphens");
|
|
6
|
+
return value;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function runnerPersona(env, agent) {
|
|
10
|
+
return personaName(env.CREW_PERSONA || agent);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function allocatePersona({ dir, project, agent, model = "", name, occupied = [], dry = false }) {
|
|
14
|
+
personaName(agent);
|
|
15
|
+
const folder = join(dir, "personas", encodeURIComponent(project));
|
|
16
|
+
if (!dry) mkdirSync(folder, { recursive: true });
|
|
17
|
+
for (let n = 1; ; n++) {
|
|
18
|
+
const persona = personaName(name || (n === 1 ? agent : `${agent}-${n}`));
|
|
19
|
+
if (occupied.includes(persona)) {
|
|
20
|
+
if (name) throw new Error(`persona ${persona} is already active`);
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
const file = join(folder, `${persona}.json`);
|
|
24
|
+
const seat = { persona, agent, model };
|
|
25
|
+
if (dry) return seat;
|
|
26
|
+
try {
|
|
27
|
+
writeFileSync(file, JSON.stringify(seat), { flag: "wx", mode: 0o600 });
|
|
28
|
+
return seat;
|
|
29
|
+
} catch (error) {
|
|
30
|
+
if (error.code !== "EEXIST") throw error;
|
|
31
|
+
if (!name) continue;
|
|
32
|
+
const prior = JSON.parse(readFileSync(file, "utf8"));
|
|
33
|
+
if (prior.agent !== agent) throw new Error(`persona ${persona} runs ${prior.agent}; use swap to change its agent`);
|
|
34
|
+
return { ...prior, model };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function updatePersona({ dir, project, persona, agent, model = "" }) {
|
|
40
|
+
const folder = join(dir, "personas", encodeURIComponent(project));
|
|
41
|
+
mkdirSync(folder, { recursive: true });
|
|
42
|
+
writeFileSync(join(folder, `${personaName(persona)}.json`), JSON.stringify({ persona, agent, model }), { mode: 0o600 });
|
|
43
|
+
}
|
package/lib/session-liveness.mjs
CHANGED
|
@@ -23,14 +23,14 @@ function query(db, statement) {
|
|
|
23
23
|
const r = spawnSync("sqlite3", ["-readonly", db, statement], {
|
|
24
24
|
encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 2000,
|
|
25
25
|
});
|
|
26
|
-
return r.status === 0 ? r.stdout.trim() :
|
|
27
|
-
} catch { return
|
|
26
|
+
return r.status === 0 ? r.stdout.trim() : null;
|
|
27
|
+
} catch { return null; }
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
// Pin the root once discovered. A fresh root must have been created during this turn;
|
|
31
31
|
// a resumed root is supplied by the runner. Never use global DB/WAL mtimes.
|
|
32
32
|
export function sessionActivityReader({ kind, sid = "", db, workDir, home, startedAt, transcriptDir }) {
|
|
33
|
-
let root = sid, logDir = "";
|
|
33
|
+
let root = sid, logDir = "", lastDb = 0;
|
|
34
34
|
const cwd = canonical(workDir);
|
|
35
35
|
return () => {
|
|
36
36
|
if (kind === "opencode") {
|
|
@@ -41,7 +41,9 @@ export function sessionActivityReader({ kind, sid = "", db, workDir, home, start
|
|
|
41
41
|
+ ` UNION SELECT s.id FROM session s JOIN own ON s.parent_id=own.id)`
|
|
42
42
|
+ ` SELECT MAX(t) FROM (SELECT MAX(time_created,time_updated) t FROM message WHERE session_id IN (SELECT id FROM own)`
|
|
43
43
|
+ ` UNION ALL SELECT MAX(time_created,time_updated) t FROM part WHERE session_id IN (SELECT id FROM own));`);
|
|
44
|
-
|
|
44
|
+
// #9832: a locked or timed-out read is unknown, not silence; keep the last seen activity.
|
|
45
|
+
if (value === null) return lastDb;
|
|
46
|
+
return (lastDb = Math.max(lastDb, Number(value) || 0));
|
|
45
47
|
}
|
|
46
48
|
if (kind === "claude") {
|
|
47
49
|
if (!root) {
|
package/lib/state/gate.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { spawnSync } from "node:child_process";
|
|
4
4
|
import { existsSync, mkdirSync, readFileSync, mkdtempSync, readdirSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { tmpdir } from "node:os";
|
|
6
|
-
import { join, resolve } from "node:path";
|
|
6
|
+
import { dirname, join, resolve } from "node:path";
|
|
7
7
|
import { CAPS } from "./schema.mjs";
|
|
8
8
|
|
|
9
9
|
const DEFAULT_MAX_MS = 300_000;
|