borgmcp 5.4.1 → 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 (101) hide show
  1. package/README.md +15 -0
  2. package/dist/assimilate-cmd.d.ts +8 -1
  3. package/dist/assimilate-cmd.d.ts.map +1 -1
  4. package/dist/assimilate-cmd.js +54 -21
  5. package/dist/assimilate-cmd.js.map +1 -1
  6. package/dist/claude.d.ts.map +1 -1
  7. package/dist/claude.js +30 -0
  8. package/dist/claude.js.map +1 -1
  9. package/dist/cli-help.d.ts +1 -0
  10. package/dist/cli-help.d.ts.map +1 -1
  11. package/dist/cli-help.js +45 -0
  12. package/dist/cli-help.js.map +1 -1
  13. package/dist/docs-sections.d.ts.map +1 -1
  14. package/dist/docs-sections.js +8 -0
  15. package/dist/docs-sections.js.map +1 -1
  16. package/dist/local-server-cursor.d.ts +10 -1
  17. package/dist/local-server-cursor.d.ts.map +1 -1
  18. package/dist/local-server-cursor.js +64 -5
  19. package/dist/local-server-cursor.js.map +1 -1
  20. package/dist/log-stream.d.ts +13 -0
  21. package/dist/log-stream.d.ts.map +1 -1
  22. package/dist/log-stream.js +45 -13
  23. package/dist/log-stream.js.map +1 -1
  24. package/dist/remote-client.d.ts +17 -0
  25. package/dist/remote-client.d.ts.map +1 -1
  26. package/dist/remote-client.js +37 -15
  27. package/dist/remote-client.js.map +1 -1
  28. package/dist/representative-cmd.d.ts +94 -0
  29. package/dist/representative-cmd.d.ts.map +1 -0
  30. package/dist/representative-cmd.js +290 -0
  31. package/dist/representative-cmd.js.map +1 -0
  32. package/dist/representative-core.d.ts +243 -0
  33. package/dist/representative-core.d.ts.map +1 -0
  34. package/dist/representative-core.js +679 -0
  35. package/dist/representative-core.js.map +1 -0
  36. package/dist/representative-delivery-store.d.ts +79 -0
  37. package/dist/representative-delivery-store.d.ts.map +1 -0
  38. package/dist/representative-delivery-store.js +233 -0
  39. package/dist/representative-delivery-store.js.map +1 -0
  40. package/dist/representative-listener-store.d.ts +46 -0
  41. package/dist/representative-listener-store.d.ts.map +1 -0
  42. package/dist/representative-listener-store.js +119 -0
  43. package/dist/representative-listener-store.js.map +1 -0
  44. package/dist/representative-listener.d.ts +32 -0
  45. package/dist/representative-listener.d.ts.map +1 -0
  46. package/dist/representative-listener.js +293 -0
  47. package/dist/representative-listener.js.map +1 -0
  48. package/dist/representative-mcp.d.ts +37 -0
  49. package/dist/representative-mcp.d.ts.map +1 -0
  50. package/dist/representative-mcp.js +211 -0
  51. package/dist/representative-mcp.js.map +1 -0
  52. package/dist/representative-owner.d.ts +10 -0
  53. package/dist/representative-owner.d.ts.map +1 -0
  54. package/dist/representative-owner.js +107 -0
  55. package/dist/representative-owner.js.map +1 -0
  56. package/dist/representative-store.d.ts +68 -0
  57. package/dist/representative-store.d.ts.map +1 -0
  58. package/dist/representative-store.js +173 -0
  59. package/dist/representative-store.js.map +1 -0
  60. package/dist/seat-probe.d.ts +1 -0
  61. package/dist/seat-probe.d.ts.map +1 -1
  62. package/dist/seat-probe.js +1 -1
  63. package/dist/seat-probe.js.map +1 -1
  64. package/dist/seat-store.d.ts +13 -0
  65. package/dist/seat-store.d.ts.map +1 -1
  66. package/dist/seat-store.js +55 -10
  67. package/dist/seat-store.js.map +1 -1
  68. package/dist/server-trust.d.ts +10 -0
  69. package/dist/server-trust.d.ts.map +1 -1
  70. package/dist/server-trust.js +23 -6
  71. package/dist/server-trust.js.map +1 -1
  72. package/dist/stream-owner.d.ts +10 -0
  73. package/dist/stream-owner.d.ts.map +1 -1
  74. package/dist/stream-owner.js +129 -21
  75. package/dist/stream-owner.js.map +1 -1
  76. package/dist/unknown-subcommand.d.ts +1 -1
  77. package/dist/unknown-subcommand.d.ts.map +1 -1
  78. package/dist/unknown-subcommand.js +1 -0
  79. package/dist/unknown-subcommand.js.map +1 -1
  80. package/docs/HUMAN_REPRESENTATIVE.md +434 -0
  81. package/package.json +1 -1
  82. package/src/assimilate-cmd.ts +73 -22
  83. package/src/claude.ts +30 -0
  84. package/src/cli-help.ts +48 -0
  85. package/src/docs-sections.ts +8 -0
  86. package/src/local-server-cursor.ts +56 -4
  87. package/src/log-stream.ts +47 -14
  88. package/src/remote-client.ts +54 -13
  89. package/src/representative-cmd.ts +369 -0
  90. package/src/representative-core.ts +908 -0
  91. package/src/representative-delivery-store.ts +235 -0
  92. package/src/representative-listener-store.ts +115 -0
  93. package/src/representative-listener.ts +215 -0
  94. package/src/representative-mcp.ts +250 -0
  95. package/src/representative-owner.ts +105 -0
  96. package/src/representative-store.ts +224 -0
  97. package/src/seat-probe.ts +1 -1
  98. package/src/seat-store.ts +61 -10
  99. package/src/server-trust.ts +25 -6
  100. package/src/stream-owner.ts +129 -20
  101. package/src/unknown-subcommand.ts +1 -0
@@ -441,6 +441,7 @@ async function selectAssimilationAuthority(
441
441
  flags: AssimilateFlags,
442
442
  deps: AuthorityResolutionDeps,
443
443
  mode: 'assimilate' | 'cube-init',
444
+ authoritySelectionCommand?: string,
444
445
  ): Promise<AssimilationAuthority | null> {
445
446
  if (flags.server !== undefined) {
446
447
  try {
@@ -460,7 +461,7 @@ async function selectAssimilationAuthority(
460
461
  }
461
462
  if (!deps.isTTY() || flags.yes) {
462
463
  if (deps.defaultAuthority) return deps.defaultAuthority;
463
- const command = mode === 'cube-init'
464
+ const command = authoritySelectionCommand ? `\`${authoritySelectionCommand}\`` : mode === 'cube-init'
464
465
  ? '`borg server cube init --host <host>`'
465
466
  : '`borg assimilate --host <host> --here`';
466
467
  deps.stderr(`No local server selected. Use ${command} to select a local server.\n`);
@@ -1132,11 +1133,11 @@ export interface CubeRoleResolutionOutcome {
1132
1133
  cli: BorgCli;
1133
1134
  }
1134
1135
 
1135
- export async function resolveAssimilationCubeRole(
1136
+ function resolveConnectionRole(
1136
1137
  input: CubeRoleResolutionInput,
1137
1138
  deps: CubeRoleResolutionDeps,
1138
- ): Promise<AssimilationPhaseOutcome<CubeRoleResolutionOutcome>> {
1139
- const { requestedRole, flags, cubeDetail, isFirstDrone, savedLocalRole, apiUrl } = input;
1139
+ ): AssimilationPhaseOutcome<{ resolvedRole: Role }> {
1140
+ const { requestedRole, cubeDetail, isFirstDrone, savedLocalRole, apiUrl } = input;
1140
1141
  let resolvedRole: Role | undefined;
1141
1142
  if (savedLocalRole) {
1142
1143
  resolvedRole = savedLocalRole;
@@ -1166,6 +1167,17 @@ export async function resolveAssimilationCubeRole(
1166
1167
  }
1167
1168
  }
1168
1169
 
1170
+ return continueAssimilation({ resolvedRole });
1171
+ }
1172
+
1173
+ export async function resolveAssimilationCubeRole(
1174
+ input: CubeRoleResolutionInput,
1175
+ deps: CubeRoleResolutionDeps,
1176
+ ): Promise<AssimilationPhaseOutcome<CubeRoleResolutionOutcome>> {
1177
+ const role = resolveConnectionRole(input, deps);
1178
+ if (role.kind === 'stop') return role;
1179
+ const { resolvedRole } = role.value;
1180
+ const { flags, apiUrl } = input;
1169
1181
  const effectiveModel: string | null = flags.model ?? null;
1170
1182
  const cli = await deps.resolveCli(flags.cli);
1171
1183
  try {
@@ -1192,7 +1204,7 @@ export interface SeatPreparationInput {
1192
1204
  serverTrustIdentity: string;
1193
1205
  cubeDetail: CubeDetail;
1194
1206
  resolvedRole: Role;
1195
- cli: BorgCli;
1207
+ cli?: BorgCli;
1196
1208
  effectiveModel: string | null;
1197
1209
  projectRoot: string;
1198
1210
  existing: CanonicalActiveCube | null;
@@ -1267,8 +1279,7 @@ export async function prepareAssimilationSeat(
1267
1279
  cube_id: cubeDetail.id,
1268
1280
  role_id: resolvedRole.id,
1269
1281
  hostname: deps.getHostname(),
1270
- agent_kind: cli,
1271
- model: effectiveModel,
1282
+ ...(cli !== undefined ? { agent_kind: cli, model: effectiveModel } : {}),
1272
1283
  working_repo: resolveWorkingRepo(projectRoot),
1273
1284
  ...(reattachPriorId ? { prior_drone_id: reattachPriorId } : {}),
1274
1285
  ...(remintInvalidPrior ? { remint_invalid_prior: true } : {}),
@@ -1471,6 +1482,7 @@ export interface AuthorityResolutionInput {
1471
1482
  args: AssimilateArgs;
1472
1483
  mode: 'assimilate' | 'cube-init';
1473
1484
  repositoryContext: GitRepositoryContext;
1485
+ authoritySelectionCommand?: string;
1474
1486
  }
1475
1487
 
1476
1488
  export interface AuthorityResolutionOutcome {
@@ -1493,6 +1505,13 @@ export async function resolveAssimilationAuthority(
1493
1505
  deps: AuthorityResolutionDeps,
1494
1506
  ): Promise<AssimilationPhaseOutcome<AuthorityResolutionOutcome>> {
1495
1507
  const { args, mode, repositoryContext } = input;
1508
+ // Representative retries must refuse before private-state initialization or
1509
+ // installation checks can mutate anything when no authority was selected.
1510
+ if (input.authoritySelectionCommand && args.flags.server === undefined &&
1511
+ deps.defaultAuthority === undefined && !args.flags.enroll && (!deps.isTTY() || args.flags.yes)) {
1512
+ await selectAssimilationAuthority(args.flags, deps, mode, input.authoritySelectionCommand);
1513
+ return { kind: 'stop', code: 1 };
1514
+ }
1496
1515
  const hostlessEnrollment = args.flags.enroll === true &&
1497
1516
  args.flags.server === undefined && deps.defaultAuthority === undefined;
1498
1517
  const artifactOnlyEnrollment = hostlessEnrollment && deps.isTTY();
@@ -1624,7 +1643,7 @@ export async function resolveAssimilationAuthority(
1624
1643
  localSeatReadError = error;
1625
1644
  }
1626
1645
 
1627
- const selectedAuthority = await selectAssimilationAuthority(args.flags, deps, mode);
1646
+ const selectedAuthority = await selectAssimilationAuthority(args.flags, deps, mode, input.authoritySelectionCommand);
1628
1647
  if (!selectedAuthority) return { kind: 'stop', code: 1 };
1629
1648
  let authority = selectedAuthority;
1630
1649
  if (localSeatReadError !== undefined) {
@@ -1750,12 +1769,31 @@ export async function runAssimilate(
1750
1769
  args: AssimilateArgs,
1751
1770
  deps: AssimilateDeps,
1752
1771
  options: RunAssimilateOptions = {},
1772
+ ): Promise<number> {
1773
+ return runAssimilationFlow(args, deps, options);
1774
+ }
1775
+
1776
+ /** Prepare a host-neutral connection using the same durable assimilation lifecycle. */
1777
+ export async function prepareConnection(
1778
+ args: AssimilateArgs,
1779
+ deps: AssimilateDeps,
1780
+ options: { validateRole: (role: Role) => void; onPrepared: (prepared: PreparedAssimilation) => void; authoritySelectionCommand?: string },
1781
+ ): Promise<number> {
1782
+ return runAssimilationFlow(args, deps, { launch: false, ...options }, options.validateRole, options.authoritySelectionCommand);
1783
+ }
1784
+
1785
+ async function runAssimilationFlow(
1786
+ args: AssimilateArgs,
1787
+ deps: AssimilateDeps,
1788
+ options: RunAssimilateOptions,
1789
+ validateConnectionRole?: (role: Role) => void,
1790
+ authoritySelectionCommand?: string,
1753
1791
  ): Promise<number> {
1754
1792
  const repository = await resolveAssimilationRepository(args, deps);
1755
1793
  if (repository.kind === 'stop') return repository.code;
1756
1794
  const { mode, repositoryContext } = repository.value;
1757
1795
 
1758
- const authorityResolution = await resolveAssimilationAuthority({ args, mode, repositoryContext }, deps);
1796
+ const authorityResolution = await resolveAssimilationAuthority({ args, mode, repositoryContext, authoritySelectionCommand }, deps);
1759
1797
  if (authorityResolution.kind === 'stop') return authorityResolution.code;
1760
1798
  const {
1761
1799
  authority,
@@ -2172,16 +2210,18 @@ export async function runAssimilate(
2172
2210
  }
2173
2211
  }
2174
2212
 
2175
- const cubeRole = await resolveAssimilationCubeRole({
2176
- requestedRole: args.role,
2177
- flags: args.flags,
2178
- cubeDetail,
2179
- isFirstDrone,
2180
- savedLocalRole,
2181
- apiUrl: authority.apiUrl,
2182
- }, deps);
2213
+ const roleInput = {
2214
+ requestedRole: args.role, flags: args.flags, cubeDetail, isFirstDrone,
2215
+ savedLocalRole, apiUrl: authority.apiUrl,
2216
+ };
2217
+ const cubeRole = validateConnectionRole
2218
+ ? resolveConnectionRole(roleInput, deps)
2219
+ : await resolveAssimilationCubeRole(roleInput, deps);
2183
2220
  if (cubeRole.kind === 'stop') return cubeRole.code;
2184
- const { resolvedRole, effectiveModel, cli } = cubeRole.value;
2221
+ const { resolvedRole } = cubeRole.value;
2222
+ validateConnectionRole?.(resolvedRole);
2223
+ const cli = 'cli' in cubeRole.value ? cubeRole.value.cli as BorgCli : undefined;
2224
+ const effectiveModel = 'effectiveModel' in cubeRole.value ? cubeRole.value.effectiveModel as string | null : null;
2185
2225
 
2186
2226
  const seat = await prepareAssimilationSeat({
2187
2227
  apiUrl: auth.apiUrl,
@@ -2202,6 +2242,7 @@ export async function runAssimilate(
2202
2242
  }, deps);
2203
2243
  if (seat.kind === 'stop') return seat.code;
2204
2244
  const { result, assignedRole, sessionExpected } = seat.value;
2245
+ validateConnectionRole?.(assignedRole);
2205
2246
 
2206
2247
  const worktree = await prepareAssimilationWorktree({
2207
2248
  flags: args.flags,
@@ -2266,7 +2307,7 @@ export async function runAssimilate(
2266
2307
  // of that request. The resolver therefore saved the preference against the
2267
2308
  // invoking checkout. Once a sibling exists, save the same choice under its
2268
2309
  // own project key so a later --here launch in that worktree can read it.
2269
- if (spawnedWorktreePath) {
2310
+ if (spawnedWorktreePath && cli !== undefined) {
2270
2311
  try {
2271
2312
  await deps.setCliPreferenceForWorktree(cli, spawnedWorktreePath);
2272
2313
  } catch (err) {
@@ -2281,8 +2322,10 @@ export async function runAssimilate(
2281
2322
  }
2282
2323
 
2283
2324
  try {
2284
- deps.mkdirp(scratchRoot);
2285
- deps.provisionLaunchAccess?.(cli, seatWorktree, launchAccessPaths);
2325
+ if (cli !== undefined) {
2326
+ deps.mkdirp(scratchRoot);
2327
+ deps.provisionLaunchAccess?.(cli, seatWorktree, launchAccessPaths);
2328
+ }
2286
2329
  } catch (err) {
2287
2330
  const message = err instanceof Error ? err.message : String(err);
2288
2331
  deps.stderr(
@@ -2320,6 +2363,14 @@ export async function runAssimilate(
2320
2363
  }
2321
2364
  }
2322
2365
 
2366
+ if (validateConnectionRole) {
2367
+ options.onPrepared?.({
2368
+ cubeId: result.cube_id, cubeName: cubeDetail.name, droneId: result.drone_id,
2369
+ droneLabel: result.drone_label, roleName: assignedRole.name, worktree: seatWorktree,
2370
+ });
2371
+ return 0;
2372
+ }
2373
+
2323
2374
  // gh#793: best-effort GC of orphaned inbox files (evicted/dead drones) in the
2324
2375
  // cube just joined — lazy-on-assimilate, no cron/new command. NEVER blocks or
2325
2376
  // fails the assimilate (whole call swallowed). Local-only signal (CubeDetail
@@ -2371,7 +2422,7 @@ export async function runAssimilate(
2371
2422
  cubeDetail,
2372
2423
  assignedRole,
2373
2424
  apiUrl: auth.apiUrl,
2374
- cli,
2425
+ cli: cli!,
2375
2426
  effectiveModel,
2376
2427
  agentCwd,
2377
2428
  seatWorktree,
package/src/claude.ts CHANGED
@@ -390,6 +390,36 @@ async function main() {
390
390
  buildDefaultSeatCommandDeps(),
391
391
  ));
392
392
  }
393
+ if (process.argv[2] === 'representative') {
394
+ // Loaded on demand: the representative facade is unrelated to agent launch.
395
+ const representative = await import('./representative-cmd.js');
396
+ const parsed = representative.parseRepresentativeArgs(process.argv.slice(3));
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
+ }
403
+ process.stderr.write(chalk.red(`${consolePrefix()}◼ borg representative: ${parsed.error}\n`));
404
+ process.stderr.write(`Run \`borg representative --help\` for usage.\n`);
405
+ process.exit(1);
406
+ }
407
+ const deps = await representative.buildDefaultRepresentativeDeps();
408
+ if (parsed.command.action === 'prepare') {
409
+ process.exit(await representative.runRepresentativePrepare(parsed.command, deps));
410
+ }
411
+ if (parsed.command.action === 'status') {
412
+ process.exit(await representative.runRepresentativeStatus(parsed.command, deps));
413
+ }
414
+ const { pinMcpSeatIdentity } = await import('./cubes.js');
415
+ if (parsed.command.action === 'listen') {
416
+ process.exit(await representative.runRepresentativeListen(parsed.command, deps));
417
+ }
418
+ process.exit(await representative.runRepresentativeMcp(parsed.command, deps, {
419
+ version: getPackageVersion(),
420
+ pinSeat: pinMcpSeatIdentity,
421
+ }));
422
+ }
393
423
  if (process.argv[2] === 'launch-all') {
394
424
  const parsed = parseLaunchAllArgs(process.argv.slice(3));
395
425
  if (!parsed.ok) {
package/src/cli-help.ts CHANGED
@@ -113,6 +113,52 @@ export function launchSeatHelpText(version: string): string {
113
113
  );
114
114
  }
115
115
 
116
+ export function representativeHelpText(version: string): string {
117
+ return (
118
+ `borg representative (borgmcp ${version}) — connect a human representative (e.g. Hermes) to one Coordinator\n\n` +
119
+ `Vocabulary:\n` +
120
+ ` cube One repository's shared coordination space on your Borg server.\n` +
121
+ ` drone One connected agent session in a cube; its role defines how it works.\n` +
122
+ ` human seat The one role in a cube that speaks with the human's authority.\n` +
123
+ ` Coordinator The drone holding the human seat. It plans the work and dispatches the other drones.\n` +
124
+ ` human representative A SEPARATE automated drone, under its own non-human-seat role, that relays the\n` +
125
+ ` human's requests, questions and decisions to that one Coordinator and reads its\n` +
126
+ ` replies. It is not the human, never takes the human seat, and never addresses\n` +
127
+ ` other drones or broadcasts.\n\n` +
128
+ `Usage:\n` +
129
+ ` borg representative prepare --coordinator <drone-label> [--role <name>] [--worktree <name>] [--host <host>] [--rebind]\n` +
130
+ ` borg representative status [--worktree <path>]\n` +
131
+ ` borg representative mcp [--worktree <path>]\n` +
132
+ ` borg representative listen --worktree <path> [--replay-after <entry_id>]\n` +
133
+ ` borg representative --help\n\n` +
134
+ `Commands:\n` +
135
+ ` prepare Create or resume the representative's own drone in this repository's cube and bind it to\n` +
136
+ ` exactly the named Coordinator drone. Launches no agent CLI and changes no other drone.\n` +
137
+ ` Fails if that Coordinator is missing, evicted, duplicated, or not in the human seat;\n` +
138
+ ` another drone is never chosen instead.\n` +
139
+ ` status Show the saved binding, re-check it against the live cube, and list unresolved sends.\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` +
142
+ `Options:\n` +
143
+ ` --replay-after <entry_id> listen: replay later retained hints after the last durably enqueued entry\n` +
144
+ ` --coordinator <drone-label> Exact label of the Coordinator drone (see \`borg drones\`). Required for prepare.\n` +
145
+ ` --role <name> Existing non-human-seat role for the representative (default: hermes-representative)\n` +
146
+ ` --worktree <name> prepare: create the drone in a new linked worktree of that name\n` +
147
+ ` --worktree <path> status/mcp/listen: absolute path of the prepared representative worktree\n` +
148
+ ` --host <host> prepare: explicit Borg server, as in \`borg assimilate --host\`\n` +
149
+ ` --rebind prepare: explicitly replace the saved cube/Coordinator selection\n` +
150
+ ` --help, -h Show this help\n\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` +
154
+ `A retried send reuses its request id so the server stores it once; an unknown outcome is reported as\n` +
155
+ `ambiguous, with its cause, and never re-sent automatically. "User-authorized" is the representative's own label:\n` +
156
+ `the Borg server does not verify it, and one relayed decision is not broader human approval.\n` +
157
+ `No command takes a credential; the drone's saved connection stays in Borg's private store.\n` +
158
+ `Details: docs/HUMAN_REPRESENTATIVE.md\n`
159
+ );
160
+ }
161
+
116
162
  export function doctorHelpText(version: string): string {
117
163
  return (
118
164
  `borg doctor (borgmcp ${version}) — inspect Borg agent integrations without changing them\n\n` +
@@ -139,6 +185,7 @@ export function clientSubcommandHelpText(
139
185
  case 'drones': return seatsHelpText(version);
140
186
  case 'launch': return launchSeatHelpText(version);
141
187
  case 'launch-all': return launchAllHelpText(version);
188
+ case 'representative': return representativeHelpText(version);
142
189
  case 'doctor': return doctorHelpText(version);
143
190
  default: return null;
144
191
  }
@@ -186,6 +233,7 @@ export function topLevelHelpText(version: string): string {
186
233
  ` borg drones List this machine's registered drones and worktrees\n` +
187
234
  ` borg launch <drone-label-or-id-prefix> Reopen one registered drone from its worktree\n` +
188
235
  ` borg launch-all [cube] Launch all drone worktrees of a cube (default: active cube)\n` +
236
+ ` borg representative prepare|status|mcp|listen Let an MCP host (e.g. Hermes) speak for you to one Coordinator drone\n` +
189
237
  ` borg server <command> [arguments]\n` +
190
238
  ` borg --cli claude|codex|opencode Launch that agent CLI directly\n` +
191
239
  ` borg --version Show installed version\n\n` +
@@ -15,6 +15,7 @@ const REPOSITORY_URL = "https://github.com/Byte-Ventures/borg-mcp-client";
15
15
  const LOCAL_SERVER_URL = `${REPOSITORY_URL}/blob/main/docs/LOCAL_SERVER.md`;
16
16
  const SEAT_LIFECYCLE_URL = `${REPOSITORY_URL}/blob/main/docs/SEAT_LIFECYCLE.md`;
17
17
  const DOCUMENTS_URL = `${REPOSITORY_URL}/blob/main/docs/DOCUMENTS.md`;
18
+ const HUMAN_REPRESENTATIVE_URL = `${REPOSITORY_URL}/blob/main/docs/HUMAN_REPRESENTATIVE.md`;
18
19
 
19
20
  export interface DocsSection {
20
21
  /** logical topic key */
@@ -91,6 +92,13 @@ export const DOCS_SECTIONS: DocsSection[] = [
91
92
  summary: "Immutable cube-local Markdown or plain text, revisions, removal, and structured activity-log citations.",
92
93
  keywords: ["document", "documents", "citation", "cite", "durable content", "supersede", "revision", "borg_put-document", "borg_get-document"],
93
94
  },
95
+ {
96
+ slug: "human-representative",
97
+ title: "Human representative",
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, replayable bounded reads with a delivered checkpoint, and supervised listener hints.",
100
+ keywords: ["representative", "hermes", "human representative", "delegate", "proxy", "borg representative", "borg_representative-send", "mcp host", "request_id", "ambiguous"],
101
+ },
94
102
  {
95
103
  slug: "tools",
96
104
  title: "Tool reference",
@@ -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
 
@@ -90,11 +91,17 @@ async function readState(): Promise<CursorFile> {
90
91
  }
91
92
  }
92
93
 
93
- async function writeState(state: CursorFile): Promise<void> {
94
+ async function writeState(state: CursorFile, continuationGuard?: () => Promise<void>): Promise<void> {
94
95
  await mkdir(dirname(CURSOR_FILE), { recursive: true });
95
96
  const temporary = `${CURSOR_FILE}.${process.pid}.${Date.now()}.tmp`;
96
97
  await writeFile(temporary, JSON.stringify(state, null, 2) + '\n', { mode: 0o600 });
97
- await rename(temporary, CURSOR_FILE);
98
+ try {
99
+ if (continuationGuard) await continuationGuard();
100
+ await rename(temporary, CURSOR_FILE);
101
+ } catch (error) {
102
+ await unlink(temporary).catch(() => undefined);
103
+ throw error;
104
+ }
98
105
  }
99
106
 
100
107
  async function withLock<T>(operation: () => Promise<T>): Promise<T> {
@@ -140,13 +147,58 @@ export async function getLocalServerCursor(
140
147
  return state.cursors[key] ?? null;
141
148
  }
142
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
+
143
193
  export async function advanceLocalServerCursor(
144
194
  binding: LocalServerCursorBinding,
145
195
  cursor: LocalServerCursor,
196
+ continuationGuard?: () => Promise<void>,
146
197
  ): Promise<void> {
147
198
  if (!validCursor(cursor)) throw new Error('invalid local Borg server cursor');
148
199
  const key = cursorKey(binding);
149
200
  await withLock(async () => {
201
+ if (continuationGuard) await continuationGuard();
150
202
  const state = await readState();
151
203
  const prior = state.cursors[key];
152
204
  if (
@@ -157,7 +209,7 @@ export async function advanceLocalServerCursor(
157
209
  return;
158
210
  }
159
211
  state.cursors[key] = cursor;
160
- await writeState(state);
212
+ await writeState(state, continuationGuard);
161
213
  });
162
214
  }
163
215
 
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