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.
@@ -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 DEFAULT_SERVER_URL = "ws://127.0.0.1:8765";
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
- try {
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. 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`,
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 mapSessionStatus(session, serverReachable, threadStatus, turnStatus) {
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: mapSessionStatus(current, false, null, null),
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: mapSessionStatus(current, true, threadStatus, turnStatus),
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 || DEFAULT_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
- 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();
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
- return { session, reused: false };
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.close();
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 && 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
- }
667
+ if (options?.shutdown_server || updated.launched_server) {
668
+ killOwnedAppServer(updated);
498
669
  }
670
+ clearSessionMonitor(updated.session_id);
499
671
  return updated;
500
672
  }