trantor 0.18.112 → 0.18.113

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.112",
3
+ "version": "0.18.113",
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/provider.mjs CHANGED
@@ -46,18 +46,27 @@ function opencodeModelCount(providerOc) {
46
46
  catch { return 0; }
47
47
  }
48
48
 
49
- function listSeats() {
50
- const world = loadWorld();
49
+ export function crewSeats(world = loadWorld(), countModels = opencodeModelCount) {
51
50
  const { roster, agents, profile } = world;
51
+ return Object.entries(roster).map(([agent, seat]) => ({
52
+ agent,
53
+ spec: seat.launch,
54
+ tier: profile?.providers?.[seat.provider]?.tier || (seat.cli === "opencode" ? "—" : ""),
55
+ available: agents.includes(agent),
56
+ brought: !!seat.discovered,
57
+ models: seat.cli === "opencode" ? countModels(seat.providerOc) : null,
58
+ }));
59
+ }
60
+
61
+ function listSeats(opts) {
62
+ const rows = crewSeats();
63
+ if (opts.json) { console.log(JSON.stringify(rows, null, 2)); return; }
52
64
  console.log("CREW SEATS — built-in + brought (BYOM). ● = available now\n");
53
- for (const [label, s] of Object.entries(roster)) {
54
- const live = agents.includes(label);
55
- const tier = profile?.providers?.[s.provider]?.tier || (s.cli === "opencode" ? "—" : "");
56
- const kind = s.discovered ? "brought " : "built-in";
57
- const models = s.cli === "opencode" ? opencodeModelCount(s.providerOc) : null;
65
+ for (const { agent, spec, tier, available, brought, models } of rows) {
66
+ const kind = brought ? "brought " : "built-in";
58
67
  const mtxt = models == null ? "" : `${models} models`;
59
- const dot = live ? `${C.grn}●${C.off}` : `${C.dim}○${C.off}`;
60
- console.log(` ${dot} ${label.padEnd(14)} ${C.dim}${kind}${C.off} launch: ${s.launch.padEnd(26)} ${tier ? `tier=${tier}` : ""} ${C.dim}${mtxt}${C.off}`);
68
+ const dot = available ? `${C.grn}●${C.off}` : `${C.dim}○${C.off}`;
69
+ console.log(` ${dot} ${agent.padEnd(14)} ${C.dim}${kind}${C.off} launch: ${spec.padEnd(26)} ${tier ? `tier=${tier}` : ""} ${C.dim}${mtxt}${C.off}`);
61
70
  }
62
71
  console.log(`\n${C.dim}add one:${C.off} trantor provider add <name> --key sk-… --plan api`);
63
72
  console.log(`${C.dim}browse models:${C.off} trantor models [<provider>]`);
@@ -279,7 +288,8 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
279
288
  // The default list leads with the seats roster and closes with the provider status board —
280
289
  // one command answers both "what can I launch" and "what is actually alive" (#6390).
281
290
  const defaultList = async () => {
282
- listSeats();
291
+ listSeats(opts);
292
+ if (opts.json) return;
283
293
  console.log("");
284
294
  printStatusRows(await providerStatus());
285
295
  };
package/hub/auth.mjs CHANGED
@@ -55,7 +55,7 @@ const AUTH_HEADERS = ["x-trantor-pubkey", "x-trantor-sig", "x-trantor-ts", "x-tr
55
55
  const PUBLIC_ENDPOINTS = new Set(["/", "/ui", "/health", "/enroll"]);
56
56
  const OWNER_ENDPOINTS = new Set(["/project/delete", "/sweep", "/reconcile", "/invite", "/import", "/policy", "/session/prompts", "/session/prompt/claim", "/session/prompt/ack"]);
57
57
  const DEVICE_ENDPOINTS = new Set(["/device/register", "/device/unregister", "/devices"]);
58
- const READ_ENDPOINTS = new Set(["/peers", "/tasks", "/events", "/inbox", "/peer", "/card", "/stream", "/history", "/projects", "/catchup", "/phases", "/recent", "/handoffs", "/verify-gates", "/claims", "/proposals", "/grants", "/overseer/context", "/overseer/status", "/session/chat", "/session/prompt"]);
58
+ const READ_ENDPOINTS = new Set(["/peers", "/tasks", "/events", "/inbox", "/peer", "/card", "/stream", "/history", "/projects", "/catchup", "/phases", "/recent", "/handoffs", "/verify-gates", "/claims", "/proposals", "/grants", "/overseer/context", "/overseer/status", "/session/chat", "/session/prompt", "/crew/roster"]);
59
59
  const roleRank = { read: 1, write: 2, approver: 3, owner: 4 };
60
60
  const hasAuthHeaders = (req) => AUTH_HEADERS.some(h => !!req.headers[h]);
61
61
  const authPath = (u) => `${u.pathname}${u.search || ""}`;
@@ -15,6 +15,14 @@ const EXPIRED_REASON = "No Mac is relaying this project. Is the Trantor app open
15
15
  const ACK_OUTCOMES = new Set(["delivered", "blocked", "not_ready", "stalled", "no_agent", "stale"]);
16
16
  const ANSWER_PICKS_MAX = 10;
17
17
  const ANSWER_OTHER_MAX = 2000;
18
+ // #12140: crew commands from the phone. The hub validates and queues; the desktop relay runs the
19
+ // trantor CLI and acks done|failed with the output tail. A seat spec is "agent[:project]".
20
+ const CREW_SPEC = /^[a-z][a-z0-9-]{0,30}(:[A-Za-z0-9._/-]{1,80})?$/;
21
+ const CREW_SEATS_MAX = 6;
22
+ const CREW_OUTPUT_MAX = 2000; // the CLI's LAST 2000 chars, ANSI stripped
23
+ const CREW_CLAUDE_REFUSAL = "The claude seat draws your own Claude quota; start it on the Mac.";
24
+ // oxlint-disable-next-line no-control-regex -- stripping ANSI escape sequences IS matching control characters
25
+ const stripAnsi = (s) => String(s).replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, "");
18
26
 
19
27
  export function createSessionRuntime({ state, markDirty, appendEvent, pushEventToStreams, notifier }) {
20
28
  const now = () => Date.now();
@@ -123,13 +131,20 @@ export function createSessionRuntime({ state, markDirty, appendEvent, pushEventT
123
131
  }
124
132
  }
125
133
 
134
+ // #12140: the desktop publishes the seats its Mac can spawn (from `trantor provider status
135
+ // --json`) at boot and every 10 minutes. Memory-only like the chat mirror — a restarted hub
136
+ // has no roster until the desktop republishes.
137
+ const roster = new Map(); // machine -> { machine, updatedAt, seats }
138
+
126
139
  async function route({ req, res, q, P, auth, ctx }) {
127
140
  const isChat = P === "/session/chat";
128
141
  const isPrompts = P === "/session/prompts";
129
142
  const isPrompt = P === "/session/prompt" || P === "/session/prompt/claim" || P === "/session/prompt/ack";
130
143
  const isAnswer = P === "/session/answer";
131
- if (!isChat && !isPrompts && !isPrompt && !isAnswer) return false;
132
- const { body, json, canon } = ctx;
144
+ const isCrewCommand = P === "/crew/command";
145
+ const isCrewRoster = P === "/crew/roster";
146
+ if (!isChat && !isPrompts && !isPrompt && !isAnswer && !isCrewCommand && !isCrewRoster) return false;
147
+ const { body, json, canon, scopeAllows } = ctx;
133
148
 
134
149
  // #12137: the phone's answer to an AskUserQuestion joins the SAME queue as a plain prompt,
135
150
  // as kind "answer" — same claim, ack, expiry and audit trail; the desktop relay maps the
@@ -161,6 +176,74 @@ export function createSessionRuntime({ state, markDirty, appendEvent, pushEventT
161
176
  return json(res, 200, { id: p.id, status: "queued" });
162
177
  }
163
178
 
179
+ // #12140: the phone drives crew lifecycle through the SAME prompt queue as chat prompts and
180
+ // answers, as kind "crew" — same claim, ack, TTL expiry and audit trail. The hub validates the
181
+ // specs and refuses the dangerous shapes; the desktop relay re-validates and runs the CLI.
182
+ if (req.method === "POST" && isCrewCommand) {
183
+ const b = await body(req);
184
+ const project = canon(String(b.project || "").slice(0, 80));
185
+ if (!project) return json(res, 400, { error: "project required" });
186
+ // Approver or owner on the project — authorize() only proves write for an unknown POST path.
187
+ if (!scopeAllows(auth?.identity, project, "approver")) return json(res, 403, { error: "approver role required" });
188
+ const action = String(b.action || "");
189
+ if (!["up", "down", "swap"].includes(action)) return json(res, 400, { error: "action must be up, down or swap" });
190
+ const seats = (Array.isArray(b.seats) ? b.seats : []).map(s => String(s ?? "").slice(0, 120));
191
+ if (seats.length > CREW_SEATS_MAX) return json(res, 400, { error: `at most ${CREW_SEATS_MAX} seats per command` });
192
+ const from = String(b.from || "").slice(0, 120);
193
+ const to = String(b.to || "").slice(0, 120);
194
+ const specs = [...seats, from, to].filter(Boolean);
195
+ const flagged = specs.find(v => v.startsWith("-"));
196
+ if (flagged) return json(res, 400, { error: `flag-like value refused: ${flagged}` });
197
+ const invalid = specs.find(v => !CREW_SPEC.test(v));
198
+ if (invalid) return json(res, 400, { error: `invalid seat spec: ${invalid}` });
199
+ // numbered claude seats (claude-2) draw the same quota as claude
200
+ if (specs.some(v => /^claude(-\d+)?$/.test(v.split(":")[0]))) return json(res, 400, { error: CREW_CLAUDE_REFUSAL });
201
+ if (action === "up" && !seats.length) return json(res, 400, { error: "up needs at least one seat" });
202
+ if (action === "swap" && (!from || !to)) return json(res, 400, { error: "swap needs from and to" });
203
+ expireQueued();
204
+ state.sessionPromptSeq += 1;
205
+ // down with an empty seat list means the whole project.
206
+ const text = action === "swap" ? `swap ${from} ${to}` : seats.length ? `${action} ${seats.join(" ")}` : `${action} ${project}`;
207
+ const p = { id: state.sessionPromptSeq, project, kind: "crew", action, seats, text,
208
+ by: String(auth?.identity?.name || "").slice(0, 120), ts: now(), status: "queued" };
209
+ if (from) p.from = from;
210
+ if (to) p.to = to;
211
+ state.sessionPrompts.push(p);
212
+ while (state.sessionPrompts.length > PROMPT_CAP) {
213
+ const ti = state.sessionPrompts.findIndex(x => x.status !== "queued" && x.status !== "claimed");
214
+ state.sessionPrompts.splice(ti >= 0 ? ti : 0, 1);
215
+ }
216
+ markDirty();
217
+ appendEvent("chat.prompt.queued", project, p.by, { promptId: p.id, kind: "crew", action, text: text.slice(0, EVENT_TEXT_MAX) });
218
+ pushEventToStreams({ ts: p.ts, type: "chat.prompt", id: p.id, project });
219
+ return json(res, 200, { id: p.id, status: "queued" });
220
+ }
221
+
222
+ // The roster row is keyed on the SIGNER's identity name (any body machine is
223
+ // ignored) so it matches the chat mirror's host.
224
+ if (req.method === "POST" && isCrewRoster) {
225
+ const b = await body(req);
226
+ if (!scopeAllows(auth?.identity, "", "owner")) return json(res, 403, { error: "owner role required" });
227
+ const machine = String(auth?.identity?.name || b.machine || "").slice(0, 120);
228
+ if (!machine) return json(res, 400, { error: "machine required" });
229
+ const seats = (Array.isArray(b.seats) ? b.seats : []).slice(0, 50).map(s => ({
230
+ agent: String(s?.agent || "").slice(0, 40),
231
+ // what `trantor up` takes for this seat (glm:zai-coding-plan); only a valid spec is kept
232
+ spec: CREW_SPEC.test(String(s?.spec || "")) ? String(s.spec) : "",
233
+ label: String(s?.label || "").slice(0, 120),
234
+ plan: String(s?.plan || "").slice(0, 40),
235
+ metered: !!s?.metered,
236
+ available: !!s?.available,
237
+ reason: String(s?.reason || "").slice(0, 200),
238
+ })).filter(s => s.agent);
239
+ roster.set(machine, { machine, updatedAt: now(), seats });
240
+ return json(res, 200, { ok: true, machine, seats: seats.length });
241
+ }
242
+
243
+ if (req.method === "GET" && isCrewRoster) {
244
+ return json(res, 200, { machines: [...roster.values()] });
245
+ }
246
+
164
247
  if (req.method === "POST" && isChat) {
165
248
  const b = await body(req);
166
249
  // the same canon the GET reads with, or a publish and a read could name two mirrors
@@ -201,13 +284,19 @@ export function createSessionRuntime({ state, markDirty, appendEvent, pushEventT
201
284
  }
202
285
 
203
286
  // The public view of a queue row: plain prompts are kind "prompt"; answers carry their
204
- // session, toolId, picks and other so the claiming desktop has everything it needs.
287
+ // session, toolId, picks and other; crew commands carry action, seats, from and to —
288
+ // so the claiming desktop has everything it needs.
205
289
  const promptView = (p) => {
206
290
  const view = { id: p.id, project: p.project, text: p.text, by: p.by, ts: p.ts, status: p.status, kind: p.kind || "prompt" };
207
291
  if (p.kind === "answer") {
208
292
  view.session = p.session; view.toolId = p.toolId; view.picks = p.picks;
209
293
  if (p.other && p.other.trim()) view.other = p.other; // absent, never "", when nothing was typed
210
294
  }
295
+ if (p.kind === "crew") {
296
+ view.action = p.action; view.seats = p.seats;
297
+ if (p.from) view.from = p.from;
298
+ if (p.to) view.to = p.to;
299
+ }
211
300
  return view;
212
301
  };
213
302
 
@@ -239,17 +328,28 @@ export function createSessionRuntime({ state, markDirty, appendEvent, pushEventT
239
328
  const p = findPrompt(b.id);
240
329
  if (!p) return json(res, 404, { error: "prompt not found" });
241
330
  const outcome = String(b.outcome || "");
242
- if (!ACK_OUTCOMES.has(outcome)) return json(res, 400, { error: `outcome must be one of: ${[...ACK_OUTCOMES].join(", ")}` });
243
- if (p.status === "delivered" || p.status === "failed" || p.status === "expired") {
331
+ // #12140: a crew command acks done|failed with the CLI output tail, and the status IS the
332
+ // outcome — never "delivered". Other kinds keep the delivered/blocked/… vocabulary.
333
+ if (p.kind === "crew") {
334
+ if (outcome !== "done" && outcome !== "failed") return json(res, 400, { error: "crew outcome must be done or failed" });
335
+ } else if (!ACK_OUTCOMES.has(outcome)) {
336
+ return json(res, 400, { error: `outcome must be one of: ${[...ACK_OUTCOMES].join(", ")}` });
337
+ }
338
+ if (p.status === "delivered" || p.status === "done" || p.status === "failed" || p.status === "expired") {
244
339
  return json(res, 409, { error: `prompt already ${p.status}`, status: p.status });
245
340
  }
246
341
  const error = String(b.error || "").slice(0, EVENT_TEXT_MAX);
247
342
  p.outcome = outcome; p.error = error; p.ackedAt = now();
248
- p.status = outcome === "delivered" ? "delivered" : "failed";
343
+ if (p.kind === "crew") {
344
+ p.output = stripAnsi(String(b.output || "")).slice(-CREW_OUTPUT_MAX);
345
+ p.status = outcome; // "done" or "failed"
346
+ } else {
347
+ p.status = outcome === "delivered" ? "delivered" : "failed";
348
+ }
249
349
  markDirty();
250
350
  const extra = { promptId: p.id, outcome };
251
351
  if (error) extra.error = error;
252
- appendEvent(outcome === "delivered" ? "chat.prompt.delivered" : "chat.prompt.failed", p.project,
352
+ appendEvent(p.status === "failed" ? "chat.prompt.failed" : "chat.prompt.delivered", p.project,
253
353
  String(auth?.identity?.name || ""), extra);
254
354
  return json(res, 200, { ok: true, status: p.status });
255
355
  }
@@ -258,7 +358,7 @@ export function createSessionRuntime({ state, markDirty, appendEvent, pushEventT
258
358
  expireQueued();
259
359
  const p = findPrompt(q.id);
260
360
  if (!p) return json(res, 404, { error: "prompt not found" });
261
- return json(res, 200, { id: p.id, status: p.status, outcome: p.outcome || "", error: p.error || "" });
361
+ return json(res, 200, { id: p.id, status: p.status, outcome: p.outcome || "", output: p.output || "", error: p.error || "" });
262
362
  }
263
363
 
264
364
  return false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.112",
3
+ "version": "0.18.113",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"