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
@@ -348,6 +348,7 @@ async function localServerRequest<T>(
348
348
  payload?: Record<string, unknown>,
349
349
  options: {
350
350
  retryMode?: AuthedFetchRetryMode;
351
+ continuationGuard?: () => Promise<void>;
351
352
  decodePayload?: (value: unknown) => T;
352
353
  } = {},
353
354
  ): Promise<T | null> {
@@ -366,6 +367,7 @@ async function localServerRequest<T>(
366
367
  body: JSON.stringify(createProtocolEnvelope(randomUUID(), payload)),
367
368
  }),
368
369
  retryMode: options.retryMode,
370
+ continuationGuard: options.continuationGuard,
369
371
  }), true, options.decodePayload);
370
372
  }
371
373
 
@@ -509,7 +511,7 @@ async function localOwnerConnection(connection?: RemoteConnection): Promise<Remo
509
511
  };
510
512
  }
511
513
 
512
- async function localCubeComposition(active: ActiveCube): Promise<{
514
+ async function localCubeComposition(active: ActiveCube, continuationGuard?: () => Promise<void>): Promise<{
513
515
  cube: any;
514
516
  roles: any[];
515
517
  drones: any[];
@@ -518,9 +520,9 @@ async function localCubeComposition(active: ActiveCube): Promise<{
518
520
  }> {
519
521
  const base = `/api/cubes/${active.cubeId}`;
520
522
  const [cubePayload, rolePayload, dronePayload] = await Promise.all([
521
- localServerRequest<{ cube: any }>(active, base, 'GET'),
522
- localServerRequest<{ roles: any[] }>(active, `${base}/roles`, 'GET'),
523
- localServerRequest<{ drones: any[] }>(active, `${base}/drones`, 'GET'),
523
+ localServerRequest<{ cube: any }>(active, base, 'GET', undefined, { continuationGuard }),
524
+ localServerRequest<{ roles: any[] }>(active, `${base}/roles`, 'GET', undefined, { continuationGuard }),
525
+ localServerRequest<{ drones: any[] }>(active, `${base}/drones`, 'GET', undefined, { continuationGuard }),
524
526
  ]);
525
527
  if (!cubePayload || !rolePayload || !dronePayload) {
526
528
  throw new Error('Local Borg server returned an incomplete cube response');
@@ -576,6 +578,7 @@ async function localReadLogPage(
576
578
  cursor?: LocalServerCursor | null;
577
579
  limit?: number;
578
580
  retryMode?: AuthedFetchRetryMode;
581
+ continuationGuard?: () => Promise<void>;
579
582
  } = {},
580
583
  ): Promise<any> {
581
584
  const payload = await localServerRequest(
@@ -586,7 +589,7 @@ async function localReadLogPage(
586
589
  cursor: opts.cursor ?? null,
587
590
  ...(opts.limit === undefined ? {} : { limit: opts.limit }),
588
591
  },
589
- { retryMode: opts.retryMode, decodePayload: decodeReadLogResult },
592
+ { retryMode: opts.retryMode, continuationGuard: opts.continuationGuard, decodePayload: decodeReadLogResult },
590
593
  );
591
594
  if (!payload) throw new Error('Local Borg server returned an empty log response');
592
595
  return payload;
@@ -691,6 +694,7 @@ export async function hasPendingWakeEntry(
691
694
  async function resolveLocalLogCursor(
692
695
  active: ActiveCube,
693
696
  since: string,
697
+ continuationGuard?: () => Promise<void>,
694
698
  ): Promise<LocalServerCursor | null> {
695
699
  const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
696
700
  .test(since);
@@ -702,7 +706,7 @@ async function resolveLocalLogCursor(
702
706
  let scanCursor: LocalServerCursor | null = null;
703
707
  let timestampCursor: LocalServerCursor | null = null;
704
708
  for (;;) {
705
- const page = await localReadLogPage(active, { cursor: scanCursor, limit: 500 });
709
+ const page = await localReadLogPage(active, { cursor: scanCursor, limit: 500, continuationGuard });
706
710
  for (const entry of page.entries as any[]) {
707
711
  if (isUuid && entry.id === since) {
708
712
  return { id: entry.id, created_at: entry.created_at };
@@ -749,6 +753,7 @@ async function authedFetch(
749
753
  serverTrustIdentity?: string;
750
754
  localSessionCredentialRef?: string;
751
755
  retryMode?: AuthedFetchRetryMode;
756
+ continuationGuard?: () => Promise<void>;
752
757
  } = {}
753
758
  ): Promise<Response> {
754
759
  const {
@@ -758,6 +763,7 @@ async function authedFetch(
758
763
  serverTrustIdentity: suppliedTrustIdentity,
759
764
  localSessionCredentialRef,
760
765
  retryMode,
766
+ continuationGuard,
761
767
  headers,
762
768
  ...rest
763
769
  } = init;
@@ -805,6 +811,9 @@ async function authedFetch(
805
811
  const method = ((rest.method as string | undefined) ?? 'GET').toUpperCase();
806
812
 
807
813
  const buildRequest = async (tok: string): Promise<Response> => {
814
+ // Rechecked for every HTTP attempt, including transport and 429 retries.
815
+ // Keep this callback out of RequestInit and preserve unguarded callers.
816
+ if (continuationGuard) await continuationGuard();
808
817
  const finalHeaders: Record<string, string> = {
809
818
  'Authorization': `Bearer ${tok}`,
810
819
  ...(headers as Record<string, string> | undefined),
@@ -1091,9 +1100,14 @@ export async function readLog(
1091
1100
  apiUrl: string,
1092
1101
  opts: {
1093
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;
1094
1106
  limit?: number;
1095
1107
  unreadOnly?: boolean;
1096
1108
  serverTrustIdentity?: string;
1109
+ /** Refuse continuation before any cursor access/advance or HTTP attempt. */
1110
+ continuationGuard?: () => Promise<void>;
1097
1111
  } = {}
1098
1112
  ): Promise<{
1099
1113
  entries: any[];
@@ -1111,17 +1125,26 @@ export async function readLog(
1111
1125
  opts.serverTrustIdentity,
1112
1126
  );
1113
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
+ }
1131
+ if (opts.continuationGuard) await opts.continuationGuard();
1132
+ if (opts.cursor !== undefined) cursor = opts.cursor;
1114
1133
  if (opts.unreadOnly) cursor = await getLocalServerCursor(localCursorBinding(local));
1115
- if (opts.since !== undefined) cursor = await resolveLocalLogCursor(local, opts.since);
1134
+ if (opts.since !== undefined) cursor = await resolveLocalLogCursor(local, opts.since, opts.continuationGuard);
1116
1135
  let page = await localReadLogPage(local, {
1117
1136
  cursor,
1118
1137
  limit: opts.limit,
1138
+ continuationGuard: opts.continuationGuard,
1119
1139
  // Keep the cursor payload stable across a lost response; do not re-read or
1120
1140
  // advance local state until one response has been decoded successfully.
1121
- ...(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 } : {}),
1122
1143
  });
1123
1144
  if (opts.unreadOnly && page.cursor) {
1124
- await advanceLocalServerCursor(localCursorBinding(local), page.cursor);
1145
+ if (opts.continuationGuard) await opts.continuationGuard();
1146
+ await advanceLocalServerCursor(localCursorBinding(local), page.cursor,
1147
+ ...(opts.continuationGuard ? [opts.continuationGuard] as const : [] as const));
1125
1148
  }
1126
1149
  const entries = [...page.entries];
1127
1150
  const backlog = entries.length + (typeof page.behind_by === 'number' ? page.behind_by : 0);
@@ -1135,14 +1158,17 @@ export async function readLog(
1135
1158
  cursor: page.cursor,
1136
1159
  limit: Math.min(500, DIGEST_FETCH_CAP - entries.length),
1137
1160
  retryMode: 'unread-cursor',
1161
+ continuationGuard: opts.continuationGuard,
1138
1162
  });
1139
1163
  if (page.cursor) {
1140
- await advanceLocalServerCursor(localCursorBinding(local), page.cursor);
1164
+ if (opts.continuationGuard) await opts.continuationGuard();
1165
+ await advanceLocalServerCursor(localCursorBinding(local), page.cursor,
1166
+ ...(opts.continuationGuard ? [opts.continuationGuard] as const : [] as const));
1141
1167
  }
1142
1168
  entries.push(...page.entries);
1143
1169
  }
1144
1170
  }
1145
- const composed = await localCubeComposition(local);
1171
+ const composed = await localCubeComposition(local, opts.continuationGuard);
1146
1172
  return {
1147
1173
  entries,
1148
1174
  drones: composed.drones,
@@ -1527,10 +1553,22 @@ export async function appendLog(
1527
1553
  class?: string;
1528
1554
  documents?: string[];
1529
1555
  serverTrustIdentity?: string;
1556
+ /**
1557
+ * Caller-owned idempotency key. A caller that may retry the SAME logical
1558
+ * post across calls (or processes) supplies it so the server deduplicates;
1559
+ * omitted, every call is a distinct post.
1560
+ */
1561
+ postId?: string;
1562
+ /**
1563
+ * false: exactly one transport attempt. For a caller that owns retries and
1564
+ * must know a typed refusal answered its only attempt (default: one
1565
+ * automatic same-post_id retry after a connection reset).
1566
+ */
1567
+ transportRetry?: boolean;
1530
1568
  },
1531
1569
  ): Promise<ReturnType<typeof decodeAppendLogResult>> {
1532
1570
  const to = normalizeLogAudience(opts?.to);
1533
- const postId = randomUUID();
1571
+ const postId = opts.postId ?? randomUUID();
1534
1572
  const local = await localAuthorityContext(
1535
1573
  sessionToken,
1536
1574
  apiUrl,
@@ -1548,7 +1586,10 @@ export async function appendLog(
1548
1586
  `/api/cubes/${local.cubeId}/logs`,
1549
1587
  'POST',
1550
1588
  { ...request },
1551
- { retryMode: 'append-log', decodePayload: decodeAppendLogResult },
1589
+ {
1590
+ ...(opts.transportRetry === false ? {} : { retryMode: 'append-log' as const }),
1591
+ decodePayload: decodeAppendLogResult,
1592
+ },
1552
1593
  );
1553
1594
  if (!payload) throw new Error('Local Borg server returned an empty log response');
1554
1595
  return payload;
@@ -0,0 +1,369 @@
1
+ /**
2
+ * `borg representative <prepare|status|mcp>` — operator entry for the human
3
+ * representative ("Hermes") connection.
4
+ *
5
+ * prepare binds ONE dedicated non-human-seat drone in this repository's cube
6
+ * to ONE explicitly named Coordinator drone. It creates/resumes the
7
+ * seat through the launch-free assimilate seam: no agent CLI starts
8
+ * and no existing drone's identity is touched.
9
+ * status shows the saved binding and re-checks it against the live cube.
10
+ * mcp serves the restricted stdio MCP facade for a generic MCP host.
11
+ *
12
+ * No command accepts a credential: the seat bearer stays in the private seat
13
+ * store and is hydrated in-process only.
14
+ */
15
+
16
+ import { realpathSync } from 'node:fs';
17
+ import { isAbsolute, resolve } from 'node:path';
18
+ import type { Readable, Writable } from 'node:stream';
19
+ import type { ActiveCube } from './cubes.js';
20
+ import { normalizeServerEndpoint } from './server-endpoint.js';
21
+ import { validateName } from './name-validator.js';
22
+ import {
23
+ RepresentativeError,
24
+ assertRepresentativeRole,
25
+ representativeStatus,
26
+ resolveCoordinator,
27
+ type RepresentativeBackend,
28
+ type RepresentativeContext,
29
+ } from './representative-core.js';
30
+ import {
31
+ RepresentativeStoreError,
32
+ bindingFingerprint,
33
+ representativeRecoveryCommand,
34
+ type RepresentativeBinding,
35
+ type RepresentativeStore,
36
+ } from './representative-store.js';
37
+
38
+ import { shellEscape } from './shell-escape.js';
39
+
40
+ export const DEFAULT_REPRESENTATIVE_ROLE = 'hermes-representative';
41
+
42
+ export type RepresentativeCommand =
43
+ | { action: 'prepare'; coordinator: string; role: string; rebind: boolean; worktreeName?: string; host?: string }
44
+ | { action: 'status'; worktree?: string }
45
+ | { action: 'mcp'; worktree?: string }
46
+ | { action: 'listen'; worktree?: string; replayAfter?: string };
47
+
48
+ export type ParsedRepresentativeArgs =
49
+ | { ok: true; command: RepresentativeCommand }
50
+ | { ok: false; error: string };
51
+
52
+ export interface RepresentativeCmdDeps {
53
+ cwd(): string;
54
+ findProjectRoot(dir: string): string;
55
+ /** Hydrates the saved seat bound to exactly this worktree, or null. */
56
+ hydrateSeat(worktree: string): Promise<ActiveCube | null>;
57
+ /** Launch-free seat creation/resume; never starts an agent CLI. */
58
+ prepareSeat(input: { role: string; coordinator?: string; worktreeName?: string; host?: string; resume?: boolean }): Promise<{ code: number; worktree?: string }>;
59
+ backendFor(active: ActiveCube): RepresentativeBackend | Promise<RepresentativeBackend>;
60
+ store: RepresentativeStore;
61
+ stdout(text: string): void;
62
+ stderr(text: string): void;
63
+ }
64
+
65
+ export function parseRepresentativeArgs(args: readonly string[]): ParsedRepresentativeArgs {
66
+ const [action, ...rest] = args;
67
+ if (action !== 'prepare' && action !== 'status' && action !== 'mcp' && action !== 'listen') {
68
+ return { ok: false, error: 'expected one of: prepare, status, mcp, listen' };
69
+ }
70
+ const values: Record<string, string> = {};
71
+ let rebind = false;
72
+ const valueFlags = action === 'prepare' ? ['--coordinator', '--role', '--worktree', '--host'] : action === 'listen' ? ['--worktree', '--replay-after'] : ['--worktree'];
73
+ for (let i = 0; i < rest.length; i += 1) {
74
+ const arg = rest[i];
75
+ if (action === 'prepare' && arg === '--rebind') {
76
+ rebind = true;
77
+ } else if (valueFlags.includes(arg)) {
78
+ const next = rest[i + 1];
79
+ if (typeof next !== 'string' || next.length === 0 || next.startsWith('-')) {
80
+ return { ok: false, error: `${arg} requires a value` };
81
+ }
82
+ values[arg] = next;
83
+ i += 1;
84
+ } else {
85
+ return {
86
+ ok: false,
87
+ error: `unknown argument: ${arg}. Supported: ${[...valueFlags, ...(action === 'prepare' ? ['--rebind'] : [])].join(', ')}`,
88
+ };
89
+ }
90
+ }
91
+ if (action !== 'prepare') {
92
+ const worktree = values['--worktree'];
93
+ if (worktree !== undefined && !isAbsolute(worktree)) {
94
+ return { ok: false, error: '--worktree must be an absolute path to the representative worktree' };
95
+ }
96
+ const replayAfter = values['--replay-after'];
97
+ if (replayAfter && !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(replayAfter)) {
98
+ return { ok: false, error: '--replay-after must be an entry UUID' };
99
+ }
100
+ return { ok: true, command: { action, ...(worktree ? { worktree } : {}), ...(replayAfter ? { replayAfter } : {}) } };
101
+ }
102
+ const coordinator = values['--coordinator'];
103
+ if (!coordinator) {
104
+ return {
105
+ ok: false,
106
+ error: '--coordinator <drone-label> is required: name the exact Coordinator drone (see `borg drones`). It is never chosen for you.',
107
+ };
108
+ }
109
+ const role = values['--role'] ?? DEFAULT_REPRESENTATIVE_ROLE;
110
+ if (!validateName(role).ok) return { ok: false, error: '--role must be a valid role name' };
111
+ if (values['--worktree'] !== undefined && !validateName(values['--worktree']).ok) {
112
+ return { ok: false, error: '--worktree for prepare is a new worktree NAME (as in `borg assimilate --worktree`), not a path' };
113
+ }
114
+ return {
115
+ ok: true,
116
+ command: {
117
+ action,
118
+ coordinator,
119
+ role,
120
+ rebind,
121
+ ...(values['--worktree'] ? { worktreeName: values['--worktree'] } : {}),
122
+ ...(values['--host'] ? { host: values['--host'] } : {}),
123
+ },
124
+ };
125
+ }
126
+
127
+ function canonicalWorktree(path: string, deps: Pick<RepresentativeCmdDeps, 'findProjectRoot'>): string {
128
+ let real = resolve(path);
129
+ try {
130
+ real = realpathSync(real);
131
+ } catch {
132
+ /* a missing path simply finds no binding below */
133
+ }
134
+ return deps.findProjectRoot(real);
135
+ }
136
+
137
+ function describeError(error: unknown): string {
138
+ if (error instanceof RepresentativeError || error instanceof RepresentativeStoreError) {
139
+ return `${error.code}: ${error.message}`;
140
+ }
141
+ return error instanceof Error ? error.message : String(error);
142
+ }
143
+
144
+ function sameSeat(binding: RepresentativeBinding, active: ActiveCube): boolean {
145
+ return active.cubeId === binding.cubeId &&
146
+ active.droneId === binding.representativeDroneId &&
147
+ active.apiUrl === binding.origin &&
148
+ active.serverTrustIdentity === binding.trustIdentity;
149
+ }
150
+
151
+ /** Load the saved binding and prove the worktree's hydrated seat is still that exact seat. Fails closed. */
152
+ export async function resolveRepresentativeContext(
153
+ worktree: string,
154
+ deps: Pick<RepresentativeCmdDeps, 'hydrateSeat' | 'backendFor' | 'store'>,
155
+ ): Promise<RepresentativeContext> {
156
+ const binding = await deps.store.getBinding(worktree);
157
+ if (!binding) {
158
+ throw new RepresentativeError(
159
+ 'NOT_PREPARED',
160
+ `No representative connection is prepared for ${worktree}. Run \`borg representative prepare --coordinator <drone-label>\` there first.`,
161
+ );
162
+ }
163
+ const active = await deps.hydrateSeat(worktree);
164
+ if (!active) {
165
+ throw new RepresentativeError(
166
+ 'SEAT_UNAVAILABLE',
167
+ `The saved representative connection is missing, reset or rejected. Run \`${representativeRecoveryCommand(binding)}\`.`,
168
+ );
169
+ }
170
+ if (!sameSeat(binding, active)) {
171
+ throw new RepresentativeError(
172
+ 'BINDING_MISMATCH',
173
+ `This worktree's saved connection is not the bound server/cube/drone. Run \`${representativeRecoveryCommand(binding)}\` to confirm a rebind; nothing is sent until then.`,
174
+ );
175
+ }
176
+ return { binding, backend: await deps.backendFor(active), store: deps.store };
177
+ }
178
+
179
+ export function hermesConfigSnippet(worktree: string): string {
180
+ return (
181
+ `mcp_servers:\n` +
182
+ ` borg-representative:\n` +
183
+ ` command: borg\n` +
184
+ ` args: ["representative", "mcp", "--worktree", ${JSON.stringify(worktree)}]\n`
185
+ );
186
+ }
187
+
188
+ export async function runRepresentativePrepare(
189
+ command: Extract<RepresentativeCommand, { action: 'prepare' }>,
190
+ deps: RepresentativeCmdDeps,
191
+ ): Promise<number> {
192
+ try {
193
+ // Same canonical key as status/mcp, so a symlinked cwd cannot bind under a path they never look up.
194
+ let worktree = canonicalWorktree(deps.cwd(), deps);
195
+ const existing = command.worktreeName === undefined ? await deps.hydrateSeat(worktree) : null;
196
+ if (existing && command.host && normalizeServerEndpoint(command.host) !== normalizeServerEndpoint(existing.apiUrl)) {
197
+ throw new RepresentativeError('BINDING_MISMATCH', 'The explicit --host does not match this worktree\'s saved connection. Use a worktree connected to the requested server; nothing was rebound.');
198
+ }
199
+ const prepared = await deps.prepareSeat({
200
+ role: command.role,
201
+ coordinator: command.coordinator,
202
+ ...(existing ? { resume: true } : {}),
203
+ ...(command.worktreeName ? { worktreeName: command.worktreeName } : {}),
204
+ ...(command.host ? { host: command.host } : {}),
205
+ });
206
+ if (prepared.code !== 0) {
207
+ deps.stderr('borg representative: the representative connection could not be prepared; nothing was bound.\n');
208
+ return prepared.code || 1;
209
+ }
210
+ if (prepared.worktree) worktree = canonicalWorktree(prepared.worktree, deps);
211
+ const active = await deps.hydrateSeat(worktree);
212
+ if (!active || !active.serverTrustIdentity) {
213
+ throw new RepresentativeError('SEAT_UNAVAILABLE', `No usable saved connection was found for ${worktree}.`);
214
+ }
215
+ const backend = await deps.backendFor(active);
216
+ const me = await backend.whoami();
217
+ if (me.cube_id !== active.cubeId || me.drone_id !== active.droneId) {
218
+ throw new RepresentativeError('BINDING_MISMATCH', 'The server does not recognise this worktree\'s saved connection.');
219
+ }
220
+ const roster = await backend.roster();
221
+ const self = roster.drones.find((drone) => drone.id === me.drone_id);
222
+ const selfRole = roster.roles.find((role) => role.id === self?.role_id);
223
+ if (!self || !selfRole) throw new RepresentativeError('SEAT_UNAVAILABLE', 'The representative drone is not active in the cube.');
224
+ assertRepresentativeRole(selfRole, self);
225
+ if (selfRole.name.toLowerCase() !== command.role.toLowerCase()) {
226
+ throw new RepresentativeError(
227
+ 'REPRESENTATIVE_ROLE_MISMATCH',
228
+ `This worktree's drone ${self.label} holds role ${JSON.stringify(selfRole.name)}, not ${JSON.stringify(command.role)}. ` +
229
+ 'The representative needs its own dedicated drone. Use the preparation syntax in `borg representative --help` with an explicit Coordinator and the intended role or new worktree name.',
230
+ );
231
+ }
232
+ const coordinator = resolveCoordinator(roster, { label: command.coordinator, selfDroneId: me.drone_id });
233
+ const binding: RepresentativeBinding = {
234
+ worktree,
235
+ origin: active.apiUrl,
236
+ trustIdentity: active.serverTrustIdentity,
237
+ cubeId: me.cube_id,
238
+ cubeName: me.cube_name,
239
+ representativeDroneId: me.drone_id,
240
+ representativeLabel: me.drone_label,
241
+ representativeRoleName: selfRole.name,
242
+ coordinatorDroneId: coordinator.drone.id,
243
+ coordinatorLabel: coordinator.drone.label,
244
+ coordinatorRoleName: coordinator.role.name,
245
+ ...(active.repositoryOrigin ? { repositoryOrigin: active.repositoryOrigin } : {}),
246
+ boundAt: new Date().toISOString(),
247
+ };
248
+ const outcome = await deps.store.saveBinding(binding, { rebind: command.rebind });
249
+ deps.stdout(
250
+ `◼ Human representative connection ${outcome === 'unchanged' ? 'resumed' : outcome}.\n` +
251
+ ` cube: ${binding.cubeName} (${binding.cubeId})\n` +
252
+ ` representative: ${binding.representativeLabel} — role ${binding.representativeRoleName} (not a human seat)\n` +
253
+ ` coordinator: ${binding.coordinatorLabel} — role ${binding.coordinatorRoleName} (human seat)\n` +
254
+ ` worktree: ${worktree}\n\n` +
255
+ `No agent CLI was launched. Serve it to a generic MCP host with:\n` +
256
+ ` borg representative mcp --worktree ${worktree}\n\n` +
257
+ `Example host configuration (no secrets belong here):\n${hermesConfigSnippet(worktree)}`,
258
+ );
259
+ return 0;
260
+ } catch (error) {
261
+ deps.stderr(`◼ borg representative prepare: ${describeError(error)}\n`);
262
+ return 1;
263
+ }
264
+ }
265
+
266
+ export async function runRepresentativeStatus(
267
+ command: Extract<RepresentativeCommand, { action: 'status' }>,
268
+ deps: RepresentativeCmdDeps,
269
+ ): Promise<number> {
270
+ try {
271
+ const worktree = canonicalWorktree(command.worktree ?? deps.cwd(), deps);
272
+ const ctx = await resolveRepresentativeContext(worktree, deps);
273
+ const status = await representativeStatus(ctx);
274
+ const { representativeOwnership } = await import('./representative-owner.js');
275
+ const ownership = await representativeOwnership(ctx.binding);
276
+ const { representativeListenerStatus } = await import('./representative-listener.js');
277
+ const listener = await representativeListenerStatus(ctx.binding);
278
+ deps.stdout(`${JSON.stringify({ ...status, ownership, listener }, null, 2)}\n`);
279
+ return status.connected ? 0 : 1;
280
+ } catch (error) {
281
+ deps.stderr(`◼ borg representative status: ${describeError(error)}\n`);
282
+ return 1;
283
+ }
284
+ }
285
+
286
+ /**
287
+ * Serve the stdio facade. The binding selection is captured at startup; a later
288
+ * operator rebind is NOT picked up by a running process — calls fail closed
289
+ * until the host restarts it.
290
+ */
291
+ export async function runRepresentativeMcp(
292
+ command: Extract<RepresentativeCommand, { action: 'mcp' }>,
293
+ deps: RepresentativeCmdDeps,
294
+ io: { version: string; pinSeat?: (active: ActiveCube) => void; stdin?: Readable; stdout?: Writable; heartbeatIntervalMs?: number },
295
+ ): Promise<number> {
296
+ let worktree: string;
297
+ let pinned: RepresentativeBinding;
298
+ try {
299
+ worktree = canonicalWorktree(command.worktree ?? deps.cwd(), deps);
300
+ pinned = (await resolveRepresentativeContext(worktree, deps)).binding;
301
+ const active = await deps.hydrateSeat(worktree);
302
+ if (active) io.pinSeat?.(active);
303
+ } catch (error) {
304
+ // stdout is reserved for JSON-RPC frames; refuse to start on stderr only.
305
+ deps.stderr(`◼ borg representative mcp: ${describeError(error)}\n`);
306
+ return 1;
307
+ }
308
+ const { serveRepresentativeMcp } = await import('./representative-mcp.js');
309
+ const served = await serveRepresentativeMcp({
310
+ version: io.version,
311
+ heartbeatIntervalMs: io.heartbeatIntervalMs,
312
+ ...(io.stdin ? { stdin: io.stdin } : {}),
313
+ ...(io.stdout ? { stdout: io.stdout } : {}),
314
+ // The full generation, so any rebind (same selection included) is refused.
315
+ pinnedFingerprint: bindingFingerprint(pinned),
316
+ context: () => resolveRepresentativeContext(worktree, deps),
317
+ });
318
+ const stdin = io.stdin ?? process.stdin;
319
+ stdin.once('end', () => { void served.close(); });
320
+ await served.closed;
321
+ return 0;
322
+ }
323
+
324
+ export async function buildDefaultRepresentativeDeps(): Promise<RepresentativeCmdDeps> {
325
+ const [{ findProjectRoot, getActiveCubeForWorktree }, { createSeatBackend }, { createRepresentativeStore }] =
326
+ await Promise.all([
327
+ import('./cubes.js'),
328
+ import('./representative-core.js'),
329
+ import('./representative-store.js'),
330
+ ]);
331
+ return {
332
+ cwd: () => process.cwd(),
333
+ findProjectRoot,
334
+ hydrateSeat: (worktree) => getActiveCubeForWorktree(worktree),
335
+ prepareSeat: async ({ role, coordinator, worktreeName, host, resume }) => {
336
+ const [{ prepareConnection }, { buildDefaultAssimilateDeps }] = await Promise.all([
337
+ import('./assimilate-cmd.js'),
338
+ import('./assimilate-deps.js'),
339
+ ]);
340
+ let worktree: string | undefined;
341
+ const code = await prepareConnection(
342
+ { role, flags: { ...(resume ? { here: true } : {}), ...(worktreeName ? { worktree: worktreeName } : {}), ...(host ? { server: host } : {}) } },
343
+ buildDefaultAssimilateDeps(),
344
+ {
345
+ validateRole: assertRepresentativeRole,
346
+ onPrepared: (prepared) => { worktree = prepared.worktree; },
347
+ authoritySelectionCommand: 'borg representative prepare --host <host>' +
348
+ (coordinator ? ` --coordinator ${shellEscape(coordinator)}` : '') +
349
+ ` --role ${shellEscape(role)}` +
350
+ (worktreeName ? ` --worktree ${shellEscape(worktreeName)}` : ''),
351
+ },
352
+ );
353
+ return { code, ...(worktree ? { worktree } : {}) };
354
+ },
355
+ backendFor: createSeatBackend,
356
+ store: createRepresentativeStore(),
357
+ stdout: (text) => { process.stdout.write(text); },
358
+ stderr: (text) => { process.stderr.write(text); },
359
+ };
360
+ }
361
+
362
+ export async function runRepresentativeListen(
363
+ command: Extract<RepresentativeCommand, { action: 'listen' }>,
364
+ deps: RepresentativeCmdDeps,
365
+ options: import('./representative-listener.js').ListenerOptions = {},
366
+ ): Promise<number> {
367
+ const { runListener } = await import('./representative-listener.js');
368
+ return runListener(command, deps, options);
369
+ }