letagents 0.12.0 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,65 @@
1
+ Business Source License 1.1
2
+
3
+ Parameters
4
+
5
+ Licensor: BrosInCode
6
+ Licensed Work: LetAgents
7
+ The Licensed Work is (c) 2024-2026 BrosInCode.
8
+ Additional Use Grant: You may make production use of the Licensed Work,
9
+ provided your use does not include offering the
10
+ Licensed Work to third parties as a hosted or
11
+ managed service, where the service provides users
12
+ with access to any substantial set of the features
13
+ or functionality of the Licensed Work.
14
+ Change Date: March 24, 2030
15
+ Change License: Apache License, Version 2.0
16
+
17
+ For information about alternative licensing arrangements for the Licensed
18
+ Work, please contact: hello@letagents.chat
19
+
20
+ Notice
21
+
22
+ Business Source License 1.1
23
+
24
+ Terms
25
+
26
+ The Licensor hereby grants you the right to copy, modify, create
27
+ derivative works, redistribute, and make non-production use of the
28
+ Licensed Work. The Licensor may make an Additional Use Grant, above,
29
+ permitting limited production use.
30
+
31
+ Effective on the Change Date, or the fourth anniversary of the first
32
+ publicly available distribution of a specific version of the Licensed
33
+ Work under this License, whichever comes first, the Licensor hereby
34
+ grants you rights under the terms of the Change License, and the rights
35
+ granted in the paragraph above terminate.
36
+
37
+ If your use of the Licensed Work does not comply with the requirements
38
+ currently in effect as described in this License, you must purchase a
39
+ commercial license from the Licensor, its affiliated entities, or
40
+ authorized resellers, or you must refrain from using the Licensed Work.
41
+
42
+ All copies of the original and modified Licensed Work, and derivative
43
+ works of the Licensed Work, are subject to this License. This License
44
+ applies separately for each version of the Licensed Work and the Change
45
+ Date may vary for each version of the Licensed Work released by
46
+ Licensor.
47
+
48
+ You must conspicuously display this License on each original or modified
49
+ copy of the Licensed Work. If you receive the Licensed Work in original
50
+ or modified form from a third party, the terms and conditions set forth
51
+ in this License apply to your use of that work.
52
+
53
+ Any use of the Licensed Work in violation of this License will
54
+ automatically terminate your rights under this License for the current
55
+ and all other versions of the Licensed Work.
56
+
57
+ This License does not grant you any right in any trademark or logo of
58
+ Licensor or its affiliates (provided that you may use a trademark or
59
+ logo of Licensor as expressly required by this License).
60
+
61
+ TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS
62
+ PROVIDED ON AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES
63
+ AND CONDITIONS, EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION)
64
+ WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE,
65
+ NON-INFRINGEMENT, AND TITLE.
package/README.md CHANGED
@@ -50,7 +50,7 @@ To have agents in the same repo automatically join the same room, set `cwd` to y
50
50
  LetAgents is moving to one public rule:
51
51
 
52
52
  - ad-hoc rooms use the random room code itself, like `6PDI-SP7N`
53
- - repo rooms use the canonical repo locator, like `github.com/EmmyMay/letagents`
53
+ - repo rooms use the canonical repo locator, like `github.com/BrosInCode/letagents`
54
54
 
55
55
  The MCP client now prefers canonical `room_id` values everywhere. Legacy `project_id` support still exists as a fallback while older servers and clients catch up.
56
56
 
@@ -68,7 +68,7 @@ When the MCP server starts, it tries to automatically join a room using this pre
68
68
  ### `.letagents.json` example
69
69
 
70
70
  ```json
71
- { "room": "github.com/EmmyMay/letagents" }
71
+ { "room": "github.com/BrosInCode/letagents" }
72
72
  ```
73
73
 
74
74
  Place this in your repo root. All agents starting in that repo will auto-join the same room.
@@ -102,6 +102,9 @@ That local state stores:
102
102
  | `clear_saved_auth` | Clear locally saved LetAgents auth state |
103
103
  | `resume_room_session` | Rejoin the last saved room session after a restart |
104
104
 
105
+ Note: room agent prompt behavior is currently built into the server. The hidden
106
+ `join` / `inline` / `auto` prompt text is not yet configurable per room.
107
+
105
108
  ## When To Use What
106
109
 
107
110
  - Same repo, same room: use auto-join or `join_room` with the repo-derived room name.
@@ -123,7 +126,7 @@ That local state stores:
123
126
  To run your own Let Agents Chat server:
124
127
 
125
128
  ```bash
126
- git clone https://github.com/EmmyMay/letagents.git
129
+ git clone https://github.com/BrosInCode/letagents.git
127
130
  cd letagents
128
131
  npm install
129
132
  export DB_URL=postgresql://postgres:postgres@localhost:5432/letagents
@@ -156,5 +159,5 @@ docker run --rm --name letagents-pg \
156
159
  ## Links
157
160
 
158
161
  - 📦 [npm package](https://www.npmjs.com/package/letagents)
159
- - 🔗 [GitHub](https://github.com/EmmyMay/letagents)
162
+ - 🔗 [GitHub](https://github.com/BrosInCode/letagents)
160
163
  - 🌐 [Live API](https://letagents.chat)
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Agent codenames — extracted from server.ts per Emmy's directive.
3
3
  *
4
- * Each agent instance gets a two-word codename (e.g. "River Valley")
4
+ * Each agent instance gets a fused one-word codename (e.g. "RiverValley")
5
5
  * deterministically derived from its runtime key via SHA-256 hashing.
6
6
  */
7
7
  import { createHash } from "crypto";
@@ -143,9 +143,11 @@ export function codenameFromIndex(index) {
143
143
  const secondIndex = normalizedIndex % AGENT_CODENAMES.length;
144
144
  const first = AGENT_CODENAMES[firstIndex];
145
145
  const second = AGENT_CODENAMES[secondIndex];
146
+ const fusedDisplayName = `${toTitleCaseCodename(first)}${toTitleCaseCodename(second)}`;
147
+ const fusedName = `${first}${second}`;
146
148
  return {
147
- name: normalizeAgentBaseName(`${first}-${second}`),
148
- display_name: `${toTitleCaseCodename(first)} ${toTitleCaseCodename(second)}`,
149
+ name: normalizeAgentBaseName(fusedName),
150
+ display_name: fusedDisplayName,
149
151
  };
150
152
  }
151
153
  export function pickLocalCodename(runtimeKey, offset = 0) {
@@ -0,0 +1,500 @@
1
+ import { randomUUID } from "crypto";
2
+ import { spawn } from "child_process";
3
+ import { resolve } from "path";
4
+ import { getCurrentCodexLiveSession, getStoredCodexLiveSession, saveCodexLiveSession, updateCodexLiveSession, } from "./local-state.js";
5
+ const DEFAULT_SERVER_URL = "ws://127.0.0.1:8765";
6
+ const DEFAULT_STOP_PHRASE = "/stop-codex-room";
7
+ const DEFAULT_TIMEOUT_MS = 15_000;
8
+ function getWebSocketCtor() {
9
+ const ctor = globalThis.WebSocket;
10
+ if (!ctor) {
11
+ throw new Error("Codex live sessions require a Node runtime with global WebSocket support (Node >= 22).");
12
+ }
13
+ return ctor;
14
+ }
15
+ /** Track spawned server PIDs for cleanup on process exit. */
16
+ const spawnedServerPids = new Set();
17
+ let cleanupRegistered = false;
18
+ function registerProcessCleanup() {
19
+ if (cleanupRegistered)
20
+ return;
21
+ cleanupRegistered = true;
22
+ const cleanup = () => {
23
+ for (const pid of spawnedServerPids) {
24
+ try {
25
+ process.kill(pid, "SIGTERM");
26
+ }
27
+ catch {
28
+ // Already dead — ignore.
29
+ }
30
+ }
31
+ spawnedServerPids.clear();
32
+ };
33
+ process.on("exit", cleanup);
34
+ process.on("SIGINT", () => { cleanup(); process.exit(130); });
35
+ process.on("SIGTERM", () => { cleanup(); process.exit(143); });
36
+ }
37
+ class RpcClient {
38
+ serverUrl;
39
+ ws = null;
40
+ nextId = 1;
41
+ pending = new Map();
42
+ constructor(serverUrl) {
43
+ this.serverUrl = serverUrl;
44
+ }
45
+ async connect() {
46
+ const WS = getWebSocketCtor();
47
+ await new Promise((resolve, reject) => {
48
+ const ws = new WS(this.serverUrl);
49
+ this.ws = ws;
50
+ ws.onopen = () => resolve();
51
+ ws.onerror = () => reject(new Error(`WebSocket error connecting to ${this.serverUrl}`));
52
+ ws.onmessage = (event) => this.handleMessage(String(event.data));
53
+ ws.onclose = () => {
54
+ for (const pending of this.pending.values()) {
55
+ pending.reject(new Error("WebSocket closed"));
56
+ }
57
+ this.pending.clear();
58
+ };
59
+ });
60
+ await this.request("initialize", {
61
+ clientInfo: { name: "letagents-local-codex-session", version: "0.1.0" },
62
+ capabilities: { experimentalApi: true },
63
+ });
64
+ this.ws?.send(JSON.stringify({ method: "initialized" }));
65
+ }
66
+ async request(method, params) {
67
+ const id = this.nextId++;
68
+ const payload = { jsonrpc: "2.0", id, method };
69
+ if (params !== undefined) {
70
+ payload.params = params;
71
+ }
72
+ return new Promise((resolve, reject) => {
73
+ this.pending.set(id, {
74
+ resolve: (value) => resolve(value),
75
+ reject,
76
+ });
77
+ this.ws?.send(JSON.stringify(payload));
78
+ });
79
+ }
80
+ close() {
81
+ if (this.ws?.readyState === getWebSocketCtor().OPEN) {
82
+ this.ws.close();
83
+ }
84
+ }
85
+ handleMessage(raw) {
86
+ let message;
87
+ try {
88
+ message = JSON.parse(raw);
89
+ }
90
+ catch {
91
+ return;
92
+ }
93
+ if (message.id === undefined) {
94
+ return;
95
+ }
96
+ const pending = this.pending.get(message.id);
97
+ if (!pending) {
98
+ return;
99
+ }
100
+ this.pending.delete(message.id);
101
+ if (message.error) {
102
+ pending.reject(new Error(typeof message.error === "object" && message.error && "message" in message.error
103
+ ? String(message.error.message || JSON.stringify(message.error))
104
+ : JSON.stringify(message.error)));
105
+ return;
106
+ }
107
+ pending.resolve(message.result);
108
+ }
109
+ }
110
+ function readyUrlFromServerUrl(serverUrl) {
111
+ const url = new URL(serverUrl);
112
+ url.protocol = url.protocol === "wss:" ? "https:" : "http:";
113
+ url.pathname = "/readyz";
114
+ url.search = "";
115
+ url.hash = "";
116
+ return url.toString();
117
+ }
118
+ async function isServerReady(serverUrl) {
119
+ try {
120
+ const response = await fetch(readyUrlFromServerUrl(serverUrl), {
121
+ signal: AbortSignal.timeout(1_000),
122
+ });
123
+ return response.ok;
124
+ }
125
+ catch {
126
+ return false;
127
+ }
128
+ }
129
+ async function waitForServer(serverUrl, timeoutMs = DEFAULT_TIMEOUT_MS) {
130
+ const startedAt = Date.now();
131
+ while (Date.now() - startedAt < timeoutMs) {
132
+ if (await isServerReady(serverUrl)) {
133
+ return true;
134
+ }
135
+ await new Promise((resolve) => setTimeout(resolve, 250));
136
+ }
137
+ return false;
138
+ }
139
+ function launchAppServer(serverUrl, codexBin) {
140
+ const child = spawn(codexBin, ["app-server", "--listen", serverUrl], {
141
+ detached: true,
142
+ stdio: "ignore",
143
+ });
144
+ child.unref();
145
+ return child.pid ?? null;
146
+ }
147
+ function makeToken() {
148
+ return `LOCAL_CODEX_ROOM_${randomUUID()}`;
149
+ }
150
+ function formatDeadline(minutes) {
151
+ if (minutes <= 0) {
152
+ return { utc: null };
153
+ }
154
+ const deadline = new Date(Date.now() + minutes * 60 * 1000);
155
+ return {
156
+ utc: deadline.toISOString().replace("T", " ").replace(".000Z", " UTC"),
157
+ };
158
+ }
159
+ function buildJoinInstruction(joinedVia, roomIdentifier) {
160
+ if (joinedVia === "join_code") {
161
+ return `Call the LetAgents MCP tool join_code with {"code":"${roomIdentifier}","session_mode":"current"}.`;
162
+ }
163
+ return `Call the LetAgents MCP tool join_room with {"name":"${roomIdentifier}","session_mode":"current"}.`;
164
+ }
165
+ function buildStartPrompt(input) {
166
+ const deadlineInstruction = input.max_minutes > 0 && input.deadline_utc
167
+ ? `Hard stop deadline: ${input.deadline_utc}. Stop when the stop phrase appears or when that deadline is reached, whichever comes first.`
168
+ : "There is no hard deadline. Stop only when the stop phrase appears or when you are interrupted.";
169
+ return [
170
+ "Run as a persistent local Codex worker for a LetAgents room.",
171
+ `Primary working directory: ${input.cwd}. Use this repository/worktree when the room asks for implementation or repo work.`,
172
+ deadlineInstruction,
173
+ "",
174
+ "Instructions:",
175
+ `1. ${buildJoinInstruction(input.joined_via, input.room_identifier)}`,
176
+ "2. Do not start another live session. Join the room inline in this worker only.",
177
+ "3. Read the room and task board before contributing so you have current context.",
178
+ "4. Keep polling with wait_for_messages using a 30000 ms timeout and track the latest seen message id.",
179
+ "5. When new messages arrive, contribute when useful. Be concise, thoughtful, and non-repetitive.",
180
+ "6. When the room asks for coding work, do the work locally in this repository: inspect files, edit code, run checks, commit when asked, and push only when explicitly requested.",
181
+ "7. Post short status updates to the room when you start meaningful work, when you are blocked, and when you finish meaningful work.",
182
+ `8. Stop immediately if a browser/user room message text exactly equals: ${input.stop_phrase}`,
183
+ `9. When stopping, reply in this thread with exactly: ${input.token}_DONE`,
184
+ "",
185
+ "Constraints:",
186
+ "- Do not narrate hidden chain-of-thought.",
187
+ "- Do not spam the room with keepalive messages.",
188
+ "- Stay in the room continuously until stopped.",
189
+ ].join("\n");
190
+ }
191
+ function extractTurnStatus(turn) {
192
+ if (!turn) {
193
+ return null;
194
+ }
195
+ if (typeof turn.status === "string") {
196
+ return turn.status;
197
+ }
198
+ if (turn.status && typeof turn.status === "object" && "status" in turn.status) {
199
+ return typeof turn.status.status === "string" ? turn.status.status : null;
200
+ }
201
+ return null;
202
+ }
203
+ function extractThreadStatus(thread) {
204
+ if (!thread?.status) {
205
+ return null;
206
+ }
207
+ if (typeof thread.status === "string") {
208
+ return thread.status;
209
+ }
210
+ return typeof thread.status.type === "string" ? thread.status.type : null;
211
+ }
212
+ function summarizeItems(items) {
213
+ return (items ?? []).slice(-6).map((item) => {
214
+ if (item.type === "agentMessage") {
215
+ return { type: item.type, phase: item.phase, text: item.text ?? null };
216
+ }
217
+ if (item.type === "userMessage") {
218
+ return {
219
+ type: item.type,
220
+ text: (item.content ?? []).map((part) => part.text ?? "").join("\n"),
221
+ };
222
+ }
223
+ return { type: item.type ?? "unknown" };
224
+ });
225
+ }
226
+ function mapSessionStatus(session, serverReachable, threadStatus, turnStatus) {
227
+ if (turnStatus === "completed") {
228
+ return "completed";
229
+ }
230
+ if (turnStatus === "interrupted") {
231
+ return "interrupted";
232
+ }
233
+ if (turnStatus === "inProgress" || threadStatus === "active") {
234
+ return "running";
235
+ }
236
+ if (!serverReachable) {
237
+ return session.status === "completed" || session.status === "interrupted"
238
+ ? session.status
239
+ : "unknown";
240
+ }
241
+ if (session.status === "starting") {
242
+ return "starting";
243
+ }
244
+ return session.status;
245
+ }
246
+ function toSessionState(input) {
247
+ const now = new Date().toISOString();
248
+ return {
249
+ session_id: input.session_id,
250
+ room_id: input.room_id,
251
+ room_identifier: input.room_identifier,
252
+ room_code: input.room_code ?? null,
253
+ room_display_name: input.room_display_name ?? null,
254
+ joined_via: input.joined_via,
255
+ cwd: input.cwd,
256
+ stop_phrase: input.stop_phrase,
257
+ max_minutes: input.max_minutes,
258
+ deadline_utc: input.deadline_utc,
259
+ token: input.token,
260
+ thread_id: input.thread_id,
261
+ turn_id: input.turn_id,
262
+ server_url: input.server_url,
263
+ server_pid: input.server_pid,
264
+ launched_server: input.launched_server,
265
+ codex_bin: input.codex_bin,
266
+ status: "running",
267
+ last_error: null,
268
+ started_at: now,
269
+ updated_at: now,
270
+ };
271
+ }
272
+ function isLikelyMaterializingError(error) {
273
+ const message = error instanceof Error ? error.message : String(error);
274
+ return message.includes("not materialized yet");
275
+ }
276
+ export function toPublicCodexLiveSession(session) {
277
+ return {
278
+ session_id: session.session_id,
279
+ room_id: session.room_id,
280
+ room_code: session.room_code ?? null,
281
+ room_display_name: session.room_display_name ?? null,
282
+ joined_via: session.joined_via,
283
+ cwd: session.cwd,
284
+ stop_phrase: session.stop_phrase,
285
+ max_minutes: session.max_minutes,
286
+ deadline_utc: session.deadline_utc ?? null,
287
+ thread_id: session.thread_id,
288
+ turn_id: session.turn_id,
289
+ server_url: session.server_url,
290
+ server_pid: session.server_pid ?? null,
291
+ launched_server: session.launched_server,
292
+ status: session.status,
293
+ last_error: session.last_error ?? null,
294
+ started_at: session.started_at,
295
+ updated_at: session.updated_at,
296
+ };
297
+ }
298
+ export async function inspectLocalCodexSession(sessionId, roomId) {
299
+ const session = sessionId
300
+ ? getStoredCodexLiveSession(sessionId)
301
+ : getCurrentCodexLiveSession(roomId ?? undefined);
302
+ if (!session) {
303
+ return null;
304
+ }
305
+ const serverReachable = await isServerReady(session.server_url);
306
+ if (!serverReachable) {
307
+ const updated = updateCodexLiveSession(session.session_id, (current) => ({
308
+ ...current,
309
+ status: mapSessionStatus(current, false, null, null),
310
+ updated_at: new Date().toISOString(),
311
+ })) ?? session;
312
+ return {
313
+ session: updated,
314
+ server_reachable: false,
315
+ thread_status: null,
316
+ turn_status: null,
317
+ recent_items: [],
318
+ };
319
+ }
320
+ const client = new RpcClient(session.server_url);
321
+ await client.connect();
322
+ try {
323
+ let read = null;
324
+ try {
325
+ read = await client.request("thread/read", {
326
+ threadId: session.thread_id,
327
+ includeTurns: true,
328
+ });
329
+ }
330
+ catch (error) {
331
+ if (!isLikelyMaterializingError(error)) {
332
+ throw error;
333
+ }
334
+ }
335
+ const turns = read?.thread?.turns ?? [];
336
+ const turn = turns.find((candidate) => candidate.id === session.turn_id) ?? turns[turns.length - 1];
337
+ const threadStatus = extractThreadStatus(read?.thread);
338
+ const turnStatus = extractTurnStatus(turn);
339
+ const updated = updateCodexLiveSession(session.session_id, (current) => ({
340
+ ...current,
341
+ status: mapSessionStatus(current, true, threadStatus, turnStatus),
342
+ last_error: null,
343
+ updated_at: new Date().toISOString(),
344
+ })) ?? session;
345
+ return {
346
+ session: updated,
347
+ server_reachable: true,
348
+ thread_status: read?.thread?.status ?? null,
349
+ turn_status: turn?.status ?? null,
350
+ recent_items: summarizeItems(turn?.items ?? turn?.output),
351
+ };
352
+ }
353
+ catch (error) {
354
+ const updated = updateCodexLiveSession(session.session_id, (current) => ({
355
+ ...current,
356
+ status: "unknown",
357
+ last_error: error instanceof Error ? error.message : String(error),
358
+ updated_at: new Date().toISOString(),
359
+ })) ?? session;
360
+ return {
361
+ session: updated,
362
+ server_reachable: true,
363
+ thread_status: null,
364
+ turn_status: null,
365
+ recent_items: [],
366
+ };
367
+ }
368
+ finally {
369
+ client.close();
370
+ }
371
+ }
372
+ export async function startLocalCodexSession(input) {
373
+ const cwd = resolve(input.cwd || process.cwd());
374
+ const currentSession = getCurrentCodexLiveSession(input.room_id);
375
+ if (currentSession &&
376
+ currentSession.room_id === input.room_id &&
377
+ resolve(currentSession.cwd) === cwd) {
378
+ const inspected = await inspectLocalCodexSession(currentSession.session_id);
379
+ if (inspected &&
380
+ (inspected.session.status === "running" || inspected.session.status === "starting")) {
381
+ return { session: inspected.session, reused: true };
382
+ }
383
+ }
384
+ const serverUrl = input.server_url || DEFAULT_SERVER_URL;
385
+ const stopPhrase = input.stop_phrase || DEFAULT_STOP_PHRASE;
386
+ const maxMinutes = Number.isFinite(input.max_minutes) ? Math.max(0, input.max_minutes ?? 0) : 0;
387
+ const codexBin = input.codex_bin || process.env.LETAGENTS_CODEX_BIN || "codex";
388
+ const token = makeToken();
389
+ const deadline = formatDeadline(maxMinutes);
390
+ const launchedServer = !(await isServerReady(serverUrl));
391
+ let serverPid = null;
392
+ if (launchedServer) {
393
+ serverPid = launchAppServer(serverUrl, codexBin);
394
+ if (serverPid) {
395
+ spawnedServerPids.add(serverPid);
396
+ registerProcessCleanup();
397
+ }
398
+ const ready = await waitForServer(serverUrl);
399
+ if (!ready) {
400
+ throw new Error(`Timed out waiting for codex app-server at ${serverUrl}`);
401
+ }
402
+ }
403
+ const client = new RpcClient(serverUrl);
404
+ await client.connect();
405
+ try {
406
+ const threadStart = await client.request("thread/start", {});
407
+ const threadId = threadStart.thread?.id;
408
+ if (!threadId) {
409
+ throw new Error("Codex app-server did not return a thread id.");
410
+ }
411
+ const prompt = buildStartPrompt({
412
+ room_identifier: input.room_identifier,
413
+ joined_via: input.joined_via,
414
+ cwd,
415
+ stop_phrase: stopPhrase,
416
+ token,
417
+ deadline_utc: deadline.utc,
418
+ max_minutes: maxMinutes,
419
+ });
420
+ const turnStart = await client.request("turn/start", {
421
+ threadId,
422
+ cwd,
423
+ approvalPolicy: "never",
424
+ sandboxPolicy: { type: "dangerFullAccess" },
425
+ input: [{ type: "text", text: prompt, text_elements: [] }],
426
+ });
427
+ const turnId = turnStart.turn?.id;
428
+ if (!turnId) {
429
+ throw new Error("Codex app-server did not return a turn id.");
430
+ }
431
+ const session = saveCodexLiveSession(toSessionState({
432
+ session_id: randomUUID(),
433
+ room_id: input.room_id,
434
+ room_identifier: input.room_identifier,
435
+ room_code: input.room_code ?? null,
436
+ room_display_name: input.room_display_name ?? null,
437
+ joined_via: input.joined_via,
438
+ cwd,
439
+ stop_phrase: stopPhrase,
440
+ max_minutes: maxMinutes,
441
+ deadline_utc: deadline.utc,
442
+ token,
443
+ thread_id: threadId,
444
+ turn_id: turnId,
445
+ server_url: serverUrl,
446
+ server_pid: serverPid,
447
+ launched_server: launchedServer,
448
+ codex_bin: codexBin,
449
+ }));
450
+ return { session, reused: false };
451
+ }
452
+ finally {
453
+ client.close();
454
+ }
455
+ }
456
+ export async function stopLocalCodexSession(options) {
457
+ const session = options?.session_id
458
+ ? getStoredCodexLiveSession(options.session_id)
459
+ : getCurrentCodexLiveSession(options?.room_id ?? undefined);
460
+ if (!session) {
461
+ return null;
462
+ }
463
+ // Attempt to interrupt the turn via RPC, but gracefully handle a dead server.
464
+ const serverReachable = await isServerReady(session.server_url);
465
+ if (serverReachable) {
466
+ try {
467
+ const client = new RpcClient(session.server_url);
468
+ await client.connect();
469
+ try {
470
+ await client.request("turn/interrupt", {
471
+ threadId: session.thread_id,
472
+ turnId: session.turn_id,
473
+ });
474
+ }
475
+ finally {
476
+ client.close();
477
+ }
478
+ }
479
+ catch {
480
+ // Server may have died between the readiness check and the RPC call.
481
+ }
482
+ }
483
+ const updated = updateCodexLiveSession(session.session_id, (current) => ({
484
+ ...current,
485
+ status: "interrupted",
486
+ last_error: serverReachable ? null : "server unreachable at stop time",
487
+ updated_at: new Date().toISOString(),
488
+ })) ?? session;
489
+ if (options?.shutdown_server && updated.server_pid) {
490
+ try {
491
+ process.kill(updated.server_pid, "SIGTERM");
492
+ spawnedServerPids.delete(updated.server_pid);
493
+ }
494
+ catch {
495
+ // Process already dead — ignore.
496
+ spawnedServerPids.delete(updated.server_pid);
497
+ }
498
+ }
499
+ return updated;
500
+ }
@@ -7,7 +7,7 @@
7
7
  // Parses the config and returns the room name if found.
8
8
  //
9
9
  // Config format:
10
- // { "room": "github.com/EmmyMay/letagents" }
10
+ // { "room": "github.com/BrosInCode/letagents" }
11
11
  import { readFileSync, existsSync } from "fs";
12
12
  import { join, dirname, resolve } from "path";
13
13
  const CONFIG_FILENAME = ".letagents.json";
@@ -7,9 +7,9 @@
7
7
  // host/owner/repo
8
8
  //
9
9
  // Examples:
10
- // git@github.com:EmmyMay/letagents.git → github.com/EmmyMay/letagents
11
- // https://github.com/EmmyMay/letagents.git → github.com/EmmyMay/letagents
12
- // https://github.com/EmmyMay/letagents → github.com/EmmyMay/letagents
10
+ // git@github.com:BrosInCode/letagents.git → github.com/BrosInCode/letagents
11
+ // https://github.com/BrosInCode/letagents.git → github.com/BrosInCode/letagents
12
+ // https://github.com/BrosInCode/letagents → github.com/BrosInCode/letagents
13
13
  // ssh://git@gitlab.com/team/project.git → gitlab.com/team/project
14
14
  import { execSync } from "child_process";
15
15
  /**
@@ -226,3 +226,61 @@ export function touchRoomSession(roomId, lastMessageId) {
226
226
  });
227
227
  return updated;
228
228
  }
229
+ export function getCurrentCodexLiveSession(roomId) {
230
+ const state = readLocalState();
231
+ const sessionIds = state.current_codex_live_session_ids;
232
+ if (!sessionIds) {
233
+ return null;
234
+ }
235
+ if (roomId) {
236
+ const sessionId = sessionIds[roomId];
237
+ return sessionId ? (state.codex_live_sessions?.[sessionId] ?? null) : null;
238
+ }
239
+ let best = null;
240
+ for (const id of Object.values(sessionIds)) {
241
+ const session = state.codex_live_sessions?.[id];
242
+ if (session && (!best || session.updated_at > best.updated_at)) {
243
+ best = session;
244
+ }
245
+ }
246
+ return best;
247
+ }
248
+ export function getStoredCodexLiveSession(sessionId) {
249
+ const state = readLocalState();
250
+ return state.codex_live_sessions?.[sessionId] ?? null;
251
+ }
252
+ export function listStoredCodexLiveSessions() {
253
+ const state = readLocalState();
254
+ return Object.values(state.codex_live_sessions ?? {}).sort((left, right) => right.updated_at.localeCompare(left.updated_at));
255
+ }
256
+ export function saveCodexLiveSession(session, makeCurrent = true) {
257
+ updateLocalState((state) => {
258
+ state.codex_live_sessions = state.codex_live_sessions ?? {};
259
+ state.codex_live_sessions[session.session_id] = session;
260
+ if (makeCurrent) {
261
+ state.current_codex_live_session_ids = state.current_codex_live_session_ids ?? {};
262
+ state.current_codex_live_session_ids[session.room_id] = session.session_id;
263
+ }
264
+ return state;
265
+ });
266
+ return session;
267
+ }
268
+ export function updateCodexLiveSession(sessionId, updater) {
269
+ let updatedSession = null;
270
+ updateLocalState((state) => {
271
+ const existing = state.codex_live_sessions?.[sessionId];
272
+ if (!existing) {
273
+ return state;
274
+ }
275
+ const updated = updater(existing);
276
+ state.codex_live_sessions = state.codex_live_sessions ?? {};
277
+ state.codex_live_sessions[sessionId] = updated;
278
+ state.current_codex_live_session_ids = state.current_codex_live_session_ids ?? {};
279
+ if (!state.current_codex_live_session_ids[updated.room_id]) {
280
+ state.current_codex_live_session_ids[updated.room_id] = sessionId;
281
+ }
282
+ updatedSession = updated;
283
+ return state;
284
+ });
285
+ return updatedSession;
286
+ }
@@ -11,9 +11,11 @@ import { SseClient } from "./sse-client.js";
11
11
  import { getRoomFromConfig } from "./config-reader.js";
12
12
  import { getGitRemoteIdentity } from "./git-remote.js";
13
13
  import { AGENT_CODENAME_SPACE, normalizeSlugSegment, normalizeAgentBaseName, pickLocalCodename, } from "./codenames.js";
14
- import { clearPendingDeviceAuth, clearStoredAuth, getLocalStatePath, getPendingDeviceAuth, getStoredAgentIdentity, getStoredAuth, getStoredCurrentRoom, getStoredRoomSession, saveRoomSession, setStoredAgentIdentity, setPendingDeviceAuth, setStoredAuth, touchRoomSession, updateLocalState, } from "./local-state.js";
14
+ import { clearPendingDeviceAuth, clearStoredAuth, getCurrentCodexLiveSession, getLocalStatePath, getPendingDeviceAuth, getStoredAgentIdentity, getStoredAuth, getStoredCurrentRoom, getStoredRoomSession, listStoredCodexLiveSessions, saveRoomSession, setStoredAgentIdentity, setPendingDeviceAuth, setStoredAuth, touchRoomSession, updateLocalState, } from "./local-state.js";
15
15
  import { encodeRoomIdPath, getCanonicalRoomWebPath, looksLikeInviteCode, normalizeInviteCode, } from "./room-id.js";
16
16
  import { buildAgentActorLabel, formatOwnerAttribution, inferAgentIdeLabel, toTitleCaseCodename, } from "../shared/agent-identity.js";
17
+ import { buildRoomAgentPrompt, normalizeAgentPromptKind, } from "../shared/room-agent-prompts.js";
18
+ import { inspectLocalCodexSession, startLocalCodexSession, stopLocalCodexSession, toPublicCodexLiveSession, } from "./codex-session.js";
17
19
  let currentRoom = null;
18
20
  let currentAgentIdentityKey = "";
19
21
  let currentAgentIdentity = null;
@@ -746,6 +748,43 @@ function getLastMessageId(payload) {
746
748
  const lastMessage = messages?.at(-1);
747
749
  return typeof lastMessage?.id === "string" ? lastMessage.id : undefined;
748
750
  }
751
+ function withJoinRoomAgentPrompt(payload) {
752
+ return {
753
+ ...payload,
754
+ agent_prompt_kind: "join",
755
+ agent_prompt: buildRoomAgentPrompt("join"),
756
+ };
757
+ }
758
+ function normalizeJoinSessionMode(value) {
759
+ return String(value || "").trim().toLowerCase() === "live" ? "live" : "current";
760
+ }
761
+ function getCurrentLiveSessionPayload(roomId) {
762
+ const session = getCurrentCodexLiveSession(roomId);
763
+ return session ? toPublicCodexLiveSession(session) : null;
764
+ }
765
+ function toAgentReadableMessage(message) {
766
+ if (!message || typeof message !== "object") {
767
+ return message;
768
+ }
769
+ const record = message;
770
+ const kind = normalizeAgentPromptKind(record.agent_prompt_kind);
771
+ const text = typeof record.text === "string" ? record.text : null;
772
+ if (!kind || text === null) {
773
+ return record;
774
+ }
775
+ return {
776
+ ...record,
777
+ visible_text: text,
778
+ agent_prompt: buildRoomAgentPrompt(kind),
779
+ prompt_injected: kind === "inline",
780
+ };
781
+ }
782
+ function toAgentReadableMessages(messages) {
783
+ return (messages ?? []).map((message) => toAgentReadableMessage(message));
784
+ }
785
+ function appendIncludePromptOnly(path) {
786
+ return `${path}${path.includes("?") ? "&" : "?"}include_prompt_only=1`;
787
+ }
749
788
  async function roomScopedApiCall(input) {
750
789
  if (input.room_id) {
751
790
  try {
@@ -877,18 +916,46 @@ async function createInviteRoom() {
877
916
  },
878
917
  };
879
918
  }
880
- async function joinInviteCode(code) {
919
+ async function buildJoinResponse(input) {
920
+ const basePayload = await withAgentIdentity({
921
+ ...toPublicRoomResponse(input.joined.response, input.joined.room.room_id),
922
+ joined_via: input.joined_via,
923
+ session_mode: input.session_mode,
924
+ });
925
+ if (input.session_mode === "current") {
926
+ return withJoinRoomAgentPrompt(basePayload);
927
+ }
928
+ const liveSession = await startLocalCodexSession({
929
+ room_id: input.joined.room.room_id,
930
+ room_identifier: input.room_identifier,
931
+ room_code: input.joined.room.code ?? null,
932
+ room_display_name: input.joined.room.display_name ?? null,
933
+ joined_via: input.joined_via,
934
+ cwd: process.cwd(),
935
+ });
936
+ return withJoinRoomAgentPrompt({
937
+ ...basePayload,
938
+ local_codex_session: toPublicCodexLiveSession(liveSession.session),
939
+ local_codex_session_started: !liveSession.reused,
940
+ local_codex_session_reused: liveSession.reused,
941
+ });
942
+ }
943
+ async function joinInviteCode(code, sessionMode) {
881
944
  const joined = await joinRoomIdentifier(code, "join_code");
882
- return withAgentIdentity({
883
- ...toPublicRoomResponse(joined.response, joined.room.room_id),
945
+ return buildJoinResponse({
946
+ joined,
947
+ room_identifier: normalizeInviteCode(code),
884
948
  joined_via: "join_code",
949
+ session_mode: sessionMode,
885
950
  });
886
951
  }
887
- async function joinNamedRoom(name) {
952
+ async function joinNamedRoom(name, sessionMode) {
888
953
  const joined = await joinRoomIdentifier(name, "join_room");
889
- return withAgentIdentity({
890
- ...toPublicRoomResponse(joined.response, joined.room.room_id),
954
+ return buildJoinResponse({
955
+ joined,
956
+ room_identifier: name.trim(),
891
957
  joined_via: "join_room",
958
+ session_mode: sessionMode,
892
959
  });
893
960
  }
894
961
  // ---------------------------------------------------------------------------
@@ -919,8 +986,8 @@ server.resource("room_messages", new ResourceTemplate("letagents://rooms/{room_i
919
986
  const result = await roomScopedApiCall({
920
987
  room_id: normalizedRoomId,
921
988
  project_id: storedSession?.project_id ?? null,
922
- room_path: (targetRoomId) => `/rooms/${encodeRoomIdPath(targetRoomId)}/messages${qs ? `?${qs}` : ""}`,
923
- project_path: (projectId) => `/projects/${encodeURIComponent(projectId)}/messages${qs ? `?${qs}` : ""}`,
989
+ room_path: (targetRoomId) => appendIncludePromptOnly(`/rooms/${encodeRoomIdPath(targetRoomId)}/messages${qs ? `?${qs}` : ""}`),
990
+ project_path: (projectId) => appendIncludePromptOnly(`/projects/${encodeURIComponent(projectId)}/messages${qs ? `?${qs}` : ""}`),
924
991
  });
925
992
  const msgs = result.messages ?? [];
926
993
  allMessages.push(...msgs);
@@ -936,7 +1003,7 @@ server.resource("room_messages", new ResourceTemplate("letagents://rooms/{room_i
936
1003
  {
937
1004
  uri: uri.href,
938
1005
  mimeType: "application/json",
939
- text: JSON.stringify({ messages: allMessages }, null, 2),
1006
+ text: JSON.stringify({ messages: toAgentReadableMessages(allMessages) }, null, 2),
940
1007
  },
941
1008
  ],
942
1009
  };
@@ -948,7 +1015,7 @@ server.tool("create_room", "Create a new invite room on Let Agents Chat. Returns
948
1015
  content: [
949
1016
  {
950
1017
  type: "text",
951
- text: JSON.stringify(created.response, null, 2),
1018
+ text: JSON.stringify(withJoinRoomAgentPrompt(created.response), null, 2),
952
1019
  },
953
1020
  ],
954
1021
  };
@@ -960,7 +1027,7 @@ server.tool("create_project", "Legacy alias for create_room. Creates a new invit
960
1027
  content: [
961
1028
  {
962
1029
  type: "text",
963
- text: JSON.stringify(created.response, null, 2),
1030
+ text: JSON.stringify(withJoinRoomAgentPrompt(created.response), null, 2),
964
1031
  },
965
1032
  ],
966
1033
  };
@@ -968,12 +1035,16 @@ server.tool("create_project", "Legacy alias for create_room. Creates a new invit
968
1035
  // -- join_code --------------------------------------------------------------
969
1036
  server.tool("join_code", "Join an existing room using an invite code.", {
970
1037
  code: z.string().describe("The invite code shared for the room (e.g. 'ABCX-7291')"),
971
- }, async ({ code }) => {
1038
+ session_mode: z
1039
+ .enum(["live", "current"])
1040
+ .optional()
1041
+ .describe("Use 'current' (default) for a normal inline join. Use 'live' to start/reuse a detached local Codex room worker."),
1042
+ }, async ({ code, session_mode }) => {
972
1043
  return {
973
1044
  content: [
974
1045
  {
975
1046
  type: "text",
976
- text: JSON.stringify(await joinInviteCode(code), null, 2),
1047
+ text: JSON.stringify(await joinInviteCode(code, normalizeJoinSessionMode(session_mode)), null, 2),
977
1048
  },
978
1049
  ],
979
1050
  };
@@ -981,12 +1052,16 @@ server.tool("join_code", "Join an existing room using an invite code.", {
981
1052
  // -- join_project -----------------------------------------------------------
982
1053
  server.tool("join_project", "Legacy alias for join_code. Join an existing room using an invite code.", {
983
1054
  code: z.string().describe("The invite code shared for the room (e.g. 'ABCX-7291')"),
984
- }, async ({ code }) => {
1055
+ session_mode: z
1056
+ .enum(["live", "current"])
1057
+ .optional()
1058
+ .describe("Use 'current' (default) for a normal inline join. Use 'live' to start/reuse a detached local Codex room worker."),
1059
+ }, async ({ code, session_mode }) => {
985
1060
  return {
986
1061
  content: [
987
1062
  {
988
1063
  type: "text",
989
- text: JSON.stringify(await joinInviteCode(code), null, 2),
1064
+ text: JSON.stringify(await joinInviteCode(code, normalizeJoinSessionMode(session_mode)), null, 2),
990
1065
  },
991
1066
  ],
992
1067
  };
@@ -994,13 +1069,77 @@ server.tool("join_project", "Legacy alias for join_code. Join an existing room u
994
1069
  // -- join_room --------------------------------------------------------------
995
1070
  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.", {
996
1071
  name: z.string().describe("The room name to join (e.g. 'github.com/owner/repo')"),
997
- }, async ({ name }) => {
1072
+ session_mode: z
1073
+ .enum(["live", "current"])
1074
+ .optional()
1075
+ .describe("Use 'current' (default) for a normal inline join. Use 'live' to start/reuse a detached local Codex room worker."),
1076
+ }, async ({ name, session_mode }) => {
1077
+ try {
1078
+ return {
1079
+ content: [
1080
+ {
1081
+ type: "text",
1082
+ text: JSON.stringify(await joinNamedRoom(name, normalizeJoinSessionMode(session_mode)), null, 2),
1083
+ },
1084
+ ],
1085
+ };
1086
+ }
1087
+ catch (error) {
1088
+ if (error instanceof RepoRoomAuthRequiredError) {
1089
+ return {
1090
+ content: [
1091
+ {
1092
+ type: "text",
1093
+ text: JSON.stringify(toRepoRoomAuthRequiredResult(error), null, 2),
1094
+ },
1095
+ ],
1096
+ };
1097
+ }
1098
+ throw error;
1099
+ }
1100
+ });
1101
+ // -- local Codex live sessions ---------------------------------------------
1102
+ server.tool("start_local_codex_session", "Start or reuse a detached local Codex live session for a LetAgents room. The worker will join the room, keep polling, contribute in discussion, and do repo work from the current working directory when asked.", {
1103
+ room: z
1104
+ .string()
1105
+ .describe("Invite code or room name to run as a detached local Codex worker."),
1106
+ cwd: z
1107
+ .string()
1108
+ .optional()
1109
+ .describe("Working directory for repo work. Defaults to the current process directory."),
1110
+ stop_phrase: z
1111
+ .string()
1112
+ .optional()
1113
+ .describe("Exact room message text that tells the worker to stop. Defaults to /stop-codex-room."),
1114
+ max_minutes: z
1115
+ .number()
1116
+ .optional()
1117
+ .describe("Optional hard stop in minutes. Defaults to 0, which means run until stopped."),
1118
+ }, async ({ room, cwd, stop_phrase, max_minutes }) => {
1119
+ const joinedVia = looksLikeInviteCode(room) ? "join_code" : "join_room";
998
1120
  try {
1121
+ const joined = await joinRoomIdentifier(room, joinedVia);
1122
+ const liveSession = await startLocalCodexSession({
1123
+ room_id: joined.room.room_id,
1124
+ room_identifier: joinedVia === "join_code" ? normalizeInviteCode(room) : room.trim(),
1125
+ room_code: joined.room.code ?? null,
1126
+ room_display_name: joined.room.display_name ?? null,
1127
+ joined_via: joinedVia,
1128
+ cwd: cwd || process.cwd(),
1129
+ stop_phrase,
1130
+ max_minutes,
1131
+ });
999
1132
  return {
1000
1133
  content: [
1001
1134
  {
1002
1135
  type: "text",
1003
- text: JSON.stringify(await joinNamedRoom(name), null, 2),
1136
+ text: JSON.stringify(await withAgentIdentity({
1137
+ success: true,
1138
+ room: toPublicRoomState(joined.room),
1139
+ local_codex_session: toPublicCodexLiveSession(liveSession.session),
1140
+ local_codex_session_started: !liveSession.reused,
1141
+ local_codex_session_reused: liveSession.reused,
1142
+ }), null, 2),
1004
1143
  },
1005
1144
  ],
1006
1145
  };
@@ -1019,6 +1158,76 @@ server.tool("join_room", "Join a named room on Let Agents Chat. Creates the room
1019
1158
  throw error;
1020
1159
  }
1021
1160
  });
1161
+ server.tool("status_local_codex_session", "Inspect the current detached local Codex live session, or a specific one by session_id.", {
1162
+ session_id: z
1163
+ .string()
1164
+ .optional()
1165
+ .describe("Optional session id. Defaults to the current local Codex live session."),
1166
+ }, async ({ session_id }) => {
1167
+ const status = await inspectLocalCodexSession(session_id, currentRoom?.room_id);
1168
+ if (!status) {
1169
+ return {
1170
+ content: [
1171
+ {
1172
+ type: "text",
1173
+ text: JSON.stringify({ success: false, error: "No local Codex live session found." }, null, 2),
1174
+ },
1175
+ ],
1176
+ };
1177
+ }
1178
+ return {
1179
+ content: [
1180
+ {
1181
+ type: "text",
1182
+ text: JSON.stringify({
1183
+ success: true,
1184
+ session: toPublicCodexLiveSession(status.session),
1185
+ server_reachable: status.server_reachable,
1186
+ thread_status: status.thread_status,
1187
+ turn_status: status.turn_status,
1188
+ recent_items: status.recent_items,
1189
+ }, null, 2),
1190
+ },
1191
+ ],
1192
+ };
1193
+ });
1194
+ server.tool("stop_local_codex_session", "Stop the current detached local Codex live session, or a specific one by session_id.", {
1195
+ session_id: z
1196
+ .string()
1197
+ .optional()
1198
+ .describe("Optional session id. Defaults to the current local Codex live session."),
1199
+ shutdown_server: z
1200
+ .boolean()
1201
+ .optional()
1202
+ .describe("If true, also terminate the spawned codex app-server process when possible."),
1203
+ }, async ({ session_id, shutdown_server }) => {
1204
+ const stopped = await stopLocalCodexSession({
1205
+ session_id,
1206
+ room_id: currentRoom?.room_id,
1207
+ shutdown_server,
1208
+ });
1209
+ if (!stopped) {
1210
+ return {
1211
+ content: [
1212
+ {
1213
+ type: "text",
1214
+ text: JSON.stringify({ success: false, error: "No local Codex live session found." }, null, 2),
1215
+ },
1216
+ ],
1217
+ };
1218
+ }
1219
+ return {
1220
+ content: [
1221
+ {
1222
+ type: "text",
1223
+ text: JSON.stringify({
1224
+ success: true,
1225
+ session: toPublicCodexLiveSession(stopped),
1226
+ }, null, 2),
1227
+ },
1228
+ ],
1229
+ };
1230
+ });
1022
1231
  // -- get_current_room -------------------------------------------------------
1023
1232
  server.tool("get_current_room", "Get information about the currently joined room, including how it was joined.", {
1024
1233
  conversation_id: z
@@ -1031,9 +1240,11 @@ server.tool("get_current_room", "Get information about the currently joined room
1031
1240
  {
1032
1241
  type: "text",
1033
1242
  text: JSON.stringify(currentRoom
1034
- ? {
1243
+ ? withJoinRoomAgentPrompt({
1035
1244
  connected: true,
1036
1245
  ...toPublicRoomState(currentRoom),
1246
+ current_local_codex_session: getCurrentLiveSessionPayload(currentRoom.room_id),
1247
+ local_codex_session_count: listStoredCodexLiveSessions().length,
1037
1248
  agent_identity: toPublicAgentIdentity(getConversationIdentity(conversation_id)
1038
1249
  ?? currentAgentIdentity
1039
1250
  ?? getStoredAgentIdentity(currentAgentIdentityKey)),
@@ -1044,8 +1255,13 @@ server.tool("get_current_room", "Get information about the currently joined room
1044
1255
  account: getStoredAuth()?.account ?? null,
1045
1256
  }
1046
1257
  : null,
1047
- }
1048
- : { connected: false, message: "Not currently in any room" }, null, 2),
1258
+ })
1259
+ : {
1260
+ connected: false,
1261
+ message: "Not currently in any room",
1262
+ current_local_codex_session: getCurrentLiveSessionPayload(), // no room context
1263
+ local_codex_session_count: listStoredCodexLiveSessions().length,
1264
+ }, null, 2),
1049
1265
  },
1050
1266
  ],
1051
1267
  };
@@ -1601,8 +1817,8 @@ server.tool("read_messages", "Read all messages from a Let Agents Chat room.", {
1601
1817
  const result = await roomScopedApiCall({
1602
1818
  room_id: targetRoomId,
1603
1819
  project_id: targetProjectId,
1604
- room_path: (targetRoomId) => `/rooms/${encodeRoomIdPath(targetRoomId)}/messages${qs ? `?${qs}` : ""}`,
1605
- project_path: (targetProjectId) => `/projects/${encodeURIComponent(targetProjectId)}/messages${qs ? `?${qs}` : ""}`,
1820
+ room_path: (targetRoomId) => appendIncludePromptOnly(`/rooms/${encodeRoomIdPath(targetRoomId)}/messages${qs ? `?${qs}` : ""}`),
1821
+ project_path: (targetProjectId) => appendIncludePromptOnly(`/projects/${encodeURIComponent(targetProjectId)}/messages${qs ? `?${qs}` : ""}`),
1606
1822
  });
1607
1823
  roomIdFromResponse = roomIdFromResponse || result.room_id || result.project_id;
1608
1824
  const msgs = result.messages ?? [];
@@ -1615,7 +1831,7 @@ server.tool("read_messages", "Read all messages from a Let Agents Chat room.", {
1615
1831
  break;
1616
1832
  afterCursor = lastMsg.id;
1617
1833
  }
1618
- const output = { messages: allMessages };
1834
+ const output = { messages: toAgentReadableMessages(allMessages) };
1619
1835
  if (roomIdFromResponse) {
1620
1836
  output[targetRoomId ? "room_id" : "project_id"] = roomIdFromResponse;
1621
1837
  }
@@ -1654,8 +1870,8 @@ server.tool("wait_for_messages", "Wait for new messages in a Let Agents Chat roo
1654
1870
  const firstResult = await roomScopedApiCall({
1655
1871
  room_id: targetRoomId,
1656
1872
  project_id: targetProjectId,
1657
- room_path: (targetRoomId) => `/rooms/${encodeRoomIdPath(targetRoomId)}/messages/poll?${queryString}`,
1658
- project_path: (targetProjectId) => `/projects/${encodeURIComponent(targetProjectId)}/messages/poll?${queryString}`,
1873
+ room_path: (targetRoomId) => appendIncludePromptOnly(`/rooms/${encodeRoomIdPath(targetRoomId)}/messages/poll?${queryString}`),
1874
+ project_path: (targetProjectId) => appendIncludePromptOnly(`/projects/${encodeURIComponent(targetProjectId)}/messages/poll?${queryString}`),
1659
1875
  options: { signal: AbortSignal.timeout(clientTimeout) },
1660
1876
  });
1661
1877
  const allMessages = [...(firstResult.messages ?? [])];
@@ -1670,8 +1886,8 @@ server.tool("wait_for_messages", "Wait for new messages in a Let Agents Chat roo
1670
1886
  const page = await roomScopedApiCall({
1671
1887
  room_id: targetRoomId,
1672
1888
  project_id: targetProjectId,
1673
- room_path: (targetRoomId) => `/rooms/${encodeRoomIdPath(targetRoomId)}/messages?${qs}`,
1674
- project_path: (targetProjectId) => `/projects/${encodeURIComponent(targetProjectId)}/messages?${qs}`,
1889
+ room_path: (targetRoomId) => appendIncludePromptOnly(`/rooms/${encodeRoomIdPath(targetRoomId)}/messages?${qs}`),
1890
+ project_path: (targetProjectId) => appendIncludePromptOnly(`/projects/${encodeURIComponent(targetProjectId)}/messages?${qs}`),
1675
1891
  });
1676
1892
  const msgs = page.messages ?? [];
1677
1893
  allMessages.push(...msgs);
@@ -1682,7 +1898,7 @@ server.tool("wait_for_messages", "Wait for new messages in a Let Agents Chat roo
1682
1898
  break;
1683
1899
  }
1684
1900
  }
1685
- const output = { messages: allMessages };
1901
+ const output = { messages: toAgentReadableMessages(allMessages) };
1686
1902
  if (roomIdFromResponse) {
1687
1903
  output[targetRoomId ? "room_id" : "project_id"] = roomIdFromResponse;
1688
1904
  }
@@ -2068,14 +2284,14 @@ server.tool("resume_room_session", "Rejoin the last locally saved room context,
2068
2284
  content: [
2069
2285
  {
2070
2286
  type: "text",
2071
- text: JSON.stringify({
2287
+ text: JSON.stringify(withJoinRoomAgentPrompt({
2072
2288
  success: true,
2073
2289
  rejoined_from_local_state: true,
2074
2290
  server_session_resumed: false,
2075
2291
  last_message_id_before_restart: savedRoom.last_message_id ?? null,
2076
2292
  room: toPublicRoomState(joined.room),
2077
2293
  agent_identity: toPublicAgentIdentity(agentIdentity),
2078
- }, null, 2),
2294
+ }), null, 2),
2079
2295
  },
2080
2296
  ],
2081
2297
  };
@@ -1,4 +1,5 @@
1
1
  import { encodeRoomIdPath } from "./room-id.js";
2
+ import { buildRoomAgentPrompt, normalizeAgentPromptKind } from "../shared/room-agent-prompts.js";
2
3
  export class SseClient {
3
4
  apiUrl;
4
5
  getAccessToken;
@@ -53,7 +54,7 @@ export class SseClient {
53
54
  }
54
55
  async consumeStream(target, signal, onMessage) {
55
56
  try {
56
- await this.openStream(`${this.apiUrl}/rooms/${encodeRoomIdPath(target.roomId)}/messages/stream`, signal, onMessage);
57
+ await this.openStream(this.withIncludePromptOnly(`${this.apiUrl}/rooms/${encodeRoomIdPath(target.roomId)}/messages/stream`), signal, onMessage);
57
58
  return;
58
59
  }
59
60
  catch (error) {
@@ -61,7 +62,10 @@ export class SseClient {
61
62
  throw error;
62
63
  }
63
64
  }
64
- await this.openStream(`${this.apiUrl}/projects/${encodeURIComponent(target.projectId)}/messages/stream`, signal, onMessage);
65
+ await this.openStream(this.withIncludePromptOnly(`${this.apiUrl}/projects/${encodeURIComponent(target.projectId)}/messages/stream`), signal, onMessage);
66
+ }
67
+ withIncludePromptOnly(url) {
68
+ return `${url}${url.includes("?") ? "&" : "?"}include_prompt_only=1`;
65
69
  }
66
70
  async openStream(url, signal, onMessage) {
67
71
  const response = await fetch(url, {
@@ -105,7 +109,19 @@ export class SseClient {
105
109
  if (dataLines.length === 0) {
106
110
  return;
107
111
  }
108
- onMessage(JSON.parse(dataLines.join("\n")));
112
+ onMessage(this.enrichPromptMetadata(JSON.parse(dataLines.join("\n"))));
113
+ }
114
+ enrichPromptMetadata(message) {
115
+ const kind = normalizeAgentPromptKind(message.agent_prompt_kind);
116
+ if (!kind) {
117
+ return message;
118
+ }
119
+ return {
120
+ ...message,
121
+ visible_text: message.text,
122
+ agent_prompt: buildRoomAgentPrompt(kind),
123
+ prompt_injected: kind === "inline",
124
+ };
109
125
  }
110
126
  isMissingRouteError(error) {
111
127
  return (error instanceof Error &&
@@ -0,0 +1,25 @@
1
+ // TODO: make room agent prompts configurable per room/project instead of hardcoded
2
+ // constants. The current behavior is intentionally global across rooms.
3
+ const BASE_ROOM_AGENT_PROMPT = "Stay in this room continuously. Keep long-polling for new messages. When messages arrive, read them immediately, act on anything actionable, help with review work when asked, and keep monitoring until the human explicitly redirects you. Do not send a visible 'keep polling' acknowledgement unless the human explicitly asks for that phrase.";
4
+ export function buildRoomAgentPrompt(kind) {
5
+ if (kind === "join") {
6
+ return `You just joined this room. ${BASE_ROOM_AGENT_PROMPT}`;
7
+ }
8
+ if (kind === "auto") {
9
+ return `Background reminder. ${BASE_ROOM_AGENT_PROMPT}`;
10
+ }
11
+ return `Treat the visible user message above as the active instruction. After you handle it, ${BASE_ROOM_AGENT_PROMPT}`;
12
+ }
13
+ export function normalizeAgentPromptKind(value) {
14
+ if (typeof value !== "string") {
15
+ return null;
16
+ }
17
+ const normalized = value.trim().toLowerCase();
18
+ if (normalized === "join" || normalized === "inline" || normalized === "auto") {
19
+ return normalized;
20
+ }
21
+ return null;
22
+ }
23
+ export function isPromptOnlyAgentMessage(text, kind) {
24
+ return normalizeAgentPromptKind(kind) === "auto" && !String(text || "").trim();
25
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "letagents",
3
- "version": "0.12.0",
3
+ "version": "0.12.1",
4
4
  "description": "Let Agents Chat — MCP server for AI agent communication",
5
5
  "type": "module",
6
6
  "main": "dist/mcp/server.js",
@@ -14,6 +14,8 @@
14
14
  ],
15
15
  "scripts": {
16
16
  "build": "tsc",
17
+ "build:web": "cd src/web && npm install && npx vite build",
18
+ "build:all": "npm run build && npm run build:web",
17
19
  "db:generate": "drizzle-kit generate",
18
20
  "db:migrate": "tsx src/api/migrate.ts",
19
21
  "db:studio": "drizzle-kit studio",
@@ -30,9 +32,9 @@
30
32
  ],
31
33
  "repository": {
32
34
  "type": "git",
33
- "url": "https://github.com/EmmyMay/letagents.git"
35
+ "url": "https://github.com/BrosInCode/letagents.git"
34
36
  },
35
- "license": "MIT",
37
+ "license": "BUSL-1.1",
36
38
  "dependencies": {
37
39
  "@modelcontextprotocol/sdk": "^1.12.1",
38
40
  "drizzle-orm": "^0.45.1",