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.
- package/README.md +15 -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 +30 -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 +45 -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 +10 -1
- package/dist/local-server-cursor.d.ts.map +1 -1
- package/dist/local-server-cursor.js +64 -5
- package/dist/local-server-cursor.js.map +1 -1
- package/dist/log-stream.d.ts +13 -0
- package/dist/log-stream.d.ts.map +1 -1
- package/dist/log-stream.js +45 -13
- package/dist/log-stream.js.map +1 -1
- package/dist/remote-client.d.ts +17 -0
- package/dist/remote-client.d.ts.map +1 -1
- package/dist/remote-client.js +37 -15
- package/dist/remote-client.js.map +1 -1
- package/dist/representative-cmd.d.ts +94 -0
- package/dist/representative-cmd.d.ts.map +1 -0
- package/dist/representative-cmd.js +290 -0
- package/dist/representative-cmd.js.map +1 -0
- package/dist/representative-core.d.ts +243 -0
- package/dist/representative-core.d.ts.map +1 -0
- package/dist/representative-core.js +679 -0
- package/dist/representative-core.js.map +1 -0
- package/dist/representative-delivery-store.d.ts +79 -0
- package/dist/representative-delivery-store.d.ts.map +1 -0
- package/dist/representative-delivery-store.js +233 -0
- package/dist/representative-delivery-store.js.map +1 -0
- package/dist/representative-listener-store.d.ts +46 -0
- package/dist/representative-listener-store.d.ts.map +1 -0
- package/dist/representative-listener-store.js +119 -0
- package/dist/representative-listener-store.js.map +1 -0
- package/dist/representative-listener.d.ts +32 -0
- package/dist/representative-listener.d.ts.map +1 -0
- package/dist/representative-listener.js +293 -0
- package/dist/representative-listener.js.map +1 -0
- package/dist/representative-mcp.d.ts +37 -0
- package/dist/representative-mcp.d.ts.map +1 -0
- package/dist/representative-mcp.js +211 -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 +68 -0
- package/dist/representative-store.d.ts.map +1 -0
- package/dist/representative-store.js +173 -0
- package/dist/representative-store.js.map +1 -0
- package/dist/seat-probe.d.ts +1 -0
- package/dist/seat-probe.d.ts.map +1 -1
- package/dist/seat-probe.js +1 -1
- package/dist/seat-probe.js.map +1 -1
- 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/server-trust.d.ts +10 -0
- package/dist/server-trust.d.ts.map +1 -1
- package/dist/server-trust.js +23 -6
- package/dist/server-trust.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 +129 -21
- 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 +434 -0
- package/package.json +1 -1
- package/src/assimilate-cmd.ts +73 -22
- package/src/claude.ts +30 -0
- package/src/cli-help.ts +48 -0
- package/src/docs-sections.ts +8 -0
- package/src/local-server-cursor.ts +56 -4
- package/src/log-stream.ts +47 -14
- package/src/remote-client.ts +54 -13
- package/src/representative-cmd.ts +369 -0
- package/src/representative-core.ts +908 -0
- package/src/representative-delivery-store.ts +235 -0
- package/src/representative-listener-store.ts +115 -0
- package/src/representative-listener.ts +215 -0
- package/src/representative-mcp.ts +250 -0
- package/src/representative-owner.ts +105 -0
- package/src/representative-store.ts +224 -0
- package/src/seat-probe.ts +1 -1
- package/src/seat-store.ts +61 -10
- package/src/server-trust.ts +25 -6
- package/src/stream-owner.ts +129 -20
- package/src/unknown-subcommand.ts +1 -0
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),
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
+
}
|