@heretek-ai/epistemic-swarm 0.4.7 → 0.4.8

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 (74) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/bin/cli.js +59 -7
  3. package/config/mcp_launcher.py +75 -48
  4. package/extensions/pi/index.js +25 -5
  5. package/install.sh +5 -5
  6. package/package.json +1 -1
  7. package/plugins/antigravity/plugin.json +1 -1
  8. package/plugins/gemini/gemini-extension.json +1 -1
  9. package/plugins/opencode/index.js +308 -142
  10. package/plugins/opencode/tui.js +7 -9
  11. package/plugins/research-cache/skills/research_cache/hasher.py +27 -9
  12. package/plugins/socratic-grilling/skills/grilling/socratic_tree.py +26 -18
  13. package/runner/__pycache__/__init__.cpython-311.pyc +0 -0
  14. package/runner/__pycache__/auctioneer.cpython-311.pyc +0 -0
  15. package/runner/__pycache__/auditor_engine.cpython-311.pyc +0 -0
  16. package/runner/__pycache__/claim_store.cpython-311.pyc +0 -0
  17. package/runner/__pycache__/claim_witness.cpython-311.pyc +0 -0
  18. package/runner/__pycache__/living_dossiers.cpython-311.pyc +0 -0
  19. package/runner/__pycache__/mcp_protocol.cpython-311.pyc +0 -0
  20. package/runner/__pycache__/mcp_server.cpython-311.pyc +0 -0
  21. package/runner/__pycache__/path_safety.cpython-311.pyc +0 -0
  22. package/runner/__pycache__/pcrb.cpython-311.pyc +0 -0
  23. package/runner/__pycache__/pcrb_verify.cpython-311.pyc +0 -0
  24. package/runner/__pycache__/refinement.cpython-311.pyc +0 -0
  25. package/runner/__pycache__/research_swarm.cpython-311.pyc +0 -0
  26. package/runner/__pycache__/state_machine.cpython-311.pyc +0 -0
  27. package/runner/auditor_engine.py +122 -102
  28. package/runner/claim_store.py +5 -4
  29. package/runner/living_dossiers.py +32 -10
  30. package/runner/mcp_protocol.py +1 -1
  31. package/runner/mcp_server.py +23 -11
  32. package/runner/path_safety.py +62 -0
  33. package/runner/pcrb.py +68 -39
  34. package/runner/pcrb_verify.py +82 -52
  35. package/runner/refinement.py +61 -47
  36. package/runner/research_swarm.py +61 -7
  37. package/runner/state_machine.py +9 -6
  38. package/runner/tests/__pycache__/test_auction_order.cpython-311.pyc +0 -0
  39. package/runner/tests/__pycache__/test_bet1_spike.cpython-311.pyc +0 -0
  40. package/runner/tests/__pycache__/test_claim_store.cpython-311.pyc +0 -0
  41. package/runner/tests/__pycache__/test_claim_witness.cpython-311.pyc +0 -0
  42. package/runner/tests/__pycache__/test_domain_packs.cpython-311.pyc +0 -0
  43. package/runner/tests/__pycache__/test_fleet_seam.cpython-311.pyc +0 -0
  44. package/runner/tests/__pycache__/test_living_dossiers.cpython-311.pyc +0 -0
  45. package/runner/tests/__pycache__/test_mcp_server.cpython-311.pyc +0 -0
  46. package/runner/tests/__pycache__/test_opencode_ux.cpython-311.pyc +0 -0
  47. package/runner/tests/__pycache__/test_pcrb.cpython-311.pyc +0 -0
  48. package/runner/tests/__pycache__/test_refinement.cpython-311.pyc +0 -0
  49. package/runner/tests/__pycache__/test_swarm.cpython-311.pyc +0 -0
  50. package/runner/tests/__pycache__/test_sweep_regressions.cpython-311.pyc +0 -0
  51. package/runner/tests/__pycache__/test_webcache.cpython-311.pyc +0 -0
  52. package/runner/tests/test_mcp_server.py +0 -1
  53. package/runner/tests/test_opencode_ux.py +368 -33
  54. package/runner/tests/test_swarm.py +20 -9
  55. package/runner/tests/test_sweep_regressions.py +373 -0
  56. package/scripts/__pycache__/bet1_advisory_spike.cpython-311.pyc +0 -0
  57. package/scripts/__pycache__/build_adapters.cpython-311.pyc +0 -0
  58. package/scripts/__pycache__/divergence_experiment.cpython-311.pyc +0 -0
  59. package/scripts/auction_experiment.py +38 -30
  60. package/scripts/bet1_advisory_spike.py +41 -25
  61. package/scripts/build_adapters.py +52 -37
  62. package/scripts/divergence_experiment.py +61 -31
  63. package/skills/brainstorming/scripts/brainstorm.py +3 -2
  64. package/skills/epistemic_search/scripts/__pycache__/search.cpython-311.pyc +0 -0
  65. package/skills/epistemic_search/scripts/fetch.py +0 -1
  66. package/skills/epistemic_search/scripts/search.py +202 -73
  67. package/skills/epistemic_search/scripts/webcache.py +1 -1
  68. package/skills/grilling/socratic_tree.py +26 -18
  69. package/skills/research_cache/__pycache__/__init__.cpython-311.pyc +0 -0
  70. package/skills/research_cache/__pycache__/hasher.cpython-311.pyc +0 -0
  71. package/skills/research_cache/hasher.py +27 -9
  72. package/skills/swarm_config/__pycache__/__init__.cpython-311.pyc +0 -0
  73. package/skills/swarm_config/__pycache__/configure.cpython-311.pyc +0 -0
  74. package/skills/swarm_config/configure.py +32 -28
@@ -11,16 +11,35 @@
11
11
  * catalog. Schemas mirror runner/mcp_server.py build_tools() — keep in sync.
12
12
  */
13
13
 
14
- import { spawn } from 'child_process';
15
- import { readFileSync } from 'fs';
16
- import path from 'path';
17
- import { fileURLToPath } from 'url';
14
+ import { spawn } from 'node:child_process';
15
+ import { accessSync, constants, readFileSync, realpathSync, statSync } from 'node:fs';
16
+ import path from 'node:path';
17
+ import { fileURLToPath } from 'node:url';
18
18
 
19
19
  const __filename = fileURLToPath(import.meta.url);
20
20
  const __dirname = path.dirname(__filename);
21
21
  const PKG_ROOT = path.resolve(__dirname, '../..');
22
22
  const MCP_SERVER = path.join(PKG_ROOT, 'runner', 'mcp_server.py');
23
23
 
24
+ function resolveBinary(name) {
25
+ const pathDirs = (process.env.PATH || '').split(path.delimiter);
26
+ for (const dir of pathDirs) {
27
+ if (!dir || dir === '.' || dir.startsWith('./') || dir.startsWith('../')) continue;
28
+ const full = path.join(dir, name);
29
+ try {
30
+ // Match Node's own PATH search: skip non-files and non-executables so a
31
+ // directory or a non-executable named `python3` earlier on PATH cannot
32
+ // shadow the real binary.
33
+ if (!statSync(full).isFile()) continue;
34
+ accessSync(full, constants.X_OK);
35
+ return realpathSync(full);
36
+ } catch {
37
+ /* not present, not a file, or not executable — keep searching */
38
+ }
39
+ }
40
+ return name;
41
+ }
42
+
24
43
  /** Plugin version tracks package.json so it cannot drift across releases. */
25
44
  let PKG_VERSION = '0.0.0';
26
45
  try {
@@ -35,7 +54,7 @@ try {
35
54
  * Async (non-blocking spawn) so long swarms/audits never freeze the host
36
55
  * event loop; cancellation remains the host's prerogative.
37
56
  */
38
- function callMcp(tool, args = {}, cwd) {
57
+ function callMcp(tool, args = {}, cwd = undefined) {
39
58
  return new Promise((resolve) => {
40
59
  let stdout = '';
41
60
  let stderr = '';
@@ -48,7 +67,7 @@ function callMcp(tool, args = {}, cwd) {
48
67
  let child;
49
68
  try {
50
69
  child = spawn(
51
- 'python3',
70
+ resolveBinary('python3'),
52
71
  [MCP_SERVER, 'call', tool, JSON.stringify(args || {})],
53
72
  {
54
73
  cwd: cwd || process.cwd(),
@@ -56,12 +75,12 @@ function callMcp(tool, args = {}, cwd) {
56
75
  }
57
76
  );
58
77
  } catch (err) {
59
- done(String((err && err.message) || err), 'error');
78
+ done(String(err?.message || err), 'error');
60
79
  return;
61
80
  }
62
81
  child.stdout.on('data', (d) => { stdout += String(d); });
63
82
  child.stderr.on('data', (d) => { stderr += String(d); });
64
- child.on('error', (err) => done(String((err && err.message) || err), 'error'));
83
+ child.on('error', (err) => done(String(err?.message || err), 'error'));
65
84
  child.on('close', (code) => done(stdout || stderr, code === 0 ? 'success' : 'error'));
66
85
  });
67
86
  }
@@ -449,16 +468,20 @@ export const OPENCODE_COMMANDS = [
449
468
  ];
450
469
 
451
470
  /**
452
- * Register the command catalog into an OpenCode config object without
453
- * overwriting user-defined commands of the same name. Exported for tests.
471
+ * Command catalog shaped for a declarative opencode config.
472
+ *
473
+ * V2's schema key is `commands` (plural) — `command` is the deprecated V1 key
474
+ * and is ignored by a V2 host. Registration on a live V2 host goes through
475
+ * `command.transform` instead of this; this exists so tests and docs can assert
476
+ * the catalog shape and the correct key.
454
477
  */
455
- export function registerOpenCodeCommands(cfg = {}) {
456
- cfg.command ??= {};
478
+ export function commandCatalog() {
479
+ const out = {};
457
480
  for (const cmd of OPENCODE_COMMANDS) {
458
- if (cfg.command[cmd.name]) continue;
459
- cfg.command[cmd.name] = { description: cmd.description, template: cmd.template };
481
+ if (!cmd?.name) continue;
482
+ out[cmd.name] = { description: cmd.description, template: cmd.template };
460
483
  }
461
- return cfg;
484
+ return out;
462
485
  }
463
486
 
464
487
  /** Tool map (object form) for the server hook; array catalog stays canonical. */
@@ -469,7 +492,7 @@ function buildToolMap() {
469
492
  {
470
493
  ...tool,
471
494
  options: { codemode: false },
472
- execute: async (args = {}, toolContext) =>
495
+ execute: async (args = {}, toolContext = undefined) =>
473
496
  callMcp(tool.name, normalizeArgs(tool.name, args), toolContext?.cwd),
474
497
  },
475
498
  ])
@@ -482,7 +505,7 @@ function buildToolMap() {
482
505
  * Reference pattern: @prevalentware/opencode-goal-plugin subscribes via
483
506
  * `context.event.subscribe({signal})`, detects idle as `session.idle` or an
484
507
  * idle `session.status`, continues via `context.session.prompt`, and preserves
485
- * state across compaction with the `experimental.session.compacting` hook.
508
+ * state across compaction with `context.session.hook("compaction", ...)`.
486
509
  * Every hook here is best-effort and never throws into the host.
487
510
  */
488
511
 
@@ -507,22 +530,31 @@ function readJsonFile(file) {
507
530
  }
508
531
  }
509
532
 
533
+ function parseLedgerEntries(ledger) {
534
+ const stale = [];
535
+ const suspect = [];
536
+ const last = new Map();
537
+ for (const e of ledger) {
538
+ if (typeof e?.claim_id === 'string') last.set(e.claim_id, e.to_status);
539
+ }
540
+ for (const [id, s] of last) {
541
+ if (s === 'STALE') stale.push(id);
542
+ else if (s === 'SUSPECT') suspect.push(id);
543
+ }
544
+ const lastAt = ledger.at(-1)?.at ?? '';
545
+ return { stale, suspect, fingerprint: `${ledger.length}:${lastAt}` };
546
+ }
547
+
510
548
  /** Last-write-wins degraded claims + requeue list. Pure fs reads. */
511
549
  export function readLedgerDegraded(root) {
512
550
  const out = { stale: [], suspect: [], requeued: [], fingerprint: 'empty' };
513
551
  try {
514
552
  const ledger = readJsonFile(path.join(root, '.research', 'ledger', 'claim_status.json'));
515
553
  if (Array.isArray(ledger)) {
516
- const last = new Map();
517
- for (const e of ledger) {
518
- if (e && typeof e.claim_id === 'string') last.set(e.claim_id, e.to_status);
519
- }
520
- for (const [id, s] of last) {
521
- if (s === 'STALE') out.stale.push(id);
522
- else if (s === 'SUSPECT') out.suspect.push(id);
523
- }
524
- const lastAt = ledger.length > 0 ? ledger[ledger.length - 1]?.at : '';
525
- out.fingerprint = `${ledger.length}:${lastAt}`;
554
+ const parsed = parseLedgerEntries(ledger);
555
+ out.stale = parsed.stale;
556
+ out.suspect = parsed.suspect;
557
+ out.fingerprint = parsed.fingerprint;
526
558
  }
527
559
  const requeue = readJsonFile(path.join(root, '.research', 'requeue.json'));
528
560
  if (Array.isArray(requeue)) {
@@ -635,6 +667,200 @@ export function startStalenessNudge(host, root) {
635
667
  }
636
668
  }
637
669
 
670
+ /**
671
+ * Plugin log. Never throws.
672
+ *
673
+ * Before this existed every setup block swallowed errors with a bare
674
+ * `catch {}`, so `--log-level all` showed only host-side symptoms and a
675
+ * registration failure was indistinguishable from success.
676
+ */
677
+ function log(host, level, message, detail) {
678
+ let text = message;
679
+ if (detail !== undefined) {
680
+ try {
681
+ text = `${message} ${JSON.stringify(detail)}`;
682
+ } catch {
683
+ text = `${message} [unserialisable detail]`;
684
+ }
685
+ }
686
+ try {
687
+ const sink = host?.client?.app?.log;
688
+ if (typeof sink === 'function') {
689
+ Promise.resolve(sink.call(host.client.app, {
690
+ body: { service: 'iumbtems', level, message: text },
691
+ })).catch(() => {});
692
+ return;
693
+ }
694
+ } catch {
695
+ /* fall through to console */
696
+ }
697
+ try {
698
+ (console.error || console.log)(`[iumbtems] ${level}: ${text}`);
699
+ } catch {
700
+ /* logging must never throw into the host */
701
+ }
702
+ }
703
+
704
+ function errDetail(err) {
705
+ return { error: String(err?.message ?? err) };
706
+ }
707
+
708
+ async function registerHostCommands(host) {
709
+ if (typeof host?.command?.transform !== 'function') return [];
710
+
711
+ // Snapshot which names are already taken *at setup time*. This snapshot is
712
+ // COPIED into the transform callback on every invocation and never grown.
713
+ //
714
+ // The host replays this callback on each command-registry rebuild, and the
715
+ // rebuilt registry starts from base config — so if the callback mutates a
716
+ // shared "already added" set, the second replay sees all six names as taken
717
+ // and adds nothing. That is exactly why the slash commands appeared at
718
+ // startup and vanished seconds later, and why only a full process restart
719
+ // brought them back (fresh setup -> fresh set).
720
+ //
721
+ // The reference plugin (@prevalentware/opencode-goal-plugin) gets this right
722
+ // with `const claimed = new Set(existingCommands)` inside the callback.
723
+ let existingNames = new Set();
724
+ try {
725
+ const listed = await host.command.list();
726
+ existingNames = new Set((listed?.data || []).map((c) => c?.name));
727
+ } catch (err) {
728
+ log(host, 'warn', 'command.list() failed; assuming an empty registry', errDetail(err));
729
+ }
730
+
731
+ const registration = await host.command.transform((draft) => {
732
+ const claimed = new Set(existingNames); // fresh copy per replay
733
+ const added = [];
734
+ for (const cmd of OPENCODE_COMMANDS) {
735
+ if (!cmd?.name || claimed.has(cmd.name)) continue;
736
+ claimed.add(cmd.name);
737
+ added.push(cmd.name);
738
+ draft.add({
739
+ name: cmd.name,
740
+ description: cmd.description,
741
+ execute: async (input) => {
742
+ const args = input?.prompt?.text || '';
743
+ const prompt = (typeof input?.prompt === 'object' && input?.prompt !== null) ? input.prompt : {};
744
+ await host.session.prompt({
745
+ ...prompt,
746
+ sessionID: input?.sessionID,
747
+ text: cmd.template.split('$ARGUMENTS').join(String(args).trim()),
748
+ delivery: input?.delivery,
749
+ });
750
+ },
751
+ });
752
+ }
753
+ log(host, 'debug', 'command.transform pass', {
754
+ added,
755
+ skippedAsPreexisting: [...existingNames].filter((n) => !added.includes(n)),
756
+ });
757
+ });
758
+
759
+ // Force materialisation now instead of waiting for the host's own later
760
+ // reconcile (which is the pass that used to wipe them).
761
+ try {
762
+ if (typeof host.command.reload === 'function') await host.command.reload();
763
+ log(host, 'info', 'slash commands registered', { count: OPENCODE_COMMANDS.length });
764
+ } catch (err) {
765
+ log(host, 'warn', 'command.reload() failed', errDetail(err));
766
+ }
767
+
768
+ return registration ? [registration] : [];
769
+ }
770
+
771
+ async function registerHostTools(host) {
772
+ if (typeof host?.tool?.transform !== 'function') return [];
773
+ const registration = await host.tool.transform((draft) => {
774
+ for (const [name, spec] of Object.entries(buildToolMap())) {
775
+ draft.add({
776
+ name,
777
+ description: spec.description,
778
+ input: spec.input,
779
+ options: { codemode: false },
780
+ execute: async (args = {}, toolContext = undefined) => {
781
+ const r = await callMcp(name, normalizeArgs(name, args), toolContext?.cwd);
782
+ return { content: r.content };
783
+ },
784
+ });
785
+ }
786
+ log(host, 'debug', 'tool.transform pass', { count: Object.keys(buildToolMap()).length });
787
+ });
788
+ try {
789
+ if (typeof host.tool.reload === 'function') await host.tool.reload();
790
+ } catch (err) {
791
+ log(host, 'warn', 'tool.reload() failed', errDetail(err));
792
+ }
793
+ return registration ? [registration] : [];
794
+ }
795
+
796
+ /**
797
+ * V2 compaction hook: keep .research state across context compaction.
798
+ *
799
+ * The V1 equivalent (`experimental.session.compacting`) lived in the old
800
+ * `server()` hook and was dead code on V2 — compaction silently lost the
801
+ * swarm state. Shape follows the reference plugin: push a text part onto
802
+ * `event.system`.
803
+ */
804
+ async function registerCompactionHook(host, context) {
805
+ const hook = host?.session?.hook || context?.session?.hook;
806
+ if (typeof hook !== 'function') return [];
807
+ try {
808
+ const registration = await hook.call(host.session || context.session, 'compaction', async (event) => {
809
+ try {
810
+ const root =
811
+ event?.directory || host.location?.directory || context.location?.directory || event?.cwd;
812
+ if (!root) return;
813
+ const text = buildCompactionContext(root);
814
+ if (!text) return;
815
+ const system = Array.isArray(event?.system) ? event.system : null;
816
+ if (!system) return;
817
+ // Idempotent: the host may invoke this more than once per session.
818
+ if (system.some((part) => part?.type === 'text' && part?.text === text)) return;
819
+ system.push({ type: 'text', text });
820
+ } catch (err) {
821
+ log(host, 'warn', 'compaction hook failed', errDetail(err));
822
+ }
823
+ });
824
+ return registration ? [registration] : [];
825
+ } catch (err) {
826
+ log(host, 'warn', 'session.hook("compaction") unavailable', errDetail(err));
827
+ return [];
828
+ }
829
+ }
830
+
831
+ async function applyConfigOptions(host, opts, context) {
832
+ if (!opts || typeof opts !== 'object') return;
833
+ const updates = {};
834
+ if (opts.search_engine) updates.search_engine = opts.search_engine;
835
+ if (opts.max_iterations) updates.max_iterations = opts.max_iterations;
836
+ if (opts.mode) updates.mode = opts.mode;
837
+ if (opts.divergence_threshold !== undefined) {
838
+ updates.divergence_threshold = opts.divergence_threshold;
839
+ }
840
+ if (Object.keys(updates).length > 0) {
841
+ await callMcp(
842
+ 'iumbtems_config',
843
+ updates,
844
+ host.location?.directory || context.location?.directory || host.cwd
845
+ );
846
+ log(host, 'debug', 'applied tuple options via iumbtems_config', updates);
847
+ }
848
+ }
849
+
850
+ function startNudgeSubscription(host, opts, context, cleanups) {
851
+ if (!(opts?.staleness_nudge ?? true)) return;
852
+ const nudgeHost = {
853
+ event: host.event || context.event,
854
+ session: host.session || context.session,
855
+ };
856
+ const root = host.location?.directory || context.location?.directory;
857
+ if (root) {
858
+ const stop = startStalenessNudge(nudgeHost, root);
859
+ if (typeof stop === 'function') cleanups.add(stop);
860
+ log(host, 'debug', 'staleness nudge subscription', { root, started: typeof stop === 'function' });
861
+ }
862
+ }
863
+
638
864
  export function createOpenCodePlugin(context = {}) {
639
865
  return {
640
866
  id: 'heretek.iumbtems.epistemic-swarm',
@@ -643,137 +869,77 @@ export function createOpenCodePlugin(context = {}) {
643
869
  description:
644
870
  'I Use My Brain To Express My Self: High-integrity dialectic research, code audits, and open-source scouting',
645
871
 
646
- server: async () => ({
647
- // Config hook: slash-command catalog (mirrors the goal plugin's
648
- // registerDesktopCommands pattern; never overwrites user commands).
649
- config: (cfg) => registerOpenCodeCommands(cfg),
650
- // Tool map (object form, as in the reference implementation).
651
- tool: buildToolMap(),
652
- // Compaction hook: preserve .research state (best-effort, silent skip
653
- // when no workspace is present so compaction never breaks).
654
- 'experimental.session.compacting': async (input, output) => {
655
- try {
656
- const root = input?.directory ?? input?.location?.directory ?? process.cwd();
657
- const ctx = buildCompactionContext(root);
658
- if (ctx && output && Array.isArray(output.context)) output.context.push(ctx);
659
- } catch {
660
- /* never break compaction */
661
- }
662
- },
663
- }),
664
-
872
+ // V2 only. The V1 `server()` factory and its `config` hook wrote the
873
+ // deprecated `command` key (V2's schema is `commands`), so they registered
874
+ // nothing on a V2 host — and carrying both shapes meant the real
875
+ // registration path had to guess which host it was talking to.
665
876
  setup: async (appContext) => {
666
877
  const host = appContext || {};
667
878
  const opts = context.options || host.options;
668
- // V2 host transforms FIRST (official dual-package pattern: V1 calls
669
- // server(), V2 calls setup()). Registration must precede any slow
670
- // work: the host may not wait out subprocess calls before reconciling.
879
+ // Transform registrations must precede any slow work: the host may
880
+ // reconcile past a slow setup and never see the commands.
881
+ const registrations = [];
882
+ const adopt = (list) => {
883
+ for (const r of list || []) if (r) registrations.push(r);
884
+ };
885
+
886
+ log(host, 'info', 'iumbtems setup', {
887
+ version: PKG_VERSION,
888
+ directory: host.location?.directory || context.location?.directory || null,
889
+ });
890
+
671
891
  try {
672
- if (host.command && typeof host.command.transform === 'function') {
673
- let existing = new Set();
674
- try {
675
- const listed = await host.command.list();
676
- existing = new Set(((listed && listed.data) || []).map((c) => c && c.name));
677
- } catch {
678
- /* treat as empty */
679
- }
680
- await host.command.transform((draft) => {
681
- for (const cmd of OPENCODE_COMMANDS) {
682
- if (!cmd || !cmd.name || existing.has(cmd.name)) continue;
683
- existing.add(cmd.name);
684
- draft.add({
685
- name: cmd.name,
686
- description: cmd.description,
687
- execute: async (input) => {
688
- const args = (input && input.prompt && input.prompt.text) || '';
689
- const prompt = (input && input.prompt && typeof input.prompt === 'object') ? input.prompt : {};
690
- await host.session.prompt({
691
- ...prompt,
692
- sessionID: input && input.sessionID,
693
- text: cmd.template.split('$ARGUMENTS').join(String(args).trim()),
694
- delivery: input && input.delivery,
695
- });
696
- },
697
- });
698
- }
699
- });
700
- }
701
- } catch {
702
- /* command transform is best-effort */
892
+ adopt(await registerHostCommands(host));
893
+ } catch (err) {
894
+ log(host, 'error', 'registering slash commands failed', errDetail(err));
703
895
  }
704
896
  try {
705
- if (host.tool && typeof host.tool.transform === 'function') {
706
- await host.tool.transform((draft) => {
707
- for (const [name, spec] of Object.entries(buildToolMap())) {
708
- draft.add({
709
- name,
710
- description: spec.description,
711
- input: spec.input,
712
- options: { codemode: false },
713
- execute: async (args = {}, toolContext) => {
714
- const r = await callMcp(name, normalizeArgs(name, args), toolContext?.cwd);
715
- return { content: r.content };
716
- },
717
- });
718
- }
719
- });
720
- }
721
- } catch {
722
- /* tool transform is best-effort */
897
+ adopt(await registerHostTools(host));
898
+ } catch (err) {
899
+ log(host, 'error', 'registering tools failed', errDetail(err));
723
900
  }
724
- // Slow work AFTER registration: apply tuple options via MCP config
725
- // write, scoped to the plugin location (never the service cwd).
726
901
  try {
727
- if (opts && typeof opts === 'object') {
728
- const updates = {};
729
- if (opts.search_engine) updates.search_engine = opts.search_engine;
730
- if (opts.max_iterations) updates.max_iterations = opts.max_iterations;
731
- if (opts.mode) updates.mode = opts.mode;
732
- if (opts.divergence_threshold !== undefined) {
733
- updates.divergence_threshold = opts.divergence_threshold;
734
- }
735
- if (Object.keys(updates).length > 0) {
736
- await callMcp(
737
- 'iumbtems_config',
738
- updates,
739
- host.location?.directory || context.location?.directory || host.cwd
740
- );
741
- }
742
- }
743
- } catch {
744
- /* options apply is best-effort */
902
+ adopt(await registerCompactionHook(host, context));
903
+ } catch (err) {
904
+ log(host, 'error', 'registering compaction hook failed', errDetail(err));
905
+ }
906
+ // Slow work AFTER registration.
907
+ try {
908
+ await applyConfigOptions(host, opts, context);
909
+ } catch (err) {
910
+ log(host, 'warn', 'applying tuple options failed', errDetail(err));
745
911
  }
746
- // Phase C: idle staleness nudge (opt out with {staleness_nudge: false}).
747
- // Subscribes only when the host exposes event/session; one per root.
748
912
  try {
749
- if ((opts?.staleness_nudge ?? true)) {
750
- const nudgeHost = {
751
- event: host.event || context.event,
752
- session: host.session || context.session,
753
- };
754
- const root =
755
- host.location?.directory || context.location?.directory;
756
- if (root) {
757
- const stop = startStalenessNudge(nudgeHost, root);
758
- if (typeof stop === 'function') setupCleanups.add(stop);
913
+ startNudgeSubscription(host, opts, context, setupCleanups);
914
+ } catch (err) {
915
+ log(host, 'warn', 'starting staleness nudge failed', errDetail(err));
916
+ }
917
+
918
+ // The host calls this on unload/reload. It MUST be a function: returning
919
+ // a plain object used to abort the whole plugin reload with
920
+ // `TypeError: de is not a function`, dropping every registration.
921
+ return async () => {
922
+ let disposed = 0;
923
+ for (const registration of registrations) {
924
+ try {
925
+ await registration.dispose();
926
+ disposed += 1;
927
+ } catch (err) {
928
+ log(host, 'warn', 'registration.dispose() failed', errDetail(err));
759
929
  }
760
930
  }
761
- } catch {
762
- /* lifecycle hooks are best-effort */
763
- }
764
- // Disposer: the host calls setup's return value as a cleanup function
765
- // on unload/reload. It MUST be a function (returning a plain object
766
- // crashes host reload with "not a function"). Aborts nudge streams so
767
- // reloads never stack duplicate subscriptions.
768
- return () => {
769
- for (const stop of [...setupCleanups]) {
770
- setupCleanups.delete(stop);
931
+ registrations.length = 0;
932
+ let stopped = 0;
933
+ for (const stop of setupCleanups) {
771
934
  try {
772
935
  stop();
773
- } catch {
774
- /* cleanup is best-effort */
936
+ stopped += 1;
937
+ } catch (err) {
938
+ log(host, 'warn', 'cleanup failed', errDetail(err));
775
939
  }
776
940
  }
941
+ setupCleanups.clear();
942
+ log(host, 'info', 'iumbtems disposed', { registrations: disposed, nudges: stopped });
777
943
  };
778
944
  },
779
945
  };
@@ -11,8 +11,8 @@
11
11
  * `keymap.layer` (inside a component scope), `api.theme`.
12
12
  */
13
13
 
14
- import { readFileSync, readdirSync, statSync } from 'fs';
15
- import path from 'path';
14
+ import { readFileSync, readdirSync, statSync } from 'node:fs';
15
+ import path from 'node:path';
16
16
  import { createElement, insert, setProp } from '@opentui/solid';
17
17
  import { createEffect, createSignal, onCleanup } from 'solid-js';
18
18
  import { OPENCODE_COMMANDS } from './index.js';
@@ -65,9 +65,8 @@ export function readSwarmStatus(root) {
65
65
  suspect: 0,
66
66
  requeued: 0,
67
67
  };
68
- let entries = [];
69
68
  try {
70
- entries = readdirSync(research);
69
+ readdirSync(research);
71
70
  status.workspace = true;
72
71
  } catch {
73
72
  return status;
@@ -149,7 +148,7 @@ function summarize(status) {
149
148
  return `IUMBTEMS: ${bits.join(' · ')}`;
150
149
  }
151
150
 
152
- function SwarmSidebar(api, root, sessionID) {
151
+ function SwarmSidebar(api, root) {
153
152
  const colors = themeFg(api?.theme);
154
153
  const [snapshot, setSnapshot] = createSignal(readSwarmStatus(root));
155
154
  createEffect(() => {
@@ -162,7 +161,6 @@ function SwarmSidebar(api, root, sessionID) {
162
161
  }, REFRESH_MS);
163
162
  onCleanup(() => clearInterval(timer));
164
163
  });
165
- void sessionID;
166
164
  return box({}, [
167
165
  () => {
168
166
  const s = snapshot();
@@ -196,7 +194,7 @@ function SwarmKeymapLayer(api) {
196
194
  // Every slash command gets a palette entry (usage toast; the host has no
197
195
  // programmatic slash-invoke API, so discovery + usage guidance is the win).
198
196
  for (const cmd of OPENCODE_COMMANDS || []) {
199
- if (!cmd || !cmd.name) continue;
197
+ if (!cmd?.name) continue;
200
198
  commands.push({
201
199
  id: `iumbtems.command.${cmd.name}`,
202
200
  title: `/${cmd.name}`,
@@ -247,8 +245,8 @@ function registerSlot(api, name, render) {
247
245
  export function setupTui(api) {
248
246
  try {
249
247
  const root = resolveRoot(api);
250
- const offSidebar = registerSlot(api, 'sidebar.content', (props) =>
251
- SwarmSidebar(api, root, props?.sessionID)
248
+ const offSidebar = registerSlot(api, 'sidebar.content', () =>
249
+ SwarmSidebar(api, root)
252
250
  );
253
251
  const offApp = registerSlot(api, 'app', () => SwarmKeymapLayer(api));
254
252
  return () => {