borgmcp 5.4.0 → 5.5.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 (72) hide show
  1. package/README.md +12 -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 +22 -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 +42 -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 +1 -1
  17. package/dist/local-server-cursor.d.ts.map +1 -1
  18. package/dist/local-server-cursor.js +14 -4
  19. package/dist/local-server-cursor.js.map +1 -1
  20. package/dist/remote-client.d.ts +14 -0
  21. package/dist/remote-client.d.ts.map +1 -1
  22. package/dist/remote-client.js +30 -14
  23. package/dist/remote-client.js.map +1 -1
  24. package/dist/representative-cmd.d.ts +87 -0
  25. package/dist/representative-cmd.d.ts.map +1 -0
  26. package/dist/representative-cmd.js +286 -0
  27. package/dist/representative-cmd.js.map +1 -0
  28. package/dist/representative-core.d.ts +197 -0
  29. package/dist/representative-core.d.ts.map +1 -0
  30. package/dist/representative-core.js +493 -0
  31. package/dist/representative-core.js.map +1 -0
  32. package/dist/representative-mcp.d.ts +30 -0
  33. package/dist/representative-mcp.d.ts.map +1 -0
  34. package/dist/representative-mcp.js +182 -0
  35. package/dist/representative-mcp.js.map +1 -0
  36. package/dist/representative-owner.d.ts +10 -0
  37. package/dist/representative-owner.d.ts.map +1 -0
  38. package/dist/representative-owner.js +107 -0
  39. package/dist/representative-owner.js.map +1 -0
  40. package/dist/representative-store.d.ts +61 -0
  41. package/dist/representative-store.d.ts.map +1 -0
  42. package/dist/representative-store.js +158 -0
  43. package/dist/representative-store.js.map +1 -0
  44. package/dist/seat-store.d.ts +13 -0
  45. package/dist/seat-store.d.ts.map +1 -1
  46. package/dist/seat-store.js +55 -10
  47. package/dist/seat-store.js.map +1 -1
  48. package/dist/stream-owner.d.ts +10 -0
  49. package/dist/stream-owner.d.ts.map +1 -1
  50. package/dist/stream-owner.js +98 -19
  51. package/dist/stream-owner.js.map +1 -1
  52. package/dist/unknown-subcommand.d.ts +1 -1
  53. package/dist/unknown-subcommand.d.ts.map +1 -1
  54. package/dist/unknown-subcommand.js +1 -0
  55. package/dist/unknown-subcommand.js.map +1 -1
  56. package/docs/HUMAN_REPRESENTATIVE.md +269 -0
  57. package/docs/RELEASING.md +2 -2
  58. package/package.json +2 -2
  59. package/src/assimilate-cmd.ts +73 -22
  60. package/src/claude.ts +22 -0
  61. package/src/cli-help.ts +45 -0
  62. package/src/docs-sections.ts +8 -0
  63. package/src/local-server-cursor.ts +11 -3
  64. package/src/remote-client.ts +45 -12
  65. package/src/representative-cmd.ts +363 -0
  66. package/src/representative-core.ts +699 -0
  67. package/src/representative-mcp.ts +209 -0
  68. package/src/representative-owner.ts +105 -0
  69. package/src/representative-store.ts +208 -0
  70. package/src/seat-store.ts +61 -10
  71. package/src/stream-owner.ts +96 -19
  72. package/src/unknown-subcommand.ts +1 -0
@@ -90,11 +90,17 @@ async function readState(): Promise<CursorFile> {
90
90
  }
91
91
  }
92
92
 
93
- async function writeState(state: CursorFile): Promise<void> {
93
+ async function writeState(state: CursorFile, continuationGuard?: () => Promise<void>): Promise<void> {
94
94
  await mkdir(dirname(CURSOR_FILE), { recursive: true });
95
95
  const temporary = `${CURSOR_FILE}.${process.pid}.${Date.now()}.tmp`;
96
96
  await writeFile(temporary, JSON.stringify(state, null, 2) + '\n', { mode: 0o600 });
97
- await rename(temporary, CURSOR_FILE);
97
+ try {
98
+ if (continuationGuard) await continuationGuard();
99
+ await rename(temporary, CURSOR_FILE);
100
+ } catch (error) {
101
+ await unlink(temporary).catch(() => undefined);
102
+ throw error;
103
+ }
98
104
  }
99
105
 
100
106
  async function withLock<T>(operation: () => Promise<T>): Promise<T> {
@@ -143,10 +149,12 @@ export async function getLocalServerCursor(
143
149
  export async function advanceLocalServerCursor(
144
150
  binding: LocalServerCursorBinding,
145
151
  cursor: LocalServerCursor,
152
+ continuationGuard?: () => Promise<void>,
146
153
  ): Promise<void> {
147
154
  if (!validCursor(cursor)) throw new Error('invalid local Borg server cursor');
148
155
  const key = cursorKey(binding);
149
156
  await withLock(async () => {
157
+ if (continuationGuard) await continuationGuard();
150
158
  const state = await readState();
151
159
  const prior = state.cursors[key];
152
160
  if (
@@ -157,7 +165,7 @@ export async function advanceLocalServerCursor(
157
165
  return;
158
166
  }
159
167
  state.cursors[key] = cursor;
160
- await writeState(state);
168
+ await writeState(state, continuationGuard);
161
169
  });
162
170
  }
163
171
 
@@ -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),
@@ -1094,6 +1103,8 @@ export async function readLog(
1094
1103
  limit?: number;
1095
1104
  unreadOnly?: boolean;
1096
1105
  serverTrustIdentity?: string;
1106
+ /** Refuse continuation before any cursor access/advance or HTTP attempt. */
1107
+ continuationGuard?: () => Promise<void>;
1097
1108
  } = {}
1098
1109
  ): Promise<{
1099
1110
  entries: any[];
@@ -1111,17 +1122,21 @@ export async function readLog(
1111
1122
  opts.serverTrustIdentity,
1112
1123
  );
1113
1124
  let cursor: LocalServerCursor | null = null;
1125
+ if (opts.continuationGuard) await opts.continuationGuard();
1114
1126
  if (opts.unreadOnly) cursor = await getLocalServerCursor(localCursorBinding(local));
1115
- if (opts.since !== undefined) cursor = await resolveLocalLogCursor(local, opts.since);
1127
+ if (opts.since !== undefined) cursor = await resolveLocalLogCursor(local, opts.since, opts.continuationGuard);
1116
1128
  let page = await localReadLogPage(local, {
1117
1129
  cursor,
1118
1130
  limit: opts.limit,
1131
+ continuationGuard: opts.continuationGuard,
1119
1132
  // Keep the cursor payload stable across a lost response; do not re-read or
1120
1133
  // advance local state until one response has been decoded successfully.
1121
1134
  ...(opts.unreadOnly && opts.since === undefined ? { retryMode: 'unread-cursor' as const } : {}),
1122
1135
  });
1123
1136
  if (opts.unreadOnly && page.cursor) {
1124
- await advanceLocalServerCursor(localCursorBinding(local), page.cursor);
1137
+ if (opts.continuationGuard) await opts.continuationGuard();
1138
+ await advanceLocalServerCursor(localCursorBinding(local), page.cursor,
1139
+ ...(opts.continuationGuard ? [opts.continuationGuard] as const : [] as const));
1125
1140
  }
1126
1141
  const entries = [...page.entries];
1127
1142
  const backlog = entries.length + (typeof page.behind_by === 'number' ? page.behind_by : 0);
@@ -1135,14 +1150,17 @@ export async function readLog(
1135
1150
  cursor: page.cursor,
1136
1151
  limit: Math.min(500, DIGEST_FETCH_CAP - entries.length),
1137
1152
  retryMode: 'unread-cursor',
1153
+ continuationGuard: opts.continuationGuard,
1138
1154
  });
1139
1155
  if (page.cursor) {
1140
- await advanceLocalServerCursor(localCursorBinding(local), page.cursor);
1156
+ if (opts.continuationGuard) await opts.continuationGuard();
1157
+ await advanceLocalServerCursor(localCursorBinding(local), page.cursor,
1158
+ ...(opts.continuationGuard ? [opts.continuationGuard] as const : [] as const));
1141
1159
  }
1142
1160
  entries.push(...page.entries);
1143
1161
  }
1144
1162
  }
1145
- const composed = await localCubeComposition(local);
1163
+ const composed = await localCubeComposition(local, opts.continuationGuard);
1146
1164
  return {
1147
1165
  entries,
1148
1166
  drones: composed.drones,
@@ -1527,10 +1545,22 @@ export async function appendLog(
1527
1545
  class?: string;
1528
1546
  documents?: string[];
1529
1547
  serverTrustIdentity?: string;
1548
+ /**
1549
+ * Caller-owned idempotency key. A caller that may retry the SAME logical
1550
+ * post across calls (or processes) supplies it so the server deduplicates;
1551
+ * omitted, every call is a distinct post.
1552
+ */
1553
+ postId?: string;
1554
+ /**
1555
+ * false: exactly one transport attempt. For a caller that owns retries and
1556
+ * must know a typed refusal answered its only attempt (default: one
1557
+ * automatic same-post_id retry after a connection reset).
1558
+ */
1559
+ transportRetry?: boolean;
1530
1560
  },
1531
1561
  ): Promise<ReturnType<typeof decodeAppendLogResult>> {
1532
1562
  const to = normalizeLogAudience(opts?.to);
1533
- const postId = randomUUID();
1563
+ const postId = opts.postId ?? randomUUID();
1534
1564
  const local = await localAuthorityContext(
1535
1565
  sessionToken,
1536
1566
  apiUrl,
@@ -1548,7 +1578,10 @@ export async function appendLog(
1548
1578
  `/api/cubes/${local.cubeId}/logs`,
1549
1579
  'POST',
1550
1580
  { ...request },
1551
- { retryMode: 'append-log', decodePayload: decodeAppendLogResult },
1581
+ {
1582
+ ...(opts.transportRetry === false ? {} : { retryMode: 'append-log' as const }),
1583
+ decodePayload: decodeAppendLogResult,
1584
+ },
1552
1585
  );
1553
1586
  if (!payload) throw new Error('Local Borg server returned an empty log response');
1554
1587
  return payload;
@@ -0,0 +1,363 @@
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
+ representativeRecoveryCommand,
33
+ type RepresentativeBinding,
34
+ type RepresentativeStore,
35
+ } from './representative-store.js';
36
+
37
+ import { shellEscape } from './shell-escape.js';
38
+
39
+ export const DEFAULT_REPRESENTATIVE_ROLE = 'hermes-representative';
40
+
41
+ export type RepresentativeCommand =
42
+ | { action: 'prepare'; coordinator: string; role: string; rebind: boolean; worktreeName?: string; host?: string }
43
+ | { action: 'status'; worktree?: string }
44
+ | { action: 'mcp'; worktree?: string };
45
+
46
+ export type ParsedRepresentativeArgs =
47
+ | { ok: true; command: RepresentativeCommand }
48
+ | { ok: false; error: string };
49
+
50
+ export interface RepresentativeCmdDeps {
51
+ cwd(): string;
52
+ findProjectRoot(dir: string): string;
53
+ /** Hydrates the saved seat bound to exactly this worktree, or null. */
54
+ hydrateSeat(worktree: string): Promise<ActiveCube | null>;
55
+ /** Launch-free seat creation/resume; never starts an agent CLI. */
56
+ prepareSeat(input: { role: string; coordinator?: string; worktreeName?: string; host?: string; resume?: boolean }): Promise<{ code: number; worktree?: string }>;
57
+ backendFor(active: ActiveCube): RepresentativeBackend | Promise<RepresentativeBackend>;
58
+ store: RepresentativeStore;
59
+ stdout(text: string): void;
60
+ stderr(text: string): void;
61
+ }
62
+
63
+ export function parseRepresentativeArgs(args: readonly string[]): ParsedRepresentativeArgs {
64
+ const [action, ...rest] = args;
65
+ if (action !== 'prepare' && action !== 'status' && action !== 'mcp') {
66
+ return { ok: false, error: 'expected one of: prepare, status, mcp' };
67
+ }
68
+ const values: Record<string, string> = {};
69
+ let rebind = false;
70
+ const valueFlags = action === 'prepare' ? ['--coordinator', '--role', '--worktree', '--host'] : ['--worktree'];
71
+ for (let i = 0; i < rest.length; i += 1) {
72
+ const arg = rest[i];
73
+ if (action === 'prepare' && arg === '--rebind') {
74
+ rebind = true;
75
+ } else if (valueFlags.includes(arg)) {
76
+ const next = rest[i + 1];
77
+ if (typeof next !== 'string' || next.length === 0 || next.startsWith('-')) {
78
+ return { ok: false, error: `${arg} requires a value` };
79
+ }
80
+ values[arg] = next;
81
+ i += 1;
82
+ } else {
83
+ return {
84
+ ok: false,
85
+ error: `unknown argument: ${arg}. Supported: ${[...valueFlags, ...(action === 'prepare' ? ['--rebind'] : [])].join(', ')}`,
86
+ };
87
+ }
88
+ }
89
+ if (action !== 'prepare') {
90
+ const worktree = values['--worktree'];
91
+ if (worktree !== undefined && !isAbsolute(worktree)) {
92
+ return { ok: false, error: '--worktree must be an absolute path to the representative worktree' };
93
+ }
94
+ return { ok: true, command: { action, ...(worktree ? { worktree } : {}) } };
95
+ }
96
+ const coordinator = values['--coordinator'];
97
+ if (!coordinator) {
98
+ return {
99
+ ok: false,
100
+ error: '--coordinator <drone-label> is required: name the exact Coordinator drone (see `borg drones`). It is never chosen for you.',
101
+ };
102
+ }
103
+ const role = values['--role'] ?? DEFAULT_REPRESENTATIVE_ROLE;
104
+ if (!validateName(role).ok) return { ok: false, error: '--role must be a valid role name' };
105
+ if (values['--worktree'] !== undefined && !validateName(values['--worktree']).ok) {
106
+ return { ok: false, error: '--worktree for prepare is a new worktree NAME (as in `borg assimilate --worktree`), not a path' };
107
+ }
108
+ return {
109
+ ok: true,
110
+ command: {
111
+ action,
112
+ coordinator,
113
+ role,
114
+ rebind,
115
+ ...(values['--worktree'] ? { worktreeName: values['--worktree'] } : {}),
116
+ ...(values['--host'] ? { host: values['--host'] } : {}),
117
+ },
118
+ };
119
+ }
120
+
121
+ function canonicalWorktree(path: string, deps: Pick<RepresentativeCmdDeps, 'findProjectRoot'>): string {
122
+ let real = resolve(path);
123
+ try {
124
+ real = realpathSync(real);
125
+ } catch {
126
+ /* a missing path simply finds no binding below */
127
+ }
128
+ return deps.findProjectRoot(real);
129
+ }
130
+
131
+ function describeError(error: unknown): string {
132
+ if (error instanceof RepresentativeError || error instanceof RepresentativeStoreError) {
133
+ return `${error.code}: ${error.message}`;
134
+ }
135
+ return error instanceof Error ? error.message : String(error);
136
+ }
137
+
138
+ function sameSeat(binding: RepresentativeBinding, active: ActiveCube): boolean {
139
+ return active.cubeId === binding.cubeId &&
140
+ active.droneId === binding.representativeDroneId &&
141
+ active.apiUrl === binding.origin &&
142
+ active.serverTrustIdentity === binding.trustIdentity;
143
+ }
144
+
145
+ /** Load the saved binding and prove the worktree's hydrated seat is still that exact seat. Fails closed. */
146
+ export async function resolveRepresentativeContext(
147
+ worktree: string,
148
+ deps: Pick<RepresentativeCmdDeps, 'hydrateSeat' | 'backendFor' | 'store'>,
149
+ ): Promise<RepresentativeContext> {
150
+ const binding = await deps.store.getBinding(worktree);
151
+ if (!binding) {
152
+ throw new RepresentativeError(
153
+ 'NOT_PREPARED',
154
+ `No representative connection is prepared for ${worktree}. Run \`borg representative prepare --coordinator <drone-label>\` there first.`,
155
+ );
156
+ }
157
+ const active = await deps.hydrateSeat(worktree);
158
+ if (!active) {
159
+ throw new RepresentativeError(
160
+ 'SEAT_UNAVAILABLE',
161
+ `The saved representative connection is missing, reset or rejected. Run \`${representativeRecoveryCommand(binding)}\`.`,
162
+ );
163
+ }
164
+ if (!sameSeat(binding, active)) {
165
+ throw new RepresentativeError(
166
+ 'BINDING_MISMATCH',
167
+ `This worktree's saved connection is not the bound server/cube/drone. Run \`${representativeRecoveryCommand(binding)}\` to confirm a rebind; nothing is sent until then.`,
168
+ );
169
+ }
170
+ return { binding, backend: await deps.backendFor(active), store: deps.store };
171
+ }
172
+
173
+ export function hermesConfigSnippet(worktree: string): string {
174
+ return (
175
+ `mcp_servers:\n` +
176
+ ` borg-representative:\n` +
177
+ ` command: borg\n` +
178
+ ` args: ["representative", "mcp", "--worktree", ${JSON.stringify(worktree)}]\n`
179
+ );
180
+ }
181
+
182
+ export async function runRepresentativePrepare(
183
+ command: Extract<RepresentativeCommand, { action: 'prepare' }>,
184
+ deps: RepresentativeCmdDeps,
185
+ ): Promise<number> {
186
+ try {
187
+ // Same canonical key as status/mcp, so a symlinked cwd cannot bind under a path they never look up.
188
+ let worktree = canonicalWorktree(deps.cwd(), deps);
189
+ const existing = command.worktreeName === undefined ? await deps.hydrateSeat(worktree) : null;
190
+ if (existing && command.host && normalizeServerEndpoint(command.host) !== normalizeServerEndpoint(existing.apiUrl)) {
191
+ 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.');
192
+ }
193
+ const prepared = await deps.prepareSeat({
194
+ role: command.role,
195
+ coordinator: command.coordinator,
196
+ ...(existing ? { resume: true } : {}),
197
+ ...(command.worktreeName ? { worktreeName: command.worktreeName } : {}),
198
+ ...(command.host ? { host: command.host } : {}),
199
+ });
200
+ if (prepared.code !== 0) {
201
+ deps.stderr('borg representative: the representative connection could not be prepared; nothing was bound.\n');
202
+ return prepared.code || 1;
203
+ }
204
+ if (prepared.worktree) worktree = canonicalWorktree(prepared.worktree, deps);
205
+ const active = await deps.hydrateSeat(worktree);
206
+ if (!active || !active.serverTrustIdentity) {
207
+ throw new RepresentativeError('SEAT_UNAVAILABLE', `No usable saved connection was found for ${worktree}.`);
208
+ }
209
+ const backend = await deps.backendFor(active);
210
+ const me = await backend.whoami();
211
+ if (me.cube_id !== active.cubeId || me.drone_id !== active.droneId) {
212
+ throw new RepresentativeError('BINDING_MISMATCH', 'The server does not recognise this worktree\'s saved connection.');
213
+ }
214
+ const roster = await backend.roster();
215
+ const self = roster.drones.find((drone) => drone.id === me.drone_id);
216
+ const selfRole = roster.roles.find((role) => role.id === self?.role_id);
217
+ if (!self || !selfRole) throw new RepresentativeError('SEAT_UNAVAILABLE', 'The representative drone is not active in the cube.');
218
+ assertRepresentativeRole(selfRole, self);
219
+ if (selfRole.name.toLowerCase() !== command.role.toLowerCase()) {
220
+ throw new RepresentativeError(
221
+ 'REPRESENTATIVE_ROLE_MISMATCH',
222
+ `This worktree's drone ${self.label} holds role ${JSON.stringify(selfRole.name)}, not ${JSON.stringify(command.role)}. ` +
223
+ '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.',
224
+ );
225
+ }
226
+ const coordinator = resolveCoordinator(roster, { label: command.coordinator, selfDroneId: me.drone_id });
227
+ const binding: RepresentativeBinding = {
228
+ worktree,
229
+ origin: active.apiUrl,
230
+ trustIdentity: active.serverTrustIdentity,
231
+ cubeId: me.cube_id,
232
+ cubeName: me.cube_name,
233
+ representativeDroneId: me.drone_id,
234
+ representativeLabel: me.drone_label,
235
+ representativeRoleName: selfRole.name,
236
+ coordinatorDroneId: coordinator.drone.id,
237
+ coordinatorLabel: coordinator.drone.label,
238
+ coordinatorRoleName: coordinator.role.name,
239
+ ...(active.repositoryOrigin ? { repositoryOrigin: active.repositoryOrigin } : {}),
240
+ boundAt: new Date().toISOString(),
241
+ };
242
+ const outcome = await deps.store.saveBinding(binding, { rebind: command.rebind });
243
+ deps.stdout(
244
+ `◼ Human representative connection ${outcome === 'unchanged' ? 'resumed' : outcome}.\n` +
245
+ ` cube: ${binding.cubeName} (${binding.cubeId})\n` +
246
+ ` representative: ${binding.representativeLabel} — role ${binding.representativeRoleName} (not a human seat)\n` +
247
+ ` coordinator: ${binding.coordinatorLabel} — role ${binding.coordinatorRoleName} (human seat)\n` +
248
+ ` worktree: ${worktree}\n\n` +
249
+ `No agent CLI was launched. Serve it to a generic MCP host with:\n` +
250
+ ` borg representative mcp --worktree ${worktree}\n\n` +
251
+ `Example host configuration (no secrets belong here):\n${hermesConfigSnippet(worktree)}`,
252
+ );
253
+ return 0;
254
+ } catch (error) {
255
+ deps.stderr(`◼ borg representative prepare: ${describeError(error)}\n`);
256
+ return 1;
257
+ }
258
+ }
259
+
260
+ export async function runRepresentativeStatus(
261
+ command: Extract<RepresentativeCommand, { action: 'status' }>,
262
+ deps: RepresentativeCmdDeps,
263
+ ): Promise<number> {
264
+ try {
265
+ const worktree = canonicalWorktree(command.worktree ?? deps.cwd(), deps);
266
+ const ctx = await resolveRepresentativeContext(worktree, deps);
267
+ const status = await representativeStatus(ctx);
268
+ const { representativeOwnership } = await import('./representative-owner.js');
269
+ const ownership = await representativeOwnership(ctx.binding);
270
+ deps.stdout(`${JSON.stringify({ ...status, ownership }, null, 2)}\n`);
271
+ return status.connected ? 0 : 1;
272
+ } catch (error) {
273
+ deps.stderr(`◼ borg representative status: ${describeError(error)}\n`);
274
+ return 1;
275
+ }
276
+ }
277
+
278
+ /**
279
+ * Serve the stdio facade. The binding selection is captured at startup; a later
280
+ * operator rebind is NOT picked up by a running process — calls fail closed
281
+ * until the host restarts it.
282
+ */
283
+ export async function runRepresentativeMcp(
284
+ command: Extract<RepresentativeCommand, { action: 'mcp' }>,
285
+ deps: RepresentativeCmdDeps,
286
+ io: { version: string; pinSeat?: (active: ActiveCube) => void; stdin?: Readable; stdout?: Writable; heartbeatIntervalMs?: number },
287
+ ): Promise<number> {
288
+ let worktree: string;
289
+ let pinned: RepresentativeBinding;
290
+ try {
291
+ worktree = canonicalWorktree(command.worktree ?? deps.cwd(), deps);
292
+ pinned = (await resolveRepresentativeContext(worktree, deps)).binding;
293
+ const active = await deps.hydrateSeat(worktree);
294
+ if (active) io.pinSeat?.(active);
295
+ } catch (error) {
296
+ // stdout is reserved for JSON-RPC frames; refuse to start on stderr only.
297
+ deps.stderr(`◼ borg representative mcp: ${describeError(error)}\n`);
298
+ return 1;
299
+ }
300
+ const { serveRepresentativeMcp } = await import('./representative-mcp.js');
301
+ const served = await serveRepresentativeMcp({
302
+ version: io.version,
303
+ heartbeatIntervalMs: io.heartbeatIntervalMs,
304
+ ...(io.stdin ? { stdin: io.stdin } : {}),
305
+ ...(io.stdout ? { stdout: io.stdout } : {}),
306
+ context: async () => {
307
+ const ctx = await resolveRepresentativeContext(worktree, deps);
308
+ if (
309
+ ctx.binding.cubeId !== pinned.cubeId ||
310
+ ctx.binding.coordinatorDroneId !== pinned.coordinatorDroneId ||
311
+ ctx.binding.representativeDroneId !== pinned.representativeDroneId
312
+ ) {
313
+ throw new RepresentativeError(
314
+ 'BINDING_MISMATCH',
315
+ 'The operator changed this connection\'s cube or Coordinator while it was running. Restart the MCP server to use the new binding.',
316
+ );
317
+ }
318
+ return ctx;
319
+ },
320
+ });
321
+ const stdin = io.stdin ?? process.stdin;
322
+ stdin.once('end', () => { void served.close(); });
323
+ await served.closed;
324
+ return 0;
325
+ }
326
+
327
+ export async function buildDefaultRepresentativeDeps(): Promise<RepresentativeCmdDeps> {
328
+ const [{ findProjectRoot, getActiveCubeForWorktree }, { createSeatBackend }, { createRepresentativeStore }] =
329
+ await Promise.all([
330
+ import('./cubes.js'),
331
+ import('./representative-core.js'),
332
+ import('./representative-store.js'),
333
+ ]);
334
+ return {
335
+ cwd: () => process.cwd(),
336
+ findProjectRoot,
337
+ hydrateSeat: (worktree) => getActiveCubeForWorktree(worktree),
338
+ prepareSeat: async ({ role, coordinator, worktreeName, host, resume }) => {
339
+ const [{ prepareConnection }, { buildDefaultAssimilateDeps }] = await Promise.all([
340
+ import('./assimilate-cmd.js'),
341
+ import('./assimilate-deps.js'),
342
+ ]);
343
+ let worktree: string | undefined;
344
+ const code = await prepareConnection(
345
+ { role, flags: { ...(resume ? { here: true } : {}), ...(worktreeName ? { worktree: worktreeName } : {}), ...(host ? { server: host } : {}) } },
346
+ buildDefaultAssimilateDeps(),
347
+ {
348
+ validateRole: assertRepresentativeRole,
349
+ onPrepared: (prepared) => { worktree = prepared.worktree; },
350
+ authoritySelectionCommand: 'borg representative prepare --host <host>' +
351
+ (coordinator ? ` --coordinator ${shellEscape(coordinator)}` : '') +
352
+ ` --role ${shellEscape(role)}` +
353
+ (worktreeName ? ` --worktree ${shellEscape(worktreeName)}` : ''),
354
+ },
355
+ );
356
+ return { code, ...(worktree ? { worktree } : {}) };
357
+ },
358
+ backendFor: createSeatBackend,
359
+ store: createRepresentativeStore(),
360
+ stdout: (text) => { process.stdout.write(text); },
361
+ stderr: (text) => { process.stderr.write(text); },
362
+ };
363
+ }