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.
- package/dist/agent-runtime.d.ts +2 -4
- package/dist/agent-runtime.d.ts.map +1 -1
- package/dist/agent-runtime.js +3 -5
- package/dist/agent-runtime.js.map +1 -1
- package/dist/assimilate-cmd.d.ts +1 -11
- package/dist/assimilate-cmd.d.ts.map +1 -1
- package/dist/assimilate-cmd.js +37 -48
- package/dist/assimilate-cmd.js.map +1 -1
- package/dist/assimilate-deps.d.ts.map +1 -1
- package/dist/assimilate-deps.js +1 -4
- package/dist/assimilate-deps.js.map +1 -1
- package/dist/claude.d.ts.map +1 -1
- package/dist/claude.js +36 -77
- package/dist/claude.js.map +1 -1
- package/dist/codex-app-server.d.ts +4 -6
- package/dist/codex-app-server.d.ts.map +1 -1
- package/dist/codex-app-server.js +4 -25
- package/dist/codex-app-server.js.map +1 -1
- package/dist/codex-app-wake.d.ts +8 -10
- package/dist/codex-app-wake.d.ts.map +1 -1
- package/dist/codex-app-wake.js +49 -66
- package/dist/codex-app-wake.js.map +1 -1
- package/dist/codex-launch.d.ts +15 -24
- package/dist/codex-launch.d.ts.map +1 -1
- package/dist/codex-launch.js +29 -55
- package/dist/codex-launch.js.map +1 -1
- package/dist/codex-remote.d.ts +15 -8
- package/dist/codex-remote.d.ts.map +1 -1
- package/dist/codex-remote.js +71 -70
- package/dist/codex-remote.js.map +1 -1
- package/dist/codex-wake-resolve.d.ts +12 -31
- package/dist/codex-wake-resolve.d.ts.map +1 -1
- package/dist/codex-wake-resolve.js +21 -38
- package/dist/codex-wake-resolve.js.map +1 -1
- package/dist/config-utils.js +2 -2
- package/dist/config-utils.js.map +1 -1
- package/dist/cubes.d.ts +0 -17
- package/dist/cubes.d.ts.map +1 -1
- package/dist/cubes.js +0 -81
- package/dist/cubes.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/log-stream.d.ts.map +1 -1
- package/dist/log-stream.js +4 -1
- package/dist/log-stream.js.map +1 -1
- package/dist/regen-format.d.ts +3 -1
- package/dist/regen-format.d.ts.map +1 -1
- package/dist/regen-format.js +24 -4
- package/dist/regen-format.js.map +1 -1
- package/dist/remote-client.d.ts +4 -3
- package/dist/remote-client.d.ts.map +1 -1
- package/dist/remote-client.js +9 -3
- package/dist/remote-client.js.map +1 -1
- package/docs/RELEASING.md +2 -2
- package/package.json +2 -2
- package/src/agent-runtime.ts +3 -5
- package/src/assimilate-cmd.ts +35 -58
- package/src/assimilate-deps.ts +0 -4
- package/src/claude.ts +34 -81
- package/src/codex-app-server.ts +8 -25
- package/src/codex-app-wake.ts +46 -73
- package/src/codex-launch.ts +43 -76
- package/src/codex-remote.ts +90 -82
- package/src/codex-wake-resolve.ts +26 -47
- package/src/config-utils.ts +2 -2
- package/src/cubes.ts +2 -100
- package/src/index.ts +3 -1
- package/src/log-stream.ts +4 -1
- package/src/regen-format.ts +28 -6
- package/src/remote-client.ts +14 -6
package/src/codex-launch.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
86
|
-
|
|
87
|
-
|
|
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
|
|
94
|
-
|
|
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
|
-
|
|
107
|
-
|
|
78
|
+
kickoff: string;
|
|
79
|
+
approvalArgs?: string[];
|
|
80
|
+
accessArgs?: string[];
|
|
81
|
+
seatExpectationArgs?: string[];
|
|
108
82
|
passthroughArgs?: string[];
|
|
109
|
-
}):
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
:
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
}
|
package/src/codex-remote.ts
CHANGED
|
@@ -35,14 +35,22 @@ export interface CodexAppServerHandle {
|
|
|
35
35
|
cleanup: () => void;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
export interface
|
|
38
|
+
export interface CodexRemoteReadyLaunch {
|
|
39
|
+
ready: true;
|
|
39
40
|
args: string[];
|
|
40
41
|
env: Record<string, string>;
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
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 =
|
|
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
|
|
270
|
-
return {
|
|
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
|
|
288
|
-
*
|
|
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
|
|
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
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
-
|
|
330
|
-
} catch {
|
|
331
|
-
|
|
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
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
-
|
|
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
|
/**
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* gh#855 — pure helpers for FRESH codex wake-target re-resolution.
|
|
3
3
|
*
|
|
4
|
-
* Root cause of codex deaf-when-idle:
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* authoritative about its OWN live app-server socket — the socket is injected
|
|
4
|
+
* Root cause of codex deaf-when-idle: stale thread selection could leave the
|
|
5
|
+
* drone permanently deaf. The waking borg-mcp child is authoritative about its
|
|
6
|
+
* OWN live app-server socket — the socket is injected
|
|
8
7
|
* into the child's pinned env at spawn (codex-remote.ts, via the #851 `-c
|
|
9
8
|
* mcp_servers.borg.env.X` channel) — and re-resolves the loaded thread FRESH on
|
|
10
9
|
* every wake (loadedThreadIds is a re-runnable RPC).
|
|
@@ -15,7 +14,7 @@
|
|
|
15
14
|
/** Pinned-env var carrying THIS drone's live app-server socket (set at spawn). */
|
|
16
15
|
export const BORG_CODEX_APP_SERVER_SOCKET_ENV = 'BORG_CODEX_APP_SERVER_SOCKET';
|
|
17
16
|
|
|
18
|
-
/** The live app-server socket for this borg-mcp child
|
|
17
|
+
/** The live app-server socket for this borg-mcp child. */
|
|
19
18
|
export function codexAppServerSocketFromEnv(env: NodeJS.ProcessEnv = process.env): string | null {
|
|
20
19
|
const v = env[BORG_CODEX_APP_SERVER_SOCKET_ENV];
|
|
21
20
|
return v && v.length > 0 ? v : null;
|
|
@@ -37,6 +36,16 @@ export interface CodexThreadInfo {
|
|
|
37
36
|
id: string;
|
|
38
37
|
cwd?: string;
|
|
39
38
|
updatedAt?: number;
|
|
39
|
+
source?: unknown;
|
|
40
|
+
ephemeral?: boolean;
|
|
41
|
+
threadSource?: string;
|
|
42
|
+
preview?: string;
|
|
43
|
+
turns?: unknown[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function isCodexSubagentSource(source: unknown): boolean {
|
|
47
|
+
if (!source || typeof source !== 'object' || Array.isArray(source)) return false;
|
|
48
|
+
return 'subagent' in source || 'subAgent' in source;
|
|
40
49
|
}
|
|
41
50
|
|
|
42
51
|
/**
|
|
@@ -44,15 +53,22 @@ export interface CodexThreadInfo {
|
|
|
44
53
|
* is fresh-per-launch / single-session, so the common case is exactly one loaded
|
|
45
54
|
* thread. When more than one is loaded, prefer the thread whose cwd matches this
|
|
46
55
|
* drone's working directory (sibling worktrees have distinct cwds), then the
|
|
47
|
-
* newest by updatedAt
|
|
56
|
+
* newest by updatedAt, without preferring an empty thread over one with turns.
|
|
57
|
+
* System, ephemeral and subagent threads are never candidates. No loaded thread → null (no wake
|
|
48
58
|
* this cycle; the next wake retries, so a transient empty list never causes
|
|
49
59
|
* permanent deafness).
|
|
50
60
|
*/
|
|
51
61
|
export function pickFreshThread(threads: CodexThreadInfo[], opts: { cwd: string }): string | null {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
62
|
+
const rootThreads = threads.filter((thread) =>
|
|
63
|
+
!isCodexSubagentSource(thread.source) && thread.ephemeral !== true &&
|
|
64
|
+
(thread.threadSource === undefined || thread.threadSource === 'user'));
|
|
65
|
+
if (rootThreads.length === 0) return null;
|
|
66
|
+
if (rootThreads.length === 1) return rootThreads[0].id;
|
|
67
|
+
const cwdMatches = rootThreads.filter((t) => t.cwd === opts.cwd);
|
|
68
|
+
let pool = cwdMatches.length > 0 ? cwdMatches : rootThreads;
|
|
69
|
+
if (pool.some((thread) => (thread.turns?.length ?? 0) > 0)) {
|
|
70
|
+
pool = pool.filter((thread) => (thread.turns?.length ?? 0) > 0 || !!thread.preview);
|
|
71
|
+
}
|
|
56
72
|
let best = pool[0];
|
|
57
73
|
for (const t of pool) {
|
|
58
74
|
if ((t.updatedAt ?? 0) > (best.updatedAt ?? 0)) best = t;
|
|
@@ -60,43 +76,6 @@ export function pickFreshThread(threads: CodexThreadInfo[], opts: { cwd: string
|
|
|
60
76
|
return best.id;
|
|
61
77
|
}
|
|
62
78
|
|
|
63
|
-
/**
|
|
64
|
-
* Pure prune: drop wake-target entries whose app-server socket is positively
|
|
65
|
-
* dead (liveness === false), so the file self-heals. Keeps alive (true) and
|
|
66
|
-
* indeterminate (null) entries — false-deaf-avoidance, mirroring
|
|
67
|
-
* checkCodexBridgeHealthy's tri-state. Returns the surviving map + whether
|
|
68
|
-
* anything changed (so the caller writes only on change).
|
|
69
|
-
*/
|
|
70
|
-
export function pruneDeadWakeTargets<T extends { socketPath: string }>(
|
|
71
|
-
targets: Record<string, T>,
|
|
72
|
-
socketLiveness: (socketPath: string) => boolean | null
|
|
73
|
-
): { targets: Record<string, T>; changed: boolean } {
|
|
74
|
-
const out: Record<string, T> = {};
|
|
75
|
-
let changed = false;
|
|
76
|
-
for (const [key, t] of Object.entries(targets)) {
|
|
77
|
-
if (socketLiveness(t.socketPath) === false) {
|
|
78
|
-
changed = true;
|
|
79
|
-
continue;
|
|
80
|
-
}
|
|
81
|
-
out[key] = t;
|
|
82
|
-
}
|
|
83
|
-
return { targets: out, changed };
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Whether the freshly-resolved target differs from what's already recorded —
|
|
88
|
-
* so the self-healing cache write happens only on change (no file thrash on a
|
|
89
|
-
* busy cube re-resolving the same socket+thread every wake).
|
|
90
|
-
*/
|
|
91
|
-
export function wakeTargetChanged(
|
|
92
|
-
existing: { socketPath: string; threadId: string } | null,
|
|
93
|
-
fresh: { socketPath: string; threadId: string }
|
|
94
|
-
): boolean {
|
|
95
|
-
return (
|
|
96
|
-
!existing || existing.socketPath !== fresh.socketPath || existing.threadId !== fresh.threadId
|
|
97
|
-
);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
79
|
// ─── gh#857 Phase 2: durable retry + heartbeat (pure helpers) ───────────────
|
|
101
80
|
|
|
102
81
|
/** Base backoff for the first retry of a dropped/deferred wake. */
|
package/src/config-utils.ts
CHANGED
|
@@ -1293,8 +1293,8 @@ export function addCodexMcpServer(): void {
|
|
|
1293
1293
|
// Codex MCP config when the operator has explicitly set it.
|
|
1294
1294
|
const apiUrl = process.env.BORG_API_URL;
|
|
1295
1295
|
// Identity is durable configuration; remote wake is a per-launch
|
|
1296
|
-
// transport capability
|
|
1297
|
-
//
|
|
1296
|
+
// transport capability pinned by the Borg-owned app-server. Do not persist
|
|
1297
|
+
// that transport marker in the user's static Codex configuration.
|
|
1298
1298
|
const apiUrlEnvArg = apiUrl ? ` --env BORG_API_URL=${shellQuote(apiUrl)}` : '';
|
|
1299
1299
|
const stateRoot = process.env[BORG_STATE_ROOT_ENV];
|
|
1300
1300
|
const stateRootEnvArg = stateRoot
|
package/src/cubes.ts
CHANGED
|
@@ -18,7 +18,6 @@
|
|
|
18
18
|
import { existsSync } from 'node:fs';
|
|
19
19
|
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
|
|
20
20
|
import { dirname, join, resolve } from 'node:path';
|
|
21
|
-
import { pruneDeadWakeTargets } from './codex-wake-resolve.js';
|
|
22
21
|
import { borgConfigRoot } from './private-root.js';
|
|
23
22
|
import {
|
|
24
23
|
getActiveSeatCredential,
|
|
@@ -38,15 +37,14 @@ import {
|
|
|
38
37
|
// The unified 0600 seat store (seats.ts / seats.json) is now the SOLE home of the
|
|
39
38
|
// per-worktree ACTIVE-CUBE seat map: each seat = credential + worktree binding +
|
|
40
39
|
// display as ONE atomic record. cubes.json no longer holds a seat map — the
|
|
41
|
-
// functions below are thin adapters over seats.ts. Only launch.json
|
|
42
|
-
//
|
|
40
|
+
// functions below are thin adapters over seats.ts. Only launch.json and the
|
|
41
|
+
// inbox tree remain owned by this module.
|
|
43
42
|
|
|
44
43
|
/** Re-exported from seats.ts for call-site parity (the retired cross-store name). */
|
|
45
44
|
export type { SeatExpectation as ExpectedBinding } from './seats.js';
|
|
46
45
|
|
|
47
46
|
const CUBES_DIR = borgConfigRoot();
|
|
48
47
|
const LAUNCH_FILE = join(CUBES_DIR, 'launch.json');
|
|
49
|
-
const CODEX_WAKE_TARGETS_FILE = join(CUBES_DIR, 'codex-wake-targets.json');
|
|
50
48
|
const INBOX_DIR = join(CUBES_DIR, 'inboxes');
|
|
51
49
|
|
|
52
50
|
export type BorgCli = 'claude' | 'codex' | 'opencode';
|
|
@@ -206,16 +204,6 @@ function unreadableStateError(filePath: string): Error {
|
|
|
206
204
|
return new Error(`Borg state file is unreadable; refusing to overwrite it: ${filePath}`);
|
|
207
205
|
}
|
|
208
206
|
|
|
209
|
-
export interface CodexWakeTargetRecord {
|
|
210
|
-
threadId: string;
|
|
211
|
-
socketPath: string;
|
|
212
|
-
updatedAt: string;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
interface CodexWakeTargetsFile {
|
|
216
|
-
targets: Record<string, CodexWakeTargetRecord>;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
207
|
/**
|
|
220
208
|
* Walk up from cwd looking for a .git directory. If found, return that
|
|
221
209
|
* directory. If not found by filesystem root, return the original cwd.
|
|
@@ -369,44 +357,6 @@ async function writeLaunchFile(data: LaunchFile): Promise<void> {
|
|
|
369
357
|
await atomicWriteFile(LAUNCH_FILE, JSON.stringify(data, null, 2) + '\n');
|
|
370
358
|
}
|
|
371
359
|
|
|
372
|
-
function codexWakeTargetKey(cubeId: string, droneId: string): string {
|
|
373
|
-
if (!UUID_RE.test(cubeId)) throw new Error(`Invalid cubeId: ${cubeId}`);
|
|
374
|
-
if (!UUID_RE.test(droneId)) throw new Error(`Invalid droneId: ${droneId}`);
|
|
375
|
-
return `${cubeId}:${droneId}`;
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
function isCodexWakeTargetsFile(data: any): data is CodexWakeTargetsFile {
|
|
379
|
-
return (
|
|
380
|
-
data !== null &&
|
|
381
|
-
typeof data === 'object' &&
|
|
382
|
-
typeof data.targets === 'object' &&
|
|
383
|
-
data.targets !== null &&
|
|
384
|
-
!Array.isArray(data.targets)
|
|
385
|
-
);
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
async function readCodexWakeTargetsFile(): Promise<StateFileRead<CodexWakeTargetsFile>> {
|
|
389
|
-
let raw: string;
|
|
390
|
-
try {
|
|
391
|
-
raw = await readFile(CODEX_WAKE_TARGETS_FILE, 'utf8');
|
|
392
|
-
} catch (error: any) {
|
|
393
|
-
if (error?.code === 'ENOENT') return null;
|
|
394
|
-
throw error;
|
|
395
|
-
}
|
|
396
|
-
try {
|
|
397
|
-
const parsed = JSON.parse(raw);
|
|
398
|
-
return isCodexWakeTargetsFile(parsed) ? parsed : UNREADABLE_STATE;
|
|
399
|
-
} catch {
|
|
400
|
-
return UNREADABLE_STATE;
|
|
401
|
-
}
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
async function writeCodexWakeTargetsFile(data: CodexWakeTargetsFile): Promise<void> {
|
|
405
|
-
// atomicWriteFile handles the mkdir + 0o600 mode, and the temp+rename keeps
|
|
406
|
-
// a concurrent reader from seeing a half-written file (gh#894, gh#901).
|
|
407
|
-
await atomicWriteFile(CODEX_WAKE_TARGETS_FILE, JSON.stringify(data, null, 2) + '\n');
|
|
408
|
-
}
|
|
409
|
-
|
|
410
360
|
/**
|
|
411
361
|
* Get the currently-active cube for the current project, or null if not
|
|
412
362
|
* assimilated in this project. Entries written by older client versions
|
|
@@ -749,51 +699,3 @@ export async function setProjectCliPreference(cli: BorgCli, dir?: string): Promi
|
|
|
749
699
|
next.projects[findProjectRoot(dir)] = { cli };
|
|
750
700
|
await writeLaunchFile(next);
|
|
751
701
|
}
|
|
752
|
-
|
|
753
|
-
export async function setCodexWakeTarget(
|
|
754
|
-
cubeId: string,
|
|
755
|
-
droneId: string,
|
|
756
|
-
target: Omit<CodexWakeTargetRecord, 'updatedAt'>
|
|
757
|
-
): Promise<void> {
|
|
758
|
-
const existing = await readCodexWakeTargetsFile();
|
|
759
|
-
if (existing === UNREADABLE_STATE) throw unreadableStateError(CODEX_WAKE_TARGETS_FILE);
|
|
760
|
-
const next = existing ?? { targets: {} };
|
|
761
|
-
next.targets[codexWakeTargetKey(cubeId, droneId)] = {
|
|
762
|
-
...target,
|
|
763
|
-
updatedAt: new Date().toISOString(),
|
|
764
|
-
};
|
|
765
|
-
await writeCodexWakeTargetsFile(next);
|
|
766
|
-
}
|
|
767
|
-
|
|
768
|
-
export async function getCodexWakeTarget(
|
|
769
|
-
cubeId: string,
|
|
770
|
-
droneId: string
|
|
771
|
-
): Promise<CodexWakeTargetRecord | null> {
|
|
772
|
-
const existing = await readCodexWakeTargetsFile();
|
|
773
|
-
if (existing === UNREADABLE_STATE) throw unreadableStateError(CODEX_WAKE_TARGETS_FILE);
|
|
774
|
-
if (!existing) return null;
|
|
775
|
-
const target = existing.targets[codexWakeTargetKey(cubeId, droneId)];
|
|
776
|
-
if (!target || typeof target.threadId !== 'string' || typeof target.socketPath !== 'string') {
|
|
777
|
-
return null;
|
|
778
|
-
}
|
|
779
|
-
return target;
|
|
780
|
-
}
|
|
781
|
-
|
|
782
|
-
/**
|
|
783
|
-
* gh#855: drop wake-target entries whose app-server socket is positively dead,
|
|
784
|
-
* so the file self-heals (stale dead-socket entries from crashed prior launches
|
|
785
|
-
* don't linger and mislead probeCodexBridgeArmed / health-beat). Pure prune
|
|
786
|
-
* decision lives in codex-wake-resolve.ts (false-deaf-avoidance: keeps alive +
|
|
787
|
-
* indeterminate); this is the thin read → prune → write-only-on-change glue.
|
|
788
|
-
* The liveness check is injected (claude.ts wires checkCodexBridgeHealthy) so
|
|
789
|
-
* cubes.ts stays free of the codex-remote dependency.
|
|
790
|
-
*/
|
|
791
|
-
export async function pruneDeadCodexWakeTargets(
|
|
792
|
-
socketLiveness: (socketPath: string) => boolean | null
|
|
793
|
-
): Promise<void> {
|
|
794
|
-
const existing = await readCodexWakeTargetsFile();
|
|
795
|
-
if (existing === UNREADABLE_STATE) throw unreadableStateError(CODEX_WAKE_TARGETS_FILE);
|
|
796
|
-
if (!existing) return;
|
|
797
|
-
const { targets, changed } = pruneDeadWakeTargets(existing.targets, socketLiveness);
|
|
798
|
-
if (changed) await writeCodexWakeTargetsFile({ ...existing, targets });
|
|
799
|
-
}
|