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
@@ -1,9 +1,10 @@
1
1
  import { z } from "zod";
2
2
  import { getPollTimeoutCapMs } from "../../../../shared/poll-timeout-cap.js";
3
3
  import { encodeRoomIdPath } from "../../../room-id.js";
4
- import { agentSessionCredentials, appendIncludePromptOnly, buildAgentDeliveryHeaders, bindSupervisedWorkerSession, scheduleSupervisedWorkerCursorCheckpoint, currentRoom, ensureAgentIdentity, getFallbackProjectId, getLatestLocalChatMessages, getLocalChatMessages, getLastMessageId, getRememberedRoomPresence, getTargetRoomId, identityFromAgentSession, isLocalRoomStorageEnabled, listLocalTasks, resolveLocalRoomStorageIdentifiers, resolveAgentSession, roomScopedApiCall, syncRoomPresence, toAgentReadableMessages, touchRoomSession, WORKER_BEARER_AGENT_SESSION_ID, waitForLocalChatMessages, } from "../../runtime.js";
4
+ import { agentSessionCredentials, AGENT_MESSAGE_BODY_MAX_BYTES, appendIncludePromptOnly, boundAgentMessageOutput, buildAgentDeliveryHeaders, bindSupervisedWorkerSession, scheduleSupervisedWorkerCursorCheckpoint, currentRoom, ensureAgentIdentity, getFallbackProjectId, getLatestLocalChatMessages, getLocalImportedRoutingAuthority, getLocalChatMessages, getLocalChatThreadRoutingMembership, getLastMessageId, getRememberedRoomPresence, getStoredAgentRoutingStateSnapshot, getTargetRoomId, identityFromAgentSession, isLocalRoomStorageEnabled, listLocalActiveTaskOwnerLeases, resolveLocalRoomStorageIdentifiers, resolveAgentSession, roomScopedApiCall, syncRoomPresence, toAgentReadableMessages, touchRoomSession, WORKER_BEARER_AGENT_SESSION_ID, waitForLocalChatMessages, } from "../../runtime.js";
5
5
  import { requireValidWorkerBearerRuntime, supervisedBoundedDeliveryDisabledToolResult, } from "../../runtime/worker-bearer.js";
6
- import { attachAgentMessageActivations } from "../../../../shared/activation-routing.js";
6
+ import { attachAgentMessageActivations, createGlobalAgentAddressResolver, decideAgentMessageActivation, isTaskOwnerFollowUpMessageText, } from "../../../../shared/activation-routing.js";
7
+ import { normalizeRoutingSender } from "../../../../../shared/routing-aliases.mjs";
7
8
  import { findLocalMessageById, findRemoteMessageById } from "./message-lookup.js";
8
9
  import { fetchRecentRemoteMessages } from "./read-tool.js";
9
10
  import { jsonToolResponse } from "./response.js";
@@ -13,9 +14,9 @@ const DEFAULT_POLL_TIMEOUT_MS = 30000;
13
14
  // room history (busy rooms archive millions of characters, which blows the
14
15
  // tool's token budget). This bounds that no-cursor catch-up to a recent tail.
15
16
  export const DEFAULT_WAIT_CATCHUP_LIMIT = 100;
16
- // Decide how the initial fetch should behave. With a cursor we return
17
- // everything after it (genuine "new since I last polled", already bounded);
18
- // without one we fetch only the bounded recent tail.
17
+ export const MAX_WAIT_MESSAGES_PER_CALL = 100;
18
+ // Decide how the initial fetch should behave. Both paths return one bounded
19
+ // page; callers continue from last_observed_message_id when truncated.
19
20
  export function planWaitForMessagesFetch(input) {
20
21
  if (input.effectiveAfterMessageId) {
21
22
  return { mode: "after_cursor", after: input.effectiveAfterMessageId };
@@ -25,23 +26,12 @@ export function planWaitForMessagesFetch(input) {
25
26
  limit: input.catchupLimit ?? DEFAULT_WAIT_CATCHUP_LIMIT,
26
27
  };
27
28
  }
28
- const LOCAL_TASK_OWNER_STATUSES = new Set(["assigned", "in_progress", "blocked", "in_review"]);
29
29
  export function buildWaitForMessagesRequestOptions(input) {
30
30
  return {
31
31
  ...(input.signal ? { signal: input.signal } : {}),
32
32
  headers: input.deliveryHeaders,
33
33
  };
34
34
  }
35
- export function buildWaitForMessagesHistoryPageRequest(input) {
36
- const querySuffix = input.queryString ? `?${input.queryString}` : "";
37
- return {
38
- room_id: input.targetRoomId,
39
- project_id: input.targetProjectId,
40
- room_path: (targetRoomId) => appendIncludePromptOnly(`/rooms/${encodeRoomIdPath(targetRoomId)}/messages${querySuffix}`),
41
- project_path: (targetProjectId) => appendIncludePromptOnly(`/projects/${encodeURIComponent(targetProjectId)}/messages${querySuffix}`),
42
- options: buildWaitForMessagesRequestOptions({ deliveryHeaders: input.deliveryHeaders }),
43
- };
44
- }
45
35
  function isRecord(value) {
46
36
  return Boolean(value && typeof value === "object");
47
37
  }
@@ -118,41 +108,233 @@ export function resolveWaitAgentSession(roomId, agentSessionId) {
118
108
  return resolveAgentSession(roomId, agentSessionId);
119
109
  }
120
110
  export async function localActivationContext(roomId) {
121
- const result = await listLocalTasks(roomId, { openOnly: true });
122
111
  return {
123
- activeTaskLeases: result.tasks
124
- .flatMap((task) => {
125
- const agentKey = task.assignee_agent_key?.trim();
126
- if (!LOCAL_TASK_OWNER_STATUSES.has(task.status) || !agentKey) {
127
- return [];
128
- }
129
- return [{
130
- kind: "work",
131
- status: "active",
132
- actor_label: task.assignee || agentKey,
133
- agent_key: agentKey,
134
- agent_instance_id: task.assignee_agent_instance_id,
135
- agent_session_id: task.assignee_agent_session_id,
136
- }];
137
- }),
112
+ activeTaskLeases: await listLocalActiveTaskOwnerLeases(roomId),
138
113
  };
139
114
  }
140
- async function attachLocalActivationMetadata(roomId, messages, agentSession, options = {}) {
115
+ export async function attachLocalActivationMetadata(roomId, messages, agentSession, options = {}) {
141
116
  if (!agentSession || agentSession.session_kind !== "worker") {
142
117
  return messages;
143
118
  }
144
119
  const records = messages.filter(isRecord);
145
- const activationContext = options.includeTaskOwnerLeases === false
146
- ? undefined
147
- : await localActivationContext(roomId);
148
- return attachAgentMessageActivations(records, {
120
+ if (records.length === 0)
121
+ return messages;
122
+ const identity = {
149
123
  actor_label: agentSession.actor_label,
150
124
  agent_key: agentSession.agent_key,
151
125
  agent_instance_id: agentSession.agent_instance_id ?? null,
152
126
  agent_session_id: agentSession.session_id,
153
127
  display_name: agentSession.display_name,
154
128
  session_kind: agentSession.session_kind,
155
- }, activationContext);
129
+ };
130
+ // Imported cloud rows carry immutable, account-scoped send-time authority.
131
+ // A present wrapper always wins over mutable local aliases, including when
132
+ // the current state population cannot be read completely.
133
+ const activeSessionRoomId = options.activeSessionRoomId?.trim() || roomId;
134
+ const routingState = getStoredAgentRoutingStateSnapshot(activeSessionRoomId);
135
+ const authoritativeLegacyDecisions = new Map();
136
+ const legacyRecords = [];
137
+ const normalizedIdentityKey = normalizeRoutingSender(identity.agent_key);
138
+ const validActivationReasons = new Set([
139
+ "self_message",
140
+ "explicit_mention",
141
+ "explicit_other_mention",
142
+ "broadcast",
143
+ "reply_target",
144
+ "other_reply_target",
145
+ "thread_participant",
146
+ "task_owner",
147
+ "system_event",
148
+ "unaddressed",
149
+ ]);
150
+ for (const message of records) {
151
+ const id = messageId(message);
152
+ if (!id)
153
+ continue;
154
+ const imported = getLocalImportedRoutingAuthority(message);
155
+ if (!imported) {
156
+ if (routingState.complete) {
157
+ legacyRecords.push(message);
158
+ }
159
+ else {
160
+ authoritativeLegacyDecisions.set(id, {
161
+ decision: "silent",
162
+ reason: "unaddressed",
163
+ addressed: false,
164
+ });
165
+ }
166
+ continue;
167
+ }
168
+ if (!routingState.accountReaderKey
169
+ || imported.readerKey !== routingState.accountReaderKey
170
+ || imported.routing.authority === "invalid") {
171
+ authoritativeLegacyDecisions.set(id, {
172
+ decision: "silent",
173
+ reason: "unaddressed",
174
+ addressed: false,
175
+ });
176
+ continue;
177
+ }
178
+ const target = imported.routing.recipientSessions.find((candidate) => candidate.agentKey === normalizedIdentityKey);
179
+ const targetSessionId = imported.routing.authority === "receipts"
180
+ ? target && "successorAgentSessionId" in target
181
+ ? target.successorAgentSessionId ?? target.agentSessionId
182
+ : target?.agentSessionId
183
+ : target?.agentSessionId;
184
+ if (!target || targetSessionId !== identity.agent_session_id) {
185
+ authoritativeLegacyDecisions.set(id, {
186
+ decision: "silent",
187
+ reason: "unaddressed",
188
+ addressed: false,
189
+ });
190
+ continue;
191
+ }
192
+ const importedReason = imported.routing.authority === "legacy"
193
+ && "activationReason" in target
194
+ && validActivationReasons.has(target.activationReason)
195
+ ? target.activationReason
196
+ : "explicit_mention";
197
+ authoritativeLegacyDecisions.set(id, {
198
+ decision: "activate",
199
+ reason: importedReason,
200
+ addressed: true,
201
+ });
202
+ }
203
+ if (legacyRecords.length === 0) {
204
+ return attachAgentMessageActivations(records, identity, { authoritativeLegacyDecisions });
205
+ }
206
+ const threadRootIds = legacyRecords.flatMap((message) => {
207
+ const messageId = typeof message.id === "string" ? message.id : "";
208
+ const thread = isRecord(message.thread) ? message.thread : null;
209
+ const rootId = typeof message.thread_root_id === "string"
210
+ ? message.thread_root_id
211
+ : typeof thread?.root_message_id === "string"
212
+ ? thread.root_message_id
213
+ : "";
214
+ return rootId && rootId !== messageId ? [rootId] : [];
215
+ });
216
+ // Linked local/cloud rooms store messages under the SQLite room id while
217
+ // registered workers remain keyed by the canonical cloud room id. Routing
218
+ // ambiguity must always use that complete canonical population.
219
+ const storedActiveSessions = routingState.sessions;
220
+ const activeIdentities = storedActiveSessions.map((session) => ({
221
+ actor_label: session.actor_label,
222
+ agent_key: session.agent_key,
223
+ agent_instance_id: session.agent_instance_id ?? null,
224
+ agent_session_id: session.session_id,
225
+ display_name: session.display_name,
226
+ session_kind: session.session_kind,
227
+ }));
228
+ if (!activeIdentities.some((candidate) => candidate.agent_session_id === identity.agent_session_id
229
+ && candidate.agent_key === identity.agent_key)) {
230
+ // The request's authenticated current session is authoritative even when
231
+ // an older local-state file has not persisted it yet.
232
+ activeIdentities.push(identity);
233
+ }
234
+ const sameKeySessions = storedActiveSessions.filter((session) => session.agent_key === identity.agent_key);
235
+ const currentRepresentativeSessionId = sameKeySessions[0]?.session_id
236
+ ?? identity.agent_session_id;
237
+ const currentIsActive = currentRepresentativeSessionId === identity.agent_session_id;
238
+ const resolveGlobalAddress = createGlobalAgentAddressResolver(activeIdentities);
239
+ const explicitMentionMessageIds = new Set();
240
+ const replyTargetMessageIds = new Set();
241
+ const exactReplyTargetMessageIds = new Set();
242
+ const selfMessageIds = new Set();
243
+ for (const message of legacyRecords) {
244
+ const messageId = typeof message.id === "string" ? message.id : "";
245
+ if (!messageId)
246
+ continue;
247
+ const addressed = resolveGlobalAddress(message);
248
+ const reply = isRecord(message.reply_to) ? message.reply_to : null;
249
+ const replyPublisherIdentity = isRecord(reply?.agent_identity)
250
+ ? reply.agent_identity
251
+ : null;
252
+ const replyPublisherKey = typeof replyPublisherIdentity?.agent_key === "string"
253
+ ? replyPublisherIdentity.agent_key.trim()
254
+ : "";
255
+ const replyPublisherSessionId = typeof replyPublisherIdentity?.agent_session_id === "string"
256
+ ? replyPublisherIdentity.agent_session_id.trim()
257
+ : "";
258
+ if (replyPublisherKey) {
259
+ addressed.replyTargetKeys.clear();
260
+ const exactReplyIdentity = replyPublisherSessionId
261
+ ? activeIdentities.find((candidate) => candidate.agent_key === replyPublisherKey
262
+ && candidate.agent_session_id === replyPublisherSessionId)
263
+ : undefined;
264
+ if (exactReplyIdentity) {
265
+ if (exactReplyIdentity.agent_session_id === identity.agent_session_id) {
266
+ exactReplyTargetMessageIds.add(messageId);
267
+ }
268
+ }
269
+ else if (activeIdentities.some((candidate) => candidate.agent_key === replyPublisherKey)) {
270
+ addressed.replyTargetKeys.add(replyPublisherKey);
271
+ }
272
+ }
273
+ const publisherIdentity = isRecord(message.agent_identity)
274
+ ? message.agent_identity
275
+ : null;
276
+ const publisherAgentKey = typeof publisherIdentity?.agent_key === "string"
277
+ ? publisherIdentity.agent_key.trim()
278
+ : "";
279
+ if (String(message.source ?? "").trim() === "agent"
280
+ && (publisherAgentKey
281
+ ? publisherAgentKey === identity.agent_key
282
+ : addressed.senderKeys.has(identity.agent_key))) {
283
+ selfMessageIds.add(messageId);
284
+ }
285
+ if (addressed.explicitMentionKeys.has(identity.agent_key)) {
286
+ explicitMentionMessageIds.add(messageId);
287
+ }
288
+ const thread = isRecord(message.thread) ? message.thread : null;
289
+ const rootId = typeof message.thread_root_id === "string"
290
+ ? message.thread_root_id
291
+ : typeof thread?.root_message_id === "string"
292
+ ? thread.root_message_id
293
+ : "";
294
+ if ((!rootId || rootId === messageId)
295
+ && (exactReplyTargetMessageIds.has(messageId)
296
+ || addressed.replyTargetKeys.has(identity.agent_key))) {
297
+ replyTargetMessageIds.add(messageId);
298
+ }
299
+ }
300
+ const [activationContext, projectedThreadParticipantRootIds] = await Promise.all([
301
+ options.includeTaskOwnerLeases === false
302
+ || !records.some((message) => isTaskOwnerFollowUpMessageText(message.text))
303
+ ? undefined
304
+ : localActivationContext(roomId),
305
+ getLocalChatThreadRoutingMembership(roomId, threadRootIds, identity, activeIdentities),
306
+ ]);
307
+ const exactContext = {
308
+ ...activationContext,
309
+ selfMessageIds,
310
+ threadParticipantRootIds: projectedThreadParticipantRootIds,
311
+ explicitMentionMessageIds,
312
+ replyTargetMessageIds,
313
+ };
314
+ const exactTaskSessionIdsForKey = new Set((activationContext?.activeTaskLeases ?? [])
315
+ .filter((lease) => lease.status === "active"
316
+ && lease.agent_key === identity.agent_key
317
+ && Boolean(lease.agent_session_id))
318
+ .map((lease) => lease.agent_session_id));
319
+ for (const message of legacyRecords) {
320
+ const messageId = typeof message.id === "string" ? message.id : "";
321
+ if (!messageId)
322
+ continue;
323
+ const resolved = decideAgentMessageActivation(message, identity, exactContext);
324
+ const exactTaskOwner = resolved.reason === "task_owner"
325
+ && activationContext?.activeTaskLeases?.some((lease) => lease.status === "active"
326
+ && lease.agent_session_id === identity.agent_session_id
327
+ && (!lease.agent_key || lease.agent_key === identity.agent_key));
328
+ const eligibleRepresentative = exactReplyTargetMessageIds.has(messageId)
329
+ ? true
330
+ : resolved.reason === "task_owner" && exactTaskSessionIdsForKey.size > 0
331
+ ? exactTaskSessionIdsForKey.size === 1 && exactTaskOwner
332
+ : currentIsActive;
333
+ authoritativeLegacyDecisions.set(messageId, eligibleRepresentative && resolved.decision === "activate"
334
+ ? resolved
335
+ : { decision: "silent", reason: "unaddressed", addressed: false });
336
+ }
337
+ return attachAgentMessageActivations(records, identity, { authoritativeLegacyDecisions });
156
338
  }
157
339
  // Resolving an out-of-window parent costs a lookup (a by-id fetch, or a
158
340
  // page-by-page history scan against older APIs), so resolved (and observed)
@@ -162,6 +344,9 @@ async function attachLocalActivationMetadata(roomId, messages, agentSession, opt
162
344
  // as immutable once posted; a bounded insertion-ordered map keeps memory flat
163
345
  // for long-running workers.
164
346
  const THREAD_CONTEXT_CACHE_MAX = 500;
347
+ export const THREAD_CONTEXT_LOOKUP_MAX = 16;
348
+ export const THREAD_CONTEXT_BYTES_MAX = 256 * 1024;
349
+ const THREAD_CONTEXT_DEADLINE_MS = 1_000;
165
350
  const threadContextCache = new Map();
166
351
  function threadContextCacheKey(scopeId, targetMessageId) {
167
352
  return `${scopeId}:${targetMessageId}`;
@@ -199,20 +384,30 @@ export async function collectThreadContextMessages(input) {
199
384
  if (pendingIds.length === 0) {
200
385
  // Nothing quotes an out-of-window message (idle polls land here), so skip
201
386
  // storage-mode resolution entirely.
202
- return [];
387
+ return { messages: [], truncated: false };
203
388
  }
204
389
  const contextMessages = [];
390
+ let contextBytes = 0;
391
+ let lookups = 0;
392
+ let truncated = false;
393
+ const deadline = Date.now() + THREAD_CONTEXT_DEADLINE_MS;
205
394
  const useLocalStorage = Boolean(input.localRoomId && await isLocalRoomStorageEnabled(input.localRoomId));
206
395
  const localIdentifiers = useLocalStorage
207
396
  ? await resolveLocalRoomStorageIdentifiers(input.localRoomId)
208
397
  : { localRoomId: input.localRoomId };
209
398
  const sqliteRoomId = localIdentifiers.localRoomId || input.localRoomId;
210
399
  while (pendingIds.length > 0) {
400
+ if (lookups >= THREAD_CONTEXT_LOOKUP_MAX || Date.now() >= deadline) {
401
+ truncated = true;
402
+ break;
403
+ }
211
404
  const nextId = pendingIds.shift();
212
405
  if (!nextId || seenIds.has(nextId))
213
406
  continue;
214
407
  seenIds.add(nextId);
215
408
  const cached = threadContextCache.get(threadContextCacheKey(scopeId, nextId));
409
+ if (!cached)
410
+ lookups += 1;
216
411
  const message = cached
217
412
  ?? (useLocalStorage && input.localRoomId
218
413
  ? await findLocalMessageById(sqliteRoomId || input.localRoomId, nextId)
@@ -226,13 +421,19 @@ export async function collectThreadContextMessages(input) {
226
421
  if (!cached) {
227
422
  rememberThreadContextMessages(scopeId, [message]);
228
423
  }
424
+ const messageBytes = Buffer.byteLength(JSON.stringify(message), "utf8");
425
+ if (contextBytes + messageBytes > THREAD_CONTEXT_BYTES_MAX) {
426
+ truncated = true;
427
+ break;
428
+ }
429
+ contextBytes += messageBytes;
229
430
  contextMessages.push(message);
230
431
  const parentId = replyReferenceId(message);
231
432
  if (parentId && !seenIds.has(parentId)) {
232
433
  pendingIds.push(parentId);
233
434
  }
234
435
  }
235
- return contextMessages;
436
+ return { messages: contextMessages, truncated };
236
437
  }
237
438
  export function registerWaitForMessagesTool(server) {
238
439
  server.tool("wait_for_messages", "Wait for new messages in a Let Agents Chat room (HTTP long-poll). Messages labeled activation.for_current_agent.decision=\"silent\" are skipped for the current worker; when last_observed_message_id is present, use it as cursor progress even if messages is empty. Threaded replies include thread_parent_id/thread.root_message_id; use send_thread_message with that id to keep focused side discussion out of the main room. For multi-hour runs, call in a loop: always pass after_message_id from the last message you processed or last_observed_message_id so an empty result means 'nothing new yet', not 'stop working'. Legacy/manual room workers may send a brief continue instruction when another legacy/manual participant posts a premature 'I will wait' closing line. Never send that nudge to or about a daemon-supervised participant, and daemon-supervised workers must not emit it; their supervisor owns wake and retry. Per-call wait is capped (default max 180s unless LETAGENTS_POLL_MAX_MS is set on API and MCP).", {
@@ -260,8 +461,13 @@ export function registerWaitForMessagesTool(server) {
260
461
  const targetRoomId = getTargetRoomId(room_id);
261
462
  const targetProjectId = getFallbackProjectId();
262
463
  const localRoomId = targetRoomId ?? currentRoom?.room_id ?? targetProjectId;
263
- const identity = await ensureAgentIdentity();
264
464
  const sessionRoomId = targetRoomId ?? currentRoom?.room_id ?? localRoomId ?? null;
465
+ const routingStateSnapshot = getStoredAgentRoutingStateSnapshot(sessionRoomId ?? "");
466
+ const localStorageEnabled = Boolean(localRoomId && await isLocalRoomStorageEnabled(localRoomId));
467
+ if (localStorageEnabled && !routingStateSnapshot.complete) {
468
+ throw new Error("Local agent routing state is unavailable; retry after restoring the state file.");
469
+ }
470
+ const identity = await ensureAgentIdentity();
265
471
  const agentSession = resolveWaitAgentSession(sessionRoomId, agent_session_id);
266
472
  if (agentSession) {
267
473
  // Registration (or a successor generation) must bind strictly once.
@@ -278,8 +484,8 @@ export function registerWaitForMessagesTool(server) {
278
484
  }
279
485
  const maxPollMs = getPollTimeoutCapMs();
280
486
  const serverTimeout = Math.min(Math.max(timeout || DEFAULT_POLL_TIMEOUT_MS, 1000), maxPollMs);
281
- if (localRoomId && await isLocalRoomStorageEnabled(localRoomId)) {
282
- const { localRoomId: sqliteRoomId } = await resolveLocalRoomStorageIdentifiers(localRoomId);
487
+ if (localRoomId && localStorageEnabled) {
488
+ const { localRoomId: sqliteRoomId, cloudRoomId, } = await resolveLocalRoomStorageIdentifiers(localRoomId);
283
489
  const effectiveLocalRoomId = sqliteRoomId || localRoomId;
284
490
  const effectiveAfterMessageId = resolveEffectiveAfterMessageId({
285
491
  requestedAfterMessageId: after_message_id,
@@ -290,11 +496,12 @@ export function registerWaitForMessagesTool(server) {
290
496
  // keep the unchanged forward "everything after the cursor" behavior.
291
497
  const existing = fetchPlan.mode === "catch_up_tail"
292
498
  ? await getLatestLocalChatMessages(effectiveLocalRoomId, {
293
- limit: fetchPlan.limit,
499
+ limit: Math.min(fetchPlan.limit, MAX_WAIT_MESSAGES_PER_CALL),
294
500
  include_prompt_only: true,
295
501
  })
296
502
  : await getLocalChatMessages(effectiveLocalRoomId, {
297
503
  after: effectiveAfterMessageId,
504
+ limit: MAX_WAIT_MESSAGES_PER_CALL,
298
505
  include_prompt_only: true,
299
506
  });
300
507
  const replayingExistingMessages = existing.messages.length > 0;
@@ -303,12 +510,18 @@ export function registerWaitForMessagesTool(server) {
303
510
  : await waitForLocalChatMessages(effectiveLocalRoomId, {
304
511
  after: effectiveAfterMessageId,
305
512
  timeoutMs: serverTimeout,
513
+ limit: MAX_WAIT_MESSAGES_PER_CALL,
306
514
  include_prompt_only: true,
307
515
  });
308
516
  const messages = await attachLocalActivationMetadata(effectiveLocalRoomId, result.messages, agentSession, {
309
517
  includeTaskOwnerLeases: !replayingExistingMessages,
518
+ activeSessionRoomId: cloudRoomId || sessionRoomId,
519
+ });
520
+ const bounded = boundAgentMessageOutput(messages, {
521
+ direction: fetchPlan.mode === "catch_up_tail" ? "suffix" : "prefix",
522
+ maxBytes: AGENT_MESSAGE_BODY_MAX_BYTES,
310
523
  });
311
- const routing = filterSilentActivationMessages(messages);
524
+ const routing = filterSilentActivationMessages(bounded.messages);
312
525
  const observedCursor = routing.last_observed_message_id ?? getLastMessageId(result);
313
526
  touchRoomSession(effectiveLocalRoomId, observedCursor);
314
527
  const threadContext = await collectThreadContextMessages({
@@ -320,8 +533,13 @@ export function registerWaitForMessagesTool(server) {
320
533
  return jsonToolResponse({
321
534
  room_id: effectiveLocalRoomId,
322
535
  ...addActivationRoutingTelemetry({
323
- messages: toAgentReadableMessages(routing.messages, threadContext),
536
+ messages: toAgentReadableMessages(routing.messages, threadContext.messages),
324
537
  }, routing),
538
+ ...(result.has_more || bounded.truncated ? { truncated: true } : {}),
539
+ ...(bounded.omittedMessageCount > 0
540
+ ? { omitted_message_count: bounded.omittedMessageCount }
541
+ : {}),
542
+ ...(threadContext.truncated ? { thread_context_truncated: true } : {}),
325
543
  });
326
544
  }
327
545
  await syncRoomPresence(targetRoomId ?? currentRoom?.room_id ?? null, identity, getRememberedRoomPresence(targetRoomId ?? currentRoom?.room_id ?? null, agentSession ? identityFromAgentSession(agentSession) : identity), agentSession);
@@ -333,6 +551,8 @@ export function registerWaitForMessagesTool(server) {
333
551
  const deliveryHeaders = buildAgentDeliveryHeaders(agentSession);
334
552
  const allMessages = [];
335
553
  let roomIdFromResponse;
554
+ let catchUpTruncated = false;
555
+ let apiObservedCursor = null;
336
556
  // Long-poll the server (blocks up to serverTimeout for new messages),
337
557
  // optionally seeded with a cursor. Shared by the cursor path and by the
338
558
  // no-cursor empty-tail fallback so a quiet room still blocks instead of
@@ -342,6 +562,7 @@ export function registerWaitForMessagesTool(server) {
342
562
  const params = new URLSearchParams();
343
563
  if (after)
344
564
  params.set("after", after);
565
+ params.set("limit", String(MAX_WAIT_MESSAGES_PER_CALL));
345
566
  params.set("timeout", String(serverTimeout));
346
567
  const queryString = params.toString();
347
568
  const firstResult = await roomScopedApiCall({
@@ -356,25 +577,10 @@ export function registerWaitForMessagesTool(server) {
356
577
  });
357
578
  allMessages.push(...(firstResult.messages ?? []));
358
579
  roomIdFromResponse = roomIdFromResponse || firstResult.room_id || firstResult.project_id;
359
- if (firstResult.has_more && allMessages.length > 0) {
360
- let afterCursor = allMessages[allMessages.length - 1]?.id;
361
- while (afterCursor) {
362
- const pageParams = new URLSearchParams();
363
- pageParams.set("after", afterCursor);
364
- const qs = pageParams.toString();
365
- const page = await roomScopedApiCall(buildWaitForMessagesHistoryPageRequest({
366
- targetRoomId,
367
- targetProjectId,
368
- queryString: qs,
369
- deliveryHeaders,
370
- }));
371
- const msgs = page.messages ?? [];
372
- allMessages.push(...msgs);
373
- if (!page.has_more || msgs.length === 0)
374
- break;
375
- afterCursor = msgs[msgs.length - 1]?.id;
376
- }
377
- }
580
+ catchUpTruncated = Boolean(firstResult.has_more);
581
+ apiObservedCursor = typeof firstResult.last_observed_message_id === "string"
582
+ ? firstResult.last_observed_message_id
583
+ : null;
378
584
  };
379
585
  if (fetchPlan.mode === "catch_up_tail") {
380
586
  // No cursor: catch up on only the bounded recent tail. Mirror
@@ -385,6 +591,7 @@ export function registerWaitForMessagesTool(server) {
385
591
  targetRoomId,
386
592
  targetProjectId,
387
593
  limit: fetchPlan.limit,
594
+ deliveryHeaders,
388
595
  });
389
596
  if (recent.messages.length > 0) {
390
597
  allMessages.push(...recent.messages);
@@ -402,7 +609,11 @@ export function registerWaitForMessagesTool(server) {
402
609
  else {
403
610
  await longPollFromCursor(fetchPlan.after);
404
611
  }
405
- const routing = filterSilentActivationMessages(allMessages);
612
+ const bounded = boundAgentMessageOutput(allMessages, {
613
+ direction: fetchPlan.mode === "catch_up_tail" ? "suffix" : "prefix",
614
+ maxBytes: AGENT_MESSAGE_BODY_MAX_BYTES,
615
+ });
616
+ const routing = filterSilentActivationMessages(bounded.messages);
406
617
  const threadContext = await collectThreadContextMessages({
407
618
  messages: routing.messages,
408
619
  localRoomId,
@@ -410,13 +621,24 @@ export function registerWaitForMessagesTool(server) {
410
621
  projectId: targetProjectId,
411
622
  });
412
623
  const output = addActivationRoutingTelemetry({
413
- messages: toAgentReadableMessages(routing.messages, threadContext),
624
+ messages: toAgentReadableMessages(routing.messages, threadContext.messages),
414
625
  }, routing);
626
+ if (threadContext.truncated)
627
+ output.thread_context_truncated = true;
415
628
  if (roomIdFromResponse) {
416
629
  output[targetRoomId ? "room_id" : "project_id"] = roomIdFromResponse;
417
630
  }
631
+ if (catchUpTruncated || bounded.truncated)
632
+ output.truncated = true;
633
+ if (bounded.omittedMessageCount > 0) {
634
+ output.omitted_message_count = bounded.omittedMessageCount;
635
+ }
636
+ if (apiObservedCursor)
637
+ output.last_observed_message_id = apiObservedCursor;
418
638
  if (targetRoomId) {
419
- const observedCursor = routing.last_observed_message_id ?? getLastMessageId(output);
639
+ const observedCursor = apiObservedCursor
640
+ ?? routing.last_observed_message_id
641
+ ?? getLastMessageId(output);
420
642
  touchRoomSession(targetRoomId, observedCursor);
421
643
  if (allMessages.length > 0 && agentSession) {
422
644
  const firstMsg = allMessages[0];
@@ -83,7 +83,7 @@ export function registerGetOnboardingStatusTool(server) {
83
83
  const storedAuth = getStoredAuth();
84
84
  const pendingAuth = getPendingDeviceAuth();
85
85
  const savedCurrentRoom = getStoredCurrentRoom();
86
- const detectedRoom = configGitContext?.activeRoom ?? gitContext?.activeRoom ?? null;
86
+ const detectedRoom = configGitContext?.activeRoomLocator ?? gitContext?.activeRoomLocator ?? null;
87
87
  const api_health = await checkOnboardingApiHealth();
88
88
  const authenticated = Boolean(process.env.LETAGENTS_TOKEN || storedAuth);
89
89
  let nextStep = "join_room";
@@ -113,10 +113,10 @@ export function registerGetOnboardingStatusTool(server) {
113
113
  saved_current_room: toPublicStoredRoomSession(savedCurrentRoom),
114
114
  detected_room_from_context: detectedRoom,
115
115
  configured_room_from_file: configRoom,
116
- configured_active_room_from_context: configGitContext?.activeRoom ?? null,
116
+ configured_active_room_from_context: configGitContext?.activeRoomLocator ?? null,
117
117
  derived_repo_room_from_git: gitContext?.repoRoom ?? null,
118
- derived_active_git_room: gitContext?.activeRoom ?? null,
119
- derived_branch_room_from_git: gitContext?.activeRefRoom ?? null,
118
+ derived_active_git_room: gitContext?.activeRoomLocator ?? null,
119
+ derived_branch_room_from_git: gitContext?.activeRefRoomLocator ?? null,
120
120
  git_current_branch: gitContext?.currentBranch ?? configGitContext?.currentBranch ?? null,
121
121
  git_default_branch: gitContext?.defaultBranch ?? configGitContext?.defaultBranch ?? null,
122
122
  repo_root: repoRoot,
@@ -3,8 +3,10 @@ import { join } from "path";
3
3
  import { z } from "zod";
4
4
  import { getRoomFromConfig } from "../../../config-reader.js";
5
5
  import { buildActiveGitRoomContext, getGitCurrentBranch, getGitDefaultBranch, getGitRoomContext, } from "../../../git-remote.js";
6
- import { currentAgentIdentity, currentAgentIdentityKey, currentRoom, getConversationIdentity, getCurrentLiveSessionPayload, getStoredAgentIdentity, listStoredCodexLiveSessions, toPublicAgentIdentity, toPublicCurrentRoomState, withJoinRoomAgentPrompt, } from "../../runtime.js";
6
+ import { currentAgentIdentity, currentAgentIdentityKey, currentRoomMatchesLocator, currentRoom, getConversationIdentity, getCurrentLiveSessionPayload, getStoredAgentIdentity, listStoredCodexLiveSessions, toPublicAgentIdentity, toPublicCurrentRoomState, withJoinRoomAgentPrompt, } from "../../runtime.js";
7
7
  import { requireValidWorkerBearerRuntime } from "../../runtime/worker-bearer.js";
8
+ import { getDaemonToolExecutionContext, getRuntimeWorkingDirectory } from "../../runtime/daemon-tool-context.js";
9
+ import { identityFromAgentSession } from "../../runtime/agent-sessions.js";
8
10
  import { findExistingConfig, resolveGitRoot, } from "../../repo-context.js";
9
11
  import { jsonToolResponse } from "./response.js";
10
12
  let ownerAuthStoreLoader = () => import("../../../local-state.js");
@@ -29,6 +31,7 @@ export function registerRoomInspectionTools(server) {
29
31
  }
30
32
  export async function getCurrentRoomPayload(conversationId) {
31
33
  const runtime = requireValidWorkerBearerRuntime();
34
+ const daemonContext = getDaemonToolExecutionContext();
32
35
  const publicCurrentRoom = toPublicCurrentRoomState();
33
36
  const workerAuth = runtime.mode !== "owner"
34
37
  ? { source: runtime.mode === "worker" ? "worker_bearer" : "daemon_supervised", expires_at: null, account: null }
@@ -55,9 +58,11 @@ export async function getCurrentRoomPayload(conversationId) {
55
58
  ...publicCurrentRoom,
56
59
  ...(runtime.mode === "supervised" ? { room_binding: "daemon_supervised" } : {}),
57
60
  ...localCodexDetails,
58
- agent_identity: toPublicAgentIdentity(getConversationIdentity(conversationId)
59
- ?? currentAgentIdentity
60
- ?? getStoredAgentIdentity(currentAgentIdentityKey)),
61
+ agent_identity: toPublicAgentIdentity(daemonContext
62
+ ? identityFromAgentSession(daemonContext.agentSession)
63
+ : getConversationIdentity(conversationId)
64
+ ?? currentAgentIdentity
65
+ ?? getStoredAgentIdentity(currentAgentIdentityKey)),
61
66
  auth: workerAuth ?? (auth
62
67
  ? {
63
68
  source: process.env.LETAGENTS_TOKEN ? "env" : "local_state",
@@ -73,7 +78,7 @@ export async function getCurrentRoomPayload(conversationId) {
73
78
  }
74
79
  function getRepoInspectionPayload(targetDir) {
75
80
  const runtime = requireValidWorkerBearerRuntime();
76
- const startDir = targetDir || process.cwd();
81
+ const startDir = targetDir || getRuntimeWorkingDirectory();
77
82
  const repoRoot = resolveGitRoot(startDir);
78
83
  const configDir = repoRoot ? findExistingConfig(startDir) : null;
79
84
  const configPath = configDir ? join(configDir, ".letagents.json") : null;
@@ -86,8 +91,8 @@ function getRepoInspectionPayload(targetDir) {
86
91
  defaultBranch: repoRoot ? getGitDefaultBranch(repoRoot) : null,
87
92
  })
88
93
  : null;
89
- const detectedRoom = configGitContext?.activeRoom ?? gitContext?.activeRoom ?? null;
90
- const currentRoomMatchesContext = Boolean(currentRoom && detectedRoom && currentRoom.room_id === detectedRoom);
94
+ const detectedRoom = configGitContext?.activeRoomLocator ?? gitContext?.activeRoomLocator ?? null;
95
+ const currentRoomMatchesContext = currentRoomMatchesLocator(detectedRoom);
91
96
  return {
92
97
  cwd: startDir,
93
98
  repo_context_status: repoRoot ? "git_repo_detected" : "not_inside_git_repo",
@@ -95,10 +100,10 @@ function getRepoInspectionPayload(targetDir) {
95
100
  config_file: configPath ?? null,
96
101
  config_contents: readConfigContents(configPath),
97
102
  configured_room_from_file: configuredRoom ?? null,
98
- configured_active_room_from_context: configGitContext?.activeRoom ?? null,
99
- derived_room_from_git: gitContext?.activeRoom ?? null,
103
+ configured_active_room_from_context: configGitContext?.activeRoomLocator ?? null,
104
+ derived_room_from_git: gitContext?.activeRoomLocator ?? null,
100
105
  derived_repo_room_from_git: gitContext?.repoRoom ?? null,
101
- derived_branch_room_from_git: gitContext?.activeRefRoom ?? null,
106
+ derived_branch_room_from_git: gitContext?.activeRefRoomLocator ?? null,
102
107
  git_current_branch: gitContext?.currentBranch ?? configGitContext?.currentBranch ?? null,
103
108
  git_default_branch: gitContext?.defaultBranch ?? configGitContext?.defaultBranch ?? null,
104
109
  detected_room_from_context: detectedRoom ?? null,
@@ -5,6 +5,7 @@ import { getGitRemoteIdentity } from "../../../git-remote.js";
5
5
  import { joinRoomIdentifier, } from "../../runtime.js";
6
6
  import { findExistingConfig, resolveGitRoot, } from "../../repo-context.js";
7
7
  import { jsonToolResponse } from "./response.js";
8
+ import { getRuntimeWorkingDirectory } from "../../runtime/daemon-tool-context.js";
8
9
  export function registerRepoInitializationTool(server) {
9
10
  server.tool("initialize_repo", "Initialize the current repo for Let Agents Chat by creating a .letagents.json config file. " +
10
11
  "This explicitly sets up repo-based room auto-join. Reads git remote to derive the room name, " +
@@ -19,7 +20,7 @@ export function registerRepoInitializationTool(server) {
19
20
  .optional()
20
21
  .describe("Working directory hint for repo detection. Defaults to the current process directory."),
21
22
  }, async ({ room, cwd: targetDir }) => {
22
- const startDir = targetDir || process.cwd();
23
+ const startDir = targetDir || getRuntimeWorkingDirectory();
23
24
  const repoRoot = resolveGitRoot(startDir);
24
25
  if (!repoRoot) {
25
26
  return jsonToolResponse({