letagents 0.12.13 → 0.12.15

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 (42) hide show
  1. package/README.md +32 -0
  2. package/dist/mcp/local-state/agent-sessions.js +18 -3
  3. package/dist/mcp/local-state/local-chat.js +11 -27
  4. package/dist/mcp/local-state/storage.js +5 -1
  5. package/dist/mcp/server/register-tools.js +4 -1
  6. package/dist/mcp/server/runtime/agent-sessions.js +2 -2
  7. package/dist/mcp/server/runtime/api.js +5 -1
  8. package/dist/mcp/server/runtime/execution-profile.js +1 -0
  9. package/dist/mcp/server/runtime/identity.js +5 -0
  10. package/dist/mcp/server/runtime/presence.js +3 -3
  11. package/dist/mcp/server/runtime/room-api.js +5 -4
  12. package/dist/mcp/server/runtime/room-state.js +6 -6
  13. package/dist/mcp/server/runtime/rooms.js +3 -3
  14. package/dist/mcp/server/runtime/supervisor-bridge.js +80 -3
  15. package/dist/mcp/server/runtime/tool-surface-policy.js +7 -0
  16. package/dist/mcp/server/runtime/worker-bearer.js +12 -1
  17. package/dist/mcp/server/runtime/worker-handles.js +190 -0
  18. package/dist/mcp/server/runtime-contract.js +1 -0
  19. package/dist/mcp/server/runtime.js +3 -3
  20. package/dist/mcp/server/supervised-tool-facade.js +74 -2
  21. package/dist/mcp/server/tools/agent-sessions.js +22 -5
  22. package/dist/mcp/server/tools/messages/read-tool.js +6 -2
  23. package/dist/mcp/server/tools/messages/wait-tool.js +22 -10
  24. package/dist/mcp/server/tools/onboarding/name-tool.js +5 -2
  25. package/dist/mcp/server/worker-tool-facade.js +38 -0
  26. package/dist/mcp/worker-call-context.js +39 -0
  27. package/dist/shared/activation-routing.js +21 -4
  28. package/dist/shared/mcp-worker.js +7 -0
  29. package/dist/shared/room-agent-prompts.js +2 -2
  30. package/package.json +3 -3
  31. package/shared/execution-approval-projection.d.mts +32 -0
  32. package/shared/execution-approval-projection.mjs +107 -0
  33. package/shared/execution-approval-publication-item.d.mts +20 -0
  34. package/shared/execution-approval-publication-item.mjs +61 -0
  35. package/shared/execution-approval-publication.d.mts +53 -0
  36. package/shared/execution-approval-publication.mjs +136 -0
  37. package/shared/execution-delegation-decision.d.mts +37 -0
  38. package/shared/execution-delegation-decision.mjs +73 -0
  39. package/shared/room-agent-work.d.mts +31 -0
  40. package/shared/room-agent-work.mjs +44 -0
  41. package/shared/room-resource-invalidation.d.mts +38 -0
  42. package/shared/room-resource-invalidation.mjs +50 -0
package/README.md CHANGED
@@ -84,6 +84,36 @@ That local state stores:
84
84
  - the LetAgents token obtained from GitHub Device Flow
85
85
  - any pending device auth request so it can be resumed
86
86
  - the last room session and heartbeat metadata for reconnects
87
+ - durable MCP worker handles and their private connection credentials
88
+
89
+ Each independent chat registers once with its own random `registration_key`:
90
+
91
+ ```text
92
+ register_agent_session(room_id="room_example", registration_key="<random UUID for this chat>", display_name="Juniper")
93
+ → worker_id="worker_..."
94
+ send_message(worker_id="worker_...", room_id="room_example", text="Working on the task")
95
+ ```
96
+
97
+ Keep that `worker_id` with the chat. After the MCP process restarts, reconnect
98
+ with `register_agent_session(worker_id="worker_...", room_id="room_example")`.
99
+ The worker keeps its routing identity, assigned room name, and session row;
100
+ its private connection credential rotates and the previous connection stops
101
+ working. Retry an uncertain first registration with the same `registration_key`.
102
+ Never share a key between chats or derive it from a name, room, or repository.
103
+
104
+ Room tools accept the handle so a shared MCP process can serve multiple chats
105
+ without selecting a global current worker. Pass `room_id` when a worker belongs
106
+ to multiple rooms. `disconnect_agent_session(worker_id="worker_...", room_id="...")`
107
+ ends its connection; the same handle can reconnect later. Distinct workers may
108
+ receive different name suffixes, but reconnecting does not allocate a new name.
109
+ Choose the name on first registration; `set_agent_name` changes only the legacy
110
+ process identity. Hosted rooms require an API version supporting worker handles.
111
+
112
+ The handle is a reference, not a credential. Keep the local state file private
113
+ and retain it across restarts. If the handle or its saved credentials are lost,
114
+ restore them explicitly; MCP never guesses another chat's identity. Existing
115
+ integrations using `agent_session_id` and supervisor-managed workers keep their
116
+ existing registration flow. The MCP server still runs locally through `npx`.
87
117
 
88
118
  ## MCP Tools
89
119
 
@@ -93,6 +123,8 @@ That local state stores:
93
123
  | `join_project` | Join a project using a join code |
94
124
  | `join_room` | Join or create a named room |
95
125
  | `get_current_room` | Show current room and how it was joined |
126
+ | `register_agent_session` | Create a chat's durable worker handle or explicitly reconnect it |
127
+ | `disconnect_agent_session` | End a worker connection while retaining its handle for reconnects |
96
128
  | `send_message` | Send a top-level message, or pass `thread_parent_id` to keep a reply in a thread |
97
129
  | `send_thread_message` | Reply inside an existing message thread without polluting the main room |
98
130
  | `read_messages` | Read all messages from the current room or a specific `room_id` |
@@ -1,10 +1,21 @@
1
- import { readLocalState, readLocalStateSnapshot, updateLocalState } from "./storage.js";
1
+ import { getLocalStatePath, readLocalState, readLocalStateSnapshot, updateLocalState } from "./storage.js";
2
+ import { isMcpWorkerId } from "../../shared/mcp-worker.js";
3
+ import { pinnedWorkerConnection } from "../worker-call-context.js";
2
4
  export function getStoredAgentSession(sessionId) {
3
5
  if (!sessionId) {
4
6
  return null;
5
7
  }
6
8
  const state = readLocalState();
7
- return state.agent_sessions?.[sessionId] ?? null;
9
+ const stored = state.agent_sessions?.[sessionId] ?? null;
10
+ if (stored && isMcpWorkerId(stored.agent_instance_id)) {
11
+ // A replaced process must never borrow its successor's credential from disk.
12
+ const pinned = pinnedWorkerConnection(getLocalStatePath(), sessionId);
13
+ if (!pinned || stored.session_token !== pinned.session_token) {
14
+ throw new Error("This worker connection was replaced. Reconnect explicitly with its worker_id.");
15
+ }
16
+ return { ...pinned, ended_at: stored.ended_at };
17
+ }
18
+ return stored;
8
19
  }
9
20
  export function getCurrentAgentSession(roomId) {
10
21
  return getCurrentAgentSessionSnapshot(roomId).session;
@@ -111,13 +122,17 @@ export function replaceLocalWorkerAgentSession(session) {
111
122
  });
112
123
  return session;
113
124
  }
114
- export function endStoredAgentSession(sessionId, endedAt = new Date().toISOString()) {
125
+ export function endStoredAgentSession(sessionId, endedAt = new Date().toISOString(), expectedConnectionToken) {
115
126
  let endedSession = null;
116
127
  updateLocalState((state) => {
117
128
  const session = state.agent_sessions?.[sessionId];
118
129
  if (!session) {
119
130
  return state;
120
131
  }
132
+ if (isMcpWorkerId(session.agent_instance_id)
133
+ && expectedConnectionToken !== session.session_token) {
134
+ return state;
135
+ }
121
136
  endedSession = {
122
137
  ...session,
123
138
  ended_at: endedAt,
@@ -2,6 +2,7 @@ import { createRequire } from "node:module";
2
2
  import { mkdir, readFile } from "node:fs/promises";
3
3
  import { homedir } from "node:os";
4
4
  import { dirname, join } from "node:path";
5
+ import { withWorkerStateFence } from "../worker-call-context.js";
5
6
  import { ensureLocalThreadRoutingProjectionSchemaAsync, getLocalThreadRoutingAgentKeysForRoots, projectLocalThreadRoutingMessage, runLocalSqliteWriteTransactionAsync, scheduleLocalThreadRoutingBackfill, } from "../../../shared/sqlite-thread-routing.mjs";
6
7
  import { MESSAGE_SENDER_MAX_CODE_POINTS, MESSAGE_SENDER_MAX_UTF8_BYTES, POSTGRES_INTEGER_MAX, isMessageSenderWithinBounds, parseAccountAgentRoutingEnvelope, parsePositivePgIntegerScopedId, } from "../../../shared/message-contracts.mjs";
7
8
  const localImportedRoutingAuthority = Symbol("localImportedRoutingAuthority");
@@ -367,17 +368,6 @@ async function hydrateRows(database, rows) {
367
368
  }
368
369
  return rows.map((row) => toMessage(row, row.reply_to_number ? replies.get(row.reply_to_number) ?? null : null, attachmentsByMessageNumber.get(row.number) || []));
369
370
  }
370
- function beginImmediate(database) {
371
- database.exec("BEGIN IMMEDIATE");
372
- }
373
- function rollback(database) {
374
- try {
375
- database.exec("ROLLBACK");
376
- }
377
- catch {
378
- // The transaction may already be closed by SQLite after an error.
379
- }
380
- }
381
371
  function allocateLocalMessageNumber(database, roomId) {
382
372
  database
383
373
  .prepare(`
@@ -537,7 +527,7 @@ export async function addLocalChatMessage(roomId, input) {
537
527
  }
538
528
  }
539
529
  const timestamp = new Date().toISOString();
540
- const row = await runLocalSqliteWriteTransactionAsync(database, () => {
530
+ const row = await runLocalSqliteWriteTransactionAsync(database, () => withWorkerStateFence(() => {
541
531
  const number = allocateLocalMessageNumber(database, trimmedRoomId);
542
532
  const insertedRow = {
543
533
  room_id: trimmedRoomId,
@@ -570,7 +560,7 @@ export async function addLocalChatMessage(roomId, input) {
570
560
  .run(insertedRow.room_id, insertedRow.number, insertedRow.reply_to_number, insertedRow.thread_root_number, insertedRow.sender, insertedRow.text, insertedRow.agent_prompt_kind, insertedRow.source, insertedRow.publisher_agent_key, insertedRow.publisher_agent_session_id, insertedRow.timestamp);
571
561
  projectLocalThreadRoutingMessage(database, insertedRow);
572
562
  return insertedRow;
573
- });
563
+ }));
574
564
  return {
575
565
  room_id: trimmedRoomId,
576
566
  ...toMessage(row, replyTarget),
@@ -731,8 +721,7 @@ export async function addLocalTask(roomId, input) {
731
721
  const database = await getDb();
732
722
  const now = new Date().toISOString();
733
723
  let taskId = "";
734
- beginImmediate(database);
735
- try {
724
+ await runLocalSqliteWriteTransactionAsync(database, () => withWorkerStateFence(() => {
736
725
  taskId = allocateLocalTaskId(database, trimmedRoomId);
737
726
  database
738
727
  .prepare(`
@@ -745,12 +734,7 @@ export async function addLocalTask(roomId, input) {
745
734
  VALUES (?, ?, ?, ?, 'proposed', NULL, NULL, NULL, NULL, ?, NULL, NULL, NULL, NULL, ?, NULL, 1, ?, ?)
746
735
  `)
747
736
  .run(trimmedRoomId, taskId, title, input.description?.trim() || null, input.created_by || "agent", `local-task:${trimmedRoomId}:${taskId}`, now, now);
748
- database.exec("COMMIT");
749
- }
750
- catch (error) {
751
- rollback(database);
752
- throw error;
753
- }
737
+ }));
754
738
  const task = await getLocalTask(trimmedRoomId, taskId);
755
739
  if (!task)
756
740
  throw new Error("Local task could not be created.");
@@ -856,7 +840,7 @@ export async function updateLocalTask(roomId, taskId, patch) {
856
840
  : JSON.stringify(Array.isArray(patch.workflow_artifacts) ? patch.workflow_artifacts : []);
857
841
  const now = new Date().toISOString();
858
842
  const database = await getDb();
859
- database
843
+ await runLocalSqliteWriteTransactionAsync(database, () => withWorkerStateFence(() => database
860
844
  .prepare(`
861
845
  UPDATE local_tasks
862
846
  SET status = ?,
@@ -870,7 +854,7 @@ export async function updateLocalTask(roomId, taskId, patch) {
870
854
  updated_at = ?
871
855
  WHERE room_id = ? AND task_id = ?
872
856
  `)
873
- .run(nextStatus, patch.assignee === undefined ? current.assignee : patch.assignee || null, assigneeAgentKey, assigneeAgentInstanceId, assigneeAgentSessionId, patch.pr_url === undefined ? current.pr_url : patch.pr_url || null, workflowArtifacts, now, roomId, taskId);
857
+ .run(nextStatus, patch.assignee === undefined ? current.assignee : patch.assignee || null, assigneeAgentKey, assigneeAgentInstanceId, assigneeAgentSessionId, patch.pr_url === undefined ? current.pr_url : patch.pr_url || null, workflowArtifacts, now, roomId, taskId)));
874
858
  const updated = await getLocalTask(roomId, taskId);
875
859
  if (!updated)
876
860
  throw new Error("Task not found.");
@@ -895,7 +879,7 @@ export async function claimLocalTaskReviewLease(roomId, taskId, input) {
895
879
  const database = await getDb();
896
880
  const now = new Date().toISOString();
897
881
  const leaseId = currentReviewLease?.id || `local_review_${Date.now()}_${Math.random().toString(36).slice(2)}`;
898
- database
882
+ await runLocalSqliteWriteTransactionAsync(database, () => withWorkerStateFence(() => database
899
883
  .prepare(`
900
884
  UPDATE local_tasks
901
885
  SET review_lease_id = ?,
@@ -906,7 +890,7 @@ export async function claimLocalTaskReviewLease(roomId, taskId, input) {
906
890
  updated_at = ?
907
891
  WHERE room_id = ? AND task_id = ?
908
892
  `)
909
- .run(leaseId, input.holder_label?.trim() || actorKey || "Local reviewer", actorKey, input.agent_session_id?.trim() || null, now, now, roomId, taskId);
893
+ .run(leaseId, input.holder_label?.trim() || actorKey || "Local reviewer", actorKey, input.agent_session_id?.trim() || null, now, now, roomId, taskId)));
910
894
  const task = await getLocalTask(roomId, taskId);
911
895
  const lease = task?.active_leases?.find((entry) => entry.id === leaseId);
912
896
  if (!task || !lease)
@@ -925,7 +909,7 @@ export async function releaseLocalTaskReviewLease(roomId, taskId, input = {}) {
925
909
  }
926
910
  const database = await getDb();
927
911
  const now = new Date().toISOString();
928
- database
912
+ await runLocalSqliteWriteTransactionAsync(database, () => withWorkerStateFence(() => database
929
913
  .prepare(`
930
914
  UPDATE local_tasks
931
915
  SET review_lease_id = NULL,
@@ -936,7 +920,7 @@ export async function releaseLocalTaskReviewLease(roomId, taskId, input = {}) {
936
920
  updated_at = ?
937
921
  WHERE room_id = ? AND task_id = ?
938
922
  `)
939
- .run(now, roomId, taskId);
923
+ .run(now, roomId, taskId)));
940
924
  const task = await getLocalTask(roomId, taskId);
941
925
  if (!task)
942
926
  throw new Error("Task not found.");
@@ -33,6 +33,10 @@ export function readLocalStateSnapshot() {
33
33
  export function readLocalState() {
34
34
  return readLocalStateSnapshot().state;
35
35
  }
36
+ /** Hold the existing state lock through a short synchronous local effect. */
37
+ export function withLocalStateReadLock(callback) {
38
+ return withStateLock((statePath) => callback(readLocalStateSnapshotFromPath(statePath)));
39
+ }
36
40
  function sleepSync(ms) {
37
41
  if (ms > 0) {
38
42
  Atomics.wait(STATE_LOCK_SLEEP_BUFFER, 0, 0, ms);
@@ -41,7 +45,7 @@ function sleepSync(ms) {
41
45
  function writeLocalStateUnlocked(statePath, state) {
42
46
  const tempPath = `${statePath}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
43
47
  try {
44
- writeFileSync(tempPath, JSON.stringify(state, null, 2) + "\n", "utf-8");
48
+ writeFileSync(tempPath, JSON.stringify(state, null, 2) + "\n", { encoding: "utf-8", mode: 0o600 });
45
49
  renameSync(tempPath, statePath);
46
50
  }
47
51
  finally {
@@ -7,10 +7,13 @@ import { registerTaskTools } from "./tools/tasks.js";
7
7
  import { registerSupervisedRoomTurnTools } from "./tools/supervised-room-turn.js";
8
8
  import { toolSurfaceForExecutionProfile } from "./runtime/tool-surface-policy.js";
9
9
  import { profileAwareToolServer } from "./supervised-tool-facade.js";
10
+ import { workerAwareToolServer } from "./worker-tool-facade.js";
10
11
  export function registerTools(server, profile = "autonomous_mcp_worker", supervisedProvider = process.env.LETAGENTS_SUPERVISOR_PROVIDER?.trim() || null, options = {}) {
11
- const tools = options.executionOwner === "daemon"
12
+ const profileTools = options.executionOwner === "daemon"
12
13
  ? server
13
14
  : profileAwareToolServer(server, profile, undefined, supervisedProvider);
15
+ const tools = profile === "autonomous_mcp_worker" || profile === "interactive_desktop"
16
+ ? workerAwareToolServer(profileTools) : profileTools;
14
17
  const surface = toolSurfaceForExecutionProfile(profile);
15
18
  registerRoomJoinTools(tools);
16
19
  if (surface.agentSessionLifecycle)
@@ -5,7 +5,7 @@ import { normalizeAgentBaseName } from "../../../shared/codenames.js";
5
5
  import { formatOwnerAttribution } from "../../../shared/agent-identity.js";
6
6
  import { LETAGENTS_AGENT_SESSION_ID_HEADER, LETAGENTS_AGENT_SESSION_TOKEN_HEADER, } from "../../../shared/request-headers.js";
7
7
  import { AGENT_INSTANCE_UUID, detectAgentIdeLabel, detectAgentRuntimeLabel, ensureAgentIdentity, } from "./identity.js";
8
- import { isSupervisedBoundedTurn, requireValidWorkerBearerRuntime } from "./worker-bearer.js";
8
+ import { hasSupervisedWorkerAuthority, requireValidWorkerBearerRuntime } from "./worker-bearer.js";
9
9
  import { resolveCurrentSupervisedWorkerSession } from "./supervisor-bridge.js";
10
10
  import { getDaemonToolExecutionContext, getRuntimeWorkingDirectory } from "./daemon-tool-context.js";
11
11
  // A worker bearer already represents a server-side worker session. This local
@@ -166,7 +166,7 @@ export async function resolveWorkerToolIdentity(input) {
166
166
  }
167
167
  const agentSession = input.agentSessionId
168
168
  ? requireWorkerAgentSession(input.roomId, input.agentSessionId)
169
- : input.roomId && !isSupervisedBoundedTurn() && await isLocalRoomStorageEnabled(input.roomId)
169
+ : input.roomId && !hasSupervisedWorkerAuthority() && await isLocalRoomStorageEnabled(input.roomId)
170
170
  ? await ensureLocalWorkerAgentSession(input.roomId)
171
171
  : requireWorkerAgentSession(input.roomId, input.agentSessionId);
172
172
  return {
@@ -1,4 +1,6 @@
1
1
  import { clearAuthenticatedAccountCache } from "./auth-cache.js";
2
+ import { currentWorkerCall } from "../../worker-call-context.js";
3
+ import { LETAGENTS_AGENT_SESSION_TOKEN_HEADER } from "../../../shared/request-headers.js";
2
4
  import { getDaemonToolExecutionContext } from "./daemon-tool-context.js";
3
5
  import { requireValidWorkerBearerRuntime } from "./worker-bearer.js";
4
6
  import { borrowCurrentSupervisedWorkerCredential, } from "./supervisor-bridge.js";
@@ -123,7 +125,9 @@ export async function apiCall(path, options) {
123
125
  });
124
126
  if (!res.ok) {
125
127
  const body = await res.text();
126
- if (res.status === 401 && requireValidWorkerBearerRuntime().mode === "owner") {
128
+ const hasWorkerCredential = currentWorkerCall() || headers.has(LETAGENTS_AGENT_SESSION_TOKEN_HEADER)
129
+ || (typeof options?.body === "string" && /"(?:agent_session_token|replace_agent_session_token|connection_token)"\s*:/.test(options.body));
130
+ if (res.status === 401 && requireValidWorkerBearerRuntime().mode === "owner" && !hasWorkerCredential) {
127
131
  // Only clear on 401 (invalid/expired credential), NOT on 403
128
132
  // (valid credential but insufficient permissions, e.g., private repo access)
129
133
  const { clearStoredAuth } = await ownerAuthStoreLoader();
@@ -1,5 +1,6 @@
1
1
  export const EXECUTION_PROFILES = [
2
2
  "supervised_room_turn",
3
+ "supervised_mcp_polling",
3
4
  "autonomous_mcp_worker",
4
5
  "interactive_desktop",
5
6
  ];
@@ -1,6 +1,8 @@
1
1
  import { buildAgentActorLabel, formatOwnerAttribution, } from "../../../shared/agent-identity.js";
2
2
  import { apiCall, getLetagentsToken, } from "./api.js";
3
3
  import { requireValidWorkerBearerRuntime } from "./worker-bearer.js";
4
+ import { currentWorkerCall } from "../../worker-call-context.js";
5
+ import { identityFromAgentSession } from "./agent-sessions.js";
4
6
  import { detectAgentIdeLabel, detectAgentRuntimeLabel, } from "./identity/config.js";
5
7
  import { resolveOwnerContext } from "./identity/directory.js";
6
8
  import { getSessionLivenessRegistration } from "./identity/liveness.js";
@@ -9,6 +11,9 @@ import { toPublicAgentIdentity } from "./identity/public.js";
9
11
  import { currentAgentIdentity, currentAgentIdentityKey, ensureAgentIdentityKey, getConversationIdentity, setConversationIdentity, storeCurrentAgentIdentity, AGENT_INSTANCE_UUID, } from "./identity/state.js";
10
12
  export { AGENT_INSTANCE_UUID, currentAgentIdentity, currentAgentIdentityKey, detectAgentIdeLabel, detectAgentRuntimeLabel, getConversationIdentity, getSessionLivenessRegistration, resolveOwnerContext, setConversationIdentity, storeCurrentAgentIdentity, toPublicAgentIdentity, };
11
13
  export async function ensureAgentIdentity() {
14
+ const worker = currentWorkerCall();
15
+ if (worker)
16
+ return identityFromAgentSession(worker);
12
17
  const owner = await resolveOwnerContext();
13
18
  const authAvailable = requireValidWorkerBearerRuntime().mode === "owner" && Boolean(await getLetagentsToken());
14
19
  const ideLabel = detectAgentIdeLabel();
@@ -4,7 +4,7 @@ import { isLocalRoomStorageEnabled, resolveLocalRoomStorageIdentifiers, touchRoo
4
4
  import { apiCall, isMissingRouteError } from "./api.js";
5
5
  import { agentSessionCredentials, identityFromAgentSession } from "./agent-sessions.js";
6
6
  import { getSessionLivenessRegistration } from "./identity.js";
7
- import { isSupervisedBoundedTurn } from "./worker-bearer.js";
7
+ import { hasSupervisedWorkerAuthority } from "./worker-bearer.js";
8
8
  const roomPresenceByIdentity = new Map();
9
9
  export function getRememberedRoomPresence(roomId, identity) {
10
10
  if (!roomId || !identity) {
@@ -19,7 +19,7 @@ export async function syncRoomPresence(roomId, identity, presence, agentSession)
19
19
  }
20
20
  roomPresenceByIdentity.set(getRoomIdentityPresenceCacheKey(roomId, resolvedIdentity.actor_label), presence);
21
21
  const { localRoomId, cloudRoomId } = await resolveLocalRoomStorageIdentifiers(roomId);
22
- if (!isSupervisedBoundedTurn() && await isLocalRoomStorageEnabled(roomId)) {
22
+ if (!hasSupervisedWorkerAuthority() && await isLocalRoomStorageEnabled(roomId)) {
23
23
  touchRoomSession(localRoomId || roomId);
24
24
  return;
25
25
  }
@@ -39,7 +39,7 @@ export async function syncRoomPresence(roomId, identity, presence, agentSession)
39
39
  ...agentSessionCredentials(agentSession),
40
40
  }),
41
41
  });
42
- if (!isSupervisedBoundedTurn())
42
+ if (!hasSupervisedWorkerAuthority())
43
43
  touchRoomSession(apiRoomId);
44
44
  }
45
45
  catch (error) {
@@ -4,9 +4,10 @@ import { apiCall, isMissingRouteError, } from "./api.js";
4
4
  import { maybeHandleRepoRoomAuthRequired } from "./device-auth.js";
5
5
  import { getLastMessageId } from "./messages.js";
6
6
  import { currentRoom, getCurrentSupervisedRoomAuthority } from "./room-state.js";
7
- import { isSupervisedBoundedTurn } from "./worker-bearer.js";
7
+ import { hasSupervisedWorkerAuthority } from "./worker-bearer.js";
8
+ import { currentWorkerCall } from "../../worker-call-context.js";
8
9
  export async function roomScopedApiCall(input) {
9
- const supervised = isSupervisedBoundedTurn();
10
+ const supervised = hasSupervisedWorkerAuthority();
10
11
  const exactRoomAuthority = supervised ? getCurrentSupervisedRoomAuthority() : null;
11
12
  if (supervised && (!exactRoomAuthority || input.room_id !== exactRoomAuthority)) {
12
13
  throw new Error("The daemon-supervised API request is missing its exact per-call room authority.");
@@ -20,8 +21,8 @@ export async function roomScopedApiCall(input) {
20
21
  delete headers[originHeaderKey];
21
22
  headers[LETAGENTS_ORIGIN_ROOM_ID_HEADER] = exactRoomAuthority;
22
23
  }
23
- else if (currentRoom?.room_id && !originHeaderKey) {
24
- headers[LETAGENTS_ORIGIN_ROOM_ID_HEADER] = currentRoom.room_id;
24
+ else if ((currentWorkerCall()?.room_id || currentRoom?.room_id) && !originHeaderKey) {
25
+ headers[LETAGENTS_ORIGIN_ROOM_ID_HEADER] = currentWorkerCall()?.room_id ?? currentRoom.room_id;
25
26
  }
26
27
  const options = {
27
28
  ...input.options,
@@ -3,7 +3,7 @@ import { getStoredAgentIdentity, saveRoomSession, touchRoomSession, } from "../.
3
3
  import { getCanonicalRoomWebPath, } from "../../room-id.js";
4
4
  import { getApiUrl, getLetagentsToken } from "./api.js";
5
5
  import { AGENT_INSTANCE_UUID, currentAgentIdentity, currentAgentIdentityKey, } from "./identity.js";
6
- import { isSupervisedBoundedTurn } from "./worker-bearer.js";
6
+ import { hasSupervisedWorkerAuthority } from "./worker-bearer.js";
7
7
  import { getCurrentSupervisedRoomAuthority, runWithSupervisedRoomAuthority, } from "./supervised-room-authority.js";
8
8
  let mcpServer = null;
9
9
  let sseClient = null;
@@ -92,7 +92,7 @@ export function toPublicRoomResponse(response, fallbackRoomId) {
92
92
  }
93
93
  export function rememberRoom(state, lastMessageId) {
94
94
  currentRoom = state;
95
- if (isSupervisedBoundedTurn())
95
+ if (hasSupervisedWorkerAuthority())
96
96
  return state;
97
97
  saveRoomSession({
98
98
  room_id: state.room_id,
@@ -123,7 +123,7 @@ export function rememberRoom(state, lastMessageId) {
123
123
  return state;
124
124
  }
125
125
  export function touchCurrentRoom(lastMessageId) {
126
- if (isSupervisedBoundedTurn())
126
+ if (hasSupervisedWorkerAuthority())
127
127
  return;
128
128
  if (!currentRoom) {
129
129
  return;
@@ -131,7 +131,7 @@ export function touchCurrentRoom(lastMessageId) {
131
131
  touchRoomSession(currentRoom.room_id, lastMessageId);
132
132
  }
133
133
  export function getTargetRoomId(roomId) {
134
- if (isSupervisedBoundedTurn()) {
134
+ if (hasSupervisedWorkerAuthority()) {
135
135
  const exactRoomAuthority = getCurrentSupervisedRoomAuthority();
136
136
  if (!exactRoomAuthority) {
137
137
  throw new Error("The daemon-supervised tool has not received its exact room authority.");
@@ -162,7 +162,7 @@ export function toPublicCurrentRoomState() {
162
162
  * repository inspection and can safely rebind after a durable room move.
163
163
  */
164
164
  export function runWithCurrentSupervisedRoom(roomId, callback) {
165
- if (!isSupervisedBoundedTurn()) {
165
+ if (!hasSupervisedWorkerAuthority()) {
166
166
  throw new Error("Only a daemon-supervised bounded turn can bind supervisor room authority.");
167
167
  }
168
168
  const normalized = roomId.trim();
@@ -172,7 +172,7 @@ export function runWithCurrentSupervisedRoom(roomId, callback) {
172
172
  return runWithSupervisedRoomAuthority(normalized, callback);
173
173
  }
174
174
  export function getFallbackProjectId() {
175
- if (isSupervisedBoundedTurn())
175
+ if (hasSupervisedWorkerAuthority())
176
176
  return null;
177
177
  return currentRoom?.project_id ?? null;
178
178
  }
@@ -9,7 +9,7 @@ import { ensureAgentIdentity, toPublicAgentIdentity, withAgentIdentity, } from "
9
9
  import { withJoinRoomAgentPrompt } from "./messages.js";
10
10
  import { syncRoomPresence } from "./presence.js";
11
11
  import { rememberRoom, toPublicRoomResponse, toRoomState, } from "./room-state.js";
12
- import { isSupervisedBoundedTurn, requireValidWorkerBearerRuntime } from "./worker-bearer.js";
12
+ import { hasSupervisedWorkerAuthority, requireValidWorkerBearerRuntime } from "./worker-bearer.js";
13
13
  export function normalizeJoinSessionMode(value) {
14
14
  return String(value || "").trim().toLowerCase() === "live" ? "live" : "current";
15
15
  }
@@ -30,7 +30,7 @@ function parseGeneratedGitRefRoomIdentifier(identifier) {
30
30
  };
31
31
  }
32
32
  export async function joinRoomIdentifier(identifier, joinedVia, options = {}) {
33
- if (isSupervisedBoundedTurn()) {
33
+ if (hasSupervisedWorkerAuthority()) {
34
34
  throw new Error("Room joins and creation are disabled during a daemon-supervised bounded turn.");
35
35
  }
36
36
  const roomId = joinedVia === "join_code" ? normalizeInviteCode(identifier) : identifier.trim();
@@ -191,7 +191,7 @@ export async function joinRoomIdentifierWithoutImplicitGitRefCreate(identifier,
191
191
  }));
192
192
  }
193
193
  export async function createInviteRoom() {
194
- if (isSupervisedBoundedTurn()) {
194
+ if (hasSupervisedWorkerAuthority()) {
195
195
  throw new Error("Room joins and creation are disabled during a daemon-supervised bounded turn.");
196
196
  }
197
197
  const project = await apiCall("/projects", { method: "POST" });
@@ -6,12 +6,86 @@ import { join } from "node:path";
6
6
  import { parsePositivePgIntegerScopedId } from "../../../../shared/message-contracts.mjs";
7
7
  import { getCurrentSupervisedRoomAuthority } from "./supervised-room-authority.js";
8
8
  const NEGOTIATION_PROTOCOL_VERSION = 1;
9
- const SUPPORTED_SUPERVISOR_PROTOCOL_VERSIONS = new Set([1, 2]);
9
+ const SUPPORTED_SUPERVISOR_PROTOCOL_VERSIONS = new Set([1, 2, 3]);
10
10
  const DEFAULT_REQUEST_TIMEOUT_MS = 5_000;
11
11
  const CONFIRMED_BINDING_VERIFY_TIMEOUT_MS = 250;
12
12
  const SUPERVISOR_CONTEXT_FILE = ".letagents-supervisor-context.json";
13
13
  const WORK_ATTEMPT_MARKER_FILE = ".letagents-work-attempt.json";
14
14
  const MAX_SUPERVISOR_CONTEXT_BYTES = 4 * 1024;
15
+ // An SDK request id is unique only within this MCP process lifetime.
16
+ const CUSTODIAL_POLLING_PROCESS_INCARNATION_ID = randomUUID();
17
+ /** Release reuses BEFORE's exact generation; it never authorizes against a successor. */
18
+ export async function authorizeCustodialPolling(toolName, prior, env = process.env, options = {}, waitRequest) {
19
+ const wait = waitRequest ? { ...waitRequest } : undefined;
20
+ if (toolName === "wait_for_messages") {
21
+ if (!wait || !(typeof wait.mcpRequestId === "string" || Number.isSafeInteger(wait.mcpRequestId))) {
22
+ throw new Error("Custodial wait is missing its exact MCP request id.");
23
+ }
24
+ if (!(wait.roomCursor === null || (typeof wait.roomCursor === "string" && parseRoomMessageNumber(wait.roomCursor) !== null))
25
+ || (prior && (!prior.wait || prior.wait.processIncarnationId !== CUSTODIAL_POLLING_PROCESS_INCARNATION_ID
26
+ || prior.wait.mcpRequestId !== wait.mcpRequestId || typeof wait.offeredFrontier !== "string"
27
+ || parseRoomMessageNumber(wait.offeredFrontier) === null || prior.roomCursor === null
28
+ || parseRoomMessageNumber(wait.offeredFrontier) < parseRoomMessageNumber(prior.roomCursor)))) {
29
+ throw new Error("Custodial wait receipt does not match its original invocation and cursor.");
30
+ }
31
+ }
32
+ else if (wait || prior?.wait)
33
+ throw new Error("Only custodial wait may acknowledge or offer a cursor.");
34
+ if (env.LETAGENTS_EXECUTION_PROFILE?.trim() !== "supervised_mcp_polling")
35
+ throw new Error("Custodial polling profile required.");
36
+ if (env.LETAGENTS_SUPERVISED_BOUNDED_TURNS?.trim() === "1"
37
+ || env.LETAGENTS_TOKEN?.trim() || env.LETAGENTS_AGENT_SESSION_BEARER?.trim()) {
38
+ throw new Error("Custodial polling refuses bounded flags and environment credentials.");
39
+ }
40
+ const coordinates = prior?.coordinates ?? await resolveSupervisorCoordinates(supervisedContextSession(env), env, options);
41
+ if (!coordinates?.roomId || !coordinates.agentSessionId)
42
+ throw new Error("Custodial polling lacks exact worker coordinates.");
43
+ if ((wait?.requestedRoomId && wait.requestedRoomId !== coordinates.roomId)
44
+ || (wait?.requestedAgentSessionId && wait.requestedAgentSessionId !== coordinates.agentSessionId)) {
45
+ throw new Error("Custodial wait room or worker identity does not match its exact authority.");
46
+ }
47
+ const timeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
48
+ const negotiated = prior?.negotiated ?? await negotiateSupervisor(coordinates.socketPath, timeoutMs);
49
+ if (!negotiated.custodialPollingV1 || negotiated.generation === null)
50
+ throw new Error("Daemon does not support custodial_polling_v1.");
51
+ if (wait && !negotiated.custodialPollingOffersV1)
52
+ throw new Error("Daemon does not support custodialPollingOffersV1; refusing an unjournaled wait.");
53
+ const apiUrl = prior?.apiUrl ?? env.LETAGENTS_API_URL?.trim();
54
+ if (!apiUrl)
55
+ throw new Error("Custodial polling requires an explicit API URL.");
56
+ const response = await supervisorRequest(coordinates.socketPath, {
57
+ version: negotiated.protocolVersion, id: randomUUID(), method: "supervisor.authorize_custodial_polling",
58
+ params: {
59
+ entry_id: coordinates.entryId, room_id: coordinates.roomId, work_attempt_id: coordinates.workAttemptId,
60
+ execution_generation_id: coordinates.executionGenerationId, agent_session_id: coordinates.agentSessionId,
61
+ daemon_generation: negotiated.generation, api_url: apiUrl, contract: "custodial_polling_v1",
62
+ phase: prior ? "release" : "before", tool_name: toolName,
63
+ ...(prior ? { expected_configuration_revision: prior.configurationRevision } : {}),
64
+ ...(wait ? {
65
+ process_incarnation_id: CUSTODIAL_POLLING_PROCESS_INCARNATION_ID, mcp_request_id: wait.mcpRequestId,
66
+ ...(prior ? { expected_activation_id: prior.wait.activationId, expected_binding_epoch: prior.wait.bindingEpoch,
67
+ input_cursor: prior.roomCursor, offered_frontier: wait.offeredFrontier } : { room_cursor: wait.roomCursor }),
68
+ } : {}),
69
+ },
70
+ }, timeoutMs);
71
+ const result = response.result;
72
+ if (!response.ok || response.version !== negotiated.protocolVersion || !result || result.status !== "authorized" || result.contract !== "custodial_polling_v1"
73
+ || result.room_id !== coordinates.roomId || result.agent_session_id !== coordinates.agentSessionId
74
+ || !Number.isSafeInteger(result.configuration_revision) || Number(result.configuration_revision) < 1
75
+ || (prior && result.configuration_revision !== prior.configurationRevision)
76
+ || (wait && (typeof result.activation_id !== "string" || !result.activation_id.trim()
77
+ || !Number.isSafeInteger(result.binding_epoch) || Number(result.binding_epoch) < 1
78
+ || typeof result.room_cursor !== "string"
79
+ || (prior && (result.activation_id !== prior.wait.activationId || result.binding_epoch !== prior.wait.bindingEpoch
80
+ || result.room_cursor !== prior.roomCursor))))
81
+ || !(result.room_cursor === null || (typeof result.room_cursor === "string" && parseRoomMessageNumber(result.room_cursor) !== null))) {
82
+ throw new Error("Custodial polling authority was rejected or became stale.");
83
+ }
84
+ return { coordinates, negotiated, apiUrl, roomId: coordinates.roomId, agentSessionId: coordinates.agentSessionId,
85
+ roomCursor: result.room_cursor, configurationRevision: Number(result.configuration_revision),
86
+ ...(wait ? { wait: { processIncarnationId: CUSTODIAL_POLLING_PROCESS_INCARNATION_ID, mcpRequestId: wait.mcpRequestId,
87
+ activationId: String(result.activation_id), bindingEpoch: Number(result.binding_epoch) } } : {}) };
88
+ }
15
89
  const confirmedBindingsBySession = new Map();
16
90
  const confirmedRequestsBySession = new Map();
17
91
  const confirmedProtocolsBySession = new Map();
@@ -184,7 +258,8 @@ async function requireCurrentSupervisedCoordinates(env, options) {
184
258
  * authority for the exact worker session identity.
185
259
  */
186
260
  export async function borrowCurrentSupervisedWorkerCredential(env = process.env, options = {}) {
187
- if (env.LETAGENTS_SUPERVISED_BOUNDED_TURNS?.trim() !== "1") {
261
+ if (env.LETAGENTS_SUPERVISED_BOUNDED_TURNS?.trim() !== "1"
262
+ && env.LETAGENTS_EXECUTION_PROFILE?.trim() !== "supervised_mcp_polling") {
188
263
  return { state: "not_supervised" };
189
264
  }
190
265
  const seed = supervisedContextSession(env);
@@ -728,7 +803,9 @@ async function negotiateSupervisor(socketPath, timeoutMs) {
728
803
  const daemonIdentity = hasCompleteIdentity
729
804
  ? [result.generation, result.pid, result.started_at].join(":")
730
805
  : null;
731
- return { protocolVersion, daemonIdentity, generation: hasCompleteIdentity ? Number(result.generation) : null };
806
+ return { protocolVersion, daemonIdentity, generation: hasCompleteIdentity ? Number(result.generation) : null,
807
+ custodialPollingV1: result.capabilities?.custodialPollingV1 === true,
808
+ custodialPollingOffersV1: result.capabilities?.custodialPollingOffersV1 === true };
732
809
  }
733
810
  function supervisorRequest(socketPath, request, timeoutMs) {
734
811
  return new Promise((resolve, reject) => {
@@ -1,4 +1,11 @@
1
1
  const TOOL_SURFACE_BY_PROFILE = {
2
+ supervised_mcp_polling: {
3
+ agentSessionLifecycle: false,
4
+ deliveryLoop: true,
5
+ onboarding: false,
6
+ rental: false,
7
+ roomResume: false,
8
+ },
2
9
  supervised_room_turn: {
3
10
  agentSessionLifecycle: false,
4
11
  deliveryLoop: false,
@@ -13,13 +13,17 @@ export function getWorkerBearerRuntime() {
13
13
  const bearer = process.env.LETAGENTS_AGENT_SESSION_BEARER?.trim();
14
14
  const supervised = process.env.LETAGENTS_SUPERVISED_BOUNDED_TURNS?.trim() === "1";
15
15
  const profile = process.env.LETAGENTS_EXECUTION_PROFILE?.trim();
16
+ const polling = profile === "supervised_mcp_polling";
16
17
  if (supervised !== (profile === "supervised_room_turn")) {
17
18
  return {
18
19
  mode: "invalid",
19
20
  error: "LETAGENTS_EXECUTION_PROFILE=supervised_room_turn and LETAGENTS_SUPERVISED_BOUNDED_TURNS=1 must be configured together.",
20
21
  };
21
22
  }
22
- if (!bearer && !supervised)
23
+ if (polling && (bearer || process.env.LETAGENTS_TOKEN?.trim())) {
24
+ return { mode: "invalid", error: "Custodial polling refuses environment credentials; borrow exact daemon worker authority." };
25
+ }
26
+ if (!bearer && !supervised && !polling)
23
27
  return { mode: "owner" };
24
28
  if (bearer && supervised) {
25
29
  return {
@@ -69,6 +73,13 @@ export function requireValidWorkerBearerRuntime() {
69
73
  return runtime;
70
74
  }
71
75
  export function isSupervisedBoundedTurn() {
76
+ return hasSupervisedWorkerAuthority() && !isCustodialPolling();
77
+ }
78
+ export function isCustodialPolling() {
79
+ return process.env.LETAGENTS_EXECUTION_PROFILE?.trim() === "supervised_mcp_polling";
80
+ }
81
+ /** Credential custody is independent of who owns room delivery. */
82
+ export function hasSupervisedWorkerAuthority() {
72
83
  return requireValidWorkerBearerRuntime().mode === "supervised";
73
84
  }
74
85
  export function workerModeDisabledToolResult(toolDescription = "This owner-auth onboarding tool") {