letagents 0.12.12 → 0.12.13

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.
Files changed (38) hide show
  1. package/dist/mcp/git-remote.js +7 -7
  2. package/dist/mcp/local-state/agent-sessions.js +63 -5
  3. package/dist/mcp/local-state/local-chat.js +189 -48
  4. package/dist/mcp/local-state/storage.js +16 -9
  5. package/dist/mcp/server/daemon-tool-executor.js +92 -0
  6. package/dist/mcp/server/register-tools.js +4 -2
  7. package/dist/mcp/server/runtime/agent-sessions.js +14 -5
  8. package/dist/mcp/server/runtime/api.js +11 -3
  9. package/dist/mcp/server/runtime/daemon-tool-context.js +11 -0
  10. package/dist/mcp/server/runtime/messages.js +55 -0
  11. package/dist/mcp/server/runtime/room-state.js +13 -3
  12. package/dist/mcp/server/runtime/rooms.js +51 -30
  13. package/dist/mcp/server/runtime/supervisor-bridge.js +49 -8
  14. package/dist/mcp/server/runtime/worker-bearer.js +5 -1
  15. package/dist/mcp/server/runtime.js +3 -3
  16. package/dist/mcp/server/supervised-tool-facade.js +34 -5
  17. package/dist/mcp/server/tools/messages/read-tool.js +54 -97
  18. package/dist/mcp/server/tools/messages/reasoning-tool.js +2 -0
  19. package/dist/mcp/server/tools/messages/send-tool.js +2 -0
  20. package/dist/mcp/server/tools/messages/status-tool.js +2 -0
  21. package/dist/mcp/server/tools/messages/wait-tool.js +290 -68
  22. package/dist/mcp/server/tools/onboarding/status-tool.js +4 -4
  23. package/dist/mcp/server/tools/rooms/inspection-tools.js +15 -10
  24. package/dist/mcp/server/tools/rooms/repo-initialization-tool.js +2 -1
  25. package/dist/mcp/server/tools/tasks/board-tools.js +34 -2
  26. package/dist/mcp/sse-client.js +163 -20
  27. package/dist/shared/activation-routing.js +146 -23
  28. package/dist/shared/agent-presence.js +6 -0
  29. package/dist/shared/desktop-release-manifest.js +63 -0
  30. package/dist/shared/desktop-release.js +60 -0
  31. package/dist/shared/scoped-ids.js +6 -0
  32. package/package.json +6 -2
  33. package/shared/message-contracts.d.mts +32 -0
  34. package/shared/message-contracts.mjs +109 -0
  35. package/shared/routing-aliases.d.mts +18 -0
  36. package/shared/routing-aliases.mjs +66 -0
  37. package/shared/sqlite-thread-routing.d.mts +72 -0
  38. package/shared/sqlite-thread-routing.mjs +1038 -0
@@ -4,6 +4,34 @@ import { createTask, listTasks } from "./api.js";
4
4
  import { resolveTaskToolIdentity, resolveTaskToolTarget, taskActorPayload, } from "./context.js";
5
5
  import { jsonToolResponse, taskToolError } from "./response.js";
6
6
  import { boardIntentApprovalSchema, TASK_STATUSES, workerTaskIdentitySchema } from "./schemas.js";
7
+ export const MAX_BOARD_WORKFLOW_ARTIFACTS_PER_TASK = 4;
8
+ export const MAX_BOARD_WORKFLOW_REFS_PER_TASK = 4;
9
+ function compactBoardWorkflowArtifact(value) {
10
+ if (!value || typeof value !== "object" || Array.isArray(value))
11
+ return value;
12
+ const artifact = value;
13
+ // A change-summary detail may itself contain hundreds of files. The board is
14
+ // an index; callers that need complete artifact detail use get_room_artifacts.
15
+ return Object.fromEntries(Object.entries(artifact).filter(([key]) => key !== "detail"));
16
+ }
17
+ export function compactTaskForBoard(value) {
18
+ if (!value || typeof value !== "object" || Array.isArray(value))
19
+ return value;
20
+ const task = value;
21
+ const artifacts = Array.isArray(task.workflow_artifacts) ? task.workflow_artifacts : [];
22
+ const refs = Array.isArray(task.workflow_refs) ? task.workflow_refs : [];
23
+ return {
24
+ ...task,
25
+ workflow_artifacts: artifacts
26
+ .slice(-MAX_BOARD_WORKFLOW_ARTIFACTS_PER_TASK)
27
+ .map(compactBoardWorkflowArtifact),
28
+ workflow_refs: refs.slice(-MAX_BOARD_WORKFLOW_REFS_PER_TASK),
29
+ workflow_artifact_count: artifacts.length,
30
+ workflow_ref_count: refs.length,
31
+ workflow_artifacts_truncated: artifacts.length > MAX_BOARD_WORKFLOW_ARTIFACTS_PER_TASK,
32
+ workflow_refs_truncated: refs.length > MAX_BOARD_WORKFLOW_REFS_PER_TASK,
33
+ };
34
+ }
7
35
  export function registerTaskBoardTools(server) {
8
36
  server.tool("add_task", "Add a new task to the room board. Tasks normally start as 'proposed' and must be " +
9
37
  "accepted before an agent can claim them. Agent-created tasks require coordinator " +
@@ -59,7 +87,7 @@ export function registerTaskBoardTools(server) {
59
87
  const qs = pageParams.toString();
60
88
  const result = await listTasks(target, qs);
61
89
  const tasks = result.tasks ?? [];
62
- allTasks.push(...tasks);
90
+ allTasks.push(...tasks.map(compactTaskForBoard));
63
91
  if (!result.has_more || tasks.length === 0)
64
92
  break;
65
93
  const lastTask = tasks[tasks.length - 1];
@@ -68,6 +96,10 @@ export function registerTaskBoardTools(server) {
68
96
  afterCursor = lastTask.id;
69
97
  }
70
98
  await heartbeatRoomPresence(target.effectiveRoomId, await ensureAgentIdentity());
71
- return jsonToolResponse({ success: true, tasks: allTasks }, 2);
99
+ return jsonToolResponse({
100
+ success: true,
101
+ tasks: allTasks,
102
+ artifact_detail_instruction: "Board tasks contain bounded artifact summaries. Use get_room_artifacts for complete workflow artifact detail.",
103
+ }, 2);
72
104
  });
73
105
  }
@@ -1,19 +1,33 @@
1
1
  import { encodeRoomIdPath } from "./room-id.js";
2
+ const MAX_SSE_FRAME_BYTES = 1024 * 1024;
3
+ class SseFrameOverflowError extends Error {
4
+ constructor() {
5
+ super("SSE frame exceeded bounded size");
6
+ this.name = "SseFrameOverflowError";
7
+ }
8
+ }
9
+ class SseFrameMalformedError extends Error {
10
+ constructor(options) {
11
+ super("SSE frame was malformed", options);
12
+ this.name = "SseFrameMalformedError";
13
+ }
14
+ }
2
15
  export class SseClient {
3
16
  apiUrl;
4
17
  getAccessToken;
5
18
  subscriptions = new Map();
19
+ eventCursors = new Map();
6
20
  constructor(apiUrl, getAccessToken) {
7
21
  this.apiUrl = apiUrl.replace(/\/$/, "");
8
22
  this.getAccessToken = getAccessToken;
9
23
  }
10
- subscribe(target, onMessage) {
24
+ subscribe(target, onMessage, onGap) {
11
25
  const subscriptionKey = target.roomId;
12
26
  if (this.subscriptions.has(subscriptionKey)) {
13
27
  return;
14
28
  }
15
29
  const controller = new AbortController();
16
- const promise = this.consumeStream(target, controller.signal, onMessage)
30
+ const promise = this.consumeStream(target, controller.signal, onMessage, onGap)
17
31
  .catch((error) => {
18
32
  if (controller.signal.aborted) {
19
33
  return;
@@ -51,17 +65,41 @@ export class SseClient {
51
65
  Authorization: `Bearer ${token}`,
52
66
  };
53
67
  }
54
- async consumeStream(target, signal, onMessage) {
55
- try {
56
- await this.openStream(this.withIncludePromptOnly(this.withAgentIdentityQuery(`${this.apiUrl}/rooms/${encodeRoomIdPath(target.roomId)}/messages/stream`, target)), signal, onMessage);
57
- return;
58
- }
59
- catch (error) {
60
- if (!target.projectId || !this.isMissingRouteError(error)) {
61
- throw error;
68
+ async consumeStream(target, signal, onMessage, onGap) {
69
+ let lastEventId = this.eventCursors.get(target.roomId) ?? null;
70
+ let useLegacyRoute = false;
71
+ let retryMs = 1_000;
72
+ while (!signal.aborted) {
73
+ const baseUrl = useLegacyRoute && target.projectId
74
+ ? `${this.apiUrl}/projects/${encodeURIComponent(target.projectId)}/messages/stream`
75
+ : `${this.apiUrl}/rooms/${encodeRoomIdPath(target.roomId)}/messages/stream`;
76
+ const url = this.withIncludePromptOnly(this.withAgentIdentityQuery(baseUrl, target));
77
+ try {
78
+ lastEventId = await this.openStream(url, signal, target.roomId, lastEventId, onMessage, onGap);
79
+ retryMs = 1_000;
62
80
  }
81
+ catch (error) {
82
+ if (signal.aborted)
83
+ return;
84
+ if (error instanceof SseFrameOverflowError || error instanceof SseFrameMalformedError) {
85
+ lastEventId = null;
86
+ }
87
+ if (this.isMissingRouteError(error)) {
88
+ if (!useLegacyRoute && target.projectId) {
89
+ useLegacyRoute = true;
90
+ continue;
91
+ }
92
+ // A missing room/route is terminal. Retrying it forever creates
93
+ // background traffic and can never heal without a new room join.
94
+ throw error;
95
+ }
96
+ console.error(`SSE stream disconnected for room ${target.roomId}:`, error);
97
+ }
98
+ if (signal.aborted)
99
+ return;
100
+ await waitForRetry(signal, retryMs);
101
+ retryMs = Math.min(Math.ceil(retryMs * 1.5), 30_000);
63
102
  }
64
- await this.openStream(this.withIncludePromptOnly(this.withAgentIdentityQuery(`${this.apiUrl}/projects/${encodeURIComponent(target.projectId)}/messages/stream`, target)), signal, onMessage);
65
103
  }
66
104
  withIncludePromptOnly(url) {
67
105
  return `${url}${url.includes("?") ? "&" : "?"}include_prompt_only=1`;
@@ -80,9 +118,14 @@ export class SseClient {
80
118
  }
81
119
  return `${url}${url.includes("?") ? "&" : "?"}${params.toString()}`;
82
120
  }
83
- async openStream(url, signal, onMessage) {
121
+ async openStream(url, signal, roomId, lastEventId, onMessage, onGap) {
122
+ const headers = await this.getHeaders();
123
+ if (signal.aborted)
124
+ return lastEventId;
125
+ if (lastEventId)
126
+ headers["Last-Event-ID"] = lastEventId;
84
127
  const response = await fetch(url, {
85
- headers: await this.getHeaders(),
128
+ headers,
86
129
  signal,
87
130
  });
88
131
  if (!response.ok) {
@@ -94,43 +137,143 @@ export class SseClient {
94
137
  const reader = response.body.getReader();
95
138
  const decoder = new TextDecoder();
96
139
  let buffer = "";
140
+ let bufferBytes = 0;
97
141
  while (!signal.aborted) {
98
142
  const { value, done } = await reader.read();
99
143
  if (done) {
100
144
  break;
101
145
  }
102
146
  buffer += decoder.decode(value, { stream: true });
147
+ bufferBytes += value.byteLength;
148
+ if (bufferBytes > MAX_SSE_FRAME_BYTES) {
149
+ // The cursor is no longer safe: an unterminated/oversize frame may
150
+ // contain an event we could not parse. Force pull-state repair before
151
+ // reconnecting and never retain attacker-controlled remainder bytes.
152
+ this.eventCursors.delete(roomId);
153
+ try {
154
+ onGap?.({ room_id: roomId, event_cursor: null, gap: true });
155
+ }
156
+ catch (error) {
157
+ console.error("SSE gap callback failed:", error);
158
+ }
159
+ await reader.cancel("SSE frame exceeded bounded size").catch(() => undefined);
160
+ throw new SseFrameOverflowError();
161
+ }
103
162
  let boundaryIndex = buffer.indexOf("\n\n");
163
+ let consumedFrame = false;
104
164
  while (boundaryIndex !== -1) {
105
165
  const rawEvent = buffer.slice(0, boundaryIndex);
106
166
  buffer = buffer.slice(boundaryIndex + 2);
107
- this.handleEvent(rawEvent, onMessage);
167
+ try {
168
+ lastEventId = this.handleEvent(rawEvent, lastEventId, onMessage, onGap);
169
+ }
170
+ catch (error) {
171
+ // A complete but malformed typed frame is the same lost-boundary
172
+ // condition as an oversized frame. Never reconnect from its cursor
173
+ // and replay it forever; force the authorized pull-state repair.
174
+ this.eventCursors.delete(roomId);
175
+ try {
176
+ onGap?.({ room_id: roomId, event_cursor: null, gap: true });
177
+ }
178
+ catch (gapError) {
179
+ console.error("SSE gap callback failed:", gapError);
180
+ }
181
+ await reader.cancel("SSE frame was malformed").catch(() => undefined);
182
+ throw new SseFrameMalformedError({ cause: error });
183
+ }
184
+ this.rememberEventCursor(roomId, lastEventId);
185
+ consumedFrame = true;
108
186
  boundaryIndex = buffer.indexOf("\n\n");
109
187
  }
188
+ if (consumedFrame)
189
+ bufferBytes = new TextEncoder().encode(buffer).byteLength;
110
190
  }
111
191
  const trailing = buffer + decoder.decode();
112
192
  if (trailing.trim()) {
113
- this.handleEvent(trailing, onMessage);
193
+ try {
194
+ lastEventId = this.handleEvent(trailing, lastEventId, onMessage, onGap);
195
+ }
196
+ catch (error) {
197
+ this.eventCursors.delete(roomId);
198
+ try {
199
+ onGap?.({ room_id: roomId, event_cursor: null, gap: true });
200
+ }
201
+ catch (gapError) {
202
+ console.error("SSE gap callback failed:", gapError);
203
+ }
204
+ throw new SseFrameMalformedError({ cause: error });
205
+ }
206
+ this.rememberEventCursor(roomId, lastEventId);
114
207
  }
208
+ return lastEventId;
115
209
  }
116
- handleEvent(rawEvent, onMessage) {
210
+ handleEvent(rawEvent, lastEventId, onMessage, onGap) {
117
211
  const normalizedEvent = rawEvent.replace(/\r/g, "");
118
- const dataLines = normalizedEvent
119
- .split("\n")
212
+ const lines = normalizedEvent.split("\n");
213
+ const eventName = lines.find((line) => line.startsWith("event:"))?.slice(6).trim() || "message";
214
+ const frameId = lines.find((line) => line.startsWith("id:"))?.slice(3).trim() || null;
215
+ const dataLines = lines
120
216
  .filter((line) => line.startsWith("data:"))
121
217
  .map((line) => line.slice(5).trimStart());
122
218
  if (dataLines.length === 0) {
123
- return;
219
+ return frameId ?? lastEventId;
124
220
  }
125
221
  // No prompt enrichment here: the only SSE subscriber (room-state.ts) discards
126
222
  // the message body, and expanding the room-agent prompt on this background
127
223
  // path would consume the once-per-session full-prompt delivery before the
128
224
  // agent ever sees it. Prompt expansion happens only on visible tool
129
225
  // responses (runtime/messages.ts).
130
- onMessage(JSON.parse(dataLines.join("\n")));
226
+ const payload = JSON.parse(dataLines.join("\n"));
227
+ if (eventName === "room_sync") {
228
+ if (payload.gap === true) {
229
+ try {
230
+ onGap?.(payload);
231
+ }
232
+ catch (error) {
233
+ console.error("SSE gap callback failed:", error);
234
+ }
235
+ }
236
+ if (Object.prototype.hasOwnProperty.call(payload, "event_cursor")) {
237
+ return typeof payload.event_cursor === "string" ? payload.event_cursor : null;
238
+ }
239
+ return frameId ?? lastEventId;
240
+ }
241
+ try {
242
+ onMessage(payload);
243
+ }
244
+ catch (error) {
245
+ console.error("SSE message callback failed:", error);
246
+ }
247
+ return frameId ?? lastEventId;
131
248
  }
132
249
  isMissingRouteError(error) {
133
250
  return (error instanceof Error &&
134
251
  /status 404|status 405|Cannot (GET|POST|PATCH)/.test(error.message));
135
252
  }
253
+ rememberEventCursor(roomId, cursor) {
254
+ this.eventCursors.delete(roomId);
255
+ if (!cursor)
256
+ return;
257
+ this.eventCursors.set(roomId, cursor);
258
+ while (this.eventCursors.size > 32) {
259
+ const oldestRoomId = this.eventCursors.keys().next().value;
260
+ if (!oldestRoomId)
261
+ break;
262
+ this.eventCursors.delete(oldestRoomId);
263
+ }
264
+ }
265
+ }
266
+ function waitForRetry(signal, delayMs) {
267
+ if (signal.aborted)
268
+ return Promise.resolve();
269
+ return new Promise((resolve) => {
270
+ const timer = setTimeout(finish, delayMs);
271
+ timer.unref?.();
272
+ function finish() {
273
+ signal.removeEventListener("abort", finish);
274
+ clearTimeout(timer);
275
+ resolve();
276
+ }
277
+ signal.addEventListener("abort", finish, { once: true });
278
+ });
136
279
  }
@@ -1,3 +1,13 @@
1
+ import { normalizeRoutingHandle, normalizeRoutingSender, routingIdentityAliases, routingSenderAliasRows, routingSenderAliases, } from "../../shared/routing-aliases.mjs";
2
+ import { parsePositivePgIntegerScopedId } from "./scoped-ids.js";
3
+ /**
4
+ * Repository events can contain text written by any external contributor.
5
+ * They remain visible room activity, but their text is never an instruction
6
+ * channel for a managed local worker.
7
+ */
8
+ export function isUntrustedExternalActivationSource(source) {
9
+ return normalizeSender(source) === "github";
10
+ }
1
11
  const NON_AGENT_AT_HANDLES = new Set([
2
12
  "charset",
3
13
  "container",
@@ -47,8 +57,8 @@ export function attachAgentMessageActivationsFromReceipts(messages, identity, re
47
57
  }
48
58
  return messages.map((message) => {
49
59
  const msgIdStr = String(message.id ?? "");
50
- const msgNum = parseInt(msgIdStr.replace(/^msg_/, ""), 10);
51
- const receipt = !isNaN(msgNum) ? receiptsMap.get(msgNum) || receiptsMap.get(msgIdStr) : null;
60
+ const msgNum = parsePositivePgIntegerScopedId(msgIdStr, "msg");
61
+ const receipt = msgNum !== null ? receiptsMap.get(msgNum) || receiptsMap.get(msgIdStr) : null;
52
62
  if (receipt) {
53
63
  const reason = receipt.activation_reason;
54
64
  return {
@@ -62,7 +72,11 @@ export function attachAgentMessageActivationsFromReceipts(messages, identity, re
62
72
  },
63
73
  };
64
74
  }
65
- if (!isNaN(msgNum) && snapshotNumbers.has(msgNum)) {
75
+ // System failure rows are canonical silent control events. A routing
76
+ // snapshot with no receipt must not erase their diagnostic reason.
77
+ if (msgNum !== null
78
+ && snapshotNumbers.has(msgNum)
79
+ && normalizeSender(message.source) !== "managed_agent_failure") {
66
80
  return {
67
81
  ...message,
68
82
  activation: {
@@ -84,17 +98,27 @@ export function attachAgentMessageActivations(messages, identity, context = {})
84
98
  return messages.map((message) => attachAgentMessageActivation(message, identity, context));
85
99
  }
86
100
  export function decideAgentMessageActivation(message, identity, context = {}) {
87
- if (normalizeSender(message.source) === "managed_agent_failure") {
101
+ if (normalizeSender(message.source) === "managed_agent_failure"
102
+ || isUntrustedExternalActivationSource(message.source)) {
88
103
  return decision("silent", "system_event");
89
104
  }
90
- if (senderMatchesIdentity(message.sender, identity)) {
105
+ const messageId = normalizedString(message.id);
106
+ const authoritativeLegacyDecision = context.authoritativeLegacyDecisions?.get(messageId);
107
+ if (authoritativeLegacyDecision)
108
+ return authoritativeLegacyDecision;
109
+ if (context.selfMessageIds !== undefined
110
+ ? context.selfMessageIds.has(messageId)
111
+ : senderMatchesIdentity(message.sender, identity)) {
91
112
  return decision("silent", "self_message");
92
113
  }
93
114
  const mentions = extractMentionHandles(message.text);
94
115
  if (mentions.some(isBroadcastHandle)) {
95
116
  return decision("activate", "broadcast");
96
117
  }
97
- if (mentions.some((mention) => identityAliases(identity).has(normalizeMentionIdentityHandle(mention)))) {
118
+ const authoritativeExplicitMentions = context.explicitMentionMessageIds;
119
+ if (authoritativeExplicitMentions !== undefined
120
+ ? authoritativeExplicitMentions.has(messageId)
121
+ : mentions.some((mention) => activationIdentityAliases(identity).has(normalizeMentionIdentityHandle(mention)))) {
98
122
  return decision("activate", "explicit_mention");
99
123
  }
100
124
  if (hasBroadcastAddress(message.text)) {
@@ -103,13 +127,22 @@ export function decideAgentMessageActivation(message, identity, context = {}) {
103
127
  if (mentions.some(isLikelyAgentMentionHandle)) {
104
128
  return decision("silent", "explicit_other_mention");
105
129
  }
106
- if (senderMatchesIdentity(message.reply_to?.sender, identity)) {
130
+ const authoritativeThreadParticipantRootIds = context.threadParticipantRootIds;
131
+ const authoritativeReplyTargets = context.replyTargetMessageIds;
132
+ const hasAuthoritativeThreadMembership = isThreadReply(message)
133
+ && authoritativeThreadParticipantRootIds !== undefined;
134
+ if (authoritativeReplyTargets !== undefined
135
+ ? authoritativeReplyTargets.has(messageId)
136
+ : !hasAuthoritativeThreadMembership && senderMatchesIdentity(message.reply_to?.sender, identity)) {
107
137
  return decision("activate", "reply_target");
108
138
  }
109
139
  if (isAgentReplyTarget(message.reply_to) && !isThreadReply(message)) {
110
140
  return decision("silent", "other_reply_target");
111
141
  }
112
- if (isThreadReply(message) && threadParticipantsIncludeIdentity(message, identity)) {
142
+ if (isThreadReply(message)
143
+ && (hasAuthoritativeThreadMembership
144
+ ? authoritativeThreadParticipantRootIds.has(threadRootId(message))
145
+ : threadParticipantsIncludeIdentity(message, identity))) {
113
146
  return decision("activate", "thread_participant");
114
147
  }
115
148
  const taskOwnerDecision = decideTaskOwnerActivation(message, identity, context);
@@ -127,9 +160,12 @@ function decision(decisionValue, reason) {
127
160
  }
128
161
  function isThreadReply(message) {
129
162
  const ownId = normalizedString(message.id);
130
- const rootId = normalizedString(message.thread_root_id) || normalizedString(message.thread?.root_message_id);
163
+ const rootId = threadRootId(message);
131
164
  return Boolean(ownId && rootId && ownId !== rootId);
132
165
  }
166
+ function threadRootId(message) {
167
+ return normalizedString(message.thread_root_id) || normalizedString(message.thread?.root_message_id);
168
+ }
133
169
  function isAgentReplyTarget(replyTo) {
134
170
  return normalizeSender(replyTo?.source) === "agent";
135
171
  }
@@ -248,24 +284,111 @@ function senderMatchesIdentity(sender, identity) {
248
284
  const normalizedSender = normalizeSender(sender);
249
285
  if (!normalizedSender)
250
286
  return false;
251
- const aliases = identityAliases(identity);
287
+ const aliases = activationIdentityAliases(identity);
252
288
  if (aliases.has(normalizedSender))
253
289
  return true;
254
290
  return String(sender || "")
255
291
  .split("|")
256
292
  .some((part) => aliases.has(normalizeSender(part)));
257
293
  }
258
- function identityAliases(identity) {
259
- const aliases = new Set();
260
- const values = [
261
- identity.actor_label,
262
- identity.display_name,
263
- identity.agent_key,
264
- identity.agent_key.split("/").pop(),
265
- ];
266
- for (const alias of aliasesForValues(values))
267
- aliases.add(alias);
268
- return aliases;
294
+ export function activationIdentityAliases(identity) {
295
+ return routingIdentityAliases(identity);
296
+ }
297
+ /** Canonical aliases materialized from a historical message sender. */
298
+ export function activationSenderAliases(sender, segmentLimit = 16) {
299
+ return routingSenderAliases(sender, segmentLimit);
300
+ }
301
+ /**
302
+ * Resolve identity-bearing addresses against the complete active room
303
+ * population. A display alias is authority only when it names one durable
304
+ * agent key globally; account/provider filtering happens after this step.
305
+ * Full historical sender labels take precedence over their pipe-delimited
306
+ * compatibility segments.
307
+ */
308
+ export function resolveGloballyAddressedAgentKeys(message, identities) {
309
+ return createGlobalAgentAddressResolver(identities)(message);
310
+ }
311
+ /**
312
+ * Build the room-wide alias authority once, then resolve a page of legacy
313
+ * messages without rebuilding every active worker alias set per message.
314
+ */
315
+ export function createGlobalAgentAddressResolver(identities) {
316
+ const keysByAlias = new Map();
317
+ for (const identity of identities) {
318
+ const key = normalizedString(identity.agent_key);
319
+ if (!key)
320
+ continue;
321
+ for (const alias of activationIdentityAliases(identity)) {
322
+ const keys = keysByAlias.get(alias) ?? new Set();
323
+ keys.add(key);
324
+ keysByAlias.set(alias, keys);
325
+ }
326
+ }
327
+ return (message) => {
328
+ const mentions = extractMentionHandles(message.text);
329
+ const broadcast = mentions.some(isBroadcastHandle) || hasBroadcastAddress(message.text);
330
+ const hasMention = mentions.some((mention) => !isBroadcastHandle(mention));
331
+ const hasAgentMention = mentions.some(isLikelyAgentMentionHandle);
332
+ const explicitMentionKeys = new Set();
333
+ for (const mention of mentions) {
334
+ if (isBroadcastHandle(mention))
335
+ continue;
336
+ const alias = normalizeMentionIdentityHandle(mention);
337
+ if (!alias)
338
+ continue;
339
+ const keys = keysByAlias.get(alias);
340
+ if (keys?.size === 1)
341
+ explicitMentionKeys.add(keys.values().next().value);
342
+ }
343
+ const replyTargetKeys = new Set();
344
+ const replyAliases = normalizedString(message.reply_to?.source) === "agent"
345
+ ? routingSenderAliasRows(message.reply_to?.sender)
346
+ : [];
347
+ const matchingKeys = (full) => {
348
+ const keys = new Set();
349
+ for (const row of replyAliases) {
350
+ if (row.isFull !== full)
351
+ continue;
352
+ for (const key of keysByAlias.get(row.alias) ?? [])
353
+ keys.add(key);
354
+ }
355
+ return keys;
356
+ };
357
+ const fullMatches = matchingKeys(true);
358
+ const replyMatches = fullMatches.size > 0 ? fullMatches : matchingKeys(false);
359
+ if (replyMatches.size === 1)
360
+ replyTargetKeys.add(replyMatches.values().next().value);
361
+ const senderKeys = new Set();
362
+ const senderAliases = routingSenderAliasRows(message.sender);
363
+ const senderMatchingKeys = (full) => {
364
+ const keys = new Set();
365
+ for (const row of senderAliases) {
366
+ if (row.isFull !== full)
367
+ continue;
368
+ for (const key of keysByAlias.get(row.alias) ?? [])
369
+ keys.add(key);
370
+ }
371
+ return keys;
372
+ };
373
+ const senderFullMatches = senderMatchingKeys(true);
374
+ const senderMatches = senderFullMatches.size > 0
375
+ ? senderFullMatches
376
+ : senderMatchingKeys(false);
377
+ if (senderMatches.size === 1)
378
+ senderKeys.add(senderMatches.values().next().value);
379
+ return {
380
+ broadcast,
381
+ hasMention,
382
+ hasAgentMention,
383
+ explicitMentionKeys,
384
+ replyTargetKeys,
385
+ senderKeys,
386
+ };
387
+ };
388
+ }
389
+ /** Shared legacy task-follow-up classifier used by API and desktop overlays. */
390
+ export function isTaskOwnerFollowUpMessageText(text) {
391
+ return isTaskOwnerFollowUp(text);
269
392
  }
270
393
  function extractMentionHandles(text) {
271
394
  const raw = typeof text === "string" ? text : "";
@@ -305,10 +428,10 @@ function normalizedString(value) {
305
428
  return typeof value === "string" ? value.trim() : "";
306
429
  }
307
430
  function normalizeSender(value) {
308
- return normalizedString(value).toLowerCase().replace(/\s+/g, " ");
431
+ return normalizeRoutingSender(value);
309
432
  }
310
433
  function normalizeHandle(value) {
311
- return normalizedString(value).toLowerCase().replace(/[^a-z0-9_.:/-]+/g, "");
434
+ return normalizeRoutingHandle(value);
312
435
  }
313
436
  function normalizeMentionIdentityHandle(value) {
314
437
  const normalized = normalizeHandle(value);
@@ -21,6 +21,12 @@ export const ROOM_AGENT_SESSION_KINDS = [
21
21
  "controller",
22
22
  "worker",
23
23
  ];
24
+ export function isRoomAgentDeliveryCredentialExpired(fence, now = Date.now()) {
25
+ if (fence?.kind !== "bearer" || !fence.expires_at)
26
+ return false;
27
+ const expiresAt = Date.parse(fence.expires_at);
28
+ return Number.isFinite(expiresAt) && expiresAt <= now;
29
+ }
24
30
  export function normalizeRoomAgentSessionKind(value) {
25
31
  return String(value || "").trim().toLowerCase() === "worker" ? "worker" : "controller";
26
32
  }
@@ -0,0 +1,63 @@
1
+ export const MAC_DESKTOP_PUBLIC_BASE_URL = "https://downloads.letagents.chat";
2
+ function record(value, label) {
3
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
4
+ throw new Error(`${label} must be an object.`);
5
+ }
6
+ return value;
7
+ }
8
+ function numericVersion(value) {
9
+ if (!/^\d+\.\d+\.\d+$/.test(value)) {
10
+ throw new Error("Desktop release manifest version must be numeric x.y.z.");
11
+ }
12
+ return value.split(".").map(Number);
13
+ }
14
+ function compareVersions(left, right) {
15
+ const leftParts = numericVersion(left);
16
+ const rightParts = numericVersion(right);
17
+ for (let index = 0; index < leftParts.length; index += 1) {
18
+ if (leftParts[index] !== rightParts[index])
19
+ return leftParts[index] - rightParts[index];
20
+ }
21
+ return 0;
22
+ }
23
+ export function parseMacDesktopPublicReleaseManifest(value, minimumVersion) {
24
+ const manifest = record(value, "Desktop release manifest");
25
+ const version = manifest.version;
26
+ if (manifest.schemaVersion !== 1 || manifest.channel !== "beta") {
27
+ throw new Error("Desktop release manifest has an unsupported contract.");
28
+ }
29
+ if (typeof version !== "string") {
30
+ throw new Error("Desktop release manifest version must be numeric x.y.z.");
31
+ }
32
+ numericVersion(version);
33
+ if (compareVersions(version, minimumVersion) < 0) {
34
+ throw new Error(`Desktop release manifest ${version} is older than ${minimumVersion}.`);
35
+ }
36
+ const rawAssets = record(manifest.assets, "Desktop release assets");
37
+ const checksumsUrl = `${MAC_DESKTOP_PUBLIC_BASE_URL}/desktop/v${version}/checksums.txt`;
38
+ if (manifest.checksumsUrl !== checksumsUrl) {
39
+ throw new Error("Desktop release manifest does not use the immutable public checksum URL.");
40
+ }
41
+ const assets = {};
42
+ for (const architecture of ["arm64", "x64"]) {
43
+ const rawAsset = record(rawAssets[architecture], `${architecture} desktop release asset`);
44
+ const fileName = `LetAgents-${version}-darwin-${architecture}.dmg`;
45
+ const publicUrl = `${MAC_DESKTOP_PUBLIC_BASE_URL}/desktop/v${version}/${fileName}`;
46
+ if (rawAsset.fileName !== fileName || rawAsset.publicUrl !== publicUrl) {
47
+ throw new Error(`${architecture} desktop release asset does not use the immutable public URL.`);
48
+ }
49
+ if (!Number.isSafeInteger(rawAsset.bytes) || Number(rawAsset.bytes) <= 0) {
50
+ throw new Error(`${architecture} desktop release asset must have a positive byte size.`);
51
+ }
52
+ if (typeof rawAsset.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(rawAsset.sha256)) {
53
+ throw new Error(`${architecture} desktop release asset must have a lowercase SHA-256 digest.`);
54
+ }
55
+ assets[architecture] = {
56
+ fileName,
57
+ publicUrl,
58
+ bytes: Number(rawAsset.bytes),
59
+ sha256: rawAsset.sha256,
60
+ };
61
+ }
62
+ return { schemaVersion: 1, channel: "beta", version, checksumsUrl, assets };
63
+ }