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,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: the wake target (socket + thread) was
5
- * resolved once at launch and never refreshed, so a missed/stale launch probe
6
- * left the drone permanently deaf. Phase 1 makes the waking borg-mcp child
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, or null (un-upgraded launch). */
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 always deterministic. No loaded thread null (no wake
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
- if (threads.length === 0) return null;
53
- if (threads.length === 1) return threads[0].id;
54
- const cwdMatches = threads.filter((t) => t.cwd === opts.cwd);
55
- const pool = cwdMatches.length > 0 ? cwdMatches : threads;
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. */
@@ -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. Do not persist a transport marker here: a future
1297
- // Codex child may launch without a live --remote socket.
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
- // codex-wake-targets.json, and the inbox tree remain owned by this module.
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
- }
package/src/log-stream.ts CHANGED
@@ -906,10 +906,13 @@ export async function streamOnce(
906
906
  // OpenCode queue. A still-unread re-ping may reconcile the prior attempt;
907
907
  // its source entry ID prevents a new nonce from resubmitting that prompt.
908
908
  if (isReping) {
909
- await injectOpenCode(
909
+ const openCodeDelivered = await injectOpenCode(
910
910
  formatOpenCodeWakePrompt(line), deliveryId, true, ev.id,
911
911
  () => hasPendingWakeEntry(active, ev.id),
912
912
  );
913
+ if (!openCodeDelivered) {
914
+ wakeCodex(formatCodexWakePrompt(line), ev.wake_nonce, ev.id);
915
+ }
913
916
  }
914
917
  markEventPersisted(ev.id, ev.data?.created_at ?? '');
915
918
  return 'persisted-skip';