letagents 0.12.8 → 0.12.9
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/dist/mcp/__tests__/config-reader.test.js +93 -0
- package/dist/mcp/__tests__/git-remote.test.js +37 -0
- package/dist/mcp/__tests__/server-helpers.test.js +121 -0
- package/dist/mcp/codex-session.js +216 -44
- package/dist/mcp/local-state.js +65 -0
- package/dist/mcp/server.js +667 -50
- package/dist/mcp/sse-client.js +16 -2
- package/dist/shared/agent-presence.js +23 -0
- package/dist/shared/agent-reasoning.js +49 -0
- package/dist/shared/handoff.js +145 -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 +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { findLetagentsConfig, getRoomFromConfig } from "../config-reader";
|
|
2
|
+
import { mkdirSync, writeFileSync, rmSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { tmpdir } from "os";
|
|
5
|
+
// Helper to create temp directories with config files
|
|
6
|
+
function createTempDir() {
|
|
7
|
+
const dir = join(tmpdir(), `letagents-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
8
|
+
mkdirSync(dir, { recursive: true });
|
|
9
|
+
return dir;
|
|
10
|
+
}
|
|
11
|
+
function cleanup(dir) {
|
|
12
|
+
try {
|
|
13
|
+
rmSync(dir, { recursive: true, force: true });
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
// ignore cleanup errors
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
describe("findLetagentsConfig", () => {
|
|
20
|
+
let tempDir;
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
if (tempDir)
|
|
23
|
+
cleanup(tempDir);
|
|
24
|
+
});
|
|
25
|
+
it("returns config when .letagents.json is in the start directory", () => {
|
|
26
|
+
tempDir = createTempDir();
|
|
27
|
+
writeFileSync(join(tempDir, ".letagents.json"), JSON.stringify({ room: "github.com/EmmyMay/letagents" }));
|
|
28
|
+
const config = findLetagentsConfig(tempDir);
|
|
29
|
+
expect(config).toEqual({ room: "github.com/EmmyMay/letagents" });
|
|
30
|
+
});
|
|
31
|
+
it("walks up to find config in parent directory", () => {
|
|
32
|
+
tempDir = createTempDir();
|
|
33
|
+
const childDir = join(tempDir, "src", "mcp");
|
|
34
|
+
mkdirSync(childDir, { recursive: true });
|
|
35
|
+
writeFileSync(join(tempDir, ".letagents.json"), JSON.stringify({ room: "gitlab.com/team/project" }));
|
|
36
|
+
const config = findLetagentsConfig(childDir);
|
|
37
|
+
expect(config).toEqual({ room: "gitlab.com/team/project" });
|
|
38
|
+
});
|
|
39
|
+
it("returns null when no config file exists", () => {
|
|
40
|
+
tempDir = createTempDir();
|
|
41
|
+
const config = findLetagentsConfig(tempDir);
|
|
42
|
+
expect(config).toBeNull();
|
|
43
|
+
});
|
|
44
|
+
it("returns null for config with missing room field", () => {
|
|
45
|
+
tempDir = createTempDir();
|
|
46
|
+
writeFileSync(join(tempDir, ".letagents.json"), JSON.stringify({ version: "1.0" }));
|
|
47
|
+
// Suppress console.error for this test
|
|
48
|
+
const spy = jest.spyOn(console, "error").mockImplementation();
|
|
49
|
+
const config = findLetagentsConfig(tempDir);
|
|
50
|
+
expect(config).toBeNull();
|
|
51
|
+
spy.mockRestore();
|
|
52
|
+
});
|
|
53
|
+
it("returns null for config with empty room field", () => {
|
|
54
|
+
tempDir = createTempDir();
|
|
55
|
+
writeFileSync(join(tempDir, ".letagents.json"), JSON.stringify({ room: "" }));
|
|
56
|
+
const spy = jest.spyOn(console, "error").mockImplementation();
|
|
57
|
+
const config = findLetagentsConfig(tempDir);
|
|
58
|
+
expect(config).toBeNull();
|
|
59
|
+
spy.mockRestore();
|
|
60
|
+
});
|
|
61
|
+
it("returns null for invalid JSON", () => {
|
|
62
|
+
tempDir = createTempDir();
|
|
63
|
+
writeFileSync(join(tempDir, ".letagents.json"), "not valid json {{{");
|
|
64
|
+
const spy = jest.spyOn(console, "error").mockImplementation();
|
|
65
|
+
const config = findLetagentsConfig(tempDir);
|
|
66
|
+
expect(config).toBeNull();
|
|
67
|
+
spy.mockRestore();
|
|
68
|
+
});
|
|
69
|
+
it("trims whitespace from room name", () => {
|
|
70
|
+
tempDir = createTempDir();
|
|
71
|
+
writeFileSync(join(tempDir, ".letagents.json"), JSON.stringify({ room: " github.com/EmmyMay/letagents " }));
|
|
72
|
+
const config = findLetagentsConfig(tempDir);
|
|
73
|
+
expect(config).toEqual({ room: "github.com/EmmyMay/letagents" });
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
describe("getRoomFromConfig", () => {
|
|
77
|
+
let tempDir;
|
|
78
|
+
afterEach(() => {
|
|
79
|
+
if (tempDir)
|
|
80
|
+
cleanup(tempDir);
|
|
81
|
+
});
|
|
82
|
+
it("returns room string when config exists", () => {
|
|
83
|
+
tempDir = createTempDir();
|
|
84
|
+
writeFileSync(join(tempDir, ".letagents.json"), JSON.stringify({ room: "github.com/EmmyMay/letagents" }));
|
|
85
|
+
const room = getRoomFromConfig(tempDir);
|
|
86
|
+
expect(room).toBe("github.com/EmmyMay/letagents");
|
|
87
|
+
});
|
|
88
|
+
it("returns null when no config exists", () => {
|
|
89
|
+
tempDir = createTempDir();
|
|
90
|
+
const room = getRoomFromConfig(tempDir);
|
|
91
|
+
expect(room).toBeNull();
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { normalizeGitRemote } from "../git-remote";
|
|
2
|
+
describe("normalizeGitRemote", () => {
|
|
3
|
+
// SSH format tests
|
|
4
|
+
it("normalizes SSH git@github.com format", () => {
|
|
5
|
+
expect(normalizeGitRemote("git@github.com:EmmyMay/letagents.git")).toBe("github.com/EmmyMay/letagents");
|
|
6
|
+
});
|
|
7
|
+
it("normalizes SSH without .git suffix", () => {
|
|
8
|
+
expect(normalizeGitRemote("git@github.com:EmmyMay/letagents")).toBe("github.com/EmmyMay/letagents");
|
|
9
|
+
});
|
|
10
|
+
it("normalizes SSH with gitlab host", () => {
|
|
11
|
+
expect(normalizeGitRemote("git@gitlab.com:team/project.git")).toBe("gitlab.com/team/project");
|
|
12
|
+
});
|
|
13
|
+
// HTTPS format tests
|
|
14
|
+
it("normalizes HTTPS with .git suffix", () => {
|
|
15
|
+
expect(normalizeGitRemote("https://github.com/EmmyMay/letagents.git")).toBe("github.com/EmmyMay/letagents");
|
|
16
|
+
});
|
|
17
|
+
it("normalizes HTTPS without .git suffix", () => {
|
|
18
|
+
expect(normalizeGitRemote("https://github.com/EmmyMay/letagents")).toBe("github.com/EmmyMay/letagents");
|
|
19
|
+
});
|
|
20
|
+
it("normalizes HTTPS with trailing slash", () => {
|
|
21
|
+
expect(normalizeGitRemote("https://github.com/EmmyMay/letagents/")).toBe("github.com/EmmyMay/letagents");
|
|
22
|
+
});
|
|
23
|
+
// SSH protocol format tests
|
|
24
|
+
it("normalizes ssh:// protocol format", () => {
|
|
25
|
+
expect(normalizeGitRemote("ssh://git@gitlab.com/team/project.git")).toBe("gitlab.com/team/project");
|
|
26
|
+
});
|
|
27
|
+
// Edge cases
|
|
28
|
+
it("handles whitespace", () => {
|
|
29
|
+
expect(normalizeGitRemote(" git@github.com:EmmyMay/letagents.git ")).toBe("github.com/EmmyMay/letagents");
|
|
30
|
+
});
|
|
31
|
+
it("handles nested paths", () => {
|
|
32
|
+
expect(normalizeGitRemote("https://gitlab.com/org/sub-group/project.git")).toBe("gitlab.com/org/sub-group/project");
|
|
33
|
+
});
|
|
34
|
+
it("handles Bitbucket SSH format", () => {
|
|
35
|
+
expect(normalizeGitRemote("git@bitbucket.org:workspace/repo.git")).toBe("bitbucket.org/workspace/repo");
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for server helper functions:
|
|
3
|
+
* - resolveGitRoot: resolves the root of a git repo from any subdirectory
|
|
4
|
+
* - findExistingConfig: walks parent dirs to find .letagents.json
|
|
5
|
+
*
|
|
6
|
+
* These helpers underpin the corrected initialize_repo tool behavior.
|
|
7
|
+
* @author Kingdavid Ehindero <kdof64squares@gmail.com>
|
|
8
|
+
*/
|
|
9
|
+
import { execSync } from "child_process";
|
|
10
|
+
import { existsSync, mkdirSync, writeFileSync, rmSync } from "fs";
|
|
11
|
+
import { join, resolve } from "path";
|
|
12
|
+
import { tmpdir } from "os";
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Re-implement helpers here (they are not exported from server.ts yet)
|
|
15
|
+
// We test the logic directly until we extract them to a shared module.
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
function resolveGitRoot(dir) {
|
|
18
|
+
try {
|
|
19
|
+
const root = execSync("git rev-parse --show-toplevel", {
|
|
20
|
+
cwd: dir,
|
|
21
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
22
|
+
encoding: "utf-8",
|
|
23
|
+
}).trim();
|
|
24
|
+
return root || null;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function findExistingConfig(startDir) {
|
|
31
|
+
const { dirname } = require("path");
|
|
32
|
+
let current = startDir;
|
|
33
|
+
while (true) {
|
|
34
|
+
if (existsSync(join(current, ".letagents.json")))
|
|
35
|
+
return current;
|
|
36
|
+
const parent = dirname(current);
|
|
37
|
+
if (parent === current)
|
|
38
|
+
break;
|
|
39
|
+
current = parent;
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Helpers for tests
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
function makeTempGitRepo() {
|
|
47
|
+
const dir = join(tmpdir(), `letagents-test-${Date.now()}`);
|
|
48
|
+
mkdirSync(dir, { recursive: true });
|
|
49
|
+
execSync("git init", { cwd: dir, stdio: "pipe" });
|
|
50
|
+
execSync("git commit --allow-empty -m init", { cwd: dir, stdio: "pipe" });
|
|
51
|
+
return dir;
|
|
52
|
+
}
|
|
53
|
+
function cleanup(dir) {
|
|
54
|
+
try {
|
|
55
|
+
rmSync(dir, { recursive: true, force: true });
|
|
56
|
+
}
|
|
57
|
+
catch { /* ignore */ }
|
|
58
|
+
}
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// resolveGitRoot tests
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
describe("resolveGitRoot", () => {
|
|
63
|
+
let repoDir;
|
|
64
|
+
beforeAll(() => { repoDir = makeTempGitRepo(); });
|
|
65
|
+
afterAll(() => cleanup(repoDir));
|
|
66
|
+
it("returns the repo root when called from repo root", () => {
|
|
67
|
+
const result = resolveGitRoot(repoDir);
|
|
68
|
+
expect(result).toBe(resolve(repoDir));
|
|
69
|
+
});
|
|
70
|
+
it("returns the repo root when called from a subdirectory", () => {
|
|
71
|
+
const subDir = join(repoDir, "src", "deep", "path");
|
|
72
|
+
mkdirSync(subDir, { recursive: true });
|
|
73
|
+
const result = resolveGitRoot(subDir);
|
|
74
|
+
expect(result).toBe(resolve(repoDir));
|
|
75
|
+
});
|
|
76
|
+
it("returns null when not inside a git repo", () => {
|
|
77
|
+
const nonRepoDir = join(tmpdir(), `no-git-${Date.now()}`);
|
|
78
|
+
mkdirSync(nonRepoDir, { recursive: true });
|
|
79
|
+
const result = resolveGitRoot(nonRepoDir);
|
|
80
|
+
cleanup(nonRepoDir);
|
|
81
|
+
expect(result).toBeNull();
|
|
82
|
+
});
|
|
83
|
+
it("returns null for a non-existent directory", () => {
|
|
84
|
+
const result = resolveGitRoot(join(tmpdir(), "does-not-exist-12345"));
|
|
85
|
+
expect(result).toBeNull();
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// findExistingConfig tests
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
describe("findExistingConfig", () => {
|
|
92
|
+
let tempDir;
|
|
93
|
+
beforeEach(() => {
|
|
94
|
+
tempDir = join(tmpdir(), `letagents-cfg-test-${Date.now()}`);
|
|
95
|
+
mkdirSync(tempDir, { recursive: true });
|
|
96
|
+
});
|
|
97
|
+
afterEach(() => cleanup(tempDir));
|
|
98
|
+
it("returns null when no .letagents.json exists anywhere", () => {
|
|
99
|
+
const subDir = join(tempDir, "a", "b", "c");
|
|
100
|
+
mkdirSync(subDir, { recursive: true });
|
|
101
|
+
expect(findExistingConfig(subDir)).toBeNull();
|
|
102
|
+
});
|
|
103
|
+
it("finds config in the start directory", () => {
|
|
104
|
+
writeFileSync(join(tempDir, ".letagents.json"), JSON.stringify({ room: "test" }));
|
|
105
|
+
expect(findExistingConfig(tempDir)).toBe(tempDir);
|
|
106
|
+
});
|
|
107
|
+
it("finds config in a parent directory when called from subdirectory", () => {
|
|
108
|
+
const subDir = join(tempDir, "nested", "path");
|
|
109
|
+
mkdirSync(subDir, { recursive: true });
|
|
110
|
+
writeFileSync(join(tempDir, ".letagents.json"), JSON.stringify({ room: "test" }));
|
|
111
|
+
expect(findExistingConfig(subDir)).toBe(tempDir);
|
|
112
|
+
});
|
|
113
|
+
it("returns the closest config when multiple exist in the tree", () => {
|
|
114
|
+
const subDir = join(tempDir, "nested");
|
|
115
|
+
mkdirSync(subDir, { recursive: true });
|
|
116
|
+
// Config at root and at nested level — should find nested first
|
|
117
|
+
writeFileSync(join(tempDir, ".letagents.json"), JSON.stringify({ room: "root" }));
|
|
118
|
+
writeFileSync(join(subDir, ".letagents.json"), JSON.stringify({ room: "nested" }));
|
|
119
|
+
expect(findExistingConfig(subDir)).toBe(subDir);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import { randomUUID } from "crypto";
|
|
2
2
|
import { spawn } from "child_process";
|
|
3
|
+
import { createServer } from "net";
|
|
3
4
|
import { resolve } from "path";
|
|
4
5
|
import { getCurrentCodexLiveSession, getStoredCodexLiveSession, saveCodexLiveSession, updateCodexLiveSession, } from "./local-state.js";
|
|
5
|
-
const
|
|
6
|
+
const DEFAULT_SERVER_HOST = "127.0.0.1";
|
|
6
7
|
const DEFAULT_STOP_PHRASE = "/stop-codex-room";
|
|
7
8
|
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
9
|
+
const DEFAULT_STARTUP_OBSERVATION_MS = 8_000;
|
|
10
|
+
const STARTUP_POLL_INTERVAL_MS = 500;
|
|
11
|
+
const SESSION_MONITOR_INTERVAL_MS = 30_000;
|
|
8
12
|
function getWebSocketCtor() {
|
|
9
13
|
const ctor = globalThis.WebSocket;
|
|
10
14
|
if (!ctor) {
|
|
@@ -14,19 +18,36 @@ function getWebSocketCtor() {
|
|
|
14
18
|
}
|
|
15
19
|
/** Track spawned server PIDs for cleanup on process exit. */
|
|
16
20
|
const spawnedServerPids = new Set();
|
|
21
|
+
const sessionMonitorTimers = new Map();
|
|
22
|
+
function terminateSpawnedProcess(pid) {
|
|
23
|
+
try {
|
|
24
|
+
if (process.platform !== "win32") {
|
|
25
|
+
process.kill(-pid, "SIGTERM");
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// Fall back to the direct process below.
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
process.kill(pid, "SIGTERM");
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
// Already gone.
|
|
37
|
+
}
|
|
38
|
+
}
|
|
17
39
|
let cleanupRegistered = false;
|
|
18
40
|
function registerProcessCleanup() {
|
|
19
41
|
if (cleanupRegistered)
|
|
20
42
|
return;
|
|
21
43
|
cleanupRegistered = true;
|
|
22
44
|
const cleanup = () => {
|
|
45
|
+
for (const timer of sessionMonitorTimers.values()) {
|
|
46
|
+
clearInterval(timer);
|
|
47
|
+
}
|
|
48
|
+
sessionMonitorTimers.clear();
|
|
23
49
|
for (const pid of spawnedServerPids) {
|
|
24
|
-
|
|
25
|
-
process.kill(pid, "SIGTERM");
|
|
26
|
-
}
|
|
27
|
-
catch {
|
|
28
|
-
// Already dead — ignore.
|
|
29
|
-
}
|
|
50
|
+
terminateSpawnedProcess(pid);
|
|
30
51
|
}
|
|
31
52
|
spawnedServerPids.clear();
|
|
32
53
|
};
|
|
@@ -136,6 +157,37 @@ async function waitForServer(serverUrl, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
|
136
157
|
}
|
|
137
158
|
return false;
|
|
138
159
|
}
|
|
160
|
+
async function allocateLoopbackServerUrl() {
|
|
161
|
+
const server = createServer();
|
|
162
|
+
await new Promise((resolve, reject) => {
|
|
163
|
+
server.once("error", reject);
|
|
164
|
+
server.listen(0, DEFAULT_SERVER_HOST, () => resolve());
|
|
165
|
+
});
|
|
166
|
+
const address = server.address();
|
|
167
|
+
await new Promise((resolve, reject) => {
|
|
168
|
+
server.close((error) => {
|
|
169
|
+
if (error) {
|
|
170
|
+
reject(error);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
resolve();
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
if (!address || typeof address === "string") {
|
|
177
|
+
throw new Error("Unable to allocate a loopback Codex app-server port.");
|
|
178
|
+
}
|
|
179
|
+
return `ws://${DEFAULT_SERVER_HOST}:${address.port}`;
|
|
180
|
+
}
|
|
181
|
+
async function resolveCodexServerUrl(explicitServerUrl) {
|
|
182
|
+
if (explicitServerUrl) {
|
|
183
|
+
return explicitServerUrl;
|
|
184
|
+
}
|
|
185
|
+
const configuredServerUrl = process.env.LETAGENTS_CODEX_SERVER_URL?.trim();
|
|
186
|
+
if (configuredServerUrl) {
|
|
187
|
+
return configuredServerUrl;
|
|
188
|
+
}
|
|
189
|
+
return allocateLoopbackServerUrl();
|
|
190
|
+
}
|
|
139
191
|
function launchAppServer(serverUrl, codexBin) {
|
|
140
192
|
const child = spawn(codexBin, ["app-server", "--listen", serverUrl], {
|
|
141
193
|
detached: true,
|
|
@@ -173,14 +225,16 @@ function buildStartPrompt(input) {
|
|
|
173
225
|
"",
|
|
174
226
|
"Instructions:",
|
|
175
227
|
`1. ${buildJoinInstruction(input.joined_via, input.room_identifier)}`,
|
|
176
|
-
"2.
|
|
177
|
-
"3.
|
|
178
|
-
"4.
|
|
179
|
-
"5.
|
|
180
|
-
"6.
|
|
181
|
-
"7.
|
|
182
|
-
|
|
183
|
-
|
|
228
|
+
"2. Call register_agent_session with session_kind=\"worker\" and runtime=\"codex\". Keep the returned agent_session_id.",
|
|
229
|
+
"3. Pass that agent_session_id to wait_for_messages, send_message, post_status, and task tools whenever the tool accepts it.",
|
|
230
|
+
"4. Do not start another live session. Join the room inline in this worker only.",
|
|
231
|
+
"5. Read the room and task board before contributing so you have current context.",
|
|
232
|
+
"6. Keep polling with wait_for_messages using a 30000 ms timeout and track the latest seen message id.",
|
|
233
|
+
"7. When new messages arrive, contribute when useful. Be concise, thoughtful, and non-repetitive.",
|
|
234
|
+
"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.",
|
|
235
|
+
"9. Post short status updates to the room when you start meaningful work, when you are blocked, and when you finish meaningful work.",
|
|
236
|
+
`10. Stop immediately if a browser/user room message text exactly equals: ${input.stop_phrase}`,
|
|
237
|
+
`11. When stopping, reply in this thread with exactly: ${input.token}_DONE`,
|
|
184
238
|
"",
|
|
185
239
|
"Constraints:",
|
|
186
240
|
"- Do not narrate hidden chain-of-thought.",
|
|
@@ -223,7 +277,10 @@ function summarizeItems(items) {
|
|
|
223
277
|
return { type: item.type ?? "unknown" };
|
|
224
278
|
});
|
|
225
279
|
}
|
|
226
|
-
function
|
|
280
|
+
export function deriveCodexLiveSessionStatus(session, serverReachable, threadStatus, turnStatus) {
|
|
281
|
+
if (threadStatus === "systemError" || threadStatus === "error" || turnStatus === "failed") {
|
|
282
|
+
return "failed";
|
|
283
|
+
}
|
|
227
284
|
if (turnStatus === "completed") {
|
|
228
285
|
return "completed";
|
|
229
286
|
}
|
|
@@ -243,6 +300,96 @@ function mapSessionStatus(session, serverReachable, threadStatus, turnStatus) {
|
|
|
243
300
|
}
|
|
244
301
|
return session.status;
|
|
245
302
|
}
|
|
303
|
+
function isTerminalCodexSessionStatus(status) {
|
|
304
|
+
return status === "completed" || status === "interrupted" || status === "failed";
|
|
305
|
+
}
|
|
306
|
+
function parseStartupObservationMs() {
|
|
307
|
+
const parsed = Number.parseInt(process.env.LETAGENTS_CODEX_STARTUP_OBSERVATION_MS ?? "", 10);
|
|
308
|
+
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
309
|
+
return DEFAULT_STARTUP_OBSERVATION_MS;
|
|
310
|
+
}
|
|
311
|
+
return parsed;
|
|
312
|
+
}
|
|
313
|
+
function sleep(ms) {
|
|
314
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
315
|
+
}
|
|
316
|
+
function clearSessionMonitor(sessionId) {
|
|
317
|
+
const timer = sessionMonitorTimers.get(sessionId);
|
|
318
|
+
if (!timer) {
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
clearInterval(timer);
|
|
322
|
+
sessionMonitorTimers.delete(sessionId);
|
|
323
|
+
}
|
|
324
|
+
function killOwnedAppServer(session) {
|
|
325
|
+
if (!session.launched_server || !session.server_pid) {
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
terminateSpawnedProcess(session.server_pid);
|
|
329
|
+
spawnedServerPids.delete(session.server_pid);
|
|
330
|
+
}
|
|
331
|
+
function scheduleOwnedSessionMonitor(session) {
|
|
332
|
+
if (!session.launched_server || sessionMonitorTimers.has(session.session_id)) {
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
const timer = setInterval(() => {
|
|
336
|
+
void inspectLocalCodexSession(session.session_id)
|
|
337
|
+
.then((status) => {
|
|
338
|
+
if (!status ||
|
|
339
|
+
!status.server_reachable ||
|
|
340
|
+
isTerminalCodexSessionStatus(status.session.status)) {
|
|
341
|
+
clearSessionMonitor(session.session_id);
|
|
342
|
+
}
|
|
343
|
+
})
|
|
344
|
+
.catch(() => {
|
|
345
|
+
const latest = getStoredCodexLiveSession(session.session_id);
|
|
346
|
+
if (latest?.launched_server) {
|
|
347
|
+
killOwnedAppServer(latest);
|
|
348
|
+
}
|
|
349
|
+
clearSessionMonitor(session.session_id);
|
|
350
|
+
});
|
|
351
|
+
}, SESSION_MONITOR_INTERVAL_MS);
|
|
352
|
+
timer.unref?.();
|
|
353
|
+
sessionMonitorTimers.set(session.session_id, timer);
|
|
354
|
+
}
|
|
355
|
+
async function waitForWorkerStartup(session) {
|
|
356
|
+
const observationMs = parseStartupObservationMs();
|
|
357
|
+
const deadline = Date.now() + observationMs;
|
|
358
|
+
let latest = session;
|
|
359
|
+
while (Date.now() < deadline) {
|
|
360
|
+
await sleep(Math.min(STARTUP_POLL_INTERVAL_MS, Math.max(deadline - Date.now(), 0)));
|
|
361
|
+
const inspected = await inspectLocalCodexSession(session.session_id);
|
|
362
|
+
if (!inspected) {
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
latest = inspected.session;
|
|
366
|
+
if (!inspected.server_reachable || latest.status === "unknown") {
|
|
367
|
+
const reason = !inspected.server_reachable
|
|
368
|
+
? "app-server became unreachable during startup"
|
|
369
|
+
: "worker status became unknown during startup";
|
|
370
|
+
const failed = updateCodexLiveSession(session.session_id, (current) => ({
|
|
371
|
+
...current,
|
|
372
|
+
status: "failed",
|
|
373
|
+
last_error: reason,
|
|
374
|
+
updated_at: new Date().toISOString(),
|
|
375
|
+
})) ?? latest;
|
|
376
|
+
throw new Error(`Codex worker exited during startup: ${failed.last_error ?? reason}`);
|
|
377
|
+
}
|
|
378
|
+
if (isTerminalCodexSessionStatus(latest.status)) {
|
|
379
|
+
const reason = latest.status === "completed"
|
|
380
|
+
? "turn completed before entering the room polling loop"
|
|
381
|
+
: `turn entered ${latest.status}`;
|
|
382
|
+
const failed = updateCodexLiveSession(session.session_id, (current) => ({
|
|
383
|
+
...current,
|
|
384
|
+
status: "failed",
|
|
385
|
+
last_error: reason,
|
|
386
|
+
updated_at: new Date().toISOString(),
|
|
387
|
+
})) ?? latest;
|
|
388
|
+
throw new Error(`Codex worker exited during startup: ${failed.last_error ?? reason}`);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
return latest;
|
|
392
|
+
}
|
|
246
393
|
function toSessionState(input) {
|
|
247
394
|
const now = new Date().toISOString();
|
|
248
395
|
return {
|
|
@@ -306,9 +453,13 @@ export async function inspectLocalCodexSession(sessionId, roomId) {
|
|
|
306
453
|
if (!serverReachable) {
|
|
307
454
|
const updated = updateCodexLiveSession(session.session_id, (current) => ({
|
|
308
455
|
...current,
|
|
309
|
-
status:
|
|
456
|
+
status: deriveCodexLiveSessionStatus(current, false, null, null),
|
|
310
457
|
updated_at: new Date().toISOString(),
|
|
311
458
|
})) ?? session;
|
|
459
|
+
if (updated.launched_server) {
|
|
460
|
+
killOwnedAppServer(updated);
|
|
461
|
+
clearSessionMonitor(updated.session_id);
|
|
462
|
+
}
|
|
312
463
|
return {
|
|
313
464
|
session: updated,
|
|
314
465
|
server_reachable: false,
|
|
@@ -318,8 +469,8 @@ export async function inspectLocalCodexSession(sessionId, roomId) {
|
|
|
318
469
|
};
|
|
319
470
|
}
|
|
320
471
|
const client = new RpcClient(session.server_url);
|
|
321
|
-
await client.connect();
|
|
322
472
|
try {
|
|
473
|
+
await client.connect();
|
|
323
474
|
let read = null;
|
|
324
475
|
try {
|
|
325
476
|
read = await client.request("thread/read", {
|
|
@@ -338,10 +489,14 @@ export async function inspectLocalCodexSession(sessionId, roomId) {
|
|
|
338
489
|
const turnStatus = extractTurnStatus(turn);
|
|
339
490
|
const updated = updateCodexLiveSession(session.session_id, (current) => ({
|
|
340
491
|
...current,
|
|
341
|
-
status:
|
|
492
|
+
status: deriveCodexLiveSessionStatus(current, true, threadStatus, turnStatus),
|
|
342
493
|
last_error: null,
|
|
343
494
|
updated_at: new Date().toISOString(),
|
|
344
495
|
})) ?? session;
|
|
496
|
+
if (isTerminalCodexSessionStatus(updated.status)) {
|
|
497
|
+
killOwnedAppServer(updated);
|
|
498
|
+
clearSessionMonitor(updated.session_id);
|
|
499
|
+
}
|
|
345
500
|
return {
|
|
346
501
|
session: updated,
|
|
347
502
|
server_reachable: true,
|
|
@@ -357,6 +512,10 @@ export async function inspectLocalCodexSession(sessionId, roomId) {
|
|
|
357
512
|
last_error: error instanceof Error ? error.message : String(error),
|
|
358
513
|
updated_at: new Date().toISOString(),
|
|
359
514
|
})) ?? session;
|
|
515
|
+
if (updated.launched_server) {
|
|
516
|
+
killOwnedAppServer(updated);
|
|
517
|
+
clearSessionMonitor(updated.session_id);
|
|
518
|
+
}
|
|
360
519
|
return {
|
|
361
520
|
session: updated,
|
|
362
521
|
server_reachable: true,
|
|
@@ -378,10 +537,11 @@ export async function startLocalCodexSession(input) {
|
|
|
378
537
|
const inspected = await inspectLocalCodexSession(currentSession.session_id);
|
|
379
538
|
if (inspected &&
|
|
380
539
|
(inspected.session.status === "running" || inspected.session.status === "starting")) {
|
|
540
|
+
scheduleOwnedSessionMonitor(inspected.session);
|
|
381
541
|
return { session: inspected.session, reused: true };
|
|
382
542
|
}
|
|
383
543
|
}
|
|
384
|
-
const serverUrl = input.server_url
|
|
544
|
+
const serverUrl = await resolveCodexServerUrl(input.server_url);
|
|
385
545
|
const stopPhrase = input.stop_phrase || DEFAULT_STOP_PHRASE;
|
|
386
546
|
const maxMinutes = Number.isFinite(input.max_minutes) ? Math.max(0, input.max_minutes ?? 0) : 0;
|
|
387
547
|
const codexBin = input.codex_bin || process.env.LETAGENTS_CODEX_BIN || "codex";
|
|
@@ -389,20 +549,22 @@ export async function startLocalCodexSession(input) {
|
|
|
389
549
|
const deadline = formatDeadline(maxMinutes);
|
|
390
550
|
const launchedServer = !(await isServerReady(serverUrl));
|
|
391
551
|
let serverPid = null;
|
|
392
|
-
|
|
393
|
-
|
|
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();
|
|
552
|
+
let client = null;
|
|
553
|
+
let startupSucceeded = false;
|
|
405
554
|
try {
|
|
555
|
+
if (launchedServer) {
|
|
556
|
+
serverPid = launchAppServer(serverUrl, codexBin);
|
|
557
|
+
if (serverPid) {
|
|
558
|
+
spawnedServerPids.add(serverPid);
|
|
559
|
+
registerProcessCleanup();
|
|
560
|
+
}
|
|
561
|
+
const ready = await waitForServer(serverUrl);
|
|
562
|
+
if (!ready) {
|
|
563
|
+
throw new Error(`Timed out waiting for codex app-server at ${serverUrl}`);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
client = new RpcClient(serverUrl);
|
|
567
|
+
await client.connect();
|
|
406
568
|
const threadStart = await client.request("thread/start", {});
|
|
407
569
|
const threadId = threadStart.thread?.id;
|
|
408
570
|
if (!threadId) {
|
|
@@ -447,10 +609,26 @@ export async function startLocalCodexSession(input) {
|
|
|
447
609
|
launched_server: launchedServer,
|
|
448
610
|
codex_bin: codexBin,
|
|
449
611
|
}));
|
|
450
|
-
|
|
612
|
+
try {
|
|
613
|
+
const verifiedSession = await waitForWorkerStartup(session);
|
|
614
|
+
scheduleOwnedSessionMonitor(verifiedSession);
|
|
615
|
+
startupSucceeded = true;
|
|
616
|
+
return { session: verifiedSession, reused: false };
|
|
617
|
+
}
|
|
618
|
+
catch (error) {
|
|
619
|
+
killOwnedAppServer(session);
|
|
620
|
+
throw error;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
catch (error) {
|
|
624
|
+
if (!startupSucceeded && launchedServer && serverPid) {
|
|
625
|
+
terminateSpawnedProcess(serverPid);
|
|
626
|
+
spawnedServerPids.delete(serverPid);
|
|
627
|
+
}
|
|
628
|
+
throw error;
|
|
451
629
|
}
|
|
452
630
|
finally {
|
|
453
|
-
client
|
|
631
|
+
client?.close();
|
|
454
632
|
}
|
|
455
633
|
}
|
|
456
634
|
export async function stopLocalCodexSession(options) {
|
|
@@ -486,15 +664,9 @@ export async function stopLocalCodexSession(options) {
|
|
|
486
664
|
last_error: serverReachable ? null : "server unreachable at stop time",
|
|
487
665
|
updated_at: new Date().toISOString(),
|
|
488
666
|
})) ?? session;
|
|
489
|
-
if (options?.shutdown_server
|
|
490
|
-
|
|
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
|
-
}
|
|
667
|
+
if (options?.shutdown_server || updated.launched_server) {
|
|
668
|
+
killOwnedAppServer(updated);
|
|
498
669
|
}
|
|
670
|
+
clearSessionMonitor(updated.session_id);
|
|
499
671
|
return updated;
|
|
500
672
|
}
|