trantor 0.18.111 → 0.18.112

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.111",
3
+ "version": "0.18.112",
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/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 payload = JSON.stringify({ aps, kind: delivery.kind, id: delivery.id, project: delivery.project });
120
- // kind in the id: hold #5 and gate #5 are different alerts
121
- const collapseId = `${delivery.kind}-${delivery.id}`;
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,
@@ -12,9 +12,11 @@ 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;
16
18
 
17
- export function createSessionRuntime({ state, markDirty, appendEvent, pushEventToStreams }) {
19
+ export function createSessionRuntime({ state, markDirty, appendEvent, pushEventToStreams, notifier }) {
18
20
  const now = () => Date.now();
19
21
  const mirrors = new Map(); // project -> { session, total, turns: Map(idx->turn), have, meta, updatedAt, host }
20
22
  state.sessionPrompts ||= [];
@@ -51,6 +53,7 @@ export function createSessionRuntime({ state, markDirty, appendEvent, pushEventT
51
53
  if (!m || m.session !== session) {
52
54
  m = { session, total: 0, turns: new Map(), have: 0, meta: {}, updatedAt: 0, host: "" };
53
55
  mirrors.set(project, m);
56
+ asked.delete(project); // a session switch orphans the old asks; its alerts go stale on the phone
54
57
  }
55
58
  // The last turn grows while the assistant writes, so the desktop resends from the lowest
56
59
  // changed idx and the hub REPLACES by idx — re-sends are idempotent by construction.
@@ -81,16 +84,83 @@ export function createSessionRuntime({ state, markDirty, appendEvent, pushEventT
81
84
  m.updatedAt = now();
82
85
  m.host = String(b.host || auth?.identity?.name || "").slice(0, 120);
83
86
  pushEventToStreams({ ts: m.updatedAt, type: "chat.updated", project, total: m.total, working: m.meta.working });
87
+ scanAsks(m, project);
84
88
  return { code: 200, body: { ok: true, have: m.have } };
85
89
  }
86
90
 
91
+ // #12137: an unanswered AskUserQuestion block in the mirror raises ONE push per tool id
92
+ // ("<project> asks: <question>"); when the desktop republishes the block with its answer, the
93
+ // phone's alert is silently cleared. Dedupe is in-memory like the mirror itself, and ask events
94
+ // never touch the audit log — the prompt queue already carries the durable record.
95
+ const asked = new Map(); // project -> Set(toolId) alerted and still unanswered
96
+ function askNotify(event) {
97
+ try { notifier?.notify(event)?.catch?.(() => {}); } catch {}
98
+ }
99
+ function firstQuestion(blk) {
100
+ const a = blk?.ask;
101
+ if (Array.isArray(a) && a.length) return String(a[0]?.question ?? a[0] ?? "").slice(0, 300);
102
+ return String(blk?.text || "").slice(0, 300);
103
+ }
104
+ function scanAsks(m, project) {
105
+ if (!notifier) return;
106
+ for (const t of m.turns.values()) {
107
+ for (const blk of t.blocks) {
108
+ if (!blk || (blk.kind !== "ask" && !blk.ask)) continue;
109
+ const toolId = String(blk.toolId || blk.tool_id || "").slice(0, 200);
110
+ if (!toolId) continue;
111
+ let seen = asked.get(project);
112
+ if (blk.answer) {
113
+ if (seen?.has(toolId)) {
114
+ seen.delete(toolId);
115
+ askNotify({ type: "ask.answered", project, by: m.host, toolId });
116
+ }
117
+ } else if (!seen?.has(toolId)) {
118
+ if (!seen) { seen = new Set(); asked.set(project, seen); }
119
+ seen.add(toolId);
120
+ askNotify({ type: "ask.opened", project, by: m.host, toolId, question: firstQuestion(blk) });
121
+ }
122
+ }
123
+ }
124
+ }
125
+
87
126
  async function route({ req, res, q, P, auth, ctx }) {
88
127
  const isChat = P === "/session/chat";
89
128
  const isPrompts = P === "/session/prompts";
90
129
  const isPrompt = P === "/session/prompt" || P === "/session/prompt/claim" || P === "/session/prompt/ack";
91
- if (!isChat && !isPrompts && !isPrompt) return false;
130
+ const isAnswer = P === "/session/answer";
131
+ if (!isChat && !isPrompts && !isPrompt && !isAnswer) return false;
92
132
  const { body, json, canon } = ctx;
93
133
 
134
+ // #12137: the phone's answer to an AskUserQuestion joins the SAME queue as a plain prompt,
135
+ // as kind "answer" — same claim, ack, expiry and audit trail; the desktop relay maps the
136
+ // picks to keystrokes and types them, or acks "stale" when the ask is gone.
137
+ if (req.method === "POST" && isAnswer) {
138
+ const b = await body(req);
139
+ const project = canon(String(b.project || "").slice(0, 80));
140
+ const session = String(b.session || "").slice(0, 200);
141
+ const toolId = String(b.toolId || "").slice(0, 200);
142
+ if (!project) return json(res, 400, { error: "project required" });
143
+ if (!session) return json(res, 400, { error: "session required" });
144
+ if (!toolId) return json(res, 400, { error: "toolId required" });
145
+ const picks = (Array.isArray(b.picks) ? b.picks : []).map(Number).filter(Number.isInteger).slice(0, ANSWER_PICKS_MAX);
146
+ const other = String(b.other || "").slice(0, ANSWER_OTHER_MAX);
147
+ if (!picks.length && !other.trim()) return json(res, 400, { error: "picks or other required" });
148
+ expireQueued();
149
+ state.sessionPromptSeq += 1;
150
+ const text = other.trim() ? other : `picks ${picks.join(", ")}`;
151
+ const p = { id: state.sessionPromptSeq, project, kind: "answer", session, toolId, picks, other,
152
+ text, by: String(auth?.identity?.name || "").slice(0, 120), ts: now(), status: "queued" };
153
+ state.sessionPrompts.push(p);
154
+ while (state.sessionPrompts.length > PROMPT_CAP) {
155
+ const ti = state.sessionPrompts.findIndex(x => x.status !== "queued" && x.status !== "claimed");
156
+ state.sessionPrompts.splice(ti >= 0 ? ti : 0, 1);
157
+ }
158
+ markDirty();
159
+ appendEvent("chat.prompt.queued", project, p.by, { promptId: p.id, kind: "answer", toolId, text: text.slice(0, EVENT_TEXT_MAX) });
160
+ pushEventToStreams({ ts: p.ts, type: "chat.prompt", id: p.id, project });
161
+ return json(res, 200, { id: p.id, status: "queued" });
162
+ }
163
+
94
164
  if (req.method === "POST" && isChat) {
95
165
  const b = await body(req);
96
166
  // the same canon the GET reads with, or a publish and a read could name two mirrors
@@ -130,13 +200,24 @@ export function createSessionRuntime({ state, markDirty, appendEvent, pushEventT
130
200
  return json(res, 200, { id: p.id, status: "queued" });
131
201
  }
132
202
 
203
+ // 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.
205
+ const promptView = (p) => {
206
+ 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
+ if (p.kind === "answer") {
208
+ view.session = p.session; view.toolId = p.toolId; view.picks = p.picks;
209
+ if (p.other && p.other.trim()) view.other = p.other; // absent, never "", when nothing was typed
210
+ }
211
+ return view;
212
+ };
213
+
133
214
  if (req.method === "GET" && isPrompts) {
134
215
  expireQueued();
135
216
  const project = canon(String(q.project || "").slice(0, 80));
136
217
  const status = String(q.status || "").slice(0, 40);
137
218
  const prompts = state.sessionPrompts
138
219
  .filter(p => (!project || p.project === project) && (!status || p.status === status))
139
- .map(p => ({ id: p.id, project: p.project, text: p.text, by: p.by, ts: p.ts, status: p.status }));
220
+ .map(promptView);
140
221
  return json(res, 200, { prompts });
141
222
  }
142
223
 
@@ -150,7 +231,7 @@ export function createSessionRuntime({ state, markDirty, appendEvent, pushEventT
150
231
  p.status = "claimed"; p.host = String(b.host || "").slice(0, 120); p.claimedAt = now();
151
232
  markDirty();
152
233
  // The full text goes only to the claimer.
153
- return json(res, 200, { ok: true, prompt: { id: p.id, project: p.project, text: p.text, by: p.by, ts: p.ts } });
234
+ return json(res, 200, { ok: true, prompt: promptView(p) });
154
235
  }
155
236
 
156
237
  if (req.method === "POST" && P === "/session/prompt/ack") {
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.111",
3
+ "version": "0.18.112",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"