borgmcp 3.2.0 → 3.4.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.
Files changed (68) hide show
  1. package/README.md +18 -3
  2. package/dist/agent-integration-health.d.ts +44 -0
  3. package/dist/agent-integration-health.d.ts.map +1 -0
  4. package/dist/agent-integration-health.js +284 -0
  5. package/dist/agent-integration-health.js.map +1 -0
  6. package/dist/claude.d.ts.map +1 -1
  7. package/dist/claude.js +13 -0
  8. package/dist/claude.js.map +1 -1
  9. package/dist/cli-help.d.ts +1 -0
  10. package/dist/cli-help.d.ts.map +1 -1
  11. package/dist/cli-help.js +11 -3
  12. package/dist/cli-help.js.map +1 -1
  13. package/dist/config-utils.d.ts +21 -0
  14. package/dist/config-utils.d.ts.map +1 -1
  15. package/dist/config-utils.js +186 -20
  16. package/dist/config-utils.js.map +1 -1
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +1 -6
  19. package/dist/index.js.map +1 -1
  20. package/dist/log-audit-core.d.ts +7 -0
  21. package/dist/log-audit-core.d.ts.map +1 -0
  22. package/dist/log-audit-core.js +80 -0
  23. package/dist/log-audit-core.js.map +1 -0
  24. package/dist/log-audit.d.ts +3 -3
  25. package/dist/log-audit.js +7 -110
  26. package/dist/log-audit.js.map +1 -1
  27. package/dist/opencode-drone.d.ts.map +1 -1
  28. package/dist/opencode-drone.js +6 -1
  29. package/dist/opencode-drone.js.map +1 -1
  30. package/dist/opencode-plugin.d.ts +47 -7
  31. package/dist/opencode-plugin.d.ts.map +1 -1
  32. package/dist/opencode-plugin.js +187 -23
  33. package/dist/opencode-plugin.js.map +1 -1
  34. package/dist/regen-format.d.ts.map +1 -1
  35. package/dist/regen-format.js +5 -6
  36. package/dist/regen-format.js.map +1 -1
  37. package/dist/resolved-cli-config.d.ts +1 -0
  38. package/dist/resolved-cli-config.d.ts.map +1 -1
  39. package/dist/resolved-cli-config.js +1 -0
  40. package/dist/resolved-cli-config.js.map +1 -1
  41. package/dist/startup-services.d.ts +0 -1
  42. package/dist/startup-services.d.ts.map +1 -1
  43. package/dist/startup-services.js +0 -2
  44. package/dist/startup-services.js.map +1 -1
  45. package/dist/unknown-subcommand.d.ts +1 -1
  46. package/dist/unknown-subcommand.d.ts.map +1 -1
  47. package/dist/unknown-subcommand.js +1 -0
  48. package/dist/unknown-subcommand.js.map +1 -1
  49. package/dist/update-cmd.d.ts +1 -1
  50. package/dist/update-cmd.d.ts.map +1 -1
  51. package/dist/update-cmd.js +15 -8
  52. package/dist/update-cmd.js.map +1 -1
  53. package/docs/LOCAL_SERVER.md +28 -7
  54. package/package.json +1 -1
  55. package/src/agent-integration-health.ts +347 -0
  56. package/src/claude.ts +14 -0
  57. package/src/cli-help.ts +14 -3
  58. package/src/config-utils.ts +185 -20
  59. package/src/index.ts +1 -7
  60. package/src/log-audit-core.ts +81 -0
  61. package/src/log-audit.ts +6 -117
  62. package/src/opencode-drone.ts +6 -1
  63. package/src/opencode-plugin.ts +216 -22
  64. package/src/regen-format.ts +5 -6
  65. package/src/resolved-cli-config.ts +2 -0
  66. package/src/startup-services.ts +0 -3
  67. package/src/unknown-subcommand.ts +1 -0
  68. package/src/update-cmd.ts +18 -11
@@ -1,37 +1,231 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import { borgHomeRoot } from './private-root.js';
4
-
5
- /**
6
- * Plugin source that preserves borg cube context across session compaction.
7
- * The initial kickoff and ongoing entry injection are handled by the launcher
8
- * and MCP client via the SDK, not by the plugin.
9
- */
10
- export const BORG_PLUGIN_SOURCE = `
11
- // Borg MCP context-preservation plugin installed by borg assimilate
12
- export default function () {
13
- return {
14
- 'experimental.session.compacting': async (_input, output) => {
15
- output.context.push(
16
- '## Borg Cube\\nYou are in a Borg MCP multi-agent coordination cube. ' +
17
- 'Use MCP tool borg_regen to get full context and recent activity.'
3
+ import { evaluateLogAudit } from './log-audit-core.js';
4
+ import { borgHomeRoot, isCanonicalPath } from './private-root.js';
5
+ import { getPackageVersion } from './version.js';
6
+
7
+ export const OPENCODE_COMPATIBILITY = {
8
+ // Empirically pinned on 2026-08-09. OpenCode 1.18.15 loads the default
9
+ // function -> Hooks object shape while the installed SDK uses the 1.17.18
10
+ // path/query/body client call shape. TextPart.metadata exists, persists
11
+ // through history/compaction/reload, and is hidden from the TUI and model.
12
+ // Do not replace either contract without a new live compatibility measurement.
13
+ opencode: '1.18.15',
14
+ sdk: '1.17.18',
15
+ } as const;
16
+
17
+ const COMPACT_FALLBACK =
18
+ '## Borg Cube\nYou are in a Borg MCP multi-agent coordination cube. ' +
19
+ 'Use MCP tool borg_regen to get full context and recent activity.';
20
+ export const OPENCODE_INJECTED_ENTRY_METADATA_KEY = 'borgOpenCodeInjectedEntry';
21
+ export const OPENCODE_RECOVERY_METADATA_KEY = 'borgOpenCodeSessionOrientation';
22
+ const PLUGIN_REL_PATH = path.join('.config', 'opencode', 'plugins', 'borg-orient.js');
23
+
24
+ export interface OpenCodePluginCoreDeps {
25
+ defer(task: () => Promise<void>): void;
26
+ wait(milliseconds: number): Promise<void>;
27
+ listMessages(sessionID: string): Promise<any[]>;
28
+ renderOrientation(source: 'clear' | 'compact'): Promise<string>;
29
+ submitPrompt(
30
+ sessionID: string,
31
+ text: string,
32
+ recoveryVersion: string,
33
+ shouldSubmit: () => boolean,
34
+ ): Promise<boolean>;
35
+ audit(messages: readonly any[]): string | null;
36
+ }
37
+
38
+ export interface OpenCodePluginCoreOptions {
39
+ enabled: boolean;
40
+ pluginVersion: string;
41
+ recoveryMetadataKey: string;
42
+ injectedEntryMetadataKey: string;
43
+ kickoffPollAttempts: number;
44
+ confirmationPollAttempts: number;
45
+ pollDelayMs: number;
46
+ compactFallback: string;
47
+ }
48
+
49
+ /** Pure, dependency-injected behavior core. Its emitted JavaScript function
50
+ * body is also embedded in the installed self-contained plugin. */
51
+ export function createOpenCodePluginCore(
52
+ deps: OpenCodePluginCoreDeps,
53
+ options: OpenCodePluginCoreOptions,
54
+ ) {
55
+ const claimedSessions = new Set<string>();
56
+ const humanPromptSessions = new Set<string>();
57
+ const textParts = (message: any): any[] => Array.isArray(message?.parts)
58
+ ? message.parts.filter((part: any) => part?.type === 'text' && typeof part.text === 'string')
59
+ : [];
60
+ const isInjectedEntry = (message: any): boolean => {
61
+ const parts = textParts(message);
62
+ return message?.info?.role === 'user' &&
63
+ parts[0]?.metadata?.[options.injectedEntryMetadataKey] === true;
64
+ };
65
+ const isOwnedRecovery = (message: any): boolean => {
66
+ const parts = textParts(message);
67
+ return message?.info?.role === 'user' &&
68
+ parts[0]?.metadata?.[options.recoveryMetadataKey] === options.pluginVersion;
69
+ };
70
+ const hasRecoveryBlocker = (sessionID: string, messages: readonly any[]): boolean =>
71
+ humanPromptSessions.has(sessionID) || messages.some((message) =>
72
+ isOwnedRecovery(message) ||
73
+ (message?.info?.role === 'user' && !isInjectedEntry(message)));
74
+
75
+ const recoverNewSession = async (sessionID: string): Promise<void> => {
76
+ try {
77
+ for (let attempt = 0; attempt < options.kickoffPollAttempts; attempt++) {
78
+ const messages = await deps.listMessages(sessionID);
79
+ if (hasRecoveryBlocker(sessionID, messages)) return;
80
+ if (attempt + 1 < options.kickoffPollAttempts) await deps.wait(options.pollDelayMs);
81
+ }
82
+
83
+ const orientation = (await deps.renderOrientation('clear')).trim();
84
+ if (!orientation) return;
85
+ const beforeSubmit = await deps.listMessages(sessionID);
86
+ if (hasRecoveryBlocker(sessionID, beforeSubmit)) return;
87
+
88
+ const submitted = await deps.submitPrompt(
89
+ sessionID,
90
+ orientation,
91
+ options.pluginVersion,
92
+ () => !hasRecoveryBlocker(sessionID, []),
18
93
  );
94
+ if (!submitted) return;
95
+ // promptAsync is not idempotent. Confirmation may retry, submission may not.
96
+ for (let attempt = 0; attempt < options.confirmationPollAttempts; attempt++) {
97
+ const messages = await deps.listMessages(sessionID);
98
+ if (messages.some(isOwnedRecovery)) return;
99
+ if (attempt + 1 < options.confirmationPollAttempts) await deps.wait(options.pollDelayMs);
100
+ }
101
+ } catch {
102
+ // The plugin is best-effort. Never block OpenCode session creation.
103
+ }
104
+ };
105
+
106
+ return {
107
+ event: async ({ event }: any): Promise<void> => {
108
+ if (!options.enabled || event?.type !== 'session.created') return;
109
+ const sessionID = event?.properties?.info?.id;
110
+ if (typeof sessionID !== 'string' || claimedSessions.has(sessionID)) return;
111
+ claimedSessions.add(sessionID);
112
+ deps.defer(() => recoverNewSession(sessionID));
113
+ },
114
+ 'experimental.session.compacting': async (
115
+ _input: { sessionID: string },
116
+ output: { context: string[] },
117
+ ): Promise<void> => {
118
+ if (!options.enabled) return;
119
+ try {
120
+ const orientation = (await deps.renderOrientation('compact')).trim();
121
+ output.context.push(orientation || options.compactFallback);
122
+ } catch {
123
+ output.context.push(options.compactFallback);
124
+ }
125
+ },
126
+ 'chat.message': async (
127
+ input: { sessionID: string },
128
+ output: { message: unknown; parts: any[] },
129
+ ): Promise<void> => {
130
+ if (!options.enabled) return;
131
+ const current = { info: { role: 'user' }, parts: [...output.parts] };
132
+ if (!isInjectedEntry(current) && !isOwnedRecovery(current)) {
133
+ // chat.message fires before the user message is persisted. Record the
134
+ // human turn synchronously so recovery cannot race that short gap.
135
+ humanPromptSessions.add(input.sessionID);
136
+ }
137
+ try {
138
+ const history = await deps.listMessages(input.sessionID);
139
+ const nudge = deps.audit([...history, current]);
140
+ if (nudge) output.parts.push({ type: 'text', text: nudge });
141
+ } catch {
142
+ // Audit is advisory and must never block a prompt.
143
+ }
19
144
  },
20
145
  };
21
146
  }
147
+
148
+ export function buildBorgPluginSource(version: string): string {
149
+ const marker = `borgmcp-opencode-plugin:${version};opencode=${OPENCODE_COMPATIBILITY.opencode};sdk=${OPENCODE_COMPATIBILITY.sdk};textpart-metadata=exists+persisted+tui-hidden+model-hidden`;
150
+ return `// ${marker}
151
+ // Generated by borgmcp. Self-contained; do not edit.
152
+ const createCore = ${createOpenCodePluginCore.toString()};
153
+ const evaluateAudit = ${evaluateLogAudit.toString()};
154
+ export default async function (ctx) {
155
+ const runRegen = async (source) => {
156
+ const input = JSON.stringify({ source });
157
+ const result = await ctx.$\`printf '%s' \${input} | borg-regen\`.quiet().nothrow();
158
+ return result.exitCode === 0 ? result.stdout.toString('utf8') : '';
159
+ };
160
+ return createCore({
161
+ defer: (task) => { setTimeout(() => { void task(); }, 0); },
162
+ wait: (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)),
163
+ listMessages: async (sessionID) => {
164
+ const result = await ctx.client.session.messages({
165
+ path: { id: sessionID },
166
+ query: { directory: ctx.directory },
167
+ });
168
+ return Array.isArray(result.data) ? result.data : [];
169
+ },
170
+ renderOrientation: runRegen,
171
+ submitPrompt: async (sessionID, text, recoveryVersion, shouldSubmit) => {
172
+ if (!shouldSubmit()) return false;
173
+ await ctx.client.session.promptAsync({
174
+ path: { id: sessionID },
175
+ query: { directory: ctx.directory },
176
+ body: { parts: [{
177
+ type: 'text',
178
+ text,
179
+ metadata: { [${JSON.stringify(OPENCODE_RECOVERY_METADATA_KEY)}]: recoveryVersion },
180
+ }] },
181
+ });
182
+ return true;
183
+ },
184
+ audit: (messages) => evaluateAudit(messages),
185
+ }, {
186
+ ...${JSON.stringify({
187
+ pluginVersion: version,
188
+ recoveryMetadataKey: OPENCODE_RECOVERY_METADATA_KEY,
189
+ injectedEntryMetadataKey: OPENCODE_INJECTED_ENTRY_METADATA_KEY,
190
+ kickoffPollAttempts: 6,
191
+ confirmationPollAttempts: 6,
192
+ pollDelayMs: 200,
193
+ compactFallback: COMPACT_FALLBACK,
194
+ })},
195
+ enabled: process.env.BORG_SESSION === '1',
196
+ });
197
+ }
22
198
  `;
199
+ }
23
200
 
24
- const PLUGIN_REL_PATH = path.join('.config', 'opencode', 'plugins', 'borg-orient.js');
201
+ export const BORG_PLUGIN_SOURCE = buildBorgPluginSource(getPackageVersion());
25
202
 
26
- export function installBorgPlugin(): void {
27
- const pluginPath = path.join(borgHomeRoot(), PLUGIN_REL_PATH);
203
+ export function openCodePluginPath(homeDir: string = borgHomeRoot()): string {
204
+ return path.join(homeDir, PLUGIN_REL_PATH);
205
+ }
206
+
207
+ export function installBorgPlugin(options: {
208
+ homeDir?: string;
209
+ version?: string;
210
+ } = {}): void {
211
+ const pluginPath = openCodePluginPath(options.homeDir);
212
+ const source = buildBorgPluginSource(options.version ?? getPackageVersion());
28
213
  try {
29
- if (fs.existsSync(pluginPath)) {
30
- const existing = fs.readFileSync(pluginPath, 'utf-8');
31
- if (existing === BORG_PLUGIN_SOURCE) return;
214
+ if (!isCanonicalPath(pluginPath)) return;
215
+ try {
216
+ if (fs.lstatSync(pluginPath).isSymbolicLink()) return;
217
+ if (fs.readFileSync(pluginPath, 'utf-8') === source) return;
218
+ } catch (error) {
219
+ if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) return;
32
220
  }
33
221
  fs.mkdirSync(path.dirname(pluginPath), { recursive: true });
34
- fs.writeFileSync(pluginPath, BORG_PLUGIN_SOURCE, 'utf-8');
222
+ if (!isCanonicalPath(pluginPath)) return;
223
+ try {
224
+ if (fs.lstatSync(pluginPath).isSymbolicLink()) return;
225
+ } catch (error) {
226
+ if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) return;
227
+ }
228
+ fs.writeFileSync(pluginPath, source, 'utf-8');
35
229
  } catch {
36
230
  // Best-effort — plugin is an optimization, not a requirement.
37
231
  }
@@ -18,7 +18,6 @@ import {
18
18
  renderRuntimeMetadataLines,
19
19
  } from './roster-render.js';
20
20
  import { shellEscape } from './shell-escape.js';
21
- import { resolveInboxMonitorPath } from './self-path.js';
22
21
  import { OPENCODE_WAKE_PATH_GUIDANCE } from './opencode-wake-copy.js';
23
22
  import { isBorgSession } from './launch-gate.js';
24
23
 
@@ -112,10 +111,10 @@ export function wakePathArming(
112
111
  if (agentKind === 'opencode') {
113
112
  return OPENCODE_WAKE_PATH_GUIDANCE;
114
113
  }
115
- // gh#client#18: use absolute path to THIS installation's borg-inbox-monitor
116
- // so the orientation command always resolves to the same version as the
117
- // running client never a different one via PATH.
118
- const monitorBin = shellEscape(resolveInboxMonitorPath());
114
+ // client#394: the stable npm bin survives Node/nvm install-path rotation.
115
+ // Launch-time health checks make a missing or version-skewed PATH target
116
+ // visible instead of silently embedding a stale installation path here.
117
+ const monitorBin = 'borg-inbox-monitor';
119
118
  const monitorCommand = monitorStateRoot
120
119
  ? `${monitorBin} --state-root ${shellEscape(monitorStateRoot)} ${shellEscape(inboxPath)}`
121
120
  : `${monitorBin} ${shellEscape(inboxPath)}`;
@@ -191,7 +190,7 @@ export function formatLeanOrientation(args: {
191
190
  '\n_(`/clear` cleared Claude\'s conversation — re-arm the inbox Monitor now.)_',
192
191
  '_Quiet-clear fallback: if a later turn follows silence, inspect `borg_stream-status` + `borg_roster`; call `borg_regen mode="full"`, drain `borg_read-log unread_only=true`, then re-arm the Monitor._\n',
193
192
  ].join('\n')
194
- : ''
193
+ : '\n_(OpenCode started a new session; Borg supplied this orientation automatically.)_\n'
195
194
  : '';
196
195
  return [
197
196
  `# Cube: ${cubeName} — ${droneLabel}`,
@@ -7,6 +7,7 @@ export interface ResolvedCliConfigDeps {
7
7
  addClaudeUserPromptSubmitHook(): void;
8
8
  addCodexSessionStartHook(): void;
9
9
  addCodexUserPromptSubmitHook(): void;
10
+ installOpenCodePlugin(): void;
10
11
  }
11
12
  /**
12
13
  * Apply only the integration writes for the CLI the launcher resolved.
@@ -37,4 +38,5 @@ export function configureResolvedCli(
37
38
  }
38
39
 
39
40
  deps.ensureMcp('opencode');
41
+ deps.installOpenCodePlugin();
40
42
  }
@@ -2,7 +2,6 @@ export type McpStartupTask = () => void | Promise<void>;
2
2
 
3
3
  /** Required named services: omission from index wiring is a type error. */
4
4
  export interface McpStartupServices {
5
- sessionStartHook: McpStartupTask;
6
5
  auditHook: McpStartupTask;
7
6
  sseStream: McpStartupTask;
8
7
  openCode: McpStartupTask;
@@ -21,12 +20,10 @@ export async function runMcpStartupServices(
21
20
  ): Promise<void> {
22
21
  if (readinessProbe) return;
23
22
  const tasks = options.openCodeFirst ? [
24
- services.sessionStartHook,
25
23
  services.auditHook,
26
24
  services.openCode,
27
25
  services.sseStream,
28
26
  ] : [
29
- services.sessionStartHook,
30
27
  services.auditHook,
31
28
  services.sseStream,
32
29
  services.openCode,
@@ -15,6 +15,7 @@
15
15
  export const KNOWN_SUBCOMMANDS = [
16
16
  'setup',
17
17
  'update',
18
+ 'doctor',
18
19
  'assimilate',
19
20
  'reset-local-connection',
20
21
  'recover-enrollment',
package/src/update-cmd.ts CHANGED
@@ -8,7 +8,7 @@ import { updateHelpText } from './cli-help.js';
8
8
  import { preflightBorgServerTag } from './server-handshake.js';
9
9
  import { loadBorgServerTrust } from './server-trust.js';
10
10
  import { shellEscape } from './shell-escape.js';
11
- import { refreshManagedAgentMcpConfigs } from './config-utils.js';
11
+ import { refreshAndVerifyManagedAgentIntegrations } from './agent-integration-health.js';
12
12
 
13
13
  const CLIENT_PACKAGE = 'borgmcp';
14
14
  const SERVER_PACKAGE = 'borgmcp-server';
@@ -65,7 +65,7 @@ export interface UpdateDeps {
65
65
  reenter(binPath: string, args: readonly string[]): Promise<number>;
66
66
  serverJson(binPath: string, command: 'update' | 'status'): Promise<unknown>;
67
67
  verifyRunningProtocol(origin: string): Promise<void>;
68
- refreshAgentMcpConfigs(): Promise<Array<'claude' | 'codex' | 'opencode'>>;
68
+ refreshAgentIntegrations(): Promise<void>;
69
69
  confirm(message: string): Promise<'yes' | 'no' | 'eof' | 'interrupted'>;
70
70
  isTTY(): boolean;
71
71
  stdout(text: string): void;
@@ -115,8 +115,7 @@ type ServerUpdateFailureStage =
115
115
  | 'final server state verification'
116
116
  | 'final package verification'
117
117
  | 'managed service continuity check'
118
- | 'running server protocol verification'
119
- | 'agent MCP config refresh';
118
+ | 'running server protocol verification';
120
119
 
121
120
  interface NpmContext {
122
121
  commandPath: string;
@@ -699,11 +698,10 @@ export async function runUpdate(options: UpdateOptions, deps: UpdateDeps): Promi
699
698
 
700
699
  if (!serverWasPresent) {
701
700
  try {
702
- await deps.refreshAgentMcpConfigs();
701
+ await deps.refreshAgentIntegrations();
703
702
  } catch (error) {
704
703
  deps.stderr(
705
- `Client updated, but agent MCP config refresh failed: ${errorMessage(error, 'unknown failure')}.\n` +
706
- `Run borg setup to repair Borg-written agent registrations. Configurations that use another command are not changed.\n`,
704
+ `Client updated, but agent integration refresh and health check failed: ${errorMessage(error, 'unknown failure')}.\n`,
707
705
  );
708
706
  return 1;
709
707
  }
@@ -821,9 +819,18 @@ export async function runUpdate(options: UpdateOptions, deps: UpdateDeps): Promi
821
819
  retryCommand = 'borg server status';
822
820
  await deps.verifyRunningProtocol(status.endpoint!);
823
821
  }
824
- failureStage = 'agent MCP config refresh';
825
- retryCommand = 'borg update --yes';
826
- await deps.refreshAgentMcpConfigs();
822
+ try {
823
+ await deps.refreshAgentIntegrations();
824
+ } catch (error) {
825
+ const verifiedOutcome = state === 'stopped'
826
+ ? 'prepared runtime verified; server remains stopped'
827
+ : 'running identities and protocol verified';
828
+ deps.stderr(
829
+ `Updated ${CLIENT_PACKAGE}@${pair.client.version} and ${SERVER_PACKAGE}@${pair.server.version}; ${verifiedOutcome}.\n` +
830
+ `Agent integration refresh and health check failed: ${errorMessage(error, 'unknown failure')}.\n`,
831
+ );
832
+ return signalExitCode(error) ?? 1;
833
+ }
827
834
  deps.stdout(
828
835
  state === 'stopped'
829
836
  ? (
@@ -1225,7 +1232,7 @@ export function buildDefaultUpdateDeps(): UpdateDeps {
1225
1232
  const trust = await loadBorgServerTrust(origin);
1226
1233
  await preflightBorgServerTag(origin, trust.fetchImpl);
1227
1234
  },
1228
- refreshAgentMcpConfigs: async () => refreshManagedAgentMcpConfigs(),
1235
+ refreshAgentIntegrations: async () => refreshAndVerifyManagedAgentIntegrations(),
1229
1236
  confirm: defaultConfirm,
1230
1237
  isTTY: () => process.stdin.isTTY === true && process.stdout.isTTY === true,
1231
1238
  stdout: (text) => process.stdout.write(text),