borgmcp 5.5.0 → 5.7.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 (85) hide show
  1. package/README.md +7 -4
  2. package/dist/claude.d.ts.map +1 -1
  3. package/dist/claude.js +12 -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 +16 -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/hermes-plugin-install.d.ts +19 -0
  11. package/dist/hermes-plugin-install.d.ts.map +1 -0
  12. package/dist/hermes-plugin-install.js +131 -0
  13. package/dist/hermes-plugin-install.js.map +1 -0
  14. package/dist/local-server-cursor.d.ts +9 -0
  15. package/dist/local-server-cursor.d.ts.map +1 -1
  16. package/dist/local-server-cursor.js +50 -1
  17. package/dist/local-server-cursor.js.map +1 -1
  18. package/dist/log-stream.d.ts +13 -0
  19. package/dist/log-stream.d.ts.map +1 -1
  20. package/dist/log-stream.js +45 -13
  21. package/dist/log-stream.js.map +1 -1
  22. package/dist/remote-client.d.ts +3 -0
  23. package/dist/remote-client.d.ts.map +1 -1
  24. package/dist/remote-client.js +7 -1
  25. package/dist/remote-client.js.map +1 -1
  26. package/dist/representative-cmd.d.ts +11 -0
  27. package/dist/representative-cmd.d.ts.map +1 -1
  28. package/dist/representative-cmd.js +48 -15
  29. package/dist/representative-cmd.js.map +1 -1
  30. package/dist/representative-core.d.ts +53 -7
  31. package/dist/representative-core.d.ts.map +1 -1
  32. package/dist/representative-core.js +229 -43
  33. package/dist/representative-core.js.map +1 -1
  34. package/dist/representative-delivery-store.d.ts +79 -0
  35. package/dist/representative-delivery-store.d.ts.map +1 -0
  36. package/dist/representative-delivery-store.js +233 -0
  37. package/dist/representative-delivery-store.js.map +1 -0
  38. package/dist/representative-listener-store.d.ts +46 -0
  39. package/dist/representative-listener-store.d.ts.map +1 -0
  40. package/dist/representative-listener-store.js +119 -0
  41. package/dist/representative-listener-store.js.map +1 -0
  42. package/dist/representative-listener.d.ts +32 -0
  43. package/dist/representative-listener.d.ts.map +1 -0
  44. package/dist/representative-listener.js +293 -0
  45. package/dist/representative-listener.js.map +1 -0
  46. package/dist/representative-mcp.d.ts +10 -3
  47. package/dist/representative-mcp.d.ts.map +1 -1
  48. package/dist/representative-mcp.js +48 -19
  49. package/dist/representative-mcp.js.map +1 -1
  50. package/dist/representative-store.d.ts +7 -0
  51. package/dist/representative-store.d.ts.map +1 -1
  52. package/dist/representative-store.js +17 -2
  53. package/dist/representative-store.js.map +1 -1
  54. package/dist/seat-probe.d.ts +1 -0
  55. package/dist/seat-probe.d.ts.map +1 -1
  56. package/dist/seat-probe.js +1 -1
  57. package/dist/seat-probe.js.map +1 -1
  58. package/dist/server-trust.d.ts +10 -0
  59. package/dist/server-trust.d.ts.map +1 -1
  60. package/dist/server-trust.js +23 -6
  61. package/dist/server-trust.js.map +1 -1
  62. package/dist/stream-owner.d.ts.map +1 -1
  63. package/dist/stream-owner.js +32 -3
  64. package/dist/stream-owner.js.map +1 -1
  65. package/docs/HUMAN_REPRESENTATIVE.md +310 -36
  66. package/hermes-plugin/borg-representative-push/__init__.py +743 -0
  67. package/hermes-plugin/borg-representative-push/plugin.yaml +39 -0
  68. package/package.json +3 -1
  69. package/src/claude.ts +12 -0
  70. package/src/cli-help.ts +16 -6
  71. package/src/docs-sections.ts +1 -1
  72. package/src/hermes-plugin-install.ts +147 -0
  73. package/src/local-server-cursor.ts +45 -1
  74. package/src/log-stream.ts +47 -14
  75. package/src/remote-client.ts +9 -1
  76. package/src/representative-cmd.ts +52 -20
  77. package/src/representative-core.ts +255 -46
  78. package/src/representative-delivery-store.ts +235 -0
  79. package/src/representative-listener-store.ts +115 -0
  80. package/src/representative-listener.ts +215 -0
  81. package/src/representative-mcp.ts +58 -17
  82. package/src/representative-store.ts +18 -2
  83. package/src/seat-probe.ts +1 -1
  84. package/src/server-trust.ts +25 -6
  85. package/src/stream-owner.ts +34 -2
@@ -0,0 +1,39 @@
1
+ name: borg-representative-push
2
+ version: 1.0.0
3
+ manifest_version: 2
4
+ api_version: 1
5
+ description: >-
6
+ Wakes one Hermes gateway conversation when the bound Borg Coordinator replies
7
+ to the human representative. Supervises `borg representative listen` inside
8
+ the messaging gateway and injects a fixed, body-free prompt; reply content is
9
+ only ever fetched by the conversation through borg_representative-read.
10
+ license: Apache-2.0
11
+ homepage: https://github.com/Byte-Ventures/borg-mcp-client
12
+ tags: [borg, gateway, representative]
13
+ # Hooks are registered only when the settings below are valid (a post_tool_call
14
+ # observer for the deliver tool), so none is declared unconditionally here.
15
+ config_schema:
16
+ session_key:
17
+ type: str
18
+ required: true
19
+ description: Gateway session key of the conversation to wake, e.g. agent:main:telegram:dm:<chat id>.
20
+ worktree:
21
+ type: str
22
+ required: true
23
+ description: Absolute path of the prepared representative worktree.
24
+ borg_command:
25
+ type: str
26
+ default: borg
27
+ description: The borg executable (borgmcp >= 5.6.0).
28
+ mcp_server:
29
+ type: str
30
+ default: borg-representative
31
+ description: Name of the mcp_servers entry that runs `borg representative mcp`.
32
+ reinject_after_s:
33
+ type: int
34
+ default: 600
35
+ description: Seconds before an undelivered reply wakes the conversation again.
36
+ max_reinjects:
37
+ type: int
38
+ default: 3
39
+ description: Maximum repeat wakes for one undelivered reply.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "borgmcp",
3
- "version": "5.5.0",
3
+ "version": "5.7.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",
@@ -45,6 +45,8 @@
45
45
  "dist",
46
46
  "src",
47
47
  "docs/*.md",
48
+ "hermes-plugin/borg-representative-push/plugin.yaml",
49
+ "hermes-plugin/borg-representative-push/__init__.py",
48
50
  "README.md",
49
51
  "LICENSE",
50
52
  "NOTICE",
package/src/claude.ts CHANGED
@@ -395,10 +395,19 @@ 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);
401
406
  }
407
+ if (parsed.command.action === 'hermes-plugin-install') {
408
+ const install = await import('./hermes-plugin-install.js');
409
+ process.exit(await install.runHermesPluginInstall(parsed.command, install.defaultHermesPluginInstallDeps()));
410
+ }
402
411
  const deps = await representative.buildDefaultRepresentativeDeps();
403
412
  if (parsed.command.action === 'prepare') {
404
413
  process.exit(await representative.runRepresentativePrepare(parsed.command, deps));
@@ -407,6 +416,9 @@ async function main() {
407
416
  process.exit(await representative.runRepresentativeStatus(parsed.command, deps));
408
417
  }
409
418
  const { pinMcpSeatIdentity } = await import('./cubes.js');
419
+ if (parsed.command.action === 'listen') {
420
+ process.exit(await representative.runRepresentativeListen(parsed.command, deps));
421
+ }
410
422
  process.exit(await representative.runRepresentativeMcp(parsed.command, deps, {
411
423
  version: getPackageVersion(),
412
424
  pinSeat: pinMcpSeatIdentity,
package/src/cli-help.ts CHANGED
@@ -129,6 +129,8 @@ 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` +
133
+ ` borg representative hermes-plugin install [--hermes-home <path>] [--force]\n` +
132
134
  ` borg representative --help\n\n` +
133
135
  `Commands:\n` +
134
136
  ` prepare Create or resume the representative's own drone in this repository's cube and bind it to\n` +
@@ -136,18 +138,26 @@ export function representativeHelpText(version: string): string {
136
138
  ` Fails if that Coordinator is missing, evicted, duplicated, or not in the human seat;\n` +
137
139
  ` another drone is never chosen instead.\n` +
138
140
  ` 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` +
141
+ ` mcp Serve the restricted stdio MCP tools (status, send, read, deliver, ack) for a generic MCP host.\n` +
142
+ ` listen Emit body-free JSON wake hints from the server stream; supervise this separate process.\n` +
143
+ ` hermes-plugin install\n` +
144
+ ` Copy the Borg-owned Hermes push plugin into <Hermes home>/plugins and print the config to add.\n` +
145
+ ` It wakes one Hermes messaging-gateway conversation (not a Desktop chat). Never edits Hermes\n` +
146
+ ` config and never starts or restarts Hermes.\n\n` +
140
147
  `Options:\n` +
148
+ ` --replay-after <entry_id> listen: replay later retained hints after the last durably enqueued entry\n` +
141
149
  ` --coordinator <drone-label> Exact label of the Coordinator drone (see \`borg drones\`). Required for prepare.\n` +
142
150
  ` --role <name> Existing non-human-seat role for the representative (default: hermes-representative)\n` +
143
151
  ` --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` +
152
+ ` --worktree <path> status/mcp/listen: absolute path of the prepared representative worktree\n` +
145
153
  ` --host <host> prepare: explicit Borg server, as in \`borg assimilate --host\`\n` +
146
154
  ` --rebind prepare: explicitly replace the saved cube/Coordinator selection\n` +
155
+ ` --hermes-home <path> hermes-plugin install: absolute Hermes home (default: $HERMES_HOME or ~/.hermes)\n` +
156
+ ` --force hermes-plugin install: replace the files of an existing install\n` +
147
157
  ` --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` +
158
+ `Limits: the MCP process has no background wake; a separate listen process emits wake hints, not content.\n` +
159
+ `Reading changes nothing: persist replies durably, then deliver through the last one. On a listener gap, call read.\n` +
160
+ `The first send/read/deliver/ack takes an exclusive tools lease; listen owns a separate exclusive listener lease.\n` +
151
161
  `A retried send reuses its request id so the server stores it once; an unknown outcome is reported as\n` +
152
162
  `ambiguous, with its cause, and never re-sent automatically. "User-authorized" is the representative's own label:\n` +
153
163
  `the Borg server does not verify it, and one relayed decision is not broader human approval.\n` +
@@ -230,7 +240,7 @@ export function topLevelHelpText(version: string): string {
230
240
  ` borg drones List this machine's registered drones and worktrees\n` +
231
241
  ` borg launch <drone-label-or-id-prefix> Reopen one registered drone from its worktree\n` +
232
242
  ` 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` +
243
+ ` borg representative prepare|status|mcp|listen|hermes-plugin Let an MCP host (e.g. Hermes) speak for you to one Coordinator drone\n` +
234
244
  ` borg server <command> [arguments]\n` +
235
245
  ` borg --cli claude|codex|opencode Launch that agent CLI directly\n` +
236
246
  ` 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
  {
@@ -0,0 +1,147 @@
1
+ /**
2
+ * `borg representative hermes-plugin install`: copy the Borg-owned Hermes push
3
+ * plugin (hermes-plugin/borg-representative-push) into <Hermes home>/plugins.
4
+ *
5
+ * It only places the plugin's own files. It never edits Hermes config, never
6
+ * enables the plugin and never starts or restarts Hermes: the operator does
7
+ * those steps from the printed snippet.
8
+ */
9
+ import { lstat, mkdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises';
10
+ import { homedir } from 'node:os';
11
+ import { isAbsolute, join } from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
13
+
14
+ export const HERMES_PLUGIN_NAME = 'borg-representative-push';
15
+ /** Exactly the shipped files; nothing else in the source directory is copied. */
16
+ export const HERMES_PLUGIN_FILES = ['plugin.yaml', '__init__.py'] as const;
17
+
18
+ export interface HermesPluginInstallDeps {
19
+ env: NodeJS.ProcessEnv;
20
+ homedir(): string;
21
+ /** Directory holding the packaged plugin files. */
22
+ sourceDir: string;
23
+ stdout(text: string): void;
24
+ stderr(text: string): void;
25
+ }
26
+
27
+ export function packagedHermesPluginDir(): string {
28
+ return fileURLToPath(new URL(`../hermes-plugin/${HERMES_PLUGIN_NAME}/`, import.meta.url));
29
+ }
30
+
31
+ export function defaultHermesPluginInstallDeps(): HermesPluginInstallDeps {
32
+ return {
33
+ env: process.env,
34
+ homedir,
35
+ sourceDir: packagedHermesPluginDir(),
36
+ stdout: (text) => { process.stdout.write(text); },
37
+ stderr: (text) => { process.stderr.write(text); },
38
+ };
39
+ }
40
+
41
+ class InstallRefused extends Error {}
42
+
43
+ function errnoCode(error: unknown): string | undefined {
44
+ return (error as NodeJS.ErrnoException | undefined)?.code;
45
+ }
46
+
47
+ async function lstatOrNull(path: string) {
48
+ try {
49
+ return await lstat(path);
50
+ } catch (error) {
51
+ if (errnoCode(error) === 'ENOENT') return null;
52
+ throw error;
53
+ }
54
+ }
55
+
56
+ export function hermesPluginConfigSnippet(): string {
57
+ return (
58
+ `plugins:\n` +
59
+ ` enabled:\n` +
60
+ ` - ${HERMES_PLUGIN_NAME}\n` +
61
+ ` entries:\n` +
62
+ ` ${HERMES_PLUGIN_NAME}:\n` +
63
+ ` allow_gateway_injection: true\n` +
64
+ ` settings:\n` +
65
+ ` session_key: "agent:main:<platform>:<chat type>:<chat id>" # the gateway conversation to wake\n` +
66
+ ` worktree: "<absolute path of the prepared representative worktree>"\n` +
67
+ `mcp_servers:\n` +
68
+ ` borg-representative:\n` +
69
+ ` command: borg\n` +
70
+ ` args: ["representative", "mcp", "--worktree", "<same absolute worktree path>"]\n` +
71
+ ` lazy: true\n`
72
+ );
73
+ }
74
+
75
+ export async function runHermesPluginInstall(
76
+ command: { hermesHome?: string; force: boolean },
77
+ deps: HermesPluginInstallDeps,
78
+ ): Promise<number> {
79
+ try {
80
+ const home = command.hermesHome ?? (deps.env.HERMES_HOME || join(deps.homedir(), '.hermes'));
81
+ if (!isAbsolute(home)) throw new InstallRefused(`The Hermes home must be an absolute path: ${home}`);
82
+ const homeStat = await stat(home).catch(() => null);
83
+ if (!homeStat?.isDirectory()) {
84
+ throw new InstallRefused(`No Hermes home at ${home}. Install Hermes first, or pass --hermes-home <path>.`);
85
+ }
86
+
87
+ const sources = await Promise.all(HERMES_PLUGIN_FILES.map(async (name) => {
88
+ try {
89
+ return { name, content: await readFile(join(deps.sourceDir, name)) };
90
+ } catch {
91
+ throw new InstallRefused(`The packaged plugin file ${name} is missing from ${deps.sourceDir}; reinstall borgmcp.`);
92
+ }
93
+ }));
94
+
95
+ const pluginsDir = join(home, 'plugins');
96
+ const pluginsStat = await stat(pluginsDir).catch((error: unknown) => {
97
+ if (errnoCode(error) === 'ENOENT') return null;
98
+ throw error;
99
+ });
100
+ if (pluginsStat && !pluginsStat.isDirectory()) throw new InstallRefused(`${pluginsDir} is not a directory.`);
101
+ if (!pluginsStat) await mkdir(pluginsDir, { mode: 0o755 });
102
+
103
+ const target = join(pluginsDir, HERMES_PLUGIN_NAME);
104
+ const existing = await lstatOrNull(target);
105
+ if (existing?.isSymbolicLink()) {
106
+ throw new InstallRefused(`${target} is a symbolic link; remove it yourself before installing.`);
107
+ }
108
+ if (existing && !existing.isDirectory()) throw new InstallRefused(`${target} exists and is not a directory.`);
109
+ if (existing && !command.force) {
110
+ throw new InstallRefused(`${target} already exists. Pass --force to replace the plugin's files.`);
111
+ }
112
+ if (!existing) await mkdir(target, { mode: 0o755 });
113
+
114
+ for (const { name, content } of sources) {
115
+ const destination = join(target, name);
116
+ const temporary = join(target, `.${name}.${process.pid}.tmp`);
117
+ // 'wx' refuses to follow or reuse anything already at the temporary path.
118
+ await writeFile(temporary, content, { mode: 0o644, flag: 'wx' });
119
+ try {
120
+ await rename(temporary, destination);
121
+ } catch (error) {
122
+ await unlink(temporary).catch(() => {});
123
+ throw error;
124
+ }
125
+ }
126
+
127
+ deps.stdout(
128
+ `${existing ? 'Replaced' : 'Installed'} the Hermes plugin ${HERMES_PLUGIN_NAME} in ${target}.\n\n` +
129
+ `Hermes config was not changed. Add the following to ${join(home, 'config.yaml')}\n` +
130
+ `(\`hermes plugins enable ${HERMES_PLUGIN_NAME}\` covers the enabled list only):\n\n` +
131
+ hermesPluginConfigSnippet() +
132
+ `\nThen:\n` +
133
+ `- session_key must name a messaging-gateway conversation (for example your Telegram DM). A Hermes\n` +
134
+ ` Desktop chat cannot be woken: Hermes injects plugin messages only into gateway conversations.\n` +
135
+ `- Only one process may use the representative tools. With lazy: true, run \`hermes tools\` and disable\n` +
136
+ ` the mcp-borg-representative toolset on every platform except the one in session_key (Desktop and CLI\n` +
137
+ ` included). If Desktop already holds the tools lease, restart the Desktop backend once.\n` +
138
+ `- Restart the gateway (\`hermes gateway restart\`) so it loads the plugin.\n` +
139
+ `Details: docs/HUMAN_REPRESENTATIVE.md, section "Hermes push plugin".\n`,
140
+ );
141
+ return 0;
142
+ } catch (error) {
143
+ const message = error instanceof InstallRefused ? error.message : `Install failed: ${error instanceof Error ? error.message : String(error)}`;
144
+ deps.stderr(`${message}\n`);
145
+ return 1;
146
+ }
147
+ }
@@ -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();