@jmylchreest/aide-plugin 0.0.52 → 0.0.53

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 (2) hide show
  1. package/package.json +1 -1
  2. package/src/core/mcp-sync.ts +277 -143
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jmylchreest/aide-plugin",
3
- "version": "0.0.52",
3
+ "version": "0.0.53",
4
4
  "description": "aide plugin for OpenCode — multi-agent orchestration, memory, skills, and persistence",
5
5
  "type": "module",
6
6
  "main": "./src/opencode/index.ts",
@@ -64,15 +64,29 @@ export interface AideMcpConfig {
64
64
  mcpServers: Record<string, Omit<CanonicalMcpServer, "name">>;
65
65
  }
66
66
 
67
- /** Journal tracking server presence across sync runs. */
67
+ /** Per-server, per-source state entry in the journal. */
68
+ export interface ServerSourceState {
69
+ /** Whether the server was present in this source at last check */
70
+ present: boolean;
71
+ /** Mtime of the source file when this state transition was detected */
72
+ mtime: number;
73
+ /** ISO timestamp for human readability */
74
+ ts: string;
75
+ /** Hash of the server config (to detect changes while present) */
76
+ configHash?: string;
77
+ }
78
+
79
+ /**
80
+ * Journal v2: per-server, per-source state tracking.
81
+ *
82
+ * Tracks the last state transition (added/removed/changed) for each
83
+ * MCP server in each config source file, with the file's mtime at the
84
+ * time of that transition. Resolution uses per-server latest-mtime-wins.
85
+ */
68
86
  export interface McpSyncJournal {
69
- /** Chronological entries of known server sets */
70
- entries: Array<{
71
- ts: string;
72
- servers: string[];
73
- }>;
74
- /** Server names intentionally removed from aide canonical config */
75
- removed: string[];
87
+ version: 2;
88
+ /** server name → source file path → state */
89
+ servers: Record<string, Record<string, ServerSourceState>>;
76
90
  }
77
91
 
78
92
  /** Result of a sync operation. */
@@ -445,24 +459,48 @@ function writeAssistantConfig(
445
459
  }
446
460
 
447
461
  // =============================================================================
448
- // Journal
462
+ // Journal (v2: per-server, per-source state tracking)
449
463
  // =============================================================================
450
464
 
465
+ /** Legacy journal format (v1) — only used for migration. */
466
+ interface McpSyncJournalV1 {
467
+ entries?: Array<{ ts: string; servers: string[] }>;
468
+ removed?: string[];
469
+ }
470
+
451
471
  function readJournal(path: string): McpSyncJournal {
452
- if (!existsSync(path)) {
453
- return { entries: [], removed: [] };
454
- }
472
+ const empty: McpSyncJournal = { version: 2, servers: {} };
473
+ if (!existsSync(path)) return empty;
455
474
 
456
475
  try {
457
476
  const parsed: unknown = JSON.parse(readFileSync(path, "utf-8"));
458
477
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
459
- return { entries: [], removed: [] };
460
- const journal = parsed as McpSyncJournal;
461
- if (!Array.isArray(journal.entries)) return { entries: [], removed: [] };
462
- if (!Array.isArray(journal.removed)) journal.removed = [];
463
- return journal;
478
+ return empty;
479
+
480
+ const obj = parsed as Record<string, unknown>;
481
+
482
+ // v2 journal
483
+ if (obj.version === 2) return parsed as McpSyncJournal;
484
+
485
+ // v1 → v2 migration: preserve the removed list as synthetic removal entries.
486
+ // Use mtime=0 so any real source file with the server will override the
487
+ // migration entry. If no source has the server, absence resolves it as removed.
488
+ const v1 = parsed as McpSyncJournalV1;
489
+ const migrated: McpSyncJournal = { version: 2, servers: {} };
490
+ if (Array.isArray(v1.removed)) {
491
+ for (const name of v1.removed) {
492
+ migrated.servers[name] = {
493
+ "migrated-v1": {
494
+ present: false,
495
+ mtime: 0,
496
+ ts: new Date().toISOString(),
497
+ },
498
+ };
499
+ }
500
+ }
501
+ return migrated;
464
502
  } catch {
465
- return { entries: [], removed: [] };
503
+ return empty;
466
504
  }
467
505
  }
468
506
 
@@ -472,66 +510,51 @@ function writeJournal(path: string, journal: McpSyncJournal): void {
472
510
  mkdirSync(dir, { recursive: true });
473
511
  }
474
512
 
475
- // Keep only last 20 entries to avoid unbounded growth
476
- if (journal.entries.length > 20) {
477
- journal.entries = journal.entries.slice(-20);
513
+ // Prune server entries with no remaining sources
514
+ for (const [serverName, sourceMap] of Object.entries(journal.servers)) {
515
+ if (Object.keys(sourceMap).length === 0) {
516
+ delete journal.servers[serverName];
517
+ }
478
518
  }
479
519
 
480
520
  writeFileSync(path, JSON.stringify(journal, null, 2) + "\n");
481
521
  }
482
522
 
483
- /**
484
- * Update the journal with the current set of aide canonical servers.
485
- * Detects deletions by comparing against the previous entry.
486
- *
487
- * Returns the set of server names that should be considered removed.
488
- */
489
- function updateJournal(
490
- jrnlPath: string,
491
- currentAideServers: string[],
492
- ): string[] {
493
- const journal = readJournal(jrnlPath);
494
- const sorted = [...currentAideServers].sort();
495
-
496
- // Get previous entry's server list
497
- const prevEntry =
498
- journal.entries.length > 0
499
- ? journal.entries[journal.entries.length - 1]
500
- : null;
501
- const prevServers = prevEntry
502
- ? new Set(prevEntry.servers)
503
- : new Set<string>();
504
-
505
- // Detect deletions: servers in previous entry but not in current aide config
506
- const currentSet = new Set(sorted);
507
- for (const prev of prevServers) {
508
- if (!currentSet.has(prev)) {
509
- // Server was removed from aide config since last run
510
- if (!journal.removed.includes(prev)) {
511
- journal.removed.push(prev);
512
- }
513
- }
523
+ /** Recursively sort object keys for deterministic serialization. */
524
+ function sortDeep(v: unknown): unknown {
525
+ if (Array.isArray(v)) return v.map(sortDeep);
526
+ if (v && typeof v === "object") {
527
+ return Object.fromEntries(
528
+ Object.keys(v as object)
529
+ .sort()
530
+ .map((k) => [k, sortDeep((v as Record<string, unknown>)[k])]),
531
+ );
514
532
  }
533
+ return v;
534
+ }
515
535
 
516
- // If a server is now in the aide config AND in the removed list, un-remove it
517
- journal.removed = journal.removed.filter((r) => !currentSet.has(r));
518
-
519
- // Only write a new entry if the server list changed
520
- const prevSorted = prevEntry ? [...prevEntry.servers].sort() : [];
521
- const changed =
522
- sorted.length !== prevSorted.length ||
523
- sorted.some((s, i) => s !== prevSorted[i]);
524
-
525
- if (changed || !prevEntry) {
526
- journal.entries.push({
527
- ts: new Date().toISOString(),
528
- servers: sorted,
529
- });
536
+ /**
537
+ * Deterministic hash of a server config for change detection.
538
+ * Normalizes away trivial differences (empty env, undefined fields)
539
+ * and sorts all keys recursively for stable ordering.
540
+ */
541
+ function serverConfigHash(server: CanonicalMcpServer): string {
542
+ const { name: _, ...rest } = server;
543
+ const normalized: Record<string, unknown> = {};
544
+ const keys = Object.keys(rest).sort();
545
+ for (const k of keys) {
546
+ const v = (rest as Record<string, unknown>)[k];
547
+ if (v === undefined) continue;
548
+ if (
549
+ typeof v === "object" &&
550
+ v !== null &&
551
+ !Array.isArray(v) &&
552
+ Object.keys(v).length === 0
553
+ )
554
+ continue;
555
+ normalized[k] = v;
530
556
  }
531
-
532
- writeJournal(jrnlPath, journal);
533
-
534
- return journal.removed;
557
+ return JSON.stringify(sortDeep(normalized));
535
558
  }
536
559
 
537
560
  // =============================================================================
@@ -546,77 +569,173 @@ function getFileMtime(path: string): number {
546
569
  }
547
570
  }
548
571
 
549
- // =============================================================================
550
- // Core sync logic
551
- // =============================================================================
572
+ interface McpSource {
573
+ path: string;
574
+ servers: Record<string, CanonicalMcpServer>;
575
+ mtime: number;
576
+ }
552
577
 
553
578
  /**
554
- * Collect all MCP servers from all sources for a given scope.
555
- *
556
- * Priority order (last-modified wins for conflicts):
557
- * 1. Aide canonical config (always included as base)
558
- * 2. Each assistant's config (sorted by file mtime, oldest first)
579
+ * Gather all MCP config source files for a given scope.
559
580
  */
560
- function collectServers(
561
- scope: McpScope,
562
- cwd: string,
563
- ): Record<string, CanonicalMcpServer> {
581
+ function gatherSources(scope: McpScope, cwd: string): McpSource[] {
564
582
  const platforms: McpPlatform[] = ["claude-code", "opencode"];
583
+ const sources: McpSource[] = [];
565
584
 
566
- // Collect all sources with their modification times
567
- const sources: Array<{
568
- label: string;
569
- servers: Record<string, CanonicalMcpServer>;
570
- mtime: number;
571
- }> = [];
572
-
573
- // Aide canonical config (always lowest priority as base)
574
585
  const aidePath =
575
586
  scope === "user" ? aideUserMcpPath() : aideProjectMcpPath(cwd);
576
587
  sources.push({
577
- label: `aide:${scope}`,
588
+ path: aidePath,
578
589
  servers: readAideConfig(aidePath),
579
590
  mtime: getFileMtime(aidePath),
580
591
  });
581
592
 
582
- // Assistant configs
583
593
  for (const platform of platforms) {
584
594
  const paths = getAssistantReadPaths(platform, scope, cwd);
585
595
  for (const p of paths) {
586
- const servers = readAssistantConfig(platform, p);
587
- if (Object.keys(servers).length > 0) {
588
- sources.push({
589
- label: `${platform}:${scope}:${p}`,
590
- servers,
591
- mtime: getFileMtime(p),
592
- });
593
- }
596
+ sources.push({
597
+ path: p,
598
+ servers: readAssistantConfig(platform, p),
599
+ mtime: getFileMtime(p),
600
+ });
594
601
  }
595
602
  }
596
603
 
597
- // Sort by mtime (oldest first, so newest overwrites)
598
- sources.sort((a, b) => a.mtime - b.mtime);
604
+ return sources;
605
+ }
599
606
 
600
- // Merge: later entries override earlier ones for same server name
601
- const merged: Record<string, CanonicalMcpServer> = {};
607
+ /**
608
+ * Update the journal with per-server state transitions and resolve the
609
+ * final server set using per-server latest-mtime-wins semantics.
610
+ *
611
+ * For each server in each source file, we track the last state transition
612
+ * (present→removed or absent→present or config changed). The mtime stored
613
+ * is the source file's mtime at the time of that transition. To resolve,
614
+ * for each server we pick the entry with the highest mtime: if present the
615
+ * server is included, if removed it is excluded.
616
+ */
617
+ function updateJournalAndResolve(
618
+ jrnlPath: string,
619
+ sources: McpSource[],
620
+ ): { resolved: Record<string, CanonicalMcpServer>; journal: McpSyncJournal } {
621
+ const journal = readJournal(jrnlPath);
622
+ const now = new Date().toISOString();
623
+ const activePaths = new Set(sources.map((s) => s.path));
624
+
625
+ // Detect state transitions per server per source
602
626
  for (const source of sources) {
627
+ const currentNames = new Set(Object.keys(source.servers));
628
+
629
+ // Servers currently present in this source
603
630
  for (const [name, server] of Object.entries(source.servers)) {
604
- merged[name] = server;
631
+ if (!journal.servers[name]) journal.servers[name] = {};
632
+ const existing = journal.servers[name][source.path];
633
+ const hash = serverConfigHash(server);
634
+
635
+ if (!existing || !existing.present || existing.configHash !== hash) {
636
+ // State transition: newly added, re-added, or config changed
637
+ journal.servers[name][source.path] = {
638
+ present: true,
639
+ mtime: source.mtime,
640
+ ts: now,
641
+ configHash: hash,
642
+ };
643
+ }
644
+ // If already present with same config: no transition, keep existing mtime
645
+ }
646
+
647
+ // Servers previously tracked in this source but now gone
648
+ for (const [serverName, sourceMap] of Object.entries(journal.servers)) {
649
+ const entry = sourceMap[source.path];
650
+ if (entry?.present && !currentNames.has(serverName)) {
651
+ sourceMap[source.path] = {
652
+ present: false,
653
+ mtime: source.mtime,
654
+ ts: now,
655
+ };
656
+ }
605
657
  }
606
658
  }
607
659
 
608
- return merged;
660
+ // Bootstrap: for servers found in some sources but absent from others,
661
+ // record "not present" entries for the missing sources. Without this,
662
+ // a server deleted from one file before the v2 journal existed would
663
+ // have no removal event to counterbalance presence in other files.
664
+ const allServerNames = new Set(Object.keys(journal.servers));
665
+ for (const serverName of allServerNames) {
666
+ const sourceMap = journal.servers[serverName];
667
+ for (const source of sources) {
668
+ if (!sourceMap[source.path]) {
669
+ // This source has never been tracked for this server.
670
+ // If the file exists (mtime > 0), record its absence.
671
+ if (source.mtime > 0) {
672
+ sourceMap[source.path] = {
673
+ present: false,
674
+ mtime: source.mtime,
675
+ ts: now,
676
+ };
677
+ }
678
+ }
679
+ }
680
+ }
681
+
682
+ // Clean up journal entries for source files that no longer exist
683
+ for (const sourceMap of Object.values(journal.servers)) {
684
+ for (const sourcePath of Object.keys(sourceMap)) {
685
+ if (sourcePath !== "migrated-v1" && !activePaths.has(sourcePath)) {
686
+ delete sourceMap[sourcePath];
687
+ }
688
+ }
689
+ }
690
+
691
+ // Resolve: per server, latest mtime wins
692
+ const resolved: Record<string, CanonicalMcpServer> = {};
693
+
694
+ const allNames = new Set<string>();
695
+ for (const source of sources) {
696
+ for (const name of Object.keys(source.servers)) allNames.add(name);
697
+ }
698
+ for (const name of Object.keys(journal.servers)) allNames.add(name);
699
+
700
+ for (const serverName of allNames) {
701
+ const sourceMap = journal.servers[serverName];
702
+ if (!sourceMap) continue;
703
+
704
+ let bestMtime = -1;
705
+ let bestPresent = false;
706
+ let bestPath = "";
707
+
708
+ for (const [sourcePath, state] of Object.entries(sourceMap)) {
709
+ if (
710
+ state.mtime > bestMtime ||
711
+ (state.mtime === bestMtime && state.present && !bestPresent)
712
+ ) {
713
+ bestMtime = state.mtime;
714
+ bestPresent = state.present;
715
+ bestPath = sourcePath;
716
+ }
717
+ }
718
+
719
+ if (bestPresent) {
720
+ // Prefer the winning source; fall back to any source that has the server
721
+ const source = sources.find((s) => s.path === bestPath);
722
+ const server =
723
+ source?.servers[serverName] ??
724
+ sources.find((s) => s.servers[serverName])?.servers[serverName];
725
+ if (server) resolved[serverName] = server;
726
+ }
727
+ }
728
+
729
+ writeJournal(jrnlPath, journal);
730
+ return { resolved, journal };
609
731
  }
610
732
 
611
733
  /**
612
734
  * Run MCP sync for a specific scope level.
613
735
  *
614
- * 1. Reads the aide canonical config
615
- * 2. Updates the journal to detect deletions
616
- * 3. Collects servers from all sources
617
- * 4. Filters out removed servers
618
- * 5. Writes merged result to the aide canonical config
619
- * 6. Writes the result (in assistant-native format) to the current assistant's config
736
+ * Gathers servers from all sources, updates the per-server journal to
737
+ * track state transitions, resolves using latest-mtime-wins per server,
738
+ * and writes the result to the aide canonical config and current assistant.
620
739
  */
621
740
  function syncScope(
622
741
  platform: McpPlatform,
@@ -631,56 +750,55 @@ function syncScope(
631
750
  serverNames: [],
632
751
  };
633
752
 
634
- // Step 1: Read current aide canonical config
635
753
  const aidePath =
636
754
  scope === "user" ? aideUserMcpPath() : aideProjectMcpPath(cwd);
637
755
  const aideServers = readAideConfig(aidePath);
638
- const aideServerNames = Object.keys(aideServers);
639
756
 
640
- // Step 2: Update journal and get removed list
641
757
  const jrnlPath = scope === "user" ? userJournalPath() : journalPath(cwd);
642
- const removed = updateJournal(jrnlPath, aideServerNames);
643
- const removedSet = new Set(removed);
644
-
645
- // Step 3: Collect all servers from all sources
646
- const allServers = collectServers(scope, cwd);
647
-
648
- // Step 4: Filter out removed servers
649
- const finalServers: Record<string, CanonicalMcpServer> = {};
650
- for (const [name, server] of Object.entries(allServers)) {
651
- if (removedSet.has(name)) {
652
- result.skipped++;
653
- continue;
654
- }
655
- finalServers[name] = server;
656
- }
758
+ const sources = gatherSources(scope, cwd);
759
+ const { resolved: finalServers, journal } = updateJournalAndResolve(
760
+ jrnlPath,
761
+ sources,
762
+ );
657
763
 
658
- // Step 5: Count imports (servers not previously in aide config)
764
+ // Count imports (servers not previously in aide config)
659
765
  for (const name of Object.keys(finalServers)) {
660
766
  if (!aideServers[name]) {
661
767
  result.imported++;
662
768
  }
663
769
  }
664
770
 
665
- // Step 6: Write to aide canonical config (if changed)
771
+ // Count skipped (servers whose latest journal state is a removal)
772
+ for (const [serverName, sourceMap] of Object.entries(journal.servers)) {
773
+ if (finalServers[serverName]) continue;
774
+ let bestMtime = -1;
775
+ let bestPresent = false;
776
+ for (const state of Object.values(sourceMap)) {
777
+ if (state.mtime > bestMtime) {
778
+ bestMtime = state.mtime;
779
+ bestPresent = state.present;
780
+ }
781
+ }
782
+ if (!bestPresent) result.skipped++;
783
+ }
784
+
785
+ // Write to aide canonical config (if changed)
786
+ const sortedStringify = (obj: object) =>
787
+ JSON.stringify(obj, Object.keys(obj).sort());
666
788
  const aideChanged =
667
- JSON.stringify(aideServers) !==
668
- JSON.stringify(
669
- Object.fromEntries(Object.entries(finalServers).map(([k, v]) => [k, v])),
670
- );
789
+ sortedStringify(aideServers) !== sortedStringify(finalServers);
671
790
 
672
791
  if (aideChanged) {
673
792
  writeAideConfig(aidePath, finalServers);
674
793
  result.modified = true;
675
794
  }
676
795
 
677
- // Step 7: Write to current assistant's config
796
+ // Write to current assistant's config
678
797
  const assistantPaths = getAssistantWritePaths(platform, scope, cwd);
679
798
  for (const p of assistantPaths) {
680
- // Read existing assistant config to check if it needs updating
681
799
  const existingAssistant = readAssistantConfig(platform, p);
682
800
  const assistantChanged =
683
- JSON.stringify(existingAssistant) !== JSON.stringify(finalServers);
801
+ sortedStringify(existingAssistant) !== sortedStringify(finalServers);
684
802
 
685
803
  if (assistantChanged) {
686
804
  writeAssistantConfig(platform, p, finalServers);
@@ -738,16 +856,32 @@ export function listSyncedServers(cwd: string): {
738
856
 
739
857
  /**
740
858
  * Get the current removed (blocked) server names.
859
+ * Derived from the v2 journal: a server is "removed" if its latest
860
+ * mtime across all sources corresponds to a removal event.
741
861
  */
742
862
  export function getRemovedServers(cwd: string): {
743
863
  user: string[];
744
864
  project: string[];
745
865
  } {
746
- const userJournal = readJournal(userJournalPath());
747
- const projectJournal = readJournal(journalPath(cwd));
866
+ function deriveRemoved(jrnlPath: string): string[] {
867
+ const journal = readJournal(jrnlPath);
868
+ const removed: string[] = [];
869
+ for (const [serverName, sourceMap] of Object.entries(journal.servers)) {
870
+ let bestMtime = -1;
871
+ let bestPresent = false;
872
+ for (const state of Object.values(sourceMap)) {
873
+ if (state.mtime > bestMtime) {
874
+ bestMtime = state.mtime;
875
+ bestPresent = state.present;
876
+ }
877
+ }
878
+ if (!bestPresent) removed.push(serverName);
879
+ }
880
+ return removed;
881
+ }
748
882
 
749
883
  return {
750
- user: userJournal.removed,
751
- project: projectJournal.removed,
884
+ user: deriveRemoved(userJournalPath()),
885
+ project: deriveRemoved(journalPath(cwd)),
752
886
  };
753
887
  }