borgmcp 4.2.2 → 4.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.
@@ -1,4 +1,4 @@
1
- import { probeCodexBridgeArmed } from './codex-app-wake.js';
1
+ import { probeCodexBridgeArmed, getCodexDeliveryState, codexWakePathHealthy, } from './codex-app-wake.js';
2
2
  import { checkInboxMonitorHealthy } from './stream-status.js';
3
3
  import { getOpenCodeConnectionState, } from './opencode-drone.js';
4
4
  export function openCodeWakePathHealthy(state) {
@@ -33,11 +33,18 @@ export async function inspectWakePath(inputs, deps = {}) {
33
33
  };
34
34
  }
35
35
  if (inputs.agentKind === 'codex') {
36
+ // client#89: fold the delivery state into health so a deferred, retrying,
37
+ // or failed injection surfaces as degraded — never as armed/healthy — even
38
+ // while the app-server socket is alive. SSE health is not the discriminator.
36
39
  const probe = deps.probeCodex ?? probeCodexBridgeArmed;
40
+ const getDelivery = deps.getCodexDelivery ?? getCodexDeliveryState;
41
+ const armed = await probe(inputs.active);
42
+ const codex = getDelivery();
37
43
  return {
38
44
  agentKind: inputs.agentKind,
39
- healthy: await probe(inputs.active),
45
+ healthy: codexWakePathHealthy(armed, codex),
40
46
  openCode: null,
47
+ codex,
41
48
  };
42
49
  }
43
50
  const getState = deps.getOpenCodeState ?? getOpenCodeConnectionState;
@@ -1 +1 @@
1
- {"version":3,"file":"wake-path-health.js","sourceRoot":"","sources":["../src/wake-path-health.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAC5D,OAAO,EAAE,wBAAwB,EAAE,MAAM,oBAAoB,CAAC;AAC9D,OAAO,EACL,0BAA0B,GAE3B,MAAM,qBAAqB,CAAC;AAsB7B,MAAM,UAAU,uBAAuB,CACrC,KAA8B;IAE9B,IAAI,CAAC,KAAK,CAAC,SAAS;QAAE,OAAO,KAAK,CAAC;IACnC,IACE,KAAK,CAAC,cAAc,CAAC,MAAM,GAAG,CAAC;QAC/B,KAAK,CAAC,cAAc,CAAC,uBAAuB,CAAC,GAAG,CAAC,EACjD,CAAC;QACD,OAAO,KAAK,CAAC;IACf,CAAC;IACD,IACE,KAAK,CAAC,cAAc,CAAC,MAAM,GAAG,CAAC;QAC/B,KAAK,CAAC,cAAc,CAAC,OAAO,GAAG,CAAC,EAChC,CAAC;QACD,OAAO,IAAI,CAAC;IACd,CAAC;IACD,IAAI,KAAK,CAAC,SAAS,KAAK,IAAI;QAAE,OAAO,IAAI,CAAC;IAC1C,OAAO,IAAI,CAAC;AACd,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,eAAe,CACnC,MAA6B,EAC7B,OAA4B,EAAE;IAE9B,IAAI,CAAC,MAAM,CAAC,MAAM,EAAE,CAAC;QACnB,OAAO;YACL,SAAS,EAAE,MAAM,CAAC,SAAS;YAC3B,OAAO,EAAE,IAAI;YACb,QAAQ,EAAE,IAAI;SACf,CAAC;IACJ,CAAC;IAED,IAAI,MAAM,CAAC,SAAS,KAAK,QAAQ,EAAE,CAAC;QAClC,MAAM,KAAK,GAAG,IAAI,CAAC,kBAAkB,IAAI,wBAAwB,CAAC;QAClE,OAAO;YACL,SAAS,EAAE,MAAM,CAAC,SAAS;YAC3B,OAAO,EAAE,KAAK,CAAC,MAAM,CAAC,SAAS,EAAE,MAAM,CAAC,gBAAgB,CAAC;YACzD,QAAQ,EAAE,IAAI;SACf,CAAC;IACJ,CAAC;IAED,IAAI,MAAM,CAAC,SAAS,KAAK,OAAO,EAAE,CAAC;QACjC,MAAM,KAAK,GAAG,IAAI,CAAC,UAAU,IAAI,qBAAqB,CAAC;QACvD,OAAO;YACL,SAAS,EAAE,MAAM,CAAC,SAAS;YAC3B,OAAO,EAAE,MAAM,KAAK,CAAC,MAAM,CAAC,MAAM,CAAC;YACnC,QAAQ,EAAE,IAAI;SACf,CAAC;IACJ,CAAC;IAED,MAAM,QAAQ,GAAG,IAAI,CAAC,gBAAgB,IAAI,0BAA0B,CAAC;IACrE,MAAM,QAAQ,GAAG,QAAQ,EAAE,CAAC;IAC5B,OAAO;QACL,SAAS,EAAE,MAAM,CAAC,SAAS;QAC3B,OAAO,EAAE,uBAAuB,CAAC,QAAQ,CAAC;QAC1C,QAAQ;KACT,CAAC;AACJ,CAAC"}
1
+ {"version":3,"file":"wake-path-health.js","sourceRoot":"","sources":["../src/wake-path-health.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,qBAAqB,EACrB,qBAAqB,EACrB,oBAAoB,GAErB,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EAAE,wBAAwB,EAAE,MAAM,oBAAoB,CAAC;AAC9D,OAAO,EACL,0BAA0B,GAE3B,MAAM,qBAAqB,CAAC;AAyB7B,MAAM,UAAU,uBAAuB,CACrC,KAA8B;IAE9B,IAAI,CAAC,KAAK,CAAC,SAAS;QAAE,OAAO,KAAK,CAAC;IACnC,IACE,KAAK,CAAC,cAAc,CAAC,MAAM,GAAG,CAAC;QAC/B,KAAK,CAAC,cAAc,CAAC,uBAAuB,CAAC,GAAG,CAAC,EACjD,CAAC;QACD,OAAO,KAAK,CAAC;IACf,CAAC;IACD,IACE,KAAK,CAAC,cAAc,CAAC,MAAM,GAAG,CAAC;QAC/B,KAAK,CAAC,cAAc,CAAC,OAAO,GAAG,CAAC,EAChC,CAAC;QACD,OAAO,IAAI,CAAC;IACd,CAAC;IACD,IAAI,KAAK,CAAC,SAAS,KAAK,IAAI;QAAE,OAAO,IAAI,CAAC;IAC1C,OAAO,IAAI,CAAC;AACd,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,eAAe,CACnC,MAA6B,EAC7B,OAA4B,EAAE;IAE9B,IAAI,CAAC,MAAM,CAAC,MAAM,EAAE,CAAC;QACnB,OAAO;YACL,SAAS,EAAE,MAAM,CAAC,SAAS;YAC3B,OAAO,EAAE,IAAI;YACb,QAAQ,EAAE,IAAI;SACf,CAAC;IACJ,CAAC;IAED,IAAI,MAAM,CAAC,SAAS,KAAK,QAAQ,EAAE,CAAC;QAClC,MAAM,KAAK,GAAG,IAAI,CAAC,kBAAkB,IAAI,wBAAwB,CAAC;QAClE,OAAO;YACL,SAAS,EAAE,MAAM,CAAC,SAAS;YAC3B,OAAO,EAAE,KAAK,CAAC,MAAM,CAAC,SAAS,EAAE,MAAM,CAAC,gBAAgB,CAAC;YACzD,QAAQ,EAAE,IAAI;SACf,CAAC;IACJ,CAAC;IAED,IAAI,MAAM,CAAC,SAAS,KAAK,OAAO,EAAE,CAAC;QACjC,0EAA0E;QAC1E,2EAA2E;QAC3E,6EAA6E;QAC7E,MAAM,KAAK,GAAG,IAAI,CAAC,UAAU,IAAI,qBAAqB,CAAC;QACvD,MAAM,WAAW,GAAG,IAAI,CAAC,gBAAgB,IAAI,qBAAqB,CAAC;QACnE,MAAM,KAAK,GAAG,MAAM,KAAK,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;QACzC,MAAM,KAAK,GAAG,WAAW,EAAE,CAAC;QAC5B,OAAO;YACL,SAAS,EAAE,MAAM,CAAC,SAAS;YAC3B,OAAO,EAAE,oBAAoB,CAAC,KAAK,EAAE,KAAK,CAAC;YAC3C,QAAQ,EAAE,IAAI;YACd,KAAK;SACN,CAAC;IACJ,CAAC;IAED,MAAM,QAAQ,GAAG,IAAI,CAAC,gBAAgB,IAAI,0BAA0B,CAAC;IACrE,MAAM,QAAQ,GAAG,QAAQ,EAAE,CAAC;IAC5B,OAAO;QACL,SAAS,EAAE,MAAM,CAAC,SAAS;QAC3B,OAAO,EAAE,uBAAuB,CAAC,QAAQ,CAAC;QAC1C,QAAQ;KACT,CAAC;AACJ,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "borgmcp",
3
- "version": "4.2.2",
3
+ "version": "4.3.0",
4
4
  "description": "Coordinate AI coding agents in shared cubes. Works with Claude Code, Codex, and OpenCode.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -123,8 +123,116 @@ export function getLastDeliveredAt(): number | null {
123
123
  return lastDeliveredAt;
124
124
  }
125
125
 
126
+ // client#89: delivery-state observability. The Codex wake-path health surface
127
+ // must distinguish "bridge armed" from "delivery healthy" — a wake deferred
128
+ // (mid-turn) or failed and being retried is NOT a healthy wake path, even
129
+ // though the app-server socket is alive. These module-scoped fields track the
130
+ // last injection attempt/result and the last failure (a secret-free error
131
+ // code/class only — never message contents); the deferred-queue state is read
132
+ // live from the existing retry-drain fields. Same process as the health probe
133
+ // (the wake path and stream-status run in the same MCP-client child), so the
134
+ // snapshot is directly visible. NONE of this changes the wake mechanism.
135
+ type CodexInjectionResult = 'delivered' | 'deferred' | 'failed';
136
+ // HISTORICAL reporting — the last injection attempt/result/failure. These are
137
+ // surfaced on the status surface for diagnosis; they are NOT used to decide
138
+ // health, because a historical result does not self-clear when work is drained
139
+ // by another path (manual read, server read-cursor recovery). Health keys off
140
+ // the LIVE signals below.
141
+ let lastInjectionAt: number | null = null;
142
+ let lastInjectionResult: CodexInjectionResult | null = null;
143
+ let lastInjectionFailureCode: string | null = null;
144
+ let lastTargetThreadId: string | null = null;
145
+ // client#89: a LIVE marker for undelivered directed wakes NOT tracked by the
146
+ // retry-drain queue (retryDrainActive / deferredEntryCount cover that queue and
147
+ // self-clear on prune/deliver). SET at every injection exit where an
148
+ // authoritatively-pending wake could not be delivered and is not queued: the
149
+ // heartbeat mid-turn skip / no-target return / transient failure, the per-entry
150
+ // no-target return for a still-pending scoped entry, and the retry-drain age-out
151
+ // hand-off. CLEARED when any delivery lands (markDelivered) and when the
152
+ // heartbeat authoritatively finds no pending work. It is a LIVE signal, not a
153
+ // historical result, so a seat that recovers by any path returns to healthy.
154
+ let deliveryDeferred = false;
155
+
156
+ export interface CodexDeliveryState {
157
+ /** Opaque thread id of the last-resolved wake target (never a socket path). */
158
+ lastTargetThreadId: string | null;
159
+ /** HISTORICAL last attempt time (reporting only; not a health input). */
160
+ lastInjectionAt: number | null;
161
+ /** HISTORICAL last attempt result (reporting only; not a health input). */
162
+ lastInjectionResult: CodexInjectionResult | null;
163
+ /** Secret-free error code/class of the last failed injection; never contents. */
164
+ lastInjectionFailureCode: string | null;
165
+ /** LIVE: entries currently deferred/retrying (not yet confirmed delivered). */
166
+ deferredEntryCount: number;
167
+ /** LIVE: a coalesced retry-drain loop is currently retrying deferred/missed wakes. */
168
+ retryDrainActive: boolean;
169
+ /** LIVE: an undelivered directed wake not tracked by the retry-drain queue. */
170
+ deliveryDeferred: boolean;
171
+ lastDeliveredAt: number | null;
172
+ }
173
+
174
+ /** Snapshot of the Codex wake-path delivery state for the health/status surface. */
175
+ export function getCodexDeliveryState(): CodexDeliveryState {
176
+ return {
177
+ lastTargetThreadId,
178
+ lastInjectionAt,
179
+ lastInjectionResult,
180
+ lastInjectionFailureCode,
181
+ deferredEntryCount: retryDrainSourceEntryIds.size + (retryDrainHasUnscopedWork ? 1 : 0),
182
+ retryDrainActive: retryDrainInFlight,
183
+ deliveryDeferred,
184
+ lastDeliveredAt,
185
+ };
186
+ }
187
+
188
+ /** Reduce a caught error to a secret-free code/class — never its message. */
189
+ function injectionFailureCode(err: unknown): string {
190
+ const code = (err as { code?: unknown } | null)?.code;
191
+ if (typeof code === 'string' && code.length > 0) return code;
192
+ if (err instanceof Error && err.name) return err.name;
193
+ return 'unknown';
194
+ }
195
+
196
+ function recordInjectionResult(
197
+ result: CodexInjectionResult,
198
+ now: () => number,
199
+ failureCode?: string,
200
+ ): void {
201
+ lastInjectionAt = now();
202
+ lastInjectionResult = result;
203
+ lastInjectionFailureCode = result === 'failed' ? (failureCode ?? 'unknown') : null;
204
+ }
205
+
206
+ /**
207
+ * client#89: pure wake-path health for Codex, folding the LIVE delivery state
208
+ * into the raw "bridge armed" probe. A wake still pending redelivery — a live
209
+ * retry-drain, a non-empty deferred queue, or an undelivered heartbeat pending
210
+ * — is NOT a confirmed-healthy path (returns null = degraded). A positively-
211
+ * dead bridge dominates (false). Historical last-attempt results are NOT used
212
+ * here: they do not self-clear when work is drained by another path, so keying
213
+ * health off them would leave a recovered seat permanently degraded.
214
+ */
215
+ export function codexWakePathHealthy(
216
+ armed: boolean | null,
217
+ state: CodexDeliveryState,
218
+ ): boolean | null {
219
+ if (armed === false) return false; // positively-dead bridge
220
+ if (armed === null) return null; // could not probe → indeterminate
221
+ // Bridge armed. Degraded while any LIVE signal shows an unconfirmed delivery.
222
+ if (
223
+ state.retryDrainActive ||
224
+ state.deferredEntryCount > 0 ||
225
+ state.deliveryDeferred
226
+ ) {
227
+ return null;
228
+ }
229
+ return true;
230
+ }
231
+
126
232
  function markDelivered(deps: CodexWakeDeps): void {
127
233
  lastDeliveredAt = (deps.now ?? Date.now)();
234
+ // client#89: any confirmed delivery clears the live heartbeat-pending marker.
235
+ deliveryDeferred = false;
128
236
  }
129
237
 
130
238
  // gh#857 WI-2: a single-in-flight guard for the heartbeat tick (mirrors
@@ -327,8 +435,18 @@ async function wakeCodexTargeted(
327
435
  // gh#855: resolve FRESH (live env socket + re-resolved thread), falling back
328
436
  // to the launch-recorded file only when the env socket is absent.
329
437
  const resolved = await resolveFreshCodexWakeTarget(active, deps);
330
- if (!resolved) return;
438
+ 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.
445
+ if (sourceEntryId) deliveryDeferred = true;
446
+ return;
447
+ }
331
448
  const { socketPath, threadId } = resolved;
449
+ lastTargetThreadId = threadId; // client#89: record the selected target
332
450
  const wakeKey = `${threadId}\0${deliveryIdentity ?? reason}`;
333
451
  if (deliveredWakeKeys.has(wakeKey)) return; // dedup before opening the wake socket
334
452
  const client = makeCodexClient(socketPath, deps);
@@ -340,21 +458,24 @@ async function wakeCodexTargeted(
340
458
  // now. Schedule the retry-drain (coalesced, retried-until-delivered) so
341
459
  // the burst's entries are drained once the thread goes idle; codex has no
342
460
  // on-disk tail fallback like Claude's borg-inbox-monitor.
461
+ recordInjectionResult('deferred', deps.now ?? Date.now); // client#89
343
462
  scheduleRetryDrain(deps, sourceEntryId);
344
463
  return;
345
464
  }
346
465
  if (sourceEntryId && !(await pendingEntry(active, sourceEntryId))) return;
347
466
  await client.startTurn(threadId, reason);
348
467
  rememberDeliveredWake(wakeKey);
468
+ recordInjectionResult('delivered', deps.now ?? Date.now); // client#89
349
469
  markDelivered(deps);
350
470
  } finally {
351
471
  client.close();
352
472
  }
353
- } catch {
473
+ } catch (err) {
354
474
  // gh#857: a transient connect/read/startTurn failure must NOT be silently
355
475
  // swallowed (the old best-effort drop let a single blip lose an entry).
356
476
  // Schedule the retry-drain so the wake is retried-until-delivered; the SSE
357
477
  // stream is never broken (this is fire-and-forget).
478
+ recordInjectionResult('failed', deps.now ?? Date.now, injectionFailureCode(err)); // client#89
358
479
  scheduleRetryDrain(deps, sourceEntryId);
359
480
  } finally {
360
481
  releaseInjectLock();
@@ -414,11 +535,13 @@ async function runRetryDrainLoop(deps: CodexWakeDeps): Promise<void> {
414
535
  const resolved = await resolveFreshCodexWakeTarget(active, deps);
415
536
  if (!resolved) continue; // thread not loaded yet → retry (age-capped)
416
537
  const { socketPath, threadId } = resolved;
538
+ lastTargetThreadId = threadId; // client#89: record the selected target
417
539
  const client = makeCodexClient(socketPath, deps);
418
540
  await client.connect();
419
541
  try {
420
542
  const thread = await client.readThread(threadId);
421
543
  if (thread?.status?.type === 'active') {
544
+ recordInjectionResult('deferred', now); // client#89
422
545
  continue; // re-defer: still mid-turn (backoff before next poll)
423
546
  }
424
547
  for (const entryId of retryDrainSourceEntryIds) {
@@ -428,19 +551,31 @@ async function runRetryDrainLoop(deps: CodexWakeDeps): Promise<void> {
428
551
  await client.startTurn(threadId, CODEX_CATCHUP_PROMPT);
429
552
  retryDrainSourceEntryIds.clear();
430
553
  retryDrainHasUnscopedWork = false;
554
+ recordInjectionResult('delivered', now); // client#89
431
555
  markDelivered(deps);
432
556
  return; // drain delivered → server read-cursor drains all unread → done
433
557
  } finally {
434
558
  client.close();
435
559
  }
436
- } catch {
560
+ } catch (err) {
437
561
  // transient socket/read error must not abort the loop — keep retrying with
438
562
  // backoff until reachable+idle or the age cap; never throws into SSE.
563
+ recordInjectionResult('failed', now, injectionFailureCode(err)); // client#89
439
564
  } finally {
440
565
  releaseInjectLock();
441
566
  }
442
567
  }
443
568
  // aged out: the gh#857 WI-2 periodic heartbeat is the ultimate backstop.
569
+ // client#89: if obligations remain unfinished (the thread stayed mid-turn
570
+ // through the age cap), the loop is gone but the entries are still pending.
571
+ // Hand off to the live marker and clear the retry-drain set, so health stays
572
+ // degraded (not stuck via a stale deferredEntryCount after the loop exits)
573
+ // and clears when a delivery lands or the unread authoritatively empties.
574
+ if (retryDrainSourceEntryIds.size > 0 || retryDrainHasUnscopedWork) {
575
+ retryDrainSourceEntryIds.clear();
576
+ retryDrainHasUnscopedWork = false;
577
+ deliveryDeferred = true;
578
+ }
444
579
  }
445
580
 
446
581
  /**
@@ -484,16 +619,41 @@ export async function fireCodexHeartbeatTick(
484
619
  // authoritative unread state without advancing its cursor; only then touch
485
620
  // the app-server socket or resolve a thread.
486
621
  const hasPendingWork = deps.hasPendingWork ?? hasPendingWakeActivity;
487
- if (!(await hasPendingWork(active))) return;
622
+ if (!(await hasPendingWork(active))) {
623
+ // client#89: authoritative unread is empty → no undelivered wake remains.
624
+ // Clear the live heartbeat-pending marker so a seat that recovered by any
625
+ // path (manual read, server read-cursor drain) returns to healthy.
626
+ deliveryDeferred = false;
627
+ return;
628
+ }
488
629
  const resolved = await resolveFreshCodexWakeTarget(active, deps);
489
- if (!resolved) return; // thread not loaded yet → next tick retries
630
+ if (!resolved) {
631
+ // client#89: authoritative pending work exists but no fresh target/thread
632
+ // resolves — the wake is UNDELIVERABLE this tick. The persisted-target
633
+ // probe can still read armed, so mark the deferral live (health degraded)
634
+ // until a target resolves and delivers, or the unread authoritatively
635
+ // clears. Cadence/retry behavior is unchanged (the next tick still tries).
636
+ deliveryDeferred = true;
637
+ return; // thread not loaded yet → next tick retries
638
+ }
639
+ lastTargetThreadId = resolved.threadId; // client#89: record the selected target
490
640
  const client = makeCodexClient(resolved.socketPath, deps);
491
641
  await client.connect();
492
642
  try {
493
643
  const thread = await client.readThread(resolved.threadId);
494
- if (thread?.status?.type === 'active') return; // mid-turn → skip; next tick retries
644
+ if (thread?.status?.type === 'active') {
645
+ // client#89: we passed hasPendingWork above, so a mid-turn thread here
646
+ // means a directed entry is deferred. Mark it LIVE-pending (the heartbeat
647
+ // does not queue into the retry-drain) so the health surface reads
648
+ // degraded until a delivery lands or the unread authoritatively clears.
649
+ // Skip semantics are unchanged — the next tick still retries.
650
+ recordInjectionResult('deferred', deps.now ?? Date.now);
651
+ deliveryDeferred = true;
652
+ return; // mid-turn → skip; next tick retries
653
+ }
495
654
  await client.startTurn(resolved.threadId, CODEX_CATCHUP_PROMPT);
496
- // markDelivered updates local heartbeat-gating state only.
655
+ // markDelivered clears the live heartbeat-pending marker.
656
+ recordInjectionResult('delivered', deps.now ?? Date.now); // client#89
497
657
  markDelivered(deps);
498
658
  } finally {
499
659
  client.close();
@@ -503,6 +663,11 @@ export async function fireCodexHeartbeatTick(
503
663
  // path is gone; signal teardown so the timer stops ticking against a dead
504
664
  // socket (re-armed when an active cube returns). Other (transient) errors are
505
665
  // best-effort skips — never break the SSE stream; next tick retries.
666
+ // client#89: we passed hasPendingWork, so a failure here leaves pending work
667
+ // undelivered → mark it live-pending (a dead bridge is separately false via
668
+ // the armed probe). Clears on the next authoritative-empty tick or delivery.
669
+ recordInjectionResult('failed', deps.now ?? Date.now, injectionFailureCode(err)); // client#89
670
+ deliveryDeferred = true;
506
671
  if (isAppServerDeadError(err)) deps.onAppServerSocketDead?.();
507
672
  } finally {
508
673
  heartbeatInFlight = false;
@@ -552,6 +717,12 @@ export function resetCodexWakeForTests(): void {
552
717
  lastDeliveredAt = null;
553
718
  heartbeatInFlight = false;
554
719
  injectInFlight = false;
720
+ // client#89 delivery-state observability
721
+ lastInjectionAt = null;
722
+ lastInjectionResult = null;
723
+ lastInjectionFailureCode = null;
724
+ lastTargetThreadId = null;
725
+ deliveryDeferred = false;
555
726
  }
556
727
 
557
728
  function rememberDeliveredWake(key: string): void {
package/src/index.ts CHANGED
@@ -38,6 +38,7 @@ import {
38
38
  regen,
39
39
  listCubes,
40
40
  createCube,
41
+ normalizeExplicitRepository,
41
42
  updateCube,
42
43
  deleteCube,
43
44
  createRole,
@@ -1243,19 +1244,37 @@ export async function main() {
1243
1244
  }
1244
1245
  }
1245
1246
 
1247
+ // client#499: the cube binds to an EXPLICIT repository (no cwd
1248
+ // inference). Normalize the argument client-side and fail with a
1249
+ // clear message when it is absent/malformed — no working-directory
1250
+ // fallback.
1251
+ const { repository, workingRepoName } = normalizeExplicitRepository(
1252
+ args?.repository,
1253
+ args?.working_repo_name,
1254
+ );
1255
+
1246
1256
  // Sprint 14: template cube_directive fills empty operator input.
1247
1257
  // Operator-supplied text takes precedence — templates fill
1248
1258
  // the blank, never stomp.
1249
1259
  const resolvedCubeDirective = resolveCubeDirectiveForCreate(cubeDirective, template);
1250
-
1251
- // v0.9.2: createCube now returns the flat shape directly
1252
- // (see remote-client unwrap). `cube.id` / `cube.name` work
1253
- // verbatim on the returned object.
1254
1260
  const resolvedMessageTaxonomy = resolveMessageTaxonomyForCreate(undefined, template);
1255
- const cube = await createCube(name, resolvedCubeDirective, {
1261
+ const { result, cube } = await createCube(name, resolvedCubeDirective, {
1256
1262
  message_taxonomy: resolvedMessageTaxonomy,
1263
+ repository,
1264
+ workingRepoName,
1257
1265
  });
1258
1266
 
1267
+ // client#499: the server homed this repository to an EXISTING cube —
1268
+ // report it honestly, apply no template, and leave its directive
1269
+ // untouched.
1270
+ if (result === 'resolved') {
1271
+ const text = `A cube already exists for this repository: **${cube.name}** (id: ${cube.id}). The local Borg server homes one cube per repository; its directive was left unchanged. Use borg_assimilate ${cube.name} to join it, or borg_update-cube to change its settings.`;
1272
+ return {
1273
+ content: [{ type: 'text', text }],
1274
+ structuredContent: { cube, result: 'resolved', template: null, roles_created: null, roles_updated: null },
1275
+ };
1276
+ }
1277
+
1259
1278
  // Apply template roles if requested. Merges by name: any role the
1260
1279
  // server auto-seeded (e.g. "Drone") that the template doesn't
1261
1280
  // also include stays put; templated roles upsert.
@@ -1269,6 +1288,7 @@ export async function main() {
1269
1288
  content: [{ type: 'text', text }],
1270
1289
  structuredContent: {
1271
1290
  cube,
1291
+ result: 'created',
1272
1292
  template: templateName,
1273
1293
  roles_created: summary.created,
1274
1294
  roles_updated: summary.updated,
@@ -1278,7 +1298,7 @@ export async function main() {
1278
1298
  const text = `Created cube **${cube.name}** (id: ${cube.id}). A default "Drone" role was seeded — rename or replace it via borg_update-role / borg_create-role / borg_delete-role. Use borg_assimilate ${cube.name} to join as a drone.`;
1279
1299
  return {
1280
1300
  content: [{ type: 'text', text }],
1281
- structuredContent: { cube, template: null, roles_created: null, roles_updated: null },
1301
+ structuredContent: { cube, result: 'created', template: null, roles_created: null, roles_updated: null },
1282
1302
  };
1283
1303
  }
1284
1304
 
@@ -51,11 +51,15 @@ import {
51
51
  type EvictDroneResult,
52
52
  type ReassignDroneResult,
53
53
  type RoleRationaleResult,
54
+ decodeCreateCubeResponse,
54
55
  type PutDocumentResult,
55
56
  type GetDocumentResult,
56
57
  type ListDocumentsResult,
57
58
  type RemoveDocumentResult,
59
+ type CreateCubeRepository,
58
60
  } from 'borgmcp-shared/protocol';
61
+ import { Buffer } from 'node:buffer';
62
+ import { canonicalizeWorkingRepoIdentity } from './working-repo.js';
59
63
  import { consolePrefix } from './console-prefix.js';
60
64
  import { debugLog } from './debug.js';
61
65
  import { assertUuidShape } from './evict-drone.js';
@@ -1526,31 +1530,116 @@ export async function listCubes(connection?: RemoteConnection): Promise<{ cubes:
1526
1530
  * orchestrator pick a default role without a follow-up `getCube` call.
1527
1531
  * Existing callers that read `body.cube` keep working (forward-compat).
1528
1532
  */
1533
+ const REPOSITORY_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
1534
+ // The wire's working_repo_name rule (borgmcp-shared decodeWorkingRepositoryName,
1535
+ // which the package does not export): 1-120 UTF-8 bytes, must start with a
1536
+ // letter or digit, then letters/digits/spaces/dots/underscores/hyphens.
1537
+ const WORKING_REPO_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9 ._-]*$/;
1538
+
1539
+ function assertValidWorkingRepoName(name: string): string {
1540
+ const bytes = Buffer.byteLength(name, 'utf8');
1541
+ if (bytes < 1 || bytes > 120 || !WORKING_REPO_NAME_RE.test(name)) {
1542
+ throw new Error(
1543
+ 'working_repo_name must start with a letter or digit and contain only letters, digits, spaces, dots, underscores, or hyphens (1-120 UTF-8 bytes).',
1544
+ );
1545
+ }
1546
+ return name;
1547
+ }
1548
+
1549
+ export interface NormalizedCreateCubeRepository {
1550
+ repository: CreateCubeRepository;
1551
+ workingRepoName: string;
1552
+ }
1553
+
1554
+ /**
1555
+ * client#499: normalize the EXPLICIT repository argument into the wire's
1556
+ * `{ repository, working_repo_name }` pair — no cwd inference. A canonical git
1557
+ * remote URL becomes an `origin` identity (reusing the shared canonicalizer,
1558
+ * the same encoding the CLI create path uses); a UUID becomes a `local`
1559
+ * identity (the server requires a UUID for local repositories). The optional
1560
+ * working-repo display name defaults to the origin's repository segment.
1561
+ */
1562
+ export function normalizeExplicitRepository(
1563
+ repositoryArg: unknown,
1564
+ workingRepoNameArg?: unknown,
1565
+ ): NormalizedCreateCubeRepository {
1566
+ if (typeof repositoryArg !== 'string' || repositoryArg.trim().length === 0) {
1567
+ throw new Error(
1568
+ 'repository is required: pass a canonical git remote URL (e.g. https://github.com/owner/repo) or a UUID identifying a local repository.',
1569
+ );
1570
+ }
1571
+ // client#499 CR: a PRESENT working_repo_name must be a string; reject a
1572
+ // present non-string rather than silently coercing it to the derived name.
1573
+ if (workingRepoNameArg !== undefined && workingRepoNameArg !== null && typeof workingRepoNameArg !== 'string') {
1574
+ throw new Error('working_repo_name must be a string when provided.');
1575
+ }
1576
+ const repoInput = repositoryArg.trim();
1577
+ const nameArg = typeof workingRepoNameArg === 'string' ? workingRepoNameArg.trim() : '';
1578
+
1579
+ const canonical = canonicalizeWorkingRepoIdentity(repoInput);
1580
+ if (canonical?.origin && canonical.name) {
1581
+ const derivedName = canonical.name.split('/').pop() || canonical.name;
1582
+ // Validate the FINAL name (explicit or derived) against the wire rule
1583
+ // before any network use, for both repository kinds — fail closed.
1584
+ return {
1585
+ repository: { kind: 'origin', value: canonical.origin },
1586
+ workingRepoName: assertValidWorkingRepoName(nameArg || derivedName),
1587
+ };
1588
+ }
1589
+ if (REPOSITORY_UUID_RE.test(repoInput)) {
1590
+ if (!nameArg) {
1591
+ throw new Error(
1592
+ 'working_repo_name is required when repository is a local UUID — there is no origin URL to derive a name from.',
1593
+ );
1594
+ }
1595
+ return { repository: { kind: 'local', value: repoInput }, workingRepoName: assertValidWorkingRepoName(nameArg) };
1596
+ }
1597
+ throw new Error(
1598
+ 'repository must be a canonical git remote URL (e.g. https://github.com/owner/repo) or a UUID identifying a local repository.',
1599
+ );
1600
+ }
1601
+
1529
1602
  export async function createCube(
1530
1603
  name: string | undefined,
1531
1604
  cubeDirective: string,
1532
- opts?: { template?: string; message_taxonomy?: MessageTaxonomy | null },
1605
+ opts?: {
1606
+ template?: string;
1607
+ message_taxonomy?: MessageTaxonomy | null;
1608
+ // client#499: the explicit repository binding (no cwd inference). Required.
1609
+ repository?: CreateCubeRepository;
1610
+ workingRepoName?: string;
1611
+ },
1533
1612
  connection?: RemoteConnection,
1534
- ): Promise<{ id: string; name: string; cube_directive?: string; roles: any[]; drones?: any[]; [k: string]: any }> {
1613
+ ): Promise<{ result: 'created' | 'resolved'; cube: { id: string; name: string; cube_directive?: string; roles: any[]; drones?: any[]; [k: string]: any } }> {
1535
1614
  if (!name?.trim()) throw new Error('Local Borg server cube creation requires a cube name');
1536
1615
  if (opts?.template !== undefined && opts.template !== 'default') {
1537
1616
  throw new Error('Local Borg server supports only the default cube seed');
1538
1617
  }
1618
+ if (!opts?.repository || !opts?.workingRepoName) {
1619
+ throw new Error('Local Borg server cube creation requires an explicit repository identity');
1620
+ }
1539
1621
  const resolved = await localOwnerConnection(connection);
1540
- const created = await localConnectionMutation<{
1541
- cube_id: string;
1542
- human_seat_role_id: string;
1543
- default_worker_role_id: string;
1544
- }>(resolved, '/api/cubes', 'POST', {
1622
+ // client#499 CR: strictly decode the response against the shared
1623
+ // CreateCubeResponse contract — `result` MUST be 'created' or 'resolved'. A
1624
+ // missing/unknown result FAILS CLOSED (throws) rather than falling through to
1625
+ // 'created' and PATCHing an existing cube's directive.
1626
+ const created = decodeCreateCubeResponse(await localConnectionMutation<unknown>(resolved, '/api/cubes', 'POST', {
1545
1627
  retry_key: randomUUID(),
1546
1628
  name: name.trim(),
1629
+ working_repo_name: opts.workingRepoName,
1630
+ repository: opts.repository,
1547
1631
  template: 'default',
1548
- });
1549
- if (!created?.cube_id) throw new Error('Local Borg server returned an invalid cube creation response');
1632
+ }));
1633
+ // client#499: the server homes one cube per repository. A 'resolved' result
1634
+ // means this repository already has a cube — report it honestly and DO NOT
1635
+ // PATCH its directive over the existing settings (the round-1 stomp defect).
1636
+ if (created.result === 'resolved') {
1637
+ return { result: 'resolved', cube: await getCube(created.cube_id, resolved) };
1638
+ }
1550
1639
  const patch: Record<string, unknown> = { cube_directive: cubeDirective };
1551
1640
  if (opts?.message_taxonomy !== undefined) patch.message_taxonomy = opts.message_taxonomy;
1552
1641
  await localConnectionMutation(resolved, `/api/cubes/${created.cube_id}`, 'PATCH', patch);
1553
- return getCube(created.cube_id, resolved);
1642
+ return { result: 'created', cube: await getCube(created.cube_id, resolved) };
1554
1643
  }
1555
1644
 
1556
1645
  /**
@@ -277,6 +277,20 @@ export function renderStreamStatus(inputs: RenderInputs): string {
277
277
  );
278
278
  }
279
279
 
280
+ // client#89: Codex remote-control delivery state — distinct from SSE health.
281
+ // Surfaces the selected target, last bounded injection attempt/result,
282
+ // deferred-queue state, and last failure (a secret-free code/class only).
283
+ if (wakePath.agentKind === 'codex' && wakePath.codex) {
284
+ const d = wakePath.codex;
285
+ lines.push(`- **Codex wake target thread**: ${d.lastTargetThreadId ?? '_(none resolved yet)_'}`);
286
+ lines.push(`- **Codex last injection**: ${d.lastInjectionResult ?? '_(none yet)_'}${d.lastInjectionAt ? ` at ${new Date(d.lastInjectionAt).toISOString()}` : ''}`);
287
+ lines.push(`- **Codex deferred/retrying entries**: ${d.deferredEntryCount}${d.retryDrainActive ? ' (retry-drain active)' : ''}${d.deliveryDeferred ? ' — undelivered wake pending' : ''}`);
288
+ lines.push(`- **Codex last failure code**: ${d.lastInjectionFailureCode ?? '_(none)_'}`);
289
+ lines.push(
290
+ '- **Codex delivery-state meaning**: a deferred or retrying wake means a directed entry has not yet been confirmed delivered to the model — the wake path reads degraded (not healthy) until the wake is delivered or the unread log is drained, even though the app-server bridge is armed.'
291
+ );
292
+ }
293
+
280
294
  // Runtime-specific wake-path warning. The wire-down case takes
281
295
  // precedence above; an indeterminate signal remains honest and silent.
282
296
  if (status.connected && wakePathHealthy === false) {
@@ -452,7 +452,7 @@ const BASE_TOOL_MANIFEST: ToolManifestEntry[] = [
452
452
  {
453
453
  name: 'borg_create-cube',
454
454
  description:
455
- 'Create a new cube. The server seeds a default "Drone" role atomically so the cube is assimilatable immediately. ' +
455
+ 'Create a new cube bound to an explicit repository. The server homes ONE cube per repository: if the given repository already has a cube, this reports that existing cube and leaves its directive unchanged (it never overwrites it). The server seeds a default "Drone" role atomically so a newly-created cube is assimilatable immediately. ' +
456
456
  'Pass an optional `template` name to apply a richer role set instead (see borg_list-templates / borg_apply-template).',
457
457
  inputSchema: {
458
458
  type: 'object',
@@ -464,12 +464,22 @@ const BASE_TOOL_MANIFEST: ToolManifestEntry[] = [
464
464
  maxLength: 120,
465
465
  },
466
466
  cube_directive: { type: 'string', description: 'Project-specific Markdown shown to every drone when it refreshes cube context.' },
467
+ repository: {
468
+ type: 'string',
469
+ description: 'The repository this cube binds to (explicit — not inferred from the working directory). Pass a canonical git remote URL (e.g. https://github.com/owner/repo) for a hosted repository, or a UUID identifying a local (no-remote) repository. The cube is homed to this repository; if it already has one, that existing cube is reported and its directive is left unchanged.',
470
+ },
471
+ working_repo_name: {
472
+ type: 'string',
473
+ description: 'Optional short display name for the repository (starts with a letter or digit; letters, digits, spaces, dots, underscores, or hyphens; max 120 bytes). Defaults to the repository segment of the URL; required when `repository` is a local UUID.',
474
+ pattern: '^[A-Za-z0-9][A-Za-z0-9 ._-]*$',
475
+ maxLength: 120,
476
+ },
467
477
  template: {
468
478
  type: 'string',
469
- description: 'Optional template name to apply after cube creation (e.g. "software-dev"). Roles are merged by name; the default Drone role gets overwritten by the template if a same-named role is in the template.',
479
+ description: 'Optional template name to apply after cube creation (e.g. "software-dev"). Roles are merged by name; the default Drone role gets overwritten by the template if a same-named role is in the template. Only applied when a cube is newly created — never to an already-existing repository cube.',
470
480
  },
471
481
  },
472
- required: ['name', 'cube_directive'],
482
+ required: ['name', 'cube_directive', 'repository'],
473
483
  },
474
484
  },
475
485
  {
@@ -1063,11 +1073,14 @@ export const TOOL_OUTPUT_SCHEMAS: Record<string, OutputSchema> = {
1063
1073
  type: 'object',
1064
1074
  properties: {
1065
1075
  cube: CUBE_OUTPUT,
1076
+ // client#499: 'created' = a new cube; 'resolved' = the repository already
1077
+ // had a cube (reported, directive left unchanged).
1078
+ result: { type: 'string', enum: ['created', 'resolved'] },
1066
1079
  template: { type: ['string', 'null'] },
1067
1080
  roles_created: { type: ['number', 'null'] },
1068
1081
  roles_updated: { type: ['number', 'null'] },
1069
1082
  },
1070
- required: ['cube', 'template'],
1083
+ required: ['cube', 'result', 'template'],
1071
1084
  },
1072
1085
  'borg_update-cube': {
1073
1086
  type: 'object',
@@ -1,4 +1,9 @@
1
- import { probeCodexBridgeArmed } from './codex-app-wake.js';
1
+ import {
2
+ probeCodexBridgeArmed,
3
+ getCodexDeliveryState,
4
+ codexWakePathHealthy,
5
+ type CodexDeliveryState,
6
+ } from './codex-app-wake.js';
2
7
  import { checkInboxMonitorHealthy } from './stream-status.js';
3
8
  import {
4
9
  getOpenCodeConnectionState,
@@ -10,6 +15,8 @@ export interface WakePathSnapshot {
10
15
  agentKind: AgentKind;
11
16
  healthy: boolean | null;
12
17
  openCode: OpenCodeConnectionState | null;
18
+ // client#89: Codex remote-control delivery state, distinct from SSE health.
19
+ codex?: CodexDeliveryState | null;
13
20
  }
14
21
 
15
22
  interface InspectWakePathInputs {
@@ -22,6 +29,7 @@ interface InspectWakePathInputs {
22
29
  interface InspectWakePathDeps {
23
30
  checkClaudeMonitor?: typeof checkInboxMonitorHealthy;
24
31
  probeCodex?: typeof probeCodexBridgeArmed;
32
+ getCodexDelivery?: typeof getCodexDeliveryState;
25
33
  getOpenCodeState?: typeof getOpenCodeConnectionState;
26
34
  }
27
35
 
@@ -67,11 +75,18 @@ export async function inspectWakePath(
67
75
  }
68
76
 
69
77
  if (inputs.agentKind === 'codex') {
78
+ // client#89: fold the delivery state into health so a deferred, retrying,
79
+ // or failed injection surfaces as degraded — never as armed/healthy — even
80
+ // while the app-server socket is alive. SSE health is not the discriminator.
70
81
  const probe = deps.probeCodex ?? probeCodexBridgeArmed;
82
+ const getDelivery = deps.getCodexDelivery ?? getCodexDeliveryState;
83
+ const armed = await probe(inputs.active);
84
+ const codex = getDelivery();
71
85
  return {
72
86
  agentKind: inputs.agentKind,
73
- healthy: await probe(inputs.active),
87
+ healthy: codexWakePathHealthy(armed, codex),
74
88
  openCode: null,
89
+ codex,
75
90
  };
76
91
  }
77
92