borgmcp 5.5.0 → 5.7.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 +7 -4
- package/dist/claude.d.ts.map +1 -1
- package/dist/claude.js +12 -0
- package/dist/claude.js.map +1 -1
- package/dist/cli-help.d.ts.map +1 -1
- package/dist/cli-help.js +16 -6
- package/dist/cli-help.js.map +1 -1
- package/dist/docs-sections.js +1 -1
- package/dist/docs-sections.js.map +1 -1
- package/dist/hermes-plugin-install.d.ts +19 -0
- package/dist/hermes-plugin-install.d.ts.map +1 -0
- package/dist/hermes-plugin-install.js +131 -0
- package/dist/hermes-plugin-install.js.map +1 -0
- package/dist/local-server-cursor.d.ts +9 -0
- package/dist/local-server-cursor.d.ts.map +1 -1
- package/dist/local-server-cursor.js +50 -1
- 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 +3 -0
- package/dist/remote-client.d.ts.map +1 -1
- package/dist/remote-client.js +7 -1
- package/dist/remote-client.js.map +1 -1
- package/dist/representative-cmd.d.ts +11 -0
- package/dist/representative-cmd.d.ts.map +1 -1
- package/dist/representative-cmd.js +48 -15
- package/dist/representative-cmd.js.map +1 -1
- package/dist/representative-core.d.ts +53 -7
- package/dist/representative-core.d.ts.map +1 -1
- package/dist/representative-core.js +229 -43
- package/dist/representative-core.js.map +1 -1
- 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 +10 -3
- package/dist/representative-mcp.d.ts.map +1 -1
- package/dist/representative-mcp.js +48 -19
- package/dist/representative-mcp.js.map +1 -1
- package/dist/representative-store.d.ts +7 -0
- package/dist/representative-store.d.ts.map +1 -1
- package/dist/representative-store.js +17 -2
- package/dist/representative-store.js.map +1 -1
- 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/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.map +1 -1
- package/dist/stream-owner.js +32 -3
- package/dist/stream-owner.js.map +1 -1
- package/docs/HUMAN_REPRESENTATIVE.md +310 -36
- package/hermes-plugin/borg-representative-push/__init__.py +743 -0
- package/hermes-plugin/borg-representative-push/plugin.yaml +39 -0
- package/package.json +3 -1
- package/src/claude.ts +12 -0
- package/src/cli-help.ts +16 -6
- package/src/docs-sections.ts +1 -1
- package/src/hermes-plugin-install.ts +147 -0
- package/src/local-server-cursor.ts +45 -1
- package/src/log-stream.ts +47 -14
- package/src/remote-client.ts +9 -1
- package/src/representative-cmd.ts +52 -20
- package/src/representative-core.ts +255 -46
- 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 +58 -17
- package/src/representative-store.ts +18 -2
- package/src/seat-probe.ts +1 -1
- package/src/server-trust.ts +25 -6
- package/src/stream-owner.ts +34 -2
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
name: borg-representative-push
|
|
2
|
+
version: 1.0.0
|
|
3
|
+
manifest_version: 2
|
|
4
|
+
api_version: 1
|
|
5
|
+
description: >-
|
|
6
|
+
Wakes one Hermes gateway conversation when the bound Borg Coordinator replies
|
|
7
|
+
to the human representative. Supervises `borg representative listen` inside
|
|
8
|
+
the messaging gateway and injects a fixed, body-free prompt; reply content is
|
|
9
|
+
only ever fetched by the conversation through borg_representative-read.
|
|
10
|
+
license: Apache-2.0
|
|
11
|
+
homepage: https://github.com/Byte-Ventures/borg-mcp-client
|
|
12
|
+
tags: [borg, gateway, representative]
|
|
13
|
+
# Hooks are registered only when the settings below are valid (a post_tool_call
|
|
14
|
+
# observer for the deliver tool), so none is declared unconditionally here.
|
|
15
|
+
config_schema:
|
|
16
|
+
session_key:
|
|
17
|
+
type: str
|
|
18
|
+
required: true
|
|
19
|
+
description: Gateway session key of the conversation to wake, e.g. agent:main:telegram:dm:<chat id>.
|
|
20
|
+
worktree:
|
|
21
|
+
type: str
|
|
22
|
+
required: true
|
|
23
|
+
description: Absolute path of the prepared representative worktree.
|
|
24
|
+
borg_command:
|
|
25
|
+
type: str
|
|
26
|
+
default: borg
|
|
27
|
+
description: The borg executable (borgmcp >= 5.6.0).
|
|
28
|
+
mcp_server:
|
|
29
|
+
type: str
|
|
30
|
+
default: borg-representative
|
|
31
|
+
description: Name of the mcp_servers entry that runs `borg representative mcp`.
|
|
32
|
+
reinject_after_s:
|
|
33
|
+
type: int
|
|
34
|
+
default: 600
|
|
35
|
+
description: Seconds before an undelivered reply wakes the conversation again.
|
|
36
|
+
max_reinjects:
|
|
37
|
+
type: int
|
|
38
|
+
default: 3
|
|
39
|
+
description: Maximum repeat wakes for one undelivered reply.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "borgmcp",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.7.0",
|
|
4
4
|
"description": "Coordinate AI coding agents in shared cubes. Works with Claude Code, Codex, and OpenCode.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -45,6 +45,8 @@
|
|
|
45
45
|
"dist",
|
|
46
46
|
"src",
|
|
47
47
|
"docs/*.md",
|
|
48
|
+
"hermes-plugin/borg-representative-push/plugin.yaml",
|
|
49
|
+
"hermes-plugin/borg-representative-push/__init__.py",
|
|
48
50
|
"README.md",
|
|
49
51
|
"LICENSE",
|
|
50
52
|
"NOTICE",
|
package/src/claude.ts
CHANGED
|
@@ -395,10 +395,19 @@ async function main() {
|
|
|
395
395
|
const representative = await import('./representative-cmd.js');
|
|
396
396
|
const parsed = representative.parseRepresentativeArgs(process.argv.slice(3));
|
|
397
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
|
+
}
|
|
398
403
|
process.stderr.write(chalk.red(`${consolePrefix()}◼ borg representative: ${parsed.error}\n`));
|
|
399
404
|
process.stderr.write(`Run \`borg representative --help\` for usage.\n`);
|
|
400
405
|
process.exit(1);
|
|
401
406
|
}
|
|
407
|
+
if (parsed.command.action === 'hermes-plugin-install') {
|
|
408
|
+
const install = await import('./hermes-plugin-install.js');
|
|
409
|
+
process.exit(await install.runHermesPluginInstall(parsed.command, install.defaultHermesPluginInstallDeps()));
|
|
410
|
+
}
|
|
402
411
|
const deps = await representative.buildDefaultRepresentativeDeps();
|
|
403
412
|
if (parsed.command.action === 'prepare') {
|
|
404
413
|
process.exit(await representative.runRepresentativePrepare(parsed.command, deps));
|
|
@@ -407,6 +416,9 @@ async function main() {
|
|
|
407
416
|
process.exit(await representative.runRepresentativeStatus(parsed.command, deps));
|
|
408
417
|
}
|
|
409
418
|
const { pinMcpSeatIdentity } = await import('./cubes.js');
|
|
419
|
+
if (parsed.command.action === 'listen') {
|
|
420
|
+
process.exit(await representative.runRepresentativeListen(parsed.command, deps));
|
|
421
|
+
}
|
|
410
422
|
process.exit(await representative.runRepresentativeMcp(parsed.command, deps, {
|
|
411
423
|
version: getPackageVersion(),
|
|
412
424
|
pinSeat: pinMcpSeatIdentity,
|
package/src/cli-help.ts
CHANGED
|
@@ -129,6 +129,8 @@ export function representativeHelpText(version: string): string {
|
|
|
129
129
|
` borg representative prepare --coordinator <drone-label> [--role <name>] [--worktree <name>] [--host <host>] [--rebind]\n` +
|
|
130
130
|
` borg representative status [--worktree <path>]\n` +
|
|
131
131
|
` borg representative mcp [--worktree <path>]\n` +
|
|
132
|
+
` borg representative listen --worktree <path> [--replay-after <entry_id>]\n` +
|
|
133
|
+
` borg representative hermes-plugin install [--hermes-home <path>] [--force]\n` +
|
|
132
134
|
` borg representative --help\n\n` +
|
|
133
135
|
`Commands:\n` +
|
|
134
136
|
` prepare Create or resume the representative's own drone in this repository's cube and bind it to\n` +
|
|
@@ -136,18 +138,26 @@ export function representativeHelpText(version: string): string {
|
|
|
136
138
|
` Fails if that Coordinator is missing, evicted, duplicated, or not in the human seat;\n` +
|
|
137
139
|
` another drone is never chosen instead.\n` +
|
|
138
140
|
` status Show the saved binding, re-check it against the live cube, and list unresolved sends.\n` +
|
|
139
|
-
` mcp Serve the restricted stdio MCP tools (status, send, read, ack) for a generic MCP host.\n
|
|
141
|
+
` mcp Serve the restricted stdio MCP tools (status, send, read, deliver, ack) for a generic MCP host.\n` +
|
|
142
|
+
` listen Emit body-free JSON wake hints from the server stream; supervise this separate process.\n` +
|
|
143
|
+
` hermes-plugin install\n` +
|
|
144
|
+
` Copy the Borg-owned Hermes push plugin into <Hermes home>/plugins and print the config to add.\n` +
|
|
145
|
+
` It wakes one Hermes messaging-gateway conversation (not a Desktop chat). Never edits Hermes\n` +
|
|
146
|
+
` config and never starts or restarts Hermes.\n\n` +
|
|
140
147
|
`Options:\n` +
|
|
148
|
+
` --replay-after <entry_id> listen: replay later retained hints after the last durably enqueued entry\n` +
|
|
141
149
|
` --coordinator <drone-label> Exact label of the Coordinator drone (see \`borg drones\`). Required for prepare.\n` +
|
|
142
150
|
` --role <name> Existing non-human-seat role for the representative (default: hermes-representative)\n` +
|
|
143
151
|
` --worktree <name> prepare: create the drone in a new linked worktree of that name\n` +
|
|
144
|
-
` --worktree <path> status/mcp: absolute path of the prepared representative worktree\n` +
|
|
152
|
+
` --worktree <path> status/mcp/listen: absolute path of the prepared representative worktree\n` +
|
|
145
153
|
` --host <host> prepare: explicit Borg server, as in \`borg assimilate --host\`\n` +
|
|
146
154
|
` --rebind prepare: explicitly replace the saved cube/Coordinator selection\n` +
|
|
155
|
+
` --hermes-home <path> hermes-plugin install: absolute Hermes home (default: $HERMES_HOME or ~/.hermes)\n` +
|
|
156
|
+
` --force hermes-plugin install: replace the files of an existing install\n` +
|
|
147
157
|
` --help, -h Show this help\n\n` +
|
|
148
|
-
`Limits:
|
|
149
|
-
`Reading
|
|
150
|
-
`
|
|
158
|
+
`Limits: the MCP process has no background wake; a separate listen process emits wake hints, not content.\n` +
|
|
159
|
+
`Reading changes nothing: persist replies durably, then deliver through the last one. On a listener gap, call read.\n` +
|
|
160
|
+
`The first send/read/deliver/ack takes an exclusive tools lease; listen owns a separate exclusive listener lease.\n` +
|
|
151
161
|
`A retried send reuses its request id so the server stores it once; an unknown outcome is reported as\n` +
|
|
152
162
|
`ambiguous, with its cause, and never re-sent automatically. "User-authorized" is the representative's own label:\n` +
|
|
153
163
|
`the Borg server does not verify it, and one relayed decision is not broader human approval.\n` +
|
|
@@ -230,7 +240,7 @@ export function topLevelHelpText(version: string): string {
|
|
|
230
240
|
` borg drones List this machine's registered drones and worktrees\n` +
|
|
231
241
|
` borg launch <drone-label-or-id-prefix> Reopen one registered drone from its worktree\n` +
|
|
232
242
|
` borg launch-all [cube] Launch all drone worktrees of a cube (default: active cube)\n` +
|
|
233
|
-
` borg representative prepare|status|mcp Let an MCP host (e.g. Hermes) speak for you to one Coordinator drone\n` +
|
|
243
|
+
` borg representative prepare|status|mcp|listen|hermes-plugin Let an MCP host (e.g. Hermes) speak for you to one Coordinator drone\n` +
|
|
234
244
|
` borg server <command> [arguments]\n` +
|
|
235
245
|
` borg --cli claude|codex|opencode Launch that agent CLI directly\n` +
|
|
236
246
|
` borg --version Show installed version\n\n` +
|
package/src/docs-sections.ts
CHANGED
|
@@ -96,7 +96,7 @@ export const DOCS_SECTIONS: DocsSection[] = [
|
|
|
96
96
|
slug: "human-representative",
|
|
97
97
|
title: "Human representative",
|
|
98
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,
|
|
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
100
|
keywords: ["representative", "hermes", "human representative", "delegate", "proxy", "borg representative", "borg_representative-send", "mcp host", "request_id", "ambiguous"],
|
|
101
101
|
},
|
|
102
102
|
{
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `borg representative hermes-plugin install`: copy the Borg-owned Hermes push
|
|
3
|
+
* plugin (hermes-plugin/borg-representative-push) into <Hermes home>/plugins.
|
|
4
|
+
*
|
|
5
|
+
* It only places the plugin's own files. It never edits Hermes config, never
|
|
6
|
+
* enables the plugin and never starts or restarts Hermes: the operator does
|
|
7
|
+
* those steps from the printed snippet.
|
|
8
|
+
*/
|
|
9
|
+
import { lstat, mkdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises';
|
|
10
|
+
import { homedir } from 'node:os';
|
|
11
|
+
import { isAbsolute, join } from 'node:path';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
|
|
14
|
+
export const HERMES_PLUGIN_NAME = 'borg-representative-push';
|
|
15
|
+
/** Exactly the shipped files; nothing else in the source directory is copied. */
|
|
16
|
+
export const HERMES_PLUGIN_FILES = ['plugin.yaml', '__init__.py'] as const;
|
|
17
|
+
|
|
18
|
+
export interface HermesPluginInstallDeps {
|
|
19
|
+
env: NodeJS.ProcessEnv;
|
|
20
|
+
homedir(): string;
|
|
21
|
+
/** Directory holding the packaged plugin files. */
|
|
22
|
+
sourceDir: string;
|
|
23
|
+
stdout(text: string): void;
|
|
24
|
+
stderr(text: string): void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function packagedHermesPluginDir(): string {
|
|
28
|
+
return fileURLToPath(new URL(`../hermes-plugin/${HERMES_PLUGIN_NAME}/`, import.meta.url));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function defaultHermesPluginInstallDeps(): HermesPluginInstallDeps {
|
|
32
|
+
return {
|
|
33
|
+
env: process.env,
|
|
34
|
+
homedir,
|
|
35
|
+
sourceDir: packagedHermesPluginDir(),
|
|
36
|
+
stdout: (text) => { process.stdout.write(text); },
|
|
37
|
+
stderr: (text) => { process.stderr.write(text); },
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
class InstallRefused extends Error {}
|
|
42
|
+
|
|
43
|
+
function errnoCode(error: unknown): string | undefined {
|
|
44
|
+
return (error as NodeJS.ErrnoException | undefined)?.code;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function lstatOrNull(path: string) {
|
|
48
|
+
try {
|
|
49
|
+
return await lstat(path);
|
|
50
|
+
} catch (error) {
|
|
51
|
+
if (errnoCode(error) === 'ENOENT') return null;
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function hermesPluginConfigSnippet(): string {
|
|
57
|
+
return (
|
|
58
|
+
`plugins:\n` +
|
|
59
|
+
` enabled:\n` +
|
|
60
|
+
` - ${HERMES_PLUGIN_NAME}\n` +
|
|
61
|
+
` entries:\n` +
|
|
62
|
+
` ${HERMES_PLUGIN_NAME}:\n` +
|
|
63
|
+
` allow_gateway_injection: true\n` +
|
|
64
|
+
` settings:\n` +
|
|
65
|
+
` session_key: "agent:main:<platform>:<chat type>:<chat id>" # the gateway conversation to wake\n` +
|
|
66
|
+
` worktree: "<absolute path of the prepared representative worktree>"\n` +
|
|
67
|
+
`mcp_servers:\n` +
|
|
68
|
+
` borg-representative:\n` +
|
|
69
|
+
` command: borg\n` +
|
|
70
|
+
` args: ["representative", "mcp", "--worktree", "<same absolute worktree path>"]\n` +
|
|
71
|
+
` lazy: true\n`
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function runHermesPluginInstall(
|
|
76
|
+
command: { hermesHome?: string; force: boolean },
|
|
77
|
+
deps: HermesPluginInstallDeps,
|
|
78
|
+
): Promise<number> {
|
|
79
|
+
try {
|
|
80
|
+
const home = command.hermesHome ?? (deps.env.HERMES_HOME || join(deps.homedir(), '.hermes'));
|
|
81
|
+
if (!isAbsolute(home)) throw new InstallRefused(`The Hermes home must be an absolute path: ${home}`);
|
|
82
|
+
const homeStat = await stat(home).catch(() => null);
|
|
83
|
+
if (!homeStat?.isDirectory()) {
|
|
84
|
+
throw new InstallRefused(`No Hermes home at ${home}. Install Hermes first, or pass --hermes-home <path>.`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const sources = await Promise.all(HERMES_PLUGIN_FILES.map(async (name) => {
|
|
88
|
+
try {
|
|
89
|
+
return { name, content: await readFile(join(deps.sourceDir, name)) };
|
|
90
|
+
} catch {
|
|
91
|
+
throw new InstallRefused(`The packaged plugin file ${name} is missing from ${deps.sourceDir}; reinstall borgmcp.`);
|
|
92
|
+
}
|
|
93
|
+
}));
|
|
94
|
+
|
|
95
|
+
const pluginsDir = join(home, 'plugins');
|
|
96
|
+
const pluginsStat = await stat(pluginsDir).catch((error: unknown) => {
|
|
97
|
+
if (errnoCode(error) === 'ENOENT') return null;
|
|
98
|
+
throw error;
|
|
99
|
+
});
|
|
100
|
+
if (pluginsStat && !pluginsStat.isDirectory()) throw new InstallRefused(`${pluginsDir} is not a directory.`);
|
|
101
|
+
if (!pluginsStat) await mkdir(pluginsDir, { mode: 0o755 });
|
|
102
|
+
|
|
103
|
+
const target = join(pluginsDir, HERMES_PLUGIN_NAME);
|
|
104
|
+
const existing = await lstatOrNull(target);
|
|
105
|
+
if (existing?.isSymbolicLink()) {
|
|
106
|
+
throw new InstallRefused(`${target} is a symbolic link; remove it yourself before installing.`);
|
|
107
|
+
}
|
|
108
|
+
if (existing && !existing.isDirectory()) throw new InstallRefused(`${target} exists and is not a directory.`);
|
|
109
|
+
if (existing && !command.force) {
|
|
110
|
+
throw new InstallRefused(`${target} already exists. Pass --force to replace the plugin's files.`);
|
|
111
|
+
}
|
|
112
|
+
if (!existing) await mkdir(target, { mode: 0o755 });
|
|
113
|
+
|
|
114
|
+
for (const { name, content } of sources) {
|
|
115
|
+
const destination = join(target, name);
|
|
116
|
+
const temporary = join(target, `.${name}.${process.pid}.tmp`);
|
|
117
|
+
// 'wx' refuses to follow or reuse anything already at the temporary path.
|
|
118
|
+
await writeFile(temporary, content, { mode: 0o644, flag: 'wx' });
|
|
119
|
+
try {
|
|
120
|
+
await rename(temporary, destination);
|
|
121
|
+
} catch (error) {
|
|
122
|
+
await unlink(temporary).catch(() => {});
|
|
123
|
+
throw error;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
deps.stdout(
|
|
128
|
+
`${existing ? 'Replaced' : 'Installed'} the Hermes plugin ${HERMES_PLUGIN_NAME} in ${target}.\n\n` +
|
|
129
|
+
`Hermes config was not changed. Add the following to ${join(home, 'config.yaml')}\n` +
|
|
130
|
+
`(\`hermes plugins enable ${HERMES_PLUGIN_NAME}\` covers the enabled list only):\n\n` +
|
|
131
|
+
hermesPluginConfigSnippet() +
|
|
132
|
+
`\nThen:\n` +
|
|
133
|
+
`- session_key must name a messaging-gateway conversation (for example your Telegram DM). A Hermes\n` +
|
|
134
|
+
` Desktop chat cannot be woken: Hermes injects plugin messages only into gateway conversations.\n` +
|
|
135
|
+
`- Only one process may use the representative tools. With lazy: true, run \`hermes tools\` and disable\n` +
|
|
136
|
+
` the mcp-borg-representative toolset on every platform except the one in session_key (Desktop and CLI\n` +
|
|
137
|
+
` included). If Desktop already holds the tools lease, restart the Desktop backend once.\n` +
|
|
138
|
+
`- Restart the gateway (\`hermes gateway restart\`) so it loads the plugin.\n` +
|
|
139
|
+
`Details: docs/HUMAN_REPRESENTATIVE.md, section "Hermes push plugin".\n`,
|
|
140
|
+
);
|
|
141
|
+
return 0;
|
|
142
|
+
} catch (error) {
|
|
143
|
+
const message = error instanceof InstallRefused ? error.message : `Install failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
144
|
+
deps.stderr(`${message}\n`);
|
|
145
|
+
return 1;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -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
|
|
|
@@ -146,6 +147,49 @@ export async function getLocalServerCursor(
|
|
|
146
147
|
return state.cursors[key] ?? null;
|
|
147
148
|
}
|
|
148
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
|
+
|
|
149
193
|
export async function advanceLocalServerCursor(
|
|
150
194
|
binding: LocalServerCursorBinding,
|
|
151
195
|
cursor: LocalServerCursor,
|
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
|
|
package/src/remote-client.ts
CHANGED
|
@@ -1100,6 +1100,9 @@ export async function readLog(
|
|
|
1100
1100
|
apiUrl: string,
|
|
1101
1101
|
opts: {
|
|
1102
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;
|
|
1103
1106
|
limit?: number;
|
|
1104
1107
|
unreadOnly?: boolean;
|
|
1105
1108
|
serverTrustIdentity?: string;
|
|
@@ -1122,7 +1125,11 @@ export async function readLog(
|
|
|
1122
1125
|
opts.serverTrustIdentity,
|
|
1123
1126
|
);
|
|
1124
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
|
+
}
|
|
1125
1131
|
if (opts.continuationGuard) await opts.continuationGuard();
|
|
1132
|
+
if (opts.cursor !== undefined) cursor = opts.cursor;
|
|
1126
1133
|
if (opts.unreadOnly) cursor = await getLocalServerCursor(localCursorBinding(local));
|
|
1127
1134
|
if (opts.since !== undefined) cursor = await resolveLocalLogCursor(local, opts.since, opts.continuationGuard);
|
|
1128
1135
|
let page = await localReadLogPage(local, {
|
|
@@ -1131,7 +1138,8 @@ export async function readLog(
|
|
|
1131
1138
|
continuationGuard: opts.continuationGuard,
|
|
1132
1139
|
// Keep the cursor payload stable across a lost response; do not re-read or
|
|
1133
1140
|
// advance local state until one response has been decoded successfully.
|
|
1134
|
-
|
|
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 } : {}),
|
|
1135
1143
|
});
|
|
1136
1144
|
if (opts.unreadOnly && page.cursor) {
|
|
1137
1145
|
if (opts.continuationGuard) await opts.continuationGuard();
|