borgmcp 5.1.2 → 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 (71) 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/index.d.ts.map +1 -1
  42. package/dist/index.js +3 -1
  43. package/dist/index.js.map +1 -1
  44. package/dist/log-stream.d.ts.map +1 -1
  45. package/dist/log-stream.js +4 -1
  46. package/dist/log-stream.js.map +1 -1
  47. package/dist/regen-format.d.ts +3 -1
  48. package/dist/regen-format.d.ts.map +1 -1
  49. package/dist/regen-format.js +24 -4
  50. package/dist/regen-format.js.map +1 -1
  51. package/dist/remote-client.d.ts +4 -3
  52. package/dist/remote-client.d.ts.map +1 -1
  53. package/dist/remote-client.js +9 -3
  54. package/dist/remote-client.js.map +1 -1
  55. package/docs/RELEASING.md +2 -2
  56. package/package.json +2 -2
  57. package/src/agent-runtime.ts +3 -5
  58. package/src/assimilate-cmd.ts +35 -58
  59. package/src/assimilate-deps.ts +0 -4
  60. package/src/claude.ts +34 -81
  61. package/src/codex-app-server.ts +8 -25
  62. package/src/codex-app-wake.ts +46 -73
  63. package/src/codex-launch.ts +43 -76
  64. package/src/codex-remote.ts +90 -82
  65. package/src/codex-wake-resolve.ts +26 -47
  66. package/src/config-utils.ts +2 -2
  67. package/src/cubes.ts +2 -100
  68. package/src/index.ts +3 -1
  69. package/src/log-stream.ts +4 -1
  70. package/src/regen-format.ts +28 -6
  71. package/src/remote-client.ts +14 -6
@@ -10,20 +10,15 @@ import {
10
10
  import { validateName } from './name-validator.js';
11
11
  import { renderAssimilationWelcome } from './assimilate-welcome.js';
12
12
  import { shellEscape } from './shell-escape.js';
13
- import { withCodexCwdArg, type CodexRemoteLaunch } from './codex-remote.js';
13
+ import { type CodexRemoteLaunch } from './codex-remote.js';
14
14
  import {
15
15
  buildAgentKickoffPrompt,
16
16
  buildKickoffWakePathClause,
17
- recordCodexWakeTarget,
18
- socketPathFromRemoteArgs,
17
+ buildCodexLaunchArgs,
19
18
  } from './codex-launch.js';
20
19
  import { perWorktreeBranchName, computeWorktreePath, localBranchExists, isMerged } from './worktree-lifecycle.js';
21
20
  import { DroneEvictedError } from './drone-lifecycle.js';
22
- import { codexBorgSessionConfigArgs } from './launch-gate.js';
23
21
  import {
24
- codexAgentKindConfigArgs,
25
- codexRemoteWakeConfigArgs,
26
- codexStateRootConfigArgs,
27
22
  withAgentRuntimeEnv,
28
23
  } from './agent-runtime.js';
29
24
  import {
@@ -405,13 +400,6 @@ export interface AssimilateDeps {
405
400
  setCliPreferenceForWorktree: (cli: BorgCli, worktree: string) => Promise<void>;
406
401
  resolveCli: (explicit?: BorgCli) => Promise<BorgCli>;
407
402
  prepareCodexRemoteLaunch: () => Promise<CodexRemoteLaunch>;
408
- setCodexWakeTarget: (cubeId: string, droneId: string, target: { threadId: string; socketPath: string }) => Promise<void>;
409
- findLoadedCodexThread: (options: {
410
- socketPath: string;
411
- cwd: string;
412
- previewIncludes: string;
413
- updatedAfter: number;
414
- }) => Promise<string | null>;
415
403
 
416
404
  }
417
405
 
@@ -952,13 +940,11 @@ export async function finalizeAssimilationSeat(
952
940
  export type LaunchDeps = Pick<
953
941
  AssimilateDeps,
954
942
  | 'exec'
955
- | 'findLoadedCodexThread'
956
943
  | 'getInboxPath'
957
944
  | 'isTTY'
958
945
  | 'prepareCodexRemoteLaunch'
959
946
  | 'probeMcpReady'
960
947
  | 'resolveCliApprovals'
961
- | 'setCodexWakeTarget'
962
948
  | 'setTerminalTitle'
963
949
  | 'stderr'
964
950
  | 'stdout'
@@ -1019,17 +1005,14 @@ export async function launchAssimilatedAgent(
1019
1005
  );
1020
1006
  }
1021
1007
  const inboxPath = deps.getInboxPath(result.cube_id, result.drone_id);
1022
- const codexWakeNonce = cli === 'codex' ? `borg-wake-${randomUUID()}` : null;
1023
1008
  const monitorClause = buildKickoffWakePathClause(
1024
1009
  cli,
1025
1010
  cli === 'claude' ? inboxPath : null,
1026
1011
  cli === 'claude' ? monitorStateRoot : null,
1027
1012
  );
1028
- let codexWakePathClause: string | undefined;
1029
- let remoteArgs: string[] = [];
1030
1013
  let launchArgs: string[];
1031
- let codexSocketPath: string | null = null;
1032
1014
  let codexServerCleanup: (() => void) | null = null;
1015
+ let codexRemote: CodexRemoteLaunch | null = null;
1033
1016
  const launchApproval = deps.resolveCliApprovals
1034
1017
  ? await deps.resolveCliApprovals(cli, agentCwd, { skipOverride: flags.noBorgApprovalOverride })
1035
1018
  : { codexArgs: [] };
@@ -1050,39 +1033,36 @@ export async function launchAssimilatedAgent(
1050
1033
  for (const key of modelEnv.unset) delete childEnv[key];
1051
1034
 
1052
1035
  if (cli === 'codex') {
1053
- const remote = await deps.prepareCodexRemoteLaunch();
1054
- if (remote.warning) {
1055
- deps.stderr(`warning: ${remote.warning}\n`);
1056
- codexWakePathClause =
1057
- '⚠ Codex wake-path capability check failed: remote-control is unavailable for this session. Run borg_regen manually whenever you return, and expect only fallback wakeups until relaunch.';
1058
- } else {
1059
- codexWakePathClause = 'Codex wake-path capability check passed: remote-control socket established for this session.';
1036
+ codexRemote = await deps.prepareCodexRemoteLaunch();
1037
+ if (!codexRemote.ready) {
1038
+ const stderr = codexRemote.stderr ? `\n${codexRemote.stderr}` : '';
1039
+ deps.stderr(`Codex launch refused: ${codexRemote.reason}${stderr}\n`);
1040
+ return 1;
1060
1041
  }
1061
- remoteArgs = remote.args;
1062
- if (Object.keys(remote.env).length > 0) Object.assign(childEnv, remote.env);
1063
- codexSocketPath = socketPathFromRemoteArgs(remote.args);
1064
- codexServerCleanup = remote.server?.cleanup ?? null;
1042
+ Object.assign(childEnv, codexRemote.env);
1043
+ codexServerCleanup = codexRemote.server.cleanup;
1065
1044
  }
1066
1045
  const kickoff = buildAgentKickoffPrompt({
1067
1046
  cli,
1068
- codexWakeNonce,
1069
1047
  monitorClause,
1070
- codexWakePathClause,
1071
1048
  });
1072
1049
  let openCodeKickoff: ReturnType<typeof createOpenCodeLaunchKickoff> | null = null;
1073
1050
  let dronePort: number | undefined;
1074
1051
  launchArgs = [kickoff];
1075
1052
  if (cli === 'codex') {
1076
- launchArgs = [
1077
- ...codexLaunchDirectoryArgs(launchAccessPaths),
1078
- ...launchApproval.codexArgs,
1079
- ...codexBorgSessionConfigArgs(),
1080
- ...codexAgentKindConfigArgs(),
1081
- ...codexRemoteWakeConfigArgs(codexSocketPath !== null),
1082
- ...codexStateRootConfigArgs(),
1083
- ...remoteArgs,
1084
- ...withCodexCwdArg(launchArgs, agentCwd),
1085
- ];
1053
+ const plan = buildCodexLaunchArgs({
1054
+ remote: codexRemote!,
1055
+ cwd: agentCwd,
1056
+ kickoff,
1057
+ approvalArgs: launchApproval.codexArgs,
1058
+ accessArgs: codexLaunchDirectoryArgs(launchAccessPaths),
1059
+ });
1060
+ if (!plan.ready) {
1061
+ codexServerCleanup?.();
1062
+ deps.stderr(`Codex launch refused: ${plan.reason}\n`);
1063
+ return 1;
1064
+ }
1065
+ launchArgs = plan.args;
1086
1066
  } else if (cli === 'opencode') {
1087
1067
  dronePort = await allocateOpenCodePort();
1088
1068
  childEnv.BORG_OPENCODE_PORT = String(dronePort);
@@ -1094,17 +1074,12 @@ export async function launchAssimilatedAgent(
1094
1074
  launchArgs = buildOpenCodeLaunchArgs(agentCwd, dronePort, openCodeKickoff.prompt);
1095
1075
  }
1096
1076
 
1097
- const exitPromise = deps.exec(cli, launchArgs, agentCwd, childEnv);
1098
- if (cli === 'codex' && codexSocketPath && codexWakeNonce) {
1099
- void recordCodexWakeTarget({
1100
- deps,
1101
- cubeId: result.cube_id,
1102
- droneId: result.drone_id,
1103
- socketPath: codexSocketPath,
1104
- cwd: agentCwd,
1105
- previewNeedle: codexWakeNonce,
1106
- launchedAtSeconds: Math.floor(Date.now() / 1000),
1107
- });
1077
+ let exitPromise: Promise<number>;
1078
+ try {
1079
+ exitPromise = deps.exec(cli, launchArgs, agentCwd, childEnv);
1080
+ } catch (error) {
1081
+ codexServerCleanup?.();
1082
+ throw error;
1108
1083
  }
1109
1084
  if (cli === 'opencode' && openCodeKickoff) {
1110
1085
  const launchKickoff = openCodeKickoff;
@@ -1117,12 +1092,14 @@ export async function launchAssimilatedAgent(
1117
1092
  launchIdentity: launchKickoff.correlationIdentity,
1118
1093
  }).then(() => injectInitialKickoff(launchKickoff)).catch(() => {});
1119
1094
  }
1120
- const exitCode = await exitPromise;
1121
- if (codexServerCleanup) {
1095
+ let exitCode: number;
1096
+ try {
1097
+ exitCode = await exitPromise;
1098
+ } finally {
1122
1099
  try {
1123
- codexServerCleanup();
1100
+ codexServerCleanup?.();
1124
1101
  } catch {
1125
- // Best-effort cleanup after a normal Codex exit.
1102
+ // Best-effort cleanup after Codex exits or fails to spawn.
1126
1103
  }
1127
1104
  }
1128
1105
  if (spawnedWorktreePath && originalCwd !== spawnedWorktreePath) {
@@ -61,7 +61,6 @@ import {
61
61
  hasPersistedActiveCube as cubesHasPersistedActive,
62
62
  inboxPathForDrone,
63
63
  setProjectCliPreference,
64
- setCodexWakeTarget,
65
64
  } from './cubes.js';
66
65
  import {
67
66
  addProjectSessionStartHook,
@@ -71,7 +70,6 @@ import { findPendingServerEnrollment, listServerCredentialOrigins } from './conf
71
70
  import { setTerminalTitle as setTitle } from './terminal-title.js';
72
71
  import { defaultCliChoiceDeps, resolveCliChoice } from './cli-platform.js';
73
72
  import { prepareCodexRemoteLaunch, defaultCodexRemoteDeps } from './codex-remote.js';
74
- import { findLoadedCodexThread } from './codex-app-server.js';
75
73
  import { defaultApprovalIo, resolveLaunchBorgApprovals } from './cli-tool-approval.js';
76
74
  import { ensurePrivateBorgConfigRoot } from './private-root.js';
77
75
  import {
@@ -577,7 +575,5 @@ export function buildDefaultAssimilateDeps(
577
575
  ),
578
576
  setCliPreferenceForWorktree: (cli, worktree) => setProjectCliPreference(cli, worktree),
579
577
  prepareCodexRemoteLaunch: () => prepareCodexRemoteLaunch(defaultCodexRemoteDeps()),
580
- setCodexWakeTarget,
581
- findLoadedCodexThread,
582
578
  };
583
579
  }
package/src/claude.ts CHANGED
@@ -16,7 +16,6 @@
16
16
  */
17
17
 
18
18
  import { spawn } from 'child_process';
19
- import { randomUUID } from 'node:crypto';
20
19
  import { realpathSync } from 'node:fs';
21
20
  import { homedir } from 'node:os';
22
21
  import { basename } from 'node:path';
@@ -30,8 +29,6 @@ import {
30
29
  getActiveCube,
31
30
  inboxPathForDrone,
32
31
  LaunchSeatIdentityChangedError,
33
- setCodexWakeTarget,
34
- pruneDeadCodexWakeTargets,
35
32
  type ActiveCube,
36
33
  type BorgCli,
37
34
  } from './cubes.js';
@@ -82,22 +79,15 @@ import { setTerminalTitle } from './terminal-title.js';
82
79
  import { initConsolePrefix, consolePrefix } from './console-prefix.js';
83
80
  import { initDebugFromArgv } from './debug.js';
84
81
  import { configuredCliNames, defaultCliChoiceDeps, detectCliAvailability, detectCliConfiguration, parseCliFlag, resolveCliChoice } from './cli-platform.js';
85
- import { prepareCodexRemoteLaunch, resolveCodexLaunchCwd, withCodexCwdArg, defaultCodexRemoteDeps, checkCodexBridgeHealthy } from './codex-remote.js';
82
+ import { prepareCodexRemoteLaunch, resolveCodexLaunchCwd, defaultCodexRemoteDeps } from './codex-remote.js';
86
83
  import {
87
- BORG_CODEX_REMOTE_WAKE_ENV,
88
- codexAgentKindConfigArgs,
89
- codexRemoteWakeConfigArgs,
90
- codexStateRootConfigArgs,
91
84
  withAgentRuntimeEnv,
92
85
  } from './agent-runtime.js';
93
- import { findLoadedCodexThread } from './codex-app-server.js';
94
86
  import {
95
87
  buildAgentKickoffPrompt,
96
88
  buildKickoffWakePathClause,
97
- recordCodexWakeTarget,
98
- socketPathFromRemoteArgs,
89
+ buildCodexLaunchArgs,
99
90
  } from './codex-launch.js';
100
- import { codexBorgSessionConfigArgs } from './launch-gate.js';
101
91
  import {
102
92
  addCodexSessionStartHook,
103
93
  addCodexUserPromptSubmitHook,
@@ -639,9 +629,6 @@ async function main() {
639
629
  : null
640
630
  );
641
631
 
642
- const codexWakeNonce = cli === 'codex' ? `borg-wake-${randomUUID()}` : null;
643
- let codexWakePathClause: string | undefined;
644
- let remoteArgs: string[] = [];
645
632
  // gh#673 P1: mark the agent session as borg-launched. Claude Code's MCP
646
633
  // child + hook commands inherit this env, gating the borg activation
647
634
  // surface (launch-gate.ts). ACTIVATION-only — never a security gate.
@@ -654,45 +641,33 @@ async function main() {
654
641
  if (cli === 'opencode' && launchApproval.openCodePermission) {
655
642
  launchEnv.OPENCODE_PERMISSION = launchApproval.openCodePermission;
656
643
  }
657
- let codexSocketPath: string | null = null;
658
644
  let codexServerCleanup: (() => void) | null = null;
659
- if (cli === 'codex' && !passthroughArgs.includes('--remote')) {
645
+ let codexRemote: Awaited<ReturnType<typeof prepareCodexRemoteLaunch>> | null = null;
646
+ if (cli === 'codex') {
647
+ if (passthroughArgs.some((arg) => arg === '--remote' || arg.startsWith('--remote='))) {
648
+ console.error(`${consolePrefix()}${chalk.red('Codex launch refused: Borg owns Codex remote control; remove the passthrough --remote option and retry.')}`);
649
+ process.exit(1);
650
+ return;
651
+ }
660
652
  console.error(`${consolePrefix()}${chalk.gray('◼ Starting Codex remote-wake app-server…')}`);
661
- const remote = await prepareCodexRemoteLaunch(defaultCodexRemoteDeps());
662
- if (remote.warning) {
663
- console.error(`${consolePrefix()}${chalk.yellow(`warning: ${remote.warning}`)}`);
664
- codexWakePathClause =
665
- `⚠ Codex wake-path capability check failed: remote-control is unavailable for this session. Run borg_regen manually whenever you return, and expect only fallback wakeups until relaunch.`;
666
- } else {
667
- codexWakePathClause =
668
- `Codex wake-path capability check passed: remote-control socket established for this session.`;
653
+ codexRemote = await prepareCodexRemoteLaunch(defaultCodexRemoteDeps());
654
+ if (!codexRemote.ready) {
655
+ const stderr = codexRemote.stderr ? `\n${codexRemote.stderr}` : '';
656
+ console.error(`${consolePrefix()}${chalk.red(`Codex launch refused: ${codexRemote.reason}${stderr}`)}`);
657
+ process.exit(1);
658
+ return;
669
659
  }
670
- remoteArgs = remote.args;
671
660
  launchEnv = {
672
661
  ...withAgentRuntimeEnv(process.env, cli),
673
- ...remote.env,
662
+ ...codexRemote.env,
674
663
  BORG_SESSION: '1',
675
664
  };
676
- codexSocketPath = socketPathFromRemoteArgs(remote.args);
677
- codexServerCleanup = remote.server?.cleanup ?? null;
678
- } else if (cli === 'codex' && passthroughArgs.includes('--remote')) {
679
- codexWakePathClause =
680
- `Codex wake-path capability check: using caller-provided --remote socket; if no wake arrives, run borg_regen manually when returning to the session.`;
681
- codexSocketPath = socketPathFromRemoteArgs(passthroughArgs);
682
- if (codexSocketPath) {
683
- launchEnv = {
684
- ...withAgentRuntimeEnv(process.env, cli),
685
- [BORG_CODEX_REMOTE_WAKE_ENV]: '1',
686
- BORG_SESSION: '1',
687
- };
688
- }
665
+ codexServerCleanup = codexRemote.server.cleanup;
689
666
  }
690
667
 
691
668
  const kickoff = buildAgentKickoffPrompt({
692
669
  cli,
693
- codexWakeNonce,
694
670
  monitorClause,
695
- codexWakePathClause,
696
671
  });
697
672
  // This stays separate from the shared kickoff so OpenCode can carry
698
673
  // launch-only API and correlation identities outside argv and prompt text.
@@ -700,29 +675,26 @@ async function main() {
700
675
  let openCodePort: number | undefined;
701
676
  let launchArgs: string[];
702
677
  if (cli === 'codex') {
703
- // gh#673 P1-codex: codex MCP children only see the pinned
704
- // [mcp_servers.borg.env], never inherited env (V2 probe) — deliver
705
- // the borg-launch marker and selected CLI identity via per-invocation
706
- // -c overrides instead (V2b-proven). Remote wake remains a separate
707
- // transport capability, explicitly disabled when this launch has no
708
- // socket so legacy static config cannot spuriously arm a bridge.
709
- // client#20: the exact approval overrides above are launch-scoped and
710
- // consented. They remain separate from activation/identity/wake config.
711
- launchArgs = [
712
- ...launchApproval.codexArgs,
713
- ...codexBorgSessionConfigArgs(),
714
- ...codexAgentKindConfigArgs(),
715
- ...codexRemoteWakeConfigArgs(codexSocketPath !== null),
716
- ...codexStateRootConfigArgs(),
717
- ...codexLaunchSeatExpectationConfigArgs(),
718
- ...remoteArgs,
719
- ...withCodexCwdArg([...passthroughArgs, kickoff], process.cwd()),
720
- ];
678
+ const plan = buildCodexLaunchArgs({
679
+ remote: codexRemote!,
680
+ cwd: process.cwd(),
681
+ kickoff,
682
+ approvalArgs: launchApproval.codexArgs,
683
+ seatExpectationArgs: codexLaunchSeatExpectationConfigArgs(),
684
+ passthroughArgs,
685
+ });
686
+ if (!plan.ready) {
687
+ codexServerCleanup?.();
688
+ console.error(`${consolePrefix()}${chalk.red(`Codex launch refused: ${plan.reason}`)}`);
689
+ process.exit(1);
690
+ return;
691
+ }
692
+ launchArgs = plan.args;
721
693
  } else if (cli === 'opencode') {
722
694
  // OpenCode launch: start TUI with the kickoff passed via --prompt
723
695
  // (auto-submits it as the first message). BORG_SESSION is pinned in
724
- // opencode.json. The OS-selected loopback port lets the MCP child connect
725
- // to OpenCode's local HTTP API without a shared deterministic collision space.
696
+ // opencode.json. Wake delivery uses HTTP entry injection through OpenCode's local HTTP API;
697
+ // the OS-selected port avoids a deterministic collision space.
726
698
  openCodePort = await allocateOpenCodePort();
727
699
  installBorgPlugin();
728
700
  openCodeKickoff = createOpenCodeLaunchKickoff(kickoff);
@@ -749,25 +721,6 @@ async function main() {
749
721
  })).process
750
722
  : spawn(cli, launchArgs, { stdio: 'inherit', shell: false, env: launchEnv });
751
723
 
752
- // gh#857 WI-2: wake-target recording is codex-only (app-server bridge).
753
- // OpenCode uses HTTP entry injection; Claude uses the inbox Monitor.
754
- if (cli === 'codex' && active && codexSocketPath) {
755
- void recordCodexWakeTarget({
756
- deps: { setCodexWakeTarget, findLoadedCodexThread },
757
- cubeId: active.cubeId,
758
- droneId: active.droneId,
759
- socketPath: codexSocketPath,
760
- passthroughArgs,
761
- previewNeedle: codexWakeNonce ?? kickoff.slice(0, 120),
762
- cwd: process.cwd(),
763
- launchedAtSeconds: Math.floor(Date.now() / 1000),
764
- });
765
- // gh#855: self-heal the wake-target file — drop entries whose app-server
766
- // socket is positively dead (crashed prior launches), mirroring the
767
- // socket-dir pruneStaleSockets. Best-effort; never blocks the launch.
768
- void pruneDeadCodexWakeTargets((sock) => checkCodexBridgeHealthy(sock));
769
- }
770
-
771
724
  agentProcess.on('error', (err: NodeJS.ErrnoException) => {
772
725
  if (codexServerCleanup) {
773
726
  try {
@@ -7,6 +7,10 @@ export interface CodexThreadSummary {
7
7
  preview: string;
8
8
  status: { type: string };
9
9
  updatedAt: number;
10
+ source?: unknown;
11
+ ephemeral?: boolean;
12
+ threadSource?: string;
13
+ turns?: unknown[];
10
14
  }
11
15
 
12
16
  interface PendingRequest {
@@ -85,6 +89,10 @@ export class CodexAppServerClient {
85
89
  preview: thread.preview,
86
90
  status: thread.status,
87
91
  updatedAt: thread.updatedAt,
92
+ source: thread.source,
93
+ ephemeral: thread.ephemeral,
94
+ threadSource: thread.threadSource,
95
+ turns: thread.turns,
88
96
  };
89
97
  }
90
98
 
@@ -159,31 +167,6 @@ export class CodexAppServerClient {
159
167
  }
160
168
  }
161
169
 
162
- export async function findLoadedCodexThread(options: {
163
- socketPath: string;
164
- cwd: string;
165
- previewIncludes: string;
166
- updatedAfter: number;
167
- }): Promise<string | null> {
168
- const client = new CodexAppServerClient(options.socketPath);
169
- await client.connect();
170
- try {
171
- const ids = await client.loadedThreadIds();
172
- let best: CodexThreadSummary | null = null;
173
- for (const id of ids) {
174
- const thread = await client.readThread(id);
175
- if (!thread) continue;
176
- if (thread.cwd !== options.cwd) continue;
177
- if (!thread.preview.includes(options.previewIncludes)) continue;
178
- if (thread.updatedAt < options.updatedAfter) continue;
179
- if (!best || thread.updatedAt > best.updatedAt) best = thread;
180
- }
181
- return best?.id ?? null;
182
- } finally {
183
- client.close();
184
- }
185
- }
186
-
187
170
  function encodeWebSocketTextFrame(text: string): Buffer {
188
171
  const payload = Buffer.from(text);
189
172
  let header: Buffer;
@@ -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;