@heretek-ai/epistemic-swarm 0.4.6 → 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 +310 -134
  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 +398 -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,127 +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
- if (opts && typeof opts === 'object') {
669
- const updates = {};
670
- if (opts.search_engine) updates.search_engine = opts.search_engine;
671
- if (opts.max_iterations) updates.max_iterations = opts.max_iterations;
672
- if (opts.mode) updates.mode = opts.mode;
673
- if (opts.divergence_threshold !== undefined) {
674
- updates.divergence_threshold = opts.divergence_threshold;
675
- }
676
- if (Object.keys(updates).length > 0) {
677
- await callMcp('iumbtems_config', updates, host.cwd);
678
- }
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
+
891
+ try {
892
+ adopt(await registerHostCommands(host));
893
+ } catch (err) {
894
+ log(host, 'error', 'registering slash commands failed', errDetail(err));
679
895
  }
680
- // Phase C: idle staleness nudge (opt out with {staleness_nudge: false}).
681
- // Subscribes only when the host exposes event/session; one per root.
682
896
  try {
683
- if ((opts?.staleness_nudge ?? true)) {
684
- const nudgeHost = {
685
- event: host.event || context.event,
686
- session: host.session || context.session,
687
- };
688
- const root =
689
- host.location?.directory || context.location?.directory;
690
- if (root) {
691
- const stop = startStalenessNudge(nudgeHost, root);
692
- if (typeof stop === 'function') setupCleanups.add(stop);
693
- }
694
- }
695
- } catch {
696
- /* lifecycle hooks are best-effort */
897
+ adopt(await registerHostTools(host));
898
+ } catch (err) {
899
+ log(host, 'error', 'registering tools failed', errDetail(err));
697
900
  }
698
- // V2 host transforms (official dual-package pattern: V1 calls server(),
699
- // V2 calls setup()). Registers slash commands + tools via the owning
700
- // domains; each guarded so V1 hosts and partial V2 hosts keep working.
701
901
  try {
702
- if (host.command && typeof host.command.transform === 'function') {
703
- let existing = new Set();
704
- try {
705
- const listed = await host.command.list();
706
- existing = new Set(((listed && listed.data) || []).map((c) => c && c.name));
707
- } catch {
708
- /* treat as empty */
709
- }
710
- await host.command.transform((draft) => {
711
- for (const cmd of OPENCODE_COMMANDS) {
712
- if (!cmd || !cmd.name || existing.has(cmd.name)) continue;
713
- existing.add(cmd.name);
714
- draft.add({
715
- name: cmd.name,
716
- description: cmd.description,
717
- execute: async (input) => {
718
- const args = (input && input.prompt && input.prompt.text) || '';
719
- const prompt = (input && input.prompt && typeof input.prompt === 'object') ? input.prompt : {};
720
- await host.session.prompt({
721
- ...prompt,
722
- sessionID: input && input.sessionID,
723
- text: cmd.template.split('$ARGUMENTS').join(String(args).trim()),
724
- delivery: input && input.delivery,
725
- });
726
- },
727
- });
728
- }
729
- });
730
- }
731
- } catch {
732
- /* command transform is best-effort */
902
+ adopt(await registerCompactionHook(host, context));
903
+ } catch (err) {
904
+ log(host, 'error', 'registering compaction hook failed', errDetail(err));
733
905
  }
906
+ // Slow work AFTER registration.
734
907
  try {
735
- if (host.tool && typeof host.tool.transform === 'function') {
736
- await host.tool.transform((draft) => {
737
- for (const [name, spec] of Object.entries(buildToolMap())) {
738
- draft.add({
739
- name,
740
- description: spec.description,
741
- input: spec.input,
742
- options: { codemode: false },
743
- execute: async (args = {}, toolContext) => {
744
- const r = await callMcp(name, normalizeArgs(name, args), toolContext?.cwd);
745
- return { content: r.content };
746
- },
747
- });
748
- }
749
- });
750
- }
751
- } catch {
752
- /* tool transform is best-effort */
908
+ await applyConfigOptions(host, opts, context);
909
+ } catch (err) {
910
+ log(host, 'warn', 'applying tuple options failed', errDetail(err));
753
911
  }
754
- // Disposer: the host calls setup's return value as a cleanup function
755
- // on unload/reload. It MUST be a function (returning a plain object
756
- // crashes host reload with "not a function"). Aborts nudge streams so
757
- // reloads never stack duplicate subscriptions.
758
- return () => {
759
- for (const stop of [...setupCleanups]) {
760
- setupCleanups.delete(stop);
912
+ try {
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));
929
+ }
930
+ }
931
+ registrations.length = 0;
932
+ let stopped = 0;
933
+ for (const stop of setupCleanups) {
761
934
  try {
762
935
  stop();
763
- } catch {
764
- /* cleanup is best-effort */
936
+ stopped += 1;
937
+ } catch (err) {
938
+ log(host, 'warn', 'cleanup failed', errDetail(err));
765
939
  }
766
940
  }
941
+ setupCleanups.clear();
942
+ log(host, 'info', 'iumbtems disposed', { registrations: disposed, nudges: stopped });
767
943
  };
768
944
  },
769
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 () => {
@@ -14,10 +14,14 @@ from datetime import datetime, timezone
14
14
  from pathlib import Path
15
15
  from typing import Dict, Any, Optional, Tuple
16
16
 
17
+ DEFAULT_RESEARCH_DIR = ".research"
18
+ BASE_DIR_HELP = "Base .research directory"
19
+
17
20
  class SourceHasher:
18
21
  def __init__(self, base_dir: Optional[Path] = None):
19
- self.base_dir = base_dir or Path(".research")
20
- self.sources_dir = self.base_dir / "sources"
22
+ target = base_dir or Path(DEFAULT_RESEARCH_DIR)
23
+ self.base_dir = Path(os.path.realpath(str(target)))
24
+ self.sources_dir = Path(os.path.realpath(str(self.base_dir / "sources")))
21
25
  self.sources_dir.mkdir(parents=True, exist_ok=True)
22
26
 
23
27
  @staticmethod
@@ -70,15 +74,28 @@ class SourceHasher:
70
74
  return None
71
75
 
72
76
  def _resolve_hash_file(self, hash_prefix: str, extension: str) -> Optional[Path]:
73
- """Resolves exact or prefix hash to disk path."""
74
- exact_path = self.sources_dir / f"{hash_prefix}{extension}"
77
+ """Resolves exact or prefix hash to disk path.
78
+
79
+ ``hash_prefix`` is untrusted (it arrives from an LLM tool call), so the
80
+ result is required to stay inside ``sources_dir``. Containment is checked
81
+ inline rather than via ``runner.path_safety`` because this module is
82
+ mirrored into the standalone ``research-cache`` plugin, which has no
83
+ ``runner`` package on its path.
84
+ """
85
+ if not re.match(r"^[a-fA-F0-9]+$", hash_prefix):
86
+ return None
87
+ exact_path = Path(os.path.realpath(str(self.sources_dir / f"{hash_prefix}{extension}")))
88
+ if exact_path.parent != self.sources_dir:
89
+ return None
75
90
  if exact_path.exists():
76
91
  return exact_path
77
92
 
78
93
  # If prefix match
79
94
  matches = list(self.sources_dir.glob(f"{hash_prefix}*{extension}"))
80
95
  if len(matches) == 1:
81
- return matches[0]
96
+ candidate = Path(os.path.realpath(str(matches[0])))
97
+ if candidate.parent == self.sources_dir:
98
+ return candidate
82
99
  return None
83
100
 
84
101
  @staticmethod
@@ -143,20 +160,21 @@ def main():
143
160
  cache_parser.add_argument("--title", default="Untitled", help="Document Title")
144
161
  cache_parser.add_argument("--tier", default="WEB_DOCUMENT", help="Source tier")
145
162
  cache_parser.add_argument("--content", help="Raw text content (or read from stdin)")
146
- cache_parser.add_argument("--dir", default=".research", help="Base .research directory")
163
+ cache_parser.add_argument("--dir", default=DEFAULT_RESEARCH_DIR, help=BASE_DIR_HELP)
147
164
 
148
165
  # Verify command
149
166
  verify_parser = subparsers.add_parser("verify", help="Verify a verbatim quote")
150
167
  verify_parser.add_argument("--hash", required=True, help="Document SHA-256 hash")
151
168
  verify_parser.add_argument("--quote", required=True, help="Verbatim quote to check")
152
- verify_parser.add_argument("--dir", default=".research", help="Base .research directory")
169
+ verify_parser.add_argument("--dir", default=DEFAULT_RESEARCH_DIR, help=BASE_DIR_HELP)
153
170
 
154
171
  # List command
155
172
  list_parser = subparsers.add_parser("list", help="List cached sources")
156
- list_parser.add_argument("--dir", default=".research", help="Base .research directory")
173
+ list_parser.add_argument("--dir", default=DEFAULT_RESEARCH_DIR, help=BASE_DIR_HELP)
157
174
 
158
175
  args = parser.parse_args()
159
- hasher = SourceHasher(base_dir=Path(args.dir if hasattr(args, "dir") else ".research"))
176
+ dir_val = getattr(args, "dir", DEFAULT_RESEARCH_DIR) or DEFAULT_RESEARCH_DIR
177
+ hasher = SourceHasher(base_dir=Path(os.path.realpath(str(dir_val))))
160
178
 
161
179
  if args.command == "cache":
162
180
  content = args.content