borgmcp 3.1.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.
- package/README.md +13 -1
- package/dist/agent-integration-health.d.ts +40 -0
- package/dist/agent-integration-health.d.ts.map +1 -0
- package/dist/agent-integration-health.js +185 -0
- package/dist/agent-integration-health.js.map +1 -0
- 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 +1 -0
- package/dist/cli-help.d.ts.map +1 -1
- package/dist/cli-help.js +14 -2
- package/dist/cli-help.js.map +1 -1
- package/dist/config-utils.d.ts +34 -0
- package/dist/config-utils.d.ts.map +1 -1
- package/dist/config-utils.js +316 -28
- package/dist/config-utils.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -6
- package/dist/index.js.map +1 -1
- package/dist/regen-format.d.ts.map +1 -1
- package/dist/regen-format.js +4 -5
- package/dist/regen-format.js.map +1 -1
- package/dist/startup-services.d.ts +0 -1
- package/dist/startup-services.d.ts.map +1 -1
- package/dist/startup-services.js +0 -2
- package/dist/startup-services.js.map +1 -1
- package/dist/unknown-subcommand.d.ts +1 -1
- package/dist/unknown-subcommand.d.ts.map +1 -1
- package/dist/unknown-subcommand.js +1 -0
- package/dist/unknown-subcommand.js.map +1 -1
- package/dist/update-cmd.d.ts +1 -0
- package/dist/update-cmd.d.ts.map +1 -1
- package/dist/update-cmd.js +20 -0
- package/dist/update-cmd.js.map +1 -1
- package/docs/LOCAL_SERVER.md +16 -4
- package/docs/RELEASING.md +3 -3
- package/package.json +2 -2
- package/src/agent-integration-health.ts +232 -0
- package/src/claude.ts +13 -0
- package/src/cli-help.ts +17 -2
- package/src/config-utils.ts +332 -28
- package/src/index.ts +1 -7
- package/src/regen-format.ts +4 -5
- package/src/startup-services.ts +0 -3
- package/src/unknown-subcommand.ts +1 -0
- package/src/update-cmd.ts +23 -0
package/src/config-utils.ts
CHANGED
|
@@ -15,7 +15,6 @@ import {
|
|
|
15
15
|
withAgentRuntimeEnv,
|
|
16
16
|
} from './agent-runtime.js';
|
|
17
17
|
import {
|
|
18
|
-
resolveMcpBinaryPath,
|
|
19
18
|
resolveRegenPath,
|
|
20
19
|
resolveClearRewakePath,
|
|
21
20
|
resolveLogAuditPath,
|
|
@@ -29,14 +28,14 @@ import type { LaunchAccessPaths } from './launch-access.js';
|
|
|
29
28
|
const __filename = fileURLToPath(import.meta.url);
|
|
30
29
|
const __dirname = dirname(__filename);
|
|
31
30
|
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
const HOOK_COMMAND =
|
|
36
|
-
const CLEAR_REWAKE_HOOK_COMMAND =
|
|
37
|
-
const AUDIT_HOOK_COMMAND =
|
|
38
|
-
const FOREIGN_PATH_REMINDER_HOOK_COMMAND =
|
|
39
|
-
const
|
|
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
|
+
const MCP_COMMAND = 'borg-mcp';
|
|
40
39
|
|
|
41
40
|
/**
|
|
42
41
|
* Claude Code CLI config path. The CLI reads `mcpServers.<name>` from
|
|
@@ -235,8 +234,7 @@ function addSessionStartHookAt(settingsFile: string, includeClearRewake = false)
|
|
|
235
234
|
return true;
|
|
236
235
|
}
|
|
237
236
|
|
|
238
|
-
//
|
|
239
|
-
// installations), shell-escaped canonical paths, and unescaped canonical paths.
|
|
237
|
+
// Match stable bare names plus legacy install-specific absolute forms.
|
|
240
238
|
function commandMatches(entryCommand: string, bareName: string, absolutePath: string): boolean {
|
|
241
239
|
const escaped = shellEscape(absolutePath);
|
|
242
240
|
if (entryCommand === escaped || entryCommand === absolutePath || entryCommand === bareName) return true;
|
|
@@ -255,7 +253,7 @@ function commandMatches(entryCommand: string, bareName: string, absolutePath: st
|
|
|
255
253
|
* absolute command — always owned, no marker required (fixes neutral-path
|
|
256
254
|
* false negatives that broke idempotency)
|
|
257
255
|
* (c) Foreign-install heuristic: absolute path ending in an owned basename
|
|
258
|
-
*
|
|
256
|
+
* immediately below an exact borgmcp|borg-mcp/dist package path
|
|
259
257
|
* This prevents false-positive ownership of unrelated scripts that happen
|
|
260
258
|
* to share a basename (e.g. /opt/custom-tool/regen.js). */
|
|
261
259
|
function ownedCanonical(command: string): string | null {
|
|
@@ -274,9 +272,16 @@ function ownedCanonical(command: string): string | null {
|
|
|
274
272
|
if (command === AUDIT_HOOK_COMMAND || stripped === resolveLogAuditPath()) return AUDIT_HOOK_COMMAND;
|
|
275
273
|
if (command === FOREIGN_PATH_REMINDER_HOOK_COMMAND || stripped === resolveForeignPathReminderPath()) return FOREIGN_PATH_REMINDER_HOOK_COMMAND;
|
|
276
274
|
|
|
277
|
-
// (c) Foreign-install heuristic:
|
|
278
|
-
|
|
279
|
-
|
|
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) ?? '';
|
|
280
285
|
if (name === 'regen.js') return HOOK_COMMAND;
|
|
281
286
|
if (name === 'clear-rewake.js') return CLEAR_REWAKE_HOOK_COMMAND;
|
|
282
287
|
if (name === 'log-audit.js') return AUDIT_HOOK_COMMAND;
|
|
@@ -312,6 +317,7 @@ function migrateAndDedupOwnedHooks(entries: any[]): boolean {
|
|
|
312
317
|
// object across all entries. Remove only the duplicate hook objects, not
|
|
313
318
|
// entire entries (preserving unrelated siblings and entry metadata).
|
|
314
319
|
const seenCanonicals = new Set<string>();
|
|
320
|
+
const emptiedByOwnedDedup = new Set<any>();
|
|
315
321
|
for (const entry of entries) {
|
|
316
322
|
if (!Array.isArray(entry?.hooks)) continue;
|
|
317
323
|
const before = entry.hooks.length;
|
|
@@ -327,11 +333,13 @@ function migrateAndDedupOwnedHooks(entries: any[]): boolean {
|
|
|
327
333
|
return true;
|
|
328
334
|
});
|
|
329
335
|
if (entry.hooks.length !== before) changed = true;
|
|
336
|
+
if (before > 0 && entry.hooks.length === 0) emptiedByOwnedDedup.add(entry);
|
|
330
337
|
}
|
|
331
338
|
|
|
332
|
-
// Phase 3:
|
|
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.
|
|
333
341
|
for (let i = entries.length - 1; i >= 0; i--) {
|
|
334
|
-
if (
|
|
342
|
+
if (emptiedByOwnedDedup.has(entries[i])) {
|
|
335
343
|
entries.splice(i, 1);
|
|
336
344
|
changed = true;
|
|
337
345
|
}
|
|
@@ -340,9 +348,165 @@ function migrateAndDedupOwnedHooks(entries: any[]): boolean {
|
|
|
340
348
|
return changed;
|
|
341
349
|
}
|
|
342
350
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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. */
|
|
346
510
|
function isCanonicalCommand(entryCommand: string, canonical: string): boolean {
|
|
347
511
|
return entryCommand === canonical;
|
|
348
512
|
}
|
|
@@ -369,7 +533,7 @@ function hasCommandHook(entries: any[], command: string): boolean {
|
|
|
369
533
|
);
|
|
370
534
|
}
|
|
371
535
|
|
|
372
|
-
/** Strict: only the
|
|
536
|
+
/** Strict: only the current unquoted bare canonical form. */
|
|
373
537
|
function hasCanonicalCommandHook(entries: any[], command: string): boolean {
|
|
374
538
|
return entries.some((entry: any) =>
|
|
375
539
|
Array.isArray(entry?.hooks) &&
|
|
@@ -678,6 +842,150 @@ export function isCodexMcpServerConfigured(
|
|
|
678
842
|
}
|
|
679
843
|
}
|
|
680
844
|
|
|
845
|
+
function configuredMcpCommand(value: unknown): string | null {
|
|
846
|
+
if (typeof value === 'string') return value;
|
|
847
|
+
if (Array.isArray(value) && value.length === 1 && typeof value[0] === 'string') {
|
|
848
|
+
return value[0];
|
|
849
|
+
}
|
|
850
|
+
return null;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
function isStaleBorgMcpCommand(value: unknown): boolean {
|
|
854
|
+
const command = configuredMcpCommand(value);
|
|
855
|
+
if (!command || command === MCP_COMMAND || !path.isAbsolute(command)) return false;
|
|
856
|
+
const normalized = command.replace(/\\/g, '/');
|
|
857
|
+
return /\/(?:node_modules\/borgmcp|borg-mcp-client)\/dist\/index\.js$/.test(normalized);
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
function readClaudeMcpCommand(configPath: string): unknown {
|
|
861
|
+
try {
|
|
862
|
+
return readJsonFile(configPath)?.mcpServers?.[MCP_SERVER_NAME]?.command;
|
|
863
|
+
} catch {
|
|
864
|
+
return undefined;
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
function readCodexMcpCommand(configPath: string): unknown {
|
|
869
|
+
try {
|
|
870
|
+
const text = fs.readFileSync(configPath, 'utf-8');
|
|
871
|
+
if (!isCodexMcpServerConfigured(configPath)) return undefined;
|
|
872
|
+
const header = /^\s*\[mcp_servers\.borg\]\s*$/m.exec(text);
|
|
873
|
+
if (!header) return undefined;
|
|
874
|
+
const tail = text.slice(header.index + header[0].length);
|
|
875
|
+
const nextHeader = /^\s*\[/m.exec(tail);
|
|
876
|
+
const section = nextHeader ? tail.slice(0, nextHeader.index) : tail;
|
|
877
|
+
return section?.match(/^\s*command\s*=\s*"([^"]+)"\s*$/m)?.[1];
|
|
878
|
+
} catch {
|
|
879
|
+
return undefined;
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
function readOpenCodeMcpCommand(configPath: string): unknown {
|
|
884
|
+
try {
|
|
885
|
+
const borgServer = readJsonFile(configPath)?.mcp?.[MCP_SERVER_NAME];
|
|
886
|
+
if (!borgServer || borgServer.type !== 'local') return undefined;
|
|
887
|
+
const environment = borgServer.environment ?? borgServer.env;
|
|
888
|
+
if (environment?.BORG_AGENT_KIND !== 'opencode' && environment?.BORG_OPENCODE !== '1') {
|
|
889
|
+
return undefined;
|
|
890
|
+
}
|
|
891
|
+
return borgServer.command;
|
|
892
|
+
} catch {
|
|
893
|
+
return undefined;
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
export interface RefreshManagedAgentMcpConfigOptions {
|
|
898
|
+
claudeConfigPath?: string;
|
|
899
|
+
codexConfigPath?: string;
|
|
900
|
+
openCodeConfigPath?: string;
|
|
901
|
+
refreshClaude?: () => void;
|
|
902
|
+
refreshCodex?: () => void;
|
|
903
|
+
refreshOpenCode?: () => void;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
function refreshClaudeMcpCommand(configPath: string): void {
|
|
907
|
+
const config = readJsonFile(configPath);
|
|
908
|
+
config.mcpServers[MCP_SERVER_NAME].command = MCP_COMMAND;
|
|
909
|
+
writeJsonFile(configPath, config);
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
function refreshCodexMcpCommand(configPath: string): void {
|
|
913
|
+
const text = fs.readFileSync(configPath, 'utf-8');
|
|
914
|
+
const header = /^\s*\[mcp_servers\.borg\]\s*$/m.exec(text);
|
|
915
|
+
if (!header) return;
|
|
916
|
+
const sectionStart = header.index + header[0].length;
|
|
917
|
+
const tail = text.slice(sectionStart);
|
|
918
|
+
const nextHeader = /^\s*\[/m.exec(tail);
|
|
919
|
+
const sectionEnd = nextHeader ? sectionStart + nextHeader.index : text.length;
|
|
920
|
+
const section = text.slice(sectionStart, sectionEnd);
|
|
921
|
+
const updatedSection = section.replace(
|
|
922
|
+
/^(\s*command\s*=\s*)"[^"]+"(\s*)$/m,
|
|
923
|
+
`$1"${MCP_COMMAND}"$2`,
|
|
924
|
+
);
|
|
925
|
+
fs.writeFileSync(
|
|
926
|
+
configPath,
|
|
927
|
+
text.slice(0, sectionStart) + updatedSection + text.slice(sectionEnd),
|
|
928
|
+
'utf-8',
|
|
929
|
+
);
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
function refreshOpenCodeMcpCommand(configPath: string): void {
|
|
933
|
+
const config = readJsonFile(configPath);
|
|
934
|
+
const borgServer = config.mcp[MCP_SERVER_NAME];
|
|
935
|
+
borgServer.command = Array.isArray(borgServer.command) ? [MCP_COMMAND] : MCP_COMMAND;
|
|
936
|
+
writeJsonFile(configPath, config);
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
/**
|
|
940
|
+
* Replace only the command in stale Borg registrations. Other entry fields are
|
|
941
|
+
* preserved, and a `borg` entry that points at another command is not changed.
|
|
942
|
+
*/
|
|
943
|
+
export function refreshManagedAgentMcpConfigs(
|
|
944
|
+
options: RefreshManagedAgentMcpConfigOptions = {},
|
|
945
|
+
): Array<'claude' | 'codex' | 'opencode'> {
|
|
946
|
+
const refreshed: Array<'claude' | 'codex' | 'opencode'> = [];
|
|
947
|
+
const failures: string[] = [];
|
|
948
|
+
const claudeConfigPath = options.claudeConfigPath ?? CLAUDE_CONFIG_PATH;
|
|
949
|
+
const codexConfigPath = options.codexConfigPath ?? CODEX_CONFIG_PATH;
|
|
950
|
+
const openCodeConfigPath = options.openCodeConfigPath ?? OPENCODE_CONFIG_PATH;
|
|
951
|
+
const agents = [
|
|
952
|
+
{
|
|
953
|
+
kind: 'claude' as const,
|
|
954
|
+
label: 'Claude Code',
|
|
955
|
+
command: readClaudeMcpCommand(claudeConfigPath),
|
|
956
|
+
refresh: options.refreshClaude ?? (() => refreshClaudeMcpCommand(claudeConfigPath)),
|
|
957
|
+
},
|
|
958
|
+
{
|
|
959
|
+
kind: 'codex' as const,
|
|
960
|
+
label: 'Codex',
|
|
961
|
+
command: readCodexMcpCommand(codexConfigPath),
|
|
962
|
+
refresh: options.refreshCodex ?? (() => refreshCodexMcpCommand(codexConfigPath)),
|
|
963
|
+
},
|
|
964
|
+
{
|
|
965
|
+
kind: 'opencode' as const,
|
|
966
|
+
label: 'OpenCode',
|
|
967
|
+
command: readOpenCodeMcpCommand(openCodeConfigPath),
|
|
968
|
+
refresh: options.refreshOpenCode ?? (() => refreshOpenCodeMcpCommand(openCodeConfigPath)),
|
|
969
|
+
},
|
|
970
|
+
];
|
|
971
|
+
|
|
972
|
+
for (const agent of agents) {
|
|
973
|
+
if (!isStaleBorgMcpCommand(agent.command)) continue;
|
|
974
|
+
try {
|
|
975
|
+
agent.refresh();
|
|
976
|
+
refreshed.push(agent.kind);
|
|
977
|
+
} catch (error) {
|
|
978
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
979
|
+
failures.push(`${agent.label}: ${message}`);
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
if (failures.length > 0) {
|
|
984
|
+
throw new Error(`Could not refresh agent MCP configs: ${failures.join('; ')}`);
|
|
985
|
+
}
|
|
986
|
+
return refreshed;
|
|
987
|
+
}
|
|
988
|
+
|
|
681
989
|
/**
|
|
682
990
|
* Get absolute path to borg index.js
|
|
683
991
|
* Returns the actual index.js file, not the npm symlink
|
|
@@ -703,9 +1011,7 @@ export function addMcpServer(): void {
|
|
|
703
1011
|
// Ignore - server might not exist yet
|
|
704
1012
|
}
|
|
705
1013
|
|
|
706
|
-
|
|
707
|
-
// registered server always matches the running client version.
|
|
708
|
-
const command = `claude mcp add --scope user borg ${shellQuote(MCP_BINARY)}`;
|
|
1014
|
+
const command = `claude mcp add --scope user borg ${shellQuote(MCP_COMMAND)}`;
|
|
709
1015
|
|
|
710
1016
|
execSync(command, {
|
|
711
1017
|
stdio: 'inherit', // Show output to user
|
|
@@ -736,7 +1042,6 @@ export function addCodexMcpServer(): void {
|
|
|
736
1042
|
// Identity is durable configuration; remote wake is a per-launch
|
|
737
1043
|
// transport capability. Do not persist a transport marker here: a future
|
|
738
1044
|
// Codex child may launch without a live --remote socket.
|
|
739
|
-
// gh#client#18: use absolute path to THIS installation's binary.
|
|
740
1045
|
const apiUrlEnvArg = apiUrl ? ` --env BORG_API_URL=${shellQuote(apiUrl)}` : '';
|
|
741
1046
|
const stateRoot = process.env[BORG_STATE_ROOT_ENV];
|
|
742
1047
|
const stateRootEnvArg = stateRoot
|
|
@@ -746,7 +1051,7 @@ export function addCodexMcpServer(): void {
|
|
|
746
1051
|
apiUrlEnvArg +
|
|
747
1052
|
stateRootEnvArg +
|
|
748
1053
|
` --env ${BORG_AGENT_KIND_ENV}=codex` +
|
|
749
|
-
` -- ${shellQuote(
|
|
1054
|
+
` -- ${shellQuote(MCP_COMMAND)}`, {
|
|
750
1055
|
stdio: 'inherit',
|
|
751
1056
|
env: codexConfigEnv,
|
|
752
1057
|
});
|
|
@@ -965,9 +1270,8 @@ export function addOpenCodeMcpServer(): void {
|
|
|
965
1270
|
const stateRootEnvArg = stateRoot
|
|
966
1271
|
? ` --env ${BORG_STATE_ROOT_ENV}=${shellQuote(stateRoot)}`
|
|
967
1272
|
: '';
|
|
968
|
-
// gh#client#18: use absolute path to THIS installation's binary.
|
|
969
1273
|
execSync(
|
|
970
|
-
`opencode mcp add borg --env BORG_SESSION=1 --env BORG_AGENT_KIND=opencode --env BORG_OPENCODE=1${apiUrlEnvArg}${stateRootEnvArg} -- ${shellQuote(
|
|
1274
|
+
`opencode mcp add borg --env BORG_SESSION=1 --env BORG_AGENT_KIND=opencode --env BORG_OPENCODE=1${apiUrlEnvArg}${stateRootEnvArg} -- ${shellQuote(MCP_COMMAND)}`,
|
|
971
1275
|
{ stdio: 'inherit', env: borgAgentConfigEnv(process.env) }
|
|
972
1276
|
);
|
|
973
1277
|
} catch (error: any) {
|
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 {
|
|
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
|
package/src/regen-format.ts
CHANGED
|
@@ -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
|
-
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
const monitorBin =
|
|
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)}`;
|
package/src/startup-services.ts
CHANGED
|
@@ -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,
|
package/src/update-cmd.ts
CHANGED
|
@@ -8,6 +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 { refreshAndVerifyManagedAgentIntegrations } from './agent-integration-health.js';
|
|
11
12
|
|
|
12
13
|
const CLIENT_PACKAGE = 'borgmcp';
|
|
13
14
|
const SERVER_PACKAGE = 'borgmcp-server';
|
|
@@ -64,6 +65,7 @@ export interface UpdateDeps {
|
|
|
64
65
|
reenter(binPath: string, args: readonly string[]): Promise<number>;
|
|
65
66
|
serverJson(binPath: string, command: 'update' | 'status'): Promise<unknown>;
|
|
66
67
|
verifyRunningProtocol(origin: string): Promise<void>;
|
|
68
|
+
refreshAgentIntegrations(): Promise<void>;
|
|
67
69
|
confirm(message: string): Promise<'yes' | 'no' | 'eof' | 'interrupted'>;
|
|
68
70
|
isTTY(): boolean;
|
|
69
71
|
stdout(text: string): void;
|
|
@@ -695,6 +697,14 @@ export async function runUpdate(options: UpdateOptions, deps: UpdateDeps): Promi
|
|
|
695
697
|
}
|
|
696
698
|
|
|
697
699
|
if (!serverWasPresent) {
|
|
700
|
+
try {
|
|
701
|
+
await deps.refreshAgentIntegrations();
|
|
702
|
+
} catch (error) {
|
|
703
|
+
deps.stderr(
|
|
704
|
+
`Client updated, but agent integration refresh and health check failed: ${errorMessage(error, 'unknown failure')}.\n`,
|
|
705
|
+
);
|
|
706
|
+
return 1;
|
|
707
|
+
}
|
|
698
708
|
deps.stdout(
|
|
699
709
|
`Updated ${CLIENT_PACKAGE}@${pair.client.version}. Local server: skipped (not installed).\n` +
|
|
700
710
|
`Restart active agent sessions to load the updated client.\n`,
|
|
@@ -809,6 +819,18 @@ export async function runUpdate(options: UpdateOptions, deps: UpdateDeps): Promi
|
|
|
809
819
|
retryCommand = 'borg server status';
|
|
810
820
|
await deps.verifyRunningProtocol(status.endpoint!);
|
|
811
821
|
}
|
|
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
|
+
}
|
|
812
834
|
deps.stdout(
|
|
813
835
|
state === 'stopped'
|
|
814
836
|
? (
|
|
@@ -1210,6 +1232,7 @@ export function buildDefaultUpdateDeps(): UpdateDeps {
|
|
|
1210
1232
|
const trust = await loadBorgServerTrust(origin);
|
|
1211
1233
|
await preflightBorgServerTag(origin, trust.fetchImpl);
|
|
1212
1234
|
},
|
|
1235
|
+
refreshAgentIntegrations: async () => refreshAndVerifyManagedAgentIntegrations(),
|
|
1213
1236
|
confirm: defaultConfirm,
|
|
1214
1237
|
isTTY: () => process.stdin.isTTY === true && process.stdout.isTTY === true,
|
|
1215
1238
|
stdout: (text) => process.stdout.write(text),
|