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.
- package/README.md +12 -0
- package/dist/assimilate-cmd.d.ts +8 -1
- package/dist/assimilate-cmd.d.ts.map +1 -1
- package/dist/assimilate-cmd.js +54 -21
- package/dist/assimilate-cmd.js.map +1 -1
- package/dist/claude.d.ts.map +1 -1
- package/dist/claude.js +22 -0
- package/dist/claude.js.map +1 -1
- package/dist/cli-help.d.ts +1 -0
- package/dist/cli-help.d.ts.map +1 -1
- package/dist/cli-help.js +42 -0
- package/dist/cli-help.js.map +1 -1
- package/dist/docs-sections.d.ts.map +1 -1
- package/dist/docs-sections.js +8 -0
- package/dist/docs-sections.js.map +1 -1
- package/dist/local-server-cursor.d.ts +1 -1
- package/dist/local-server-cursor.d.ts.map +1 -1
- package/dist/local-server-cursor.js +14 -4
- package/dist/local-server-cursor.js.map +1 -1
- package/dist/remote-client.d.ts +14 -0
- package/dist/remote-client.d.ts.map +1 -1
- package/dist/remote-client.js +30 -14
- package/dist/remote-client.js.map +1 -1
- package/dist/representative-cmd.d.ts +87 -0
- package/dist/representative-cmd.d.ts.map +1 -0
- package/dist/representative-cmd.js +286 -0
- package/dist/representative-cmd.js.map +1 -0
- package/dist/representative-core.d.ts +197 -0
- package/dist/representative-core.d.ts.map +1 -0
- package/dist/representative-core.js +493 -0
- package/dist/representative-core.js.map +1 -0
- package/dist/representative-mcp.d.ts +30 -0
- package/dist/representative-mcp.d.ts.map +1 -0
- package/dist/representative-mcp.js +182 -0
- package/dist/representative-mcp.js.map +1 -0
- package/dist/representative-owner.d.ts +10 -0
- package/dist/representative-owner.d.ts.map +1 -0
- package/dist/representative-owner.js +107 -0
- package/dist/representative-owner.js.map +1 -0
- package/dist/representative-store.d.ts +61 -0
- package/dist/representative-store.d.ts.map +1 -0
- package/dist/representative-store.js +158 -0
- package/dist/representative-store.js.map +1 -0
- package/dist/seat-store.d.ts +13 -0
- package/dist/seat-store.d.ts.map +1 -1
- package/dist/seat-store.js +55 -10
- package/dist/seat-store.js.map +1 -1
- package/dist/stream-owner.d.ts +10 -0
- package/dist/stream-owner.d.ts.map +1 -1
- package/dist/stream-owner.js +98 -19
- package/dist/stream-owner.js.map +1 -1
- package/dist/unknown-subcommand.d.ts +1 -1
- package/dist/unknown-subcommand.d.ts.map +1 -1
- package/dist/unknown-subcommand.js +1 -0
- package/dist/unknown-subcommand.js.map +1 -1
- package/docs/HUMAN_REPRESENTATIVE.md +269 -0
- package/docs/RELEASING.md +2 -2
- package/package.json +2 -2
- package/src/assimilate-cmd.ts +73 -22
- package/src/claude.ts +22 -0
- package/src/cli-help.ts +45 -0
- package/src/docs-sections.ts +8 -0
- package/src/local-server-cursor.ts +11 -3
- package/src/remote-client.ts +45 -12
- package/src/representative-cmd.ts +363 -0
- package/src/representative-core.ts +699 -0
- package/src/representative-mcp.ts +209 -0
- package/src/representative-owner.ts +105 -0
- package/src/representative-store.ts +208 -0
- package/src/seat-store.ts +61 -10
- package/src/stream-owner.ts +96 -19
- 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
|
-
|
|
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
|
|
package/src/remote-client.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
+
}
|