trantor 0.18.111 → 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.
- package/.claude-plugin/plugin.json +1 -1
- package/bin/provider.mjs +20 -10
- package/hub/auth.mjs +1 -1
- package/hub/notifier.mjs +22 -3
- package/hub/routes/session.mjs +192 -11
- package/hub.mjs +1 -1
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.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
|
|
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
|
|
54
|
-
const
|
|
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 =
|
|
60
|
-
console.log(` ${dot} ${
|
|
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 || ""}`;
|
package/hub/notifier.mjs
CHANGED
|
@@ -9,6 +9,14 @@ const APNS_HOSTS = {
|
|
|
9
9
|
const JWT_REUSE_MS = 50 * 60 * 1000;
|
|
10
10
|
const text = (value, max = 300) => String(value || "").slice(0, max);
|
|
11
11
|
|
|
12
|
+
// #12137: ask alerts are keyed by a tool id string, but the APNs payload's id field is numeric —
|
|
13
|
+
// a stable FNV-1a hash bridges the two, and the toolId string rides along so the phone can match.
|
|
14
|
+
function hashId(s) {
|
|
15
|
+
let h = 0x811c9dc5;
|
|
16
|
+
for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193) >>> 0; }
|
|
17
|
+
return h >>> 0;
|
|
18
|
+
}
|
|
19
|
+
|
|
12
20
|
function apnsTransport({ host, path, headers, body }) {
|
|
13
21
|
return new Promise((resolve, reject) => {
|
|
14
22
|
const session = http2.connect(`https://${host}`);
|
|
@@ -40,6 +48,10 @@ function apnsTransport({ host, path, headers, body }) {
|
|
|
40
48
|
|
|
41
49
|
function clearFor(event) {
|
|
42
50
|
// Whatever ends the item clears the phone's alert: decided, resolved, expired or withdrawn.
|
|
51
|
+
if (event.type === "ask.answered" && event.toolId) {
|
|
52
|
+
const toolId = text(event.toolId, 200);
|
|
53
|
+
return { kind: "ask", id: hashId(toolId), toolId, collapse: `ask-${toolId}`, project: text(event.project, 80), silent: true };
|
|
54
|
+
}
|
|
43
55
|
const match = /^(hold|verify\.gate|proposal)\.(?:decided|resolved|closed|expired|withdrawn)$/.exec(event.type || "");
|
|
44
56
|
if (!match) return null;
|
|
45
57
|
const kind = match[1] === "verify.gate" ? "gate" : match[1];
|
|
@@ -62,6 +74,11 @@ function alertFor(event, state) {
|
|
|
62
74
|
return { ...base, kind: "proposal", id: Number(event.proposalId), category: "PROPOSAL",
|
|
63
75
|
title: `Proposal #${Number(event.proposalId)}`, body: text(event.scope) };
|
|
64
76
|
}
|
|
77
|
+
if (event.type === "ask.opened" && event.toolId) {
|
|
78
|
+
const toolId = text(event.toolId, 200);
|
|
79
|
+
return { ...base, kind: "ask", id: hashId(toolId), toolId, collapse: `ask-${toolId}`,
|
|
80
|
+
title: `${base.project} asks`, body: text(event.question) };
|
|
81
|
+
}
|
|
65
82
|
if (event.type === "message" && Number(event.msgId)) {
|
|
66
83
|
return { ...base, kind: "message", id: Number(event.msgId), direct: true,
|
|
67
84
|
title: `Message from ${text(event.by, 120)}`, body: text(event.text) };
|
|
@@ -116,9 +133,11 @@ export function createNotifier({ state, markDirty, canRead, transport = apnsTran
|
|
|
116
133
|
? { "content-available": 1 }
|
|
117
134
|
: { alert: { title: delivery.title, body: delivery.body }, sound: "default" };
|
|
118
135
|
if (delivery.category) aps.category = delivery.category;
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
const
|
|
136
|
+
const body = { aps, kind: delivery.kind, id: delivery.id, project: delivery.project };
|
|
137
|
+
if (delivery.toolId) body.toolId = delivery.toolId;
|
|
138
|
+
const payload = JSON.stringify(body);
|
|
139
|
+
// kind in the id: hold #5 and gate #5 are different alerts; an ask collapses on its tool id
|
|
140
|
+
const collapseId = delivery.collapse || `${delivery.kind}-${delivery.id}`;
|
|
122
141
|
try {
|
|
123
142
|
const result = await transport({
|
|
124
143
|
host: APNS_HOSTS[device.env] || APNS_HOSTS.production,
|
package/hub/routes/session.mjs
CHANGED
|
@@ -12,9 +12,19 @@ const EVENT_TEXT_MAX = 500;
|
|
|
12
12
|
const PROMPT_TTL_MS = Number(process.env.RELAY_PROMPT_TTL_MS || 120000);
|
|
13
13
|
const PROMPT_SWEEP_MS = Number(process.env.RELAY_PROMPT_SWEEP_MS || 30000);
|
|
14
14
|
const EXPIRED_REASON = "No Mac is relaying this project. Is the Trantor app open?";
|
|
15
|
-
const ACK_OUTCOMES = new Set(["delivered", "blocked", "not_ready", "stalled", "no_agent"]);
|
|
15
|
+
const ACK_OUTCOMES = new Set(["delivered", "blocked", "not_ready", "stalled", "no_agent", "stale"]);
|
|
16
|
+
const ANSWER_PICKS_MAX = 10;
|
|
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, "");
|
|
16
26
|
|
|
17
|
-
export function createSessionRuntime({ state, markDirty, appendEvent, pushEventToStreams }) {
|
|
27
|
+
export function createSessionRuntime({ state, markDirty, appendEvent, pushEventToStreams, notifier }) {
|
|
18
28
|
const now = () => Date.now();
|
|
19
29
|
const mirrors = new Map(); // project -> { session, total, turns: Map(idx->turn), have, meta, updatedAt, host }
|
|
20
30
|
state.sessionPrompts ||= [];
|
|
@@ -51,6 +61,7 @@ export function createSessionRuntime({ state, markDirty, appendEvent, pushEventT
|
|
|
51
61
|
if (!m || m.session !== session) {
|
|
52
62
|
m = { session, total: 0, turns: new Map(), have: 0, meta: {}, updatedAt: 0, host: "" };
|
|
53
63
|
mirrors.set(project, m);
|
|
64
|
+
asked.delete(project); // a session switch orphans the old asks; its alerts go stale on the phone
|
|
54
65
|
}
|
|
55
66
|
// The last turn grows while the assistant writes, so the desktop resends from the lowest
|
|
56
67
|
// changed idx and the hub REPLACES by idx — re-sends are idempotent by construction.
|
|
@@ -81,15 +92,157 @@ export function createSessionRuntime({ state, markDirty, appendEvent, pushEventT
|
|
|
81
92
|
m.updatedAt = now();
|
|
82
93
|
m.host = String(b.host || auth?.identity?.name || "").slice(0, 120);
|
|
83
94
|
pushEventToStreams({ ts: m.updatedAt, type: "chat.updated", project, total: m.total, working: m.meta.working });
|
|
95
|
+
scanAsks(m, project);
|
|
84
96
|
return { code: 200, body: { ok: true, have: m.have } };
|
|
85
97
|
}
|
|
86
98
|
|
|
99
|
+
// #12137: an unanswered AskUserQuestion block in the mirror raises ONE push per tool id
|
|
100
|
+
// ("<project> asks: <question>"); when the desktop republishes the block with its answer, the
|
|
101
|
+
// phone's alert is silently cleared. Dedupe is in-memory like the mirror itself, and ask events
|
|
102
|
+
// never touch the audit log — the prompt queue already carries the durable record.
|
|
103
|
+
const asked = new Map(); // project -> Set(toolId) alerted and still unanswered
|
|
104
|
+
function askNotify(event) {
|
|
105
|
+
try { notifier?.notify(event)?.catch?.(() => {}); } catch {}
|
|
106
|
+
}
|
|
107
|
+
function firstQuestion(blk) {
|
|
108
|
+
const a = blk?.ask;
|
|
109
|
+
if (Array.isArray(a) && a.length) return String(a[0]?.question ?? a[0] ?? "").slice(0, 300);
|
|
110
|
+
return String(blk?.text || "").slice(0, 300);
|
|
111
|
+
}
|
|
112
|
+
function scanAsks(m, project) {
|
|
113
|
+
if (!notifier) return;
|
|
114
|
+
for (const t of m.turns.values()) {
|
|
115
|
+
for (const blk of t.blocks) {
|
|
116
|
+
if (!blk || (blk.kind !== "ask" && !blk.ask)) continue;
|
|
117
|
+
const toolId = String(blk.toolId || blk.tool_id || "").slice(0, 200);
|
|
118
|
+
if (!toolId) continue;
|
|
119
|
+
let seen = asked.get(project);
|
|
120
|
+
if (blk.answer) {
|
|
121
|
+
if (seen?.has(toolId)) {
|
|
122
|
+
seen.delete(toolId);
|
|
123
|
+
askNotify({ type: "ask.answered", project, by: m.host, toolId });
|
|
124
|
+
}
|
|
125
|
+
} else if (!seen?.has(toolId)) {
|
|
126
|
+
if (!seen) { seen = new Set(); asked.set(project, seen); }
|
|
127
|
+
seen.add(toolId);
|
|
128
|
+
askNotify({ type: "ask.opened", project, by: m.host, toolId, question: firstQuestion(blk) });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
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
|
+
|
|
87
139
|
async function route({ req, res, q, P, auth, ctx }) {
|
|
88
140
|
const isChat = P === "/session/chat";
|
|
89
141
|
const isPrompts = P === "/session/prompts";
|
|
90
142
|
const isPrompt = P === "/session/prompt" || P === "/session/prompt/claim" || P === "/session/prompt/ack";
|
|
91
|
-
|
|
92
|
-
const
|
|
143
|
+
const isAnswer = P === "/session/answer";
|
|
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;
|
|
148
|
+
|
|
149
|
+
// #12137: the phone's answer to an AskUserQuestion joins the SAME queue as a plain prompt,
|
|
150
|
+
// as kind "answer" — same claim, ack, expiry and audit trail; the desktop relay maps the
|
|
151
|
+
// picks to keystrokes and types them, or acks "stale" when the ask is gone.
|
|
152
|
+
if (req.method === "POST" && isAnswer) {
|
|
153
|
+
const b = await body(req);
|
|
154
|
+
const project = canon(String(b.project || "").slice(0, 80));
|
|
155
|
+
const session = String(b.session || "").slice(0, 200);
|
|
156
|
+
const toolId = String(b.toolId || "").slice(0, 200);
|
|
157
|
+
if (!project) return json(res, 400, { error: "project required" });
|
|
158
|
+
if (!session) return json(res, 400, { error: "session required" });
|
|
159
|
+
if (!toolId) return json(res, 400, { error: "toolId required" });
|
|
160
|
+
const picks = (Array.isArray(b.picks) ? b.picks : []).map(Number).filter(Number.isInteger).slice(0, ANSWER_PICKS_MAX);
|
|
161
|
+
const other = String(b.other || "").slice(0, ANSWER_OTHER_MAX);
|
|
162
|
+
if (!picks.length && !other.trim()) return json(res, 400, { error: "picks or other required" });
|
|
163
|
+
expireQueued();
|
|
164
|
+
state.sessionPromptSeq += 1;
|
|
165
|
+
const text = other.trim() ? other : `picks ${picks.join(", ")}`;
|
|
166
|
+
const p = { id: state.sessionPromptSeq, project, kind: "answer", session, toolId, picks, other,
|
|
167
|
+
text, by: String(auth?.identity?.name || "").slice(0, 120), ts: now(), status: "queued" };
|
|
168
|
+
state.sessionPrompts.push(p);
|
|
169
|
+
while (state.sessionPrompts.length > PROMPT_CAP) {
|
|
170
|
+
const ti = state.sessionPrompts.findIndex(x => x.status !== "queued" && x.status !== "claimed");
|
|
171
|
+
state.sessionPrompts.splice(ti >= 0 ? ti : 0, 1);
|
|
172
|
+
}
|
|
173
|
+
markDirty();
|
|
174
|
+
appendEvent("chat.prompt.queued", project, p.by, { promptId: p.id, kind: "answer", toolId, text: text.slice(0, EVENT_TEXT_MAX) });
|
|
175
|
+
pushEventToStreams({ ts: p.ts, type: "chat.prompt", id: p.id, project });
|
|
176
|
+
return json(res, 200, { id: p.id, status: "queued" });
|
|
177
|
+
}
|
|
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
|
+
}
|
|
93
246
|
|
|
94
247
|
if (req.method === "POST" && isChat) {
|
|
95
248
|
const b = await body(req);
|
|
@@ -130,13 +283,30 @@ export function createSessionRuntime({ state, markDirty, appendEvent, pushEventT
|
|
|
130
283
|
return json(res, 200, { id: p.id, status: "queued" });
|
|
131
284
|
}
|
|
132
285
|
|
|
286
|
+
// The public view of a queue row: plain prompts are kind "prompt"; answers carry their
|
|
287
|
+
// session, toolId, picks and other; crew commands carry action, seats, from and to —
|
|
288
|
+
// so the claiming desktop has everything it needs.
|
|
289
|
+
const promptView = (p) => {
|
|
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" };
|
|
291
|
+
if (p.kind === "answer") {
|
|
292
|
+
view.session = p.session; view.toolId = p.toolId; view.picks = p.picks;
|
|
293
|
+
if (p.other && p.other.trim()) view.other = p.other; // absent, never "", when nothing was typed
|
|
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
|
+
}
|
|
300
|
+
return view;
|
|
301
|
+
};
|
|
302
|
+
|
|
133
303
|
if (req.method === "GET" && isPrompts) {
|
|
134
304
|
expireQueued();
|
|
135
305
|
const project = canon(String(q.project || "").slice(0, 80));
|
|
136
306
|
const status = String(q.status || "").slice(0, 40);
|
|
137
307
|
const prompts = state.sessionPrompts
|
|
138
308
|
.filter(p => (!project || p.project === project) && (!status || p.status === status))
|
|
139
|
-
.map(
|
|
309
|
+
.map(promptView);
|
|
140
310
|
return json(res, 200, { prompts });
|
|
141
311
|
}
|
|
142
312
|
|
|
@@ -150,7 +320,7 @@ export function createSessionRuntime({ state, markDirty, appendEvent, pushEventT
|
|
|
150
320
|
p.status = "claimed"; p.host = String(b.host || "").slice(0, 120); p.claimedAt = now();
|
|
151
321
|
markDirty();
|
|
152
322
|
// The full text goes only to the claimer.
|
|
153
|
-
return json(res, 200, { ok: true, prompt:
|
|
323
|
+
return json(res, 200, { ok: true, prompt: promptView(p) });
|
|
154
324
|
}
|
|
155
325
|
|
|
156
326
|
if (req.method === "POST" && P === "/session/prompt/ack") {
|
|
@@ -158,17 +328,28 @@ export function createSessionRuntime({ state, markDirty, appendEvent, pushEventT
|
|
|
158
328
|
const p = findPrompt(b.id);
|
|
159
329
|
if (!p) return json(res, 404, { error: "prompt not found" });
|
|
160
330
|
const outcome = String(b.outcome || "");
|
|
161
|
-
|
|
162
|
-
|
|
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") {
|
|
163
339
|
return json(res, 409, { error: `prompt already ${p.status}`, status: p.status });
|
|
164
340
|
}
|
|
165
341
|
const error = String(b.error || "").slice(0, EVENT_TEXT_MAX);
|
|
166
342
|
p.outcome = outcome; p.error = error; p.ackedAt = now();
|
|
167
|
-
p.
|
|
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
|
+
}
|
|
168
349
|
markDirty();
|
|
169
350
|
const extra = { promptId: p.id, outcome };
|
|
170
351
|
if (error) extra.error = error;
|
|
171
|
-
appendEvent(
|
|
352
|
+
appendEvent(p.status === "failed" ? "chat.prompt.failed" : "chat.prompt.delivered", p.project,
|
|
172
353
|
String(auth?.identity?.name || ""), extra);
|
|
173
354
|
return json(res, 200, { ok: true, status: p.status });
|
|
174
355
|
}
|
|
@@ -177,7 +358,7 @@ export function createSessionRuntime({ state, markDirty, appendEvent, pushEventT
|
|
|
177
358
|
expireQueued();
|
|
178
359
|
const p = findPrompt(q.id);
|
|
179
360
|
if (!p) return json(res, 404, { error: "prompt not found" });
|
|
180
|
-
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 || "" });
|
|
181
362
|
}
|
|
182
363
|
|
|
183
364
|
return false;
|
package/hub.mjs
CHANGED
|
@@ -128,7 +128,7 @@ const context = {
|
|
|
128
128
|
};
|
|
129
129
|
const sessionChat = createSessionRuntime({
|
|
130
130
|
state: store.state, markDirty: store.markDirty,
|
|
131
|
-
appendEvent: events.appendEvent, pushEventToStreams: events.pushEventToStreams,
|
|
131
|
+
appendEvent: events.appendEvent, pushEventToStreams: events.pushEventToStreams, notifier,
|
|
132
132
|
});
|
|
133
133
|
const routes = [routeDevices, routeAdmin, routeCards, routeInsights, routeMessages, sessionChat.route];
|
|
134
134
|
|