letagents 0.12.10 → 0.12.12

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 (46) 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/agent-sessions.js +15 -0
  5. package/dist/mcp/local-state/local-chat.js +1 -1
  6. package/dist/mcp/rental-tools/context.js +30 -0
  7. package/dist/mcp/server/register-tools.js +23 -12
  8. package/dist/mcp/server/runtime/agent-sessions.js +81 -2
  9. package/dist/mcp/server/runtime/api.js +63 -13
  10. package/dist/mcp/server/runtime/execution-profile.js +19 -0
  11. package/dist/mcp/server/runtime/identity/directory.js +9 -2
  12. package/dist/mcp/server/runtime/identity.js +2 -1
  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 +46 -0
  16. package/dist/mcp/server/runtime/rooms.js +70 -0
  17. package/dist/mcp/server/runtime/supervised-room-authority.js +8 -0
  18. package/dist/mcp/server/runtime/supervisor-bridge.js +731 -0
  19. package/dist/mcp/server/runtime/tool-surface-policy.js +26 -0
  20. package/dist/mcp/server/runtime/worker-bearer.js +101 -0
  21. package/dist/mcp/server/runtime-contract.js +27 -0
  22. package/dist/mcp/server/runtime.js +14 -3
  23. package/dist/mcp/server/supervised-tool-facade.js +105 -0
  24. package/dist/mcp/server/tools/agent-sessions.js +113 -5
  25. package/dist/mcp/server/tools/messages/index.js +3 -2
  26. package/dist/mcp/server/tools/messages/message-lookup.js +75 -0
  27. package/dist/mcp/server/tools/messages/read-tool.js +1 -1
  28. package/dist/mcp/server/tools/messages/send-tool.js +5 -45
  29. package/dist/mcp/server/tools/messages/wait-tool.js +212 -91
  30. package/dist/mcp/server/tools/onboarding/device-auth-tools.js +10 -0
  31. package/dist/mcp/server/tools/onboarding/name-tool.js +5 -1
  32. package/dist/mcp/server/tools/onboarding/status-tool.js +18 -0
  33. package/dist/mcp/server/tools/rental/context-tools.js +9 -1
  34. package/dist/mcp/server/tools/rooms/inspection-tools.js +47 -18
  35. package/dist/mcp/server/tools/supervised-room-turn.js +42 -0
  36. package/dist/mcp/server/tools/tasks/board-intent-tools.js +7 -2
  37. package/dist/mcp/server/tools/tasks/index.js +2 -0
  38. package/dist/mcp/server/tools/tasks/verdict-tools.js +51 -0
  39. package/dist/mcp/server.js +16 -7
  40. package/dist/mcp/sse-client.js +3 -3
  41. package/dist/shared/activation-routing.js +57 -4
  42. package/dist/shared/agent-presence.js +1 -0
  43. package/dist/shared/agent-session-bearer.js +28 -0
  44. package/dist/shared/board-manager-failover.js +16 -0
  45. package/dist/shared/room-agent-prompts.js +2 -2
  46. package/package.json +24 -15
@@ -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);
@@ -155,6 +112,9 @@ async function sendMessageFromTool(input) {
155
112
  });
156
113
  touchCurrentRoom(typeof message.id === "string" ? message.id : undefined);
157
114
  await syncRoomPresence(targetRoomId ?? currentRoom?.room_id ?? null, identity, getRememberedRoomPresence(targetRoomId ?? currentRoom?.room_id ?? null, identity), agentSession);
115
+ // The `replied` receipt transition is server-owned: message creation marks
116
+ // the publisher's receipt on the reply target atomically with the reply
117
+ // itself, for MCP workers and supervised daemon publications alike.
158
118
  return jsonToolResponse({
159
119
  ...message,
160
120
  agent_identity: toPublicAgentIdentity(identity),
@@ -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 { 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";
5
+ import { requireValidWorkerBearerRuntime, supervisedBoundedDeliveryDisabledToolResult, } 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)) {
@@ -210,12 +235,12 @@ async function collectThreadContextMessages(input) {
210
235
  return contextMessages;
211
236
  }
212
237
  export function registerWaitForMessagesTool(server) {
213
- 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).", {
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'. 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).", {
214
239
  room_id: z.string().optional().describe("Canonical room ID. Defaults to the current room."),
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()
@@ -225,12 +250,32 @@ export function registerWaitForMessagesTool(server) {
225
250
  .optional()
226
251
  .describe("Registered agent session to use. Without this, the MCP transport is treated as controller traffic and is hidden from connected-agent activity."),
227
252
  }, async ({ room_id, after_message_id, timeout, agent_session_id }) => {
253
+ // This guard intentionally runs before room resolution, identity setup,
254
+ // local SQLite reads, presence writes, or HTTP traffic. In supervised
255
+ // bounded-turn mode the daemon is the sole inbox owner.
256
+ const boundedDeliveryDisabled = supervisedBoundedDeliveryDisabledToolResult();
257
+ if (boundedDeliveryDisabled) {
258
+ return jsonToolResponse(boundedDeliveryDisabled);
259
+ }
228
260
  const targetRoomId = getTargetRoomId(room_id);
229
261
  const targetProjectId = getFallbackProjectId();
230
262
  const localRoomId = targetRoomId ?? currentRoom?.room_id ?? targetProjectId;
231
263
  const identity = await ensureAgentIdentity();
232
264
  const sessionRoomId = targetRoomId ?? currentRoom?.room_id ?? localRoomId ?? null;
233
265
  const agentSession = resolveWaitAgentSession(sessionRoomId, agent_session_id);
266
+ if (agentSession) {
267
+ // Registration (or a successor generation) must bind strictly once.
268
+ // Later waits use a read-only exact verification capped at 250ms, so a
269
+ // wedged daemon cannot consume the room-poll budget and an old worker
270
+ // cannot read after a successor generation takes ownership.
271
+ await bindSupervisedWorkerSession(agentSession, process.env, { allowConfirmedFastPath: true });
272
+ // A cursor is acknowledged only when the worker explicitly uses it to
273
+ // request the next page. Persisting a cursor from the response we are
274
+ // still constructing could skip a message if serialization, presence,
275
+ // or the provider turn fails afterward.
276
+ if (after_message_id)
277
+ scheduleSupervisedWorkerCursorCheckpoint(agentSession, after_message_id);
278
+ }
234
279
  const maxPollMs = getPollTimeoutCapMs();
235
280
  const serverTimeout = Math.min(Math.max(timeout || DEFAULT_POLL_TIMEOUT_MS, 1000), maxPollMs);
236
281
  if (localRoomId && await isLocalRoomStorageEnabled(localRoomId)) {
@@ -239,10 +284,19 @@ export function registerWaitForMessagesTool(server) {
239
284
  const effectiveAfterMessageId = resolveEffectiveAfterMessageId({
240
285
  requestedAfterMessageId: after_message_id,
241
286
  });
242
- const existing = await getLocalChatMessages(effectiveLocalRoomId, {
243
- after: effectiveAfterMessageId,
244
- include_prompt_only: true,
245
- });
287
+ const fetchPlan = planWaitForMessagesFetch({ effectiveAfterMessageId });
288
+ // Without a cursor, page BACKWARDS from the tail (getLatest…) so we
289
+ // return the most-recent messages, not the oldest N; with a cursor we
290
+ // keep the unchanged forward "everything after the cursor" behavior.
291
+ const existing = fetchPlan.mode === "catch_up_tail"
292
+ ? await getLatestLocalChatMessages(effectiveLocalRoomId, {
293
+ limit: fetchPlan.limit,
294
+ include_prompt_only: true,
295
+ })
296
+ : await getLocalChatMessages(effectiveLocalRoomId, {
297
+ after: effectiveAfterMessageId,
298
+ include_prompt_only: true,
299
+ });
246
300
  const replayingExistingMessages = existing.messages.length > 0;
247
301
  const result = replayingExistingMessages
248
302
  ? existing
@@ -255,7 +309,8 @@ export function registerWaitForMessagesTool(server) {
255
309
  includeTaskOwnerLeases: !replayingExistingMessages,
256
310
  });
257
311
  const routing = filterSilentActivationMessages(messages);
258
- touchRoomSession(effectiveLocalRoomId, routing.last_observed_message_id ?? getLastMessageId(result));
312
+ const observedCursor = routing.last_observed_message_id ?? getLastMessageId(result);
313
+ touchRoomSession(effectiveLocalRoomId, observedCursor);
259
314
  const threadContext = await collectThreadContextMessages({
260
315
  messages: routing.messages,
261
316
  localRoomId: effectiveLocalRoomId,
@@ -271,46 +326,82 @@ export function registerWaitForMessagesTool(server) {
271
326
  }
272
327
  await syncRoomPresence(targetRoomId ?? currentRoom?.room_id ?? null, identity, getRememberedRoomPresence(targetRoomId ?? currentRoom?.room_id ?? null, agentSession ? identityFromAgentSession(agentSession) : identity), agentSession);
273
328
  const clientTimeout = serverTimeout + (serverTimeout > 120_000 ? 120_000 : 5_000);
274
- const params = new URLSearchParams();
275
329
  const effectiveAfterMessageId = resolveEffectiveAfterMessageId({
276
330
  requestedAfterMessageId: after_message_id,
277
331
  });
278
- if (effectiveAfterMessageId)
279
- params.set("after", effectiveAfterMessageId);
280
- params.set("timeout", String(serverTimeout));
281
- const queryString = params.toString();
332
+ const fetchPlan = planWaitForMessagesFetch({ effectiveAfterMessageId });
282
333
  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,
334
+ const allMessages = [];
335
+ let roomIdFromResponse;
336
+ // Long-poll the server (blocks up to serverTimeout for new messages),
337
+ // optionally seeded with a cursor. Shared by the cursor path and by the
338
+ // no-cursor empty-tail fallback so a quiet room still blocks instead of
339
+ // busy-spinning. When `after` is provided this also catches up any backlog
340
+ // after the cursor via forward pagination.
341
+ const longPollFromCursor = async (after) => {
342
+ const params = new URLSearchParams();
343
+ if (after)
344
+ params.set("after", after);
345
+ params.set("timeout", String(serverTimeout));
346
+ const queryString = params.toString();
347
+ const firstResult = await roomScopedApiCall({
348
+ room_id: targetRoomId,
349
+ project_id: targetProjectId,
350
+ room_path: (targetRoomId) => appendIncludePromptOnly(`/rooms/${encodeRoomIdPath(targetRoomId)}/messages/poll?${queryString}`),
351
+ project_path: (targetProjectId) => appendIncludePromptOnly(`/projects/${encodeURIComponent(targetProjectId)}/messages/poll?${queryString}`),
352
+ options: buildWaitForMessagesRequestOptions({
305
353
  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;
354
+ signal: AbortSignal.timeout(clientTimeout),
355
+ }),
356
+ });
357
+ allMessages.push(...(firstResult.messages ?? []));
358
+ 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
+ }
378
+ };
379
+ if (fetchPlan.mode === "catch_up_tail") {
380
+ // No cursor: catch up on only the bounded recent tail. Mirror
381
+ // read_messages by paging BACKWARDS from the tail (before=latest) so we
382
+ // return the newest N and never walk the full room history. This also
383
+ // advances the session cursor to the newest message returned.
384
+ const recent = await fetchRecentRemoteMessages({
385
+ targetRoomId,
386
+ targetProjectId,
387
+ limit: fetchPlan.limit,
388
+ });
389
+ if (recent.messages.length > 0) {
390
+ allMessages.push(...recent.messages);
391
+ roomIdFromResponse = recent.roomIdFromResponse;
392
+ }
393
+ else {
394
+ // Empty tail => the room is effectively empty, so there is nothing to
395
+ // dump. Fall through to the long-poll (no cursor) so a worker looping
396
+ // in a quiet room blocks up to the timeout instead of busy-spinning,
397
+ // exactly as the local path does via waitForLocalChatMessages.
398
+ roomIdFromResponse = recent.roomIdFromResponse;
399
+ await longPollFromCursor();
312
400
  }
313
401
  }
402
+ else {
403
+ await longPollFromCursor(fetchPlan.after);
404
+ }
314
405
  const routing = filterSilentActivationMessages(allMessages);
315
406
  const threadContext = await collectThreadContextMessages({
316
407
  messages: routing.messages,
@@ -325,7 +416,37 @@ export function registerWaitForMessagesTool(server) {
325
416
  output[targetRoomId ? "room_id" : "project_id"] = roomIdFromResponse;
326
417
  }
327
418
  if (targetRoomId) {
328
- touchRoomSession(targetRoomId, routing.last_observed_message_id ?? getLastMessageId(output));
419
+ const observedCursor = routing.last_observed_message_id ?? getLastMessageId(output);
420
+ touchRoomSession(targetRoomId, observedCursor);
421
+ if (allMessages.length > 0 && agentSession) {
422
+ const firstMsg = allMessages[0];
423
+ const lastMsg = allMessages[allMessages.length - 1];
424
+ if (typeof firstMsg?.id === "string" && typeof lastMsg?.id === "string") {
425
+ try {
426
+ await roomScopedApiCall({
427
+ room_id: targetRoomId,
428
+ project_id: targetProjectId,
429
+ room_path: (r) => `/rooms/${encodeRoomIdPath(r)}/agents/self/observation`,
430
+ project_path: (p) => `/projects/${encodeURIComponent(p)}/agents/self/observation`,
431
+ options: {
432
+ method: "PUT",
433
+ body: JSON.stringify({
434
+ first_message_id: firstMsg.id,
435
+ last_message_id: lastMsg.id,
436
+ ...agentSessionCredentials(agentSession),
437
+ }),
438
+ },
439
+ });
440
+ }
441
+ catch {
442
+ // Non-blocking telemetry
443
+ }
444
+ }
445
+ }
446
+ // Delivery alone is observation evidence (the span above), never a
447
+ // "responding" receipt: an agent that ignores an activation must not
448
+ // present as responding. Receipts advance only on real transitions —
449
+ // send-tool marks "replied" when the agent actually publishes a reply.
329
450
  }
330
451
  await syncRoomPresence(targetRoomId ?? currentRoom?.room_id ?? null, identity, getRememberedRoomPresence(targetRoomId ?? currentRoom?.room_id ?? null, agentSession ? identityFromAgentSession(agentSession) : identity), agentSession);
331
452
  return jsonToolResponse(output);
@@ -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,
@@ -4,6 +4,7 @@ import { buildActiveGitRoomContext, getGitCurrentBranch, getGitDefaultBranch, ge
4
4
  import { resolveGitRoot } from "../../repo-context.js";
5
5
  import { API_URL, currentAgentIdentity, currentAgentIdentityKey, currentRoom, getLocalStatePath, getPendingDeviceAuth, getStoredAgentIdentity, getStoredAuth, getStoredCurrentRoom, toPublicAgentIdentity, toPublicRoomState, toPublicStoredRoomSession, } from "../../runtime.js";
6
6
  import { jsonTextResponse } from "./responses.js";
7
+ import { getWorkerBearerRuntime } from "../../runtime/worker-bearer.js";
7
8
  export async function checkOnboardingApiHealth(apiUrl = API_URL, fetchImpl = globalThis.fetch, timeoutMs = 1500) {
8
9
  const url = `${apiUrl.replace(/\/+$/, "")}/api/health`;
9
10
  const controller = new AbortController();
@@ -51,6 +52,23 @@ export function registerGetOnboardingStatusTool(server) {
51
52
  .optional()
52
53
  .describe("Working directory to inspect for repo context. Defaults to the current process directory."),
53
54
  }, async ({ cwd }) => {
55
+ const workerRuntime = getWorkerBearerRuntime();
56
+ if (workerRuntime.mode === "invalid") {
57
+ return jsonTextResponse({ success: false, error: "worker_bearer_configuration_invalid", message: workerRuntime.error });
58
+ }
59
+ if (workerRuntime.mode === "worker" || workerRuntime.mode === "supervised") {
60
+ return jsonTextResponse({
61
+ api_url: API_URL,
62
+ worker_bearer_mode: workerRuntime.mode === "worker",
63
+ supervised_bounded_mode: workerRuntime.mode === "supervised",
64
+ authenticated: true,
65
+ auth_source: workerRuntime.mode === "worker" ? "worker_bearer" : "daemon_supervised",
66
+ account: null,
67
+ pending_device_auth: null,
68
+ next_step: "join_room",
69
+ note: "Owner-auth onboarding and saved-auth state are disabled in worker credential mode.",
70
+ });
71
+ }
54
72
  const workingDir = cwd || process.cwd();
55
73
  const repoRoot = resolveGitRoot(workingDir);
56
74
  const configRoom = getRoomFromConfig(workingDir);
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { rentalReadFile, rentalSearch } from "../../../rental-tools.js";
2
+ import { rentalReadFile, rentalRequestContext, rentalSearch, } from "../../../rental-tools.js";
3
3
  import { rentalTextResponse } from "./response.js";
4
4
  export function registerRentalContextTools({ server, deps, }) {
5
5
  server.tool("rental_read_file", "Read one repo-relative file from the scoped rental workspace through the Context Broker. The server applies the Secret Firewall, records an Exposure Ledger entry, and returns redacted content when needed. This is the only approved file-read path for provider agents in scoped mode.", {
@@ -32,4 +32,12 @@ export function registerRentalContextTools({ server, deps, }) {
32
32
  max_results,
33
33
  case_sensitive,
34
34
  })));
35
+ server.tool("rental_request_context", "Request renter approval to access a file outside the approved rental scope. Use this when rental_read_file returns file_not_found for a path you believe exists in the repo. Creates a pending context access request the renter can approve or deny; once approved, the file is materialized into the workspace and becomes readable via rental_read_file. Repeat calls for the same path return the existing pending request, and re-asking for an already-approved path retries delivery into the workspace (useful if the workspace was not ready at approval time).", {
36
+ session_id: z.string().describe("Rental session id the request belongs to."),
37
+ path: z.string().describe("Repo-relative path you need access to."),
38
+ reason: z
39
+ .string()
40
+ .optional()
41
+ .describe("Short justification shown to the renter (max 500 chars)."),
42
+ }, async ({ session_id, path, reason }) => rentalTextResponse(await rentalRequestContext(deps, { session_id, path, reason })));
35
43
  }