borgmcp 5.6.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.
@@ -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.6.0",
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
@@ -404,6 +404,10 @@ async function main() {
404
404
  process.stderr.write(`Run \`borg representative --help\` for usage.\n`);
405
405
  process.exit(1);
406
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
+ }
407
411
  const deps = await representative.buildDefaultRepresentativeDeps();
408
412
  if (parsed.command.action === 'prepare') {
409
413
  process.exit(await representative.runRepresentativePrepare(parsed.command, deps));
package/src/cli-help.ts CHANGED
@@ -130,6 +130,7 @@ export function representativeHelpText(version: string): string {
130
130
  ` borg representative status [--worktree <path>]\n` +
131
131
  ` borg representative mcp [--worktree <path>]\n` +
132
132
  ` borg representative listen --worktree <path> [--replay-after <entry_id>]\n` +
133
+ ` borg representative hermes-plugin install [--hermes-home <path>] [--force]\n` +
133
134
  ` borg representative --help\n\n` +
134
135
  `Commands:\n` +
135
136
  ` prepare Create or resume the representative's own drone in this repository's cube and bind it to\n` +
@@ -138,7 +139,11 @@ export function representativeHelpText(version: string): string {
138
139
  ` another drone is never chosen instead.\n` +
139
140
  ` status Show the saved binding, re-check it against the live cube, and list unresolved sends.\n` +
140
141
  ` 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
+ ` 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` +
142
147
  `Options:\n` +
143
148
  ` --replay-after <entry_id> listen: replay later retained hints after the last durably enqueued entry\n` +
144
149
  ` --coordinator <drone-label> Exact label of the Coordinator drone (see \`borg drones\`). Required for prepare.\n` +
@@ -147,6 +152,8 @@ export function representativeHelpText(version: string): string {
147
152
  ` --worktree <path> status/mcp/listen: absolute path of the prepared representative worktree\n` +
148
153
  ` --host <host> prepare: explicit Borg server, as in \`borg assimilate --host\`\n` +
149
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` +
150
157
  ` --help, -h Show this help\n\n` +
151
158
  `Limits: the MCP process has no background wake; a separate listen process emits wake hints, not content.\n` +
152
159
  `Reading changes nothing: persist replies durably, then deliver through the last one. On a listener gap, call read.\n` +
@@ -233,7 +240,7 @@ export function topLevelHelpText(version: string): string {
233
240
  ` borg drones List this machine's registered drones and worktrees\n` +
234
241
  ` borg launch <drone-label-or-id-prefix> Reopen one registered drone from its worktree\n` +
235
242
  ` 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` +
243
+ ` borg representative prepare|status|mcp|listen|hermes-plugin Let an MCP host (e.g. Hermes) speak for you to one Coordinator drone\n` +
237
244
  ` borg server <command> [arguments]\n` +
238
245
  ` borg --cli claude|codex|opencode Launch that agent CLI directly\n` +
239
246
  ` borg --version Show installed version\n\n` +
@@ -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
+ }
@@ -43,7 +43,8 @@ export type RepresentativeCommand =
43
43
  | { action: 'prepare'; coordinator: string; role: string; rebind: boolean; worktreeName?: string; host?: string }
44
44
  | { action: 'status'; worktree?: string }
45
45
  | { action: 'mcp'; worktree?: string }
46
- | { action: 'listen'; worktree?: string; replayAfter?: string };
46
+ | { action: 'listen'; worktree?: string; replayAfter?: string }
47
+ | { action: 'hermes-plugin-install'; hermesHome?: string; force: boolean };
47
48
 
48
49
  export type ParsedRepresentativeArgs =
49
50
  | { ok: true; command: RepresentativeCommand }
@@ -64,8 +65,9 @@ export interface RepresentativeCmdDeps {
64
65
 
65
66
  export function parseRepresentativeArgs(args: readonly string[]): ParsedRepresentativeArgs {
66
67
  const [action, ...rest] = args;
68
+ if (action === 'hermes-plugin') return parseHermesPluginArgs(rest);
67
69
  if (action !== 'prepare' && action !== 'status' && action !== 'mcp' && action !== 'listen') {
68
- return { ok: false, error: 'expected one of: prepare, status, mcp, listen' };
70
+ return { ok: false, error: 'expected one of: prepare, status, mcp, listen, hermes-plugin' };
69
71
  }
70
72
  const values: Record<string, string> = {};
71
73
  let rebind = false;
@@ -124,6 +126,30 @@ export function parseRepresentativeArgs(args: readonly string[]): ParsedRepresen
124
126
  };
125
127
  }
126
128
 
129
+ function parseHermesPluginArgs(args: readonly string[]): ParsedRepresentativeArgs {
130
+ const [subcommand, ...rest] = args;
131
+ if (subcommand !== 'install') return { ok: false, error: 'expected: hermes-plugin install [--hermes-home <path>] [--force]' };
132
+ let hermesHome: string | undefined;
133
+ let force = false;
134
+ for (let i = 0; i < rest.length; i += 1) {
135
+ const arg = rest[i];
136
+ if (arg === '--force') {
137
+ force = true;
138
+ } else if (arg === '--hermes-home') {
139
+ const next = rest[i + 1];
140
+ if (typeof next !== 'string' || next.length === 0 || next.startsWith('-')) {
141
+ return { ok: false, error: '--hermes-home requires a value' };
142
+ }
143
+ if (!isAbsolute(next)) return { ok: false, error: '--hermes-home must be an absolute path' };
144
+ hermesHome = next;
145
+ i += 1;
146
+ } else {
147
+ return { ok: false, error: `unknown argument: ${arg}. Supported: --hermes-home, --force` };
148
+ }
149
+ }
150
+ return { ok: true, command: { action: 'hermes-plugin-install', force, ...(hermesHome ? { hermesHome } : {}) } };
151
+ }
152
+
127
153
  function canonicalWorktree(path: string, deps: Pick<RepresentativeCmdDeps, 'findProjectRoot'>): string {
128
154
  let real = resolve(path);
129
155
  try {