letagents 0.6.0 → 0.8.0

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.
@@ -8,11 +8,16 @@ import { execSync } from "child_process";
8
8
  import { SseClient } from "./sse-client.js";
9
9
  import { getRoomFromConfig } from "./config-reader.js";
10
10
  import { getGitRemoteIdentity } from "./git-remote.js";
11
+ import { clearPendingDeviceAuth, clearStoredAuth, getLocalStatePath, getPendingDeviceAuth, getStoredAuth, getStoredCurrentRoom, getStoredRoomSession, saveRoomSession, setPendingDeviceAuth, setStoredAuth, touchRoomSession, } from "./local-state.js";
12
+ import { encodeRoomIdPath, looksLikeInviteCode, normalizeInviteCode, } from "./room-id.js";
11
13
  let currentRoom = null;
12
14
  // ---------------------------------------------------------------------------
13
15
  // Config
14
16
  // ---------------------------------------------------------------------------
15
- const API_URL = process.env.LETAGENTS_API_URL || "http://localhost:3001";
17
+ const API_URL = (process.env.LETAGENTS_API_URL || "http://localhost:3001").replace(/\/+$/, "");
18
+ const AGENT_NAME = (process.env.LETAGENTS_AGENT_NAME || process.env.AGENT_NAME || "").trim();
19
+ const AGENT_DISPLAY_NAME = (process.env.LETAGENTS_AGENT_DISPLAY_NAME || "").trim();
20
+ const AGENT_OWNER_LABEL = (process.env.LETAGENTS_AGENT_OWNER_LABEL || "").trim();
16
21
  // ---------------------------------------------------------------------------
17
22
  // Helpers
18
23
  // ---------------------------------------------------------------------------
@@ -49,19 +54,282 @@ function findExistingConfig(startDir) {
49
54
  }
50
55
  return null;
51
56
  }
57
+ class ApiError extends Error {
58
+ status;
59
+ body;
60
+ constructor(status, body) {
61
+ super(`API error ${status}: ${body}`);
62
+ this.name = "ApiError";
63
+ this.status = status;
64
+ this.body = body;
65
+ }
66
+ }
67
+ function getLetagentsToken() {
68
+ return process.env.LETAGENTS_TOKEN || getStoredAuth()?.token || "";
69
+ }
70
+ function getAuthorizationHeader() {
71
+ const letagentsToken = getLetagentsToken();
72
+ if (letagentsToken) {
73
+ return `Bearer ${letagentsToken}`;
74
+ }
75
+ return null;
76
+ }
77
+ function isMissingRouteError(error) {
78
+ return (error instanceof ApiError &&
79
+ (error.status === 404 || error.status === 405) &&
80
+ /Cannot (GET|POST|PATCH)|Not Found|Cannot GET \/rooms|Cannot POST \/rooms/i.test(error.body));
81
+ }
52
82
  async function apiCall(path, options) {
83
+ const headers = {
84
+ "Content-Type": "application/json",
85
+ ...options?.headers,
86
+ };
87
+ const authorizationHeader = getAuthorizationHeader();
88
+ if (authorizationHeader && !headers.Authorization) {
89
+ headers.Authorization = authorizationHeader;
90
+ }
53
91
  const res = await fetch(`${API_URL}${path}`, {
54
92
  ...options,
55
- headers: {
56
- "Content-Type": "application/json",
57
- ...options?.headers,
58
- },
93
+ headers,
59
94
  });
60
95
  if (!res.ok) {
61
96
  const body = await res.text();
62
- throw new Error(`API error ${res.status}: ${body}`);
97
+ if (res.status === 401) {
98
+ // Only clear on 401 (invalid/expired credential), NOT on 403
99
+ // (valid credential but insufficient permissions, e.g., private repo access)
100
+ clearStoredAuth();
101
+ }
102
+ throw new ApiError(res.status, body);
103
+ }
104
+ const body = await res.text();
105
+ if (!body) {
106
+ return null;
107
+ }
108
+ return JSON.parse(body);
109
+ }
110
+ function toRoomState(input) {
111
+ return {
112
+ room_id: input.room_id,
113
+ project_id: input.project_id ?? null,
114
+ code: input.code ?? null,
115
+ joined_via: input.joined_via,
116
+ };
117
+ }
118
+ function toPublicRoomState(state) {
119
+ if (!state) {
120
+ return null;
121
+ }
122
+ return {
123
+ room_id: state.room_id,
124
+ code: state.code ?? null,
125
+ joined_via: state.joined_via,
126
+ };
127
+ }
128
+ function toPublicStoredRoomSession(session) {
129
+ if (!session) {
130
+ return null;
131
+ }
132
+ return {
133
+ room_id: session.room_id,
134
+ code: session.code ?? null,
135
+ joined_via: session.joined_via,
136
+ joined_at: session.joined_at,
137
+ last_seen_at: session.last_seen_at,
138
+ last_message_id: session.last_message_id ?? null,
139
+ };
140
+ }
141
+ function toPublicRoomResponse(response, fallbackRoomId) {
142
+ const { id: _legacyId, project_id: _legacyProjectId, ...rest } = response;
143
+ return {
144
+ ...rest,
145
+ room_id: typeof rest.room_id === "string" ? rest.room_id : fallbackRoomId,
146
+ };
147
+ }
148
+ function rememberRoom(state, lastMessageId) {
149
+ currentRoom = state;
150
+ saveRoomSession({
151
+ room_id: state.room_id,
152
+ project_id: state.project_id ?? null,
153
+ code: state.code ?? null,
154
+ joined_via: state.joined_via,
155
+ last_message_id: lastMessageId,
156
+ });
157
+ sseClient.unsubscribeAll();
158
+ sseClient.subscribe({
159
+ roomId: state.room_id,
160
+ projectId: state.project_id ?? null,
161
+ }, (_message) => {
162
+ touchRoomSession(state.room_id);
163
+ server.server.sendResourceListChanged();
164
+ });
165
+ return state;
166
+ }
167
+ function touchCurrentRoom(lastMessageId) {
168
+ if (!currentRoom) {
169
+ return;
170
+ }
171
+ touchRoomSession(currentRoom.room_id, lastMessageId);
172
+ }
173
+ function getTargetRoomId(roomId) {
174
+ return roomId || currentRoom?.room_id || null;
175
+ }
176
+ function getFallbackProjectId() {
177
+ return currentRoom?.project_id ?? null;
178
+ }
179
+ function getLastMessageId(payload) {
180
+ if (!payload || typeof payload !== "object") {
181
+ return undefined;
182
+ }
183
+ const messages = payload.messages;
184
+ const lastMessage = messages?.at(-1);
185
+ return typeof lastMessage?.id === "string" ? lastMessage.id : undefined;
186
+ }
187
+ async function roomScopedApiCall(input) {
188
+ if (input.room_id) {
189
+ try {
190
+ const result = await apiCall(input.room_path(input.room_id), input.options);
191
+ touchRoomSession(input.room_id, getLastMessageId(result));
192
+ return result;
193
+ }
194
+ catch (error) {
195
+ if (!input.project_id || !isMissingRouteError(error)) {
196
+ throw error;
197
+ }
198
+ }
199
+ }
200
+ if (!input.project_id) {
201
+ throw new Error("No room is available for this request.");
202
+ }
203
+ const result = await apiCall(input.project_path(input.project_id), input.options);
204
+ if (input.room_id) {
205
+ touchRoomSession(input.room_id, getLastMessageId(result));
206
+ }
207
+ return result;
208
+ }
209
+ async function joinRoomIdentifier(identifier, joinedVia) {
210
+ const roomId = joinedVia === "join_code" ? normalizeInviteCode(identifier) : identifier.trim();
211
+ try {
212
+ const response = await apiCall(`/rooms/${encodeRoomIdPath(roomId)}/join`, { method: "POST" });
213
+ const joinedRoomId = typeof response.room_id === "string"
214
+ ? response.room_id
215
+ : roomId;
216
+ const room = rememberRoom(toRoomState({
217
+ room_id: joinedRoomId,
218
+ project_id: typeof response.project_id === "string" ? response.project_id : null,
219
+ code: typeof response.code === "string"
220
+ ? response.code
221
+ : looksLikeInviteCode(joinedRoomId)
222
+ ? joinedRoomId
223
+ : null,
224
+ joined_via: joinedVia,
225
+ }));
226
+ return {
227
+ room,
228
+ response: { ...response, room_id: joinedRoomId },
229
+ };
230
+ }
231
+ catch (error) {
232
+ if (!isMissingRouteError(error)) {
233
+ throw error;
234
+ }
235
+ }
236
+ if (joinedVia === "join_code") {
237
+ const project = await apiCall(`/projects/join/${encodeURIComponent(roomId)}`);
238
+ const legacyRoomId = typeof project.code === "string"
239
+ ? project.code
240
+ : roomId;
241
+ const room = rememberRoom(toRoomState({
242
+ room_id: legacyRoomId,
243
+ project_id: typeof project.id === "string" ? project.id : null,
244
+ code: typeof project.code === "string" ? project.code : legacyRoomId,
245
+ joined_via: joinedVia,
246
+ }));
247
+ return {
248
+ room,
249
+ response: {
250
+ ...project,
251
+ room_id: legacyRoomId,
252
+ project_id: typeof project.id === "string" ? project.id : null,
253
+ },
254
+ };
255
+ }
256
+ const project = await apiCall(`/projects/room/${encodeURIComponent(roomId)}`, { method: "POST" });
257
+ const legacyRoomId = typeof project.name === "string" && project.name.trim()
258
+ ? project.name
259
+ : typeof project.code === "string" && project.code.trim()
260
+ ? project.code
261
+ : roomId;
262
+ const room = rememberRoom(toRoomState({
263
+ room_id: legacyRoomId,
264
+ project_id: typeof project.id === "string" ? project.id : null,
265
+ code: typeof project.code === "string"
266
+ ? project.code
267
+ : looksLikeInviteCode(legacyRoomId)
268
+ ? legacyRoomId
269
+ : null,
270
+ joined_via: joinedVia,
271
+ }));
272
+ return {
273
+ room,
274
+ response: {
275
+ ...project,
276
+ room_id: legacyRoomId,
277
+ project_id: typeof project.id === "string" ? project.id : null,
278
+ },
279
+ };
280
+ }
281
+ async function createInviteRoom() {
282
+ const project = await apiCall("/projects", { method: "POST" });
283
+ const roomId = typeof project.code === "string"
284
+ ? project.code
285
+ : typeof project.id === "string"
286
+ ? project.id
287
+ : "unknown-room";
288
+ const room = rememberRoom(toRoomState({
289
+ room_id: roomId,
290
+ project_id: typeof project.id === "string" ? project.id : null,
291
+ code: typeof project.code === "string" ? project.code : roomId,
292
+ joined_via: "join_code",
293
+ }));
294
+ await autoRegisterAgentIdentity();
295
+ return {
296
+ room,
297
+ response: toPublicRoomResponse(project, roomId),
298
+ };
299
+ }
300
+ async function joinInviteCode(code) {
301
+ const joined = await joinRoomIdentifier(code, "join_code");
302
+ await autoRegisterAgentIdentity();
303
+ return {
304
+ ...toPublicRoomResponse(joined.response, joined.room.room_id),
305
+ joined_via: "join_code",
306
+ };
307
+ }
308
+ async function joinNamedRoom(name) {
309
+ const joined = await joinRoomIdentifier(name, "join_room");
310
+ await autoRegisterAgentIdentity();
311
+ return {
312
+ ...toPublicRoomResponse(joined.response, joined.room.room_id),
313
+ joined_via: "join_room",
314
+ };
315
+ }
316
+ async function autoRegisterAgentIdentity() {
317
+ if (!AGENT_NAME || !currentRoom) {
318
+ return;
319
+ }
320
+ try {
321
+ await apiCall("/agents", {
322
+ method: "POST",
323
+ body: JSON.stringify({
324
+ name: AGENT_NAME,
325
+ display_name: AGENT_DISPLAY_NAME || AGENT_NAME,
326
+ owner_label: AGENT_OWNER_LABEL || undefined,
327
+ }),
328
+ });
329
+ }
330
+ catch (error) {
331
+ console.error("Agent identity registration failed:", error);
63
332
  }
64
- return res.json();
65
333
  }
66
334
  // ---------------------------------------------------------------------------
67
335
  // MCP Server
@@ -70,14 +338,22 @@ const server = new McpServer({
70
338
  name: "letagents",
71
339
  version: "0.2.0",
72
340
  });
73
- const sseClient = new SseClient(API_URL);
341
+ const sseClient = new SseClient(API_URL, () => getLetagentsToken());
74
342
  // ---------------------------------------------------------------------------
75
343
  // MCP Resources
76
344
  // ---------------------------------------------------------------------------
77
- server.resource("project_messages", new ResourceTemplate("letagents://projects/{project_id}/messages", {
345
+ server.resource("room_messages", new ResourceTemplate("letagents://rooms/{room_id}/messages", {
78
346
  list: undefined,
79
- }), async (uri, { project_id }) => {
80
- const result = await apiCall(`/projects/${encodeURIComponent(project_id)}/messages`);
347
+ }), async (uri, { room_id }) => {
348
+ const normalizedRoomId = String(room_id);
349
+ const storedSession = getStoredRoomSession(normalizedRoomId) ??
350
+ (currentRoom?.room_id === normalizedRoomId ? getStoredCurrentRoom() : null);
351
+ const result = await roomScopedApiCall({
352
+ room_id: normalizedRoomId,
353
+ project_id: storedSession?.project_id ?? null,
354
+ room_path: (targetRoomId) => `/rooms/${encodeRoomIdPath(targetRoomId)}/messages`,
355
+ project_path: (projectId) => `/projects/${encodeURIComponent(projectId)}/messages`,
356
+ });
81
357
  return {
82
358
  contents: [
83
359
  {
@@ -88,43 +364,52 @@ server.resource("project_messages", new ResourceTemplate("letagents://projects/{
88
364
  ],
89
365
  };
90
366
  });
367
+ // -- create_room ------------------------------------------------------------
368
+ server.tool("create_room", "Create a new invite room on Let Agents Chat. Returns the room ID and join code.", {}, async () => {
369
+ const created = await createInviteRoom();
370
+ return {
371
+ content: [
372
+ {
373
+ type: "text",
374
+ text: JSON.stringify(created.response, null, 2),
375
+ },
376
+ ],
377
+ };
378
+ });
91
379
  // -- create_project ---------------------------------------------------------
92
- server.tool("create_project", "Create a new project on Let Agents Chat. Returns a project ID and a join code that other agents can use to join.", {}, async () => {
93
- const project = await apiCall("/projects", { method: "POST" });
94
- // Auto-subscribe to SSE for this project
95
- sseClient.subscribe(project.id, (_message) => {
96
- server.server.sendResourceListChanged();
97
- });
380
+ server.tool("create_project", "Legacy alias for create_room. Creates a new invite room and returns its join code.", {}, async () => {
381
+ const created = await createInviteRoom();
98
382
  return {
99
383
  content: [
100
384
  {
101
385
  type: "text",
102
- text: JSON.stringify(project, null, 2),
386
+ text: JSON.stringify(created.response, null, 2),
103
387
  },
104
388
  ],
105
389
  };
106
390
  });
107
- // -- join_project -----------------------------------------------------------
108
- server.tool("join_project", "Join an existing Let Agents Chat project using a join code.", {
109
- code: z.string().describe("The join code shared by the project creator (e.g. 'ABCX-7291')"),
391
+ // -- join_code --------------------------------------------------------------
392
+ server.tool("join_code", "Join an existing room using an invite code.", {
393
+ code: z.string().describe("The invite code shared for the room (e.g. 'ABCX-7291')"),
110
394
  }, async ({ code }) => {
111
- const project = await apiCall(`/projects/join/${encodeURIComponent(code)}`);
112
- // Track room state
113
- currentRoom = {
114
- room: project.name || code,
115
- project_id: project.id,
116
- code: project.code || code,
117
- joined_via: "join_code",
395
+ return {
396
+ content: [
397
+ {
398
+ type: "text",
399
+ text: JSON.stringify(await joinInviteCode(code), null, 2),
400
+ },
401
+ ],
118
402
  };
119
- // Auto-subscribe to SSE for this project
120
- sseClient.subscribe(project.id, (_message) => {
121
- server.server.sendResourceListChanged();
122
- });
403
+ });
404
+ // -- join_project -----------------------------------------------------------
405
+ server.tool("join_project", "Legacy alias for join_code. Join an existing room using an invite code.", {
406
+ code: z.string().describe("The invite code shared for the room (e.g. 'ABCX-7291')"),
407
+ }, async ({ code }) => {
123
408
  return {
124
409
  content: [
125
410
  {
126
411
  type: "text",
127
- text: JSON.stringify({ ...project, joined_via: "join_code" }, null, 2),
412
+ text: JSON.stringify(await joinInviteCode(code), null, 2),
128
413
  },
129
414
  ],
130
415
  };
@@ -133,23 +418,11 @@ server.tool("join_project", "Join an existing Let Agents Chat project using a jo
133
418
  server.tool("join_room", "Join a named room on Let Agents Chat. Creates the room if it doesn't exist. Use this for repo-based room joining.", {
134
419
  name: z.string().describe("The room name to join (e.g. 'github.com/owner/repo')"),
135
420
  }, async ({ name }) => {
136
- const project = await apiCall(`/projects/room/${encodeURIComponent(name)}`, { method: "POST" });
137
- // Track room state
138
- currentRoom = {
139
- room: name,
140
- project_id: project.id,
141
- code: project.code,
142
- joined_via: "join_room",
143
- };
144
- // Auto-subscribe to SSE
145
- sseClient.subscribe(project.id, (_message) => {
146
- server.server.sendResourceListChanged();
147
- });
148
421
  return {
149
422
  content: [
150
423
  {
151
424
  type: "text",
152
- text: JSON.stringify({ ...project, joined_via: "join_room" }, null, 2),
425
+ text: JSON.stringify(await joinNamedRoom(name), null, 2),
153
426
  },
154
427
  ],
155
428
  };
@@ -170,7 +443,17 @@ server.tool("get_current_room", "Get information about the currently joined room
170
443
  content: [
171
444
  {
172
445
  type: "text",
173
- text: JSON.stringify({ connected: true, ...currentRoom }, null, 2),
446
+ text: JSON.stringify({
447
+ connected: true,
448
+ ...toPublicRoomState(currentRoom),
449
+ auth: getStoredAuth()
450
+ ? {
451
+ source: process.env.LETAGENTS_TOKEN ? "env" : "local_state",
452
+ expires_at: getStoredAuth()?.expires_at ?? null,
453
+ account: getStoredAuth()?.account ?? null,
454
+ }
455
+ : null,
456
+ }, null, 2),
174
457
  },
175
458
  ],
176
459
  };
@@ -209,11 +492,11 @@ server.tool("check_repo", "Inspect the current repository context for Let Agents
209
492
  config_file: configPath ?? null,
210
493
  config_contents: configContents,
211
494
  derived_room_from_git: derivedRoom ?? null,
212
- current_room: currentRoom ?? null,
495
+ current_room: toPublicRoomState(currentRoom),
213
496
  join_hint: !currentRoom
214
497
  ? repoRoot
215
- ? "Run initialize_repo to set up .letagents.json, or join_room/join_project to connect."
216
- : "Not inside a git repo. Use join_project or join_room to connect manually."
498
+ ? "Run initialize_repo to set up .letagents.json, or use join_room/join_code to connect."
499
+ : "Not inside a git repo. Use create_room, join_code, or join_room to connect manually."
217
500
  : null,
218
501
  }, null, 2),
219
502
  },
@@ -227,21 +510,22 @@ server.tool("post_status", "Broadcast a lightweight status update to the current
227
510
  "Status updates are distinct from chat messages and can be filtered separately.", {
228
511
  sender: z.string().describe("Name of the agent posting the status (e.g. 'codex-agent')"),
229
512
  status: z.string().describe("Short status description (e.g. 'reviewing PR #2', 'idle', 'thinking...')"),
230
- project_id: z
513
+ room_id: z
231
514
  .string()
232
515
  .optional()
233
- .describe("Project ID to post status to. Defaults to the current room if joined."),
234
- }, async ({ sender, status, project_id }) => {
235
- const targetProjectId = project_id || currentRoom?.project_id;
236
- if (!targetProjectId) {
516
+ .describe("Canonical room ID. Defaults to the current room."),
517
+ }, async ({ sender, status, room_id }) => {
518
+ const targetRoomId = getTargetRoomId(room_id);
519
+ const targetProjectId = getFallbackProjectId();
520
+ if (!targetRoomId && !targetProjectId) {
237
521
  return {
238
522
  content: [
239
523
  {
240
524
  type: "text",
241
525
  text: JSON.stringify({
242
526
  success: false,
243
- error: "No project_id provided and not currently in a room.",
244
- hint: "Join a room first with join_project or join_room, or pass project_id explicitly.",
527
+ error: "No room_id provided and not currently in a room.",
528
+ hint: "Join or create a room first, or pass room_id explicitly.",
245
529
  }, null, 2),
246
530
  },
247
531
  ],
@@ -250,10 +534,17 @@ server.tool("post_status", "Broadcast a lightweight status update to the current
250
534
  // Status messages use a reserved prefix so the UI (and agents) can distinguish
251
535
  // them from normal chat messages without changing the data model.
252
536
  const statusText = `[status] ${status}`;
253
- const message = await apiCall(`/projects/${encodeURIComponent(targetProjectId)}/messages`, {
254
- method: "POST",
255
- body: JSON.stringify({ sender, text: statusText }),
537
+ const message = await roomScopedApiCall({
538
+ room_id: targetRoomId,
539
+ project_id: targetProjectId,
540
+ room_path: (targetRoomId) => `/rooms/${encodeRoomIdPath(targetRoomId)}/messages`,
541
+ project_path: (targetProjectId) => `/projects/${encodeURIComponent(targetProjectId)}/messages`,
542
+ options: {
543
+ method: "POST",
544
+ body: JSON.stringify({ sender, text: statusText }),
545
+ },
256
546
  });
547
+ touchCurrentRoom(typeof message.id === "string" ? message.id : undefined);
257
548
  return {
258
549
  content: [
259
550
  {
@@ -262,8 +553,8 @@ server.tool("post_status", "Broadcast a lightweight status update to the current
262
553
  success: true,
263
554
  status_posted: status,
264
555
  sender,
265
- message_id: message.id,
266
- timestamp: message.timestamp,
556
+ message_id: typeof message.id === "string" ? message.id : null,
557
+ timestamp: typeof message.timestamp === "string" ? message.timestamp : null,
267
558
  }, null, 2),
268
559
  },
269
560
  ],
@@ -274,7 +565,7 @@ const TASK_STATUSES = [
274
565
  "proposed", "accepted", "assigned", "in_progress",
275
566
  "blocked", "in_review", "merged", "done", "cancelled",
276
567
  ];
277
- server.tool("add_task", "Add a new task to the project board. Tasks normally start as 'proposed' and must be " +
568
+ server.tool("add_task", "Add a new task to the room board. Tasks normally start as 'proposed' and must be " +
278
569
  "accepted before an agent can claim them, but tasks created by trusted agents already " +
279
570
  "active in the room may be auto-accepted. Use this when a human or agent identifies " +
280
571
  "work that needs to be done.", {
@@ -282,31 +573,39 @@ server.tool("add_task", "Add a new task to the project board. Tasks normally sta
282
573
  description: z.string().optional().describe("Longer description of what needs to be done"),
283
574
  created_by: z.string().describe("Name of the agent or human creating the task"),
284
575
  source_message_id: z.string().optional().describe("Optional message ID where task was agreed, e.g. 'msg_42'"),
285
- project_id: z.string().optional().describe("Project ID. Defaults to current room."),
286
- }, async ({ title, description, created_by, source_message_id, project_id }) => {
287
- const targetProjectId = project_id || currentRoom?.project_id;
288
- if (!targetProjectId) {
576
+ room_id: z.string().optional().describe("Canonical room ID. Defaults to current room."),
577
+ }, async ({ title, description, created_by, source_message_id, room_id }) => {
578
+ const targetRoomId = getTargetRoomId(room_id);
579
+ const targetProjectId = getFallbackProjectId();
580
+ if (!targetRoomId && !targetProjectId) {
289
581
  return {
290
582
  content: [{ type: "text", text: JSON.stringify({ success: false, error: "Not in a room. Join one first." }) }],
291
583
  };
292
584
  }
293
- const task = await apiCall(`/projects/${encodeURIComponent(targetProjectId)}/tasks`, {
294
- method: "POST",
295
- body: JSON.stringify({ title, description, created_by, source_message_id }),
585
+ const task = await roomScopedApiCall({
586
+ room_id: targetRoomId,
587
+ project_id: targetProjectId,
588
+ room_path: (targetRoomId) => `/rooms/${encodeRoomIdPath(targetRoomId)}/tasks`,
589
+ project_path: (targetProjectId) => `/projects/${encodeURIComponent(targetProjectId)}/tasks`,
590
+ options: {
591
+ method: "POST",
592
+ body: JSON.stringify({ title, description, created_by, source_message_id }),
593
+ },
296
594
  });
297
595
  return {
298
596
  content: [{ type: "text", text: JSON.stringify({ success: true, task }, null, 2) }],
299
597
  };
300
598
  });
301
- server.tool("get_board", "Get the current task board for the project. By default shows only open tasks " +
599
+ server.tool("get_board", "Get the current task board for the room. By default shows only open tasks " +
302
600
  "(not done/cancelled). Agents should check this on startup and when idle to " +
303
601
  "see if there is unassigned work to claim.", {
304
602
  status: z.enum(TASK_STATUSES).optional().describe("Filter by specific status"),
305
603
  open_only: z.boolean().optional().describe("If true (default), only show tasks not done/cancelled"),
306
- project_id: z.string().optional().describe("Project ID. Defaults to current room."),
307
- }, async ({ status, open_only, project_id }) => {
308
- const targetProjectId = project_id || currentRoom?.project_id;
309
- if (!targetProjectId) {
604
+ room_id: z.string().optional().describe("Canonical room ID. Defaults to current room."),
605
+ }, async ({ status, open_only, room_id }) => {
606
+ const targetRoomId = getTargetRoomId(room_id);
607
+ const targetProjectId = getFallbackProjectId();
608
+ if (!targetRoomId && !targetProjectId) {
310
609
  return {
311
610
  content: [{ type: "text", text: JSON.stringify({ success: false, error: "Not in a room. Join one first." }) }],
312
611
  };
@@ -317,7 +616,12 @@ server.tool("get_board", "Get the current task board for the project. By default
317
616
  if (open_only !== false)
318
617
  params.set("open", "true");
319
618
  const qs = params.toString();
320
- const result = await apiCall(`/projects/${encodeURIComponent(targetProjectId)}/tasks${qs ? `?${qs}` : ""}`);
619
+ const result = await roomScopedApiCall({
620
+ room_id: targetRoomId,
621
+ project_id: targetProjectId,
622
+ room_path: (targetRoomId) => `/rooms/${encodeRoomIdPath(targetRoomId)}/tasks${qs ? `?${qs}` : ""}`,
623
+ project_path: (targetProjectId) => `/projects/${encodeURIComponent(targetProjectId)}/tasks${qs ? `?${qs}` : ""}`,
624
+ });
321
625
  return {
322
626
  content: [{ type: "text", text: JSON.stringify({ success: true, ...result }, null, 2) }],
323
627
  };
@@ -327,18 +631,25 @@ server.tool("claim_task", "Claim an accepted task. The task must be in 'accepted
327
631
  "Do NOT claim proposed tasks — they need to be accepted first.", {
328
632
  task_id: z.string().describe("The task ID to claim, e.g. 'task_1'"),
329
633
  assignee: z.string().describe("Your agent name, e.g. 'antigravity'"),
330
- project_id: z.string().optional().describe("Project ID. Defaults to current room."),
331
- }, async ({ task_id, assignee, project_id }) => {
332
- const targetProjectId = project_id || currentRoom?.project_id;
333
- if (!targetProjectId) {
634
+ room_id: z.string().optional().describe("Canonical room ID. Defaults to current room."),
635
+ }, async ({ task_id, assignee, room_id }) => {
636
+ const targetRoomId = getTargetRoomId(room_id);
637
+ const targetProjectId = getFallbackProjectId();
638
+ if (!targetRoomId && !targetProjectId) {
334
639
  return {
335
640
  content: [{ type: "text", text: JSON.stringify({ success: false, error: "Not in a room." }) }],
336
641
  };
337
642
  }
338
643
  try {
339
- const updated = await apiCall(`/projects/${encodeURIComponent(targetProjectId)}/tasks/${encodeURIComponent(task_id)}`, {
340
- method: "PATCH",
341
- body: JSON.stringify({ status: "assigned", assignee }),
644
+ const updated = await roomScopedApiCall({
645
+ room_id: targetRoomId,
646
+ project_id: targetProjectId,
647
+ room_path: (targetRoomId) => `/rooms/${encodeRoomIdPath(targetRoomId)}/tasks/${encodeURIComponent(task_id)}`,
648
+ project_path: (targetProjectId) => `/projects/${encodeURIComponent(targetProjectId)}/tasks/${encodeURIComponent(task_id)}`,
649
+ options: {
650
+ method: "PATCH",
651
+ body: JSON.stringify({ status: "assigned", assignee }),
652
+ },
342
653
  });
343
654
  return {
344
655
  content: [{ type: "text", text: JSON.stringify({ success: true, task: updated }, null, 2) }],
@@ -357,18 +668,25 @@ server.tool("update_task", "Update a task's status or assignee. Status transitio
357
668
  status: z.enum(TASK_STATUSES).optional().describe("New status for the task"),
358
669
  assignee: z.string().optional().describe("New assignee for the task"),
359
670
  pr_url: z.string().optional().describe("PR URL to link to the task"),
360
- project_id: z.string().optional().describe("Project ID. Defaults to current room."),
361
- }, async ({ task_id, status, assignee, pr_url, project_id }) => {
362
- const targetProjectId = project_id || currentRoom?.project_id;
363
- if (!targetProjectId) {
671
+ room_id: z.string().optional().describe("Canonical room ID. Defaults to current room."),
672
+ }, async ({ task_id, status, assignee, pr_url, room_id }) => {
673
+ const targetRoomId = getTargetRoomId(room_id);
674
+ const targetProjectId = getFallbackProjectId();
675
+ if (!targetRoomId && !targetProjectId) {
364
676
  return {
365
677
  content: [{ type: "text", text: JSON.stringify({ success: false, error: "Not in a room." }) }],
366
678
  };
367
679
  }
368
680
  try {
369
- const updated = await apiCall(`/projects/${encodeURIComponent(targetProjectId)}/tasks/${encodeURIComponent(task_id)}`, {
370
- method: "PATCH",
371
- body: JSON.stringify({ status, assignee, pr_url }),
681
+ const updated = await roomScopedApiCall({
682
+ room_id: targetRoomId,
683
+ project_id: targetProjectId,
684
+ room_path: (targetRoomId) => `/rooms/${encodeRoomIdPath(targetRoomId)}/tasks/${encodeURIComponent(task_id)}`,
685
+ project_path: (targetProjectId) => `/projects/${encodeURIComponent(targetProjectId)}/tasks/${encodeURIComponent(task_id)}`,
686
+ options: {
687
+ method: "PATCH",
688
+ body: JSON.stringify({ status, assignee, pr_url }),
689
+ },
372
690
  });
373
691
  return {
374
692
  content: [{ type: "text", text: JSON.stringify({ success: true, task: updated }, null, 2) }],
@@ -385,18 +703,25 @@ server.tool("complete_task", "Submit a task for review. Moves the task to 'in_re
385
703
  "the work is merged before it can be marked done.", {
386
704
  task_id: z.string().describe("The task ID to submit for review"),
387
705
  pr_url: z.string().optional().describe("GitHub PR URL for the work"),
388
- project_id: z.string().optional().describe("Project ID. Defaults to current room."),
389
- }, async ({ task_id, pr_url, project_id }) => {
390
- const targetProjectId = project_id || currentRoom?.project_id;
391
- if (!targetProjectId) {
706
+ room_id: z.string().optional().describe("Canonical room ID. Defaults to current room."),
707
+ }, async ({ task_id, pr_url, room_id }) => {
708
+ const targetRoomId = getTargetRoomId(room_id);
709
+ const targetProjectId = getFallbackProjectId();
710
+ if (!targetRoomId && !targetProjectId) {
392
711
  return {
393
712
  content: [{ type: "text", text: JSON.stringify({ success: false, error: "Not in a room." }) }],
394
713
  };
395
714
  }
396
715
  try {
397
- const updated = await apiCall(`/projects/${encodeURIComponent(targetProjectId)}/tasks/${encodeURIComponent(task_id)}`, {
398
- method: "PATCH",
399
- body: JSON.stringify({ status: "in_review", pr_url }),
716
+ const updated = await roomScopedApiCall({
717
+ room_id: targetRoomId,
718
+ project_id: targetProjectId,
719
+ room_path: (targetRoomId) => `/rooms/${encodeRoomIdPath(targetRoomId)}/tasks/${encodeURIComponent(task_id)}`,
720
+ project_path: (targetProjectId) => `/projects/${encodeURIComponent(targetProjectId)}/tasks/${encodeURIComponent(task_id)}`,
721
+ options: {
722
+ method: "PATCH",
723
+ body: JSON.stringify({ status: "in_review", pr_url }),
724
+ },
400
725
  });
401
726
  return {
402
727
  content: [{ type: "text", text: JSON.stringify({ success: true, task: updated }, null, 2) }],
@@ -516,16 +841,7 @@ server.tool("initialize_repo", "Initialize the current repo for Let Agents Chat
516
841
  }
517
842
  // Auto-join the room after creating config
518
843
  try {
519
- const project = await apiCall(`/projects/room/${encodeURIComponent(roomName)}`, { method: "POST" });
520
- currentRoom = {
521
- room: roomName,
522
- project_id: project.id,
523
- code: project.code,
524
- joined_via: "config",
525
- };
526
- sseClient.subscribe(project.id, (_message) => {
527
- server.server.sendResourceListChanged();
528
- });
844
+ const joined = await joinRoomIdentifier(roomName, "config");
529
845
  return {
530
846
  content: [
531
847
  {
@@ -533,9 +849,8 @@ server.tool("initialize_repo", "Initialize the current repo for Let Agents Chat
533
849
  text: JSON.stringify({
534
850
  success: true,
535
851
  created: configPath,
536
- room: roomName,
537
- project_id: project.id,
538
- code: project.code,
852
+ room_id: joined.room.room_id,
853
+ code: joined.room.code ?? null,
539
854
  joined: true,
540
855
  hint: "Consider adding .letagents.json to git so other contributors auto-join the same room.",
541
856
  }, null, 2),
@@ -552,7 +867,7 @@ server.tool("initialize_repo", "Initialize the current repo for Let Agents Chat
552
867
  text: JSON.stringify({
553
868
  success: true,
554
869
  created: configPath,
555
- room: roomName,
870
+ room_id: roomName,
556
871
  joined: false,
557
872
  error: `Config created but auto-join failed: ${err instanceof Error ? err.message : err}`,
558
873
  hint: "The .letagents.json was created. Use join_room to manually connect.",
@@ -563,15 +878,27 @@ server.tool("initialize_repo", "Initialize the current repo for Let Agents Chat
563
878
  }
564
879
  });
565
880
  // -- send_message -----------------------------------------------------------
566
- server.tool("send_message", "Send a message to a Let Agents Chat project.", {
567
- project_id: z.string().describe("The project ID to send the message to"),
881
+ server.tool("send_message", "Send a message to a Let Agents Chat room.", {
882
+ room_id: z.string().optional().describe("Canonical room ID. Defaults to the current room."),
568
883
  sender: z.string().describe("Name identifying the sending agent (e.g. 'antigravity-agent')"),
569
884
  text: z.string().describe("The message text to send"),
570
- }, async ({ project_id, sender, text }) => {
571
- const message = await apiCall(`/projects/${encodeURIComponent(project_id)}/messages`, {
572
- method: "POST",
573
- body: JSON.stringify({ sender, text }),
885
+ }, async ({ room_id, sender, text }) => {
886
+ const targetRoomId = getTargetRoomId(room_id);
887
+ const targetProjectId = getFallbackProjectId();
888
+ if (!targetRoomId && !targetProjectId) {
889
+ throw new Error("No room is currently selected. Join a room first or pass room_id.");
890
+ }
891
+ const message = await roomScopedApiCall({
892
+ room_id: targetRoomId,
893
+ project_id: targetProjectId,
894
+ room_path: (targetRoomId) => `/rooms/${encodeRoomIdPath(targetRoomId)}/messages`,
895
+ project_path: (targetProjectId) => `/projects/${encodeURIComponent(targetProjectId)}/messages`,
896
+ options: {
897
+ method: "POST",
898
+ body: JSON.stringify({ sender, text }),
899
+ },
574
900
  });
901
+ touchCurrentRoom(typeof message.id === "string" ? message.id : undefined);
575
902
  return {
576
903
  content: [
577
904
  {
@@ -582,10 +909,17 @@ server.tool("send_message", "Send a message to a Let Agents Chat project.", {
582
909
  };
583
910
  });
584
911
  // -- read_messages ----------------------------------------------------------
585
- server.tool("read_messages", "Read all messages from a Let Agents Chat project.", {
586
- project_id: z.string().describe("The project ID to read messages from"),
587
- }, async ({ project_id }) => {
588
- const result = await apiCall(`/projects/${encodeURIComponent(project_id)}/messages`);
912
+ server.tool("read_messages", "Read all messages from a Let Agents Chat room.", {
913
+ room_id: z.string().optional().describe("Canonical room ID. Defaults to the current room."),
914
+ }, async ({ room_id }) => {
915
+ const targetRoomId = getTargetRoomId(room_id);
916
+ const targetProjectId = getFallbackProjectId();
917
+ const result = await roomScopedApiCall({
918
+ room_id: targetRoomId,
919
+ project_id: targetProjectId,
920
+ room_path: (targetRoomId) => `/rooms/${encodeRoomIdPath(targetRoomId)}/messages`,
921
+ project_path: (targetProjectId) => `/projects/${encodeURIComponent(targetProjectId)}/messages`,
922
+ });
589
923
  return {
590
924
  content: [
591
925
  {
@@ -598,8 +932,8 @@ server.tool("read_messages", "Read all messages from a Let Agents Chat project."
598
932
  // -- wait_for_messages ------------------------------------------------------
599
933
  const MAX_POLL_TIMEOUT_MS = 180000; // 3 minutes
600
934
  const DEFAULT_POLL_TIMEOUT_MS = 30000; // 30 seconds
601
- server.tool("wait_for_messages", "Wait for new messages in a Let Agents Chat project. Blocks until new messages arrive or 30 seconds elapse. Use the after_message_id parameter to only receive messages newer than a specific message.", {
602
- project_id: z.string().describe("The project ID to wait for messages in"),
935
+ server.tool("wait_for_messages", "Wait for new messages in a Let Agents Chat room. Blocks until new messages arrive or 30 seconds elapse. Use the after_message_id parameter to only receive messages newer than a specific message.", {
936
+ room_id: z.string().optional().describe("Canonical room ID. Defaults to the current room."),
603
937
  after_message_id: z
604
938
  .string()
605
939
  .optional()
@@ -608,7 +942,9 @@ server.tool("wait_for_messages", "Wait for new messages in a Let Agents Chat pro
608
942
  .number()
609
943
  .optional()
610
944
  .describe("Maximum wait time in milliseconds. If set to 0, the default timeout will be used."),
611
- }, async ({ project_id, after_message_id, timeout }) => {
945
+ }, async ({ room_id, after_message_id, timeout }) => {
946
+ const targetRoomId = getTargetRoomId(room_id);
947
+ const targetProjectId = getFallbackProjectId();
612
948
  const serverTimeout = Math.min(Math.max(timeout || DEFAULT_POLL_TIMEOUT_MS, 1000), MAX_POLL_TIMEOUT_MS);
613
949
  const clientTimeout = serverTimeout + 5000; // 5s buffer over server timeout
614
950
  const params = new URLSearchParams();
@@ -616,7 +952,304 @@ server.tool("wait_for_messages", "Wait for new messages in a Let Agents Chat pro
616
952
  params.set("after", after_message_id);
617
953
  params.set("timeout", String(serverTimeout));
618
954
  const queryString = params.toString();
619
- const result = await apiCall(`/projects/${encodeURIComponent(project_id)}/messages/poll?${queryString}`, { signal: AbortSignal.timeout(clientTimeout) });
955
+ const result = await roomScopedApiCall({
956
+ room_id: targetRoomId,
957
+ project_id: targetProjectId,
958
+ room_path: (targetRoomId) => `/rooms/${encodeRoomIdPath(targetRoomId)}/messages/poll?${queryString}`,
959
+ project_path: (targetProjectId) => `/projects/${encodeURIComponent(targetProjectId)}/messages/poll?${queryString}`,
960
+ options: { signal: AbortSignal.timeout(clientTimeout) },
961
+ });
962
+ if (targetRoomId) {
963
+ touchRoomSession(targetRoomId, getLastMessageId(result));
964
+ }
965
+ return {
966
+ content: [
967
+ {
968
+ type: "text",
969
+ text: JSON.stringify(result, null, 2),
970
+ },
971
+ ],
972
+ };
973
+ });
974
+ // -- onboarding -------------------------------------------------------------
975
+ server.tool("get_onboarding_status", "Inspect local Let Agents MCP auth and room-session state so a user can finish onboarding without guessing what is missing.", {
976
+ cwd: z
977
+ .string()
978
+ .optional()
979
+ .describe("Working directory to inspect for repo context. Defaults to the current process directory."),
980
+ }, async ({ cwd }) => {
981
+ const workingDir = cwd || process.cwd();
982
+ const repoRoot = resolveGitRoot(workingDir);
983
+ const configRoom = getRoomFromConfig(workingDir);
984
+ const gitRoom = repoRoot ? getGitRemoteIdentity(repoRoot) : null;
985
+ const storedAuth = getStoredAuth();
986
+ const pendingAuth = getPendingDeviceAuth();
987
+ const savedCurrentRoom = getStoredCurrentRoom();
988
+ const detectedRoom = configRoom || gitRoom;
989
+ let nextStep = "join_room";
990
+ if (!storedAuth && pendingAuth) {
991
+ nextStep = "poll_device_auth";
992
+ }
993
+ else if (savedCurrentRoom && !currentRoom) {
994
+ nextStep = "resume_room_session";
995
+ }
996
+ return {
997
+ content: [
998
+ {
999
+ type: "text",
1000
+ text: JSON.stringify({
1001
+ api_url: API_URL,
1002
+ local_state_path: getLocalStatePath(),
1003
+ authenticated: Boolean(process.env.LETAGENTS_TOKEN || storedAuth),
1004
+ auth_source: process.env.LETAGENTS_TOKEN
1005
+ ? "env"
1006
+ : storedAuth
1007
+ ? "local_state"
1008
+ : "none",
1009
+ account: storedAuth?.account ?? null,
1010
+ token_expires_at: storedAuth?.expires_at ?? null,
1011
+ pending_device_auth: pendingAuth,
1012
+ current_room: toPublicRoomState(currentRoom),
1013
+ saved_current_room: toPublicStoredRoomSession(savedCurrentRoom),
1014
+ detected_room_from_context: detectedRoom,
1015
+ repo_root: repoRoot,
1016
+ next_step: nextStep,
1017
+ }, null, 2),
1018
+ },
1019
+ ],
1020
+ };
1021
+ });
1022
+ server.tool("start_device_auth", "Start GitHub Device Flow for Let Agents Chat and persist the pending request locally. Use this when private repo access or explicit LetAgents auth is needed.", {
1023
+ room_id: z
1024
+ .string()
1025
+ .optional()
1026
+ .describe("Optional room to associate with this auth request for later auto-join."),
1027
+ force: z
1028
+ .boolean()
1029
+ .optional()
1030
+ .describe("If true, replaces any existing pending device auth request."),
1031
+ }, async ({ room_id, force }) => {
1032
+ const existing = getPendingDeviceAuth();
1033
+ if (existing && !force) {
1034
+ return {
1035
+ content: [
1036
+ {
1037
+ type: "text",
1038
+ text: JSON.stringify({
1039
+ success: true,
1040
+ reused_existing_request: true,
1041
+ ...existing,
1042
+ }, null, 2),
1043
+ },
1044
+ ],
1045
+ };
1046
+ }
1047
+ const response = await apiCall("/auth/device/start", {
1048
+ method: "POST",
1049
+ });
1050
+ const pendingAuth = setPendingDeviceAuth({
1051
+ request_id: response.request_id,
1052
+ user_code: response.user_code,
1053
+ verification_uri: response.verification_uri,
1054
+ interval_seconds: response.interval,
1055
+ expires_at: new Date(Date.now() + response.expires_in * 1000).toISOString(),
1056
+ started_at: new Date().toISOString(),
1057
+ suggested_room_id: room_id ?? currentRoom?.room_id ?? getRoomFromConfig() ?? undefined,
1058
+ });
1059
+ return {
1060
+ content: [
1061
+ {
1062
+ type: "text",
1063
+ text: JSON.stringify({
1064
+ success: true,
1065
+ ...pendingAuth,
1066
+ }, null, 2),
1067
+ },
1068
+ ],
1069
+ };
1070
+ });
1071
+ server.tool("poll_device_auth", "Poll a pending GitHub Device Flow request. On success this stores the LetAgents token locally and can optionally join a room immediately.", {
1072
+ request_id: z
1073
+ .string()
1074
+ .optional()
1075
+ .describe("The device auth request to poll. Defaults to the locally saved pending request."),
1076
+ room_id: z
1077
+ .string()
1078
+ .optional()
1079
+ .describe("Optional room to auto-join after authorization succeeds."),
1080
+ auto_join: z
1081
+ .boolean()
1082
+ .optional()
1083
+ .describe("If true, tries to join the room immediately after the auth succeeds."),
1084
+ }, async ({ request_id, room_id, auto_join }) => {
1085
+ const pendingAuth = request_id
1086
+ ? getPendingDeviceAuth()?.request_id === request_id
1087
+ ? getPendingDeviceAuth()
1088
+ : null
1089
+ : getPendingDeviceAuth();
1090
+ if (!pendingAuth && !request_id) {
1091
+ return {
1092
+ content: [
1093
+ {
1094
+ type: "text",
1095
+ text: JSON.stringify({ success: false, error: "No pending device auth request found." }, null, 2),
1096
+ },
1097
+ ],
1098
+ };
1099
+ }
1100
+ const requestId = request_id || pendingAuth?.request_id;
1101
+ if (!requestId) {
1102
+ return {
1103
+ content: [
1104
+ {
1105
+ type: "text",
1106
+ text: JSON.stringify({ success: false, error: "A request_id is required when nothing is saved locally." }, null, 2),
1107
+ },
1108
+ ],
1109
+ };
1110
+ }
1111
+ const result = await apiCall(`/auth/device/poll/${encodeURIComponent(requestId)}`);
1112
+ if (result.status === "pending" || result.status === "slow_down") {
1113
+ if (pendingAuth) {
1114
+ setPendingDeviceAuth({
1115
+ ...pendingAuth,
1116
+ interval_seconds: result.interval ?? pendingAuth.interval_seconds,
1117
+ expires_at: result.expires_in !== undefined
1118
+ ? new Date(Date.now() + result.expires_in * 1000).toISOString()
1119
+ : pendingAuth.expires_at,
1120
+ });
1121
+ }
1122
+ return {
1123
+ content: [
1124
+ {
1125
+ type: "text",
1126
+ text: JSON.stringify({ success: true, ...result }, null, 2),
1127
+ },
1128
+ ],
1129
+ };
1130
+ }
1131
+ if (result.status === "denied" || result.status === "expired") {
1132
+ clearPendingDeviceAuth();
1133
+ clearStoredAuth();
1134
+ return {
1135
+ content: [
1136
+ {
1137
+ type: "text",
1138
+ text: JSON.stringify({ success: false, ...result }, null, 2),
1139
+ },
1140
+ ],
1141
+ };
1142
+ }
1143
+ if (!result.letagents_token) {
1144
+ throw new Error("Device auth completed without a LetAgents token.");
1145
+ }
1146
+ clearPendingDeviceAuth();
1147
+ const storedAuth = setStoredAuth({
1148
+ token: result.letagents_token,
1149
+ expires_at: result.expires_at,
1150
+ account: result.account,
1151
+ stored_at: new Date().toISOString(),
1152
+ source: "device_flow",
1153
+ });
1154
+ let joinedRoom = null;
1155
+ const roomToJoin = room_id ||
1156
+ pendingAuth?.suggested_room_id ||
1157
+ currentRoom?.room_id ||
1158
+ getRoomFromConfig() ||
1159
+ undefined;
1160
+ if (auto_join && roomToJoin) {
1161
+ const joinedVia = looksLikeInviteCode(roomToJoin) ? "join_code" : "join_room";
1162
+ const joined = await joinRoomIdentifier(roomToJoin, joinedVia);
1163
+ joinedRoom = joined.room;
1164
+ await autoRegisterAgentIdentity();
1165
+ }
1166
+ return {
1167
+ content: [
1168
+ {
1169
+ type: "text",
1170
+ text: JSON.stringify({
1171
+ success: true,
1172
+ status: "authorized",
1173
+ account: storedAuth.account ?? null,
1174
+ expires_at: storedAuth.expires_at ?? null,
1175
+ auto_joined_room: joinedRoom,
1176
+ }, null, 2),
1177
+ },
1178
+ ],
1179
+ };
1180
+ });
1181
+ server.tool("clear_saved_auth", "Clear any locally saved LetAgents auth token and pending device auth request.", {}, async () => {
1182
+ clearPendingDeviceAuth();
1183
+ clearStoredAuth();
1184
+ return {
1185
+ content: [
1186
+ {
1187
+ type: "text",
1188
+ text: JSON.stringify({
1189
+ success: true,
1190
+ env_token_still_present: Boolean(process.env.LETAGENTS_TOKEN),
1191
+ }, null, 2),
1192
+ },
1193
+ ],
1194
+ };
1195
+ });
1196
+ server.tool("resume_room_session", "Rejoin the last locally saved room context, or a specific saved room, after a restart. This recreates participation in the room; it does not preserve a prior server-side session ID.", {
1197
+ room_id: z
1198
+ .string()
1199
+ .optional()
1200
+ .describe("Optional saved room ID to resume. Defaults to the last current room."),
1201
+ }, async ({ room_id }) => {
1202
+ const savedRoom = (room_id ? getStoredRoomSession(room_id) : null) ??
1203
+ getStoredCurrentRoom();
1204
+ if (!savedRoom) {
1205
+ return {
1206
+ content: [
1207
+ {
1208
+ type: "text",
1209
+ text: JSON.stringify({ success: false, error: "No saved room session found." }, null, 2),
1210
+ },
1211
+ ],
1212
+ };
1213
+ }
1214
+ const joined = await joinRoomIdentifier(savedRoom.room_id, savedRoom.joined_via);
1215
+ await autoRegisterAgentIdentity();
1216
+ return {
1217
+ content: [
1218
+ {
1219
+ type: "text",
1220
+ text: JSON.stringify({
1221
+ success: true,
1222
+ rejoined_from_local_state: true,
1223
+ server_session_resumed: false,
1224
+ last_message_id_before_restart: savedRoom.last_message_id ?? null,
1225
+ room: joined.room,
1226
+ }, null, 2),
1227
+ },
1228
+ ],
1229
+ };
1230
+ });
1231
+ // -- check_repo_visibility --------------------------------------------------
1232
+ server.tool("check_repo_visibility", "Auto-detect the current repo's git remote and check if it's public or private. Returns the canonical key, provider, visibility, and suggested room type (discoverable for public, invite for private/unknown). Useful for deciding whether to auto-join a discoverable room or create an invite room.", {
1233
+ cwd: z
1234
+ .string()
1235
+ .optional()
1236
+ .describe("Working directory to detect git remote from. Defaults to the MCP server's working directory."),
1237
+ }, async ({ cwd }) => {
1238
+ const { autoDetectRepo } = await import("./repo-visibility.js");
1239
+ const result = await autoDetectRepo(cwd);
1240
+ if (!result) {
1241
+ return {
1242
+ content: [
1243
+ {
1244
+ type: "text",
1245
+ text: JSON.stringify({
1246
+ error: "Not in a git repository or no remote configured",
1247
+ suggestion: "Use create_room to create an invite room instead",
1248
+ }, null, 2),
1249
+ },
1250
+ ],
1251
+ };
1252
+ }
620
1253
  return {
621
1254
  content: [
622
1255
  {
@@ -638,23 +1271,26 @@ async function main() {
638
1271
  // 1. Try .letagents.json config
639
1272
  const configRoom = getRoomFromConfig();
640
1273
  if (configRoom) {
641
- const project = await apiCall(`/projects/room/${encodeURIComponent(configRoom)}`, { method: "POST" });
642
- currentRoom = { room: configRoom, project_id: project.id, code: project.code, joined_via: "config" };
643
- sseClient.subscribe(project.id, (_message) => { server.server.sendResourceListChanged(); });
1274
+ await joinRoomIdentifier(configRoom, "config");
644
1275
  console.error(`🏠 Auto-joined room '${configRoom}' (from .letagents.json)`);
645
1276
  return;
646
1277
  }
647
1278
  // 2. Try git remote URL
648
1279
  const gitRoom = getGitRemoteIdentity();
649
1280
  if (gitRoom) {
650
- const project = await apiCall(`/projects/room/${encodeURIComponent(gitRoom)}`, { method: "POST" });
651
- currentRoom = { room: gitRoom, project_id: project.id, code: project.code, joined_via: "git-remote" };
652
- sseClient.subscribe(project.id, (_message) => { server.server.sendResourceListChanged(); });
1281
+ await joinRoomIdentifier(gitRoom, "git-remote");
653
1282
  console.error(`🏠 Auto-joined room '${gitRoom}' (inferred from git remote — consider adding a .letagents.json)`);
654
1283
  return;
655
1284
  }
656
- // 3. No context found
657
- console.error("â„šī¸ No .letagents.json or git remote found — use join_project or join_room to connect.");
1285
+ // 3. Fall back to the most recent saved room session
1286
+ const savedCurrentRoom = getStoredCurrentRoom();
1287
+ if (savedCurrentRoom) {
1288
+ await joinRoomIdentifier(savedCurrentRoom.room_id, savedCurrentRoom.joined_via);
1289
+ console.error(`🏠 Rejoined saved room '${savedCurrentRoom.room_id}' (from local state)`);
1290
+ return;
1291
+ }
1292
+ // 4. No context found
1293
+ console.error("â„šī¸ No .letagents.json, git remote, or saved room found — use create_room, join_code, or join_room to connect.");
658
1294
  }
659
1295
  catch (err) {
660
1296
  // Auto-join failure should never block the MCP server