letagents 0.12.11 → 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 (49) hide show
  1. package/dist/mcp/git-remote.js +7 -7
  2. package/dist/mcp/local-state/agent-sessions.js +78 -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 +25 -12
  7. package/dist/mcp/server/runtime/agent-sessions.js +58 -9
  8. package/dist/mcp/server/runtime/api.js +40 -4
  9. package/dist/mcp/server/runtime/daemon-tool-context.js +11 -0
  10. package/dist/mcp/server/runtime/execution-profile.js +19 -0
  11. package/dist/mcp/server/runtime/identity/directory.js +2 -2
  12. package/dist/mcp/server/runtime/messages.js +55 -0
  13. package/dist/mcp/server/runtime/presence.js +4 -2
  14. package/dist/mcp/server/runtime/room-api.js +24 -7
  15. package/dist/mcp/server/runtime/room-state.js +59 -3
  16. package/dist/mcp/server/runtime/rooms.js +67 -32
  17. package/dist/mcp/server/runtime/supervised-room-authority.js +8 -0
  18. package/dist/mcp/server/runtime/supervisor-bridge.js +702 -24
  19. package/dist/mcp/server/runtime/tool-surface-policy.js +26 -0
  20. package/dist/mcp/server/runtime/worker-bearer.js +44 -6
  21. package/dist/mcp/server/runtime-contract.js +27 -0
  22. package/dist/mcp/server/runtime.js +15 -4
  23. package/dist/mcp/server/supervised-tool-facade.js +134 -0
  24. package/dist/mcp/server/tools/agent-sessions.js +74 -7
  25. package/dist/mcp/server/tools/messages/index.js +3 -2
  26. package/dist/mcp/server/tools/messages/read-tool.js +54 -97
  27. package/dist/mcp/server/tools/messages/reasoning-tool.js +2 -0
  28. package/dist/mcp/server/tools/messages/send-tool.js +5 -0
  29. package/dist/mcp/server/tools/messages/status-tool.js +2 -0
  30. package/dist/mcp/server/tools/messages/wait-tool.js +344 -71
  31. package/dist/mcp/server/tools/onboarding/status-tool.js +9 -8
  32. package/dist/mcp/server/tools/rooms/inspection-tools.js +40 -22
  33. package/dist/mcp/server/tools/rooms/repo-initialization-tool.js +2 -1
  34. package/dist/mcp/server/tools/supervised-room-turn.js +42 -0
  35. package/dist/mcp/server/tools/tasks/board-tools.js +34 -2
  36. package/dist/mcp/server.js +14 -7
  37. package/dist/mcp/sse-client.js +163 -20
  38. package/dist/shared/activation-routing.js +187 -20
  39. package/dist/shared/agent-presence.js +6 -0
  40. package/dist/shared/desktop-release-manifest.js +63 -0
  41. package/dist/shared/desktop-release.js +60 -0
  42. package/dist/shared/scoped-ids.js +6 -0
  43. package/package.json +11 -3
  44. package/shared/message-contracts.d.mts +32 -0
  45. package/shared/message-contracts.mjs +109 -0
  46. package/shared/routing-aliases.d.mts +18 -0
  47. package/shared/routing-aliases.mjs +66 -0
  48. package/shared/sqlite-thread-routing.d.mts +72 -0
  49. 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 { appendIncludePromptOnly, buildAgentDeliveryHeaders, 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";
5
- import { requireValidWorkerBearerRuntime } from "../../runtime/worker-bearer.js";
6
- import { attachAgentMessageActivations } from "../../../../shared/activation-routing.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
+ import { requireValidWorkerBearerRuntime, supervisedBoundedDeliveryDisabledToolResult, } from "../../runtime/worker-bearer.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,16 +421,22 @@ 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
- 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'. If someone posted a premature 'I will wait' closing line, use send_message with a brief continue instruction. Per-call wait is capped (default max 180s unless LETAGENTS_POLL_MAX_MS is set on API and MCP).", {
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).", {
239
440
  room_id: z.string().optional().describe("Canonical room ID. Defaults to the current room."),
240
441
  after_message_id: z
241
442
  .string()
@@ -250,16 +451,41 @@ export function registerWaitForMessagesTool(server) {
250
451
  .optional()
251
452
  .describe("Registered agent session to use. Without this, the MCP transport is treated as controller traffic and is hidden from connected-agent activity."),
252
453
  }, async ({ room_id, after_message_id, timeout, agent_session_id }) => {
454
+ // This guard intentionally runs before room resolution, identity setup,
455
+ // local SQLite reads, presence writes, or HTTP traffic. In supervised
456
+ // bounded-turn mode the daemon is the sole inbox owner.
457
+ const boundedDeliveryDisabled = supervisedBoundedDeliveryDisabledToolResult();
458
+ if (boundedDeliveryDisabled) {
459
+ return jsonToolResponse(boundedDeliveryDisabled);
460
+ }
253
461
  const targetRoomId = getTargetRoomId(room_id);
254
462
  const targetProjectId = getFallbackProjectId();
255
463
  const localRoomId = targetRoomId ?? currentRoom?.room_id ?? targetProjectId;
256
- const identity = await ensureAgentIdentity();
257
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();
258
471
  const agentSession = resolveWaitAgentSession(sessionRoomId, agent_session_id);
472
+ if (agentSession) {
473
+ // Registration (or a successor generation) must bind strictly once.
474
+ // Later waits use a read-only exact verification capped at 250ms, so a
475
+ // wedged daemon cannot consume the room-poll budget and an old worker
476
+ // cannot read after a successor generation takes ownership.
477
+ await bindSupervisedWorkerSession(agentSession, process.env, { allowConfirmedFastPath: true });
478
+ // A cursor is acknowledged only when the worker explicitly uses it to
479
+ // request the next page. Persisting a cursor from the response we are
480
+ // still constructing could skip a message if serialization, presence,
481
+ // or the provider turn fails afterward.
482
+ if (after_message_id)
483
+ scheduleSupervisedWorkerCursorCheckpoint(agentSession, after_message_id);
484
+ }
259
485
  const maxPollMs = getPollTimeoutCapMs();
260
486
  const serverTimeout = Math.min(Math.max(timeout || DEFAULT_POLL_TIMEOUT_MS, 1000), maxPollMs);
261
- if (localRoomId && await isLocalRoomStorageEnabled(localRoomId)) {
262
- const { localRoomId: sqliteRoomId } = await resolveLocalRoomStorageIdentifiers(localRoomId);
487
+ if (localRoomId && localStorageEnabled) {
488
+ const { localRoomId: sqliteRoomId, cloudRoomId, } = await resolveLocalRoomStorageIdentifiers(localRoomId);
263
489
  const effectiveLocalRoomId = sqliteRoomId || localRoomId;
264
490
  const effectiveAfterMessageId = resolveEffectiveAfterMessageId({
265
491
  requestedAfterMessageId: after_message_id,
@@ -270,11 +496,12 @@ export function registerWaitForMessagesTool(server) {
270
496
  // keep the unchanged forward "everything after the cursor" behavior.
271
497
  const existing = fetchPlan.mode === "catch_up_tail"
272
498
  ? await getLatestLocalChatMessages(effectiveLocalRoomId, {
273
- limit: fetchPlan.limit,
499
+ limit: Math.min(fetchPlan.limit, MAX_WAIT_MESSAGES_PER_CALL),
274
500
  include_prompt_only: true,
275
501
  })
276
502
  : await getLocalChatMessages(effectiveLocalRoomId, {
277
503
  after: effectiveAfterMessageId,
504
+ limit: MAX_WAIT_MESSAGES_PER_CALL,
278
505
  include_prompt_only: true,
279
506
  });
280
507
  const replayingExistingMessages = existing.messages.length > 0;
@@ -283,13 +510,20 @@ export function registerWaitForMessagesTool(server) {
283
510
  : await waitForLocalChatMessages(effectiveLocalRoomId, {
284
511
  after: effectiveAfterMessageId,
285
512
  timeoutMs: serverTimeout,
513
+ limit: MAX_WAIT_MESSAGES_PER_CALL,
286
514
  include_prompt_only: true,
287
515
  });
288
516
  const messages = await attachLocalActivationMetadata(effectiveLocalRoomId, result.messages, agentSession, {
289
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,
290
523
  });
291
- const routing = filterSilentActivationMessages(messages);
292
- touchRoomSession(effectiveLocalRoomId, routing.last_observed_message_id ?? getLastMessageId(result));
524
+ const routing = filterSilentActivationMessages(bounded.messages);
525
+ const observedCursor = routing.last_observed_message_id ?? getLastMessageId(result);
526
+ touchRoomSession(effectiveLocalRoomId, observedCursor);
293
527
  const threadContext = await collectThreadContextMessages({
294
528
  messages: routing.messages,
295
529
  localRoomId: effectiveLocalRoomId,
@@ -299,8 +533,13 @@ export function registerWaitForMessagesTool(server) {
299
533
  return jsonToolResponse({
300
534
  room_id: effectiveLocalRoomId,
301
535
  ...addActivationRoutingTelemetry({
302
- messages: toAgentReadableMessages(routing.messages, threadContext),
536
+ messages: toAgentReadableMessages(routing.messages, threadContext.messages),
303
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 } : {}),
304
543
  });
305
544
  }
306
545
  await syncRoomPresence(targetRoomId ?? currentRoom?.room_id ?? null, identity, getRememberedRoomPresence(targetRoomId ?? currentRoom?.room_id ?? null, agentSession ? identityFromAgentSession(agentSession) : identity), agentSession);
@@ -312,6 +551,8 @@ export function registerWaitForMessagesTool(server) {
312
551
  const deliveryHeaders = buildAgentDeliveryHeaders(agentSession);
313
552
  const allMessages = [];
314
553
  let roomIdFromResponse;
554
+ let catchUpTruncated = false;
555
+ let apiObservedCursor = null;
315
556
  // Long-poll the server (blocks up to serverTimeout for new messages),
316
557
  // optionally seeded with a cursor. Shared by the cursor path and by the
317
558
  // no-cursor empty-tail fallback so a quiet room still blocks instead of
@@ -321,6 +562,7 @@ export function registerWaitForMessagesTool(server) {
321
562
  const params = new URLSearchParams();
322
563
  if (after)
323
564
  params.set("after", after);
565
+ params.set("limit", String(MAX_WAIT_MESSAGES_PER_CALL));
324
566
  params.set("timeout", String(serverTimeout));
325
567
  const queryString = params.toString();
326
568
  const firstResult = await roomScopedApiCall({
@@ -335,25 +577,10 @@ export function registerWaitForMessagesTool(server) {
335
577
  });
336
578
  allMessages.push(...(firstResult.messages ?? []));
337
579
  roomIdFromResponse = roomIdFromResponse || firstResult.room_id || firstResult.project_id;
338
- if (firstResult.has_more && allMessages.length > 0) {
339
- let afterCursor = allMessages[allMessages.length - 1]?.id;
340
- while (afterCursor) {
341
- const pageParams = new URLSearchParams();
342
- pageParams.set("after", afterCursor);
343
- const qs = pageParams.toString();
344
- const page = await roomScopedApiCall(buildWaitForMessagesHistoryPageRequest({
345
- targetRoomId,
346
- targetProjectId,
347
- queryString: qs,
348
- deliveryHeaders,
349
- }));
350
- const msgs = page.messages ?? [];
351
- allMessages.push(...msgs);
352
- if (!page.has_more || msgs.length === 0)
353
- break;
354
- afterCursor = msgs[msgs.length - 1]?.id;
355
- }
356
- }
580
+ catchUpTruncated = Boolean(firstResult.has_more);
581
+ apiObservedCursor = typeof firstResult.last_observed_message_id === "string"
582
+ ? firstResult.last_observed_message_id
583
+ : null;
357
584
  };
358
585
  if (fetchPlan.mode === "catch_up_tail") {
359
586
  // No cursor: catch up on only the bounded recent tail. Mirror
@@ -364,6 +591,7 @@ export function registerWaitForMessagesTool(server) {
364
591
  targetRoomId,
365
592
  targetProjectId,
366
593
  limit: fetchPlan.limit,
594
+ deliveryHeaders,
367
595
  });
368
596
  if (recent.messages.length > 0) {
369
597
  allMessages.push(...recent.messages);
@@ -381,7 +609,11 @@ export function registerWaitForMessagesTool(server) {
381
609
  else {
382
610
  await longPollFromCursor(fetchPlan.after);
383
611
  }
384
- 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);
385
617
  const threadContext = await collectThreadContextMessages({
386
618
  messages: routing.messages,
387
619
  localRoomId,
@@ -389,13 +621,54 @@ export function registerWaitForMessagesTool(server) {
389
621
  projectId: targetProjectId,
390
622
  });
391
623
  const output = addActivationRoutingTelemetry({
392
- messages: toAgentReadableMessages(routing.messages, threadContext),
624
+ messages: toAgentReadableMessages(routing.messages, threadContext.messages),
393
625
  }, routing);
626
+ if (threadContext.truncated)
627
+ output.thread_context_truncated = true;
394
628
  if (roomIdFromResponse) {
395
629
  output[targetRoomId ? "room_id" : "project_id"] = roomIdFromResponse;
396
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;
397
638
  if (targetRoomId) {
398
- touchRoomSession(targetRoomId, routing.last_observed_message_id ?? getLastMessageId(output));
639
+ const observedCursor = apiObservedCursor
640
+ ?? routing.last_observed_message_id
641
+ ?? getLastMessageId(output);
642
+ touchRoomSession(targetRoomId, observedCursor);
643
+ if (allMessages.length > 0 && agentSession) {
644
+ const firstMsg = allMessages[0];
645
+ const lastMsg = allMessages[allMessages.length - 1];
646
+ if (typeof firstMsg?.id === "string" && typeof lastMsg?.id === "string") {
647
+ try {
648
+ await roomScopedApiCall({
649
+ room_id: targetRoomId,
650
+ project_id: targetProjectId,
651
+ room_path: (r) => `/rooms/${encodeRoomIdPath(r)}/agents/self/observation`,
652
+ project_path: (p) => `/projects/${encodeURIComponent(p)}/agents/self/observation`,
653
+ options: {
654
+ method: "PUT",
655
+ body: JSON.stringify({
656
+ first_message_id: firstMsg.id,
657
+ last_message_id: lastMsg.id,
658
+ ...agentSessionCredentials(agentSession),
659
+ }),
660
+ },
661
+ });
662
+ }
663
+ catch {
664
+ // Non-blocking telemetry
665
+ }
666
+ }
667
+ }
668
+ // Delivery alone is observation evidence (the span above), never a
669
+ // "responding" receipt: an agent that ignores an activation must not
670
+ // present as responding. Receipts advance only on real transitions —
671
+ // send-tool marks "replied" when the agent actually publishes a reply.
399
672
  }
400
673
  await syncRoomPresence(targetRoomId ?? currentRoom?.room_id ?? null, identity, getRememberedRoomPresence(targetRoomId ?? currentRoom?.room_id ?? null, agentSession ? identityFromAgentSession(agentSession) : identity), agentSession);
401
674
  return jsonToolResponse(output);
@@ -56,16 +56,17 @@ export function registerGetOnboardingStatusTool(server) {
56
56
  if (workerRuntime.mode === "invalid") {
57
57
  return jsonTextResponse({ success: false, error: "worker_bearer_configuration_invalid", message: workerRuntime.error });
58
58
  }
59
- if (workerRuntime.mode === "worker") {
59
+ if (workerRuntime.mode === "worker" || workerRuntime.mode === "supervised") {
60
60
  return jsonTextResponse({
61
61
  api_url: API_URL,
62
- worker_bearer_mode: true,
62
+ worker_bearer_mode: workerRuntime.mode === "worker",
63
+ supervised_bounded_mode: workerRuntime.mode === "supervised",
63
64
  authenticated: true,
64
- auth_source: "worker_bearer",
65
+ auth_source: workerRuntime.mode === "worker" ? "worker_bearer" : "daemon_supervised",
65
66
  account: null,
66
67
  pending_device_auth: null,
67
68
  next_step: "join_room",
68
- note: "Owner-auth onboarding and saved-auth state are disabled in worker bearer mode.",
69
+ note: "Owner-auth onboarding and saved-auth state are disabled in worker credential mode.",
69
70
  });
70
71
  }
71
72
  const workingDir = cwd || process.cwd();
@@ -82,7 +83,7 @@ export function registerGetOnboardingStatusTool(server) {
82
83
  const storedAuth = getStoredAuth();
83
84
  const pendingAuth = getPendingDeviceAuth();
84
85
  const savedCurrentRoom = getStoredCurrentRoom();
85
- const detectedRoom = configGitContext?.activeRoom ?? gitContext?.activeRoom ?? null;
86
+ const detectedRoom = configGitContext?.activeRoomLocator ?? gitContext?.activeRoomLocator ?? null;
86
87
  const api_health = await checkOnboardingApiHealth();
87
88
  const authenticated = Boolean(process.env.LETAGENTS_TOKEN || storedAuth);
88
89
  let nextStep = "join_room";
@@ -112,10 +113,10 @@ export function registerGetOnboardingStatusTool(server) {
112
113
  saved_current_room: toPublicStoredRoomSession(savedCurrentRoom),
113
114
  detected_room_from_context: detectedRoom,
114
115
  configured_room_from_file: configRoom,
115
- configured_active_room_from_context: configGitContext?.activeRoom ?? null,
116
+ configured_active_room_from_context: configGitContext?.activeRoomLocator ?? null,
116
117
  derived_repo_room_from_git: gitContext?.repoRoom ?? null,
117
- derived_active_git_room: gitContext?.activeRoom ?? null,
118
- 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,
119
120
  git_current_branch: gitContext?.currentBranch ?? configGitContext?.currentBranch ?? null,
120
121
  git_default_branch: gitContext?.defaultBranch ?? configGitContext?.defaultBranch ?? null,
121
122
  repo_root: repoRoot,