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.
- package/.claude-plugin/plugin.json +1 -1
- package/hub/notifier.mjs +22 -3
- package/hub/routes/session.mjs +86 -5
- 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.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
|
|
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,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
|
-
|
|
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(
|
|
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:
|
|
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
|
|