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/assimilate-cmd.ts
CHANGED
|
@@ -441,6 +441,7 @@ async function selectAssimilationAuthority(
|
|
|
441
441
|
flags: AssimilateFlags,
|
|
442
442
|
deps: AuthorityResolutionDeps,
|
|
443
443
|
mode: 'assimilate' | 'cube-init',
|
|
444
|
+
authoritySelectionCommand?: string,
|
|
444
445
|
): Promise<AssimilationAuthority | null> {
|
|
445
446
|
if (flags.server !== undefined) {
|
|
446
447
|
try {
|
|
@@ -460,7 +461,7 @@ async function selectAssimilationAuthority(
|
|
|
460
461
|
}
|
|
461
462
|
if (!deps.isTTY() || flags.yes) {
|
|
462
463
|
if (deps.defaultAuthority) return deps.defaultAuthority;
|
|
463
|
-
const command = mode === 'cube-init'
|
|
464
|
+
const command = authoritySelectionCommand ? `\`${authoritySelectionCommand}\`` : mode === 'cube-init'
|
|
464
465
|
? '`borg server cube init --host <host>`'
|
|
465
466
|
: '`borg assimilate --host <host> --here`';
|
|
466
467
|
deps.stderr(`No local server selected. Use ${command} to select a local server.\n`);
|
|
@@ -1132,11 +1133,11 @@ export interface CubeRoleResolutionOutcome {
|
|
|
1132
1133
|
cli: BorgCli;
|
|
1133
1134
|
}
|
|
1134
1135
|
|
|
1135
|
-
|
|
1136
|
+
function resolveConnectionRole(
|
|
1136
1137
|
input: CubeRoleResolutionInput,
|
|
1137
1138
|
deps: CubeRoleResolutionDeps,
|
|
1138
|
-
):
|
|
1139
|
-
const { requestedRole,
|
|
1139
|
+
): AssimilationPhaseOutcome<{ resolvedRole: Role }> {
|
|
1140
|
+
const { requestedRole, cubeDetail, isFirstDrone, savedLocalRole, apiUrl } = input;
|
|
1140
1141
|
let resolvedRole: Role | undefined;
|
|
1141
1142
|
if (savedLocalRole) {
|
|
1142
1143
|
resolvedRole = savedLocalRole;
|
|
@@ -1166,6 +1167,17 @@ export async function resolveAssimilationCubeRole(
|
|
|
1166
1167
|
}
|
|
1167
1168
|
}
|
|
1168
1169
|
|
|
1170
|
+
return continueAssimilation({ resolvedRole });
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
export async function resolveAssimilationCubeRole(
|
|
1174
|
+
input: CubeRoleResolutionInput,
|
|
1175
|
+
deps: CubeRoleResolutionDeps,
|
|
1176
|
+
): Promise<AssimilationPhaseOutcome<CubeRoleResolutionOutcome>> {
|
|
1177
|
+
const role = resolveConnectionRole(input, deps);
|
|
1178
|
+
if (role.kind === 'stop') return role;
|
|
1179
|
+
const { resolvedRole } = role.value;
|
|
1180
|
+
const { flags, apiUrl } = input;
|
|
1169
1181
|
const effectiveModel: string | null = flags.model ?? null;
|
|
1170
1182
|
const cli = await deps.resolveCli(flags.cli);
|
|
1171
1183
|
try {
|
|
@@ -1192,7 +1204,7 @@ export interface SeatPreparationInput {
|
|
|
1192
1204
|
serverTrustIdentity: string;
|
|
1193
1205
|
cubeDetail: CubeDetail;
|
|
1194
1206
|
resolvedRole: Role;
|
|
1195
|
-
cli
|
|
1207
|
+
cli?: BorgCli;
|
|
1196
1208
|
effectiveModel: string | null;
|
|
1197
1209
|
projectRoot: string;
|
|
1198
1210
|
existing: CanonicalActiveCube | null;
|
|
@@ -1267,8 +1279,7 @@ export async function prepareAssimilationSeat(
|
|
|
1267
1279
|
cube_id: cubeDetail.id,
|
|
1268
1280
|
role_id: resolvedRole.id,
|
|
1269
1281
|
hostname: deps.getHostname(),
|
|
1270
|
-
agent_kind: cli,
|
|
1271
|
-
model: effectiveModel,
|
|
1282
|
+
...(cli !== undefined ? { agent_kind: cli, model: effectiveModel } : {}),
|
|
1272
1283
|
working_repo: resolveWorkingRepo(projectRoot),
|
|
1273
1284
|
...(reattachPriorId ? { prior_drone_id: reattachPriorId } : {}),
|
|
1274
1285
|
...(remintInvalidPrior ? { remint_invalid_prior: true } : {}),
|
|
@@ -1471,6 +1482,7 @@ export interface AuthorityResolutionInput {
|
|
|
1471
1482
|
args: AssimilateArgs;
|
|
1472
1483
|
mode: 'assimilate' | 'cube-init';
|
|
1473
1484
|
repositoryContext: GitRepositoryContext;
|
|
1485
|
+
authoritySelectionCommand?: string;
|
|
1474
1486
|
}
|
|
1475
1487
|
|
|
1476
1488
|
export interface AuthorityResolutionOutcome {
|
|
@@ -1493,6 +1505,13 @@ export async function resolveAssimilationAuthority(
|
|
|
1493
1505
|
deps: AuthorityResolutionDeps,
|
|
1494
1506
|
): Promise<AssimilationPhaseOutcome<AuthorityResolutionOutcome>> {
|
|
1495
1507
|
const { args, mode, repositoryContext } = input;
|
|
1508
|
+
// Representative retries must refuse before private-state initialization or
|
|
1509
|
+
// installation checks can mutate anything when no authority was selected.
|
|
1510
|
+
if (input.authoritySelectionCommand && args.flags.server === undefined &&
|
|
1511
|
+
deps.defaultAuthority === undefined && !args.flags.enroll && (!deps.isTTY() || args.flags.yes)) {
|
|
1512
|
+
await selectAssimilationAuthority(args.flags, deps, mode, input.authoritySelectionCommand);
|
|
1513
|
+
return { kind: 'stop', code: 1 };
|
|
1514
|
+
}
|
|
1496
1515
|
const hostlessEnrollment = args.flags.enroll === true &&
|
|
1497
1516
|
args.flags.server === undefined && deps.defaultAuthority === undefined;
|
|
1498
1517
|
const artifactOnlyEnrollment = hostlessEnrollment && deps.isTTY();
|
|
@@ -1624,7 +1643,7 @@ export async function resolveAssimilationAuthority(
|
|
|
1624
1643
|
localSeatReadError = error;
|
|
1625
1644
|
}
|
|
1626
1645
|
|
|
1627
|
-
const selectedAuthority = await selectAssimilationAuthority(args.flags, deps, mode);
|
|
1646
|
+
const selectedAuthority = await selectAssimilationAuthority(args.flags, deps, mode, input.authoritySelectionCommand);
|
|
1628
1647
|
if (!selectedAuthority) return { kind: 'stop', code: 1 };
|
|
1629
1648
|
let authority = selectedAuthority;
|
|
1630
1649
|
if (localSeatReadError !== undefined) {
|
|
@@ -1750,12 +1769,31 @@ export async function runAssimilate(
|
|
|
1750
1769
|
args: AssimilateArgs,
|
|
1751
1770
|
deps: AssimilateDeps,
|
|
1752
1771
|
options: RunAssimilateOptions = {},
|
|
1772
|
+
): Promise<number> {
|
|
1773
|
+
return runAssimilationFlow(args, deps, options);
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
/** Prepare a host-neutral connection using the same durable assimilation lifecycle. */
|
|
1777
|
+
export async function prepareConnection(
|
|
1778
|
+
args: AssimilateArgs,
|
|
1779
|
+
deps: AssimilateDeps,
|
|
1780
|
+
options: { validateRole: (role: Role) => void; onPrepared: (prepared: PreparedAssimilation) => void; authoritySelectionCommand?: string },
|
|
1781
|
+
): Promise<number> {
|
|
1782
|
+
return runAssimilationFlow(args, deps, { launch: false, ...options }, options.validateRole, options.authoritySelectionCommand);
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
async function runAssimilationFlow(
|
|
1786
|
+
args: AssimilateArgs,
|
|
1787
|
+
deps: AssimilateDeps,
|
|
1788
|
+
options: RunAssimilateOptions,
|
|
1789
|
+
validateConnectionRole?: (role: Role) => void,
|
|
1790
|
+
authoritySelectionCommand?: string,
|
|
1753
1791
|
): Promise<number> {
|
|
1754
1792
|
const repository = await resolveAssimilationRepository(args, deps);
|
|
1755
1793
|
if (repository.kind === 'stop') return repository.code;
|
|
1756
1794
|
const { mode, repositoryContext } = repository.value;
|
|
1757
1795
|
|
|
1758
|
-
const authorityResolution = await resolveAssimilationAuthority({ args, mode, repositoryContext }, deps);
|
|
1796
|
+
const authorityResolution = await resolveAssimilationAuthority({ args, mode, repositoryContext, authoritySelectionCommand }, deps);
|
|
1759
1797
|
if (authorityResolution.kind === 'stop') return authorityResolution.code;
|
|
1760
1798
|
const {
|
|
1761
1799
|
authority,
|
|
@@ -2172,16 +2210,18 @@ export async function runAssimilate(
|
|
|
2172
2210
|
}
|
|
2173
2211
|
}
|
|
2174
2212
|
|
|
2175
|
-
const
|
|
2176
|
-
requestedRole: args.role,
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
}, deps);
|
|
2213
|
+
const roleInput = {
|
|
2214
|
+
requestedRole: args.role, flags: args.flags, cubeDetail, isFirstDrone,
|
|
2215
|
+
savedLocalRole, apiUrl: authority.apiUrl,
|
|
2216
|
+
};
|
|
2217
|
+
const cubeRole = validateConnectionRole
|
|
2218
|
+
? resolveConnectionRole(roleInput, deps)
|
|
2219
|
+
: await resolveAssimilationCubeRole(roleInput, deps);
|
|
2183
2220
|
if (cubeRole.kind === 'stop') return cubeRole.code;
|
|
2184
|
-
const { resolvedRole
|
|
2221
|
+
const { resolvedRole } = cubeRole.value;
|
|
2222
|
+
validateConnectionRole?.(resolvedRole);
|
|
2223
|
+
const cli = 'cli' in cubeRole.value ? cubeRole.value.cli as BorgCli : undefined;
|
|
2224
|
+
const effectiveModel = 'effectiveModel' in cubeRole.value ? cubeRole.value.effectiveModel as string | null : null;
|
|
2185
2225
|
|
|
2186
2226
|
const seat = await prepareAssimilationSeat({
|
|
2187
2227
|
apiUrl: auth.apiUrl,
|
|
@@ -2202,6 +2242,7 @@ export async function runAssimilate(
|
|
|
2202
2242
|
}, deps);
|
|
2203
2243
|
if (seat.kind === 'stop') return seat.code;
|
|
2204
2244
|
const { result, assignedRole, sessionExpected } = seat.value;
|
|
2245
|
+
validateConnectionRole?.(assignedRole);
|
|
2205
2246
|
|
|
2206
2247
|
const worktree = await prepareAssimilationWorktree({
|
|
2207
2248
|
flags: args.flags,
|
|
@@ -2266,7 +2307,7 @@ export async function runAssimilate(
|
|
|
2266
2307
|
// of that request. The resolver therefore saved the preference against the
|
|
2267
2308
|
// invoking checkout. Once a sibling exists, save the same choice under its
|
|
2268
2309
|
// own project key so a later --here launch in that worktree can read it.
|
|
2269
|
-
if (spawnedWorktreePath) {
|
|
2310
|
+
if (spawnedWorktreePath && cli !== undefined) {
|
|
2270
2311
|
try {
|
|
2271
2312
|
await deps.setCliPreferenceForWorktree(cli, spawnedWorktreePath);
|
|
2272
2313
|
} catch (err) {
|
|
@@ -2281,8 +2322,10 @@ export async function runAssimilate(
|
|
|
2281
2322
|
}
|
|
2282
2323
|
|
|
2283
2324
|
try {
|
|
2284
|
-
|
|
2285
|
-
|
|
2325
|
+
if (cli !== undefined) {
|
|
2326
|
+
deps.mkdirp(scratchRoot);
|
|
2327
|
+
deps.provisionLaunchAccess?.(cli, seatWorktree, launchAccessPaths);
|
|
2328
|
+
}
|
|
2286
2329
|
} catch (err) {
|
|
2287
2330
|
const message = err instanceof Error ? err.message : String(err);
|
|
2288
2331
|
deps.stderr(
|
|
@@ -2320,6 +2363,14 @@ export async function runAssimilate(
|
|
|
2320
2363
|
}
|
|
2321
2364
|
}
|
|
2322
2365
|
|
|
2366
|
+
if (validateConnectionRole) {
|
|
2367
|
+
options.onPrepared?.({
|
|
2368
|
+
cubeId: result.cube_id, cubeName: cubeDetail.name, droneId: result.drone_id,
|
|
2369
|
+
droneLabel: result.drone_label, roleName: assignedRole.name, worktree: seatWorktree,
|
|
2370
|
+
});
|
|
2371
|
+
return 0;
|
|
2372
|
+
}
|
|
2373
|
+
|
|
2323
2374
|
// gh#793: best-effort GC of orphaned inbox files (evicted/dead drones) in the
|
|
2324
2375
|
// cube just joined — lazy-on-assimilate, no cron/new command. NEVER blocks or
|
|
2325
2376
|
// fails the assimilate (whole call swallowed). Local-only signal (CubeDetail
|
|
@@ -2371,7 +2422,7 @@ export async function runAssimilate(
|
|
|
2371
2422
|
cubeDetail,
|
|
2372
2423
|
assignedRole,
|
|
2373
2424
|
apiUrl: auth.apiUrl,
|
|
2374
|
-
cli
|
|
2425
|
+
cli: cli!,
|
|
2375
2426
|
effectiveModel,
|
|
2376
2427
|
agentCwd,
|
|
2377
2428
|
seatWorktree,
|
package/src/claude.ts
CHANGED
|
@@ -390,6 +390,36 @@ async function main() {
|
|
|
390
390
|
buildDefaultSeatCommandDeps(),
|
|
391
391
|
));
|
|
392
392
|
}
|
|
393
|
+
if (process.argv[2] === 'representative') {
|
|
394
|
+
// Loaded on demand: the representative facade is unrelated to agent launch.
|
|
395
|
+
const representative = await import('./representative-cmd.js');
|
|
396
|
+
const parsed = representative.parseRepresentativeArgs(process.argv.slice(3));
|
|
397
|
+
if (!parsed.ok) {
|
|
398
|
+
if (process.argv[3] === 'listen') {
|
|
399
|
+
process.stdout.write(JSON.stringify({ event: 'refused', code: 'INVALID_INPUT', exit_code: 2 }) + '\n');
|
|
400
|
+
process.stderr.write(parsed.error + '\n');
|
|
401
|
+
process.exit(2);
|
|
402
|
+
}
|
|
403
|
+
process.stderr.write(chalk.red(`${consolePrefix()}◼ borg representative: ${parsed.error}\n`));
|
|
404
|
+
process.stderr.write(`Run \`borg representative --help\` for usage.\n`);
|
|
405
|
+
process.exit(1);
|
|
406
|
+
}
|
|
407
|
+
const deps = await representative.buildDefaultRepresentativeDeps();
|
|
408
|
+
if (parsed.command.action === 'prepare') {
|
|
409
|
+
process.exit(await representative.runRepresentativePrepare(parsed.command, deps));
|
|
410
|
+
}
|
|
411
|
+
if (parsed.command.action === 'status') {
|
|
412
|
+
process.exit(await representative.runRepresentativeStatus(parsed.command, deps));
|
|
413
|
+
}
|
|
414
|
+
const { pinMcpSeatIdentity } = await import('./cubes.js');
|
|
415
|
+
if (parsed.command.action === 'listen') {
|
|
416
|
+
process.exit(await representative.runRepresentativeListen(parsed.command, deps));
|
|
417
|
+
}
|
|
418
|
+
process.exit(await representative.runRepresentativeMcp(parsed.command, deps, {
|
|
419
|
+
version: getPackageVersion(),
|
|
420
|
+
pinSeat: pinMcpSeatIdentity,
|
|
421
|
+
}));
|
|
422
|
+
}
|
|
393
423
|
if (process.argv[2] === 'launch-all') {
|
|
394
424
|
const parsed = parseLaunchAllArgs(process.argv.slice(3));
|
|
395
425
|
if (!parsed.ok) {
|
package/src/cli-help.ts
CHANGED
|
@@ -113,6 +113,52 @@ export function launchSeatHelpText(version: string): string {
|
|
|
113
113
|
);
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
export function representativeHelpText(version: string): string {
|
|
117
|
+
return (
|
|
118
|
+
`borg representative (borgmcp ${version}) — connect a human representative (e.g. Hermes) to one Coordinator\n\n` +
|
|
119
|
+
`Vocabulary:\n` +
|
|
120
|
+
` cube One repository's shared coordination space on your Borg server.\n` +
|
|
121
|
+
` drone One connected agent session in a cube; its role defines how it works.\n` +
|
|
122
|
+
` human seat The one role in a cube that speaks with the human's authority.\n` +
|
|
123
|
+
` Coordinator The drone holding the human seat. It plans the work and dispatches the other drones.\n` +
|
|
124
|
+
` human representative A SEPARATE automated drone, under its own non-human-seat role, that relays the\n` +
|
|
125
|
+
` human's requests, questions and decisions to that one Coordinator and reads its\n` +
|
|
126
|
+
` replies. It is not the human, never takes the human seat, and never addresses\n` +
|
|
127
|
+
` other drones or broadcasts.\n\n` +
|
|
128
|
+
`Usage:\n` +
|
|
129
|
+
` borg representative prepare --coordinator <drone-label> [--role <name>] [--worktree <name>] [--host <host>] [--rebind]\n` +
|
|
130
|
+
` borg representative status [--worktree <path>]\n` +
|
|
131
|
+
` borg representative mcp [--worktree <path>]\n` +
|
|
132
|
+
` borg representative listen --worktree <path> [--replay-after <entry_id>]\n` +
|
|
133
|
+
` borg representative --help\n\n` +
|
|
134
|
+
`Commands:\n` +
|
|
135
|
+
` prepare Create or resume the representative's own drone in this repository's cube and bind it to\n` +
|
|
136
|
+
` exactly the named Coordinator drone. Launches no agent CLI and changes no other drone.\n` +
|
|
137
|
+
` Fails if that Coordinator is missing, evicted, duplicated, or not in the human seat;\n` +
|
|
138
|
+
` another drone is never chosen instead.\n` +
|
|
139
|
+
` status Show the saved binding, re-check it against the live cube, and list unresolved sends.\n` +
|
|
140
|
+
` mcp Serve the restricted stdio MCP tools (status, send, read, deliver, ack) for a generic MCP host.\n` +
|
|
141
|
+
` listen Emit body-free JSON wake hints from the server stream; supervise this separate process.\n\n` +
|
|
142
|
+
`Options:\n` +
|
|
143
|
+
` --replay-after <entry_id> listen: replay later retained hints after the last durably enqueued entry\n` +
|
|
144
|
+
` --coordinator <drone-label> Exact label of the Coordinator drone (see \`borg drones\`). Required for prepare.\n` +
|
|
145
|
+
` --role <name> Existing non-human-seat role for the representative (default: hermes-representative)\n` +
|
|
146
|
+
` --worktree <name> prepare: create the drone in a new linked worktree of that name\n` +
|
|
147
|
+
` --worktree <path> status/mcp/listen: absolute path of the prepared representative worktree\n` +
|
|
148
|
+
` --host <host> prepare: explicit Borg server, as in \`borg assimilate --host\`\n` +
|
|
149
|
+
` --rebind prepare: explicitly replace the saved cube/Coordinator selection\n` +
|
|
150
|
+
` --help, -h Show this help\n\n` +
|
|
151
|
+
`Limits: the MCP process has no background wake; a separate listen process emits wake hints, not content.\n` +
|
|
152
|
+
`Reading changes nothing: persist replies durably, then deliver through the last one. On a listener gap, call read.\n` +
|
|
153
|
+
`The first send/read/deliver/ack takes an exclusive tools lease; listen owns a separate exclusive listener lease.\n` +
|
|
154
|
+
`A retried send reuses its request id so the server stores it once; an unknown outcome is reported as\n` +
|
|
155
|
+
`ambiguous, with its cause, and never re-sent automatically. "User-authorized" is the representative's own label:\n` +
|
|
156
|
+
`the Borg server does not verify it, and one relayed decision is not broader human approval.\n` +
|
|
157
|
+
`No command takes a credential; the drone's saved connection stays in Borg's private store.\n` +
|
|
158
|
+
`Details: docs/HUMAN_REPRESENTATIVE.md\n`
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
116
162
|
export function doctorHelpText(version: string): string {
|
|
117
163
|
return (
|
|
118
164
|
`borg doctor (borgmcp ${version}) — inspect Borg agent integrations without changing them\n\n` +
|
|
@@ -139,6 +185,7 @@ export function clientSubcommandHelpText(
|
|
|
139
185
|
case 'drones': return seatsHelpText(version);
|
|
140
186
|
case 'launch': return launchSeatHelpText(version);
|
|
141
187
|
case 'launch-all': return launchAllHelpText(version);
|
|
188
|
+
case 'representative': return representativeHelpText(version);
|
|
142
189
|
case 'doctor': return doctorHelpText(version);
|
|
143
190
|
default: return null;
|
|
144
191
|
}
|
|
@@ -186,6 +233,7 @@ export function topLevelHelpText(version: string): string {
|
|
|
186
233
|
` borg drones List this machine's registered drones and worktrees\n` +
|
|
187
234
|
` borg launch <drone-label-or-id-prefix> Reopen one registered drone from its worktree\n` +
|
|
188
235
|
` borg launch-all [cube] Launch all drone worktrees of a cube (default: active cube)\n` +
|
|
236
|
+
` borg representative prepare|status|mcp|listen Let an MCP host (e.g. Hermes) speak for you to one Coordinator drone\n` +
|
|
189
237
|
` borg server <command> [arguments]\n` +
|
|
190
238
|
` borg --cli claude|codex|opencode Launch that agent CLI directly\n` +
|
|
191
239
|
` borg --version Show installed version\n\n` +
|
package/src/docs-sections.ts
CHANGED
|
@@ -15,6 +15,7 @@ const REPOSITORY_URL = "https://github.com/Byte-Ventures/borg-mcp-client";
|
|
|
15
15
|
const LOCAL_SERVER_URL = `${REPOSITORY_URL}/blob/main/docs/LOCAL_SERVER.md`;
|
|
16
16
|
const SEAT_LIFECYCLE_URL = `${REPOSITORY_URL}/blob/main/docs/SEAT_LIFECYCLE.md`;
|
|
17
17
|
const DOCUMENTS_URL = `${REPOSITORY_URL}/blob/main/docs/DOCUMENTS.md`;
|
|
18
|
+
const HUMAN_REPRESENTATIVE_URL = `${REPOSITORY_URL}/blob/main/docs/HUMAN_REPRESENTATIVE.md`;
|
|
18
19
|
|
|
19
20
|
export interface DocsSection {
|
|
20
21
|
/** logical topic key */
|
|
@@ -91,6 +92,13 @@ export const DOCS_SECTIONS: DocsSection[] = [
|
|
|
91
92
|
summary: "Immutable cube-local Markdown or plain text, revisions, removal, and structured activity-log citations.",
|
|
92
93
|
keywords: ["document", "documents", "citation", "cite", "durable content", "supersede", "revision", "borg_put-document", "borg_get-document"],
|
|
93
94
|
},
|
|
95
|
+
{
|
|
96
|
+
slug: "human-representative",
|
|
97
|
+
title: "Human representative",
|
|
98
|
+
url: HUMAN_REPRESENTATIVE_URL,
|
|
99
|
+
summary: "A separate non-human-seat drone that relays the human's requests and decisions to one bound Coordinator over a restricted stdio MCP facade: prepare, host configuration, idempotent sends, replayable bounded reads with a delivered checkpoint, and supervised listener hints.",
|
|
100
|
+
keywords: ["representative", "hermes", "human representative", "delegate", "proxy", "borg representative", "borg_representative-send", "mcp host", "request_id", "ambiguous"],
|
|
101
|
+
},
|
|
94
102
|
{
|
|
95
103
|
slug: "tools",
|
|
96
104
|
title: "Tool reference",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
-
import {
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { lstat, mkdir, open, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises';
|
|
3
4
|
import { dirname, join } from 'node:path';
|
|
4
5
|
import { borgConfigRoot } from './private-root.js';
|
|
5
6
|
|
|
@@ -90,11 +91,17 @@ async function readState(): Promise<CursorFile> {
|
|
|
90
91
|
}
|
|
91
92
|
}
|
|
92
93
|
|
|
93
|
-
async function writeState(state: CursorFile): Promise<void> {
|
|
94
|
+
async function writeState(state: CursorFile, continuationGuard?: () => Promise<void>): Promise<void> {
|
|
94
95
|
await mkdir(dirname(CURSOR_FILE), { recursive: true });
|
|
95
96
|
const temporary = `${CURSOR_FILE}.${process.pid}.${Date.now()}.tmp`;
|
|
96
97
|
await writeFile(temporary, JSON.stringify(state, null, 2) + '\n', { mode: 0o600 });
|
|
97
|
-
|
|
98
|
+
try {
|
|
99
|
+
if (continuationGuard) await continuationGuard();
|
|
100
|
+
await rename(temporary, CURSOR_FILE);
|
|
101
|
+
} catch (error) {
|
|
102
|
+
await unlink(temporary).catch(() => undefined);
|
|
103
|
+
throw error;
|
|
104
|
+
}
|
|
98
105
|
}
|
|
99
106
|
|
|
100
107
|
async function withLock<T>(operation: () => Promise<T>): Promise<T> {
|
|
@@ -140,13 +147,58 @@ export async function getLocalServerCursor(
|
|
|
140
147
|
return state.cursors[key] ?? null;
|
|
141
148
|
}
|
|
142
149
|
|
|
150
|
+
/**
|
|
151
|
+
* Fail-closed read for importing the unread watermark into other private
|
|
152
|
+
* state. The product writes this file 0600 (writeState); a symlink, a file
|
|
153
|
+
* that is not a regular file, not owned by this user, or group- or
|
|
154
|
+
* world-writable, and any unparsable state all read as null, never as a
|
|
155
|
+
* position. Read-only: nothing is written or locked. The caller validates
|
|
156
|
+
* the private root the file lives in.
|
|
157
|
+
*/
|
|
158
|
+
export async function readPrivateLocalServerCursor(
|
|
159
|
+
binding: LocalServerCursorBinding,
|
|
160
|
+
): Promise<LocalServerCursor | null> {
|
|
161
|
+
// lstat first: a FIFO or device is refused without ever being opened (an
|
|
162
|
+
// open would block). The non-blocking open and the identity recheck keep a
|
|
163
|
+
// swapped-in object from being read in its place.
|
|
164
|
+
let before;
|
|
165
|
+
try {
|
|
166
|
+
before = await lstat(CURSOR_FILE);
|
|
167
|
+
} catch {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
if (!before.isFile()) return null;
|
|
171
|
+
let handle;
|
|
172
|
+
try {
|
|
173
|
+
handle = await open(CURSOR_FILE, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
174
|
+
} catch {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
try {
|
|
178
|
+
const metadata = await handle.stat();
|
|
179
|
+
if (metadata.dev !== before.dev || metadata.ino !== before.ino) return null;
|
|
180
|
+
if (!metadata.isFile() || (metadata.mode & 0o022) !== 0 ||
|
|
181
|
+
(typeof process.getuid === 'function' && metadata.uid !== process.getuid())) return null;
|
|
182
|
+
const parsed = JSON.parse(await handle.readFile('utf8')) as Partial<CursorFile>;
|
|
183
|
+
if (parsed?.version !== 1 || typeof parsed.cursors !== 'object' || parsed.cursors === null) return null;
|
|
184
|
+
const cursor = parsed.cursors[cursorKey(binding)];
|
|
185
|
+
return validCursor(cursor) ? { id: cursor.id, created_at: cursor.created_at } : null;
|
|
186
|
+
} catch {
|
|
187
|
+
return null;
|
|
188
|
+
} finally {
|
|
189
|
+
await handle.close();
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
143
193
|
export async function advanceLocalServerCursor(
|
|
144
194
|
binding: LocalServerCursorBinding,
|
|
145
195
|
cursor: LocalServerCursor,
|
|
196
|
+
continuationGuard?: () => Promise<void>,
|
|
146
197
|
): Promise<void> {
|
|
147
198
|
if (!validCursor(cursor)) throw new Error('invalid local Borg server cursor');
|
|
148
199
|
const key = cursorKey(binding);
|
|
149
200
|
await withLock(async () => {
|
|
201
|
+
if (continuationGuard) await continuationGuard();
|
|
150
202
|
const state = await readState();
|
|
151
203
|
const prior = state.cursors[key];
|
|
152
204
|
if (
|
|
@@ -157,7 +209,7 @@ export async function advanceLocalServerCursor(
|
|
|
157
209
|
return;
|
|
158
210
|
}
|
|
159
211
|
state.cursors[key] = cursor;
|
|
160
|
-
await writeState(state);
|
|
212
|
+
await writeState(state, continuationGuard);
|
|
161
213
|
});
|
|
162
214
|
}
|
|
163
215
|
|
package/src/log-stream.ts
CHANGED
|
@@ -61,7 +61,7 @@ import {
|
|
|
61
61
|
} from './codex-app-wake.js';
|
|
62
62
|
import { formatCubeActivityWakeMessage } from './cube-activity-wake-copy.js';
|
|
63
63
|
import { readBoundedResponseBody } from './server-response.js';
|
|
64
|
-
import { BorgServerError } from './server-errors.js';
|
|
64
|
+
import { BorgServerError, BorgServerTrustError } from './server-errors.js';
|
|
65
65
|
import { markSeatRejected } from './seats.js';
|
|
66
66
|
import { formatDocumentCitations } from './document-render.js';
|
|
67
67
|
import { hasPendingWakeEntry as hasPendingDurableWakeEntry } from './remote-client.js';
|
|
@@ -378,7 +378,22 @@ export function startLogStream(opts: { runForever?: () => void } = {}): void {
|
|
|
378
378
|
// Dependency injection seams (for tests)
|
|
379
379
|
// ------------------------------------------------------------------
|
|
380
380
|
|
|
381
|
+
/** Production adapter for a separate private stream consumer. Ordinary drone defaults are unchanged. */
|
|
382
|
+
export function streamReconnectDelay(attempt: number): number {
|
|
383
|
+
return Math.min(RECONNECT_MIN_MS * 2 ** attempt, RECONNECT_MAX_MS) + Math.random() * 500;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
export interface StreamConsumer {
|
|
387
|
+
/** Retained dedupe horizon when an expired wire resume cursor has been cleared. */
|
|
388
|
+
catchupCursor?: LocalServerCursor | null;
|
|
389
|
+
connected(): Promise<void>;
|
|
390
|
+
beforeEvent(): Promise<void>;
|
|
391
|
+
log(event: Extract<ParsedEvent, { type: 'log' }>, catchupCursor: LocalServerCursor | null): Promise<void>;
|
|
392
|
+
clearCursor(): Promise<void>;
|
|
393
|
+
}
|
|
381
394
|
export interface StreamDeps {
|
|
395
|
+
consumer?: StreamConsumer;
|
|
396
|
+
|
|
382
397
|
/** Override the global fetch (tests inject a controlled Response). */
|
|
383
398
|
fetchImpl?: typeof fetch;
|
|
384
399
|
/** Override persisted trust loading to verify pre-network confinement. */
|
|
@@ -424,7 +439,7 @@ export interface StreamDeps {
|
|
|
424
439
|
settleOpenCodeEntry?: (sourceEntryId: string) => void;
|
|
425
440
|
}
|
|
426
441
|
|
|
427
|
-
const defaultDeps: Required<StreamDeps
|
|
442
|
+
const defaultDeps: Required<Omit<StreamDeps, 'consumer'>> = {
|
|
428
443
|
fetchImpl: globalThis.fetch.bind(globalThis),
|
|
429
444
|
loadTrust: loadBorgServerTrust,
|
|
430
445
|
getCursor: getLocalServerCursor,
|
|
@@ -636,8 +651,7 @@ async function runLoop(testDeps: RunLoopTestDeps = {}): Promise<void> {
|
|
|
636
651
|
}
|
|
637
652
|
streamState.connected = false;
|
|
638
653
|
const delay =
|
|
639
|
-
|
|
640
|
-
Math.random() * 500;
|
|
654
|
+
streamReconnectDelay(attempt);
|
|
641
655
|
process.stderr.write(
|
|
642
656
|
`[borg-mcp log stream] reconnect in ${Math.round(delay)}ms: ${err?.message ?? err}\n`
|
|
643
657
|
);
|
|
@@ -685,6 +699,8 @@ export async function streamOnce(
|
|
|
685
699
|
onEventId: (id: string) => void,
|
|
686
700
|
deps: StreamDeps = {}
|
|
687
701
|
): Promise<void> {
|
|
702
|
+
// A representative consumer must never alter borg_stream-status's singleton.
|
|
703
|
+
const state = deps.consumer ? { ...streamState } : streamState;
|
|
688
704
|
const {
|
|
689
705
|
fetchImpl,
|
|
690
706
|
loadTrust,
|
|
@@ -714,6 +730,7 @@ export async function streamOnce(
|
|
|
714
730
|
if (deps.fetchImpl === undefined) {
|
|
715
731
|
const trust = await loadTrust(active.apiUrl);
|
|
716
732
|
if (trust.identity !== active.serverTrustIdentity) {
|
|
733
|
+
if (deps.consumer) throw new BorgServerTrustError('Borg server trust identity changed; refusing the stream');
|
|
717
734
|
throw new Error('Borg server trust identity changed; refusing the stream');
|
|
718
735
|
}
|
|
719
736
|
requestFetch = trust.fetchImpl;
|
|
@@ -804,7 +821,7 @@ export async function streamOnce(
|
|
|
804
821
|
}
|
|
805
822
|
lastPersistedHwm = next;
|
|
806
823
|
lastPersistedEventId = id;
|
|
807
|
-
|
|
824
|
+
state.lastPersistedEventId = id;
|
|
808
825
|
onEventId(id);
|
|
809
826
|
};
|
|
810
827
|
|
|
@@ -854,7 +871,7 @@ export async function streamOnce(
|
|
|
854
871
|
// Set + FIFO array for O(1) membership + bounded memory.
|
|
855
872
|
const recentIds = new Set<string>();
|
|
856
873
|
const recentIdsOrder: string[] = [];
|
|
857
|
-
let isCatchingUp = lastEventId !== null || cursor !== null;
|
|
874
|
+
let isCatchingUp = lastEventId !== null || cursor !== null || deps.consumer?.catchupCursor != null;
|
|
858
875
|
|
|
859
876
|
// gh#29 quality-stream (#5): shared inbox-write + cursor-advance helpers,
|
|
860
877
|
// extracted from the previously-duplicated ack / regular-log branches in the
|
|
@@ -973,11 +990,13 @@ export async function streamOnce(
|
|
|
973
990
|
});
|
|
974
991
|
} catch (err) {
|
|
975
992
|
if (watchdog) clearTimeout(watchdog);
|
|
993
|
+
if (deps.consumer) abortSignal.removeEventListener('abort', abortFromExternal);
|
|
976
994
|
throw err;
|
|
977
995
|
}
|
|
978
996
|
|
|
979
997
|
if (!response.ok || !response.body) {
|
|
980
998
|
if (watchdog) clearTimeout(watchdog);
|
|
999
|
+
if (deps.consumer) abortSignal.removeEventListener('abort', abortFromExternal);
|
|
981
1000
|
// gh#877 Path-B (stream bootstrap): an evicted drone's stream re-subscribe
|
|
982
1001
|
// returns the authoritative 410 DRONE_EVICTED. Surface it as the terminal
|
|
983
1002
|
// typed error so the reconnect loop stops retrying (B25) instead of backing
|
|
@@ -1034,34 +1053,36 @@ export async function streamOnce(
|
|
|
1034
1053
|
// the dead cursor. The unread watermark (client#41) is untouched, so no
|
|
1035
1054
|
// undrained wake is lost across the reset.
|
|
1036
1055
|
if (code === CURSOR_EXPIRED_CODE) {
|
|
1037
|
-
await clearLocalServerCursor({
|
|
1056
|
+
await (deps.consumer ? deps.consumer.clearCursor() : clearLocalServerCursor({
|
|
1038
1057
|
origin: active.apiUrl,
|
|
1039
1058
|
trustIdentity: active.serverTrustIdentity,
|
|
1040
1059
|
cubeId: active.cubeId,
|
|
1041
1060
|
droneId: active.droneId,
|
|
1042
1061
|
purpose: 'stream',
|
|
1043
|
-
});
|
|
1062
|
+
}));
|
|
1044
1063
|
throw new StreamCursorExpiredError();
|
|
1045
1064
|
}
|
|
1046
1065
|
}
|
|
1047
1066
|
throw new Error(`stream HTTP ${response.status}`);
|
|
1048
1067
|
}
|
|
1049
1068
|
|
|
1050
|
-
|
|
1069
|
+
state.connected = true;
|
|
1051
1070
|
|
|
1052
1071
|
try {
|
|
1072
|
+
await deps.consumer?.connected();
|
|
1053
1073
|
for await (const event of parseSSE(
|
|
1054
1074
|
response.body,
|
|
1055
1075
|
LOCAL_SERVER_SSE_FRAME_LIMIT_BYTES,
|
|
1056
1076
|
)) {
|
|
1077
|
+
await deps.consumer?.beforeEvent();
|
|
1057
1078
|
bumpWatchdog();
|
|
1058
1079
|
const nowIso = new Date().toISOString();
|
|
1059
|
-
|
|
1080
|
+
state.lastWireActivityAt = nowIso;
|
|
1060
1081
|
// Content vs wire split (T1.2): content freshness is what a reader
|
|
1061
1082
|
// skimming the top-line verdict actually cares about. Heartbeats
|
|
1062
1083
|
// bump wire-activity only; log and bookmark events bump both.
|
|
1063
1084
|
if (event.type === 'log' || event.type === 'bookmark') {
|
|
1064
|
-
|
|
1085
|
+
state.lastContentEventAt = nowIso;
|
|
1065
1086
|
}
|
|
1066
1087
|
|
|
1067
1088
|
// gh#877 Path-A: terminal eviction control frame. Handled EARLY (before
|
|
@@ -1074,8 +1095,9 @@ export async function streamOnce(
|
|
|
1074
1095
|
// (the client process cannot reach the agent loop); we only deliver the
|
|
1075
1096
|
// wake. The reconnect's stream-bootstrap 410 (authoritative) is what flips
|
|
1076
1097
|
// this loop terminal below.
|
|
1098
|
+
if (event.type === 'eviction' && deps.consumer) break;
|
|
1077
1099
|
if (event.type === 'eviction') {
|
|
1078
|
-
|
|
1100
|
+
state.lastContentEventAt = nowIso;
|
|
1079
1101
|
try {
|
|
1080
1102
|
const line = formatEvictionSentinelLine(event.reason);
|
|
1081
1103
|
await appendLine(
|
|
@@ -1103,7 +1125,7 @@ export async function streamOnce(
|
|
|
1103
1125
|
}
|
|
1104
1126
|
|
|
1105
1127
|
if (event.type === 'heartbeat') {
|
|
1106
|
-
|
|
1128
|
+
state.lastHeartbeatAt = nowIso;
|
|
1107
1129
|
// First/baseline heartbeat absorb: until this session has seen
|
|
1108
1130
|
// a broadcast entry, the server's broadcast HWM is our baseline.
|
|
1109
1131
|
// Direct messages may advance the persistence cursor past this
|
|
@@ -1142,6 +1164,16 @@ export async function streamOnce(
|
|
|
1142
1164
|
isCatchingUp = false;
|
|
1143
1165
|
continue;
|
|
1144
1166
|
}
|
|
1167
|
+
if (event.type === 'log' && deps.consumer) {
|
|
1168
|
+
if (!recentIds.has(event.id)) {
|
|
1169
|
+
await deps.consumer.log(event, isCatchingUp ? cursor ?? deps.consumer.catchupCursor ?? null : null);
|
|
1170
|
+
recentIds.add(event.id); recentIdsOrder.push(event.id);
|
|
1171
|
+
while (recentIdsOrder.length > RECENT_IDS_CAP) recentIds.delete(recentIdsOrder.shift()!);
|
|
1172
|
+
}
|
|
1173
|
+
markEventPersisted(event.id, event.data?.created_at ?? '');
|
|
1174
|
+
markBroadcastPersisted(broadcastHwmFromLogEvent(event));
|
|
1175
|
+
continue;
|
|
1176
|
+
}
|
|
1145
1177
|
if (event.type === 'log') {
|
|
1146
1178
|
const isHeartbeatPing =
|
|
1147
1179
|
typeof event.data?.message === 'string' &&
|
|
@@ -1264,7 +1296,8 @@ export async function streamOnce(
|
|
|
1264
1296
|
abortSignal.removeEventListener('abort', abortFromExternal);
|
|
1265
1297
|
if (watchdog) clearTimeout(watchdog);
|
|
1266
1298
|
clearPendingHwmDivergence();
|
|
1267
|
-
|
|
1299
|
+
if (deps.consumer) ac.abort(); // release the transport when a consumer stops or throws
|
|
1300
|
+
state.connected = false;
|
|
1268
1301
|
}
|
|
1269
1302
|
}
|
|
1270
1303
|
|