borgmcp 2.14.0 → 2.14.2

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.
@@ -22,7 +22,7 @@ The extraction copied the monorepo's `client/src/` production boundary and top-l
22
22
  recovery, `2.0.2`, `2.0.3`, `2.0.4`, `2.0.5`, and `2.0.6` successors were
23
23
  published and registry-verified. The immutable `2.0.7` workflow failed before
24
24
  package creation or npm publication. The `2.0.8`, `2.0.9`, `2.0.10`, `2.0.11`,
25
- `2.1.0`, and `2.1.1` successors were published, and `2.2.0` was subsequently published, and `2.3.0` was subsequently published, and `2.4.0` was subsequently published, and `2.4.1` was subsequently published, and `2.5.0` was subsequently published, and `2.6.0` was subsequently published, and `2.6.1` was subsequently published, and `2.7.0` was subsequently published, and `2.7.1` was subsequently published, and `2.7.2` was subsequently published, and `2.7.3` was subsequently published, and `2.8.0` was subsequently published, and `2.9.0` was subsequently published, and `2.10.0` was subsequently published, and the immutable `2.10.1` release attempt failed before artifact creation or publication. The `2.10.2`, `2.11.0`, `2.12.1`, and `2.13.0` successors were published; the immutable `2.12.0` attempt failed before artifact creation or publication and was superseded by `2.12.1`. The next candidate identity is `2.14.0`. Extraction and
25
+ `2.1.0`, and `2.1.1` successors were published, and `2.2.0` was subsequently published, and `2.3.0` was subsequently published, and `2.4.0` was subsequently published, and `2.4.1` was subsequently published, and `2.5.0` was subsequently published, and `2.6.0` was subsequently published, and `2.6.1` was subsequently published, and `2.7.0` was subsequently published, and `2.7.1` was subsequently published, and `2.7.2` was subsequently published, and `2.7.3` was subsequently published, and `2.8.0` was subsequently published, and `2.9.0` was subsequently published, and `2.10.0` was subsequently published, and the immutable `2.10.1` release attempt failed before artifact creation or publication. The `2.10.2`, `2.11.0`, `2.12.1`, and `2.13.0` successors were published; the immutable `2.12.0` attempt failed before artifact creation or publication and was superseded by `2.12.1`. The current release identity is `2.14.2`. Extraction and
26
26
  versioning do not authorize publication.
27
27
 
28
28
  ## Review Holds
@@ -47,7 +47,6 @@ client `borgmcp@2.10.0` line. Its annotated tag object
47
47
  `sha512-lOxIPg3WcjSBte46iTM7SAFVN6Y0b2oizOKAJ9Q1EijBdOlmsh/cQlpUIaa8F2UJPaTDFsXy3M64gpGs6XgKzA==`
48
48
  in the `borgmcp-server-0.9.0-release` registry decision. That server and its
49
49
  client line pin historical `borgmcp-shared` version `0.8.1` and remain
50
- immutable. Client `borgmcp@2.10.2` is published. The immutable `v2.0.7`
51
- attempt failed before publication and remains preserved. Publication of the next
52
- candidate remains gated by reviewed `v2.14.0` source, a fresh annotated tag, and
50
+ immutable. Client `borgmcp@2.14.1` is published. The immutable `v2.0.7`
51
+ attempt failed before publication and remains preserved. The current release identity and publication gate remain governed by the reviewed `v2.14.2` source, a fresh annotated tag, and
53
52
  the exact-artifact and protected-publication gates.
package/docs/RELEASING.md CHANGED
@@ -205,8 +205,18 @@ Never move, replace, reuse, or rerun that tag or workflow. The annotated `v2.13.
205
205
  `fc5e4d9299210b70f979c7d39130a9c12ea265ea`. Workflow run `30894287863`, attempt 1,
206
206
  successfully published that exact source as `borgmcp@2.13.0`; the same-run artifact report records integrity
207
207
  `sha512-YuF3NwY5iBRVKhgdRiK+3GX+6O8O7S/V9g+XYN/1A0so9RLIYoQWpS91qJewqGQ59nyIARjRmk2wKhK8ODxU0Q==`.
208
+ Never move, replace, reuse, or rerun that tag or workflow. The annotated `v2.14.0` tag object
209
+ `eb9d5cb14b08b1a2eb6d48b2492acbc6cecb7bef` peels to protected-main commit
210
+ `4dd92bcbac26f460a4f137f766992e8f93843576`. Workflow run `30929322201`, attempt 1,
211
+ successfully published that exact source as `borgmcp@2.14.0`; the same-run artifact report records integrity
212
+ `sha512-haKgpQbcbaRya1XqYQaOy7yZiHMxeP8QO3fr7ahHaYDNYrDvlGmXNlW9VMtqnRJ9mtVDC5UTkeuQmCRJMzF/Tg==`.
213
+ Never move, replace, reuse, or rerun that tag or workflow. The annotated `v2.14.1` tag object
214
+ `c1c8b526dcc31259f6b821f0a6fdd0017a42435c` peels to protected-main commit
215
+ `3de4cbf2e5adf6883334fe4e9b7aa1f43868daae`. Workflow run `30943397747`, attempt 1,
216
+ successfully published that exact source as `borgmcp@2.14.1`; the same-run artifact report records integrity
217
+ `sha512-VqkOp3jo/KnzgusCk98TFj/U8U7Q/gt8agvIbjiSWrV0lIHnsD+A+7w3C+GMQeyA08KXxRuZDF3No9sDomqMTQ==`.
208
218
  Never move, replace, reuse, or rerun that tag or workflow. The next candidate
209
- uses the unused `v2.14.0` identity from a fresh reviewed protected-main commit
219
+ uses the unused `v2.14.2` identity from a fresh reviewed protected-main commit
210
220
  and requires the complete release gate again.
211
221
 
212
222
  ## Release Prerequisites
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "borgmcp",
3
- "version": "2.14.0",
3
+ "version": "2.14.2",
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",
@@ -1060,7 +1060,7 @@ export async function runAssimilate(
1060
1060
  }
1061
1061
  if (error instanceof RepositoryAssociationOperationError) {
1062
1062
  const recovery = error.failure === 'repository-already-associated'
1063
- ? 'This repository is already associated with another cube. Run the same command again to use the existing managed association, or ask the server operator to correct the repository binding.'
1063
+ ? 'This repository is already associated with another cube. The selected cube grant is valid, but it cannot replace that existing repository binding. Run the same command again to use the existing managed association, or ask the server operator to correct the repository binding.'
1064
1064
  : error.failure === 'cube-already-associated'
1065
1065
  ? 'The selected cube is already associated with another repository. Choose a different cube, or run the command from the repository already linked to that cube.'
1066
1066
  : error.failure === 'access-denied'
@@ -42,6 +42,12 @@ const ENTRY_LINE_RE =
42
42
  /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\S*)\s+(\S+)\s+\(([^)]+)\):\s*(.*)$/;
43
43
 
44
44
  export const RECENT_EMITTED_LINE_CAP = 1024;
45
+ /**
46
+ * Allow a small amount of server/host clock skew at monitor arm. Lines older
47
+ * than this boundary are historical materialized inbox content, not live
48
+ * activity for this monitor instance.
49
+ */
50
+ export const INBOX_ARM_TIMESTAMP_SKEW_MS = 5_000;
45
51
 
46
52
  export class RecentLineDeduper {
47
53
  private readonly seen = new Set<string>();
@@ -86,13 +92,35 @@ export function formatEventLine(inboxLine: string): string | null {
86
92
  return `${label} (${role}): ${summary}`;
87
93
  }
88
94
 
95
+ /**
96
+ * Return whether an inbox entry is fresh enough to have been produced after
97
+ * this monitor armed. A malformed timestamp fails closed when this guard is
98
+ * active; callers without an arm boundary retain the legacy parser behavior.
99
+ */
100
+ export function isInboxLineAtOrAfterArm(
101
+ inboxLine: string,
102
+ armTimeMs: number,
103
+ skewMs = INBOX_ARM_TIMESTAMP_SKEW_MS,
104
+ ): boolean {
105
+ const match = ENTRY_LINE_RE.exec(inboxLine);
106
+ if (!match || !Number.isFinite(armTimeMs) || !Number.isFinite(skewMs) || skewMs < 0) {
107
+ return false;
108
+ }
109
+ const timestampMs = Date.parse(match[1]);
110
+ return Number.isFinite(timestampMs) && timestampMs >= armTimeMs - skewMs;
111
+ }
112
+
89
113
  export function formatFreshEventLine(
90
114
  inboxLine: string,
91
115
  deduper: RecentLineDeduper,
92
116
  includeWakeMessage = false,
117
+ armTimeMs?: number,
93
118
  ): string | null {
94
119
  const pretty = formatEventLine(inboxLine);
95
120
  if (pretty === null) return null;
121
+ if (armTimeMs !== undefined && !isInboxLineAtOrAfterArm(inboxLine, armTimeMs)) {
122
+ return null;
123
+ }
96
124
  return deduper.remember(inboxLine)
97
125
  ? includeWakeMessage ? formatCubeActivityWakeMessage(pretty) : pretty
98
126
  : null;
@@ -887,6 +915,7 @@ function main(): void {
887
915
  // wire behavior is identical — only the per-line projection changes.
888
916
  // `-n 0` skips backfilling history so fresh sessions don't replay
889
917
  // old entries on every restart.
918
+ const monitorArmTimeMs = Date.now();
890
919
  const deduper = new RecentLineDeduper();
891
920
  seedDeduperFromInboxTail(inboxPath, deduper);
892
921
 
@@ -918,7 +947,7 @@ function main(): void {
918
947
 
919
948
  const rl = createInterface({ input: tail.stdout, crlfDelay: Infinity });
920
949
  rl.on('line', (line) => {
921
- const pretty = formatFreshEventLine(line, deduper, true);
950
+ const pretty = formatFreshEventLine(line, deduper, true, monitorArmTimeMs);
922
951
  if (pretty !== null) {
923
952
  console.log(pretty);
924
953
  // Delivered → the tail is current; re-anchor the offset to the live
package/src/log-stream.ts CHANGED
@@ -739,7 +739,10 @@ export async function streamOnce(
739
739
  // Local mirror of the resume cursor, updated AFTER each successful
740
740
  // disk write (or dedup-recognized replay). Heartbeat-hwm comparison
741
741
  // reads this value.
742
- let lastPersistedEventId: string | null = lastEventId;
742
+ // Seed it from the persisted cursor as well as the in-memory Last-Event-ID:
743
+ // a reconnect after process restart must not treat the cursor's durable
744
+ // horizon as unknown while catchup is being replayed.
745
+ let lastPersistedEventId: string | null = cursor?.id ?? lastEventId;
743
746
  let lastBroadcastHwm: BroadcastHwm | null = null;
744
747
  let pendingHwmDivergence:
745
748
  | { hwm: BroadcastHwm; timer: NodeJS.Timeout }
@@ -769,7 +772,9 @@ export async function streamOnce(
769
772
  // a created_at would itself widen the next reconnect's window (the very
770
773
  // storm this fixes). Proven older-or-equal events are still recorded in
771
774
  // recentIds for dedup but do not move the cursor or re-fire onEventId.
772
- let lastPersistedHwm: BroadcastHwm | null = null;
775
+ let lastPersistedHwm: BroadcastHwm | null = cursor
776
+ ? { id: cursor.id, created_at: cursor.created_at }
777
+ : null;
773
778
  const markEventPersisted = (id: string, createdAt: string) => {
774
779
  const next: BroadcastHwm = { id, created_at: createdAt };
775
780
  if (
@@ -842,16 +847,36 @@ export async function streamOnce(
842
847
  const line = formatInboxLine(withSseEventId(ev.data, ev.id));
843
848
  const deliveryId = ev.wake_nonce ?? ev.id;
844
849
  const isReping = ev.wake_nonce !== undefined;
845
- const alreadyPersisted = (ev.wake_nonce !== undefined || isCatchingUp) && (
846
- // gh#441: pass the rendered line so the dedup can also recognize LEGACY
847
- // (no-entry_id-prefix) on-disk lines, not just the [entry_id:] marker.
850
+ const eventHwm = ev.data?.created_at
851
+ ? { id: ev.id, created_at: ev.data.created_at }
852
+ : null;
853
+ const atOrBeforeResumeCursor = Boolean(
854
+ isCatchingUp &&
855
+ lastPersistedHwm &&
856
+ eventHwm &&
857
+ compareBroadcastHwm(eventHwm, lastPersistedHwm) <= 0
858
+ );
859
+ // The persisted stream cursor is the authoritative catchup horizon. Only
860
+ // fall back to file-content inspection when there is no comparable cursor
861
+ // (legacy/no-cursor sessions), or for a nonce reping whose durable line is
862
+ // intentionally reused while its wake identity is delivered again.
863
+ const consultInboxFile = ev.wake_nonce !== undefined ||
864
+ (isCatchingUp && lastPersistedHwm === null);
865
+ const alreadyPersisted = atOrBeforeResumeCursor || (
866
+ consultInboxFile &&
867
+ // gh#441: pass the rendered line so the fallback dedup can also
868
+ // recognize LEGACY (no-entry_id-prefix) on-disk lines, not just the
869
+ // [entry_id:] marker.
848
870
  await hasInboxEntryId(active.cubeId, active.droneId, ev.id, line)
849
871
  );
850
872
  if (alreadyPersisted) {
851
- // Replay still re-enters the OpenCode delivery queue. Ordinary entries
852
- // correlate by canonical inbox text; re-pings add a stable nonce marker
853
- // to the injected text so each distinct nonce submits exactly once.
854
- await injectOpenCode(formatOpenCodeWakePrompt(line, ev.wake_nonce), deliveryId, isReping);
873
+ // An ordinary entry at/before the persisted cursor is already delivered
874
+ // and must not re-enter the OpenCode queue. Re-pings are the explicit
875
+ // exception: each wake nonce is a new delivery identity even when the
876
+ // underlying inbox line is already durable.
877
+ if (isReping) {
878
+ await injectOpenCode(formatOpenCodeWakePrompt(line, ev.wake_nonce), deliveryId, true);
879
+ }
855
880
  markEventPersisted(ev.id, ev.data?.created_at ?? '');
856
881
  return 'persisted-skip';
857
882
  }
@@ -80,6 +80,11 @@ export interface RemoteConnection {
80
80
  // retryAfter can't wedge the call.
81
81
  const RATE_LIMIT_MAX_RETRIES = 3;
82
82
  const RATE_LIMIT_MAX_WAIT_MS = 60_000; // cap a single Retry-After honor
83
+ const UNREAD_CURSOR_MAX_TRANSPORT_RETRIES = 1;
84
+ // Replay is opt-in: most local requests are mutations or have ambiguous
85
+ // delivery, while unread-log reads carry an explicit cursor and are safe to
86
+ // repeat with the exact same request body.
87
+ type AuthedFetchRetryMode = 'unread-cursor';
83
88
  export const LOCAL_SERVER_RESPONSE_LIMIT_BYTES = 32 * 1024 * 1024;
84
89
  // A typed auth-error envelope is tiny; anything larger is hostile and the
85
90
  // bounded read throws → the 401 fails closed to non-destructive CREDENTIAL_REJECTED.
@@ -179,6 +184,25 @@ export async function retryOn429(
179
184
  return response;
180
185
  }
181
186
 
187
+ function isConnectionReset(error: unknown): boolean {
188
+ let candidate: unknown = error;
189
+ for (let depth = 0; depth < 2; depth += 1) {
190
+ if (candidate === null || typeof candidate !== 'object') return false;
191
+ const typed = candidate as { code?: unknown; cause?: unknown };
192
+ if (typed.code === 'ECONNRESET') return true;
193
+ candidate = typed.cause;
194
+ }
195
+ return false;
196
+ }
197
+
198
+ function unreadLogTransportFailure(cause: unknown): BorgServerUnreachableError {
199
+ return new BorgServerUnreachableError(
200
+ 'Borg could not complete the unread log read after one automatic retry. ' +
201
+ 'The request may have reached the server; repeat `borg_read-log unread_only=true` until caught up.',
202
+ { cause },
203
+ );
204
+ }
205
+
182
206
  async function localAuthorityContext(
183
207
  sessionToken: string,
184
208
  apiUrl: string,
@@ -271,6 +295,7 @@ async function localServerRequest<T>(
271
295
  path: string,
272
296
  method: 'GET' | 'POST' | 'PUT' | 'PATCH',
273
297
  payload?: Record<string, unknown>,
298
+ options: { retryMode?: AuthedFetchRetryMode } = {},
274
299
  ): Promise<T | null> {
275
300
  return decodeLocalProtocolResponse<T>((signal) => authedFetch(path, {
276
301
  method,
@@ -286,6 +311,7 @@ async function localServerRequest<T>(
286
311
  headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
287
312
  body: JSON.stringify(createProtocolEnvelope(randomUUID(), payload)),
288
313
  }),
314
+ retryMode: options.retryMode,
289
315
  }), true);
290
316
  }
291
317
 
@@ -492,7 +518,11 @@ function localCursorBinding(active: ActiveCube) {
492
518
 
493
519
  async function localReadLogPage(
494
520
  active: ActiveCube,
495
- opts: { cursor?: LocalServerCursor | null; limit?: number } = {},
521
+ opts: {
522
+ cursor?: LocalServerCursor | null;
523
+ limit?: number;
524
+ retryMode?: AuthedFetchRetryMode;
525
+ } = {},
496
526
  ): Promise<any> {
497
527
  const payload = await localServerRequest<any>(
498
528
  active,
@@ -502,6 +532,7 @@ async function localReadLogPage(
502
532
  cursor: opts.cursor ?? null,
503
533
  ...(opts.limit === undefined ? {} : { limit: opts.limit }),
504
534
  },
535
+ { retryMode: opts.retryMode },
505
536
  );
506
537
  if (!payload) throw new Error('Local Borg server returned an empty log response');
507
538
  return payload;
@@ -627,6 +658,7 @@ async function authedFetch(
627
658
  authToken?: string;
628
659
  serverTrustIdentity?: string;
629
660
  localSessionCredentialRef?: string;
661
+ retryMode?: AuthedFetchRetryMode;
630
662
  } = {}
631
663
  ): Promise<Response> {
632
664
  const {
@@ -635,6 +667,7 @@ async function authedFetch(
635
667
  authToken,
636
668
  serverTrustIdentity: suppliedTrustIdentity,
637
669
  localSessionCredentialRef,
670
+ retryMode,
638
671
  headers,
639
672
  ...rest
640
673
  } = init;
@@ -697,7 +730,35 @@ async function authedFetch(
697
730
  return res;
698
731
  };
699
732
 
700
- const response = await buildRequest(token);
733
+ let transportRetriesRemaining = retryMode === 'unread-cursor'
734
+ ? UNREAD_CURSOR_MAX_TRANSPORT_RETRIES
735
+ : 0;
736
+ const requestWithRetry = async (): Promise<Response> => {
737
+ try {
738
+ return await buildRequest(token);
739
+ } catch (error) {
740
+ if (retryMode !== 'unread-cursor' || !isConnectionReset(error)) throw error;
741
+ if (transportRetriesRemaining === 0) throw unreadLogTransportFailure(error);
742
+ transportRetriesRemaining -= 1;
743
+ debugLog('↻ retrying unread log read after ECONNRESET');
744
+ try {
745
+ return await buildRequest(token);
746
+ } catch (retryError) {
747
+ if (isConnectionReset(retryError)) throw unreadLogTransportFailure(retryError);
748
+ throw retryError;
749
+ }
750
+ }
751
+ };
752
+
753
+ let response = await requestWithRetry();
754
+ let rateLimitRetryExhausted = false;
755
+ if (retryMode === 'unread-cursor') {
756
+ response = await retryOn429(response, requestWithRetry, {
757
+ sleep,
758
+ log: debugLog,
759
+ });
760
+ rateLimitRetryExhausted = response.status === 429;
761
+ }
701
762
 
702
763
  if (response.status === 401) {
703
764
  // Reached only after pinned-TLS trust is verified (localAuthorityContext
@@ -805,11 +866,14 @@ async function authedFetch(
805
866
  if (localSessionCredentialRef !== undefined) markSeatRejected(localSessionCredentialRef);
806
867
  throw new CubeDeletedError();
807
868
  }
869
+ const retryGuidance = rateLimitRetryExhausted
870
+ ? ' Repeat `borg_read-log unread_only=true` until caught up.'
871
+ : '';
808
872
  throw new BorgServerHttpError(
809
873
  response.status,
810
874
  serverMessage
811
- ? `Borg server request failed (HTTP ${response.status}): ${serverMessage}`
812
- : `Borg server request failed (HTTP ${response.status})`,
875
+ ? `Borg server request failed (HTTP ${response.status}): ${serverMessage}${retryGuidance}`
876
+ : `Borg server request failed (HTTP ${response.status})${retryGuidance}`,
813
877
  code,
814
878
  );
815
879
  }
@@ -954,7 +1018,13 @@ export async function readLog(
954
1018
  let cursor: LocalServerCursor | null = null;
955
1019
  if (opts.unreadOnly) cursor = await getLocalServerCursor(localCursorBinding(local));
956
1020
  if (opts.since !== undefined) cursor = await resolveLocalLogCursor(local, opts.since);
957
- const page = await localReadLogPage(local, { cursor, limit: opts.limit });
1021
+ const page = await localReadLogPage(local, {
1022
+ cursor,
1023
+ limit: opts.limit,
1024
+ // Keep the cursor payload stable across a lost response; do not re-read or
1025
+ // advance local state until one response has been decoded successfully.
1026
+ ...(opts.unreadOnly && opts.since === undefined ? { retryMode: 'unread-cursor' as const } : {}),
1027
+ });
958
1028
  if (opts.unreadOnly && page.cursor) {
959
1029
  await advanceLocalServerCursor(localCursorBinding(local), page.cursor);
960
1030
  }