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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.85",
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.agent === seat.agent).at(-1)?.handle || "" : "";
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.agent === seat.agent).at(-1)?.handle || "" : "";
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(agent)} ${shellQuote(ctx.dir)}`;
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) {
@@ -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.agent === seat.agent).at(-1)?.handle || "";
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
 
@@ -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, agent, handle) {
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.agent === agent && row.handle === handle)) return;
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.agent === agent) && (!handle || row.handle === handle)
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.agent);
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.agent)));
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.agent} (${row.kind})`);
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.agent}|${row.handle}`));
144
- const kept = rows.filter(row => !removed.has(`${row.project}|${row.kind}|${row.agent}|${row.handle}`) || (orchProjects.has(row.project) && ["herdrws", "cmuxws", "orch"].includes(row.kind)));
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.agent}`, row])).values()) {
151
- appendFileSync(join(logDir, `${row.agent}-${row.project}.jsonl`),
152
- `${JSON.stringify({ ts: Date.now(), agent: row.agent, project: row.project, stopped: true })}\n`);
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.agent))];
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.agent))];
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, agent: stage.label });
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, 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 }));
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 = options.agent === options.from ? pending : readPending(ctx, options.agent);
143
- const from = `${options.from}:${ctx.project}`, to = `${options.agent}:${ctx.project}`;
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.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
+ 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
- if (options.agent !== options.from) savePending(ctx, options.from, { ...pending, wake: [], bcast: [], ask: 0, askTo: "" });
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
  }
@@ -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: 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 || `${AGENT}:${PROJ}`;
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, `${AGENT}-${PROJ}.jsonl`), JSON.stringify(rec) + "\n"); } catch {} };
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] === AGENT)
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-${AGENT}-${PROJ}.txt`);
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-${AGENT}-${PROJ}.json`);
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-${AGENT}-${PROJ}.json`);
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(AGENT, PROJ);
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(AGENT, PROJ, { phase: "parked", since: Date.now() });
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-${AGENT}-${PROJ}.json`);
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(AGENT, PROJ, { turn: TURN, phase: "working", since: t0, card: sessionCard || 0, lastBytesAt: t0, lastTranscriptAt: 0 });
775
- const pf = join(homedir(), ".agent-bus", `turn-${AGENT}-${PROJ}.txt`);
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-${AGENT}-${PROJ}.json`);
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-${AGENT}-${PROJ}.json`);
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-${AGENT}-${PROJ}`);
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-${AGENT}-${PROJ}`);
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-${AGENT}-${PROJ}`);
854
- const EXTF = join(homedir(), ".agent-bus", `turnext-${AGENT}-${PROJ}`);
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-${AGENT}-${PROJ}`);
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-${AGENT}-${PROJ}.json`)],
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(AGENT, PROJ, { phase: stallCut ? "stalled" : "cut", since: Date.now() });
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(AGENT, PROJ, { phase: "idle", since: Date.now() });
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(`@${AGENT}`) || message.text.toLowerCase().includes(`${AGENT}:`));
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 result.specs.push(arg);
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 = spec => {
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(spec => spec.split(":")[0]), started));
111
+ let failed = failedAgents(verifyCrew(ctx, options.specs.map(seat => seat.persona), started));
100
112
  if (failed.length) {
101
- const retry = options.specs.filter(spec => failed.includes(spec.split(":")[0]));
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(24).toString("hex");
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 : "warn";
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
- if (!existsSync(f)) return null;
71
- try {
72
- const id = JSON.parse(readFileSync(f, "utf8"));
73
- if (!id?.pubkey || !id?.privkey) return null;
74
- return id;
75
- } catch { return null; }
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)) { return load(f && name) || id; } // someone beat us between check and write
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 "";
@@ -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
+ }
@@ -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
- return Number(value) || 0;
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) {
@@ -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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.85",
3
+ "version": "0.18.86",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"