letagents 0.12.10 → 0.12.11

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 (34) hide show
  1. package/README.md +2 -0
  2. package/dist/api/board-intent-payloads.js +25 -0
  3. package/dist/mcp/codex-session/runtime-bridge.js +42 -25
  4. package/dist/mcp/local-state/local-chat.js +1 -1
  5. package/dist/mcp/rental-tools/context.js +30 -0
  6. package/dist/mcp/server/runtime/agent-sessions.js +40 -1
  7. package/dist/mcp/server/runtime/api.js +35 -13
  8. package/dist/mcp/server/runtime/identity/directory.js +9 -2
  9. package/dist/mcp/server/runtime/identity.js +2 -1
  10. package/dist/mcp/server/runtime/rooms.js +56 -0
  11. package/dist/mcp/server/runtime/supervisor-bridge.js +94 -0
  12. package/dist/mcp/server/runtime/worker-bearer.js +67 -0
  13. package/dist/mcp/server/runtime.js +1 -1
  14. package/dist/mcp/server/tools/agent-sessions.js +44 -3
  15. package/dist/mcp/server/tools/messages/message-lookup.js +75 -0
  16. package/dist/mcp/server/tools/messages/read-tool.js +1 -1
  17. package/dist/mcp/server/tools/messages/send-tool.js +2 -45
  18. package/dist/mcp/server/tools/messages/wait-tool.js +158 -88
  19. package/dist/mcp/server/tools/onboarding/device-auth-tools.js +10 -0
  20. package/dist/mcp/server/tools/onboarding/name-tool.js +5 -1
  21. package/dist/mcp/server/tools/onboarding/status-tool.js +17 -0
  22. package/dist/mcp/server/tools/rental/context-tools.js +9 -1
  23. package/dist/mcp/server/tools/rooms/inspection-tools.js +26 -10
  24. package/dist/mcp/server/tools/tasks/board-intent-tools.js +7 -2
  25. package/dist/mcp/server/tools/tasks/index.js +2 -0
  26. package/dist/mcp/server/tools/tasks/verdict-tools.js +51 -0
  27. package/dist/mcp/server.js +2 -0
  28. package/dist/mcp/sse-client.js +3 -3
  29. package/dist/shared/activation-routing.js +13 -4
  30. package/dist/shared/agent-presence.js +1 -0
  31. package/dist/shared/agent-session-bearer.js +28 -0
  32. package/dist/shared/board-manager-failover.js +16 -0
  33. package/dist/shared/room-agent-prompts.js +2 -2
  34. package/package.json +19 -14
@@ -2,7 +2,9 @@ import { z } from "zod";
2
2
  import { scheduleCodexRuntimeStreamBridgeBind, } from "../../codex-session.js";
3
3
  import { getManagedAgentProvider, toManagedAgentStartResponse, } from "../../managed-agent-providers.js";
4
4
  import { encodeRoomIdPath, looksLikeInviteCode, normalizeInviteCode } from "../../room-id.js";
5
- import { AGENT_INSTANCE_UUID, RepoRoomAuthRequiredError, apiCall, agentSessionCredentials, currentRoom, detectAgentIdeLabel, detectAgentRuntimeLabel, endStoredAgentSession, ensureAgentIdentity, getSessionLivenessRegistration, getAgentSessionRepoBranch, getStoredAgentSession, getTargetRoomId, ensureLocalWorkerAgentSession, isLocalRoomStorageEnabled, joinRoomIdentifier, resolveLocalRoomStorageIdentifiers, saveAgentSession, toPublicAgentSession, toPublicRoomState, toRepoRoomAuthRequiredResult, withAgentIdentity, } from "../runtime.js";
5
+ import { AGENT_INSTANCE_UUID, RepoRoomAuthRequiredError, apiCall, agentSessionCredentials, currentRoom, detectAgentIdeLabel, detectAgentRuntimeLabel, endStoredAgentSession, ensureAgentIdentity, getSessionLivenessRegistration, getAgentSessionRepoBranch, getStoredAgentSession, getTargetRoomId, ensureLocalWorkerAgentSession, isLocalRoomStorageEnabled, joinRoomIdentifier, resolveLocalRoomStorageIdentifiers, saveAgentSession, toPublicAgentSession, toPublicRoomState, toRepoRoomAuthRequiredResult, withAgentIdentity, resolveWorkerToolIdentity, } from "../runtime.js";
6
+ import { requireValidWorkerBearerRuntime, workerModeDisabledToolResult, } from "../runtime/worker-bearer.js";
7
+ import { bindSupervisedWorkerSession } from "../runtime/supervisor-bridge.js";
6
8
  export function registerAgentSessionTools(server) {
7
9
  // -- register_agent_session -------------------------------------------------
8
10
  server.tool("register_agent_session", "Register this MCP client as an explicit room agent session. Unregistered MCP traffic is treated as controller traffic and stays out of the connected-agent roster.", {
@@ -66,6 +68,28 @@ export function registerAgentSessionTools(server) {
66
68
  ],
67
69
  };
68
70
  }
71
+ const { cloudRoomId } = await resolveLocalRoomStorageIdentifiers(targetRoomId);
72
+ const apiRoomId = cloudRoomId || targetRoomId;
73
+ if (requireValidWorkerBearerRuntime().mode === "worker") {
74
+ // The supplied bearer is issued for an existing server-side agent
75
+ // session. Do not call the owner-only registration endpoint or write
76
+ // any session credential to local storage.
77
+ const { agentSession } = await resolveWorkerToolIdentity({ roomId: apiRoomId });
78
+ return {
79
+ content: [
80
+ {
81
+ type: "text",
82
+ text: JSON.stringify({
83
+ success: true,
84
+ worker_bearer_mode: true,
85
+ agent_session: toPublicAgentSession(agentSession),
86
+ agent_session_id: agentSession.session_id,
87
+ use_agent_session_id: "This local worker-bearer session marker may be passed to room tools. The supplied bearer remains the only server credential.",
88
+ }, null, 2),
89
+ },
90
+ ],
91
+ };
92
+ }
69
93
  const identity = await ensureAgentIdentity();
70
94
  if (!identity.canonical_key) {
71
95
  return {
@@ -80,8 +104,6 @@ export function registerAgentSessionTools(server) {
80
104
  ],
81
105
  };
82
106
  }
83
- const { cloudRoomId } = await resolveLocalRoomStorageIdentifiers(targetRoomId);
84
- const apiRoomId = cloudRoomId || targetRoomId;
85
107
  const created = await apiCall(`/rooms/${encodeRoomIdPath(apiRoomId)}/agent-sessions`, {
86
108
  method: "POST",
87
109
  body: JSON.stringify({
@@ -124,6 +146,7 @@ export function registerAgentSessionTools(server) {
124
146
  last_seen_at: typeof created.last_seen_at === "string" ? created.last_seen_at : new Date().toISOString(),
125
147
  ended_at: typeof created.ended_at === "string" ? created.ended_at : null,
126
148
  });
149
+ await bindSupervisedWorkerSession(session);
127
150
  scheduleCodexRuntimeStreamBridgeBind(session);
128
151
  return {
129
152
  content: [
@@ -258,6 +281,12 @@ export function registerAgentSessionTools(server) {
258
281
  .optional()
259
282
  .describe("Optional hard stop in minutes. Defaults to 0, which means run until stopped."),
260
283
  }, async ({ room, cwd, stop_phrase, max_minutes }) => {
284
+ const disabled = workerModeDisabledToolResult("Local Codex session orchestration");
285
+ if (disabled) {
286
+ return {
287
+ content: [{ type: "text", text: JSON.stringify(disabled, null, 2) }],
288
+ };
289
+ }
261
290
  const joinedVia = looksLikeInviteCode(room) ? "join_code" : "join_room";
262
291
  try {
263
292
  const joined = await joinRoomIdentifier(room, joinedVia);
@@ -305,6 +334,12 @@ export function registerAgentSessionTools(server) {
305
334
  .optional()
306
335
  .describe("Optional session id. Defaults to the current local Codex live session."),
307
336
  }, async ({ session_id }) => {
337
+ const disabled = workerModeDisabledToolResult("Local Codex session orchestration");
338
+ if (disabled) {
339
+ return {
340
+ content: [{ type: "text", text: JSON.stringify(disabled, null, 2) }],
341
+ };
342
+ }
308
343
  const provider = getManagedAgentProvider("codex");
309
344
  const status = await provider.inspectLocalSession(session_id, currentRoom?.room_id);
310
345
  if (!status) {
@@ -343,6 +378,12 @@ export function registerAgentSessionTools(server) {
343
378
  .optional()
344
379
  .describe("If true, also terminate the spawned codex app-server process when possible."),
345
380
  }, async ({ session_id, shutdown_server }) => {
381
+ const disabled = workerModeDisabledToolResult("Local Codex session orchestration");
382
+ if (disabled) {
383
+ return {
384
+ content: [{ type: "text", text: JSON.stringify(disabled, null, 2) }],
385
+ };
386
+ }
346
387
  const provider = getManagedAgentProvider("codex");
347
388
  const stopped = await provider.stopLocalSession({
348
389
  session_id,
@@ -0,0 +1,75 @@
1
+ import { encodeRoomIdPath } from "../../../room-id.js";
2
+ import { ApiError, appendIncludePromptOnly, getLocalChatMessages, isMissingRouteError, roomScopedApiCall, } from "../../runtime.js";
3
+ function isRecord(value) {
4
+ return Boolean(value && typeof value === "object");
5
+ }
6
+ export async function findLocalMessageById(roomId, messageId) {
7
+ let afterCursor;
8
+ for (;;) {
9
+ const result = await getLocalChatMessages(roomId, {
10
+ after: afterCursor,
11
+ include_prompt_only: true,
12
+ });
13
+ const messages = (result.messages ?? []).filter(isRecord);
14
+ const match = messages.find((message) => message.id === messageId);
15
+ if (match)
16
+ return match;
17
+ if (!result.has_more || messages.length === 0)
18
+ return null;
19
+ const lastMessage = messages[messages.length - 1];
20
+ afterCursor = typeof lastMessage?.id === "string" ? lastMessage.id : undefined;
21
+ if (!afterCursor)
22
+ return null;
23
+ }
24
+ }
25
+ export async function findRemoteMessageById(input) {
26
+ if (input.roomId) {
27
+ try {
28
+ const result = await roomScopedApiCall({
29
+ room_id: input.roomId,
30
+ room_path: (roomId) => appendIncludePromptOnly(`/rooms/${encodeRoomIdPath(roomId)}/messages/${encodeURIComponent(input.messageId)}`),
31
+ project_path: () => "",
32
+ // Context lookups must not advance the room session cursor.
33
+ preserve_session_cursor: true,
34
+ });
35
+ return isRecord(result.message) ? result.message : null;
36
+ }
37
+ catch (error) {
38
+ if (isMissingRouteError(error)) {
39
+ // Older API without the by-id route: fall back to scanning history.
40
+ }
41
+ else if (error instanceof ApiError && error.status === 404) {
42
+ return null;
43
+ }
44
+ else {
45
+ throw error;
46
+ }
47
+ }
48
+ }
49
+ return scanRemoteMessageHistoryById(input);
50
+ }
51
+ async function scanRemoteMessageHistoryById(input) {
52
+ let afterCursor;
53
+ for (;;) {
54
+ const query = new URLSearchParams();
55
+ if (afterCursor)
56
+ query.set("after", afterCursor);
57
+ const qs = query.toString();
58
+ const result = await roomScopedApiCall({
59
+ room_id: input.roomId,
60
+ project_id: input.projectId,
61
+ room_path: (roomId) => appendIncludePromptOnly(`/rooms/${encodeRoomIdPath(roomId)}/messages${qs ? `?${qs}` : ""}`),
62
+ project_path: (projectId) => appendIncludePromptOnly(`/projects/${encodeURIComponent(projectId)}/messages${qs ? `?${qs}` : ""}`),
63
+ });
64
+ const messages = (result.messages ?? []).filter(isRecord);
65
+ const match = messages.find((message) => message.id === input.messageId);
66
+ if (match)
67
+ return match;
68
+ if (!result.has_more || messages.length === 0)
69
+ return null;
70
+ const lastMessage = messages[messages.length - 1];
71
+ afterCursor = typeof lastMessage?.id === "string" ? lastMessage.id : undefined;
72
+ if (!afterCursor)
73
+ return null;
74
+ }
75
+ }
@@ -24,7 +24,7 @@ function withRecencyTelemetry(output, selection) {
24
24
  }
25
25
  // Fetch the most recent messages by paging BACKWARDS from the tail
26
26
  // (before=latest), so a limited read never walks the full room history.
27
- async function fetchRecentRemoteMessages(input) {
27
+ export async function fetchRecentRemoteMessages(input) {
28
28
  const collected = [];
29
29
  let beforeCursor = "latest";
30
30
  let truncated = false;
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { encodeRoomIdPath } from "../../../room-id.js";
3
- import { agentSessionCredentials, addLocalChatMessage, appendIncludePromptOnly, currentRoom, getFallbackProjectId, getLocalChatMessages, getRememberedRoomPresence, getTargetRoomId, isLocalRoomStorageEnabled, resolveLocalRoomStorageIdentifiers, resolveWorkerToolIdentity, roomScopedApiCall, syncRoomPresence, toPublicAgentIdentity, touchCurrentRoom, } from "../../runtime.js";
3
+ import { agentSessionCredentials, addLocalChatMessage, currentRoom, getFallbackProjectId, getRememberedRoomPresence, getTargetRoomId, isLocalRoomStorageEnabled, resolveLocalRoomStorageIdentifiers, resolveWorkerToolIdentity, roomScopedApiCall, syncRoomPresence, toPublicAgentIdentity, touchCurrentRoom, } from "../../runtime.js";
4
+ import { findLocalMessageById, findRemoteMessageById } from "./message-lookup.js";
4
5
  import { jsonToolResponse } from "./response.js";
5
6
  export function buildSendMessageRequestBody(input) {
6
7
  return {
@@ -22,50 +23,6 @@ function explicitThreadRootId(message) {
22
23
  }
23
24
  return null;
24
25
  }
25
- async function findLocalMessageById(roomId, messageId) {
26
- let afterCursor;
27
- for (;;) {
28
- const result = await getLocalChatMessages(roomId, {
29
- after: afterCursor,
30
- include_prompt_only: true,
31
- });
32
- const messages = (result.messages ?? []);
33
- const match = messages.find((message) => message.id === messageId);
34
- if (match)
35
- return match;
36
- if (!result.has_more || messages.length === 0)
37
- return null;
38
- const lastMessage = messages[messages.length - 1];
39
- afterCursor = typeof lastMessage?.id === "string" ? lastMessage.id : undefined;
40
- if (!afterCursor)
41
- return null;
42
- }
43
- }
44
- async function findRemoteMessageById(input) {
45
- let afterCursor;
46
- for (;;) {
47
- const query = new URLSearchParams();
48
- if (afterCursor)
49
- query.set("after", afterCursor);
50
- const qs = query.toString();
51
- const result = await roomScopedApiCall({
52
- room_id: input.roomId,
53
- project_id: input.projectId,
54
- room_path: (roomId) => appendIncludePromptOnly(`/rooms/${encodeRoomIdPath(roomId)}/messages${qs ? `?${qs}` : ""}`),
55
- project_path: (projectId) => appendIncludePromptOnly(`/projects/${encodeURIComponent(projectId)}/messages${qs ? `?${qs}` : ""}`),
56
- });
57
- const messages = result.messages ?? [];
58
- const match = messages.find((message) => message.id === input.messageId);
59
- if (match)
60
- return match;
61
- if (!result.has_more || messages.length === 0)
62
- return null;
63
- const lastMessage = messages[messages.length - 1];
64
- afterCursor = typeof lastMessage?.id === "string" ? lastMessage.id : undefined;
65
- if (!afterCursor)
66
- return null;
67
- }
68
- }
69
26
  async function findMessageById(input) {
70
27
  if (input.localRoomId && await isLocalRoomStorageEnabled(input.localRoomId)) {
71
28
  const { localRoomId } = await resolveLocalRoomStorageIdentifiers(input.localRoomId);
@@ -1,10 +1,30 @@
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, getLocalChatMessages, getLastMessageId, getRememberedRoomPresence, getTargetRoomId, identityFromAgentSession, isLocalRoomStorageEnabled, listLocalTasks, resolveLocalRoomStorageIdentifiers, resolveAgentSession, roomScopedApiCall, syncRoomPresence, toAgentReadableMessages, touchRoomSession, waitForLocalChatMessages, } from "../../runtime.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";
5
6
  import { attachAgentMessageActivations } from "../../../../shared/activation-routing.js";
7
+ import { findLocalMessageById, findRemoteMessageById } from "./message-lookup.js";
8
+ import { fetchRecentRemoteMessages } from "./read-tool.js";
6
9
  import { jsonToolResponse } from "./response.js";
7
10
  const DEFAULT_POLL_TIMEOUT_MS = 30000;
11
+ // When wait_for_messages is called WITHOUT an after_message_id cursor, it must
12
+ // catch up on only the most RECENT messages instead of replaying the entire
13
+ // room history (busy rooms archive millions of characters, which blows the
14
+ // tool's token budget). This bounds that no-cursor catch-up to a recent tail.
15
+ 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.
19
+ export function planWaitForMessagesFetch(input) {
20
+ if (input.effectiveAfterMessageId) {
21
+ return { mode: "after_cursor", after: input.effectiveAfterMessageId };
22
+ }
23
+ return {
24
+ mode: "catch_up_tail",
25
+ limit: input.catchupLimit ?? DEFAULT_WAIT_CATCHUP_LIMIT,
26
+ };
27
+ }
8
28
  const LOCAL_TASK_OWNER_STATUSES = new Set(["assigned", "in_progress", "blocked", "in_review"]);
9
29
  export function buildWaitForMessagesRequestOptions(input) {
10
30
  return {
@@ -88,6 +108,10 @@ function addActivationRoutingTelemetry(output, routing) {
88
108
  return output;
89
109
  }
90
110
  export function resolveWaitAgentSession(roomId, agentSessionId) {
111
+ if (requireValidWorkerBearerRuntime().mode === "worker" &&
112
+ (!agentSessionId?.trim() || agentSessionId === WORKER_BEARER_AGENT_SESSION_ID)) {
113
+ return null;
114
+ }
91
115
  if (!agentSessionId?.trim()) {
92
116
  return null;
93
117
  }
@@ -130,57 +154,53 @@ async function attachLocalActivationMetadata(roomId, messages, agentSession, opt
130
154
  session_kind: agentSession.session_kind,
131
155
  }, activationContext);
132
156
  }
133
- async function findLocalMessageById(roomId, targetMessageId) {
134
- let afterCursor;
135
- for (;;) {
136
- const result = await getLocalChatMessages(roomId, {
137
- after: afterCursor,
138
- include_prompt_only: true,
139
- });
140
- const messages = (result.messages ?? []).filter(isRecord);
141
- const match = messages.find((message) => message.id === targetMessageId);
142
- if (match)
143
- return match;
144
- if (!result.has_more || messages.length === 0)
145
- return null;
146
- const lastMessage = messages[messages.length - 1];
147
- afterCursor = messageId(lastMessage) ?? undefined;
148
- if (!afterCursor)
149
- return null;
150
- }
157
+ // Resolving an out-of-window parent costs a lookup (a by-id fetch, or a
158
+ // page-by-page history scan against older APIs), so resolved (and observed)
159
+ // messages are cached per process to keep repeat polls from re-resolving the
160
+ // same thread roots. Message ids are
161
+ // room-scoped, so the key includes the room scope. Message records are treated
162
+ // as immutable once posted; a bounded insertion-ordered map keeps memory flat
163
+ // for long-running workers.
164
+ const THREAD_CONTEXT_CACHE_MAX = 500;
165
+ const threadContextCache = new Map();
166
+ function threadContextCacheKey(scopeId, targetMessageId) {
167
+ return `${scopeId}:${targetMessageId}`;
151
168
  }
152
- async function findRemoteMessageById(input) {
153
- let afterCursor;
154
- for (;;) {
155
- const params = new URLSearchParams();
156
- if (afterCursor)
157
- params.set("after", afterCursor);
158
- const queryString = params.toString();
159
- const result = await roomScopedApiCall({
160
- room_id: input.roomId,
161
- project_id: input.projectId,
162
- room_path: (roomId) => appendIncludePromptOnly(`/rooms/${encodeRoomIdPath(roomId)}/messages${queryString ? `?${queryString}` : ""}`),
163
- project_path: (projectId) => appendIncludePromptOnly(`/projects/${encodeURIComponent(projectId)}/messages${queryString ? `?${queryString}` : ""}`),
164
- });
165
- const messages = (result.messages ?? []).filter(isRecord);
166
- const match = messages.find((message) => message.id === input.targetMessageId);
167
- if (match)
168
- return match;
169
- if (!result.has_more || messages.length === 0)
170
- return null;
171
- const lastMessage = messages[messages.length - 1];
172
- afterCursor = messageId(lastMessage) ?? undefined;
173
- if (!afterCursor)
174
- return null;
169
+ export function rememberThreadContextMessages(scopeId, records) {
170
+ for (const record of records) {
171
+ if (!isRecord(record))
172
+ continue;
173
+ const id = messageId(record);
174
+ if (!id)
175
+ continue;
176
+ const key = threadContextCacheKey(scopeId, id);
177
+ threadContextCache.delete(key);
178
+ threadContextCache.set(key, record);
179
+ }
180
+ while (threadContextCache.size > THREAD_CONTEXT_CACHE_MAX) {
181
+ const oldest = threadContextCache.keys().next().value;
182
+ if (oldest === undefined)
183
+ break;
184
+ threadContextCache.delete(oldest);
175
185
  }
176
186
  }
177
- async function collectThreadContextMessages(input) {
187
+ export function resetThreadContextCacheForTests() {
188
+ threadContextCache.clear();
189
+ }
190
+ export async function collectThreadContextMessages(input) {
178
191
  const records = input.messages.filter(isRecord);
179
192
  const knownIds = new Set(records.map(messageId).filter((id) => Boolean(id)));
180
193
  const seenIds = new Set(knownIds);
181
194
  const pendingIds = records
182
195
  .map(replyReferenceId)
183
196
  .filter((id) => Boolean(id && !knownIds.has(id)));
197
+ const scopeId = input.roomId ?? input.localRoomId ?? input.projectId ?? "";
198
+ rememberThreadContextMessages(scopeId, records);
199
+ if (pendingIds.length === 0) {
200
+ // Nothing quotes an out-of-window message (idle polls land here), so skip
201
+ // storage-mode resolution entirely.
202
+ return [];
203
+ }
184
204
  const contextMessages = [];
185
205
  const useLocalStorage = Boolean(input.localRoomId && await isLocalRoomStorageEnabled(input.localRoomId));
186
206
  const localIdentifiers = useLocalStorage
@@ -192,15 +212,20 @@ async function collectThreadContextMessages(input) {
192
212
  if (!nextId || seenIds.has(nextId))
193
213
  continue;
194
214
  seenIds.add(nextId);
195
- const message = useLocalStorage && input.localRoomId
196
- ? await findLocalMessageById(sqliteRoomId || input.localRoomId, nextId)
197
- : await findRemoteMessageById({
198
- roomId: input.roomId,
199
- projectId: input.projectId,
200
- targetMessageId: nextId,
201
- });
215
+ const cached = threadContextCache.get(threadContextCacheKey(scopeId, nextId));
216
+ const message = cached
217
+ ?? (useLocalStorage && input.localRoomId
218
+ ? await findLocalMessageById(sqliteRoomId || input.localRoomId, nextId)
219
+ : await findRemoteMessageById({
220
+ roomId: input.roomId,
221
+ projectId: input.projectId,
222
+ messageId: nextId,
223
+ }));
202
224
  if (!message)
203
225
  continue;
226
+ if (!cached) {
227
+ rememberThreadContextMessages(scopeId, [message]);
228
+ }
204
229
  contextMessages.push(message);
205
230
  const parentId = replyReferenceId(message);
206
231
  if (parentId && !seenIds.has(parentId)) {
@@ -215,7 +240,7 @@ export function registerWaitForMessagesTool(server) {
215
240
  after_message_id: z
216
241
  .string()
217
242
  .optional()
218
- .describe("Only return messages after this message ID (e.g. 'msg_3'). If omitted, returns all existing messages immediately."),
243
+ .describe(`Only return messages after this message ID (e.g. 'msg_3'). If omitted, returns just the most recent ${DEFAULT_WAIT_CATCHUP_LIMIT} messages (bounded recent tail) instead of the full room history, and advances the session cursor to the newest message.`),
219
244
  timeout: z
220
245
  .number()
221
246
  .optional()
@@ -239,10 +264,19 @@ export function registerWaitForMessagesTool(server) {
239
264
  const effectiveAfterMessageId = resolveEffectiveAfterMessageId({
240
265
  requestedAfterMessageId: after_message_id,
241
266
  });
242
- const existing = await getLocalChatMessages(effectiveLocalRoomId, {
243
- after: effectiveAfterMessageId,
244
- include_prompt_only: true,
245
- });
267
+ const fetchPlan = planWaitForMessagesFetch({ effectiveAfterMessageId });
268
+ // Without a cursor, page BACKWARDS from the tail (getLatest…) so we
269
+ // return the most-recent messages, not the oldest N; with a cursor we
270
+ // keep the unchanged forward "everything after the cursor" behavior.
271
+ const existing = fetchPlan.mode === "catch_up_tail"
272
+ ? await getLatestLocalChatMessages(effectiveLocalRoomId, {
273
+ limit: fetchPlan.limit,
274
+ include_prompt_only: true,
275
+ })
276
+ : await getLocalChatMessages(effectiveLocalRoomId, {
277
+ after: effectiveAfterMessageId,
278
+ include_prompt_only: true,
279
+ });
246
280
  const replayingExistingMessages = existing.messages.length > 0;
247
281
  const result = replayingExistingMessages
248
282
  ? existing
@@ -271,45 +305,81 @@ export function registerWaitForMessagesTool(server) {
271
305
  }
272
306
  await syncRoomPresence(targetRoomId ?? currentRoom?.room_id ?? null, identity, getRememberedRoomPresence(targetRoomId ?? currentRoom?.room_id ?? null, agentSession ? identityFromAgentSession(agentSession) : identity), agentSession);
273
307
  const clientTimeout = serverTimeout + (serverTimeout > 120_000 ? 120_000 : 5_000);
274
- const params = new URLSearchParams();
275
308
  const effectiveAfterMessageId = resolveEffectiveAfterMessageId({
276
309
  requestedAfterMessageId: after_message_id,
277
310
  });
278
- if (effectiveAfterMessageId)
279
- params.set("after", effectiveAfterMessageId);
280
- params.set("timeout", String(serverTimeout));
281
- const queryString = params.toString();
311
+ const fetchPlan = planWaitForMessagesFetch({ effectiveAfterMessageId });
282
312
  const deliveryHeaders = buildAgentDeliveryHeaders(agentSession);
283
- const firstResult = await roomScopedApiCall({
284
- room_id: targetRoomId,
285
- project_id: targetProjectId,
286
- room_path: (targetRoomId) => appendIncludePromptOnly(`/rooms/${encodeRoomIdPath(targetRoomId)}/messages/poll?${queryString}`),
287
- project_path: (targetProjectId) => appendIncludePromptOnly(`/projects/${encodeURIComponent(targetProjectId)}/messages/poll?${queryString}`),
288
- options: buildWaitForMessagesRequestOptions({
289
- deliveryHeaders,
290
- signal: AbortSignal.timeout(clientTimeout),
291
- }),
292
- });
293
- const allMessages = [...(firstResult.messages ?? [])];
294
- const roomIdFromResponse = firstResult.room_id || firstResult.project_id;
295
- if (firstResult.has_more && allMessages.length > 0) {
296
- let afterCursor = allMessages[allMessages.length - 1]?.id;
297
- while (afterCursor) {
298
- const pageParams = new URLSearchParams();
299
- pageParams.set("after", afterCursor);
300
- const qs = pageParams.toString();
301
- const page = await roomScopedApiCall(buildWaitForMessagesHistoryPageRequest({
302
- targetRoomId,
303
- targetProjectId,
304
- queryString: qs,
313
+ const allMessages = [];
314
+ let roomIdFromResponse;
315
+ // Long-poll the server (blocks up to serverTimeout for new messages),
316
+ // optionally seeded with a cursor. Shared by the cursor path and by the
317
+ // no-cursor empty-tail fallback so a quiet room still blocks instead of
318
+ // busy-spinning. When `after` is provided this also catches up any backlog
319
+ // after the cursor via forward pagination.
320
+ const longPollFromCursor = async (after) => {
321
+ const params = new URLSearchParams();
322
+ if (after)
323
+ params.set("after", after);
324
+ params.set("timeout", String(serverTimeout));
325
+ const queryString = params.toString();
326
+ const firstResult = await roomScopedApiCall({
327
+ room_id: targetRoomId,
328
+ project_id: targetProjectId,
329
+ room_path: (targetRoomId) => appendIncludePromptOnly(`/rooms/${encodeRoomIdPath(targetRoomId)}/messages/poll?${queryString}`),
330
+ project_path: (targetProjectId) => appendIncludePromptOnly(`/projects/${encodeURIComponent(targetProjectId)}/messages/poll?${queryString}`),
331
+ options: buildWaitForMessagesRequestOptions({
305
332
  deliveryHeaders,
306
- }));
307
- const msgs = page.messages ?? [];
308
- allMessages.push(...msgs);
309
- if (!page.has_more || msgs.length === 0)
310
- break;
311
- afterCursor = msgs[msgs.length - 1]?.id;
333
+ signal: AbortSignal.timeout(clientTimeout),
334
+ }),
335
+ });
336
+ allMessages.push(...(firstResult.messages ?? []));
337
+ 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
+ }
312
356
  }
357
+ };
358
+ if (fetchPlan.mode === "catch_up_tail") {
359
+ // No cursor: catch up on only the bounded recent tail. Mirror
360
+ // read_messages by paging BACKWARDS from the tail (before=latest) so we
361
+ // return the newest N and never walk the full room history. This also
362
+ // advances the session cursor to the newest message returned.
363
+ const recent = await fetchRecentRemoteMessages({
364
+ targetRoomId,
365
+ targetProjectId,
366
+ limit: fetchPlan.limit,
367
+ });
368
+ if (recent.messages.length > 0) {
369
+ allMessages.push(...recent.messages);
370
+ roomIdFromResponse = recent.roomIdFromResponse;
371
+ }
372
+ else {
373
+ // Empty tail => the room is effectively empty, so there is nothing to
374
+ // dump. Fall through to the long-poll (no cursor) so a worker looping
375
+ // in a quiet room blocks up to the timeout instead of busy-spinning,
376
+ // exactly as the local path does via waitForLocalChatMessages.
377
+ roomIdFromResponse = recent.roomIdFromResponse;
378
+ await longPollFromCursor();
379
+ }
380
+ }
381
+ else {
382
+ await longPollFromCursor(fetchPlan.after);
313
383
  }
314
384
  const routing = filterSilentActivationMessages(allMessages);
315
385
  const threadContext = await collectThreadContextMessages({
@@ -3,6 +3,7 @@ import { getRoomFromConfig } from "../../../config-reader.js";
3
3
  import { looksLikeInviteCode } from "../../../room-id.js";
4
4
  import { apiCall, clearAuthenticatedAccountCache, clearPendingDeviceAuth, clearStoredAuth, currentRoom, ensureAgentIdentity, getPendingDeviceAuth, joinRoomIdentifierWithoutImplicitGitRefCreate, setAuthenticatedAccountCache, setPendingDeviceAuth, setStoredAuth, toPublicAgentIdentity, withCanonicalRoomLink, } from "../../runtime.js";
5
5
  import { jsonTextResponse } from "./responses.js";
6
+ import { workerModeDisabledToolResult } from "../../runtime/worker-bearer.js";
6
7
  export function registerDeviceAuthTools(server) {
7
8
  registerStartDeviceAuthTool(server);
8
9
  registerPollDeviceAuthTool(server);
@@ -19,6 +20,9 @@ function registerStartDeviceAuthTool(server) {
19
20
  .optional()
20
21
  .describe("If true, replaces any existing pending device auth request."),
21
22
  }, async ({ room_id, force }) => {
23
+ const disabled = workerModeDisabledToolResult();
24
+ if (disabled)
25
+ return jsonTextResponse(disabled);
22
26
  const existing = getPendingDeviceAuth();
23
27
  if (existing && !force) {
24
28
  return jsonTextResponse({
@@ -60,6 +64,9 @@ function registerPollDeviceAuthTool(server) {
60
64
  .optional()
61
65
  .describe("If true, tries to join the room immediately after the auth succeeds."),
62
66
  }, async ({ request_id, room_id, auto_join }) => {
67
+ const disabled = workerModeDisabledToolResult();
68
+ if (disabled)
69
+ return jsonTextResponse(disabled);
63
70
  const pendingAuth = request_id
64
71
  ? getPendingDeviceAuth()?.request_id === request_id
65
72
  ? getPendingDeviceAuth()
@@ -137,6 +144,9 @@ function registerPollDeviceAuthTool(server) {
137
144
  }
138
145
  function registerClearSavedAuthTool(server) {
139
146
  server.tool("clear_saved_auth", "Clear any locally saved LetAgents auth token and pending device auth request.", {}, async () => {
147
+ const disabled = workerModeDisabledToolResult();
148
+ if (disabled)
149
+ return jsonTextResponse(disabled);
140
150
  clearPendingDeviceAuth();
141
151
  clearStoredAuth();
142
152
  clearAuthenticatedAccountCache();
@@ -3,6 +3,7 @@ import { buildAgentActorLabel, formatOwnerAttribution } from "../../../../shared
3
3
  import { normalizeAgentBaseName } from "../../../../shared/codenames.js";
4
4
  import { apiCall, currentAgentIdentity, currentAgentIdentityKey, detectAgentIdeLabel, getConversationIdentity, getLetagentsToken, resolveOwnerContext, setConversationIdentity, storeCurrentAgentIdentity, toPublicAgentIdentity, } from "../../runtime.js";
5
5
  import { jsonTextResponse } from "./responses.js";
6
+ import { workerModeDisabledToolResult } from "../../runtime/worker-bearer.js";
6
7
  export function registerSetAgentNameTool(server) {
7
8
  server.tool("set_agent_name", "Set or change the agent's display name. The agent will be known by this name in the room. Use this to pick a custom name instead of the auto-generated codename.", {
8
9
  name: z
@@ -15,6 +16,9 @@ export function registerSetAgentNameTool(server) {
15
16
  .optional()
16
17
  .describe("Optional conversation ID to scope this name change. When provided, only this conversation uses the new name; other conversations keep their own identity."),
17
18
  }, async ({ name: desiredName, conversation_id }) => {
19
+ const disabled = workerModeDisabledToolResult();
20
+ if (disabled)
21
+ return jsonTextResponse(disabled);
18
22
  const trimmedName = desiredName.trim();
19
23
  if (trimmedName.length < 2 || trimmedName.length > 64) {
20
24
  return jsonTextResponse({
@@ -22,7 +26,7 @@ export function registerSetAgentNameTool(server) {
22
26
  error: "Name must be between 2 and 64 characters.",
23
27
  });
24
28
  }
25
- const authAvailable = Boolean(getLetagentsToken());
29
+ const authAvailable = Boolean(await getLetagentsToken());
26
30
  if (!authAvailable) {
27
31
  return jsonTextResponse({
28
32
  success: false,