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.
- package/README.md +1 -1
- package/dist/mcp/local-state/storage.js +0 -5
- package/dist/mcp/server/runtime/api.js +53 -1
- package/dist/mcp/server/runtime/identity/liveness.js +19 -1
- package/dist/mcp/server/runtime/process-connection.js +239 -0
- package/dist/mcp/server/runtime/worker-bearer.js +0 -4
- package/dist/mcp/server/runtime/worker-handles.js +109 -15
- package/dist/mcp/server/runtime-contract.js +11 -0
- package/dist/mcp/server/runtime.js +1 -1
- package/dist/mcp/server/supervised-tool-facade.js +1 -1
- package/dist/mcp/server/tools/agent-sessions.js +38 -6
- package/dist/mcp/server/tools/knowledge.js +29 -0
- package/dist/mcp/server/tools/rooms/inspection-tools.js +1 -1
- package/dist/mcp/server.js +21 -9
- package/dist/mcp/worker-call-context.js +4 -0
- package/dist/shared/agent-presence.js +141 -0
- package/dist/shared/codenames.js +2 -104
- package/dist/shared/handoff.js +0 -6
- package/dist/shared/request-headers.js +11 -0
- package/dist/shared/room-agent-prompts.js +1 -1
- package/package.json +4 -5
- package/shared/activation-routing.d.mts +5 -0
- package/shared/activation-routing.mjs +10 -1
- package/shared/agent-codenames.d.mts +17 -0
- package/shared/agent-codenames.mjs +96 -0
- package/shared/daemon-maintenance.d.mts +13 -0
- package/shared/daemon-maintenance.mjs +99 -0
- package/shared/permission-review.d.mts +30 -0
- package/shared/permission-review.mjs +482 -0
- package/shared/provider-acquisition-evidence.d.mts +15 -0
- package/shared/provider-acquisition-evidence.mjs +33 -0
- package/shared/room-agent-work.d.mts +1 -0
- package/shared/room-agent-work.mjs +11 -0
- package/shared/room-settings.d.mts +48 -0
- package/shared/room-settings.mjs +108 -0
- package/shared/ui/PrivateMessages.vue +12 -17
- 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 — **
|
|
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
|
-
|
|
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
|
|
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
|
|
148
|
-
|
|
149
|
-
|
|
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,
|
|
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",
|