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.
Files changed (78) hide show
  1. package/README.md +7 -4
  2. package/dist/claude.d.ts.map +1 -1
  3. package/dist/claude.js +8 -0
  4. package/dist/claude.js.map +1 -1
  5. package/dist/cli-help.d.ts.map +1 -1
  6. package/dist/cli-help.js +9 -6
  7. package/dist/cli-help.js.map +1 -1
  8. package/dist/docs-sections.js +1 -1
  9. package/dist/docs-sections.js.map +1 -1
  10. package/dist/local-server-cursor.d.ts +9 -0
  11. package/dist/local-server-cursor.d.ts.map +1 -1
  12. package/dist/local-server-cursor.js +50 -1
  13. package/dist/local-server-cursor.js.map +1 -1
  14. package/dist/log-stream.d.ts +13 -0
  15. package/dist/log-stream.d.ts.map +1 -1
  16. package/dist/log-stream.js +45 -13
  17. package/dist/log-stream.js.map +1 -1
  18. package/dist/remote-client.d.ts +3 -0
  19. package/dist/remote-client.d.ts.map +1 -1
  20. package/dist/remote-client.js +7 -1
  21. package/dist/remote-client.js.map +1 -1
  22. package/dist/representative-cmd.d.ts +7 -0
  23. package/dist/representative-cmd.d.ts.map +1 -1
  24. package/dist/representative-cmd.js +19 -15
  25. package/dist/representative-cmd.js.map +1 -1
  26. package/dist/representative-core.d.ts +53 -7
  27. package/dist/representative-core.d.ts.map +1 -1
  28. package/dist/representative-core.js +229 -43
  29. package/dist/representative-core.js.map +1 -1
  30. package/dist/representative-delivery-store.d.ts +79 -0
  31. package/dist/representative-delivery-store.d.ts.map +1 -0
  32. package/dist/representative-delivery-store.js +233 -0
  33. package/dist/representative-delivery-store.js.map +1 -0
  34. package/dist/representative-listener-store.d.ts +46 -0
  35. package/dist/representative-listener-store.d.ts.map +1 -0
  36. package/dist/representative-listener-store.js +119 -0
  37. package/dist/representative-listener-store.js.map +1 -0
  38. package/dist/representative-listener.d.ts +32 -0
  39. package/dist/representative-listener.d.ts.map +1 -0
  40. package/dist/representative-listener.js +293 -0
  41. package/dist/representative-listener.js.map +1 -0
  42. package/dist/representative-mcp.d.ts +10 -3
  43. package/dist/representative-mcp.d.ts.map +1 -1
  44. package/dist/representative-mcp.js +48 -19
  45. package/dist/representative-mcp.js.map +1 -1
  46. package/dist/representative-store.d.ts +7 -0
  47. package/dist/representative-store.d.ts.map +1 -1
  48. package/dist/representative-store.js +17 -2
  49. package/dist/representative-store.js.map +1 -1
  50. package/dist/seat-probe.d.ts +1 -0
  51. package/dist/seat-probe.d.ts.map +1 -1
  52. package/dist/seat-probe.js +1 -1
  53. package/dist/seat-probe.js.map +1 -1
  54. package/dist/server-trust.d.ts +10 -0
  55. package/dist/server-trust.d.ts.map +1 -1
  56. package/dist/server-trust.js +23 -6
  57. package/dist/server-trust.js.map +1 -1
  58. package/dist/stream-owner.d.ts.map +1 -1
  59. package/dist/stream-owner.js +32 -3
  60. package/dist/stream-owner.js.map +1 -1
  61. package/docs/HUMAN_REPRESENTATIVE.md +201 -36
  62. package/package.json +1 -1
  63. package/src/claude.ts +8 -0
  64. package/src/cli-help.ts +9 -6
  65. package/src/docs-sections.ts +1 -1
  66. package/src/local-server-cursor.ts +45 -1
  67. package/src/log-stream.ts +47 -14
  68. package/src/remote-client.ts +9 -1
  69. package/src/representative-cmd.ts +26 -20
  70. package/src/representative-core.ts +255 -46
  71. package/src/representative-delivery-store.ts +235 -0
  72. package/src/representative-listener-store.ts +115 -0
  73. package/src/representative-listener.ts +215 -0
  74. package/src/representative-mcp.ts +58 -17
  75. package/src/representative-store.ts +18 -2
  76. package/src/seat-probe.ts +1 -1
  77. package/src/server-trust.ts +25 -6
  78. 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 also discards the old request ledger. A running
74
- `borg representative mcp` process never picks up a rebind: its calls fail
75
- closed until the MCP host restarts it.
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` | Unread replies from the bound Coordinator addressed to the representative. Drains everything it fetches. |
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, cursors and wake limits
206
-
207
- - `read` drains only the representative drone's **own** unread cursor. Other
208
- drones' cursors are separate client-owned state and are never touched.
209
- - A read **consumes everything it fetched**, not only what it returns: the
210
- Coordinator's replies, and equally the entries it ignores (other drones'
211
- entries are counted in `ignored_entries` and never returned; the
212
- Coordinator's broadcasts are returned only with `include_broadcast`). None
213
- of them appear unread again.
214
- - If the MCP host stops between reading a reply and relaying it to the human,
215
- that reply is gone from the unread view. It still exists in the cube log, but
216
- this version offers no tool to list past replies again. Persist the read result
217
- in the host before relaying it.
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
- - `limit` is a page-size hint, not a hard cap: when the unread backlog is
222
- large the client's digest mode fetches, and drains, more than `limit`.
223
- - `ack` is only a signal to the Coordinator that a direct reply was received.
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 processes
228
- receive `REPRESENTATIVE_OWNERSHIP_REQUIRED` before any ledger reservation or
229
- write, cursor access, or network call. The refusal names the owner's PID and
230
- start time. Use that host, or wait for it to exit before using another.
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
- - **There is no background wake.** A generic MCP host receives nothing
242
- unsolicited: replies are seen only when the host calls
243
- `borg_representative-read`. This version provides explicit send/read round
244
- trips only and makes no claim of automatic ongoing coordination. The
245
- Coordinator is woken by the direct message through its own normal wake path.
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
- read result before relaying it, and route replies using `in_reply_to`. Hold
252
- replies with an unknown or missing request ID for the human instead of dropping
253
- them. Borg cannot enforce these duties inside the host; it provides neither a
254
- durable inbox nor a separate unread cursor for each conversation.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "borgmcp",
3
- "version": "5.5.0",
3
+ "version": "5.6.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",
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\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: explicit send/read round trips only — there is no background wake or push to the MCP host.\n` +
149
- `Reading drains everything it fetches, so relay replies at once; ack is only a signal to the Coordinator.\n` +
150
- `Run exactly one MCP host process per representative worktree (not enforced).\n` +
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` +
@@ -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, no background wake.",
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 { mkdir, open, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises';
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
- Math.min(RECONNECT_MIN_MS * 2 ** attempt, RECONNECT_MAX_MS) +
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
- streamState.lastPersistedEventId = id;
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
- streamState.connected = true;
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
- streamState.lastWireActivityAt = nowIso;
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
- streamState.lastContentEventAt = nowIso;
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
- streamState.lastContentEventAt = nowIso;
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
- streamState.lastHeartbeatAt = nowIso;
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
- streamState.connected = false;
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
 
@@ -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
- ...(opts.unreadOnly && opts.since === undefined ? { retryMode: 'unread-cursor' as const } : {}),
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();