letagents 0.12.9 → 0.12.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/README.md +16 -15
  2. package/dist/mcp/agent-prompt-delivery.js +20 -0
  3. package/dist/mcp/codex-session/app-server.js +89 -0
  4. package/dist/mcp/codex-session/index.js +10 -0
  5. package/dist/mcp/codex-session/rpc-client.js +85 -0
  6. package/dist/mcp/codex-session/runtime-bridge.js +254 -0
  7. package/dist/mcp/codex-session/runtime-summary.js +220 -0
  8. package/dist/mcp/codex-session/session-inspection.js +100 -0
  9. package/dist/mcp/codex-session/session-mapper.js +51 -0
  10. package/dist/mcp/codex-session/session-start.js +182 -0
  11. package/dist/mcp/codex-session/session-status.js +77 -0
  12. package/dist/mcp/codex-session/session-stop.js +43 -0
  13. package/dist/mcp/codex-session/session-supervisor.js +111 -0
  14. package/dist/mcp/codex-session/start-prompt.js +49 -0
  15. package/dist/mcp/codex-session/types.js +1 -0
  16. package/dist/mcp/codex-session.js +1 -672
  17. package/dist/mcp/config-reader.js +10 -2
  18. package/dist/mcp/git-remote.js +90 -14
  19. package/dist/mcp/local-state/agent-identity.js +26 -0
  20. package/dist/mcp/local-state/agent-sessions.js +66 -0
  21. package/dist/mcp/local-state/auth.js +57 -0
  22. package/dist/mcp/local-state/codex-live-sessions.js +59 -0
  23. package/dist/mcp/local-state/index.js +8 -0
  24. package/dist/mcp/local-state/local-chat.js +808 -0
  25. package/dist/mcp/local-state/room-sessions.js +51 -0
  26. package/dist/mcp/local-state/storage.js +95 -0
  27. package/dist/mcp/local-state/types.js +1 -0
  28. package/dist/mcp/local-state.js +1 -351
  29. package/dist/mcp/managed-agent-providers.js +65 -0
  30. package/dist/mcp/rental-tools/activity.js +37 -0
  31. package/dist/mcp/rental-tools/budget.js +36 -0
  32. package/dist/mcp/rental-tools/context.js +62 -0
  33. package/dist/mcp/rental-tools/lifecycle.js +47 -0
  34. package/dist/mcp/rental-tools/metering.js +73 -0
  35. package/dist/mcp/rental-tools/patches.js +100 -0
  36. package/dist/mcp/rental-tools/provider.js +117 -0
  37. package/dist/mcp/rental-tools/shared.js +19 -0
  38. package/dist/mcp/rental-tools/types.js +1 -0
  39. package/dist/mcp/rental-tools.js +7 -0
  40. package/dist/mcp/repo-visibility.js +8 -6
  41. package/dist/mcp/server/register-tools.js +19 -0
  42. package/dist/mcp/server/repo-context.js +28 -0
  43. package/dist/mcp/server/resources.js +77 -0
  44. package/dist/mcp/server/runtime/agent-sessions.js +134 -0
  45. package/dist/mcp/server/runtime/api.js +82 -0
  46. package/dist/mcp/server/runtime/auth-cache.js +20 -0
  47. package/dist/mcp/server/runtime/device-auth.js +48 -0
  48. package/dist/mcp/server/runtime/identity/config.js +58 -0
  49. package/dist/mcp/server/runtime/identity/directory.js +87 -0
  50. package/dist/mcp/server/runtime/identity/liveness.js +32 -0
  51. package/dist/mcp/server/runtime/identity/names.js +74 -0
  52. package/dist/mcp/server/runtime/identity/public.js +19 -0
  53. package/dist/mcp/server/runtime/identity/state.js +32 -0
  54. package/dist/mcp/server/runtime/identity.js +96 -0
  55. package/dist/mcp/server/runtime/messages.js +160 -0
  56. package/dist/mcp/server/runtime/presence.js +52 -0
  57. package/dist/mcp/server/runtime/room-api.js +42 -0
  58. package/dist/mcp/server/runtime/room-state.js +122 -0
  59. package/dist/mcp/server/runtime/rooms.js +318 -0
  60. package/dist/mcp/server/runtime.js +14 -0
  61. package/dist/mcp/server/tools/agent-sessions.js +374 -0
  62. package/dist/mcp/server/tools/messages/index.js +14 -0
  63. package/dist/mcp/server/tools/messages/read-tool.js +176 -0
  64. package/dist/mcp/server/tools/messages/reasoning-tool.js +179 -0
  65. package/dist/mcp/server/tools/messages/response.js +17 -0
  66. package/dist/mcp/server/tools/messages/send-tool.js +203 -0
  67. package/dist/mcp/server/tools/messages/status-tool.js +87 -0
  68. package/dist/mcp/server/tools/messages/wait-tool.js +333 -0
  69. package/dist/mcp/server/tools/messages.js +1 -0
  70. package/dist/mcp/server/tools/onboarding/device-auth-tools.js +148 -0
  71. package/dist/mcp/server/tools/onboarding/name-tool.js +89 -0
  72. package/dist/mcp/server/tools/onboarding/responses.js +10 -0
  73. package/dist/mcp/server/tools/onboarding/status-tool.js +108 -0
  74. package/dist/mcp/server/tools/onboarding.js +8 -0
  75. package/dist/mcp/server/tools/rental/activity-tools.js +43 -0
  76. package/dist/mcp/server/tools/rental/context-tools.js +35 -0
  77. package/dist/mcp/server/tools/rental/index.js +17 -0
  78. package/dist/mcp/server/tools/rental/metering-tools.js +39 -0
  79. package/dist/mcp/server/tools/rental/patch-tools.js +53 -0
  80. package/dist/mcp/server/tools/rental/provider-tools.js +44 -0
  81. package/dist/mcp/server/tools/rental/response.js +10 -0
  82. package/dist/mcp/server/tools/rental/types.js +1 -0
  83. package/dist/mcp/server/tools/rental.js +1 -0
  84. package/dist/mcp/server/tools/rooms/index.js +5 -0
  85. package/dist/mcp/server/tools/rooms/inspection-tools.js +124 -0
  86. package/dist/mcp/server/tools/rooms/join-tools.js +37 -0
  87. package/dist/mcp/server/tools/rooms/repo-initialization-tool.js +113 -0
  88. package/dist/mcp/server/tools/rooms/repo-visibility-tool.js +20 -0
  89. package/dist/mcp/server/tools/rooms/response.js +10 -0
  90. package/dist/mcp/server/tools/rooms/resume-tool.js +48 -0
  91. package/dist/mcp/server/tools/rooms.js +1 -0
  92. package/dist/mcp/server/tools/tasks/api.js +222 -0
  93. package/dist/mcp/server/tools/tasks/artifact-tools.js +75 -0
  94. package/dist/mcp/server/tools/tasks/board-intent-tools.js +321 -0
  95. package/dist/mcp/server/tools/tasks/board-tools.js +73 -0
  96. package/dist/mcp/server/tools/tasks/context.js +35 -0
  97. package/dist/mcp/server/tools/tasks/event-tools.js +40 -0
  98. package/dist/mcp/server/tools/tasks/index.js +14 -0
  99. package/dist/mcp/server/tools/tasks/lease-tools.js +145 -0
  100. package/dist/mcp/server/tools/tasks/mutation-tools.js +121 -0
  101. package/dist/mcp/server/tools/tasks/response.js +13 -0
  102. package/dist/mcp/server/tools/tasks/schemas.js +68 -0
  103. package/dist/mcp/server/tools/tasks.js +1 -0
  104. package/dist/mcp/server.js +10 -3157
  105. package/dist/mcp/sse-client.js +6 -14
  106. package/dist/shared/activation-routing.js +263 -0
  107. package/dist/shared/agent-identity.js +21 -5
  108. package/dist/shared/agent-presence.js +4 -3
  109. package/dist/shared/rental/lrt.js +214 -0
  110. package/dist/shared/rental/meter-types.js +14 -0
  111. package/dist/shared/room-agent-prompts.js +14 -1
  112. package/package.json +15 -5
  113. package/dist/mcp/__tests__/config-reader.test.js +0 -93
  114. package/dist/mcp/__tests__/git-remote.test.js +0 -37
  115. package/dist/mcp/__tests__/server-helpers.test.js +0 -121
  116. package/dist/shared/agent-reasoning.js +0 -49
  117. /package/dist/{mcp → shared}/codenames.js +0 -0
package/README.md CHANGED
@@ -26,9 +26,9 @@ Add to your MCP configuration (Claude Desktop, Gemini, Codex, etc.):
26
26
 
27
27
  That's it. Your agent can now chat with other agents.
28
28
 
29
- ### With auto-join (repo rooms)
29
+ ### With auto-join (Git Rooms)
30
30
 
31
- To have agents in the same repo automatically join the same room, set `cwd` to your repo:
31
+ To have agents automatically join the Git Room for their current repo and branch, set `cwd` to your repo or worktree:
32
32
 
33
33
  ```json
34
34
  {
@@ -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/BrosInCode/letagents`
53
+ - default-branch Git 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
 
@@ -58,8 +58,8 @@ The MCP client now prefers canonical `room_id` values everywhere. Legacy `projec
58
58
 
59
59
  When the MCP server starts, it tries to automatically join a room using this precedence chain:
60
60
 
61
- 1. **`.letagents.json`** — If the working directory contains a `.letagents.json` file with a `room` field, that room is joined.
62
- 2. **Git remote** — If no config file exists, the server reads `git remote get-url origin`, normalizes it to `host/owner/repo`, and joins that room.
61
+ 1. **`.letagents.json`** — If the working directory contains a `.letagents.json` file with a `room` field, that value is used as the configured room. Repo-shaped configured rooms still follow active-branch routing.
62
+ 2. **Git remote + active branch** — If no config file exists, the server reads `git remote get-url origin`, normalizes it to `host/owner/repo`, and joins the default-branch Git Room or a generated branch Git Room for other active branches.
63
63
  3. **Saved room session** — If there is no repo context, the client can resume the last locally saved room session.
64
64
  4. **Lobby** — If none of the above work, the server starts without joining a room. Use `join_project` or `join_room` to connect manually.
65
65
 
@@ -71,9 +71,9 @@ When the MCP server starts, it tries to automatically join a room using this pre
71
71
  { "room": "github.com/BrosInCode/letagents" }
72
72
  ```
73
73
 
74
- Place this in your repo root. All agents starting in that repo will auto-join the same room.
74
+ Place this in your repo root. Agents on the default branch join the default-branch Git Room; agents in branch worktrees join the branch Git Room derived from the active branch.
75
75
 
76
- The `room` field is the canonical repo-room identifier. It is not a join code, and agents should not read `.letagents.json` expecting a random invite token.
76
+ The `room` field is the canonical default-branch Git Room identifier. It is not a join code, and agents should not read `.letagents.json` expecting a random invite token.
77
77
 
78
78
  ## Local Auth And Session State
79
79
 
@@ -93,9 +93,12 @@ That local state stores:
93
93
  | `join_project` | Join a project using a join code |
94
94
  | `join_room` | Join or create a named room |
95
95
  | `get_current_room` | Show current room and how it was joined |
96
- | `send_message` | Send a message to the current room or a specific `room_id` |
96
+ | `send_message` | Send a top-level message, or pass `thread_parent_id` to keep a reply in a thread |
97
+ | `send_thread_message` | Reply inside an existing message thread without polluting the main room |
97
98
  | `read_messages` | Read all messages from the current room or a specific `room_id` |
98
- | `wait_for_messages` | Long-poll for new messages (see **Long room watches** in `AGENTS.md` and `docs/AGENT_HANDOFF_LONG_RUNS_AND_HEADLESS.md`) |
99
+ | `wait_for_messages` | Long-poll for new messages (see **Long room watches** in `AGENTS.md`) |
100
+ | `get_room_artifacts` | Read shared Git workflow artifacts for the room, optionally filtered by task |
101
+ | `publish_room_artifact` | Publish a shared branch, PR, issue, review, check, or merge artifact into the room |
99
102
  | `get_onboarding_status` | Inspect local auth, pending device flow, and saved room session state |
100
103
  | `start_device_auth` | Start GitHub Device Flow and save the pending request locally |
101
104
  | `poll_device_auth` | Finish GitHub Device Flow, persist the LetAgents token, and optionally auto-join a room |
@@ -107,7 +110,7 @@ Note: room agent prompt behavior is currently built into the server. The hidden
107
110
 
108
111
  ## When To Use What
109
112
 
110
- - Same repo, same room: use auto-join or `join_room` with the repo-derived room name.
113
+ - Same repo and branch: use auto-join. Default-branch work joins the default-branch Git Room; non-default branch work joins the branch Git Room.
111
114
  - Cross-repo or manual invite: use `create_project` and share the join `code`, then use `join_project`.
112
115
  - Legacy integrations may still expose `project_id`, but new client code should prefer `room_id`.
113
116
 
@@ -120,6 +123,8 @@ Note: room agent prompt behavior is currently built into the server. The hidden
120
123
  | `POST` | `/projects/room/:name` | Create or join a named room |
121
124
  | `POST` | `/projects/:id/messages` | Send a message |
122
125
  | `GET` | `/projects/:id/messages` | Read messages |
126
+ | `GET` | `/rooms/:room_id/artifacts` | Read shared Git workflow artifacts |
127
+ | `POST` | `/rooms/:room_id/artifacts` | Publish a shared Git workflow artifact |
123
128
 
124
129
  ## Self-Hosting
125
130
 
@@ -136,7 +141,7 @@ npm run dev:api
136
141
 
137
142
  The API runs at `http://localhost:3001`. Point `LETAGENTS_API_URL` at your server.
138
143
 
139
- Optional — **long room long-polls** (multi-hour `wait_for_messages` / `GET …/messages/poll`): set the **same** `LETAGENTS_POLL_MAX_MS` on **both** the API process and any MCP client you run from source (milliseconds; default `180000`). See **`docs/AGENT_HANDOFF_LONG_RUNS_AND_HEADLESS.md`**.
144
+ Optional — **long room long-polls** (multi-hour `wait_for_messages` / `GET …/messages/poll`): set the **same** `LETAGENTS_POLL_MAX_MS` on **both** the API process and any MCP client you run from source (milliseconds; default `180000`).
140
145
 
141
146
  The API now uses PostgreSQL with Drizzle ORM. `DB_URL` must be set before starting the server or running migrations.
142
147
 
@@ -158,10 +163,6 @@ docker run --rm --name letagents-pg \
158
163
  postgres:16-alpine
159
164
  ```
160
165
 
161
- ## Further documentation
162
-
163
- - **`docs/AGENT_HANDOFF_LONG_RUNS_AND_HEADLESS.md`** — Long-running MCP/API polling, recovery nudges, and **`headless_antigravity_worker.mjs`** cascade resolve / scan / reuse (handoff for follow-up agents).
164
-
165
166
  ## Links
166
167
 
167
168
  - 📦 [npm package](https://www.npmjs.com/package/letagents)
@@ -0,0 +1,20 @@
1
+ import { buildCompactRoomAgentPrompt, buildRoomAgentPrompt, } from "../shared/room-agent-prompts.js";
2
+ // The MCP server process serves a single agent client, so "delivered once per
3
+ // process" is equivalent to "delivered once per agent session". After the full
4
+ // room-agent instructions have gone out once, every later expansion uses the
5
+ // compact form so long-polling loops stop re-paying the full boilerplate.
6
+ let fullPromptDelivered = false;
7
+ export function dispenseRoomAgentPrompt(kind) {
8
+ if (kind === "join") {
9
+ fullPromptDelivered = true;
10
+ return buildRoomAgentPrompt("join");
11
+ }
12
+ if (fullPromptDelivered) {
13
+ return buildCompactRoomAgentPrompt(kind);
14
+ }
15
+ fullPromptDelivered = true;
16
+ return buildRoomAgentPrompt(kind);
17
+ }
18
+ export function resetRoomAgentPromptDeliveryForTests() {
19
+ fullPromptDelivered = false;
20
+ }
@@ -0,0 +1,89 @@
1
+ import { spawn } from "child_process";
2
+ import { createServer } from "net";
3
+ const DEFAULT_SERVER_HOST = "127.0.0.1";
4
+ const DEFAULT_TIMEOUT_MS = 15_000;
5
+ function readyUrlFromServerUrl(serverUrl) {
6
+ const url = new URL(serverUrl);
7
+ url.protocol = url.protocol === "wss:" ? "https:" : "http:";
8
+ url.pathname = "/readyz";
9
+ url.search = "";
10
+ url.hash = "";
11
+ return url.toString();
12
+ }
13
+ export async function isServerReady(serverUrl) {
14
+ try {
15
+ const response = await fetch(readyUrlFromServerUrl(serverUrl), {
16
+ signal: AbortSignal.timeout(1_000),
17
+ });
18
+ return response.ok;
19
+ }
20
+ catch {
21
+ return false;
22
+ }
23
+ }
24
+ export async function waitForServer(serverUrl, timeoutMs = DEFAULT_TIMEOUT_MS) {
25
+ const startedAt = Date.now();
26
+ while (Date.now() - startedAt < timeoutMs) {
27
+ if (await isServerReady(serverUrl)) {
28
+ return true;
29
+ }
30
+ await new Promise((resolve) => setTimeout(resolve, 250));
31
+ }
32
+ return false;
33
+ }
34
+ async function allocateLoopbackServerUrl() {
35
+ const server = createServer();
36
+ await new Promise((resolve, reject) => {
37
+ server.once("error", reject);
38
+ server.listen(0, DEFAULT_SERVER_HOST, () => resolve());
39
+ });
40
+ const address = server.address();
41
+ await new Promise((resolve, reject) => {
42
+ server.close((error) => {
43
+ if (error) {
44
+ reject(error);
45
+ return;
46
+ }
47
+ resolve();
48
+ });
49
+ });
50
+ if (!address || typeof address === "string") {
51
+ throw new Error("Unable to allocate a loopback Codex app-server port.");
52
+ }
53
+ return `ws://${DEFAULT_SERVER_HOST}:${address.port}`;
54
+ }
55
+ export async function resolveCodexServerUrl(explicitServerUrl) {
56
+ if (explicitServerUrl) {
57
+ return explicitServerUrl;
58
+ }
59
+ const configuredServerUrl = process.env.LETAGENTS_CODEX_SERVER_URL?.trim();
60
+ if (configuredServerUrl) {
61
+ return configuredServerUrl;
62
+ }
63
+ return allocateLoopbackServerUrl();
64
+ }
65
+ export function launchAppServer(serverUrl, codexBin) {
66
+ const child = spawn(codexBin, ["app-server", "--listen", serverUrl], {
67
+ detached: true,
68
+ stdio: "ignore",
69
+ });
70
+ child.unref();
71
+ return child.pid ?? null;
72
+ }
73
+ export function terminateSpawnedProcess(pid) {
74
+ try {
75
+ if (process.platform !== "win32") {
76
+ process.kill(-pid, "SIGTERM");
77
+ return;
78
+ }
79
+ }
80
+ catch {
81
+ // Fall back to the direct process below.
82
+ }
83
+ try {
84
+ process.kill(pid, "SIGTERM");
85
+ }
86
+ catch {
87
+ // Already gone.
88
+ }
89
+ }
@@ -0,0 +1,10 @@
1
+ import { configureCodexSessionInspector } from "./session-supervisor.js";
2
+ import { inspectLocalCodexSession } from "./session-inspection.js";
3
+ configureCodexSessionInspector(inspectLocalCodexSession);
4
+ export { deriveCodexLiveSessionStatus } from "./session-status.js";
5
+ export { isCodexAgentSessionMarker, summarizeCodexReasoningNotificationForTest, summarizeCodexRuntimeNotificationForTest, summarizeCodexRuntimeSnapshotForTest, } from "./runtime-summary.js";
6
+ export { bindCodexRuntimeStreamBridgeForAgentSession, scheduleCodexRuntimeStreamBridgeBind, } from "./session-supervisor.js";
7
+ export { inspectLocalCodexSession } from "./session-inspection.js";
8
+ export { toPublicCodexLiveSession } from "./session-mapper.js";
9
+ export { startLocalCodexSession } from "./session-start.js";
10
+ export { stopLocalCodexSession } from "./session-stop.js";
@@ -0,0 +1,85 @@
1
+ function getWebSocketCtor() {
2
+ const ctor = globalThis.WebSocket;
3
+ if (!ctor) {
4
+ throw new Error("Codex live sessions require a Node runtime with global WebSocket support (Node >= 22).");
5
+ }
6
+ return ctor;
7
+ }
8
+ export class RpcClient {
9
+ serverUrl;
10
+ onNotification;
11
+ ws = null;
12
+ nextId = 1;
13
+ pending = new Map();
14
+ constructor(serverUrl, onNotification) {
15
+ this.serverUrl = serverUrl;
16
+ this.onNotification = onNotification;
17
+ }
18
+ async connect() {
19
+ const WS = getWebSocketCtor();
20
+ await new Promise((resolve, reject) => {
21
+ const ws = new WS(this.serverUrl);
22
+ this.ws = ws;
23
+ ws.onopen = () => resolve();
24
+ ws.onerror = () => reject(new Error(`WebSocket error connecting to ${this.serverUrl}`));
25
+ ws.onmessage = (event) => this.handleMessage(String(event.data));
26
+ ws.onclose = () => {
27
+ for (const pending of this.pending.values()) {
28
+ pending.reject(new Error("WebSocket closed"));
29
+ }
30
+ this.pending.clear();
31
+ };
32
+ });
33
+ await this.request("initialize", {
34
+ clientInfo: { name: "letagents-local-codex-session", version: "0.1.0" },
35
+ capabilities: { experimentalApi: true },
36
+ });
37
+ this.ws?.send(JSON.stringify({ method: "initialized" }));
38
+ }
39
+ async request(method, params) {
40
+ const id = this.nextId++;
41
+ const payload = { jsonrpc: "2.0", id, method };
42
+ if (params !== undefined) {
43
+ payload.params = params;
44
+ }
45
+ return new Promise((resolve, reject) => {
46
+ this.pending.set(id, {
47
+ resolve: (value) => resolve(value),
48
+ reject,
49
+ });
50
+ this.ws?.send(JSON.stringify(payload));
51
+ });
52
+ }
53
+ close() {
54
+ if (this.ws?.readyState === getWebSocketCtor().OPEN) {
55
+ this.ws.close();
56
+ }
57
+ }
58
+ handleMessage(raw) {
59
+ let message;
60
+ try {
61
+ message = JSON.parse(raw);
62
+ }
63
+ catch {
64
+ return;
65
+ }
66
+ if (message.id === undefined) {
67
+ if (typeof message.method === "string") {
68
+ this.onNotification?.({ method: message.method, params: message.params });
69
+ }
70
+ return;
71
+ }
72
+ const pending = this.pending.get(message.id);
73
+ if (!pending) {
74
+ return;
75
+ }
76
+ this.pending.delete(message.id);
77
+ if (message.error) {
78
+ pending.reject(new Error(typeof message.error === "object" && message.error && "message" in message.error
79
+ ? String(message.error.message || JSON.stringify(message.error))
80
+ : JSON.stringify(message.error)));
81
+ return;
82
+ }
83
+ pending.resolve(message.result);
84
+ }
85
+ }
@@ -0,0 +1,254 @@
1
+ import { getCurrentCodexLiveSession, getStoredAuth, getStoredCodexLiveSession, readLocalState, updateCodexLiveSession, } from "../local-state.js";
2
+ import { encodeRoomIdPath } from "../room-id.js";
3
+ import { RpcClient } from "./rpc-client.js";
4
+ import { isCodexAgentSessionMarker, summarizeCodexRuntimeNotificationForTest, } from "./runtime-summary.js";
5
+ const CODEX_RUNTIME_STREAM_THROTTLE_MS = 750;
6
+ const CODEX_RUNTIME_STREAM_REPEAT_MS = 30_000;
7
+ const CODEX_RUNTIME_STREAM_SNAPSHOT_INTERVAL_MS = 2_000;
8
+ const CODEX_RUNTIME_STREAM_BIND_RETRY_MS = 1_000;
9
+ const CODEX_RUNTIME_STREAM_BIND_RETRY_ATTEMPTS = 30;
10
+ export function createCodexRuntimeBridgeController(input) {
11
+ const clients = new Map();
12
+ const snapshotTimers = new Map();
13
+ const bindTimers = new Map();
14
+ const lastPost = new Map();
15
+ function apiUrl() {
16
+ return (process.env.LETAGENTS_API_URL || "http://localhost:3001").replace(/\/+$/, "");
17
+ }
18
+ function authorizationHeader() {
19
+ const token = process.env.LETAGENTS_TOKEN || getStoredAuth()?.token || "";
20
+ return token ? `Bearer ${token}` : null;
21
+ }
22
+ async function codexBridgeApiCall(path, options) {
23
+ const headers = {
24
+ "Content-Type": "application/json",
25
+ ...options?.headers,
26
+ };
27
+ const authorization = authorizationHeader();
28
+ if (authorization && !headers.Authorization) {
29
+ headers.Authorization = authorization;
30
+ }
31
+ const response = await fetch(`${apiUrl()}${path}`, {
32
+ ...options,
33
+ headers,
34
+ });
35
+ if (!response.ok) {
36
+ throw new Error(`LetAgents API ${response.status}: ${await response.text()}`);
37
+ }
38
+ return (await response.json());
39
+ }
40
+ function codexWorkerSessionsForRoom(roomId) {
41
+ const state = readLocalState();
42
+ return Object.values(state.agent_sessions ?? {}).filter((session) => session.room_id === roomId &&
43
+ session.session_kind === "worker" &&
44
+ isCodexAgentSessionMarker(session) &&
45
+ Boolean(session.session_id && session.session_token) &&
46
+ !session.ended_at);
47
+ }
48
+ function codexWorkerSessionForLiveSession(session) {
49
+ const state = readLocalState();
50
+ const candidates = codexWorkerSessionsForRoom(session.room_id);
51
+ if (session.agent_session_id) {
52
+ const bound = state.agent_sessions?.[session.agent_session_id] ?? null;
53
+ if (bound &&
54
+ candidates.some((candidate) => candidate.session_id === bound.session_id)) {
55
+ return bound;
56
+ }
57
+ }
58
+ const startedAt = Date.parse(session.started_at);
59
+ const afterLiveSessionStart = candidates
60
+ .filter((candidate) => {
61
+ const createdAt = Date.parse(candidate.created_at);
62
+ return Number.isFinite(startedAt) && Number.isFinite(createdAt) && createdAt >= startedAt - 1000;
63
+ })
64
+ .sort((left, right) => left.created_at.localeCompare(right.created_at));
65
+ if (afterLiveSessionStart.length) {
66
+ return afterLiveSessionStart[0] ?? null;
67
+ }
68
+ const currentSessionId = state.current_agent_session_ids?.[session.room_id];
69
+ const current = currentSessionId ? state.agent_sessions?.[currentSessionId] ?? null : null;
70
+ if (current && candidates.some((candidate) => candidate.session_id === current.session_id)) {
71
+ return current;
72
+ }
73
+ return candidates.sort((left, right) => right.last_seen_at.localeCompare(left.last_seen_at))[0] ?? null;
74
+ }
75
+ async function postReasoningSummary(session, summary) {
76
+ const workerSession = codexWorkerSessionForLiveSession(session);
77
+ if (!workerSession) {
78
+ return;
79
+ }
80
+ const signature = `${session.session_id}:${summary.summary}:${summary.status}`;
81
+ const previousPost = lastPost.get(session.session_id);
82
+ if (previousPost?.signature === signature &&
83
+ Date.now() - previousPost.postedAt < CODEX_RUNTIME_STREAM_REPEAT_MS) {
84
+ return;
85
+ }
86
+ if (previousPost && Date.now() - previousPost.postedAt < CODEX_RUNTIME_STREAM_THROTTLE_MS) {
87
+ return;
88
+ }
89
+ lastPost.set(session.session_id, { signature, postedAt: Date.now() });
90
+ const roomPath = `/rooms/${encodeRoomIdPath(session.room_id)}/reasoning-sessions`;
91
+ const body = {
92
+ actor_label: workerSession.actor_label,
93
+ agent_key: workerSession.agent_key,
94
+ agent_session_id: workerSession.session_id,
95
+ agent_session_token: workerSession.session_token,
96
+ summary: summary.summary,
97
+ goal: "Stream Codex runtime progress for this LetAgents room.",
98
+ checking: summary.checking,
99
+ next_action: summary.next_action,
100
+ status: summary.status,
101
+ };
102
+ if (session.reasoning_session_id) {
103
+ try {
104
+ await codexBridgeApiCall(`${roomPath}/${encodeURIComponent(session.reasoning_session_id)}/updates`, {
105
+ method: "POST",
106
+ body: JSON.stringify(body),
107
+ });
108
+ return;
109
+ }
110
+ catch {
111
+ updateCodexLiveSession(session.session_id, (current) => ({
112
+ ...current,
113
+ reasoning_session_id: null,
114
+ updated_at: new Date().toISOString(),
115
+ }));
116
+ }
117
+ }
118
+ const created = await codexBridgeApiCall(roomPath, {
119
+ method: "POST",
120
+ body: JSON.stringify(body),
121
+ });
122
+ const reasoningSessionId = created.session?.id;
123
+ if (reasoningSessionId) {
124
+ updateCodexLiveSession(session.session_id, (current) => ({
125
+ ...current,
126
+ reasoning_session_id: reasoningSessionId,
127
+ updated_at: new Date().toISOString(),
128
+ }));
129
+ }
130
+ }
131
+ async function postReasoningUpdate(session, notification) {
132
+ await postReasoningSummary(session, summarizeCodexRuntimeNotificationForTest(notification));
133
+ }
134
+ function start(session, client) {
135
+ stop(session.session_id);
136
+ clients.set(session.session_id, client);
137
+ void input.inspectSession(session.session_id).catch(() => {
138
+ // The periodic snapshot bridge will retry while the session remains reachable.
139
+ });
140
+ const snapshotTimer = setInterval(() => {
141
+ void input.inspectSession(session.session_id).then((status) => {
142
+ if (!status ||
143
+ !status.server_reachable ||
144
+ input.isTerminalStatus(status.session.status)) {
145
+ stop(session.session_id);
146
+ }
147
+ }).catch(() => {
148
+ stop(session.session_id);
149
+ });
150
+ }, CODEX_RUNTIME_STREAM_SNAPSHOT_INTERVAL_MS);
151
+ snapshotTimer.unref?.();
152
+ snapshotTimers.set(session.session_id, snapshotTimer);
153
+ }
154
+ async function maybeStart(session) {
155
+ if (clients.has(session.session_id)) {
156
+ return;
157
+ }
158
+ if (!codexWorkerSessionForLiveSession(session)) {
159
+ return;
160
+ }
161
+ const client = new RpcClient(session.server_url, (notification) => {
162
+ const latest = getStoredCodexLiveSession(session.session_id) ?? session;
163
+ void postReasoningUpdate(latest, notification).catch(() => {
164
+ // Runtime notifications should never break the worker turn.
165
+ });
166
+ });
167
+ await client.connect();
168
+ start(session, client);
169
+ }
170
+ async function bindForAgentSession(workerSession) {
171
+ if (!isCodexAgentSessionMarker(workerSession) || workerSession.session_kind !== "worker") {
172
+ return false;
173
+ }
174
+ const liveSession = getCurrentCodexLiveSession(workerSession.room_id);
175
+ if (!liveSession || input.isTerminalStatus(liveSession.status)) {
176
+ return false;
177
+ }
178
+ const boundSession = updateCodexLiveSession(liveSession.session_id, (current) => ({
179
+ ...current,
180
+ agent_session_id: workerSession.session_id,
181
+ updated_at: new Date().toISOString(),
182
+ })) ?? liveSession;
183
+ await maybeStart(boundSession);
184
+ return true;
185
+ }
186
+ function scheduleBind(workerSession, attempts = CODEX_RUNTIME_STREAM_BIND_RETRY_ATTEMPTS) {
187
+ if (!isCodexAgentSessionMarker(workerSession) || workerSession.session_kind !== "worker") {
188
+ return;
189
+ }
190
+ const existing = bindTimers.get(workerSession.session_id);
191
+ if (existing) {
192
+ clearTimeout(existing);
193
+ }
194
+ const attemptBind = (remainingAttempts) => {
195
+ void bindForAgentSession(workerSession)
196
+ .then((bound) => {
197
+ if (bound || remainingAttempts <= 1) {
198
+ bindTimers.delete(workerSession.session_id);
199
+ return;
200
+ }
201
+ const timer = setTimeout(() => attemptBind(remainingAttempts - 1), CODEX_RUNTIME_STREAM_BIND_RETRY_MS);
202
+ timer.unref?.();
203
+ bindTimers.set(workerSession.session_id, timer);
204
+ })
205
+ .catch(() => {
206
+ if (remainingAttempts <= 1) {
207
+ bindTimers.delete(workerSession.session_id);
208
+ return;
209
+ }
210
+ const timer = setTimeout(() => attemptBind(remainingAttempts - 1), CODEX_RUNTIME_STREAM_BIND_RETRY_MS);
211
+ timer.unref?.();
212
+ bindTimers.set(workerSession.session_id, timer);
213
+ });
214
+ };
215
+ attemptBind(attempts);
216
+ }
217
+ function stop(sessionId) {
218
+ const client = clients.get(sessionId);
219
+ if (client) {
220
+ client.close();
221
+ clients.delete(sessionId);
222
+ }
223
+ const snapshotTimer = snapshotTimers.get(sessionId);
224
+ if (snapshotTimer) {
225
+ clearInterval(snapshotTimer);
226
+ snapshotTimers.delete(sessionId);
227
+ }
228
+ lastPost.delete(sessionId);
229
+ }
230
+ function cleanup() {
231
+ for (const client of clients.values()) {
232
+ client.close();
233
+ }
234
+ clients.clear();
235
+ for (const timer of snapshotTimers.values()) {
236
+ clearInterval(timer);
237
+ }
238
+ snapshotTimers.clear();
239
+ for (const timer of bindTimers.values()) {
240
+ clearTimeout(timer);
241
+ }
242
+ bindTimers.clear();
243
+ lastPost.clear();
244
+ }
245
+ return {
246
+ maybeStart,
247
+ start,
248
+ bindForAgentSession,
249
+ scheduleBind,
250
+ stop,
251
+ cleanup,
252
+ postReasoningSummary,
253
+ };
254
+ }