borgmcp 3.2.0 → 3.3.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 (45) hide show
  1. package/README.md +11 -3
  2. package/dist/agent-integration-health.d.ts +40 -0
  3. package/dist/agent-integration-health.d.ts.map +1 -0
  4. package/dist/agent-integration-health.js +185 -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 +12 -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 +185 -19
  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/regen-format.d.ts.map +1 -1
  21. package/dist/regen-format.js +4 -5
  22. package/dist/regen-format.js.map +1 -1
  23. package/dist/startup-services.d.ts +0 -1
  24. package/dist/startup-services.d.ts.map +1 -1
  25. package/dist/startup-services.js +0 -2
  26. package/dist/startup-services.js.map +1 -1
  27. package/dist/unknown-subcommand.d.ts +1 -1
  28. package/dist/unknown-subcommand.d.ts.map +1 -1
  29. package/dist/unknown-subcommand.js +1 -0
  30. package/dist/unknown-subcommand.js.map +1 -1
  31. package/dist/update-cmd.d.ts +1 -1
  32. package/dist/update-cmd.d.ts.map +1 -1
  33. package/dist/update-cmd.js +15 -8
  34. package/dist/update-cmd.js.map +1 -1
  35. package/docs/LOCAL_SERVER.md +13 -5
  36. package/package.json +1 -1
  37. package/src/agent-integration-health.ts +232 -0
  38. package/src/claude.ts +13 -0
  39. package/src/cli-help.ts +14 -3
  40. package/src/config-utils.ts +184 -19
  41. package/src/index.ts +1 -7
  42. package/src/regen-format.ts +4 -5
  43. package/src/startup-services.ts +0 -3
  44. package/src/unknown-subcommand.ts +1 -0
  45. package/src/update-cmd.ts +18 -11
@@ -28,13 +28,13 @@ import type { LaunchAccessPaths } from './launch-access.js';
28
28
  const __filename = fileURLToPath(import.meta.url);
29
29
  const __dirname = dirname(__filename);
30
30
 
31
- // gh#client#18: canonical hook commands stored in JSON are shell-escaped
32
- // absolute paths. Bare names and stale/other-install absolute paths are
33
- // migrated to this form on every hook write.
34
- const HOOK_COMMAND = shellEscape(resolveRegenPath());
35
- const CLEAR_REWAKE_HOOK_COMMAND = shellEscape(resolveClearRewakePath());
36
- const AUDIT_HOOK_COMMAND = shellEscape(resolveLogAuditPath());
37
- const FOREIGN_PATH_REMINDER_HOOK_COMMAND = shellEscape(resolveForeignPathReminderPath());
31
+ // client#394: hook commands are stable npm bin names. They intentionally stay
32
+ // unquoted: each is a fixed single shell token with no metacharacters. Legacy
33
+ // quoted bare names and install-specific absolute paths migrate to this form.
34
+ const HOOK_COMMAND = 'borg-regen';
35
+ const CLEAR_REWAKE_HOOK_COMMAND = 'borg-clear-rewake';
36
+ const AUDIT_HOOK_COMMAND = 'borg-log-audit';
37
+ const FOREIGN_PATH_REMINDER_HOOK_COMMAND = 'borg-foreign-path-reminder';
38
38
  const MCP_COMMAND = 'borg-mcp';
39
39
 
40
40
  /**
@@ -234,8 +234,7 @@ function addSessionStartHookAt(settingsFile: string, includeClearRewake = false)
234
234
  return true;
235
235
  }
236
236
 
237
- // gh#client#18: match bare names (old configs), stale absolute paths (other
238
- // installations), shell-escaped canonical paths, and unescaped canonical paths.
237
+ // Match stable bare names plus legacy install-specific absolute forms.
239
238
  function commandMatches(entryCommand: string, bareName: string, absolutePath: string): boolean {
240
239
  const escaped = shellEscape(absolutePath);
241
240
  if (entryCommand === escaped || entryCommand === absolutePath || entryCommand === bareName) return true;
@@ -254,7 +253,7 @@ function commandMatches(entryCommand: string, bareName: string, absolutePath: st
254
253
  * absolute command — always owned, no marker required (fixes neutral-path
255
254
  * false negatives that broke idempotency)
256
255
  * (c) Foreign-install heuristic: absolute path ending in an owned basename
257
- * AND containing a borg package marker (borgmcp|borg-mcp) in the path
256
+ * immediately below an exact borgmcp|borg-mcp/dist package path
258
257
  * This prevents false-positive ownership of unrelated scripts that happen
259
258
  * to share a basename (e.g. /opt/custom-tool/regen.js). */
260
259
  function ownedCanonical(command: string): string | null {
@@ -273,9 +272,16 @@ function ownedCanonical(command: string): string | null {
273
272
  if (command === AUDIT_HOOK_COMMAND || stripped === resolveLogAuditPath()) return AUDIT_HOOK_COMMAND;
274
273
  if (command === FOREIGN_PATH_REMINDER_HOOK_COMMAND || stripped === resolveForeignPathReminderPath()) return FOREIGN_PATH_REMINDER_HOOK_COMMAND;
275
274
 
276
- // (c) Foreign-install heuristic: absolute path + owned basename + borg marker
277
- if (stripped.startsWith('/') && (stripped.includes('borgmcp') || stripped.includes('borg-mcp'))) {
278
- const name = stripped.split('/').pop() ?? '';
275
+ // (c) Foreign-install heuristic: require exact path segments shaped like a
276
+ // package root. A substring such as /opt/borgmcp-tools/ is not ownership.
277
+ const segments = stripped.split('/');
278
+ const packageIndex = segments.length - 3;
279
+ const packageShaped = packageIndex >= 0 &&
280
+ (segments[packageIndex] === 'borgmcp' || segments[packageIndex] === 'borg-mcp') &&
281
+ segments[packageIndex + 1] === 'dist' &&
282
+ packageIndex + 2 === segments.length - 1;
283
+ if (stripped.startsWith('/') && packageShaped) {
284
+ const name = segments.at(-1) ?? '';
279
285
  if (name === 'regen.js') return HOOK_COMMAND;
280
286
  if (name === 'clear-rewake.js') return CLEAR_REWAKE_HOOK_COMMAND;
281
287
  if (name === 'log-audit.js') return AUDIT_HOOK_COMMAND;
@@ -311,6 +317,7 @@ function migrateAndDedupOwnedHooks(entries: any[]): boolean {
311
317
  // object across all entries. Remove only the duplicate hook objects, not
312
318
  // entire entries (preserving unrelated siblings and entry metadata).
313
319
  const seenCanonicals = new Set<string>();
320
+ const emptiedByOwnedDedup = new Set<any>();
314
321
  for (const entry of entries) {
315
322
  if (!Array.isArray(entry?.hooks)) continue;
316
323
  const before = entry.hooks.length;
@@ -326,11 +333,13 @@ function migrateAndDedupOwnedHooks(entries: any[]): boolean {
326
333
  return true;
327
334
  });
328
335
  if (entry.hooks.length !== before) changed = true;
336
+ if (before > 0 && entry.hooks.length === 0) emptiedByOwnedDedup.add(entry);
329
337
  }
330
338
 
331
- // Phase 3: Remove entries that became empty after dedup
339
+ // Phase 3: remove only entries emptied by removal of duplicate Borg-owned
340
+ // hooks. Pre-existing empty or unusual operator entries remain byte-stable.
332
341
  for (let i = entries.length - 1; i >= 0; i--) {
333
- if (!Array.isArray(entries[i]?.hooks) || entries[i].hooks.length === 0) {
342
+ if (emptiedByOwnedDedup.has(entries[i])) {
334
343
  entries.splice(i, 1);
335
344
  changed = true;
336
345
  }
@@ -339,9 +348,165 @@ function migrateAndDedupOwnedHooks(entries: any[]): boolean {
339
348
  return changed;
340
349
  }
341
350
 
342
- /** Strict canonical match: only the shell-escaped canonical form.
343
- * gh#client#18: raw unescaped paths are NOT canonical — a path with spaces
344
- * or metacharacters would break at shell-fire time if not escaped. */
351
+ export interface RefreshManagedAgentHookConfigOptions {
352
+ homeDir?: string;
353
+ }
354
+
355
+ export interface ManagedAgentHookConfigHealth {
356
+ path: string;
357
+ status: 'absent' | 'ok' | 'stale' | 'invalid';
358
+ detail?: string;
359
+ }
360
+
361
+ function globalManagedAgentHookConfigPaths(homeDir: string): string[] {
362
+ return [
363
+ path.join(homeDir, '.claude', 'settings.json'),
364
+ path.join(homeDir, '.codex', 'hooks.json'),
365
+ ];
366
+ }
367
+
368
+ function realDirectory(pathname: string): boolean {
369
+ try {
370
+ const stat = fs.lstatSync(pathname);
371
+ return stat.isDirectory() && !stat.isSymbolicLink();
372
+ } catch {
373
+ return false;
374
+ }
375
+ }
376
+
377
+ /**
378
+ * Enumerate only Borg's canonical two-level managed-worktree layout. Every
379
+ * traversed component must be a real directory; symlinks are never followed.
380
+ */
381
+ export function managedAgentHookConfigPaths(homeDir: string = CONFIG_HOME): string[] {
382
+ const paths = globalManagedAgentHookConfigPaths(homeDir);
383
+ const borgDir = path.join(homeDir, '.borg');
384
+ if (!realDirectory(borgDir)) return paths;
385
+ const root = path.join(borgDir, 'worktrees');
386
+ if (!realDirectory(root)) return paths;
387
+
388
+ for (const repo of fs.readdirSync(root, { withFileTypes: true })) {
389
+ const repoPath = path.join(root, repo.name);
390
+ if (!repo.isDirectory() || !realDirectory(repoPath)) continue;
391
+ for (const worktree of fs.readdirSync(repoPath, { withFileTypes: true })) {
392
+ const worktreePath = path.join(repoPath, worktree.name);
393
+ if (!worktree.isDirectory() || !realDirectory(worktreePath)) continue;
394
+ const claudeDir = path.join(worktreePath, '.claude');
395
+ if (!realDirectory(claudeDir)) continue;
396
+ const settingsFile = path.join(claudeDir, 'settings.local.json');
397
+ try {
398
+ if (fs.lstatSync(settingsFile).isSymbolicLink()) continue;
399
+ } catch {
400
+ // No settings file in this canonical worktree; nothing to inventory.
401
+ continue;
402
+ }
403
+ paths.push(settingsFile);
404
+ }
405
+ }
406
+ return paths;
407
+ }
408
+
409
+ function refreshHookFile(configPath: string): boolean {
410
+ if (!fs.existsSync(configPath)) return false;
411
+ const config = readJsonFile(configPath);
412
+ if (!config || typeof config !== 'object' || Array.isArray(config)) {
413
+ throw new Error('top-level value is not an object');
414
+ }
415
+ const hooks = config.hooks;
416
+ if (hooks === undefined) return false;
417
+ if (!hooks || typeof hooks !== 'object' || Array.isArray(hooks)) {
418
+ throw new Error('hooks is not an object');
419
+ }
420
+ let changed = false;
421
+ for (const entries of Object.values(hooks)) {
422
+ if (!Array.isArray(entries)) continue;
423
+ changed = migrateAndDedupOwnedHooks(entries) || changed;
424
+ }
425
+ if (changed) writeJsonFile(configPath, config);
426
+ return changed;
427
+ }
428
+
429
+ /**
430
+ * Heal stale Borg-owned hook commands in global agent files and canonical
431
+ * managed worktrees. Non-Borg hooks and noncanonical worktree roots are left
432
+ * untouched. Later files are still attempted when one file is invalid.
433
+ */
434
+ export function refreshManagedAgentHookConfigs(
435
+ options: RefreshManagedAgentHookConfigOptions = {},
436
+ ): string[] {
437
+ const homeDir = options.homeDir ?? CONFIG_HOME;
438
+ const refreshed: string[] = [];
439
+ const failures: string[] = [];
440
+ let configPaths = globalManagedAgentHookConfigPaths(homeDir);
441
+ try {
442
+ configPaths = managedAgentHookConfigPaths(homeDir);
443
+ } catch (error) {
444
+ const message = error instanceof Error ? error.message : String(error);
445
+ failures.push(`hook inventory ${path.join(homeDir, '.borg', 'worktrees')}: ${message}`);
446
+ }
447
+ for (const configPath of configPaths) {
448
+ try {
449
+ if (refreshHookFile(configPath)) refreshed.push(configPath);
450
+ } catch (error) {
451
+ const label = configPath.endsWith(path.join('.codex', 'hooks.json'))
452
+ ? 'Codex'
453
+ : 'Claude Code';
454
+ const message = error instanceof Error ? error.message : String(error);
455
+ failures.push(`${label} ${configPath}: ${message}`);
456
+ }
457
+ }
458
+ if (failures.length > 0) {
459
+ throw new Error(`Could not refresh managed agent hooks: ${failures.join('; ')}`);
460
+ }
461
+ return refreshed;
462
+ }
463
+
464
+ /** Read-only mirror of the updater's stale-command predicate. */
465
+ export function inspectManagedAgentHookConfigs(
466
+ homeDir: string = CONFIG_HOME,
467
+ ): ManagedAgentHookConfigHealth[] {
468
+ let configPaths = globalManagedAgentHookConfigPaths(homeDir);
469
+ let inventoryIssue: ManagedAgentHookConfigHealth | null = null;
470
+ try {
471
+ configPaths = managedAgentHookConfigPaths(homeDir);
472
+ } catch (error) {
473
+ inventoryIssue = {
474
+ path: path.join(homeDir, '.borg', 'worktrees'),
475
+ status: 'invalid',
476
+ detail: `inventory failed: ${error instanceof Error ? error.message : String(error)}`,
477
+ };
478
+ }
479
+ const health = configPaths.map((configPath): ManagedAgentHookConfigHealth => {
480
+ if (!fs.existsSync(configPath)) return { path: configPath, status: 'absent' };
481
+ try {
482
+ const config = readJsonFile(configPath);
483
+ if (!config || typeof config !== 'object' || Array.isArray(config)) {
484
+ return { path: configPath, status: 'invalid', detail: 'top-level value is not an object' };
485
+ }
486
+ if (config.hooks === undefined) return { path: configPath, status: 'ok' };
487
+ if (!config.hooks || typeof config.hooks !== 'object' || Array.isArray(config.hooks)) {
488
+ return { path: configPath, status: 'invalid', detail: 'hooks is not an object' };
489
+ }
490
+ const copy = structuredClone(config.hooks);
491
+ let stale = false;
492
+ for (const entries of Object.values(copy)) {
493
+ if (Array.isArray(entries)) stale = migrateAndDedupOwnedHooks(entries) || stale;
494
+ }
495
+ return { path: configPath, status: stale ? 'stale' : 'ok' };
496
+ } catch (error) {
497
+ return {
498
+ path: configPath,
499
+ status: 'invalid',
500
+ detail: error instanceof Error ? error.message : String(error),
501
+ };
502
+ }
503
+ });
504
+ if (inventoryIssue) health.push(inventoryIssue);
505
+ return health;
506
+ }
507
+
508
+ /** Strict canonical match. Quoted bare and legacy path forms are owned but
509
+ * require migration before they count as the one current command form. */
345
510
  function isCanonicalCommand(entryCommand: string, canonical: string): boolean {
346
511
  return entryCommand === canonical;
347
512
  }
@@ -368,7 +533,7 @@ function hasCommandHook(entries: any[], command: string): boolean {
368
533
  );
369
534
  }
370
535
 
371
- /** Strict: only the shell-escaped canonical form (no bare-name fallback). */
536
+ /** Strict: only the current unquoted bare canonical form. */
372
537
  function hasCanonicalCommandHook(entries: any[], command: string): boolean {
373
538
  return entries.some((entry: any) =>
374
539
  Array.isArray(entry?.hooks) &&
package/src/index.ts CHANGED
@@ -73,7 +73,7 @@ import {
73
73
  pinMcpSeatIdentity,
74
74
  } from './cubes.js';
75
75
  import { isEntryInvocation, monitorStateRootForWorktree } from './inbox-monitor.js';
76
- import { addSessionStartHook, addUserPromptSubmitHook } from './config-utils.js';
76
+ import { addUserPromptSubmitHook } from './config-utils.js';
77
77
  import {
78
78
  humanAgo,
79
79
  formatLogEntryMarkdown,
@@ -282,12 +282,6 @@ export async function main() {
282
282
  const readinessProbe = isMcpReadinessProbe();
283
283
  const openCodeRuntime = resolveSessionAgentKind() === 'opencode';
284
284
  const startupServices = {
285
- // Auto-register the SessionStart hook so existing users get borg-regen
286
- // auto-orientation on session start without re-running borg setup. Idempotent.
287
- sessionStartHook: () => {
288
- addSessionStartHook();
289
- },
290
-
291
285
  // Auto-register the UserPromptSubmit audit hook so the drone gets a
292
286
  // nudge if the previous assistant span used state-changing tools
293
287
  // without calling borg_log. Domain-agnostic — knows nothing about git
@@ -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)}`;
@@ -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),