borgmcp 5.2.0 → 5.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/dist/agent-runtime.d.ts +2 -4
  2. package/dist/agent-runtime.d.ts.map +1 -1
  3. package/dist/agent-runtime.js +3 -5
  4. package/dist/agent-runtime.js.map +1 -1
  5. package/dist/assimilate-cmd.d.ts +1 -11
  6. package/dist/assimilate-cmd.d.ts.map +1 -1
  7. package/dist/assimilate-cmd.js +37 -48
  8. package/dist/assimilate-cmd.js.map +1 -1
  9. package/dist/assimilate-deps.d.ts.map +1 -1
  10. package/dist/assimilate-deps.js +1 -4
  11. package/dist/assimilate-deps.js.map +1 -1
  12. package/dist/claude.d.ts.map +1 -1
  13. package/dist/claude.js +36 -77
  14. package/dist/claude.js.map +1 -1
  15. package/dist/codex-app-server.d.ts +4 -6
  16. package/dist/codex-app-server.d.ts.map +1 -1
  17. package/dist/codex-app-server.js +4 -25
  18. package/dist/codex-app-server.js.map +1 -1
  19. package/dist/codex-app-wake.d.ts +8 -10
  20. package/dist/codex-app-wake.d.ts.map +1 -1
  21. package/dist/codex-app-wake.js +49 -66
  22. package/dist/codex-app-wake.js.map +1 -1
  23. package/dist/codex-launch.d.ts +15 -24
  24. package/dist/codex-launch.d.ts.map +1 -1
  25. package/dist/codex-launch.js +29 -55
  26. package/dist/codex-launch.js.map +1 -1
  27. package/dist/codex-remote.d.ts +15 -8
  28. package/dist/codex-remote.d.ts.map +1 -1
  29. package/dist/codex-remote.js +71 -70
  30. package/dist/codex-remote.js.map +1 -1
  31. package/dist/codex-wake-resolve.d.ts +12 -31
  32. package/dist/codex-wake-resolve.d.ts.map +1 -1
  33. package/dist/codex-wake-resolve.js +21 -38
  34. package/dist/codex-wake-resolve.js.map +1 -1
  35. package/dist/config-utils.js +2 -2
  36. package/dist/config-utils.js.map +1 -1
  37. package/dist/cubes.d.ts +0 -17
  38. package/dist/cubes.d.ts.map +1 -1
  39. package/dist/cubes.js +0 -81
  40. package/dist/cubes.js.map +1 -1
  41. package/dist/log-stream.d.ts.map +1 -1
  42. package/dist/log-stream.js +4 -1
  43. package/dist/log-stream.js.map +1 -1
  44. package/package.json +1 -1
  45. package/src/agent-runtime.ts +3 -5
  46. package/src/assimilate-cmd.ts +35 -58
  47. package/src/assimilate-deps.ts +0 -4
  48. package/src/claude.ts +34 -81
  49. package/src/codex-app-server.ts +8 -25
  50. package/src/codex-app-wake.ts +46 -73
  51. package/src/codex-launch.ts +43 -76
  52. package/src/codex-remote.ts +90 -82
  53. package/src/codex-wake-resolve.ts +26 -47
  54. package/src/config-utils.ts +2 -2
  55. package/src/cubes.ts +2 -100
  56. package/src/log-stream.ts +4 -1
@@ -1,11 +1,11 @@
1
1
  import {
2
2
  getActiveCube,
3
- getCodexWakeTarget,
4
- setCodexWakeTarget,
5
3
  type ActiveCube,
6
4
  } from './cubes.js';
5
+ import { existsSync } from 'node:fs';
7
6
  import { CodexAppServerClient } from './codex-app-server.js';
8
7
  import { checkCodexBridgeHealthy } from './codex-remote.js';
8
+ import { debugLog } from './debug.js';
9
9
  import { hasPendingWakeActivity, hasPendingWakeEntry } from './remote-client.js';
10
10
  import {
11
11
  BORG_CODEX_REMOTE_WAKE_ENV,
@@ -14,7 +14,6 @@ import {
14
14
  import {
15
15
  codexAppServerSocketFromEnv,
16
16
  pickFreshThread,
17
- wakeTargetChanged,
18
17
  wakeRetryBackoffMs,
19
18
  wakeRetryExpired,
20
19
  WAKE_RETRY_MAX_ATTEMPTS,
@@ -69,26 +68,25 @@ export function resolveCodexWakeTarget(env: NodeJS.ProcessEnv = process.env): Co
69
68
  * (which is false-by-design for codex and falsely flagged them, gh#633).
70
69
  *
71
70
  * Tri-state (boolean|null; caller maps null→armed for false-deaf-avoidance):
72
- * - false ONLY on a positively-dead bridge: no wake target registered (the
73
- * bridge cannot deliver wakes), OR the app-server pid is dead.
74
- * - true when the wake target resolves AND the app-server pid is alive.
75
- * - null when the bridge health is indeterminate (target read or pid check
76
- * could not resolve) → armed (don't false-flag on uncertainty).
71
+ * - false when this child has no live socket, the socket file is missing, or
72
+ * the app-server pid is dead.
73
+ * - true when the env socket exists and the app-server pid is alive.
74
+ * - null when the pidfile cannot be read or parsed.
77
75
  */
78
76
  export async function probeCodexBridgeArmed(
79
- active: { cubeId: string; droneId: string },
77
+ _active: { cubeId: string; droneId: string },
80
78
  deps: {
81
- getCodexWakeTarget?: typeof getCodexWakeTarget;
79
+ env?: NodeJS.ProcessEnv;
80
+ socketExists?: (socketPath: string) => boolean;
82
81
  checkBridge?: typeof checkCodexBridgeHealthy;
83
82
  } = {}
84
83
  ): Promise<boolean | null> {
85
84
  try {
86
- const resolve = deps.getCodexWakeTarget ?? getCodexWakeTarget;
87
- const target = await resolve(active.cubeId, active.droneId);
88
- // No registered wake target → the bridge cannot deliver a wake → not armed.
89
- if (!target) return false;
85
+ const socketPath = codexAppServerSocketFromEnv(deps.env ?? process.env);
86
+ if (!socketPath) return false;
87
+ if (!(deps.socketExists ?? existsSync)(socketPath)) return false;
90
88
  const check = deps.checkBridge ?? checkCodexBridgeHealthy;
91
- return check(target.socketPath);
89
+ return check(socketPath);
92
90
  } catch {
93
91
  return null;
94
92
  }
@@ -280,9 +278,6 @@ function defaultSleep(ms: number): Promise<void> {
280
278
 
281
279
  export interface CodexWakeDeps {
282
280
  getActiveCube?: typeof getActiveCube;
283
- getCodexWakeTarget?: typeof getCodexWakeTarget;
284
- // gh#855: self-heal write of the freshly-resolved target (write-only-on-change).
285
- setCodexWakeTarget?: typeof setCodexWakeTarget;
286
281
  createClient?: (
287
282
  socketPath: string
288
283
  ) => Pick<CodexAppServerClient, 'connect' | 'readThread' | 'startTurn' | 'loadedThreadIds' | 'close'>;
@@ -320,12 +315,9 @@ export interface CodexWakeDeps {
320
315
  /**
321
316
  * gh#855: FRESH wake-target resolution. Prefer THIS drone's live app-server
322
317
  * socket (pinned into the child's env at spawn) and re-resolve the loaded thread
323
- * NOW (loadedThreadIds is re-runnable) so a missed/stale launch probe or a
324
- * thread change can never cause permanent deafness. Self-heals the file cache
325
- * (write-only-on-change) so other readers (probeCodexBridgeArmed / health-beat)
326
- * stay current. Falls back to the launch-recorded file when the env socket is
327
- * absent (un-upgraded launch) — no regression. Returns the resolved target, or
328
- * null (caller skips this wake; the next one retries). Does NOT keep a
318
+ * NOW (loadedThreadIds is re-runnable), so a thread change cannot cause
319
+ * permanent deafness. The live env socket is the sole source. Returns the
320
+ * resolved target, or null when the socket/thread is unavailable. Does NOT keep a
329
321
  * connection open — the env path opens a short-lived probe client to re-resolve
330
322
  * the thread, then closes it, so the caller can dedup BEFORE opening the wake
331
323
  * connection (no reconnect on an already-delivered wake).
@@ -338,52 +330,31 @@ function makeCodexClient(
338
330
  }
339
331
 
340
332
  async function resolveFreshCodexWakeTarget(
341
- active: { cubeId: string; droneId: string },
333
+ _active: { cubeId: string; droneId: string },
342
334
  deps: CodexWakeDeps
343
335
  ): Promise<{ socketPath: string; threadId: string } | null> {
344
336
  const envSocket = codexAppServerSocketFromEnv(deps.env ?? process.env);
345
- if (envSocket) {
346
- const probe = makeCodexClient(envSocket, deps);
347
- await probe.connect();
348
- try {
349
- const ids = await probe.loadedThreadIds();
350
- const summaries: CodexThreadInfo[] = [];
351
- for (const id of ids) {
352
- const t = await probe.readThread(id);
353
- if (t) summaries.push({ id: t.id, cwd: t.cwd, updatedAt: t.updatedAt });
354
- }
355
- const threadId = pickFreshThread(summaries, { cwd: (deps.cwd ?? (() => process.cwd()))() });
356
- if (!threadId) return null; // no loaded thread yet — next wake retries (no permanent fail)
357
- await maybePersistWakeTarget(active, { socketPath: envSocket, threadId }, deps);
358
- return { socketPath: envSocket, threadId };
359
- } finally {
360
- probe.close();
361
- }
337
+ if (!envSocket) {
338
+ debugLog('codex wake target unavailable: BORG_CODEX_APP_SERVER_SOCKET is absent');
339
+ return null;
362
340
  }
363
-
364
- // Fallback: the launch-recorded file (un-upgraded launch / env absent) — no
365
- // connect needed to resolve, so the caller dedups before opening any socket.
366
- const target = await (deps.getCodexWakeTarget ?? getCodexWakeTarget)(active.cubeId, active.droneId);
367
- if (!target) return null;
368
- return { socketPath: target.socketPath, threadId: target.threadId };
369
- }
370
-
371
- /** Self-healing cache write — only when the resolved target actually changed. */
372
- async function maybePersistWakeTarget(
373
- active: { cubeId: string; droneId: string },
374
- fresh: { socketPath: string; threadId: string },
375
- deps: CodexWakeDeps
376
- ): Promise<void> {
341
+ const probe = makeCodexClient(envSocket, deps);
342
+ await probe.connect();
377
343
  try {
378
- const get = deps.getCodexWakeTarget ?? getCodexWakeTarget;
379
- const set = deps.setCodexWakeTarget ?? setCodexWakeTarget;
380
- const existing = await get(active.cubeId, active.droneId);
381
- const prev = existing ? { socketPath: existing.socketPath, threadId: existing.threadId } : null;
382
- if (wakeTargetChanged(prev, fresh)) {
383
- await set(active.cubeId, active.droneId, fresh);
344
+ const ids = await probe.loadedThreadIds();
345
+ const summaries: CodexThreadInfo[] = [];
346
+ for (const id of ids) {
347
+ try {
348
+ const thread = await probe.readThread(id);
349
+ if (thread) summaries.push(thread);
350
+ } catch {
351
+ // A loaded thread may disappear before its read; keep other candidates.
352
+ }
384
353
  }
385
- } catch {
386
- // best-effort cache write; never break the wake path
354
+ const threadId = pickFreshThread(summaries, { cwd: (deps.cwd ?? (() => process.cwd()))() });
355
+ return threadId ? { socketPath: envSocket, threadId } : null;
356
+ } finally {
357
+ probe.close();
387
358
  }
388
359
  }
389
360
 
@@ -396,7 +367,12 @@ export function wakeCodexViaAppServer(
396
367
  ): void {
397
368
  const target = resolveCodexWakeTarget(env);
398
369
  if (!target.enabled) return;
399
- pendingWakeRequests.push({ reason, deliveryIdentity, sourceEntryId, deps });
370
+ pendingWakeRequests.push({
371
+ reason,
372
+ deliveryIdentity,
373
+ sourceEntryId,
374
+ deps: deps.env ? deps : { ...deps, env },
375
+ });
400
376
  if (wakeInFlight) return;
401
377
 
402
378
  wakeInFlight = true;
@@ -432,17 +408,14 @@ async function wakeCodexTargeted(
432
408
  if (!active) return;
433
409
  const pendingEntry = deps.hasPendingEntry ?? hasPendingWakeEntry;
434
410
  if (sourceEntryId && !(await pendingEntry(active, sourceEntryId))) return;
435
- // gh#855: resolve FRESH (live env socket + re-resolved thread), falling back
436
- // to the launch-recorded file only when the env socket is absent.
411
+ // Resolve fresh from this child’s live env socket and loaded threads.
437
412
  const resolved = await resolveFreshCodexWakeTarget(active, deps);
438
413
  if (!resolved) {
439
- // client#89: a scoped entry passed the pending check above but no target
440
- // resolves undeliverable, and this path does NOT schedule a retry-drain
441
- // (the design leaves it to the next wake / the heartbeat backstop). Mark
442
- // the deferral live so health reads degraded rather than armed. An
443
- // unscoped wake carries no authoritative pending signal, so it does not
444
- // set the marker. Retry behavior is unchanged.
414
+ // A scoped entry passed the pending check but its TUI thread is not loaded
415
+ // yet. Keep it in the coalesced retry-drain instead of waiting for another
416
+ // inbound event or the heartbeat.
445
417
  if (sourceEntryId) deliveryDeferred = true;
418
+ scheduleRetryDrain(deps, sourceEntryId);
446
419
  return;
447
420
  }
448
421
  const { socketPath, threadId } = resolved;
@@ -2,6 +2,13 @@ import type { BorgCli } from './cubes.js';
2
2
  import type { AgentKind } from './agent-runtime.js';
3
3
  import { wakePathArming } from './regen-format.js';
4
4
  import { OPENCODE_WAKE_PATH_GUIDANCE } from './opencode-wake-copy.js';
5
+ import { withCodexCwdArg, type CodexRemoteReadyLaunch } from './codex-remote.js';
6
+ import { codexBorgSessionConfigArgs } from './launch-gate.js';
7
+ import {
8
+ codexAgentKindConfigArgs,
9
+ codexRemoteWakeConfigArgs,
10
+ codexStateRootConfigArgs,
11
+ } from './agent-runtime.js';
5
12
 
6
13
  /**
7
14
  * The claude kickoff prompt's wake-path section (gh#929) — the SAME shared
@@ -29,25 +36,9 @@ export function buildKickoffWakePathClause(
29
36
  return '';
30
37
  }
31
38
 
32
- export interface CodexWakeTargetDeps {
33
- setCodexWakeTarget: (
34
- cubeId: string,
35
- droneId: string,
36
- target: { threadId: string; socketPath: string }
37
- ) => Promise<void>;
38
- findLoadedCodexThread: (options: {
39
- socketPath: string;
40
- cwd: string;
41
- previewIncludes: string;
42
- updatedAfter: number;
43
- }) => Promise<string | null>;
44
- }
45
-
46
39
  export function buildAgentKickoffPrompt(options: {
47
40
  cli: BorgCli;
48
- codexWakeNonce: string | null;
49
41
  monitorClause: string;
50
- codexWakePathClause?: string;
51
42
  }): string {
52
43
  // gh#929: compacted to the load-bearing launch essentials (lean/explicit/
53
44
  // imperative, #914 treatment). STRIPPED: the read-log-triage paragraph (the
@@ -55,13 +46,9 @@ export function buildAgentKickoffPrompt(options: {
55
46
  // clause (Coordinator/Queen-only; belongs in role-text, not injected for
56
47
  // ALL). KEPT: core call + MCP-disconnect recovery + the wake-path arming
57
48
  // (claude via the shared monitorClause = buildKickoffWakePathClause; codex
58
- // via codexWakePathClause) + the claude/codex/opencode cli-branching.
59
- const codexNonceClause = options.codexWakeNonce
60
- ? `Wake target nonce: ${options.codexWakeNonce}. `
61
- : '';
49
+ // via the Borg-owned remote socket clause) + the cli-specific branching.
62
50
  const codexWakePathClause =
63
- options.codexWakePathClause ??
64
- `Codex Borg wakeups use remote-control when available; if no wake arrives, run borg_regen manually when returning to the session.`;
51
+ 'Codex Borg wakeups use the Borg-owned remote-control socket for this session.';
65
52
  const opencodeWakePathClause = OPENCODE_WAKE_PATH_GUIDANCE;
66
53
  const wakeClause = options.cli === 'claude'
67
54
  ? options.monitorClause
@@ -70,7 +57,6 @@ export function buildAgentKickoffPrompt(options: {
70
57
  : opencodeWakePathClause;
71
58
  return (
72
59
  `Call borg_regen and follow the playbook in its response. ` +
73
- codexNonceClause +
74
60
  `Note: at session start the borg MCP server is still spinning up in ` +
75
61
  `parallel — if a system reminder claims "MCP server disconnected" or ` +
76
62
  `the borg tools are not yet registered, do NOT bail. Make one recovery attempt via ` +
@@ -82,61 +68,42 @@ export function buildAgentKickoffPrompt(options: {
82
68
  );
83
69
  }
84
70
 
85
- export function socketPathFromRemoteArgs(args: string[]): string | null {
86
- const index = args.indexOf('--remote');
87
- if (index < 0) return null;
88
- const value = args[index + 1];
89
- if (!value?.startsWith('unix://')) return null;
90
- return value.slice('unix://'.length);
91
- }
71
+ export type CodexLaunchArgsResult =
72
+ | { ready: true; args: string[] }
73
+ | { ready: false; reason: string };
92
74
 
93
- export function threadIdFromPassthroughArgs(args: string[]): string | null {
94
- if (args[0] === 'resume' && args[1] && !args[1].startsWith('-')) return args[1];
95
- const resumeIndex = args.indexOf('--resume');
96
- if (resumeIndex >= 0 && args[resumeIndex + 1]) return args[resumeIndex + 1];
97
- return null;
98
- }
99
-
100
- export async function recordCodexWakeTarget(options: {
101
- deps: CodexWakeTargetDeps;
102
- cubeId: string;
103
- droneId: string;
104
- socketPath: string;
75
+ export function buildCodexLaunchArgs(options: {
76
+ remote: CodexRemoteReadyLaunch;
105
77
  cwd: string;
106
- previewNeedle: string;
107
- launchedAtSeconds: number;
78
+ kickoff: string;
79
+ approvalArgs?: string[];
80
+ accessArgs?: string[];
81
+ seatExpectationArgs?: string[];
108
82
  passthroughArgs?: string[];
109
- }): Promise<void> {
110
- try {
111
- const explicitThreadId = options.passthroughArgs
112
- ? threadIdFromPassthroughArgs(options.passthroughArgs)
113
- : null;
114
- if (explicitThreadId) {
115
- await options.deps.setCodexWakeTarget(options.cubeId, options.droneId, {
116
- threadId: explicitThreadId,
117
- socketPath: options.socketPath,
118
- });
119
- return;
120
- }
121
-
122
- const deadline = Date.now() + 15_000;
123
- while (Date.now() < deadline) {
124
- const threadId = await options.deps.findLoadedCodexThread({
125
- socketPath: options.socketPath,
126
- cwd: options.cwd,
127
- previewIncludes: options.previewNeedle,
128
- updatedAfter: options.launchedAtSeconds - 5,
129
- });
130
- if (threadId) {
131
- await options.deps.setCodexWakeTarget(options.cubeId, options.droneId, {
132
- threadId,
133
- socketPath: options.socketPath,
134
- });
135
- return;
136
- }
137
- await new Promise((resolve) => setTimeout(resolve, 500));
138
- }
139
- } catch {
140
- // Best-effort mapping: launch still succeeds and manual regen remains available.
83
+ }): CodexLaunchArgsResult {
84
+ const passthroughArgs = options.passthroughArgs ?? [];
85
+ if (passthroughArgs.some((arg) => arg === '--remote' || arg.startsWith('--remote='))) {
86
+ return {
87
+ ready: false,
88
+ reason: 'Borg owns Codex remote control; remove the passthrough --remote option and retry.',
89
+ };
90
+ }
91
+ const remoteValue = `unix://${options.remote.server.socketPath}`;
92
+ if (options.remote.args.length !== 2 || options.remote.args[0] !== '--remote' || options.remote.args[1] !== remoteValue) {
93
+ return { ready: false, reason: 'The Borg-owned Codex remote socket is unavailable.' };
141
94
  }
95
+ return {
96
+ ready: true,
97
+ args: [
98
+ ...(options.accessArgs ?? []),
99
+ ...(options.approvalArgs ?? []),
100
+ ...codexBorgSessionConfigArgs(),
101
+ ...codexAgentKindConfigArgs(),
102
+ ...codexRemoteWakeConfigArgs(),
103
+ ...codexStateRootConfigArgs(),
104
+ ...(options.seatExpectationArgs ?? []),
105
+ ...options.remote.args,
106
+ ...withCodexCwdArg([...passthroughArgs, options.kickoff], options.cwd),
107
+ ],
108
+ };
142
109
  }
@@ -35,14 +35,22 @@ export interface CodexAppServerHandle {
35
35
  cleanup: () => void;
36
36
  }
37
37
 
38
- export interface CodexRemoteLaunch {
38
+ export interface CodexRemoteReadyLaunch {
39
+ ready: true;
39
40
  args: string[];
40
41
  env: Record<string, string>;
41
- warning?: string;
42
- /** Present when borg owns a per-launch app-server that must be cleaned up on TUI exit. */
43
- server?: CodexAppServerHandle;
42
+ /** Borg owns this per-launch app-server and must clean it up on TUI exit. */
43
+ server: CodexAppServerHandle;
44
44
  }
45
45
 
46
+ export interface CodexRemoteLaunchRefusal {
47
+ ready: false;
48
+ reason: string;
49
+ stderr: string;
50
+ }
51
+
52
+ export type CodexRemoteLaunch = CodexRemoteReadyLaunch | CodexRemoteLaunchRefusal;
53
+
46
54
  export interface CodexChild {
47
55
  pid: number | undefined;
48
56
  kill: () => void;
@@ -69,15 +77,17 @@ export interface PrepareCodexRemoteDeps {
69
77
  runtimeDir?: string;
70
78
  /** Unique socket id generator (default 32-hex). Injected for deterministic tests. */
71
79
  socketId?: () => string;
72
- /** Readiness timeout (default 30000ms) + poll interval (default 250ms). */
80
+ /** Readiness timeout (default 60000ms) + poll interval (default 250ms). */
73
81
  readyTimeoutMs?: number;
74
82
  pollIntervalMs?: number;
83
+ /** Wall clock used to enforce the readiness budget. */
84
+ now?: () => number;
75
85
  /** Whether a pid is alive (default process.kill(pid, 0)). Injected for tests. */
76
86
  isAlive?: (pid: number) => boolean;
77
87
  }
78
88
 
79
89
  export const DEFAULT_CODEX_REMOTE_DIR = join(borgConfigRoot(), 'codex-remote');
80
- const DEFAULT_CODEX_REMOTE_READY_TIMEOUT_MS = 30_000;
90
+ const DEFAULT_CODEX_REMOTE_READY_TIMEOUT_MS = 60_000;
81
91
  const MAX_CODEX_APP_SERVER_STDERR_CHARS = 16_384;
82
92
 
83
93
  /**
@@ -266,8 +276,8 @@ function pruneStaleSockets(runtimeDir: string, isAlive: (pid: number) => boolean
266
276
  }
267
277
  }
268
278
 
269
- function failLoud(reason: string): CodexRemoteLaunch {
270
- return { args: [], env: {}, warning: reason };
279
+ function refuse(reason: string, stderr = ''): CodexRemoteLaunchRefusal {
280
+ return { ready: false, reason, stderr: sanitizeCodexDiagnostic(stderr).trim() };
271
281
  }
272
282
 
273
283
  function formatExitReason(diagnostics: CodexChildDiagnostics): string {
@@ -277,16 +287,10 @@ function formatExitReason(diagnostics: CodexChildDiagnostics): string {
277
287
  return 'unknown exit status';
278
288
  }
279
289
 
280
- function formatStderr(stderr: string): string {
281
- const trimmed = sanitizeCodexDiagnostic(stderr).trim();
282
- return trimmed ? ` Stderr: ${trimmed}` : '';
283
- }
284
-
285
290
  /**
286
291
  * Start a borg-owned per-launch Codex app-server, probe it for readiness, and
287
- * return the `--remote` launch args + an owned handle (or a fail-loud warning).
288
- * Async + lifecycle-owning: the caller MUST call `result.server?.cleanup()` on
289
- * TUI exit.
292
+ * return the `--remote` launch args + an owned handle, or a typed refusal after
293
+ * one retry. The caller MUST call `result.server.cleanup()` on TUI exit.
290
294
  */
291
295
  export async function prepareCodexRemoteLaunch(
292
296
  deps: PrepareCodexRemoteDeps
@@ -295,6 +299,7 @@ export async function prepareCodexRemoteLaunch(
295
299
  const isAlive = deps.isAlive ?? defaultIsAlive;
296
300
  const readyTimeoutMs = deps.readyTimeoutMs ?? DEFAULT_CODEX_REMOTE_READY_TIMEOUT_MS;
297
301
  const pollIntervalMs = deps.pollIntervalMs ?? 250;
302
+ const now = deps.now ?? Date.now;
298
303
 
299
304
  // 1. 0700 owned dir + prune crashed prior sockets (concurrent-safe via pid liveness).
300
305
  try {
@@ -302,89 +307,92 @@ export async function prepareCodexRemoteLaunch(
302
307
  chmodSync(runtimeDir, 0o700); // enforce 0700 even if it pre-existed with looser perms
303
308
  pruneStaleSockets(runtimeDir, isAlive);
304
309
  } catch (err: any) {
305
- return failLoud(
306
- `Codex remote-wake disabled: could not prepare ${runtimeDir} (${err?.message ?? err}); run borg_regen manually.`
307
- );
310
+ return refuse(`could not prepare the Codex app-server runtime directory (${sanitizeCodexDiagnostic(String(err?.message ?? err))})`);
308
311
  }
309
312
 
310
- // 2. unique, non-predictable socket path inside the owned dir.
311
- const id = (deps.socketId ?? (() => randomBytes(16).toString('hex')))();
312
- const socketPath = join(runtimeDir, `${id}.sock`);
313
- const pidPath = join(runtimeDir, `${id}.pid`);
314
-
315
- // 3. spawn the long-lived app-server.
316
- let child: CodexChild;
317
- try {
318
- child = deps.spawnAppServer(socketPath);
319
- } catch (err: any) {
320
- safeRm(socketPath);
321
- return failLoud(
322
- `Codex remote-wake disabled: could not start \`codex app-server\` (${sanitizeCodexDiagnostic(String(err?.message ?? err))}) — ` +
323
- `confirm the Codex executable is installed and available on PATH. ` +
324
- `Remote wake is unavailable for this session; run borg_regen manually to catch up.`
325
- );
326
- }
327
- if (child.pid != null) {
313
+ let lastRefusal = refuse('the Codex app-server did not become ready');
314
+ for (let launchAttempt = 0; launchAttempt < 2; launchAttempt += 1) {
315
+ // Each retry owns a fresh, non-predictable socket and pidfile.
316
+ const id = (deps.socketId ?? (() => randomBytes(16).toString('hex')))();
317
+ const socketPath = join(runtimeDir, `${id}.sock`);
318
+ const pidPath = join(runtimeDir, `${id}.pid`);
319
+ let child: CodexChild;
328
320
  try {
329
- writeFileSync(pidPath, String(child.pid));
330
- } catch {
331
- // pidfile is only for stale-prune; launch still proceeds.
321
+ child = deps.spawnAppServer(socketPath);
322
+ } catch (err: any) {
323
+ safeRm(socketPath);
324
+ safeRm(pidPath);
325
+ lastRefusal = refuse(
326
+ `could not start \`codex app-server\` (${sanitizeCodexDiagnostic(String(err?.message ?? err))}); confirm the Codex executable is installed and available on PATH`
327
+ );
328
+ continue;
332
329
  }
333
- }
334
-
335
- const cleanup = () => {
336
- try {
337
- child.kill();
338
- } catch {
339
- // best-effort
330
+ if (child.pid != null) {
331
+ try {
332
+ writeFileSync(pidPath, String(child.pid));
333
+ } catch {
334
+ // pidfile is only for stale-prune; launch still proceeds.
335
+ }
340
336
  }
341
- safeRm(socketPath);
342
- safeRm(pidPath);
343
- };
344
337
 
345
- // 4. readiness via a real protocol round-trip — bounded attempts (no clock dep).
346
- const attempts = Math.max(1, Math.ceil(readyTimeoutMs / pollIntervalMs));
347
- let ready = false;
348
- let exitDiagnostics: CodexChildDiagnostics | undefined;
349
- for (let i = 0; i < attempts && !ready; i++) {
350
- try {
351
- ready = await deps.probeReady(socketPath);
352
- } catch {
353
- ready = false;
354
- }
355
- if (!ready) {
338
+ const cleanup = () => {
339
+ try {
340
+ child.kill();
341
+ } catch {
342
+ // best-effort
343
+ }
344
+ safeRm(socketPath);
345
+ safeRm(pidPath);
346
+ };
347
+
348
+ // Readiness is bounded by elapsed wall time, so slow protocol probes count
349
+ // against the same cold-start budget as the poll sleeps.
350
+ const startedAt = now();
351
+ let ready = false;
352
+ let exitDiagnostics: CodexChildDiagnostics | undefined;
353
+ for (;;) {
354
+ try {
355
+ ready = await deps.probeReady(socketPath);
356
+ } catch {
357
+ ready = false;
358
+ }
359
+ if (ready) break;
356
360
  const diagnostics = child.diagnostics?.();
357
361
  if (diagnostics?.exited) {
358
362
  exitDiagnostics = diagnostics;
359
363
  break;
360
364
  }
365
+ const remainingMs = readyTimeoutMs - (now() - startedAt);
366
+ if (remainingMs <= 0) break;
367
+ await deps.sleep(Math.min(pollIntervalMs, remainingMs));
361
368
  }
362
- if (!ready && i < attempts - 1) await deps.sleep(pollIntervalMs);
363
- }
364
369
 
365
- if (!ready) {
366
370
  const diagnostics = exitDiagnostics ?? child.diagnostics?.();
367
- cleanup();
368
- if (diagnostics?.exited) {
369
- return failLoud(
370
- `Codex remote-wake disabled: \`codex app-server\` exited before becoming ready ` +
371
- `(${formatExitReason(diagnostics)}).${formatStderr(diagnostics.stderr)} ` +
372
- `Run borg_regen manually to catch up.`
373
- );
371
+ if (!ready) {
372
+ cleanup();
373
+ if (diagnostics?.exited) {
374
+ lastRefusal = refuse(
375
+ `\`codex app-server\` exited before becoming ready (${formatExitReason(diagnostics)})`,
376
+ diagnostics.stderr,
377
+ );
378
+ } else {
379
+ lastRefusal = refuse(
380
+ `\`codex app-server\` remained running but did not become ready within ${readyTimeoutMs}ms`,
381
+ diagnostics?.stderr ?? '',
382
+ );
383
+ }
384
+ continue;
374
385
  }
375
- return failLoud(
376
- `Codex remote-wake disabled: \`codex app-server\` remained running but did not become ready ` +
377
- `at ${sanitizeCodexDiagnostic(socketPath)} within ${readyTimeoutMs}ms.${formatStderr(diagnostics?.stderr ?? '')} ` +
378
- `Run borg_regen manually to catch up.`
379
- );
386
+
387
+ return {
388
+ ready: true,
389
+ args: ['--remote', `unix://${socketPath}`],
390
+ env: { [BORG_CODEX_REMOTE_WAKE_ENV]: '1' },
391
+ server: { pid: child.pid, socketPath, cleanup },
392
+ };
380
393
  }
381
394
 
382
- // 5. ready → owned remote launch.
383
- return {
384
- args: ['--remote', `unix://${socketPath}`],
385
- env: { [BORG_CODEX_REMOTE_WAKE_ENV]: '1' },
386
- server: { pid: child.pid, socketPath, cleanup },
387
- };
395
+ return lastRefusal;
388
396
  }
389
397
 
390
398
  /**