letagents 0.12.29 → 0.12.31

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 (37) hide show
  1. package/README.md +1 -1
  2. package/dist/mcp/local-state/storage.js +0 -5
  3. package/dist/mcp/server/runtime/api.js +53 -1
  4. package/dist/mcp/server/runtime/identity/liveness.js +19 -1
  5. package/dist/mcp/server/runtime/process-connection.js +239 -0
  6. package/dist/mcp/server/runtime/worker-bearer.js +0 -4
  7. package/dist/mcp/server/runtime/worker-handles.js +109 -15
  8. package/dist/mcp/server/runtime-contract.js +11 -0
  9. package/dist/mcp/server/runtime.js +1 -1
  10. package/dist/mcp/server/supervised-tool-facade.js +1 -1
  11. package/dist/mcp/server/tools/agent-sessions.js +38 -6
  12. package/dist/mcp/server/tools/knowledge.js +29 -0
  13. package/dist/mcp/server/tools/rooms/inspection-tools.js +1 -1
  14. package/dist/mcp/server.js +21 -9
  15. package/dist/mcp/worker-call-context.js +4 -0
  16. package/dist/shared/agent-presence.js +141 -0
  17. package/dist/shared/codenames.js +2 -104
  18. package/dist/shared/handoff.js +0 -6
  19. package/dist/shared/request-headers.js +11 -0
  20. package/dist/shared/room-agent-prompts.js +1 -1
  21. package/package.json +4 -5
  22. package/shared/activation-routing.d.mts +5 -0
  23. package/shared/activation-routing.mjs +10 -1
  24. package/shared/agent-codenames.d.mts +17 -0
  25. package/shared/agent-codenames.mjs +96 -0
  26. package/shared/daemon-maintenance.d.mts +13 -0
  27. package/shared/daemon-maintenance.mjs +99 -0
  28. package/shared/permission-review.d.mts +30 -0
  29. package/shared/permission-review.mjs +482 -0
  30. package/shared/provider-acquisition-evidence.d.mts +15 -0
  31. package/shared/provider-acquisition-evidence.mjs +33 -0
  32. package/shared/room-agent-work.d.mts +1 -0
  33. package/shared/room-agent-work.mjs +11 -0
  34. package/shared/room-settings.d.mts +48 -0
  35. package/shared/room-settings.mjs +108 -0
  36. package/shared/ui/PrivateMessages.vue +12 -17
  37. package/shared/ui/private-messages.css +35 -25
package/README.md CHANGED
@@ -212,7 +212,7 @@ The API runs at `http://localhost:3001`. Point `LETAGENTS_API_URL` at your serve
212
212
 
213
213
  Optional — **long room long-polls** (multi-hour `wait_for_messages` / `GET …/messages/poll`): set the **same** `LETAGENTS_POLL_MAX_MS` on **both** the API process and any MCP client you run from source (milliseconds; default `180000`).
214
214
 
215
- Optional — **visible worker-channel warning grace**: set `LETAGENTS_LIVENESS_NOTICE_AFTER_MS` on the API process (milliseconds; default `300000`, or 5 minutes). Internal transport staleness remains 2 minutes for routing and diagnostics; this setting controls only when the room sees the softer “message channel unreachable” notice.
215
+ Optional — **offline-with-work notice grace**: set `LETAGENTS_LIVENESS_NOTICE_AFTER_MS` on the API process (milliseconds; default `1800000`, or 30 minutes). The room is told about an unreachable worker only while it holds an active work lease, and only after this grace; ordinary connects and disconnects are never posted (the roster shows presence). Internal transport staleness remains 2 minutes for routing and diagnostics.
216
216
 
217
217
  The API now uses PostgreSQL with Drizzle ORM. `DB_URL` must be set before starting the server or running migrations.
218
218
 
@@ -47,11 +47,6 @@ function writeLocalStateUnlocked(statePath, state) {
47
47
  function withStateLock(callback) {
48
48
  return withStateFileLock(getLocalStatePath(), callback);
49
49
  }
50
- export function writeLocalState(state) {
51
- withStateLock((statePath) => {
52
- writeLocalStateUnlocked(statePath, state);
53
- });
54
- }
55
50
  export function updateLocalState(updater) {
56
51
  return withStateLock((statePath) => {
57
52
  const current = readLocalStateFromPath(statePath);
@@ -1,6 +1,7 @@
1
1
  import { clearAuthenticatedAccountCache } from "./auth-cache.js";
2
2
  import { currentWorkerCall } from "../../worker-call-context.js";
3
- import { LETAGENTS_AGENT_SESSION_TOKEN_HEADER } from "../../../shared/request-headers.js";
3
+ import { AGENT_SESSION_ENDED_ERROR, LETAGENTS_AGENT_SESSION_ID_HEADER, LETAGENTS_AGENT_SESSION_TOKEN_HEADER, } from "../../../shared/request-headers.js";
4
+ import { endStoredAgentSession } from "../../local-state/agent-sessions.js";
4
5
  import { getDaemonToolExecutionContext } from "./daemon-tool-context.js";
5
6
  import { requireValidWorkerBearerRuntime } from "./worker-bearer.js";
6
7
  import { borrowCurrentSupervisedWorkerCredential, } from "./supervisor-bridge.js";
@@ -97,6 +98,54 @@ export function resolveApiPath(urlOrPath) {
97
98
  return "/auth/device/start";
98
99
  }
99
100
  }
101
+ /**
102
+ * The room says the session this request was made for has ended. Record it,
103
+ * so that the next registration starts a session instead of answering from
104
+ * the one the room no longer has.
105
+ */
106
+ function forgetEndedSession(headers, body) {
107
+ let sessionId = headers.get(LETAGENTS_AGENT_SESSION_ID_HEADER);
108
+ let sessionToken = headers.get(LETAGENTS_AGENT_SESSION_TOKEN_HEADER);
109
+ if ((!sessionId || !sessionToken) && typeof body === "string") {
110
+ try {
111
+ const sent = JSON.parse(body);
112
+ if (typeof sent.agent_session_id === "string")
113
+ sessionId = sent.agent_session_id;
114
+ if (typeof sent.agent_session_token === "string")
115
+ sessionToken = sent.agent_session_token;
116
+ }
117
+ catch {
118
+ // Not a request that names a session.
119
+ }
120
+ }
121
+ if (!sessionId || !sessionToken)
122
+ return;
123
+ try {
124
+ endStoredAgentSession(sessionId, new Date().toISOString(), sessionToken);
125
+ }
126
+ catch {
127
+ // The error the agent is about to see tells it what to do.
128
+ }
129
+ }
130
+ const BUSY_RETRY_DELAYS_MS = [1_000, 2_000];
131
+ /**
132
+ * For a request the room may turn away only because it is busy for a moment,
133
+ * and that changed nothing when it was turned away. The room answers 503 when
134
+ * another registration is still committing; asking again shortly succeeds.
135
+ */
136
+ export async function apiCallWhenFree(path, options) {
137
+ for (let attempt = 0;; attempt += 1) {
138
+ try {
139
+ return await apiCall(path, options);
140
+ }
141
+ catch (error) {
142
+ const delay = BUSY_RETRY_DELAYS_MS[attempt];
143
+ if (!(error instanceof ApiError) || error.status !== 503 || delay === undefined)
144
+ throw error;
145
+ await new Promise((resolve) => setTimeout(resolve, delay));
146
+ }
147
+ }
148
+ }
100
149
  export async function apiCall(path, options) {
101
150
  const headers = new Headers(options?.headers);
102
151
  if (!headers.has("Content-Type")) {
@@ -134,6 +183,9 @@ export async function apiCall(path, options) {
134
183
  clearStoredAuth();
135
184
  clearAuthenticatedAccountCache();
136
185
  }
186
+ if (res.status === 401 && body.includes(AGENT_SESSION_ENDED_ERROR)) {
187
+ forgetEndedSession(headers, options?.body);
188
+ }
137
189
  throw new ApiError(res.status, body);
138
190
  }
139
191
  const body = await res.text();
@@ -1,4 +1,5 @@
1
- import { randomUUID } from "crypto";
1
+ import { createHash, randomUUID } from "crypto";
2
+ import { hostname, userInfo } from "os";
2
3
  import { readLocalState, updateLocalState, } from "../../../local-state.js";
3
4
  import { detectAgentIdeLabel, detectAgentRuntimeLabel, } from "./config.js";
4
5
  import { AGENT_INSTANCE_UUID } from "./state.js";
@@ -16,6 +17,23 @@ function getOrCreateLocalHostId() {
16
17
  });
17
18
  return readLocalState().local_host_id || hostId;
18
19
  }
20
+ /**
21
+ * Names this machine for the room's same-machine reasoning. The stored host
22
+ * id alone cannot: it lives in a file, and a file that is copied to another
23
+ * machine, or shared with one, carries it along. This adds what the machine
24
+ * says of itself, so two machines never report the same value.
25
+ */
26
+ export function getProcessHostId() {
27
+ let user = "";
28
+ try {
29
+ user = userInfo().username;
30
+ }
31
+ catch {
32
+ // Some systems have no entry for the current user.
33
+ }
34
+ const parts = [getOrCreateLocalHostId(), hostname(), user, process.platform, process.arch];
35
+ return `phost_${createHash("sha256").update(parts.join("\n")).digest("hex").slice(0, 32)}`;
36
+ }
19
37
  export function getSessionLivenessRegistration(runtime = detectAgentRuntimeLabel()) {
20
38
  const hostId = getOrCreateLocalHostId();
21
39
  const ideLabel = detectAgentIdeLabel();
@@ -0,0 +1,239 @@
1
+ import { AGENT_PROCESS_CONNECTION_SILENT_AFTER_MS, AGENT_PROCESS_RECONNECT_MAX_DELAY_MS, } from "../../../shared/agent-presence.js";
2
+ import { LETAGENTS_AGENT_SESSION_ID_HEADER, LETAGENTS_AGENT_SESSION_TOKEN_HEADER, } from "../../../shared/request-headers.js";
3
+ import { endStoredAgentSession, getStoredAgentSession } from "../../local-state.js";
4
+ import { encodeRoomIdPath } from "../../room-id.js";
5
+ import { getApiUrl, getLetagentsToken } from "./api.js";
6
+ import { requireValidWorkerBearerRuntime } from "./worker-bearer.js";
7
+ /**
8
+ * This process holds one connection open per worker session for as long as it
9
+ * runs. The connection carries nothing: that it is open tells the room this
10
+ * process exists, which nothing else can. What the agent does cannot, because
11
+ * an agent that is busy or waiting between room calls looks the same as one
12
+ * whose process has gone.
13
+ *
14
+ * The room uses it for one decision. When another of the owner's agents asks
15
+ * for a name this session holds, the name passes on only if this process is
16
+ * gone. So a session that holds its connection open is never mistaken for an
17
+ * abandoned one.
18
+ *
19
+ * Nothing here polls. The connection is opened, and reopened only after it
20
+ * drops.
21
+ */
22
+ const RECONNECT_DELAYS_MS = [1_000, 2_000, AGENT_PROCESS_RECONNECT_MAX_DELAY_MS];
23
+ // A server that refuses the connection is asked again, but seldom: it will
24
+ // likely refuse again, and the agent's own room calls speak for it meanwhile.
25
+ const REFUSED_DELAYS_MS = [AGENT_PROCESS_RECONNECT_MAX_DELAY_MS, 15_000, 60_000];
26
+ // A server that does not know the route is asked more seldom still. It may
27
+ // be one of several behind the same address, not all of them yet replaced,
28
+ // or be replaced while this process runs. A process that gave up on the
29
+ // first such answer would offer no evidence for the rest of its life.
30
+ const UNKNOWN_DELAYS_MS = [10_000, 60_000, 5 * 60_000];
31
+ const ANSWER_TIMEOUT_MS = 10_000;
32
+ const EXIT_ANNOUNCEMENT_TIMEOUT_MS = 1_500;
33
+ const held = new Map();
34
+ async function authorization(session) {
35
+ const token = await getLetagentsToken();
36
+ return {
37
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
38
+ [LETAGENTS_AGENT_SESSION_ID_HEADER]: session.session_id,
39
+ [LETAGENTS_AGENT_SESSION_TOKEN_HEADER]: session.session_token,
40
+ };
41
+ }
42
+ function processUrl(session, suffix = "") {
43
+ return `${getApiUrl()}/rooms/${encodeRoomIdPath(session.room_id)}/agent-sessions/`
44
+ + `${encodeURIComponent(session.session_id)}/process${suffix}`;
45
+ }
46
+ function discard(response) {
47
+ void response.body?.cancel().catch(() => undefined);
48
+ }
49
+ async function holdOnce(session, signal, onOpen) {
50
+ // One attempt, ended by the caller or by a server that has gone quiet. A
51
+ // connection can die without either end being told, as when a machine
52
+ // wakes from sleep; the server writes often enough that silence shows it.
53
+ const attempt = new AbortController();
54
+ const abort = () => attempt.abort();
55
+ signal.addEventListener("abort", abort, { once: true });
56
+ let quiet;
57
+ const expectWithin = (ms) => {
58
+ clearTimeout(quiet);
59
+ quiet = setTimeout(abort, ms);
60
+ quiet.unref?.();
61
+ };
62
+ try {
63
+ expectWithin(ANSWER_TIMEOUT_MS);
64
+ const response = await fetch(processUrl(session), {
65
+ headers: { Accept: "text/event-stream", ...await authorization(session) },
66
+ signal: attempt.signal,
67
+ });
68
+ if (response.status === 410) {
69
+ discard(response);
70
+ return "ended";
71
+ }
72
+ // A room server that predates this connection has no such route. That
73
+ // says nothing about the session.
74
+ if (response.status === 404 || response.status === 405) {
75
+ discard(response);
76
+ return "unsupported";
77
+ }
78
+ if (response.status >= 500) {
79
+ discard(response);
80
+ return "dropped";
81
+ }
82
+ if (!response.ok || !response.body
83
+ || !(response.headers.get("content-type") ?? "").includes("text/event-stream")) {
84
+ discard(response);
85
+ return "refused";
86
+ }
87
+ onOpen(null);
88
+ const reader = response.body.getReader();
89
+ const decoder = new TextDecoder();
90
+ let tail = "";
91
+ for (;;) {
92
+ expectWithin(AGENT_PROCESS_CONNECTION_SILENT_AFTER_MS);
93
+ const { done, value } = await reader.read();
94
+ if (done)
95
+ return "dropped";
96
+ // Only the last few bytes are kept: enough to see either event sent.
97
+ tail = (tail + decoder.decode(value, { stream: true })).slice(-128);
98
+ const named = /"connection_id":"([0-9a-f-]{36})"/.exec(tail);
99
+ if (named)
100
+ onOpen(named[1]);
101
+ if (tail.includes("event: ended")) {
102
+ void reader.cancel().catch(() => undefined);
103
+ return "ended";
104
+ }
105
+ }
106
+ }
107
+ finally {
108
+ clearTimeout(quiet);
109
+ signal.removeEventListener("abort", abort);
110
+ }
111
+ }
112
+ function stillOurs(session) {
113
+ try {
114
+ const stored = getStoredAgentSession(session.session_id);
115
+ return Boolean(stored && !stored.ended_at && stored.session_token === session.session_token);
116
+ }
117
+ catch {
118
+ // A worker connection that was replaced reads as an error.
119
+ return false;
120
+ }
121
+ }
122
+ function sleep(ms, signal) {
123
+ return new Promise((resolve) => {
124
+ if (signal.aborted)
125
+ return resolve();
126
+ const timer = setTimeout(done, ms);
127
+ // Waiting to reconnect is no reason for this process to stay alive.
128
+ timer.unref?.();
129
+ function done() {
130
+ clearTimeout(timer);
131
+ signal.removeEventListener("abort", done);
132
+ resolve();
133
+ }
134
+ signal.addEventListener("abort", done, { once: true });
135
+ });
136
+ }
137
+ async function hold(connection) {
138
+ const { session, controller } = connection;
139
+ let drops = 0;
140
+ let refusals = 0;
141
+ let unknowns = 0;
142
+ while (!controller.signal.aborted) {
143
+ let outcome = "dropped";
144
+ try {
145
+ outcome = await holdOnce(session, controller.signal, (connectionId) => {
146
+ drops = 0;
147
+ refusals = 0;
148
+ unknowns = 0;
149
+ connection.connectionId = connectionId;
150
+ });
151
+ }
152
+ catch {
153
+ // A dropped network or an aborted request. The loop condition tells them apart.
154
+ }
155
+ connection.connectionId = null;
156
+ if (controller.signal.aborted)
157
+ return;
158
+ if (outcome === "ended") {
159
+ // The room ended this session. Say so locally, so the next
160
+ // registration starts a session instead of reusing this one.
161
+ endStoredAgentSession(session.session_id, new Date().toISOString(), session.session_token);
162
+ }
163
+ if (outcome === "ended") {
164
+ if (held.get(session.session_id) === connection)
165
+ held.delete(session.session_id);
166
+ return;
167
+ }
168
+ if (outcome !== "dropped" && !stillOurs(session)) {
169
+ // The session was registered again with another credential, by this
170
+ // process or another. Whoever did that holds its connection now.
171
+ if (held.get(session.session_id) === connection)
172
+ held.delete(session.session_id);
173
+ return;
174
+ }
175
+ const [delays, attempt] = outcome === "unsupported" ? [UNKNOWN_DELAYS_MS, unknowns]
176
+ : outcome === "refused" ? [REFUSED_DELAYS_MS, refusals]
177
+ : [RECONNECT_DELAYS_MS, drops];
178
+ await sleep(delays[Math.min(attempt, delays.length - 1)], controller.signal);
179
+ if (outcome === "unsupported")
180
+ unknowns += 1;
181
+ else if (outcome === "refused")
182
+ refusals += 1;
183
+ else
184
+ drops += 1;
185
+ }
186
+ }
187
+ function holdsCredentials(session) {
188
+ return Boolean(session
189
+ && session.session_kind === "worker"
190
+ && !session.ended_at
191
+ && session.session_token
192
+ && !session.session_id.startsWith("local_")
193
+ && requireValidWorkerBearerRuntime().mode === "owner");
194
+ }
195
+ /** Hold the process connection for a session this process has just registered. */
196
+ export function holdProcessConnection(session) {
197
+ if (!holdsCredentials(session))
198
+ return;
199
+ const current = held.get(session.session_id);
200
+ if (current?.session.session_token === session.session_token)
201
+ return;
202
+ current?.controller.abort();
203
+ const connection = { session, controller: new AbortController(), connectionId: null };
204
+ held.set(session.session_id, connection);
205
+ void hold(connection);
206
+ }
207
+ /** Stop holding it, because this process ended the session itself. */
208
+ export function releaseProcessConnection(sessionId) {
209
+ held.get(sessionId)?.controller.abort();
210
+ held.delete(sessionId);
211
+ }
212
+ /**
213
+ * Tell the room this process is exiting. A connection that merely closes may
214
+ * reopen, so the room waits before it concludes anything; a process that says
215
+ * it is leaving spares its successor that wait. Best effort and bounded: an
216
+ * exit is never held up by a room that does not answer.
217
+ */
218
+ export async function announceProcessExit() {
219
+ const connections = [...held.values()];
220
+ held.clear();
221
+ if (connections.length === 0)
222
+ return;
223
+ const timeout = AbortSignal.timeout(EXIT_ANNOUNCEMENT_TIMEOUT_MS);
224
+ await Promise.allSettled(connections.map(async ({ session, controller, connectionId }) => {
225
+ // Closed first, so nothing the room sends in answer is read as news
226
+ // about the session.
227
+ controller.abort();
228
+ await fetch(processUrl(session, "/exit"), {
229
+ method: "POST",
230
+ headers: { "Content-Type": "application/json", ...await authorization(session) },
231
+ body: JSON.stringify({
232
+ agent_session_id: session.session_id,
233
+ agent_session_token: session.session_token,
234
+ process_connection_id: connectionId,
235
+ }),
236
+ signal: timeout,
237
+ });
238
+ }));
239
+ }
@@ -1,6 +1,5 @@
1
1
  import { isLocalRoomApi } from "../../../../shared/room-api-origin.mjs";
2
2
  import { getDaemonToolExecutionContext } from "./daemon-tool-context.js";
3
- export const LETAGENTS_AGENT_SESSION_BEARER_ENV = "LETAGENTS_AGENT_SESSION_BEARER";
4
3
  export const LETAGENTS_SUPERVISED_BOUNDED_TURNS_ENV = "LETAGENTS_SUPERVISED_BOUNDED_TURNS";
5
4
  export class WorkerBearerRuntimeConfigurationError extends Error {
6
5
  constructor(message) {
@@ -75,9 +74,6 @@ export function requireValidWorkerBearerRuntime() {
75
74
  }
76
75
  return runtime;
77
76
  }
78
- export function isSupervisedBoundedTurn() {
79
- return hasSupervisedWorkerAuthority() && !isCustodialPolling();
80
- }
81
77
  export function isCustodialPolling() {
82
78
  return process.env.LETAGENTS_EXECUTION_PROFILE?.trim() === "supervised_mcp_polling";
83
79
  }
@@ -1,17 +1,21 @@
1
1
  import { createHash, randomBytes, randomUUID } from "node:crypto";
2
2
  import { getLocalStatePath, readLocalStateSnapshot, updateLocalState, } from "../../local-state/storage.js";
3
3
  import { isLocalRoomStorageEnabled, resolveLocalRoomStorageIdentifiers } from "../../local-state.js";
4
- import { assertWorkerConnection, pinWorkerConnection, pinnedWorkerConnection, withWorkerCall } from "../../worker-call-context.js";
4
+ import { assertWorkerConnection, heldWorkerConnection, pinWorkerConnection, pinnedWorkerConnection, withWorkerCall } from "../../worker-call-context.js";
5
5
  import { isMcpWorkerId } from "../../../shared/mcp-worker.js";
6
- import { pickLocalCodename } from "../../../shared/codenames.js";
6
+ import { AGENT_CODENAME_SPACE, pickLocalCodename } from "../../../shared/codenames.js";
7
7
  import { buildAgentActorLabel } from "../../../shared/agent-identity.js";
8
+ import { normalizeRoutingHandle, normalizeRoutingSender } from "../../../../shared/routing-aliases.mjs";
8
9
  import { encodeRoomIdPath } from "../../room-id.js";
9
10
  import { getGitCurrentBranch } from "../../git-remote.js";
10
- import { apiCall, getApiUrl, getLetagentsToken } from "./api.js";
11
+ import { apiCall, apiCallWhenFree, getApiUrl, getLetagentsToken } from "./api.js";
11
12
  import { resolveOwnerContext } from "./identity/directory.js";
12
13
  import { detectAgentIdeLabel, detectAgentRuntimeLabel } from "./identity/config.js";
13
- import { getSessionLivenessRegistration } from "./identity/liveness.js";
14
+ import { getProcessHostId, getSessionLivenessRegistration } from "./identity/liveness.js";
14
15
  import { requireValidWorkerBearerRuntime } from "./worker-bearer.js";
16
+ import { holdProcessConnection } from "./process-connection.js";
17
+ // What the room said of the registration just made, kept for its caller.
18
+ const adoptedBySession = new Map();
15
19
  const connecting = new Map();
16
20
  function snapshot() {
17
21
  const result = readLocalStateSnapshot();
@@ -37,16 +41,88 @@ function getWorker(workerId, scope) {
37
41
  }
38
42
  return worker;
39
43
  }
44
+ export function readAdoptedTaskLeases(value) {
45
+ if (!Array.isArray(value))
46
+ return [];
47
+ return value.flatMap((entry) => {
48
+ const lease = entry;
49
+ return lease && typeof lease.lease_id === "string" && typeof lease.task_id === "string"
50
+ && typeof lease.kind === "string"
51
+ ? [{ lease_id: lease.lease_id, task_id: lease.task_id, kind: lease.kind }] : [];
52
+ });
53
+ }
54
+ /**
55
+ * The workers this process holds in a room under a name.
56
+ *
57
+ * A chat that has lost its worker_id registers as if it were new, and asks
58
+ * for the name it knows itself by. This process still holds the worker it
59
+ * registered as, so the room sees that name held by an agent that is there,
60
+ * and gives the chat another. It is then a second agent, under a name it
61
+ * does not know, with none of its tasks.
62
+ *
63
+ * A worker_id is what lets a chat act as a worker, so it is not given to
64
+ * every chat that asks for a worker's name: two chats served by one process
65
+ * may ask for the same name, and each is its own worker. A chat is told only
66
+ * that the name is held here, and how to say that it is the one holding it.
67
+ */
68
+ function workersThisProcessHoldsUnder(displayName, scope, roomId) {
69
+ const asked = displayName?.trim();
70
+ if (!asked)
71
+ return [];
72
+ const nameKey = (name) => normalizeRoutingHandle(name) || normalizeRoutingSender(name);
73
+ const state = snapshot();
74
+ return Object.values(state.mcp_workers ?? {}).flatMap((worker) => {
75
+ if (worker.scope !== scope)
76
+ return [];
77
+ const sessionId = worker.rooms[roomId]?.session_id;
78
+ const session = sessionId ? state.agent_sessions?.[sessionId] : undefined;
79
+ if (!sessionId || !session || session.ended_at)
80
+ return [];
81
+ const held = heldWorkerConnection(getLocalStatePath(), sessionId);
82
+ if (!held || held.session_token !== session.session_token)
83
+ return [];
84
+ // The name it lives under, which is the name it knows itself by. A
85
+ // worker that asked for this name and was given another is not it.
86
+ if (nameKey(session.display_name) !== nameKey(asked))
87
+ return [];
88
+ return [{ worker_id: worker.worker_id, display_name: session.display_name }];
89
+ });
90
+ }
91
+ /**
92
+ * A chat says that it registered under this name and has lost its handle. If
93
+ * this process holds one worker under the name in the room, that is the
94
+ * worker, and the chat has it back.
95
+ */
96
+ export async function recoverMcpWorker(input) {
97
+ const scope = await workerScope();
98
+ const identifiers = await resolveLocalRoomStorageIdentifiers(input.roomId);
99
+ const held = workersThisProcessHoldsUnder(input.displayName, scope, identifiers.cloudRoomId || input.roomId);
100
+ if (held.length === 0) {
101
+ throw new Error(`This MCP server holds no worker named "${input.displayName.trim()}" in this room, so there is nothing to recover. `
102
+ + "Register as a new worker with a registration_key.");
103
+ }
104
+ if (held.length > 1) {
105
+ throw new Error(`This MCP server holds more than one worker named "${input.displayName.trim()}" in this room and cannot tell `
106
+ + "which one this chat is. Register as a new worker with a registration_key.");
107
+ }
108
+ return registerMcpWorker({ roomId: input.roomId, workerId: held[0].worker_id, runtime: input.runtime, cwd: input.cwd });
109
+ }
40
110
  export async function registerMcpWorker(input) {
41
111
  const scope = await workerScope();
42
112
  if (input.workerId && input.registrationKey)
43
113
  throw new Error("Use worker_id to resume or registration_key to create, not both.");
44
114
  let workerId = input.workerId;
115
+ let nameHeldHere = null;
116
+ const identifiers = await resolveLocalRoomStorageIdentifiers(input.roomId);
117
+ const roomId = identifiers.cloudRoomId || input.roomId;
45
118
  if (!workerId) {
46
119
  if (!input.registrationKey?.trim())
47
120
  throw new Error("A new chat must supply its own registration_key and retain the returned worker_id.");
48
121
  const keyHash = createHash("sha256").update(`${scope}\n${input.registrationKey}`).digest("hex");
49
- snapshot();
122
+ const known = Object.values(snapshot().mcp_workers ?? {})
123
+ .some((worker) => worker.scope === scope && worker.registration_key_hash === keyHash);
124
+ if (!known)
125
+ nameHeldHere = workersThisProcessHoldsUnder(input.displayName, scope, roomId)[0]?.display_name ?? null;
50
126
  updateLocalState((state) => {
51
127
  state.mcp_workers ??= {};
52
128
  const prior = Object.values(state.mcp_workers).find((w) => w.scope === scope && w.registration_key_hash === keyHash);
@@ -59,8 +135,6 @@ export async function registerMcpWorker(input) {
59
135
  });
60
136
  }
61
137
  const worker = getWorker(workerId, scope);
62
- const identifiers = await resolveLocalRoomStorageIdentifiers(input.roomId);
63
- const roomId = identifiers.cloudRoomId || input.roomId;
64
138
  const local = await isLocalRoomStorageEnabled(input.roomId);
65
139
  const key = `${getLocalStatePath()}\n${worker.worker_id}\n${roomId}`;
66
140
  const inFlight = connecting.get(key);
@@ -69,7 +143,14 @@ export async function registerMcpWorker(input) {
69
143
  const result = connectWorker(input, worker, scope, roomId, local);
70
144
  connecting.set(key, result);
71
145
  try {
72
- return await result;
146
+ const connection = await result;
147
+ const adopted = adoptedBySession.get(connection.session.session_id);
148
+ adoptedBySession.delete(connection.session.session_id);
149
+ return {
150
+ ...connection,
151
+ ...(nameHeldHere ? { name_held_here: nameHeldHere } : {}),
152
+ ...(adopted?.length ? { adopted_task_leases: adopted } : {}),
153
+ };
73
154
  }
74
155
  finally {
75
156
  connecting.delete(key);
@@ -80,8 +161,11 @@ async function connectWorker(input, worker, scope, roomId, local) {
80
161
  const current = currentId ? snapshot().agent_sessions?.[currentId] : undefined;
81
162
  const pinned = currentId ? pinnedWorkerConnection(getLocalStatePath(), currentId) : null;
82
163
  if (pinned && current && !current.ended_at && pinned.session_token === current.session_token
83
- && !worker.rooms[roomId]?.pending)
164
+ && !worker.rooms[roomId]?.pending) {
165
+ if (!local)
166
+ holdProcessConnection(pinned);
84
167
  return { worker, session: pinned };
168
+ }
85
169
  const owner = await resolveOwnerContext();
86
170
  if (!local && !owner.login)
87
171
  throw new Error("Sign in before registering a worker in a hosted room.");
@@ -114,13 +198,14 @@ async function connectWorker(input, worker, scope, roomId, local) {
114
198
  };
115
199
  }
116
200
  else {
117
- const created = await apiCall(`/rooms/${encodeRoomIdPath(roomId)}/agent-sessions`, {
201
+ const created = await apiCallWhenFree(`/rooms/${encodeRoomIdPath(roomId)}/agent-sessions`, {
118
202
  method: "POST", body: JSON.stringify({
119
203
  actor_key: agent.canonical_key, agent_instance_id: worker.worker_id,
120
204
  display_name: worker.display_name, requested_base_display_name: worker.display_name,
121
205
  session_kind: "worker", runtime: input.runtime || detectAgentRuntimeLabel(), ide_label: ide,
122
206
  repo_branch: getGitCurrentBranch(input.cwd),
123
207
  registration_liveness: getSessionLivenessRegistration(input.runtime || detectAgentRuntimeLabel()),
208
+ process_host_id: getProcessHostId(),
124
209
  connection_token: operation.connection_token,
125
210
  replace_agent_session_id: operation.predecessor_id ?? null,
126
211
  replace_agent_session_token: operation.predecessor_token ?? null,
@@ -132,8 +217,11 @@ async function connectWorker(input, worker, scope, roomId, local) {
132
217
  throw new Error("The server did not confirm this worker connection. Upgrade the API before using durable worker handles.");
133
218
  }
134
219
  // Keep only the session credential; the server's optional bearer is not used here.
135
- const { assigned_base_display_name, worker_bearer: _unusedBearer, ...record } = created;
220
+ const { assigned_base_display_name, worker_bearer: _unusedBearer, adopted_task_leases, ...record } = created;
136
221
  session = { ...record, requested_base_display_name: assigned_base_display_name ?? worker.display_name };
222
+ const adopted = readAdoptedTaskLeases(adopted_task_leases);
223
+ if (adopted.length > 0)
224
+ adoptedBySession.set(session.session_id, adopted);
137
225
  }
138
226
  updateLocalState((state) => {
139
227
  const target = state.mcp_workers?.[worker.worker_id]?.rooms[roomId];
@@ -141,12 +229,16 @@ async function connectWorker(input, worker, scope, roomId, local) {
141
229
  throw new Error("Worker registration was superseded; retry explicitly.");
142
230
  state.agent_sessions ??= {};
143
231
  if (local && !target.session_id) {
232
+ // Mirror the hosted allocator: names are compared the way mentions
233
+ // are, and a held name receives its own codename, never a numbered
234
+ // variant that cannot be mentioned as typed.
235
+ const nameKey = (name) => normalizeRoutingHandle(name) || normalizeRoutingSender(name);
144
236
  const used = new Set(Object.values(state.agent_sessions)
145
237
  .filter((other) => other.room_id === roomId && other.session_id !== session.session_id)
146
- .map((other) => other.display_name));
147
- let suffix = 1;
148
- while (used.has(session.display_name))
149
- session.display_name = `${worker.display_name} ${suffix++}`;
238
+ .map((other) => nameKey(other.display_name)));
239
+ for (let offset = 1; used.has(nameKey(session.display_name)) && offset <= AGENT_CODENAME_SPACE; offset += 1) {
240
+ session.display_name = pickLocalCodename(`${agent.canonical_key}:${offset}`).display_name;
241
+ }
150
242
  session.actor_label = buildAgentActorLabel({ display_name: session.display_name, owner_label: owner.label, ide_label: ide });
151
243
  }
152
244
  state.agent_sessions[session.session_id] = session;
@@ -174,6 +266,8 @@ async function connectWorker(input, worker, scope, roomId, local) {
174
266
  });
175
267
  const session = await finish(operation);
176
268
  pinWorkerConnection(getLocalStatePath(), session);
269
+ if (!local)
270
+ holdProcessConnection(session);
177
271
  return { worker: getWorker(worker.worker_id, scope), session };
178
272
  }
179
273
  export async function runMcpWorkerCall(workerId, roomId, callback, allowEnded = false) {
@@ -1,5 +1,6 @@
1
1
  import { registerTools } from "./register-tools.js";
2
2
  export const LETAGENTS_RUNTIME_CONTRACT_ARG = "--letagents-runtime-contract";
3
+ export const LETAGENTS_RUNTIME_READINESS_URI = "letagents://runtime/readiness";
3
4
  /**
4
5
  * Discover through the production registration path rather than maintaining a
5
6
  * second capability list that can drift from the MCP server.
@@ -15,6 +16,16 @@ export function registeredToolNames(profile, supervisedProvider = null, apiUrl =
15
16
  registerTools(recorder, profile, supervisedProvider, { apiUrl });
16
17
  return [...names].sort();
17
18
  }
19
+ /** Public capability metadata only; reading it grants no room or tool authority. */
20
+ export function registerRuntimeReadinessResource(server, profile, supervisedProvider, apiUrl = process.env.LETAGENTS_API_URL) {
21
+ const text = JSON.stringify({
22
+ format: 1, profile, provider: supervisedProvider,
23
+ tools: registeredToolNames(profile, supervisedProvider, apiUrl),
24
+ });
25
+ server.resource("runtime_readiness", LETAGENTS_RUNTIME_READINESS_URI, { mimeType: "application/json" }, async (uri) => ({
26
+ contents: [{ uri: uri.href, mimeType: "application/json", text }],
27
+ }));
28
+ }
18
29
  export function letAgentsRuntimeContract(apiUrl = process.env.LETAGENTS_API_URL) {
19
30
  return {
20
31
  format: 1,
@@ -22,4 +22,4 @@ export { getRememberedRoomPresence, heartbeatRoomPresence, syncRoomPresence, } f
22
22
  export { roomScopedApiCall } from "./runtime/room-api.js";
23
23
  export { borrowSupervisedWorkerCredential, borrowCurrentSupervisedWorkerCredential, bindSupervisedWorkerSession, checkpointSupervisedWorkerCursor, isRetryableSupervisorBridgeError, scheduleSupervisedWorkerCursorCheckpoint, resolveCurrentSupervisedWorkerSession, } from "./runtime/supervisor-bridge.js";
24
24
  export { autoJoinFromContext, buildJoinResponse, createInviteRoom, getCurrentLiveSessionPayload, joinInviteCode, joinNamedRoom, joinRoomIdentifier, joinRoomIdentifierWithoutImplicitGitRefCreate, normalizeJoinSessionMode, } from "./runtime/rooms.js";
25
- export { clearPendingDeviceAuth, clearStoredAuth, clearStoredAuth as clearStoredAuthorization, endStoredAgentSession, getCurrentAgentSession, getLocalStatePath, getPendingDeviceAuth, getStoredAgentIdentity, getStoredAgentSession, getCurrentAgentSessionSnapshot, getStoredAgentSessionsForRoomIdentity, getStoredActiveAgentSessionsForRoom, getStoredAgentRoutingStateSnapshot, getStoredAuth, getStoredCurrentRoom, getStoredRoomSession, listStoredCodexLiveSessions, saveAgentSession, setPendingDeviceAuth, setStoredAuth, setStoredAgentIdentity, addLocalChatMessage, addLocalTask, claimLocalTaskReviewLease, getLatestLocalChatMessages, getLocalImportedRoutingAuthority, getLocalChatMessages, getLocalChatThreadRoutingMembership, getLocalTask, listLocalActiveTaskOwnerLeases, listLocalTasks, isLocalChatStorageEnabled, resolveLocalRoomStorageIdentifiers, releaseLocalTaskReviewLease, updateLocalTask, waitForLocalChatMessages, } from "../local-state.js";
25
+ export { clearPendingDeviceAuth, clearStoredAuth, endStoredAgentSession, getCurrentAgentSession, getLocalStatePath, getPendingDeviceAuth, getStoredAgentIdentity, getStoredAgentSession, getCurrentAgentSessionSnapshot, getStoredAgentSessionsForRoomIdentity, getStoredActiveAgentSessionsForRoom, getStoredAgentRoutingStateSnapshot, getStoredAuth, getStoredCurrentRoom, getStoredRoomSession, listStoredCodexLiveSessions, saveAgentSession, setPendingDeviceAuth, setStoredAuth, setStoredAgentIdentity, addLocalChatMessage, addLocalTask, claimLocalTaskReviewLease, getLatestLocalChatMessages, getLocalImportedRoutingAuthority, getLocalChatMessages, getLocalChatThreadRoutingMembership, getLocalTask, listLocalActiveTaskOwnerLeases, listLocalTasks, isLocalChatStorageEnabled, resolveLocalRoomStorageIdentifiers, releaseLocalTaskReviewLease, updateLocalTask, waitForLocalChatMessages, } from "../local-state.js";
@@ -9,7 +9,7 @@ const READ_TOOLS = new Set([
9
9
  "wait_for_messages",
10
10
  "get_board",
11
11
  "get_board_settings",
12
- "get_room_memory", "get_human_requests",
12
+ "get_room_memory", "get_human_requests", "get_room_guidelines",
13
13
  "get_room_artifacts",
14
14
  "get_room_events",
15
15
  "list_board_intents",