letagents 0.12.8 → 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.
- package/README.md +16 -15
- package/dist/mcp/agent-prompt-delivery.js +20 -0
- package/dist/mcp/codex-session/app-server.js +89 -0
- package/dist/mcp/codex-session/index.js +10 -0
- package/dist/mcp/codex-session/rpc-client.js +85 -0
- package/dist/mcp/codex-session/runtime-bridge.js +254 -0
- package/dist/mcp/codex-session/runtime-summary.js +220 -0
- package/dist/mcp/codex-session/session-inspection.js +100 -0
- package/dist/mcp/codex-session/session-mapper.js +51 -0
- package/dist/mcp/codex-session/session-start.js +182 -0
- package/dist/mcp/codex-session/session-status.js +77 -0
- package/dist/mcp/codex-session/session-stop.js +43 -0
- package/dist/mcp/codex-session/session-supervisor.js +111 -0
- package/dist/mcp/codex-session/start-prompt.js +49 -0
- package/dist/mcp/codex-session/types.js +1 -0
- package/dist/mcp/codex-session.js +1 -500
- package/dist/mcp/config-reader.js +10 -2
- package/dist/mcp/git-remote.js +90 -14
- package/dist/mcp/local-state/agent-identity.js +26 -0
- package/dist/mcp/local-state/agent-sessions.js +66 -0
- package/dist/mcp/local-state/auth.js +57 -0
- package/dist/mcp/local-state/codex-live-sessions.js +59 -0
- package/dist/mcp/local-state/index.js +8 -0
- package/dist/mcp/local-state/local-chat.js +808 -0
- package/dist/mcp/local-state/room-sessions.js +51 -0
- package/dist/mcp/local-state/storage.js +95 -0
- package/dist/mcp/local-state/types.js +1 -0
- package/dist/mcp/local-state.js +1 -286
- package/dist/mcp/managed-agent-providers.js +65 -0
- package/dist/mcp/rental-tools/activity.js +37 -0
- package/dist/mcp/rental-tools/budget.js +36 -0
- package/dist/mcp/rental-tools/context.js +62 -0
- package/dist/mcp/rental-tools/lifecycle.js +47 -0
- package/dist/mcp/rental-tools/metering.js +73 -0
- package/dist/mcp/rental-tools/patches.js +100 -0
- package/dist/mcp/rental-tools/provider.js +117 -0
- package/dist/mcp/rental-tools/shared.js +19 -0
- package/dist/mcp/rental-tools/types.js +1 -0
- package/dist/mcp/rental-tools.js +7 -0
- package/dist/mcp/repo-visibility.js +8 -6
- package/dist/mcp/server/register-tools.js +19 -0
- package/dist/mcp/server/repo-context.js +28 -0
- package/dist/mcp/server/resources.js +77 -0
- package/dist/mcp/server/runtime/agent-sessions.js +134 -0
- package/dist/mcp/server/runtime/api.js +82 -0
- package/dist/mcp/server/runtime/auth-cache.js +20 -0
- package/dist/mcp/server/runtime/device-auth.js +48 -0
- package/dist/mcp/server/runtime/identity/config.js +58 -0
- package/dist/mcp/server/runtime/identity/directory.js +87 -0
- package/dist/mcp/server/runtime/identity/liveness.js +32 -0
- package/dist/mcp/server/runtime/identity/names.js +74 -0
- package/dist/mcp/server/runtime/identity/public.js +19 -0
- package/dist/mcp/server/runtime/identity/state.js +32 -0
- package/dist/mcp/server/runtime/identity.js +96 -0
- package/dist/mcp/server/runtime/messages.js +160 -0
- package/dist/mcp/server/runtime/presence.js +52 -0
- package/dist/mcp/server/runtime/room-api.js +42 -0
- package/dist/mcp/server/runtime/room-state.js +122 -0
- package/dist/mcp/server/runtime/rooms.js +318 -0
- package/dist/mcp/server/runtime.js +14 -0
- package/dist/mcp/server/tools/agent-sessions.js +374 -0
- package/dist/mcp/server/tools/messages/index.js +14 -0
- package/dist/mcp/server/tools/messages/read-tool.js +176 -0
- package/dist/mcp/server/tools/messages/reasoning-tool.js +179 -0
- package/dist/mcp/server/tools/messages/response.js +17 -0
- package/dist/mcp/server/tools/messages/send-tool.js +203 -0
- package/dist/mcp/server/tools/messages/status-tool.js +87 -0
- package/dist/mcp/server/tools/messages/wait-tool.js +333 -0
- package/dist/mcp/server/tools/messages.js +1 -0
- package/dist/mcp/server/tools/onboarding/device-auth-tools.js +148 -0
- package/dist/mcp/server/tools/onboarding/name-tool.js +89 -0
- package/dist/mcp/server/tools/onboarding/responses.js +10 -0
- package/dist/mcp/server/tools/onboarding/status-tool.js +108 -0
- package/dist/mcp/server/tools/onboarding.js +8 -0
- package/dist/mcp/server/tools/rental/activity-tools.js +43 -0
- package/dist/mcp/server/tools/rental/context-tools.js +35 -0
- package/dist/mcp/server/tools/rental/index.js +17 -0
- package/dist/mcp/server/tools/rental/metering-tools.js +39 -0
- package/dist/mcp/server/tools/rental/patch-tools.js +53 -0
- package/dist/mcp/server/tools/rental/provider-tools.js +44 -0
- package/dist/mcp/server/tools/rental/response.js +10 -0
- package/dist/mcp/server/tools/rental/types.js +1 -0
- package/dist/mcp/server/tools/rental.js +1 -0
- package/dist/mcp/server/tools/rooms/index.js +5 -0
- package/dist/mcp/server/tools/rooms/inspection-tools.js +124 -0
- package/dist/mcp/server/tools/rooms/join-tools.js +37 -0
- package/dist/mcp/server/tools/rooms/repo-initialization-tool.js +113 -0
- package/dist/mcp/server/tools/rooms/repo-visibility-tool.js +20 -0
- package/dist/mcp/server/tools/rooms/response.js +10 -0
- package/dist/mcp/server/tools/rooms/resume-tool.js +48 -0
- package/dist/mcp/server/tools/rooms.js +1 -0
- package/dist/mcp/server/tools/tasks/api.js +222 -0
- package/dist/mcp/server/tools/tasks/artifact-tools.js +75 -0
- package/dist/mcp/server/tools/tasks/board-intent-tools.js +321 -0
- package/dist/mcp/server/tools/tasks/board-tools.js +73 -0
- package/dist/mcp/server/tools/tasks/context.js +35 -0
- package/dist/mcp/server/tools/tasks/event-tools.js +40 -0
- package/dist/mcp/server/tools/tasks/index.js +14 -0
- package/dist/mcp/server/tools/tasks/lease-tools.js +145 -0
- package/dist/mcp/server/tools/tasks/mutation-tools.js +121 -0
- package/dist/mcp/server/tools/tasks/response.js +13 -0
- package/dist/mcp/server/tools/tasks/schemas.js +68 -0
- package/dist/mcp/server/tools/tasks.js +1 -0
- package/dist/mcp/server.js +10 -2540
- package/dist/mcp/sse-client.js +22 -16
- package/dist/shared/activation-routing.js +263 -0
- package/dist/shared/agent-identity.js +21 -5
- package/dist/shared/agent-presence.js +24 -0
- package/dist/shared/handoff.js +145 -0
- package/dist/shared/rental/lrt.js +214 -0
- package/dist/shared/rental/meter-types.js +14 -0
- package/dist/shared/request-headers.js +3 -0
- package/dist/shared/room-agent-activity.js +41 -0
- package/dist/shared/room-agent-prompts.js +14 -1
- package/package.json +15 -5
- /package/dist/{mcp → shared}/codenames.js +0 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { getStoredCodexLiveSession, } from "../local-state.js";
|
|
2
|
+
import { terminateSpawnedProcess } from "./app-server.js";
|
|
3
|
+
import { createCodexRuntimeBridgeController } from "./runtime-bridge.js";
|
|
4
|
+
import { isTerminalCodexSessionStatus } from "./session-status.js";
|
|
5
|
+
const SESSION_MONITOR_INTERVAL_MS = 30_000;
|
|
6
|
+
const spawnedServerPids = new Set();
|
|
7
|
+
const sessionMonitorTimers = new Map();
|
|
8
|
+
let inspectSession = null;
|
|
9
|
+
let cleanupRegistered = false;
|
|
10
|
+
function inspectConfiguredSession(sessionId) {
|
|
11
|
+
if (!inspectSession) {
|
|
12
|
+
throw new Error("Codex session inspector has not been configured.");
|
|
13
|
+
}
|
|
14
|
+
return inspectSession(sessionId);
|
|
15
|
+
}
|
|
16
|
+
const runtimeBridge = createCodexRuntimeBridgeController({
|
|
17
|
+
inspectSession: inspectConfiguredSession,
|
|
18
|
+
isTerminalStatus: isTerminalCodexSessionStatus,
|
|
19
|
+
});
|
|
20
|
+
export function configureCodexSessionInspector(inspector) {
|
|
21
|
+
inspectSession = inspector;
|
|
22
|
+
}
|
|
23
|
+
function registerProcessCleanup() {
|
|
24
|
+
if (cleanupRegistered)
|
|
25
|
+
return;
|
|
26
|
+
cleanupRegistered = true;
|
|
27
|
+
const cleanup = () => {
|
|
28
|
+
for (const timer of sessionMonitorTimers.values()) {
|
|
29
|
+
clearInterval(timer);
|
|
30
|
+
}
|
|
31
|
+
sessionMonitorTimers.clear();
|
|
32
|
+
runtimeBridge.cleanup();
|
|
33
|
+
for (const pid of spawnedServerPids) {
|
|
34
|
+
terminateSpawnedProcess(pid);
|
|
35
|
+
}
|
|
36
|
+
spawnedServerPids.clear();
|
|
37
|
+
};
|
|
38
|
+
process.on("exit", cleanup);
|
|
39
|
+
process.on("SIGINT", () => { cleanup(); process.exit(130); });
|
|
40
|
+
process.on("SIGTERM", () => { cleanup(); process.exit(143); });
|
|
41
|
+
}
|
|
42
|
+
export function registerLaunchedAppServer(pid) {
|
|
43
|
+
spawnedServerPids.add(pid);
|
|
44
|
+
registerProcessCleanup();
|
|
45
|
+
}
|
|
46
|
+
export function forgetLaunchedAppServer(pid) {
|
|
47
|
+
spawnedServerPids.delete(pid);
|
|
48
|
+
}
|
|
49
|
+
export function clearSessionMonitor(sessionId) {
|
|
50
|
+
const timer = sessionMonitorTimers.get(sessionId);
|
|
51
|
+
if (!timer) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
clearInterval(timer);
|
|
55
|
+
sessionMonitorTimers.delete(sessionId);
|
|
56
|
+
}
|
|
57
|
+
export function killOwnedAppServer(session) {
|
|
58
|
+
if (!session.launched_server || !session.server_pid) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
terminateSpawnedProcess(session.server_pid);
|
|
62
|
+
spawnedServerPids.delete(session.server_pid);
|
|
63
|
+
}
|
|
64
|
+
export function scheduleOwnedSessionMonitor(session) {
|
|
65
|
+
if (!session.launched_server || sessionMonitorTimers.has(session.session_id)) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const timer = setInterval(() => {
|
|
69
|
+
void inspectConfiguredSession(session.session_id)
|
|
70
|
+
.then((status) => {
|
|
71
|
+
if (!status ||
|
|
72
|
+
!status.server_reachable ||
|
|
73
|
+
isTerminalCodexSessionStatus(status.session.status)) {
|
|
74
|
+
runtimeBridge.stop(session.session_id);
|
|
75
|
+
clearSessionMonitor(session.session_id);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
void runtimeBridge.maybeStart(status.session).catch(() => {
|
|
79
|
+
// The monitor should keep supervising even when the optional stream bridge is unavailable.
|
|
80
|
+
});
|
|
81
|
+
})
|
|
82
|
+
.catch(() => {
|
|
83
|
+
const latest = getStoredCodexLiveSession(session.session_id);
|
|
84
|
+
if (latest?.launched_server) {
|
|
85
|
+
killOwnedAppServer(latest);
|
|
86
|
+
}
|
|
87
|
+
runtimeBridge.stop(session.session_id);
|
|
88
|
+
clearSessionMonitor(session.session_id);
|
|
89
|
+
});
|
|
90
|
+
}, SESSION_MONITOR_INTERVAL_MS);
|
|
91
|
+
timer.unref?.();
|
|
92
|
+
sessionMonitorTimers.set(session.session_id, timer);
|
|
93
|
+
}
|
|
94
|
+
export async function bindCodexRuntimeStreamBridgeForAgentSession(workerSession) {
|
|
95
|
+
return runtimeBridge.bindForAgentSession(workerSession);
|
|
96
|
+
}
|
|
97
|
+
export function scheduleCodexRuntimeStreamBridgeBind(workerSession, attempts) {
|
|
98
|
+
runtimeBridge.scheduleBind(workerSession, attempts);
|
|
99
|
+
}
|
|
100
|
+
export async function maybeStartCodexRuntimeBridge(session) {
|
|
101
|
+
await runtimeBridge.maybeStart(session);
|
|
102
|
+
}
|
|
103
|
+
export function startCodexRuntimeBridge(session, client) {
|
|
104
|
+
runtimeBridge.start(session, client);
|
|
105
|
+
}
|
|
106
|
+
export async function postCodexRuntimeReasoningSummary(session, summary) {
|
|
107
|
+
if (!summary) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
await runtimeBridge.postReasoningSummary(session, summary);
|
|
111
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { randomUUID } from "crypto";
|
|
2
|
+
export const DEFAULT_STOP_PHRASE = "/stop-codex-room";
|
|
3
|
+
export function makeToken() {
|
|
4
|
+
return `LOCAL_CODEX_ROOM_${randomUUID()}`;
|
|
5
|
+
}
|
|
6
|
+
export function formatDeadline(minutes) {
|
|
7
|
+
if (minutes <= 0) {
|
|
8
|
+
return { utc: null };
|
|
9
|
+
}
|
|
10
|
+
const deadline = new Date(Date.now() + minutes * 60 * 1000);
|
|
11
|
+
return {
|
|
12
|
+
utc: deadline.toISOString().replace("T", " ").replace(".000Z", " UTC"),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
function buildJoinInstruction(joinedVia, roomIdentifier) {
|
|
16
|
+
if (joinedVia === "join_code") {
|
|
17
|
+
return `Call the LetAgents MCP tool join_code with {"code":"${roomIdentifier}","session_mode":"current"}.`;
|
|
18
|
+
}
|
|
19
|
+
return `Call the LetAgents MCP tool join_room with {"name":"${roomIdentifier}","session_mode":"current"}.`;
|
|
20
|
+
}
|
|
21
|
+
export function buildStartPrompt(input) {
|
|
22
|
+
const deadlineInstruction = input.max_minutes > 0 && input.deadline_utc
|
|
23
|
+
? `Hard stop deadline: ${input.deadline_utc}. Stop when the stop phrase appears or when that deadline is reached, whichever comes first.`
|
|
24
|
+
: "There is no hard deadline. Stop only when the stop phrase appears or when you are interrupted.";
|
|
25
|
+
return [
|
|
26
|
+
"Run as a persistent local Codex worker for a LetAgents room.",
|
|
27
|
+
`Primary working directory: ${input.cwd}. Use this repository/worktree when the room asks for implementation or repo work.`,
|
|
28
|
+
input.repo_branch ? `Active git branch at startup: ${input.repo_branch}.` : null,
|
|
29
|
+
deadlineInstruction,
|
|
30
|
+
"",
|
|
31
|
+
"Instructions:",
|
|
32
|
+
`1. ${buildJoinInstruction(input.joined_via, input.room_identifier)}`,
|
|
33
|
+
"2. Call register_agent_session with session_kind=\"worker\" and runtime=\"codex\". Keep the returned agent_session_id.",
|
|
34
|
+
"3. Pass that agent_session_id to wait_for_messages, send_message, send_thread_message, post_status, and task tools whenever the tool accepts it.",
|
|
35
|
+
"4. Do not start another live session. Join the room inline in this worker only.",
|
|
36
|
+
"5. Read the room and task board before contributing so you have current context.",
|
|
37
|
+
"6. Keep polling with wait_for_messages using a 30000 ms timeout. Track the latest returned message id, or last_observed_message_id when present even if messages is empty.",
|
|
38
|
+
"7. When new messages arrive, contribute when useful. Be concise, thoughtful, and non-repetitive.",
|
|
39
|
+
"8. 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.",
|
|
40
|
+
"9. Post short status updates to the room when you start meaningful work, when you are blocked, and when you finish meaningful work.",
|
|
41
|
+
`10. Stop immediately if a browser/user room message text exactly equals: ${input.stop_phrase}`,
|
|
42
|
+
`11. When stopping, reply in this thread with exactly: ${input.token}_DONE`,
|
|
43
|
+
"",
|
|
44
|
+
"Constraints:",
|
|
45
|
+
"- Do not narrate hidden chain-of-thought.",
|
|
46
|
+
"- Do not spam the room with keepalive messages.",
|
|
47
|
+
"- Stay in the room continuously until stopped.",
|
|
48
|
+
].filter(Boolean).join("\n");
|
|
49
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -1,500 +1 @@
|
|
|
1
|
-
|
|
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
|
-
}
|
|
1
|
+
export * from "./codex-session/index.js";
|
|
@@ -7,10 +7,18 @@
|
|
|
7
7
|
// Parses the config and returns the room name if found.
|
|
8
8
|
//
|
|
9
9
|
// Config format:
|
|
10
|
-
// { "room": "github.com/
|
|
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";
|
|
14
|
+
function normalizeConfiguredRoom(room) {
|
|
15
|
+
const trimmed = room.trim();
|
|
16
|
+
const githubRepoRoom = /^github\.com\/([^/:\s]+)\/([^/:\s]+)$/i.exec(trimmed);
|
|
17
|
+
if (!githubRepoRoom) {
|
|
18
|
+
return trimmed;
|
|
19
|
+
}
|
|
20
|
+
return `github.com/${githubRepoRoom[1].toLowerCase()}/${githubRepoRoom[2].toLowerCase()}`;
|
|
21
|
+
}
|
|
14
22
|
/**
|
|
15
23
|
* Search for `.letagents.json` starting from `startDir` and walking up
|
|
16
24
|
* to the filesystem root. Returns the parsed config or null if not found.
|
|
@@ -27,7 +35,7 @@ export function findLetagentsConfig(startDir) {
|
|
|
27
35
|
const parsed = JSON.parse(raw);
|
|
28
36
|
// Validate required fields
|
|
29
37
|
if (typeof parsed.room === "string" && parsed.room.trim() !== "") {
|
|
30
|
-
return { room: parsed.room
|
|
38
|
+
return { room: normalizeConfiguredRoom(parsed.room) };
|
|
31
39
|
}
|
|
32
40
|
// Config exists but is malformed — log and continue
|
|
33
41
|
console.error(`[letagents] Found ${configPath} but missing valid "room" field`);
|