borgmcp 3.11.1 → 3.12.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 (38) hide show
  1. package/THIRD_PARTY_NOTICES.md +5 -0
  2. package/dist/assimilate-deps.d.ts.map +1 -1
  3. package/dist/assimilate-deps.js +11 -0
  4. package/dist/assimilate-deps.js.map +1 -1
  5. package/dist/codex-app-wake.d.ts +3 -2
  6. package/dist/codex-app-wake.d.ts.map +1 -1
  7. package/dist/codex-app-wake.js +42 -9
  8. package/dist/codex-app-wake.js.map +1 -1
  9. package/dist/cube-activity-wake-copy.d.ts +1 -1
  10. package/dist/cube-activity-wake-copy.d.ts.map +1 -1
  11. package/dist/cube-activity-wake-copy.js +1 -1
  12. package/dist/cube-activity-wake-copy.js.map +1 -1
  13. package/dist/log-stream.d.ts +3 -3
  14. package/dist/log-stream.d.ts.map +1 -1
  15. package/dist/log-stream.js +8 -13
  16. package/dist/log-stream.js.map +1 -1
  17. package/dist/opencode-drone.d.ts +1 -1
  18. package/dist/opencode-drone.d.ts.map +1 -1
  19. package/dist/opencode-drone.js +16 -2
  20. package/dist/opencode-drone.js.map +1 -1
  21. package/dist/remote-client.d.ts +2 -21
  22. package/dist/remote-client.d.ts.map +1 -1
  23. package/dist/remote-client.js +8 -6
  24. package/dist/remote-client.js.map +1 -1
  25. package/dist/server-handshake.d.ts +6 -1
  26. package/dist/server-handshake.d.ts.map +1 -1
  27. package/dist/server-handshake.js +1 -0
  28. package/dist/server-handshake.js.map +1 -1
  29. package/docs/LOCAL_SERVER.md +4 -8
  30. package/docs/RELEASING.md +32 -308
  31. package/package.json +3 -2
  32. package/src/assimilate-deps.ts +11 -0
  33. package/src/codex-app-wake.ts +42 -8
  34. package/src/cube-activity-wake-copy.ts +1 -1
  35. package/src/log-stream.ts +24 -15
  36. package/src/opencode-drone.ts +14 -1
  37. package/src/remote-client.ts +11 -25
  38. package/src/server-handshake.ts +4 -1
package/src/log-stream.ts CHANGED
@@ -125,12 +125,14 @@ function isProcessAlive(pid: number): boolean {
125
125
  * Used by defaultDeps when no explicit injectOpenCode is supplied.
126
126
  */
127
127
  let _moduleInjectOpenCode:
128
- | ((text: string, entryId: string, allowSubmit: boolean, sourceEntryId?: string) => Promise<boolean>)
128
+ | ((text: string, entryId: string, allowSubmit: boolean, sourceEntryId?: string,
129
+ isSourcePending?: () => Promise<boolean>) => Promise<boolean>)
129
130
  | undefined;
130
131
  let _moduleSettleOpenCodeEntry: ((sourceEntryId: string) => void) | undefined;
131
132
 
132
133
  export function setModuleInjectOpenCode(
133
- fn: (text: string, entryId: string, allowSubmit: boolean, sourceEntryId?: string) => Promise<boolean>,
134
+ fn: (text: string, entryId: string, allowSubmit: boolean, sourceEntryId?: string,
135
+ isSourcePending?: () => Promise<boolean>) => Promise<boolean>,
134
136
  settle: (sourceEntryId: string) => void,
135
137
  ): void {
136
138
  _moduleInjectOpenCode = fn;
@@ -392,7 +394,7 @@ export interface StreamDeps {
392
394
  renderedLine: string
393
395
  ) => Promise<boolean>;
394
396
  /** Optional Codex app-server wake sink; tests inject a spy. */
395
- wakeCodex?: (reason: string, deliveryIdentity?: string) => void;
397
+ wakeCodex?: (reason: string, deliveryIdentity?: string, sourceEntryId?: string) => void;
396
398
  /** Override the heartbeat watchdog timeout. */
397
399
  heartbeatTimeoutMs?: number;
398
400
  /** Override HWM divergence grace for focused tests. */
@@ -409,6 +411,7 @@ export interface StreamDeps {
409
411
  entryId: string,
410
412
  allowSubmit: boolean,
411
413
  sourceEntryId?: string,
414
+ isSourcePending?: () => Promise<boolean>,
412
415
  ) => Promise<boolean>;
413
416
  /** Inspect whether one retry source remains beyond the durable unread cursor. */
414
417
  hasPendingWakeEntry?: (active: ActiveCube, entryId: string) => Promise<boolean>;
@@ -422,16 +425,16 @@ const defaultDeps: Required<StreamDeps> = {
422
425
  getCursor: getLocalServerCursor,
423
426
  appendLine: defaultAppendLine,
424
427
  hasInboxEntryId: defaultHasInboxEntryId,
425
- wakeCodex: (reason, deliveryIdentity) =>
426
- wakeCodexViaAppServer(reason, process.env, {}, deliveryIdentity),
428
+ wakeCodex: (reason, deliveryIdentity, sourceEntryId) =>
429
+ wakeCodexViaAppServer(reason, process.env, {}, deliveryIdentity, sourceEntryId),
427
430
  heartbeatTimeoutMs: HEARTBEAT_TIMEOUT_MS,
428
431
  hwmDivergenceGraceMs: HWM_DIVERGENCE_GRACE_MS,
429
432
  abortSignal: new AbortController().signal,
430
433
  ownerDeps: {},
431
434
  ownerStaleMs: 70_000,
432
- injectOpenCode: (text, entryId, allowSubmit, sourceEntryId) =>
435
+ injectOpenCode: (text, entryId, allowSubmit, sourceEntryId, isSourcePending) =>
433
436
  _moduleInjectOpenCode
434
- ? _moduleInjectOpenCode(text, entryId, allowSubmit, sourceEntryId)
437
+ ? _moduleInjectOpenCode(text, entryId, allowSubmit, sourceEntryId, isSourcePending)
435
438
  : Promise.resolve(false),
436
439
  hasPendingWakeEntry: (active, entryId) => hasPendingDurableWakeEntry(
437
440
  active as Parameters<typeof hasPendingDurableWakeEntry>[0],
@@ -902,7 +905,10 @@ export async function streamOnce(
902
905
  // OpenCode queue. A still-unread re-ping may reconcile the prior attempt;
903
906
  // its source entry ID prevents a new nonce from resubmitting that prompt.
904
907
  if (isReping) {
905
- await injectOpenCode(formatOpenCodeWakePrompt(line), deliveryId, true, ev.id);
908
+ await injectOpenCode(
909
+ formatOpenCodeWakePrompt(line), deliveryId, true, ev.id,
910
+ () => hasPendingWakeEntry(active, ev.id),
911
+ );
906
912
  }
907
913
  markEventPersisted(ev.id, ev.data?.created_at ?? '');
908
914
  return 'persisted-skip';
@@ -910,12 +916,12 @@ export async function streamOnce(
910
916
  // The inbox append is the durable record. OpenCode injection is only the
911
917
  // wake attempt and may return before the agent finishes processing.
912
918
  await appendLine(active.cubeId, active.droneId, line);
913
- const openCodeDelivered = isReping
914
- ? await injectOpenCode(formatOpenCodeWakePrompt(line), deliveryId, true, ev.id)
915
- : await injectOpenCode(formatOpenCodeWakePrompt(line), deliveryId, true);
919
+ const openCodeDelivered = await injectOpenCode(
920
+ formatOpenCodeWakePrompt(line), deliveryId, true, ev.id,
921
+ () => hasPendingWakeEntry(active, ev.id),
922
+ );
916
923
  if (!openCodeDelivered) {
917
- if (ev.wake_nonce === undefined) wakeCodex(formatCodexWakePrompt(line));
918
- else wakeCodex(formatCodexWakePrompt(line), ev.wake_nonce);
924
+ wakeCodex(formatCodexWakePrompt(line), ev.wake_nonce, ev.id);
919
925
  }
920
926
  return 'written';
921
927
  };
@@ -1234,9 +1240,12 @@ export async function streamOnce(
1234
1240
  if (
1235
1241
  wakeNonce !== undefined &&
1236
1242
  await shouldDeliverWakeRetry(event.id) &&
1237
- !(await injectOpenCode(formatOpenCodeWakePrompt(line), wakeNonce, true, event.id))
1243
+ !(await injectOpenCode(
1244
+ formatOpenCodeWakePrompt(line), wakeNonce, true, event.id,
1245
+ () => hasPendingWakeEntry(active, event.id),
1246
+ ))
1238
1247
  ) {
1239
- wakeCodex(formatCodexWakePrompt(line), wakeNonce);
1248
+ wakeCodex(formatCodexWakePrompt(line), wakeNonce, event.id);
1240
1249
  }
1241
1250
  continue;
1242
1251
  }
@@ -89,6 +89,7 @@ interface OpenCodeDelivery {
89
89
  acceptedSubmission: boolean;
90
90
  sessionId: string | null;
91
91
  settled: boolean;
92
+ isSourcePending?: () => Promise<boolean>;
92
93
  state: Exclude<OpenCodeDeliveryState, 'failed'>;
93
94
  resolve: (delivered: boolean) => void;
94
95
  promise: Promise<boolean>;
@@ -628,6 +629,14 @@ async function deliverOpenCodeEntry(
628
629
  ): Promise<OpenCodeDeliveryOutcome> {
629
630
  let target: OCSession | null = null;
630
631
 
632
+ const sourcePending = async (): Promise<boolean> => {
633
+ if (!delivery.isSourcePending) return true;
634
+ if (await delivery.isSourcePending()) return true;
635
+ delivery.settled = true;
636
+ settleOpenCodeEntry(delivery.sourceEntryId);
637
+ return false;
638
+ };
639
+
631
640
  // Before the one allowed POST, retries are safe: no submission has happened.
632
641
  // OpenCode must generate the message ID: its run loop treats IDs as
633
642
  // lexicographically ordered, so arbitrary caller IDs can persist without
@@ -636,6 +645,7 @@ async function deliverOpenCodeEntry(
636
645
  for (let attempt = 0; attempt < OPEN_CODE_DELIVERY_RETRY_DELAYS_MS.length; attempt++) {
637
646
  if (delivery.settled) return 'delivered';
638
647
  if (state !== owner || !owner.connected) return 'failed';
648
+ if (!(await sourcePending())) return 'delivered';
639
649
  if (attempt > 0) {
640
650
  delivery.state = 'retried';
641
651
  owner.totalEntriesRetried++;
@@ -688,6 +698,7 @@ async function deliverOpenCodeEntry(
688
698
  delivery.state = 'delivered-unconfirmed';
689
699
  } else {
690
700
  if (delivery.settled) return 'delivered';
701
+ if (!(await sourcePending())) return 'delivered';
691
702
  owner.pendingSubmissions.set(delivery.entryId, {
692
703
  sourceEntryId: delivery.sourceEntryId,
693
704
  sessionId: target.id,
@@ -864,6 +875,7 @@ export function injectOpenCodeEntry(
864
875
  entryId: string = createHash('sha256').update(text).digest('hex'),
865
876
  allowSubmit: boolean = true,
866
877
  sourceEntryId: string = entryId,
878
+ isSourcePending?: () => Promise<boolean>,
867
879
  ): Promise<boolean> {
868
880
  const owner = state;
869
881
  if (!owner?.connected) {
@@ -882,7 +894,7 @@ export function injectOpenCodeEntry(
882
894
  );
883
895
  if (pendingSource) {
884
896
  log(`entry ${entryId} reconciles pending source ${sourceEntryId}`);
885
- return injectOpenCodeEntry(text, pendingSource[0], false, sourceEntryId);
897
+ return injectOpenCodeEntry(text, pendingSource[0], false, sourceEntryId, isSourcePending);
886
898
  }
887
899
  for (const [deliveredEntryId, record] of owner.deliveredEntries) {
888
900
  if (deliveredEntryId !== entryId && record.sourceEntryId === sourceEntryId) {
@@ -967,6 +979,7 @@ export function injectOpenCodeEntry(
967
979
  acceptedSubmission: false,
968
980
  sessionId: null,
969
981
  settled: false,
982
+ isSourcePending,
970
983
  state: 'queued',
971
984
  resolve: resolveDelivery,
972
985
  promise,
@@ -16,6 +16,7 @@ import {
16
16
  import { randomUUID } from 'node:crypto';
17
17
  import {
18
18
  createProtocolEnvelope,
19
+ decodeAppendLogResult,
19
20
  decodeDeleteCubeResponse,
20
21
  decodeDeleteRoleRequest,
21
22
  decodeDeleteRoleResult,
@@ -90,7 +91,7 @@ const UNREAD_CURSOR_MAX_TRANSPORT_RETRIES = 1;
90
91
  // Replay is opt-in: most local requests are mutations or have ambiguous
91
92
  // delivery, while unread-log reads carry an explicit cursor and are safe to
92
93
  // repeat with the exact same request body.
93
- type AuthedFetchRetryMode = 'unread-cursor';
94
+ type AuthedFetchRetryMode = 'unread-cursor' | 'append-log';
94
95
  export const LOCAL_SERVER_RESPONSE_LIMIT_BYTES = 32 * 1024 * 1024;
95
96
  // A typed auth-error envelope is tiny; anything larger is hostile and the
96
97
  // bounded read throws → the 401 fails closed to non-destructive CREDENTIAL_REJECTED.
@@ -790,17 +791,17 @@ async function authedFetch(
790
791
  return res;
791
792
  };
792
793
 
793
- let transportRetriesRemaining = retryMode === 'unread-cursor'
794
+ let transportRetriesRemaining = retryMode === 'unread-cursor' || retryMode === 'append-log'
794
795
  ? UNREAD_CURSOR_MAX_TRANSPORT_RETRIES
795
796
  : 0;
796
797
  const requestWithRetry = async (): Promise<Response> => {
797
798
  try {
798
799
  return await buildRequest(token);
799
800
  } catch (error) {
800
- if (retryMode !== 'unread-cursor' || !isConnectionReset(error)) throw error;
801
+ if ((retryMode !== 'unread-cursor' && retryMode !== 'append-log') || !isConnectionReset(error)) throw error;
801
802
  if (transportRetriesRemaining === 0) throw unreadLogTransportFailure(error);
802
803
  transportRetriesRemaining -= 1;
803
- debugLog('↻ retrying unread log read after ECONNRESET');
804
+ debugLog(`↻ retrying ${retryMode === 'append-log' ? 'log append' : 'unread log read'} after ECONNRESET`);
804
805
  try {
805
806
  return await buildRequest(token);
806
807
  } catch (retryError) {
@@ -1334,31 +1335,14 @@ export async function appendLog(
1334
1335
  to?: string[];
1335
1336
  serverTrustIdentity?: string;
1336
1337
  } = {}
1337
- ): Promise<{
1338
- entry: {
1339
- id: string;
1340
- cube_id: string;
1341
- drone_id: string;
1342
- message: string;
1343
- visibility: 'broadcast' | 'direct';
1344
- created_at: string;
1345
- };
1346
- routing?: {
1347
- class: string | null;
1348
- recipients: string[];
1349
- fellOpen: boolean;
1350
- message: string | null;
1351
- } | null;
1352
- // gh#534: directed recipients currently unreachable via the wake path
1353
- // (wake-path:deaf). Empty/absent for broadcast or all-reachable sends.
1354
- unreachableRecipients?: { id: string; label: string }[];
1355
- }> {
1338
+ ): Promise<ReturnType<typeof decodeAppendLogResult>> {
1356
1339
  if (opts.visibility === 'broadcast' && (opts.to?.length ?? 0) > 0) {
1357
1340
  throw new Error(
1358
1341
  "Invalid input: visibility:'broadcast' cannot be combined with non-empty to:. " +
1359
1342
  'Remove visibility to direct to recipients, or remove to: to broadcast.',
1360
1343
  );
1361
1344
  }
1345
+ const postId = randomUUID();
1362
1346
  const local = await localAuthorityContext(
1363
1347
  sessionToken,
1364
1348
  apiUrl,
@@ -1386,11 +1370,12 @@ export async function appendLog(
1386
1370
  } else if (visibility === undefined && recipientDroneIds !== undefined) {
1387
1371
  visibility = 'direct';
1388
1372
  }
1389
- const payload = await localServerRequest<{ entry: any }>(
1373
+ const payload = await localServerRequest<ReturnType<typeof decodeAppendLogResult>>(
1390
1374
  local,
1391
1375
  `/api/cubes/${local.cubeId}/logs`,
1392
1376
  'POST',
1393
1377
  {
1378
+ post_id: postId,
1394
1379
  message,
1395
1380
  ...(visibility ? { visibility } : {}),
1396
1381
  ...(visibility === 'direct' && recipientDroneIds
@@ -1401,9 +1386,10 @@ export async function appendLog(
1401
1386
  // visibility/recipients override it (server resolveMessageRouting).
1402
1387
  ...(opts.class ? { class: opts.class } : {}),
1403
1388
  },
1389
+ { retryMode: 'append-log', decodePayload: decodeAppendLogResult },
1404
1390
  );
1405
1391
  if (!payload) throw new Error('Local Borg server returned an empty log response');
1406
- return { entry: payload.entry };
1392
+ return payload;
1407
1393
  }
1408
1394
 
1409
1395
  /**
@@ -211,10 +211,11 @@ export interface ServerAttachResult {
211
211
  sessionId: string;
212
212
  };
213
213
  result: 'created' | 'reused';
214
+ initial_log_cursor: { id: string; created_at: string } | null;
214
215
  }
215
216
 
216
217
  /**
217
- * Attach an enrolled client principal to one granted cube/role over protocol v8.
218
+ * Attach an enrolled client principal to one granted cube/role over protocol v9.
218
219
  * The client CSPRNG-generates the session bearer and persists it PENDING in the
219
220
  * OS keychain (keyed by the stable per-seat identity) BEFORE this request, so an
220
221
  * interrupted/lost response is recovered by re-sending the exact same bearer —
@@ -237,6 +238,7 @@ export interface PreparedServerAttach {
237
238
  drone: ServerAttachResult['drone'];
238
239
  session: { sessionId: string };
239
240
  result: 'created' | 'reused';
241
+ initialLogCursor: ServerAttachResult['initial_log_cursor'];
240
242
  credentialRef: string;
241
243
  pendingBearerDigest: string;
242
244
  /** The single-store ATOMIC activate+bind (CR#2 collapse): given the worktree
@@ -391,6 +393,7 @@ export async function sendBorgServerAttach(
391
393
  sessionId: decoded.session.id,
392
394
  },
393
395
  result: decoded.result,
396
+ initialLogCursor: decoded.initial_log_cursor,
394
397
  credentialRef,
395
398
  pendingBearerDigest,
396
399
  // The single-store ATOMIC activate+bind — invoked by FINALIZE with the decided