letagents 0.6.0 → 0.7.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,217 @@ 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 rememberRoom(state, lastMessageId) {
119
+ currentRoom = state;
120
+ saveRoomSession({
121
+ room_id: state.room_id,
122
+ project_id: state.project_id ?? null,
123
+ code: state.code ?? null,
124
+ joined_via: state.joined_via,
125
+ last_message_id: lastMessageId,
126
+ });
127
+ sseClient.unsubscribeAll();
128
+ sseClient.subscribe({
129
+ roomId: state.room_id,
130
+ projectId: state.project_id ?? null,
131
+ }, (_message) => {
132
+ touchRoomSession(state.room_id);
133
+ server.server.sendResourceListChanged();
134
+ });
135
+ return state;
136
+ }
137
+ function touchCurrentRoom(lastMessageId) {
138
+ if (!currentRoom) {
139
+ return;
140
+ }
141
+ touchRoomSession(currentRoom.room_id, lastMessageId);
142
+ }
143
+ function getTargetRoomId(roomId) {
144
+ return roomId || currentRoom?.room_id || null;
145
+ }
146
+ function getTargetProjectId(projectId) {
147
+ return projectId || currentRoom?.project_id || null;
148
+ }
149
+ function getLastMessageId(payload) {
150
+ if (!payload || typeof payload !== "object") {
151
+ return undefined;
152
+ }
153
+ const messages = payload.messages;
154
+ const lastMessage = messages?.at(-1);
155
+ return typeof lastMessage?.id === "string" ? lastMessage.id : undefined;
156
+ }
157
+ async function roomScopedApiCall(input) {
158
+ if (input.room_id) {
159
+ try {
160
+ const result = await apiCall(input.room_path(input.room_id), input.options);
161
+ touchRoomSession(input.room_id, getLastMessageId(result));
162
+ return result;
163
+ }
164
+ catch (error) {
165
+ if (!input.project_id || !isMissingRouteError(error)) {
166
+ throw error;
167
+ }
168
+ }
169
+ }
170
+ if (!input.project_id) {
171
+ throw new Error("No room_id or project_id is available for this request.");
172
+ }
173
+ const result = await apiCall(input.project_path(input.project_id), input.options);
174
+ if (input.room_id) {
175
+ touchRoomSession(input.room_id, getLastMessageId(result));
176
+ }
177
+ return result;
178
+ }
179
+ async function joinRoomIdentifier(identifier, joinedVia) {
180
+ const roomId = joinedVia === "join_code" ? normalizeInviteCode(identifier) : identifier.trim();
181
+ try {
182
+ const response = await apiCall(`/rooms/${encodeRoomIdPath(roomId)}/join`, { method: "POST" });
183
+ const joinedRoomId = typeof response.room_id === "string"
184
+ ? response.room_id
185
+ : roomId;
186
+ const room = rememberRoom(toRoomState({
187
+ room_id: joinedRoomId,
188
+ project_id: typeof response.project_id === "string" ? response.project_id : null,
189
+ code: typeof response.code === "string"
190
+ ? response.code
191
+ : looksLikeInviteCode(joinedRoomId)
192
+ ? joinedRoomId
193
+ : null,
194
+ joined_via: joinedVia,
195
+ }));
196
+ return {
197
+ room,
198
+ response: { ...response, room_id: joinedRoomId },
199
+ };
200
+ }
201
+ catch (error) {
202
+ if (!isMissingRouteError(error)) {
203
+ throw error;
204
+ }
205
+ }
206
+ if (joinedVia === "join_code") {
207
+ const project = await apiCall(`/projects/join/${encodeURIComponent(roomId)}`);
208
+ const legacyRoomId = typeof project.code === "string"
209
+ ? project.code
210
+ : roomId;
211
+ const room = rememberRoom(toRoomState({
212
+ room_id: legacyRoomId,
213
+ project_id: typeof project.id === "string" ? project.id : null,
214
+ code: typeof project.code === "string" ? project.code : legacyRoomId,
215
+ joined_via: joinedVia,
216
+ }));
217
+ return {
218
+ room,
219
+ response: {
220
+ ...project,
221
+ room_id: legacyRoomId,
222
+ project_id: typeof project.id === "string" ? project.id : null,
223
+ },
224
+ };
225
+ }
226
+ const project = await apiCall(`/projects/room/${encodeURIComponent(roomId)}`, { method: "POST" });
227
+ const legacyRoomId = typeof project.name === "string" && project.name.trim()
228
+ ? project.name
229
+ : typeof project.code === "string" && project.code.trim()
230
+ ? project.code
231
+ : roomId;
232
+ const room = rememberRoom(toRoomState({
233
+ room_id: legacyRoomId,
234
+ project_id: typeof project.id === "string" ? project.id : null,
235
+ code: typeof project.code === "string"
236
+ ? project.code
237
+ : looksLikeInviteCode(legacyRoomId)
238
+ ? legacyRoomId
239
+ : null,
240
+ joined_via: joinedVia,
241
+ }));
242
+ return {
243
+ room,
244
+ response: {
245
+ ...project,
246
+ room_id: legacyRoomId,
247
+ project_id: typeof project.id === "string" ? project.id : null,
248
+ },
249
+ };
250
+ }
251
+ async function autoRegisterAgentIdentity() {
252
+ if (!AGENT_NAME || !currentRoom) {
253
+ return;
254
+ }
255
+ try {
256
+ await apiCall("/agents", {
257
+ method: "POST",
258
+ body: JSON.stringify({
259
+ name: AGENT_NAME,
260
+ display_name: AGENT_DISPLAY_NAME || AGENT_NAME,
261
+ owner_label: AGENT_OWNER_LABEL || undefined,
262
+ }),
263
+ });
264
+ }
265
+ catch (error) {
266
+ console.error("Agent identity registration failed:", error);
63
267
  }
64
- return res.json();
65
268
  }
66
269
  // ---------------------------------------------------------------------------
67
270
  // MCP Server
@@ -70,10 +273,32 @@ const server = new McpServer({
70
273
  name: "letagents",
71
274
  version: "0.2.0",
72
275
  });
73
- const sseClient = new SseClient(API_URL);
276
+ const sseClient = new SseClient(API_URL, () => getLetagentsToken());
74
277
  // ---------------------------------------------------------------------------
75
278
  // MCP Resources
76
279
  // ---------------------------------------------------------------------------
280
+ server.resource("room_messages", new ResourceTemplate("letagents://rooms/{room_id}/messages", {
281
+ list: undefined,
282
+ }), async (uri, { room_id }) => {
283
+ const normalizedRoomId = String(room_id);
284
+ const storedSession = getStoredRoomSession(normalizedRoomId) ??
285
+ (currentRoom?.room_id === normalizedRoomId ? getStoredCurrentRoom() : null);
286
+ const result = await roomScopedApiCall({
287
+ room_id: normalizedRoomId,
288
+ project_id: storedSession?.project_id ?? null,
289
+ room_path: (targetRoomId) => `/rooms/${encodeRoomIdPath(targetRoomId)}/messages`,
290
+ project_path: (projectId) => `/projects/${encodeURIComponent(projectId)}/messages`,
291
+ });
292
+ return {
293
+ contents: [
294
+ {
295
+ uri: uri.href,
296
+ mimeType: "application/json",
297
+ text: JSON.stringify(result, null, 2),
298
+ },
299
+ ],
300
+ };
301
+ });
77
302
  server.resource("project_messages", new ResourceTemplate("letagents://projects/{project_id}/messages", {
78
303
  list: undefined,
79
304
  }), async (uri, { project_id }) => {
@@ -91,15 +316,23 @@ server.resource("project_messages", new ResourceTemplate("letagents://projects/{
91
316
  // -- create_project ---------------------------------------------------------
92
317
  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
318
  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
- });
319
+ const roomId = typeof project.code === "string"
320
+ ? project.code
321
+ : typeof project.id === "string"
322
+ ? project.id
323
+ : "unknown-room";
324
+ rememberRoom(toRoomState({
325
+ room_id: roomId,
326
+ project_id: typeof project.id === "string" ? project.id : null,
327
+ code: typeof project.code === "string" ? project.code : roomId,
328
+ joined_via: "join_code",
329
+ }));
330
+ await autoRegisterAgentIdentity();
98
331
  return {
99
332
  content: [
100
333
  {
101
334
  type: "text",
102
- text: JSON.stringify(project, null, 2),
335
+ text: JSON.stringify({ ...project, room_id: roomId }, null, 2),
103
336
  },
104
337
  ],
105
338
  };
@@ -108,23 +341,13 @@ server.tool("create_project", "Create a new project on Let Agents Chat. Returns
108
341
  server.tool("join_project", "Join an existing Let Agents Chat project using a join code.", {
109
342
  code: z.string().describe("The join code shared by the project creator (e.g. 'ABCX-7291')"),
110
343
  }, 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",
118
- };
119
- // Auto-subscribe to SSE for this project
120
- sseClient.subscribe(project.id, (_message) => {
121
- server.server.sendResourceListChanged();
122
- });
344
+ const joined = await joinRoomIdentifier(code, "join_code");
345
+ await autoRegisterAgentIdentity();
123
346
  return {
124
347
  content: [
125
348
  {
126
349
  type: "text",
127
- text: JSON.stringify({ ...project, joined_via: "join_code" }, null, 2),
350
+ text: JSON.stringify({ ...joined.response, joined_via: "join_code" }, null, 2),
128
351
  },
129
352
  ],
130
353
  };
@@ -133,23 +356,13 @@ server.tool("join_project", "Join an existing Let Agents Chat project using a jo
133
356
  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
357
  name: z.string().describe("The room name to join (e.g. 'github.com/owner/repo')"),
135
358
  }, 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
- });
359
+ const joined = await joinRoomIdentifier(name, "join_room");
360
+ await autoRegisterAgentIdentity();
148
361
  return {
149
362
  content: [
150
363
  {
151
364
  type: "text",
152
- text: JSON.stringify({ ...project, joined_via: "join_room" }, null, 2),
365
+ text: JSON.stringify({ ...joined.response, joined_via: "join_room" }, null, 2),
153
366
  },
154
367
  ],
155
368
  };
@@ -170,7 +383,17 @@ server.tool("get_current_room", "Get information about the currently joined room
170
383
  content: [
171
384
  {
172
385
  type: "text",
173
- text: JSON.stringify({ connected: true, ...currentRoom }, null, 2),
386
+ text: JSON.stringify({
387
+ connected: true,
388
+ ...currentRoom,
389
+ auth: getStoredAuth()
390
+ ? {
391
+ source: process.env.LETAGENTS_TOKEN ? "env" : "local_state",
392
+ expires_at: getStoredAuth()?.expires_at ?? null,
393
+ account: getStoredAuth()?.account ?? null,
394
+ }
395
+ : null,
396
+ }, null, 2),
174
397
  },
175
398
  ],
176
399
  };
@@ -227,21 +450,26 @@ server.tool("post_status", "Broadcast a lightweight status update to the current
227
450
  "Status updates are distinct from chat messages and can be filtered separately.", {
228
451
  sender: z.string().describe("Name of the agent posting the status (e.g. 'codex-agent')"),
229
452
  status: z.string().describe("Short status description (e.g. 'reviewing PR #2', 'idle', 'thinking...')"),
453
+ room_id: z
454
+ .string()
455
+ .optional()
456
+ .describe("Canonical room ID. Defaults to the current room."),
230
457
  project_id: z
231
458
  .string()
232
459
  .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) {
460
+ .describe("Legacy project ID. Defaults to the current room if joined."),
461
+ }, async ({ sender, status, room_id, project_id }) => {
462
+ const targetRoomId = getTargetRoomId(room_id);
463
+ const targetProjectId = getTargetProjectId(project_id);
464
+ if (!targetRoomId && !targetProjectId) {
237
465
  return {
238
466
  content: [
239
467
  {
240
468
  type: "text",
241
469
  text: JSON.stringify({
242
470
  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.",
471
+ error: "No room_id or project_id provided and not currently in a room.",
472
+ hint: "Join a room first with join_project or join_room, or pass room_id explicitly.",
245
473
  }, null, 2),
246
474
  },
247
475
  ],
@@ -250,10 +478,17 @@ server.tool("post_status", "Broadcast a lightweight status update to the current
250
478
  // Status messages use a reserved prefix so the UI (and agents) can distinguish
251
479
  // them from normal chat messages without changing the data model.
252
480
  const statusText = `[status] ${status}`;
253
- const message = await apiCall(`/projects/${encodeURIComponent(targetProjectId)}/messages`, {
254
- method: "POST",
255
- body: JSON.stringify({ sender, text: statusText }),
481
+ const message = await roomScopedApiCall({
482
+ room_id: targetRoomId,
483
+ project_id: targetProjectId,
484
+ room_path: (targetRoomId) => `/rooms/${encodeRoomIdPath(targetRoomId)}/messages`,
485
+ project_path: (targetProjectId) => `/projects/${encodeURIComponent(targetProjectId)}/messages`,
486
+ options: {
487
+ method: "POST",
488
+ body: JSON.stringify({ sender, text: statusText }),
489
+ },
256
490
  });
491
+ touchCurrentRoom(typeof message.id === "string" ? message.id : undefined);
257
492
  return {
258
493
  content: [
259
494
  {
@@ -262,8 +497,8 @@ server.tool("post_status", "Broadcast a lightweight status update to the current
262
497
  success: true,
263
498
  status_posted: status,
264
499
  sender,
265
- message_id: message.id,
266
- timestamp: message.timestamp,
500
+ message_id: typeof message.id === "string" ? message.id : null,
501
+ timestamp: typeof message.timestamp === "string" ? message.timestamp : null,
267
502
  }, null, 2),
268
503
  },
269
504
  ],
@@ -282,17 +517,25 @@ server.tool("add_task", "Add a new task to the project board. Tasks normally sta
282
517
  description: z.string().optional().describe("Longer description of what needs to be done"),
283
518
  created_by: z.string().describe("Name of the agent or human creating the task"),
284
519
  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) {
520
+ room_id: z.string().optional().describe("Canonical room ID. Defaults to current room."),
521
+ project_id: z.string().optional().describe("Legacy project ID. Defaults to current room."),
522
+ }, async ({ title, description, created_by, source_message_id, room_id, project_id }) => {
523
+ const targetRoomId = getTargetRoomId(room_id);
524
+ const targetProjectId = getTargetProjectId(project_id);
525
+ if (!targetRoomId && !targetProjectId) {
289
526
  return {
290
527
  content: [{ type: "text", text: JSON.stringify({ success: false, error: "Not in a room. Join one first." }) }],
291
528
  };
292
529
  }
293
- const task = await apiCall(`/projects/${encodeURIComponent(targetProjectId)}/tasks`, {
294
- method: "POST",
295
- body: JSON.stringify({ title, description, created_by, source_message_id }),
530
+ const task = await roomScopedApiCall({
531
+ room_id: targetRoomId,
532
+ project_id: targetProjectId,
533
+ room_path: (targetRoomId) => `/rooms/${encodeRoomIdPath(targetRoomId)}/tasks`,
534
+ project_path: (targetProjectId) => `/projects/${encodeURIComponent(targetProjectId)}/tasks`,
535
+ options: {
536
+ method: "POST",
537
+ body: JSON.stringify({ title, description, created_by, source_message_id }),
538
+ },
296
539
  });
297
540
  return {
298
541
  content: [{ type: "text", text: JSON.stringify({ success: true, task }, null, 2) }],
@@ -303,10 +546,12 @@ server.tool("get_board", "Get the current task board for the project. By default
303
546
  "see if there is unassigned work to claim.", {
304
547
  status: z.enum(TASK_STATUSES).optional().describe("Filter by specific status"),
305
548
  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) {
549
+ room_id: z.string().optional().describe("Canonical room ID. Defaults to current room."),
550
+ project_id: z.string().optional().describe("Legacy project ID. Defaults to current room."),
551
+ }, async ({ status, open_only, room_id, project_id }) => {
552
+ const targetRoomId = getTargetRoomId(room_id);
553
+ const targetProjectId = getTargetProjectId(project_id);
554
+ if (!targetRoomId && !targetProjectId) {
310
555
  return {
311
556
  content: [{ type: "text", text: JSON.stringify({ success: false, error: "Not in a room. Join one first." }) }],
312
557
  };
@@ -317,7 +562,12 @@ server.tool("get_board", "Get the current task board for the project. By default
317
562
  if (open_only !== false)
318
563
  params.set("open", "true");
319
564
  const qs = params.toString();
320
- const result = await apiCall(`/projects/${encodeURIComponent(targetProjectId)}/tasks${qs ? `?${qs}` : ""}`);
565
+ const result = await roomScopedApiCall({
566
+ room_id: targetRoomId,
567
+ project_id: targetProjectId,
568
+ room_path: (targetRoomId) => `/rooms/${encodeRoomIdPath(targetRoomId)}/tasks${qs ? `?${qs}` : ""}`,
569
+ project_path: (targetProjectId) => `/projects/${encodeURIComponent(targetProjectId)}/tasks${qs ? `?${qs}` : ""}`,
570
+ });
321
571
  return {
322
572
  content: [{ type: "text", text: JSON.stringify({ success: true, ...result }, null, 2) }],
323
573
  };
@@ -327,18 +577,26 @@ server.tool("claim_task", "Claim an accepted task. The task must be in 'accepted
327
577
  "Do NOT claim proposed tasks — they need to be accepted first.", {
328
578
  task_id: z.string().describe("The task ID to claim, e.g. 'task_1'"),
329
579
  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) {
580
+ room_id: z.string().optional().describe("Canonical room ID. Defaults to current room."),
581
+ project_id: z.string().optional().describe("Legacy project ID. Defaults to current room."),
582
+ }, async ({ task_id, assignee, room_id, project_id }) => {
583
+ const targetRoomId = getTargetRoomId(room_id);
584
+ const targetProjectId = getTargetProjectId(project_id);
585
+ if (!targetRoomId && !targetProjectId) {
334
586
  return {
335
587
  content: [{ type: "text", text: JSON.stringify({ success: false, error: "Not in a room." }) }],
336
588
  };
337
589
  }
338
590
  try {
339
- const updated = await apiCall(`/projects/${encodeURIComponent(targetProjectId)}/tasks/${encodeURIComponent(task_id)}`, {
340
- method: "PATCH",
341
- body: JSON.stringify({ status: "assigned", assignee }),
591
+ const updated = await roomScopedApiCall({
592
+ room_id: targetRoomId,
593
+ project_id: targetProjectId,
594
+ room_path: (targetRoomId) => `/rooms/${encodeRoomIdPath(targetRoomId)}/tasks/${encodeURIComponent(task_id)}`,
595
+ project_path: (targetProjectId) => `/projects/${encodeURIComponent(targetProjectId)}/tasks/${encodeURIComponent(task_id)}`,
596
+ options: {
597
+ method: "PATCH",
598
+ body: JSON.stringify({ status: "assigned", assignee }),
599
+ },
342
600
  });
343
601
  return {
344
602
  content: [{ type: "text", text: JSON.stringify({ success: true, task: updated }, null, 2) }],
@@ -357,18 +615,26 @@ server.tool("update_task", "Update a task's status or assignee. Status transitio
357
615
  status: z.enum(TASK_STATUSES).optional().describe("New status for the task"),
358
616
  assignee: z.string().optional().describe("New assignee for the task"),
359
617
  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) {
618
+ room_id: z.string().optional().describe("Canonical room ID. Defaults to current room."),
619
+ project_id: z.string().optional().describe("Legacy project ID. Defaults to current room."),
620
+ }, async ({ task_id, status, assignee, pr_url, room_id, project_id }) => {
621
+ const targetRoomId = getTargetRoomId(room_id);
622
+ const targetProjectId = getTargetProjectId(project_id);
623
+ if (!targetRoomId && !targetProjectId) {
364
624
  return {
365
625
  content: [{ type: "text", text: JSON.stringify({ success: false, error: "Not in a room." }) }],
366
626
  };
367
627
  }
368
628
  try {
369
- const updated = await apiCall(`/projects/${encodeURIComponent(targetProjectId)}/tasks/${encodeURIComponent(task_id)}`, {
370
- method: "PATCH",
371
- body: JSON.stringify({ status, assignee, pr_url }),
629
+ const updated = await roomScopedApiCall({
630
+ room_id: targetRoomId,
631
+ project_id: targetProjectId,
632
+ room_path: (targetRoomId) => `/rooms/${encodeRoomIdPath(targetRoomId)}/tasks/${encodeURIComponent(task_id)}`,
633
+ project_path: (targetProjectId) => `/projects/${encodeURIComponent(targetProjectId)}/tasks/${encodeURIComponent(task_id)}`,
634
+ options: {
635
+ method: "PATCH",
636
+ body: JSON.stringify({ status, assignee, pr_url }),
637
+ },
372
638
  });
373
639
  return {
374
640
  content: [{ type: "text", text: JSON.stringify({ success: true, task: updated }, null, 2) }],
@@ -385,18 +651,26 @@ server.tool("complete_task", "Submit a task for review. Moves the task to 'in_re
385
651
  "the work is merged before it can be marked done.", {
386
652
  task_id: z.string().describe("The task ID to submit for review"),
387
653
  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) {
654
+ room_id: z.string().optional().describe("Canonical room ID. Defaults to current room."),
655
+ project_id: z.string().optional().describe("Legacy project ID. Defaults to current room."),
656
+ }, async ({ task_id, pr_url, room_id, project_id }) => {
657
+ const targetRoomId = getTargetRoomId(room_id);
658
+ const targetProjectId = getTargetProjectId(project_id);
659
+ if (!targetRoomId && !targetProjectId) {
392
660
  return {
393
661
  content: [{ type: "text", text: JSON.stringify({ success: false, error: "Not in a room." }) }],
394
662
  };
395
663
  }
396
664
  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 }),
665
+ const updated = await roomScopedApiCall({
666
+ room_id: targetRoomId,
667
+ project_id: targetProjectId,
668
+ room_path: (targetRoomId) => `/rooms/${encodeRoomIdPath(targetRoomId)}/tasks/${encodeURIComponent(task_id)}`,
669
+ project_path: (targetProjectId) => `/projects/${encodeURIComponent(targetProjectId)}/tasks/${encodeURIComponent(task_id)}`,
670
+ options: {
671
+ method: "PATCH",
672
+ body: JSON.stringify({ status: "in_review", pr_url }),
673
+ },
400
674
  });
401
675
  return {
402
676
  content: [{ type: "text", text: JSON.stringify({ success: true, task: updated }, null, 2) }],
@@ -516,16 +790,7 @@ server.tool("initialize_repo", "Initialize the current repo for Let Agents Chat
516
790
  }
517
791
  // Auto-join the room after creating config
518
792
  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
- });
793
+ const joined = await joinRoomIdentifier(roomName, "config");
529
794
  return {
530
795
  content: [
531
796
  {
@@ -533,9 +798,9 @@ server.tool("initialize_repo", "Initialize the current repo for Let Agents Chat
533
798
  text: JSON.stringify({
534
799
  success: true,
535
800
  created: configPath,
536
- room: roomName,
537
- project_id: project.id,
538
- code: project.code,
801
+ room_id: joined.room.room_id,
802
+ project_id: joined.room.project_id ?? null,
803
+ code: joined.room.code ?? null,
539
804
  joined: true,
540
805
  hint: "Consider adding .letagents.json to git so other contributors auto-join the same room.",
541
806
  }, null, 2),
@@ -552,7 +817,7 @@ server.tool("initialize_repo", "Initialize the current repo for Let Agents Chat
552
817
  text: JSON.stringify({
553
818
  success: true,
554
819
  created: configPath,
555
- room: roomName,
820
+ room_id: roomName,
556
821
  joined: false,
557
822
  error: `Config created but auto-join failed: ${err instanceof Error ? err.message : err}`,
558
823
  hint: "The .letagents.json was created. Use join_room to manually connect.",
@@ -563,15 +828,28 @@ server.tool("initialize_repo", "Initialize the current repo for Let Agents Chat
563
828
  }
564
829
  });
565
830
  // -- 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"),
831
+ server.tool("send_message", "Send a message to a Let Agents Chat room.", {
832
+ room_id: z.string().optional().describe("Canonical room ID. Defaults to the current room."),
833
+ project_id: z.string().optional().describe("Legacy project ID. Defaults to the current room."),
568
834
  sender: z.string().describe("Name identifying the sending agent (e.g. 'antigravity-agent')"),
569
835
  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 }),
836
+ }, async ({ room_id, project_id, sender, text }) => {
837
+ const targetRoomId = getTargetRoomId(room_id);
838
+ const targetProjectId = getTargetProjectId(project_id);
839
+ if (!targetRoomId && !targetProjectId) {
840
+ throw new Error("No room is currently selected. Join a room first or pass room_id.");
841
+ }
842
+ const message = await roomScopedApiCall({
843
+ room_id: targetRoomId,
844
+ project_id: targetProjectId,
845
+ room_path: (targetRoomId) => `/rooms/${encodeRoomIdPath(targetRoomId)}/messages`,
846
+ project_path: (targetProjectId) => `/projects/${encodeURIComponent(targetProjectId)}/messages`,
847
+ options: {
848
+ method: "POST",
849
+ body: JSON.stringify({ sender, text }),
850
+ },
574
851
  });
852
+ touchCurrentRoom(typeof message.id === "string" ? message.id : undefined);
575
853
  return {
576
854
  content: [
577
855
  {
@@ -582,10 +860,18 @@ server.tool("send_message", "Send a message to a Let Agents Chat project.", {
582
860
  };
583
861
  });
584
862
  // -- 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`);
863
+ server.tool("read_messages", "Read all messages from a Let Agents Chat room.", {
864
+ room_id: z.string().optional().describe("Canonical room ID. Defaults to the current room."),
865
+ project_id: z.string().optional().describe("Legacy project ID. Defaults to the current room."),
866
+ }, async ({ room_id, project_id }) => {
867
+ const targetRoomId = getTargetRoomId(room_id);
868
+ const targetProjectId = getTargetProjectId(project_id);
869
+ const result = await roomScopedApiCall({
870
+ room_id: targetRoomId,
871
+ project_id: targetProjectId,
872
+ room_path: (targetRoomId) => `/rooms/${encodeRoomIdPath(targetRoomId)}/messages`,
873
+ project_path: (targetProjectId) => `/projects/${encodeURIComponent(targetProjectId)}/messages`,
874
+ });
589
875
  return {
590
876
  content: [
591
877
  {
@@ -598,8 +884,9 @@ server.tool("read_messages", "Read all messages from a Let Agents Chat project."
598
884
  // -- wait_for_messages ------------------------------------------------------
599
885
  const MAX_POLL_TIMEOUT_MS = 180000; // 3 minutes
600
886
  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"),
887
+ 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.", {
888
+ room_id: z.string().optional().describe("Canonical room ID. Defaults to the current room."),
889
+ project_id: z.string().optional().describe("Legacy project ID. Defaults to the current room."),
603
890
  after_message_id: z
604
891
  .string()
605
892
  .optional()
@@ -608,7 +895,9 @@ server.tool("wait_for_messages", "Wait for new messages in a Let Agents Chat pro
608
895
  .number()
609
896
  .optional()
610
897
  .describe("Maximum wait time in milliseconds. If set to 0, the default timeout will be used."),
611
- }, async ({ project_id, after_message_id, timeout }) => {
898
+ }, async ({ room_id, project_id, after_message_id, timeout }) => {
899
+ const targetRoomId = getTargetRoomId(room_id);
900
+ const targetProjectId = getTargetProjectId(project_id);
612
901
  const serverTimeout = Math.min(Math.max(timeout || DEFAULT_POLL_TIMEOUT_MS, 1000), MAX_POLL_TIMEOUT_MS);
613
902
  const clientTimeout = serverTimeout + 5000; // 5s buffer over server timeout
614
903
  const params = new URLSearchParams();
@@ -616,7 +905,304 @@ server.tool("wait_for_messages", "Wait for new messages in a Let Agents Chat pro
616
905
  params.set("after", after_message_id);
617
906
  params.set("timeout", String(serverTimeout));
618
907
  const queryString = params.toString();
619
- const result = await apiCall(`/projects/${encodeURIComponent(project_id)}/messages/poll?${queryString}`, { signal: AbortSignal.timeout(clientTimeout) });
908
+ const result = await roomScopedApiCall({
909
+ room_id: targetRoomId,
910
+ project_id: targetProjectId,
911
+ room_path: (targetRoomId) => `/rooms/${encodeRoomIdPath(targetRoomId)}/messages/poll?${queryString}`,
912
+ project_path: (targetProjectId) => `/projects/${encodeURIComponent(targetProjectId)}/messages/poll?${queryString}`,
913
+ options: { signal: AbortSignal.timeout(clientTimeout) },
914
+ });
915
+ if (targetRoomId) {
916
+ touchRoomSession(targetRoomId, getLastMessageId(result));
917
+ }
918
+ return {
919
+ content: [
920
+ {
921
+ type: "text",
922
+ text: JSON.stringify(result, null, 2),
923
+ },
924
+ ],
925
+ };
926
+ });
927
+ // -- onboarding -------------------------------------------------------------
928
+ 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.", {
929
+ cwd: z
930
+ .string()
931
+ .optional()
932
+ .describe("Working directory to inspect for repo context. Defaults to the current process directory."),
933
+ }, async ({ cwd }) => {
934
+ const workingDir = cwd || process.cwd();
935
+ const repoRoot = resolveGitRoot(workingDir);
936
+ const configRoom = getRoomFromConfig(workingDir);
937
+ const gitRoom = repoRoot ? getGitRemoteIdentity(repoRoot) : null;
938
+ const storedAuth = getStoredAuth();
939
+ const pendingAuth = getPendingDeviceAuth();
940
+ const savedCurrentRoom = getStoredCurrentRoom();
941
+ const detectedRoom = configRoom || gitRoom;
942
+ let nextStep = "join_room";
943
+ if (!storedAuth && pendingAuth) {
944
+ nextStep = "poll_device_auth";
945
+ }
946
+ else if (savedCurrentRoom && !currentRoom) {
947
+ nextStep = "resume_room_session";
948
+ }
949
+ return {
950
+ content: [
951
+ {
952
+ type: "text",
953
+ text: JSON.stringify({
954
+ api_url: API_URL,
955
+ local_state_path: getLocalStatePath(),
956
+ authenticated: Boolean(process.env.LETAGENTS_TOKEN || storedAuth),
957
+ auth_source: process.env.LETAGENTS_TOKEN
958
+ ? "env"
959
+ : storedAuth
960
+ ? "local_state"
961
+ : "none",
962
+ account: storedAuth?.account ?? null,
963
+ token_expires_at: storedAuth?.expires_at ?? null,
964
+ pending_device_auth: pendingAuth,
965
+ current_room: currentRoom,
966
+ saved_current_room: savedCurrentRoom,
967
+ detected_room_from_context: detectedRoom,
968
+ repo_root: repoRoot,
969
+ next_step: nextStep,
970
+ }, null, 2),
971
+ },
972
+ ],
973
+ };
974
+ });
975
+ 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.", {
976
+ room_id: z
977
+ .string()
978
+ .optional()
979
+ .describe("Optional room to associate with this auth request for later auto-join."),
980
+ force: z
981
+ .boolean()
982
+ .optional()
983
+ .describe("If true, replaces any existing pending device auth request."),
984
+ }, async ({ room_id, force }) => {
985
+ const existing = getPendingDeviceAuth();
986
+ if (existing && !force) {
987
+ return {
988
+ content: [
989
+ {
990
+ type: "text",
991
+ text: JSON.stringify({
992
+ success: true,
993
+ reused_existing_request: true,
994
+ ...existing,
995
+ }, null, 2),
996
+ },
997
+ ],
998
+ };
999
+ }
1000
+ const response = await apiCall("/auth/device/start", {
1001
+ method: "POST",
1002
+ });
1003
+ const pendingAuth = setPendingDeviceAuth({
1004
+ request_id: response.request_id,
1005
+ user_code: response.user_code,
1006
+ verification_uri: response.verification_uri,
1007
+ interval_seconds: response.interval,
1008
+ expires_at: new Date(Date.now() + response.expires_in * 1000).toISOString(),
1009
+ started_at: new Date().toISOString(),
1010
+ suggested_room_id: room_id ?? currentRoom?.room_id ?? getRoomFromConfig() ?? undefined,
1011
+ });
1012
+ return {
1013
+ content: [
1014
+ {
1015
+ type: "text",
1016
+ text: JSON.stringify({
1017
+ success: true,
1018
+ ...pendingAuth,
1019
+ }, null, 2),
1020
+ },
1021
+ ],
1022
+ };
1023
+ });
1024
+ 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.", {
1025
+ request_id: z
1026
+ .string()
1027
+ .optional()
1028
+ .describe("The device auth request to poll. Defaults to the locally saved pending request."),
1029
+ room_id: z
1030
+ .string()
1031
+ .optional()
1032
+ .describe("Optional room to auto-join after authorization succeeds."),
1033
+ auto_join: z
1034
+ .boolean()
1035
+ .optional()
1036
+ .describe("If true, tries to join the room immediately after the auth succeeds."),
1037
+ }, async ({ request_id, room_id, auto_join }) => {
1038
+ const pendingAuth = request_id
1039
+ ? getPendingDeviceAuth()?.request_id === request_id
1040
+ ? getPendingDeviceAuth()
1041
+ : null
1042
+ : getPendingDeviceAuth();
1043
+ if (!pendingAuth && !request_id) {
1044
+ return {
1045
+ content: [
1046
+ {
1047
+ type: "text",
1048
+ text: JSON.stringify({ success: false, error: "No pending device auth request found." }, null, 2),
1049
+ },
1050
+ ],
1051
+ };
1052
+ }
1053
+ const requestId = request_id || pendingAuth?.request_id;
1054
+ if (!requestId) {
1055
+ return {
1056
+ content: [
1057
+ {
1058
+ type: "text",
1059
+ text: JSON.stringify({ success: false, error: "A request_id is required when nothing is saved locally." }, null, 2),
1060
+ },
1061
+ ],
1062
+ };
1063
+ }
1064
+ const result = await apiCall(`/auth/device/poll/${encodeURIComponent(requestId)}`);
1065
+ if (result.status === "pending" || result.status === "slow_down") {
1066
+ if (pendingAuth) {
1067
+ setPendingDeviceAuth({
1068
+ ...pendingAuth,
1069
+ interval_seconds: result.interval ?? pendingAuth.interval_seconds,
1070
+ expires_at: result.expires_in !== undefined
1071
+ ? new Date(Date.now() + result.expires_in * 1000).toISOString()
1072
+ : pendingAuth.expires_at,
1073
+ });
1074
+ }
1075
+ return {
1076
+ content: [
1077
+ {
1078
+ type: "text",
1079
+ text: JSON.stringify({ success: true, ...result }, null, 2),
1080
+ },
1081
+ ],
1082
+ };
1083
+ }
1084
+ if (result.status === "denied" || result.status === "expired") {
1085
+ clearPendingDeviceAuth();
1086
+ clearStoredAuth();
1087
+ return {
1088
+ content: [
1089
+ {
1090
+ type: "text",
1091
+ text: JSON.stringify({ success: false, ...result }, null, 2),
1092
+ },
1093
+ ],
1094
+ };
1095
+ }
1096
+ if (!result.letagents_token) {
1097
+ throw new Error("Device auth completed without a LetAgents token.");
1098
+ }
1099
+ clearPendingDeviceAuth();
1100
+ const storedAuth = setStoredAuth({
1101
+ token: result.letagents_token,
1102
+ expires_at: result.expires_at,
1103
+ account: result.account,
1104
+ stored_at: new Date().toISOString(),
1105
+ source: "device_flow",
1106
+ });
1107
+ let joinedRoom = null;
1108
+ const roomToJoin = room_id ||
1109
+ pendingAuth?.suggested_room_id ||
1110
+ currentRoom?.room_id ||
1111
+ getRoomFromConfig() ||
1112
+ undefined;
1113
+ if (auto_join && roomToJoin) {
1114
+ const joinedVia = looksLikeInviteCode(roomToJoin) ? "join_code" : "join_room";
1115
+ const joined = await joinRoomIdentifier(roomToJoin, joinedVia);
1116
+ joinedRoom = joined.room;
1117
+ await autoRegisterAgentIdentity();
1118
+ }
1119
+ return {
1120
+ content: [
1121
+ {
1122
+ type: "text",
1123
+ text: JSON.stringify({
1124
+ success: true,
1125
+ status: "authorized",
1126
+ account: storedAuth.account ?? null,
1127
+ expires_at: storedAuth.expires_at ?? null,
1128
+ auto_joined_room: joinedRoom,
1129
+ }, null, 2),
1130
+ },
1131
+ ],
1132
+ };
1133
+ });
1134
+ server.tool("clear_saved_auth", "Clear any locally saved LetAgents auth token and pending device auth request.", {}, async () => {
1135
+ clearPendingDeviceAuth();
1136
+ clearStoredAuth();
1137
+ return {
1138
+ content: [
1139
+ {
1140
+ type: "text",
1141
+ text: JSON.stringify({
1142
+ success: true,
1143
+ env_token_still_present: Boolean(process.env.LETAGENTS_TOKEN),
1144
+ }, null, 2),
1145
+ },
1146
+ ],
1147
+ };
1148
+ });
1149
+ 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.", {
1150
+ room_id: z
1151
+ .string()
1152
+ .optional()
1153
+ .describe("Optional saved room ID to resume. Defaults to the last current room."),
1154
+ }, async ({ room_id }) => {
1155
+ const savedRoom = (room_id ? getStoredRoomSession(room_id) : null) ??
1156
+ getStoredCurrentRoom();
1157
+ if (!savedRoom) {
1158
+ return {
1159
+ content: [
1160
+ {
1161
+ type: "text",
1162
+ text: JSON.stringify({ success: false, error: "No saved room session found." }, null, 2),
1163
+ },
1164
+ ],
1165
+ };
1166
+ }
1167
+ const joined = await joinRoomIdentifier(savedRoom.room_id, savedRoom.joined_via);
1168
+ await autoRegisterAgentIdentity();
1169
+ return {
1170
+ content: [
1171
+ {
1172
+ type: "text",
1173
+ text: JSON.stringify({
1174
+ success: true,
1175
+ rejoined_from_local_state: true,
1176
+ server_session_resumed: false,
1177
+ last_message_id_before_restart: savedRoom.last_message_id ?? null,
1178
+ room: joined.room,
1179
+ }, null, 2),
1180
+ },
1181
+ ],
1182
+ };
1183
+ });
1184
+ // -- check_repo_visibility --------------------------------------------------
1185
+ 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.", {
1186
+ cwd: z
1187
+ .string()
1188
+ .optional()
1189
+ .describe("Working directory to detect git remote from. Defaults to the MCP server's working directory."),
1190
+ }, async ({ cwd }) => {
1191
+ const { autoDetectRepo } = await import("./repo-visibility.js");
1192
+ const result = await autoDetectRepo(cwd);
1193
+ if (!result) {
1194
+ return {
1195
+ content: [
1196
+ {
1197
+ type: "text",
1198
+ text: JSON.stringify({
1199
+ error: "Not in a git repository or no remote configured",
1200
+ suggestion: "Use create_project to create an invite room instead",
1201
+ }, null, 2),
1202
+ },
1203
+ ],
1204
+ };
1205
+ }
620
1206
  return {
621
1207
  content: [
622
1208
  {
@@ -638,23 +1224,26 @@ async function main() {
638
1224
  // 1. Try .letagents.json config
639
1225
  const configRoom = getRoomFromConfig();
640
1226
  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(); });
1227
+ await joinRoomIdentifier(configRoom, "config");
644
1228
  console.error(`🏠 Auto-joined room '${configRoom}' (from .letagents.json)`);
645
1229
  return;
646
1230
  }
647
1231
  // 2. Try git remote URL
648
1232
  const gitRoom = getGitRemoteIdentity();
649
1233
  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(); });
1234
+ await joinRoomIdentifier(gitRoom, "git-remote");
653
1235
  console.error(`🏠 Auto-joined room '${gitRoom}' (inferred from git remote — consider adding a .letagents.json)`);
654
1236
  return;
655
1237
  }
656
- // 3. No context found
657
- console.error("â„šī¸ No .letagents.json or git remote found — use join_project or join_room to connect.");
1238
+ // 3. Fall back to the most recent saved room session
1239
+ const savedCurrentRoom = getStoredCurrentRoom();
1240
+ if (savedCurrentRoom) {
1241
+ await joinRoomIdentifier(savedCurrentRoom.room_id, savedCurrentRoom.joined_via);
1242
+ console.error(`🏠 Rejoined saved room '${savedCurrentRoom.room_id}' (from local state)`);
1243
+ return;
1244
+ }
1245
+ // 4. No context found
1246
+ console.error("â„šī¸ No .letagents.json, git remote, or saved room found — use join_project or join_room to connect.");
658
1247
  }
659
1248
  catch (err) {
660
1249
  // Auto-join failure should never block the MCP server