borgmcp 5.5.0 → 5.6.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/README.md +7 -4
- package/dist/claude.d.ts.map +1 -1
- package/dist/claude.js +8 -0
- package/dist/claude.js.map +1 -1
- package/dist/cli-help.d.ts.map +1 -1
- package/dist/cli-help.js +9 -6
- package/dist/cli-help.js.map +1 -1
- package/dist/docs-sections.js +1 -1
- package/dist/docs-sections.js.map +1 -1
- package/dist/local-server-cursor.d.ts +9 -0
- package/dist/local-server-cursor.d.ts.map +1 -1
- package/dist/local-server-cursor.js +50 -1
- package/dist/local-server-cursor.js.map +1 -1
- package/dist/log-stream.d.ts +13 -0
- package/dist/log-stream.d.ts.map +1 -1
- package/dist/log-stream.js +45 -13
- package/dist/log-stream.js.map +1 -1
- package/dist/remote-client.d.ts +3 -0
- package/dist/remote-client.d.ts.map +1 -1
- package/dist/remote-client.js +7 -1
- package/dist/remote-client.js.map +1 -1
- package/dist/representative-cmd.d.ts +7 -0
- package/dist/representative-cmd.d.ts.map +1 -1
- package/dist/representative-cmd.js +19 -15
- package/dist/representative-cmd.js.map +1 -1
- package/dist/representative-core.d.ts +53 -7
- package/dist/representative-core.d.ts.map +1 -1
- package/dist/representative-core.js +229 -43
- package/dist/representative-core.js.map +1 -1
- package/dist/representative-delivery-store.d.ts +79 -0
- package/dist/representative-delivery-store.d.ts.map +1 -0
- package/dist/representative-delivery-store.js +233 -0
- package/dist/representative-delivery-store.js.map +1 -0
- package/dist/representative-listener-store.d.ts +46 -0
- package/dist/representative-listener-store.d.ts.map +1 -0
- package/dist/representative-listener-store.js +119 -0
- package/dist/representative-listener-store.js.map +1 -0
- package/dist/representative-listener.d.ts +32 -0
- package/dist/representative-listener.d.ts.map +1 -0
- package/dist/representative-listener.js +293 -0
- package/dist/representative-listener.js.map +1 -0
- package/dist/representative-mcp.d.ts +10 -3
- package/dist/representative-mcp.d.ts.map +1 -1
- package/dist/representative-mcp.js +48 -19
- package/dist/representative-mcp.js.map +1 -1
- package/dist/representative-store.d.ts +7 -0
- package/dist/representative-store.d.ts.map +1 -1
- package/dist/representative-store.js +17 -2
- package/dist/representative-store.js.map +1 -1
- package/dist/seat-probe.d.ts +1 -0
- package/dist/seat-probe.d.ts.map +1 -1
- package/dist/seat-probe.js +1 -1
- package/dist/seat-probe.js.map +1 -1
- package/dist/server-trust.d.ts +10 -0
- package/dist/server-trust.d.ts.map +1 -1
- package/dist/server-trust.js +23 -6
- package/dist/server-trust.js.map +1 -1
- package/dist/stream-owner.d.ts.map +1 -1
- package/dist/stream-owner.js +32 -3
- package/dist/stream-owner.js.map +1 -1
- package/docs/HUMAN_REPRESENTATIVE.md +201 -36
- package/package.json +1 -1
- package/src/claude.ts +8 -0
- package/src/cli-help.ts +9 -6
- package/src/docs-sections.ts +1 -1
- package/src/local-server-cursor.ts +45 -1
- package/src/log-stream.ts +47 -14
- package/src/remote-client.ts +9 -1
- package/src/representative-cmd.ts +26 -20
- package/src/representative-core.ts +255 -46
- package/src/representative-delivery-store.ts +235 -0
- package/src/representative-listener-store.ts +115 -0
- package/src/representative-listener.ts +215 -0
- package/src/representative-mcp.ts +58 -17
- package/src/representative-store.ts +18 -2
- package/src/seat-probe.ts +1 -1
- package/src/server-trust.ts +25 -6
- package/src/stream-owner.ts +34 -2
|
@@ -70,9 +70,14 @@ To resume later, run `borg representative prepare --coordinator <coordinator-dro
|
|
|
70
70
|
your saved labels. Recovery errors for a bound connection print that complete
|
|
71
71
|
command with its actual labels and worktree. Changing the cube or Coordinator
|
|
72
72
|
is refused unless you
|
|
73
|
-
pass `--rebind`; a rebind
|
|
74
|
-
|
|
75
|
-
|
|
73
|
+
pass `--rebind`; a rebind that changes the cube or Coordinator also discards the
|
|
74
|
+
old request ledger. A rebind of any kind, including one with the same cube and
|
|
75
|
+
Coordinator, requires restarting every adapter and the listener; running ones
|
|
76
|
+
refuse. A running `borg representative mcp` process answers `send`, `read`,
|
|
77
|
+
`deliver` and `ack` with `BINDING_MISMATCH` until the MCP host restarts it;
|
|
78
|
+
its `status` reports the new `binding_fingerprint` beside the
|
|
79
|
+
`pinned_binding_fingerprint` it started with. A running listener stops with
|
|
80
|
+
reason `rebound`.
|
|
76
81
|
|
|
77
82
|
`prepare` reuses Borg's connection and worktree preparation, including its
|
|
78
83
|
private-state initialization and saved-connection checks. It does not require
|
|
@@ -114,9 +119,10 @@ Check a connection at any time with
|
|
|
114
119
|
|
|
115
120
|
| Tool | Purpose |
|
|
116
121
|
| --- | --- |
|
|
117
|
-
| `borg_representative-status` | Bound cube, representative, Coordinator; live re-check; unresolved sends; limits
|
|
122
|
+
| `borg_representative-status` | Bound cube, representative, Coordinator; live re-check; unresolved sends; limits; `binding_fingerprint`. |
|
|
118
123
|
| `borg_representative-send` | Relay one `request`, `question` or `decision` to the bound Coordinator. |
|
|
119
|
-
| `borg_representative-read` |
|
|
124
|
+
| `borg_representative-read` | The bound Coordinator's replies after the delivered checkpoint, oldest first, bounded. Changes nothing. |
|
|
125
|
+
| `borg_representative-deliver` | Move the delivered checkpoint through a reply the host has durably persisted. |
|
|
120
126
|
| `borg_representative-ack` | Signal to the Coordinator that one direct reply was received. Nothing more. |
|
|
121
127
|
|
|
122
128
|
There is no tool for logging to other drones, broadcasting, role or drone
|
|
@@ -202,32 +208,111 @@ Exactly-once delivery is therefore not claimed; at-most-once per `request_id`
|
|
|
202
208
|
relies on the server honouring `post_id` deduplication as the shared protocol
|
|
203
209
|
specifies, and on the single-process rule below.
|
|
204
210
|
|
|
205
|
-
## Reading,
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
211
|
+
## Reading, delivery and wake limits
|
|
212
|
+
|
|
213
|
+
Four separate notions, each with its own tool or owner:
|
|
214
|
+
|
|
215
|
+
- **Receipt**: `read` returned the reply. Nothing moves; the same reply comes back
|
|
216
|
+
on every `read` until it is delivered.
|
|
217
|
+
- **Delivered**: the host called `deliver` after durably persisting everything up
|
|
218
|
+
to that reply. Only `deliver` moves the read window.
|
|
219
|
+
- **Processing and display**: the host's own business after delivery.
|
|
220
|
+
- **`ack`**: a signal to the Coordinator that a direct reply was received. It is
|
|
221
|
+
not delivery and does not move the window.
|
|
222
|
+
|
|
223
|
+
Reading:
|
|
224
|
+
|
|
225
|
+
- `read` returns the Coordinator's replies addressed to the representative that
|
|
226
|
+
come strictly after the delivered checkpoint, in `(created_at, entry_id)` order.
|
|
227
|
+
Other drones' entries are counted in `ignored_entries` and never returned; the
|
|
228
|
+
Coordinator's broadcasts are returned only with `include_broadcast`.
|
|
229
|
+
- `read` never advances anything: not the checkpoint, not the representative's
|
|
230
|
+
unread cursor, not a server cursor. After a crash at any point, the next `read`
|
|
231
|
+
returns every reply not yet delivered.
|
|
232
|
+
- Bounds: `limit` (1 to 50, default 10) is a hard cap on returned replies.
|
|
233
|
+
`max_bytes` (4096 to 60000, default 32768) caps the serialized tool result.
|
|
234
|
+
Replies are whole or omitted, never truncated; a reply that alone exceeds
|
|
235
|
+
`max_bytes` is returned alone with `"oversize": true`. The serialized result
|
|
236
|
+
never exceeds `max(max_bytes, 16384)` bytes: if an oversize reply is still
|
|
237
|
+
larger than that, its document citations are reduced to ids and it carries
|
|
238
|
+
`"documents_reduced": true`; message text is never cut. `status` reports this
|
|
239
|
+
floor as `envelope_floor`; set the host's tool-result ceiling at or above it.
|
|
240
|
+
The floor covers a message at the server's default post limit (4096 bytes)
|
|
241
|
+
with ordinary metadata. JSON escaping (a message full of tabs or quotes) or
|
|
242
|
+
heavy citation metadata can still push a single reply above it, as can a
|
|
243
|
+
server that allows larger posts. Then `read` refuses with
|
|
244
|
+
`REPRESENTATIVE_READ_OVERSIZE` (`entry_id`, `measured_bytes`, `bound`) and
|
|
245
|
+
changes nothing; retry with a larger `max_bytes` (up to 60000).
|
|
246
|
+
- `read` scans past entries that are not for the representative until the page
|
|
247
|
+
is full or the log ends, so it never returns no replies with `has_more: true`.
|
|
248
|
+
`has_more` is true only when another reply follows the returned window.
|
|
249
|
+
Page by delivering and reading again.
|
|
250
|
+
- The result includes `checkpoint` (`entry_id` and `created_at`; null until the
|
|
251
|
+
first delivery, unless the upgrade below started it at the old read position)
|
|
252
|
+
and `binding_fingerprint`.
|
|
253
|
+
|
|
254
|
+
Delivering:
|
|
255
|
+
|
|
256
|
+
- `deliver` takes `{ "through": "<entry_id>" }` and moves the checkpoint to that
|
|
257
|
+
reply. Call it only after the host has durably persisted every reply up to it.
|
|
258
|
+
- `through` must be a reply that `read` actually returned since the checkpoint
|
|
259
|
+
last moved; membership is checked, not only the range, so a broadcast skipped
|
|
260
|
+
by a read without `include_broadcast` is refused too. Any other entry is refused
|
|
261
|
+
with `REPRESENTATIVE_DELIVER_UNKNOWN_ENTRY` and nothing changes. The same or an older
|
|
262
|
+
id is a no-op that returns `advanced: false`, so a retry after a lost result is safe.
|
|
263
|
+
- Implementation note: the checkpoint, an internal read fence (the latest reply
|
|
264
|
+
any `read` returned) and the replies returned since the checkpoint last moved
|
|
265
|
+
are stored together in one private file per binding, written atomically.
|
|
266
|
+
`deliver` accepts only one of those returned replies. None of this is part of
|
|
267
|
+
the interface.
|
|
268
|
+
|
|
269
|
+
Binding fence:
|
|
270
|
+
|
|
271
|
+
- `binding_fingerprint` is the hex SHA-256 of the canonical JSON array
|
|
272
|
+
`[origin, trustIdentity, cubeId, representativeDroneId, coordinatorDroneId, boundAt]`.
|
|
273
|
+
It changes on every rebind and on a server trust change, and contains no path or
|
|
274
|
+
credential. `prepare --rebind` always starts a new generation, even when the
|
|
275
|
+
cube and Coordinator stay the same. It appears in `status`, `read`, `send`, `deliver` and the listener's
|
|
276
|
+
`listening` event. Persist it at binding time; if any result shows a different
|
|
277
|
+
value, stop routing and hold for the human.
|
|
278
|
+
|
|
279
|
+
Upgrading from a version without `deliver`:
|
|
280
|
+
|
|
281
|
+
- The first `read` or `deliver` for a binding starts the checkpoint where the old
|
|
282
|
+
destructive read left the representative's unread cursor: replies that were
|
|
283
|
+
unread at upgrade time are returned, replies already read are not. When the
|
|
284
|
+
binding never read, every addressed reply in the cube log is returned. If the
|
|
285
|
+
upgrade is interrupted before its checkpoint is written, the next read returns
|
|
286
|
+
every addressed reply instead; deduplicate by `entry_id`.
|
|
287
|
+
The old position is taken only from a genuine private file (a regular file you
|
|
288
|
+
own, not a symlink, not writable by group or others, in the private config
|
|
289
|
+
directory); otherwise the checkpoint starts empty and every addressed reply is
|
|
290
|
+
returned.
|
|
291
|
+
- That upgrade happens once per representative drone and server authority, and is
|
|
292
|
+
recorded in a private marker, so it never runs again, even after a checkpoint
|
|
293
|
+
file is removed. A later
|
|
294
|
+
binding generation (any `prepare --rebind`, or a trust change) starts with an
|
|
295
|
+
empty checkpoint, so its first read returns its addressed history. Deduplicate by
|
|
296
|
+
`entry_id` (the host persists everything it delivers) and page with `limit`.
|
|
297
|
+
|
|
298
|
+
Known limits:
|
|
299
|
+
|
|
300
|
+
- Retention is the server's cube log; the local inbox is not a content source.
|
|
301
|
+
- Each `read` scans the cube log from the checkpoint, including entries not
|
|
302
|
+
addressed to the representative, so a host that never calls `deliver` pays a
|
|
303
|
+
growing scan. Deliver promptly.
|
|
218
304
|
- Replies preserve document citations (id, title and state). Document bodies are
|
|
219
305
|
not included and cannot be fetched through this connection. Ask the Coordinator
|
|
220
306
|
to provide the content through a supported channel.
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
It does not make delivery reliable, and it neither advances nor restores the
|
|
225
|
-
unread cursor.
|
|
307
|
+
|
|
308
|
+
Ownership:
|
|
309
|
+
|
|
226
310
|
- Exclusive process ownership is enforced for each representative drone. Processes
|
|
227
|
-
may start idle; the first `send`, `read` or `ack` takes the lease. Other
|
|
228
|
-
receive `REPRESENTATIVE_OWNERSHIP_REQUIRED` before any ledger
|
|
229
|
-
write, cursor access
|
|
230
|
-
start time. Use that host, or wait for it to exit before
|
|
311
|
+
may start idle; the first `send`, `read`, `deliver` or `ack` takes the lease. Other
|
|
312
|
+
processes receive `REPRESENTATIVE_OWNERSHIP_REQUIRED` before any ledger
|
|
313
|
+
reservation, delivery-state write, cursor access or network call. The refusal
|
|
314
|
+
names the owner's PID and start time. Use that host, or wait for it to exit before
|
|
315
|
+
using another.
|
|
231
316
|
- `status` is allowed in every process, is read-only, and takes no lease. Its
|
|
232
317
|
`ownership` field reports the state, PID, start time, and heartbeat age in
|
|
233
318
|
milliseconds (`ageMs`). A clean exit releases ownership; a dead PID or a
|
|
@@ -238,20 +323,97 @@ specifies, and on the single-process rule below.
|
|
|
238
323
|
deduplication. Retry an ambiguous send with its original `request_id`.
|
|
239
324
|
- `in_reply_to` is a textual match of a known `request_id` quoted in the reply.
|
|
240
325
|
It is a convenience, not a protocol guarantee.
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
326
|
+
|
|
327
|
+
Wake hints:
|
|
328
|
+
|
|
329
|
+
- **The MCP process does not push content.** A separate supervised `listen`
|
|
330
|
+
process emits body-free wake hints. The owning adapter still calls `read` for
|
|
331
|
+
content. Hints are neither delivery receipts nor authority.
|
|
332
|
+
- Live dedupe is bounded to the surviving inbox tail and recent IDs; an ancient
|
|
333
|
+
trimmed ID sent again as a live event can produce a duplicate hint. Ordered
|
|
334
|
+
catch-up also dedupes against its captured resume cursor.
|
|
335
|
+
- The listener retains a bounded tail: above 1024 lines it trims to the latest
|
|
336
|
+
512. Lost-hint replay covers only that tail; beyond it `read` from the delivered
|
|
337
|
+
checkpoint is the source of truth. On every `gap`, call `read`.
|
|
246
338
|
|
|
247
339
|
### Host conversation routing
|
|
248
340
|
|
|
249
341
|
The lease selects one consuming process, not a conversation within that host.
|
|
250
342
|
The host must record which conversation owns each `request_id`, persist every
|
|
251
|
-
|
|
252
|
-
replies with an unknown or missing request ID for the human instead of
|
|
253
|
-
them. Borg cannot enforce these duties inside the host; it provides
|
|
254
|
-
|
|
343
|
+
reply durably before calling `deliver`, and route replies using `in_reply_to`.
|
|
344
|
+
Hold replies with an unknown or missing request ID for the human instead of
|
|
345
|
+
dropping them. Borg cannot enforce these duties inside the host; it provides one
|
|
346
|
+
delivered checkpoint per binding, not one per conversation. The listener inbox
|
|
347
|
+
is private client state, not a host content API.
|
|
348
|
+
|
|
349
|
+
## Supervised listener
|
|
350
|
+
|
|
351
|
+
For a prepared connection, run a separate long-lived subprocess:
|
|
352
|
+
|
|
353
|
+
```bash
|
|
354
|
+
borg representative listen --worktree <path>
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
Supervise it and persist the last `entry_id` durably admitted to the host's work
|
|
358
|
+
queue. On subsequent starts, pass that checkpoint:
|
|
359
|
+
|
|
360
|
+
```bash
|
|
361
|
+
borg representative listen --worktree <path> --replay-after <entry_id>
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
There is one listener lease per representative drone and server authority,
|
|
365
|
+
independent of the lazy tools lease. A second listener refuses without consuming
|
|
366
|
+
or appending anything. A dead owner or expired heartbeat permits takeover; a
|
|
367
|
+
process that loses ownership exits and must be restarted. A local lease cannot
|
|
368
|
+
cancel an already-issued request. No eager tools-lease option is needed when one
|
|
369
|
+
adapter exclusively calls `send`, `read`, `deliver` and `ack`.
|
|
370
|
+
|
|
371
|
+
`representative status` reports `listener` beside tool `ownership`: running
|
|
372
|
+
state, owner PID and start time, heartbeat age (`ageMs`), persisted watermark and
|
|
373
|
+
private inbox path. Status acquires nothing. Do not read the inbox or depend on
|
|
374
|
+
its pathname; it is not the content-delivery interface.
|
|
375
|
+
|
|
376
|
+
Stdout is newline-delimited JSON only. Stderr contains human diagnostics and
|
|
377
|
+
must not be parsed. The host must ignore unknown fields and unknown event types.
|
|
378
|
+
|
|
379
|
+
| Event | Fields and meaning |
|
|
380
|
+
| --- | --- |
|
|
381
|
+
| `refused` | The only stdout line on startup refusal: `code`, `exit_code`. Another listener uses `REPRESENTATIVE_LISTENER_OWNED`, plus `owner_pid` and `owner_started_at`. |
|
|
382
|
+
| `listening` | Once connected and holding the lease: `cube_id`, `drone_id`, `binding_fingerprint`, `watermark` (entry id or null), `inbox`. |
|
|
383
|
+
| `entry` | `entry_id`, `created_at`, `from_label`, `from_role`, `visibility`, `request_id` (UUID or null), `documents` (count), `replay` (boolean). No message body. |
|
|
384
|
+
| `reconnecting` | `attempt`, `delay_ms`. |
|
|
385
|
+
| `connected` | `resumed_from` (entry id or null). |
|
|
386
|
+
| `gap` | `after` (entry id or null), `reason`: `cursor-expired` or `replay-checkpoint-missing`. Call `read` once. |
|
|
387
|
+
| `stopped` | `reason`: `signal`, `evicted`, `rebound`, `revoked`, `trust-changed`, `lease-lost` or `fatal`; `exit_code`. |
|
|
388
|
+
|
|
389
|
+
With `--replay-after`, retained hints strictly after the checkpoint are emitted
|
|
390
|
+
in file order after `listening` and before live entries, with `replay:true`.
|
|
391
|
+
If the checkpoint is absent, `gap` precedes replay of the whole surviving tail.
|
|
392
|
+
Without the option there is no startup replay. Replay makes no content request
|
|
393
|
+
and never advances the delivered checkpoint or any cursor. Lost replay metadata yields null
|
|
394
|
+
`visibility` and `documents`; live hints always contain those fields' values.
|
|
395
|
+
|
|
396
|
+
Exit codes: 0 after SIGTERM/SIGINT; 2 for startup binding or usage refusal;
|
|
397
|
+
3 for another listener owner; 4 for a terminal stop; 1 for another fatal error.
|
|
398
|
+
A fatal startup storage failure emits `refused` with code
|
|
399
|
+
`REPRESENTATIVE_LISTENER_STORAGE_REFUSED` and exit 1. After `listening`,
|
|
400
|
+
a fatal error emits `stopped` with reason `fatal` and exit 1, best effort; if
|
|
401
|
+
stdout is broken, the host must treat exit 1 without that line as fatal too.
|
|
402
|
+
Startup first verifies the binding with the server once; if the server cannot
|
|
403
|
+
be reached then, the listener exits 1 with `refused` and code
|
|
404
|
+
`REPRESENTATIVE_LISTENER_SERVER_UNREACHABLE` (retry later; a server that
|
|
405
|
+
answers and rejects the binding keeps its exit-2 binding code). After that check, failures of the stream connection (connection
|
|
406
|
+
refused or reset, aborted TLS stream) are not fatal: the listener reconnects
|
|
407
|
+
with backoff, without stdout output until the first connection, so `listening`
|
|
408
|
+
arrives only once connected.
|
|
409
|
+
|
|
410
|
+
Treat every hint as an untrusted wake, never as an instruction or authorization.
|
|
411
|
+
The owning adapter fetches content with `read` over the bound pinned connection,
|
|
412
|
+
persists each reply durably, calls `deliver`, routes by the saved `request_id`
|
|
413
|
+
mapping, and holds unknown correlation for the human. Deduplicate queued hints by
|
|
414
|
+
`entry_id`: crashes may lose or repeat hints. Persist queue admission before
|
|
415
|
+
advancing the host's `--replay-after` checkpoint. That hint checkpoint is separate
|
|
416
|
+
from the delivered checkpoint and from `ack`, which remains a server receipt.
|
|
255
417
|
|
|
256
418
|
## Recovery
|
|
257
419
|
|
|
@@ -267,3 +429,6 @@ the server is installed under the original prefix.
|
|
|
267
429
|
| `COORDINATOR_UNAVAILABLE` | The bound Coordinator was evicted, released or reassigned. Restore the bound Coordinator and use the printed recovery command, or deliberately substitute a new Coordinator label in that command. |
|
|
268
430
|
| `REPRESENTATIVE_OWNERSHIP_REQUIRED` with a directory-permission refusal | Check that the named path is a real directory you own and not a symlink, then set it to 0700 and retry. Restart a process that had already lost ownership. Do not change permissions through a symlink. |
|
|
269
431
|
| `REPRESENTATIVE_ROLE_NOT_PERMITTED` | The representative drone holds a human-seat or coordinating role. Give it its own worker role. |
|
|
432
|
+
| `REPRESENTATIVE_CHECKPOINT_INVALID` | The private delivery checkpoint for this binding (named in the message) is corrupt, belongs to another seat, or fails the private-file checks. Every tool except `status` refuses and `status` reports it as `checkpoint_problem`; nothing is used or reset automatically. Inspect the file, then remove it; the next `read` returns every addressed reply again, so deduplicate by `entry_id`. |
|
|
433
|
+
| `REPRESENTATIVE_READ_OVERSIZE` | The next reply does not fit `max(max_bytes, 16384)` bytes even with its citations reduced to ids (heavy JSON escaping or citation metadata, or a server allowing posts above its default 4096-byte limit). Nothing was read or advanced. Retry with a larger `max_bytes` (up to 60000); beyond that the operator must lower the server's post limit. |
|
|
434
|
+
| `REPRESENTATIVE_DELIVER_UNKNOWN_ENTRY` | `deliver` named an entry that `read` has not returned (or no Coordinator reply). Nothing changed. Call `read`, persist what it returns, then deliver through its last `entry_id`. |
|
package/package.json
CHANGED
package/src/claude.ts
CHANGED
|
@@ -395,6 +395,11 @@ async function main() {
|
|
|
395
395
|
const representative = await import('./representative-cmd.js');
|
|
396
396
|
const parsed = representative.parseRepresentativeArgs(process.argv.slice(3));
|
|
397
397
|
if (!parsed.ok) {
|
|
398
|
+
if (process.argv[3] === 'listen') {
|
|
399
|
+
process.stdout.write(JSON.stringify({ event: 'refused', code: 'INVALID_INPUT', exit_code: 2 }) + '\n');
|
|
400
|
+
process.stderr.write(parsed.error + '\n');
|
|
401
|
+
process.exit(2);
|
|
402
|
+
}
|
|
398
403
|
process.stderr.write(chalk.red(`${consolePrefix()}◼ borg representative: ${parsed.error}\n`));
|
|
399
404
|
process.stderr.write(`Run \`borg representative --help\` for usage.\n`);
|
|
400
405
|
process.exit(1);
|
|
@@ -407,6 +412,9 @@ async function main() {
|
|
|
407
412
|
process.exit(await representative.runRepresentativeStatus(parsed.command, deps));
|
|
408
413
|
}
|
|
409
414
|
const { pinMcpSeatIdentity } = await import('./cubes.js');
|
|
415
|
+
if (parsed.command.action === 'listen') {
|
|
416
|
+
process.exit(await representative.runRepresentativeListen(parsed.command, deps));
|
|
417
|
+
}
|
|
410
418
|
process.exit(await representative.runRepresentativeMcp(parsed.command, deps, {
|
|
411
419
|
version: getPackageVersion(),
|
|
412
420
|
pinSeat: pinMcpSeatIdentity,
|
package/src/cli-help.ts
CHANGED
|
@@ -129,6 +129,7 @@ export function representativeHelpText(version: string): string {
|
|
|
129
129
|
` borg representative prepare --coordinator <drone-label> [--role <name>] [--worktree <name>] [--host <host>] [--rebind]\n` +
|
|
130
130
|
` borg representative status [--worktree <path>]\n` +
|
|
131
131
|
` borg representative mcp [--worktree <path>]\n` +
|
|
132
|
+
` borg representative listen --worktree <path> [--replay-after <entry_id>]\n` +
|
|
132
133
|
` borg representative --help\n\n` +
|
|
133
134
|
`Commands:\n` +
|
|
134
135
|
` prepare Create or resume the representative's own drone in this repository's cube and bind it to\n` +
|
|
@@ -136,18 +137,20 @@ export function representativeHelpText(version: string): string {
|
|
|
136
137
|
` Fails if that Coordinator is missing, evicted, duplicated, or not in the human seat;\n` +
|
|
137
138
|
` another drone is never chosen instead.\n` +
|
|
138
139
|
` status Show the saved binding, re-check it against the live cube, and list unresolved sends.\n` +
|
|
139
|
-
` mcp Serve the restricted stdio MCP tools (status, send, read, ack) for a generic MCP host.\n
|
|
140
|
+
` mcp Serve the restricted stdio MCP tools (status, send, read, deliver, ack) for a generic MCP host.\n` +
|
|
141
|
+
` listen Emit body-free JSON wake hints from the server stream; supervise this separate process.\n\n` +
|
|
140
142
|
`Options:\n` +
|
|
143
|
+
` --replay-after <entry_id> listen: replay later retained hints after the last durably enqueued entry\n` +
|
|
141
144
|
` --coordinator <drone-label> Exact label of the Coordinator drone (see \`borg drones\`). Required for prepare.\n` +
|
|
142
145
|
` --role <name> Existing non-human-seat role for the representative (default: hermes-representative)\n` +
|
|
143
146
|
` --worktree <name> prepare: create the drone in a new linked worktree of that name\n` +
|
|
144
|
-
` --worktree <path> status/mcp: absolute path of the prepared representative worktree\n` +
|
|
147
|
+
` --worktree <path> status/mcp/listen: absolute path of the prepared representative worktree\n` +
|
|
145
148
|
` --host <host> prepare: explicit Borg server, as in \`borg assimilate --host\`\n` +
|
|
146
149
|
` --rebind prepare: explicitly replace the saved cube/Coordinator selection\n` +
|
|
147
150
|
` --help, -h Show this help\n\n` +
|
|
148
|
-
`Limits:
|
|
149
|
-
`Reading
|
|
150
|
-
`
|
|
151
|
+
`Limits: the MCP process has no background wake; a separate listen process emits wake hints, not content.\n` +
|
|
152
|
+
`Reading changes nothing: persist replies durably, then deliver through the last one. On a listener gap, call read.\n` +
|
|
153
|
+
`The first send/read/deliver/ack takes an exclusive tools lease; listen owns a separate exclusive listener lease.\n` +
|
|
151
154
|
`A retried send reuses its request id so the server stores it once; an unknown outcome is reported as\n` +
|
|
152
155
|
`ambiguous, with its cause, and never re-sent automatically. "User-authorized" is the representative's own label:\n` +
|
|
153
156
|
`the Borg server does not verify it, and one relayed decision is not broader human approval.\n` +
|
|
@@ -230,7 +233,7 @@ export function topLevelHelpText(version: string): string {
|
|
|
230
233
|
` borg drones List this machine's registered drones and worktrees\n` +
|
|
231
234
|
` borg launch <drone-label-or-id-prefix> Reopen one registered drone from its worktree\n` +
|
|
232
235
|
` borg launch-all [cube] Launch all drone worktrees of a cube (default: active cube)\n` +
|
|
233
|
-
` borg representative prepare|status|mcp Let an MCP host (e.g. Hermes) speak for you to one Coordinator drone\n` +
|
|
236
|
+
` borg representative prepare|status|mcp|listen Let an MCP host (e.g. Hermes) speak for you to one Coordinator drone\n` +
|
|
234
237
|
` borg server <command> [arguments]\n` +
|
|
235
238
|
` borg --cli claude|codex|opencode Launch that agent CLI directly\n` +
|
|
236
239
|
` borg --version Show installed version\n\n` +
|
package/src/docs-sections.ts
CHANGED
|
@@ -96,7 +96,7 @@ export const DOCS_SECTIONS: DocsSection[] = [
|
|
|
96
96
|
slug: "human-representative",
|
|
97
97
|
title: "Human representative",
|
|
98
98
|
url: HUMAN_REPRESENTATIVE_URL,
|
|
99
|
-
summary: "A separate non-human-seat drone that relays the human's requests and decisions to one bound Coordinator over a restricted stdio MCP facade: prepare, host configuration, idempotent sends,
|
|
99
|
+
summary: "A separate non-human-seat drone that relays the human's requests and decisions to one bound Coordinator over a restricted stdio MCP facade: prepare, host configuration, idempotent sends, replayable bounded reads with a delivered checkpoint, and supervised listener hints.",
|
|
100
100
|
keywords: ["representative", "hermes", "human representative", "delegate", "proxy", "borg representative", "borg_representative-send", "mcp host", "request_id", "ambiguous"],
|
|
101
101
|
},
|
|
102
102
|
{
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
-
import {
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { lstat, mkdir, open, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises';
|
|
3
4
|
import { dirname, join } from 'node:path';
|
|
4
5
|
import { borgConfigRoot } from './private-root.js';
|
|
5
6
|
|
|
@@ -146,6 +147,49 @@ export async function getLocalServerCursor(
|
|
|
146
147
|
return state.cursors[key] ?? null;
|
|
147
148
|
}
|
|
148
149
|
|
|
150
|
+
/**
|
|
151
|
+
* Fail-closed read for importing the unread watermark into other private
|
|
152
|
+
* state. The product writes this file 0600 (writeState); a symlink, a file
|
|
153
|
+
* that is not a regular file, not owned by this user, or group- or
|
|
154
|
+
* world-writable, and any unparsable state all read as null, never as a
|
|
155
|
+
* position. Read-only: nothing is written or locked. The caller validates
|
|
156
|
+
* the private root the file lives in.
|
|
157
|
+
*/
|
|
158
|
+
export async function readPrivateLocalServerCursor(
|
|
159
|
+
binding: LocalServerCursorBinding,
|
|
160
|
+
): Promise<LocalServerCursor | null> {
|
|
161
|
+
// lstat first: a FIFO or device is refused without ever being opened (an
|
|
162
|
+
// open would block). The non-blocking open and the identity recheck keep a
|
|
163
|
+
// swapped-in object from being read in its place.
|
|
164
|
+
let before;
|
|
165
|
+
try {
|
|
166
|
+
before = await lstat(CURSOR_FILE);
|
|
167
|
+
} catch {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
if (!before.isFile()) return null;
|
|
171
|
+
let handle;
|
|
172
|
+
try {
|
|
173
|
+
handle = await open(CURSOR_FILE, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
174
|
+
} catch {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
try {
|
|
178
|
+
const metadata = await handle.stat();
|
|
179
|
+
if (metadata.dev !== before.dev || metadata.ino !== before.ino) return null;
|
|
180
|
+
if (!metadata.isFile() || (metadata.mode & 0o022) !== 0 ||
|
|
181
|
+
(typeof process.getuid === 'function' && metadata.uid !== process.getuid())) return null;
|
|
182
|
+
const parsed = JSON.parse(await handle.readFile('utf8')) as Partial<CursorFile>;
|
|
183
|
+
if (parsed?.version !== 1 || typeof parsed.cursors !== 'object' || parsed.cursors === null) return null;
|
|
184
|
+
const cursor = parsed.cursors[cursorKey(binding)];
|
|
185
|
+
return validCursor(cursor) ? { id: cursor.id, created_at: cursor.created_at } : null;
|
|
186
|
+
} catch {
|
|
187
|
+
return null;
|
|
188
|
+
} finally {
|
|
189
|
+
await handle.close();
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
149
193
|
export async function advanceLocalServerCursor(
|
|
150
194
|
binding: LocalServerCursorBinding,
|
|
151
195
|
cursor: LocalServerCursor,
|
package/src/log-stream.ts
CHANGED
|
@@ -61,7 +61,7 @@ import {
|
|
|
61
61
|
} from './codex-app-wake.js';
|
|
62
62
|
import { formatCubeActivityWakeMessage } from './cube-activity-wake-copy.js';
|
|
63
63
|
import { readBoundedResponseBody } from './server-response.js';
|
|
64
|
-
import { BorgServerError } from './server-errors.js';
|
|
64
|
+
import { BorgServerError, BorgServerTrustError } from './server-errors.js';
|
|
65
65
|
import { markSeatRejected } from './seats.js';
|
|
66
66
|
import { formatDocumentCitations } from './document-render.js';
|
|
67
67
|
import { hasPendingWakeEntry as hasPendingDurableWakeEntry } from './remote-client.js';
|
|
@@ -378,7 +378,22 @@ export function startLogStream(opts: { runForever?: () => void } = {}): void {
|
|
|
378
378
|
// Dependency injection seams (for tests)
|
|
379
379
|
// ------------------------------------------------------------------
|
|
380
380
|
|
|
381
|
+
/** Production adapter for a separate private stream consumer. Ordinary drone defaults are unchanged. */
|
|
382
|
+
export function streamReconnectDelay(attempt: number): number {
|
|
383
|
+
return Math.min(RECONNECT_MIN_MS * 2 ** attempt, RECONNECT_MAX_MS) + Math.random() * 500;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
export interface StreamConsumer {
|
|
387
|
+
/** Retained dedupe horizon when an expired wire resume cursor has been cleared. */
|
|
388
|
+
catchupCursor?: LocalServerCursor | null;
|
|
389
|
+
connected(): Promise<void>;
|
|
390
|
+
beforeEvent(): Promise<void>;
|
|
391
|
+
log(event: Extract<ParsedEvent, { type: 'log' }>, catchupCursor: LocalServerCursor | null): Promise<void>;
|
|
392
|
+
clearCursor(): Promise<void>;
|
|
393
|
+
}
|
|
381
394
|
export interface StreamDeps {
|
|
395
|
+
consumer?: StreamConsumer;
|
|
396
|
+
|
|
382
397
|
/** Override the global fetch (tests inject a controlled Response). */
|
|
383
398
|
fetchImpl?: typeof fetch;
|
|
384
399
|
/** Override persisted trust loading to verify pre-network confinement. */
|
|
@@ -424,7 +439,7 @@ export interface StreamDeps {
|
|
|
424
439
|
settleOpenCodeEntry?: (sourceEntryId: string) => void;
|
|
425
440
|
}
|
|
426
441
|
|
|
427
|
-
const defaultDeps: Required<StreamDeps
|
|
442
|
+
const defaultDeps: Required<Omit<StreamDeps, 'consumer'>> = {
|
|
428
443
|
fetchImpl: globalThis.fetch.bind(globalThis),
|
|
429
444
|
loadTrust: loadBorgServerTrust,
|
|
430
445
|
getCursor: getLocalServerCursor,
|
|
@@ -636,8 +651,7 @@ async function runLoop(testDeps: RunLoopTestDeps = {}): Promise<void> {
|
|
|
636
651
|
}
|
|
637
652
|
streamState.connected = false;
|
|
638
653
|
const delay =
|
|
639
|
-
|
|
640
|
-
Math.random() * 500;
|
|
654
|
+
streamReconnectDelay(attempt);
|
|
641
655
|
process.stderr.write(
|
|
642
656
|
`[borg-mcp log stream] reconnect in ${Math.round(delay)}ms: ${err?.message ?? err}\n`
|
|
643
657
|
);
|
|
@@ -685,6 +699,8 @@ export async function streamOnce(
|
|
|
685
699
|
onEventId: (id: string) => void,
|
|
686
700
|
deps: StreamDeps = {}
|
|
687
701
|
): Promise<void> {
|
|
702
|
+
// A representative consumer must never alter borg_stream-status's singleton.
|
|
703
|
+
const state = deps.consumer ? { ...streamState } : streamState;
|
|
688
704
|
const {
|
|
689
705
|
fetchImpl,
|
|
690
706
|
loadTrust,
|
|
@@ -714,6 +730,7 @@ export async function streamOnce(
|
|
|
714
730
|
if (deps.fetchImpl === undefined) {
|
|
715
731
|
const trust = await loadTrust(active.apiUrl);
|
|
716
732
|
if (trust.identity !== active.serverTrustIdentity) {
|
|
733
|
+
if (deps.consumer) throw new BorgServerTrustError('Borg server trust identity changed; refusing the stream');
|
|
717
734
|
throw new Error('Borg server trust identity changed; refusing the stream');
|
|
718
735
|
}
|
|
719
736
|
requestFetch = trust.fetchImpl;
|
|
@@ -804,7 +821,7 @@ export async function streamOnce(
|
|
|
804
821
|
}
|
|
805
822
|
lastPersistedHwm = next;
|
|
806
823
|
lastPersistedEventId = id;
|
|
807
|
-
|
|
824
|
+
state.lastPersistedEventId = id;
|
|
808
825
|
onEventId(id);
|
|
809
826
|
};
|
|
810
827
|
|
|
@@ -854,7 +871,7 @@ export async function streamOnce(
|
|
|
854
871
|
// Set + FIFO array for O(1) membership + bounded memory.
|
|
855
872
|
const recentIds = new Set<string>();
|
|
856
873
|
const recentIdsOrder: string[] = [];
|
|
857
|
-
let isCatchingUp = lastEventId !== null || cursor !== null;
|
|
874
|
+
let isCatchingUp = lastEventId !== null || cursor !== null || deps.consumer?.catchupCursor != null;
|
|
858
875
|
|
|
859
876
|
// gh#29 quality-stream (#5): shared inbox-write + cursor-advance helpers,
|
|
860
877
|
// extracted from the previously-duplicated ack / regular-log branches in the
|
|
@@ -973,11 +990,13 @@ export async function streamOnce(
|
|
|
973
990
|
});
|
|
974
991
|
} catch (err) {
|
|
975
992
|
if (watchdog) clearTimeout(watchdog);
|
|
993
|
+
if (deps.consumer) abortSignal.removeEventListener('abort', abortFromExternal);
|
|
976
994
|
throw err;
|
|
977
995
|
}
|
|
978
996
|
|
|
979
997
|
if (!response.ok || !response.body) {
|
|
980
998
|
if (watchdog) clearTimeout(watchdog);
|
|
999
|
+
if (deps.consumer) abortSignal.removeEventListener('abort', abortFromExternal);
|
|
981
1000
|
// gh#877 Path-B (stream bootstrap): an evicted drone's stream re-subscribe
|
|
982
1001
|
// returns the authoritative 410 DRONE_EVICTED. Surface it as the terminal
|
|
983
1002
|
// typed error so the reconnect loop stops retrying (B25) instead of backing
|
|
@@ -1034,34 +1053,36 @@ export async function streamOnce(
|
|
|
1034
1053
|
// the dead cursor. The unread watermark (client#41) is untouched, so no
|
|
1035
1054
|
// undrained wake is lost across the reset.
|
|
1036
1055
|
if (code === CURSOR_EXPIRED_CODE) {
|
|
1037
|
-
await clearLocalServerCursor({
|
|
1056
|
+
await (deps.consumer ? deps.consumer.clearCursor() : clearLocalServerCursor({
|
|
1038
1057
|
origin: active.apiUrl,
|
|
1039
1058
|
trustIdentity: active.serverTrustIdentity,
|
|
1040
1059
|
cubeId: active.cubeId,
|
|
1041
1060
|
droneId: active.droneId,
|
|
1042
1061
|
purpose: 'stream',
|
|
1043
|
-
});
|
|
1062
|
+
}));
|
|
1044
1063
|
throw new StreamCursorExpiredError();
|
|
1045
1064
|
}
|
|
1046
1065
|
}
|
|
1047
1066
|
throw new Error(`stream HTTP ${response.status}`);
|
|
1048
1067
|
}
|
|
1049
1068
|
|
|
1050
|
-
|
|
1069
|
+
state.connected = true;
|
|
1051
1070
|
|
|
1052
1071
|
try {
|
|
1072
|
+
await deps.consumer?.connected();
|
|
1053
1073
|
for await (const event of parseSSE(
|
|
1054
1074
|
response.body,
|
|
1055
1075
|
LOCAL_SERVER_SSE_FRAME_LIMIT_BYTES,
|
|
1056
1076
|
)) {
|
|
1077
|
+
await deps.consumer?.beforeEvent();
|
|
1057
1078
|
bumpWatchdog();
|
|
1058
1079
|
const nowIso = new Date().toISOString();
|
|
1059
|
-
|
|
1080
|
+
state.lastWireActivityAt = nowIso;
|
|
1060
1081
|
// Content vs wire split (T1.2): content freshness is what a reader
|
|
1061
1082
|
// skimming the top-line verdict actually cares about. Heartbeats
|
|
1062
1083
|
// bump wire-activity only; log and bookmark events bump both.
|
|
1063
1084
|
if (event.type === 'log' || event.type === 'bookmark') {
|
|
1064
|
-
|
|
1085
|
+
state.lastContentEventAt = nowIso;
|
|
1065
1086
|
}
|
|
1066
1087
|
|
|
1067
1088
|
// gh#877 Path-A: terminal eviction control frame. Handled EARLY (before
|
|
@@ -1074,8 +1095,9 @@ export async function streamOnce(
|
|
|
1074
1095
|
// (the client process cannot reach the agent loop); we only deliver the
|
|
1075
1096
|
// wake. The reconnect's stream-bootstrap 410 (authoritative) is what flips
|
|
1076
1097
|
// this loop terminal below.
|
|
1098
|
+
if (event.type === 'eviction' && deps.consumer) break;
|
|
1077
1099
|
if (event.type === 'eviction') {
|
|
1078
|
-
|
|
1100
|
+
state.lastContentEventAt = nowIso;
|
|
1079
1101
|
try {
|
|
1080
1102
|
const line = formatEvictionSentinelLine(event.reason);
|
|
1081
1103
|
await appendLine(
|
|
@@ -1103,7 +1125,7 @@ export async function streamOnce(
|
|
|
1103
1125
|
}
|
|
1104
1126
|
|
|
1105
1127
|
if (event.type === 'heartbeat') {
|
|
1106
|
-
|
|
1128
|
+
state.lastHeartbeatAt = nowIso;
|
|
1107
1129
|
// First/baseline heartbeat absorb: until this session has seen
|
|
1108
1130
|
// a broadcast entry, the server's broadcast HWM is our baseline.
|
|
1109
1131
|
// Direct messages may advance the persistence cursor past this
|
|
@@ -1142,6 +1164,16 @@ export async function streamOnce(
|
|
|
1142
1164
|
isCatchingUp = false;
|
|
1143
1165
|
continue;
|
|
1144
1166
|
}
|
|
1167
|
+
if (event.type === 'log' && deps.consumer) {
|
|
1168
|
+
if (!recentIds.has(event.id)) {
|
|
1169
|
+
await deps.consumer.log(event, isCatchingUp ? cursor ?? deps.consumer.catchupCursor ?? null : null);
|
|
1170
|
+
recentIds.add(event.id); recentIdsOrder.push(event.id);
|
|
1171
|
+
while (recentIdsOrder.length > RECENT_IDS_CAP) recentIds.delete(recentIdsOrder.shift()!);
|
|
1172
|
+
}
|
|
1173
|
+
markEventPersisted(event.id, event.data?.created_at ?? '');
|
|
1174
|
+
markBroadcastPersisted(broadcastHwmFromLogEvent(event));
|
|
1175
|
+
continue;
|
|
1176
|
+
}
|
|
1145
1177
|
if (event.type === 'log') {
|
|
1146
1178
|
const isHeartbeatPing =
|
|
1147
1179
|
typeof event.data?.message === 'string' &&
|
|
@@ -1264,7 +1296,8 @@ export async function streamOnce(
|
|
|
1264
1296
|
abortSignal.removeEventListener('abort', abortFromExternal);
|
|
1265
1297
|
if (watchdog) clearTimeout(watchdog);
|
|
1266
1298
|
clearPendingHwmDivergence();
|
|
1267
|
-
|
|
1299
|
+
if (deps.consumer) ac.abort(); // release the transport when a consumer stops or throws
|
|
1300
|
+
state.connected = false;
|
|
1268
1301
|
}
|
|
1269
1302
|
}
|
|
1270
1303
|
|
package/src/remote-client.ts
CHANGED
|
@@ -1100,6 +1100,9 @@ export async function readLog(
|
|
|
1100
1100
|
apiUrl: string,
|
|
1101
1101
|
opts: {
|
|
1102
1102
|
since?: string;
|
|
1103
|
+
/** Exact (created_at, id) resume point, strictly after; null = log start.
|
|
1104
|
+
* Stateless: never reads or advances the unread cursor, never digest. */
|
|
1105
|
+
cursor?: LocalServerCursor | null;
|
|
1103
1106
|
limit?: number;
|
|
1104
1107
|
unreadOnly?: boolean;
|
|
1105
1108
|
serverTrustIdentity?: string;
|
|
@@ -1122,7 +1125,11 @@ export async function readLog(
|
|
|
1122
1125
|
opts.serverTrustIdentity,
|
|
1123
1126
|
);
|
|
1124
1127
|
let cursor: LocalServerCursor | null = null;
|
|
1128
|
+
if (opts.cursor !== undefined && (opts.unreadOnly || opts.since !== undefined)) {
|
|
1129
|
+
throw new Error('readLog cursor cannot be combined with since or unreadOnly');
|
|
1130
|
+
}
|
|
1125
1131
|
if (opts.continuationGuard) await opts.continuationGuard();
|
|
1132
|
+
if (opts.cursor !== undefined) cursor = opts.cursor;
|
|
1126
1133
|
if (opts.unreadOnly) cursor = await getLocalServerCursor(localCursorBinding(local));
|
|
1127
1134
|
if (opts.since !== undefined) cursor = await resolveLocalLogCursor(local, opts.since, opts.continuationGuard);
|
|
1128
1135
|
let page = await localReadLogPage(local, {
|
|
@@ -1131,7 +1138,8 @@ export async function readLog(
|
|
|
1131
1138
|
continuationGuard: opts.continuationGuard,
|
|
1132
1139
|
// Keep the cursor payload stable across a lost response; do not re-read or
|
|
1133
1140
|
// advance local state until one response has been decoded successfully.
|
|
1134
|
-
|
|
1141
|
+
// An exact-cursor read is stateless, so the same bounded retries are safe.
|
|
1142
|
+
...((opts.unreadOnly && opts.since === undefined) || opts.cursor !== undefined ? { retryMode: 'unread-cursor' as const } : {}),
|
|
1135
1143
|
});
|
|
1136
1144
|
if (opts.unreadOnly && page.cursor) {
|
|
1137
1145
|
if (opts.continuationGuard) await opts.continuationGuard();
|