@heretek-ai/epistemic-swarm 0.7.19 → 0.7.21
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/.claude-plugin/plugin.json +1 -1
- package/README.md +23 -2
- package/package.json +1 -1
- package/plugins/antigravity/plugin.json +1 -1
- package/plugins/antigravity/rules/AGENTS.md +5 -3
- package/plugins/opencode/config-io.js +55 -12
- package/plugins/opencode/index.js +611 -0
- package/plugins/opencode/tui.js +278 -6
- package/runner/__pycache__/__init__.cpython-311.pyc +0 -0
- package/runner/__pycache__/auctioneer.cpython-311.pyc +0 -0
- package/runner/__pycache__/auditor_engine.cpython-311.pyc +0 -0
- package/runner/__pycache__/claim_store.cpython-311.pyc +0 -0
- package/runner/__pycache__/claim_witness.cpython-311.pyc +0 -0
- package/runner/__pycache__/darkharvest_claims.cpython-311.pyc +0 -0
- package/runner/__pycache__/errors.cpython-311.pyc +0 -0
- package/runner/__pycache__/living_dossiers.cpython-311.pyc +0 -0
- package/runner/__pycache__/mcp_protocol.cpython-311.pyc +0 -0
- package/runner/__pycache__/mcp_server.cpython-311.pyc +0 -0
- package/runner/__pycache__/path_safety.cpython-311.pyc +0 -0
- package/runner/__pycache__/pcrb.cpython-311.pyc +0 -0
- package/runner/__pycache__/pcrb_verify.cpython-311.pyc +0 -0
- package/runner/__pycache__/preflight.cpython-311.pyc +0 -0
- package/runner/__pycache__/refinement.cpython-311.pyc +0 -0
- package/runner/__pycache__/research_swarm.cpython-311.pyc +0 -0
- package/runner/__pycache__/retrieval_log.cpython-311.pyc +0 -0
- package/runner/__pycache__/schema_validate.cpython-311.pyc +0 -0
- package/runner/__pycache__/schemas.cpython-311.pyc +0 -0
- package/runner/__pycache__/state_machine.cpython-311.pyc +0 -0
- package/runner/mcp_server.py +81 -10
- package/runner/research_swarm.py +12 -2
- package/runner/schemas.py +18 -0
- package/runner/tests/__pycache__/test_antigravity_plugin.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_auction_order.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_backends.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_bet1_spike.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_claim_store.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_claim_witness.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_claude_plugin.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_config_schema.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_contract_tooling.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_darkharvest_audit.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_domain_packs.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_dossier_contract.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_factory.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_fleet_seam.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_frontier_concurrency.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_frontier_mirror_standalone.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_grounding.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_living_dossiers.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_manifest_concurrency.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_mcp_server.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_opencode_ux.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_opencode_v2_registrars.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_pcrb.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_refinement.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_search_anomaly.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_search_provider.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_search_provider_plugin.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_swarm.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_swarm_config.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_sweep_regressions.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_webcache.cpython-311.pyc +0 -0
- package/runner/tests/test_config_schema.py +106 -0
- package/runner/tests/test_opencode_ux.py +462 -7
- package/runner/tests/test_opencode_v2_registrars.py +1112 -3
- package/runner/tests/test_swarm_config.py +21 -0
- package/schemas/config.schema.json +11 -0
- package/scripts/__pycache__/bet1_advisory_spike.cpython-311.pyc +0 -0
- package/scripts/__pycache__/build_adapters.cpython-311.pyc +0 -0
- package/scripts/__pycache__/check_docs.cpython-311.pyc +0 -0
- package/scripts/__pycache__/divergence_experiment.cpython-311.pyc +0 -0
- package/scripts/__pycache__/gen_schemas.cpython-311.pyc +0 -0
- package/skills/epistemic_search/scripts/__pycache__/search.cpython-311.pyc +0 -0
- package/skills/grilling/__pycache__/__init__.cpython-311.pyc +0 -0
- package/skills/grilling/__pycache__/socratic_tree.cpython-311.pyc +0 -0
- package/skills/research_cache/__pycache__/__init__.cpython-311.pyc +0 -0
- package/skills/research_cache/__pycache__/hasher.cpython-311.pyc +0 -0
- package/skills/swarm_config/SKILL.md +26 -0
- package/skills/swarm_config/__pycache__/__init__.cpython-311.pyc +0 -0
- package/skills/swarm_config/__pycache__/configure.cpython-311.pyc +0 -0
- package/skills/swarm_config/configure.py +40 -6
package/plugins/opencode/tui.js
CHANGED
|
@@ -26,11 +26,174 @@ import {
|
|
|
26
26
|
saveConfig,
|
|
27
27
|
validateConfig,
|
|
28
28
|
} from './config-io.js';
|
|
29
|
-
import { OPENCODE_COMMANDS } from './index.js';
|
|
29
|
+
import { OPENCODE_COMMANDS, SettingsRpc } from './index.js';
|
|
30
30
|
|
|
31
31
|
const PLUGIN_ID = 'heretek.iumbtems.epistemic-swarm.tui';
|
|
32
32
|
const REFRESH_MS = 5000;
|
|
33
33
|
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Phase 03 (opencode-settings-menu): RPC-preferred reads/writes, `changed`
|
|
36
|
+
// subscription, UI-only prefs.
|
|
37
|
+
//
|
|
38
|
+
// The server plugin's `iumbtems.settings` RPC domain is the preferred path:
|
|
39
|
+
// `api.client.rpc(SettingsRpc)` (the v2 TUI context carries `client:
|
|
40
|
+
// OpenCodeClient`). When absent — or when an RPC call fails with a transport
|
|
41
|
+
// error — the wizard/panel fall back to the direct-fs `config-io.js` path
|
|
42
|
+
// with a visible degraded-path toast. Both paths share validation and the
|
|
43
|
+
// expected-hash guard. Live refresh subscribes to the `changed` event; the
|
|
44
|
+
// existing 5s mtime poll stays as the floor.
|
|
45
|
+
//
|
|
46
|
+
// UI preferences live in `api.storage.store(...)` (durable, synced across TUI
|
|
47
|
+
// instances) and are NEVER run-affecting: only dialog conveniences such as
|
|
48
|
+
// the last-save timestamp. Effective values always come from the RPC snapshot
|
|
49
|
+
// or `.research/config.json`.
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
/** Live-refresh floor: the sidebar poll interval, in ms (unchanged). */
|
|
53
|
+
export const SETTINGS_POLL_MS = REFRESH_MS;
|
|
54
|
+
|
|
55
|
+
/** UI-only preferences key (TUI storage; never run-affecting). */
|
|
56
|
+
export const UI_PREFS_KEY = 'iumbtems.ui.prefs';
|
|
57
|
+
|
|
58
|
+
// Invariant (phase-03 R4c): UI prefs are write-only conveniences (e.g. the
|
|
59
|
+
// last-save timestamp). NO read path — wizard, panel, or otherwise — consults
|
|
60
|
+
// them when computing effective settings: effective values come exclusively
|
|
61
|
+
// from the RPC snapshot or `.research/config.json`. `TestUiPrefsBoundary`
|
|
62
|
+
// pins this: prefs deliberately named like config keys never surface in the
|
|
63
|
+
// panel body or on disk. No behavior change by this comment.
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* RPC subclient for the settings domain, or null when the host has no
|
|
67
|
+
* `client.rpc` (degraded direct-fs path). Never throws.
|
|
68
|
+
*/
|
|
69
|
+
export function getSettingsRpcClient(api) {
|
|
70
|
+
try {
|
|
71
|
+
const rpc = api?.client?.rpc;
|
|
72
|
+
if (typeof rpc !== 'function') return null;
|
|
73
|
+
return rpc.call(api.client, SettingsRpc);
|
|
74
|
+
} catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Subscribe to the server `changed` event for live refresh. Returns the
|
|
81
|
+
* unsubscribe function, or null when the host has no RPC events (the 5s poll
|
|
82
|
+
* remains the floor). Never throws.
|
|
83
|
+
*/
|
|
84
|
+
export function subscribeSettingsChanged(api, onChanged) {
|
|
85
|
+
try {
|
|
86
|
+
const client = getSettingsRpcClient(api);
|
|
87
|
+
if (!client || !client.events || typeof client.events.on !== 'function') {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
const unsub = client.events.on('changed', (event) => {
|
|
91
|
+
try {
|
|
92
|
+
if (typeof onChanged === 'function') onChanged(event);
|
|
93
|
+
} catch {
|
|
94
|
+
/* refresh callbacks are best-effort */
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
return typeof unsub === 'function' ? unsub : () => {};
|
|
98
|
+
} catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Read UI-only prefs ({} when the host has no TUI storage). Never throws. */
|
|
104
|
+
export function readUiPrefs(api) {
|
|
105
|
+
try {
|
|
106
|
+
const store = api?.storage?.store;
|
|
107
|
+
if (typeof store !== 'function') return {};
|
|
108
|
+
const [state] = store.call(api.storage, UI_PREFS_KEY, { initial: {} });
|
|
109
|
+
return { ...(state || {}) };
|
|
110
|
+
} catch {
|
|
111
|
+
return {};
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Merge UI-only prefs (never run-affecting; false when the host has no TUI
|
|
117
|
+
* storage). Never throws.
|
|
118
|
+
*/
|
|
119
|
+
export async function writeUiPrefs(api, patch) {
|
|
120
|
+
try {
|
|
121
|
+
const store = api?.storage?.store;
|
|
122
|
+
if (typeof store !== 'function') return false;
|
|
123
|
+
const parts = store.call(api.storage, UI_PREFS_KEY, { initial: {} });
|
|
124
|
+
const mutate = parts && parts[1];
|
|
125
|
+
if (typeof mutate !== 'function') return false;
|
|
126
|
+
await mutate((draft) => {
|
|
127
|
+
Object.assign(draft, patch || {});
|
|
128
|
+
});
|
|
129
|
+
return true;
|
|
130
|
+
} catch {
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Visible toast marking the degraded direct-fs path. */
|
|
136
|
+
function degradedRpcToast(host, reason) {
|
|
137
|
+
wizardToast(
|
|
138
|
+
host,
|
|
139
|
+
'IUMBTEMS settings',
|
|
140
|
+
`Server RPC ${reason} — using direct file write (degraded path). ` +
|
|
141
|
+
'Changes still validate and apply to the next swarm run.'
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Human-readable settings text for already-loaded rows (the RPC path). */
|
|
146
|
+
function formatSettingRows(rows) {
|
|
147
|
+
try {
|
|
148
|
+
if (!rows || rows.length === 0) return 'IUMBTEMS settings: no schema available.';
|
|
149
|
+
const lines = rows.map((row) => {
|
|
150
|
+
const restartNote =
|
|
151
|
+
row.restart === 'next-run'
|
|
152
|
+
? 'applies to next swarm run'
|
|
153
|
+
: `restart: ${row.restart}`;
|
|
154
|
+
const stale = row.deprecated ? ' (deprecated, display-only)' : '';
|
|
155
|
+
return `- ${row.label} (${row.key}): ${displayCell(row)} [${row.source}] — ${restartNote}${stale}`;
|
|
156
|
+
});
|
|
157
|
+
return ['IUMBTEMS settings (effective values):', ...lines].join('\n');
|
|
158
|
+
} catch {
|
|
159
|
+
return 'IUMBTEMS settings: unavailable (workspace unreadable).';
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Effective settings body: RPC snapshot when available, direct-fs text
|
|
165
|
+
* otherwise. Never throws.
|
|
166
|
+
*/
|
|
167
|
+
async function effectiveSettingsBody(host, baseDir, env) {
|
|
168
|
+
let body = null;
|
|
169
|
+
const rpc = getSettingsRpcClient(host);
|
|
170
|
+
if (rpc) {
|
|
171
|
+
try {
|
|
172
|
+
const snap = await rpc.get();
|
|
173
|
+
if (snap && snap.config && typeof snap.config === 'object') {
|
|
174
|
+
// Provenance comes from the ON-DISK `raw`, not the merged effective
|
|
175
|
+
// `config` (every key is present there, which made every row `file`).
|
|
176
|
+
const rows = buildSettingRows({ ...snap.config }, snap.raw, env);
|
|
177
|
+
if (rows.length > 0) body = formatSettingRows(rows);
|
|
178
|
+
}
|
|
179
|
+
} catch {
|
|
180
|
+
/* fall through to the direct-fs read */
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (body === null) body = formatSettingsText(baseDir, env);
|
|
184
|
+
// R10: a malformed file must not render as silent defaults. Advisory local-fs
|
|
185
|
+
// check (the same rule the wizard warns on); surfaced in the panel body —
|
|
186
|
+
// never throws, never writes.
|
|
187
|
+
try {
|
|
188
|
+
if (isConfigMalformed(baseDir)) {
|
|
189
|
+
body = `⚠️ Could not parse ${baseDir}/config.json — showing defaults.\n\n${body}`;
|
|
190
|
+
}
|
|
191
|
+
} catch {
|
|
192
|
+
/* advisory only — a failed check must not break the panel */
|
|
193
|
+
}
|
|
194
|
+
return body;
|
|
195
|
+
}
|
|
196
|
+
|
|
34
197
|
/** Palette command opening the read-only settings status panel. */
|
|
35
198
|
export const SETTINGS_PANEL_COMMAND_ID = 'iumbtems.swarm-settings';
|
|
36
199
|
/** Palette command opening the settings dialog wizard. */
|
|
@@ -206,6 +369,26 @@ function SwarmSidebar(api, root) {
|
|
|
206
369
|
}
|
|
207
370
|
}, REFRESH_MS);
|
|
208
371
|
onCleanup(() => clearInterval(timer));
|
|
372
|
+
// Live refresh on the server `changed` event; the poll above is the floor.
|
|
373
|
+
let unsub = null;
|
|
374
|
+
try {
|
|
375
|
+
unsub = subscribeSettingsChanged(api, () => {
|
|
376
|
+
try {
|
|
377
|
+
setSnapshot(readSwarmStatus(root));
|
|
378
|
+
} catch {
|
|
379
|
+
/* keep last snapshot */
|
|
380
|
+
}
|
|
381
|
+
});
|
|
382
|
+
} catch {
|
|
383
|
+
/* poll remains the floor */
|
|
384
|
+
}
|
|
385
|
+
onCleanup(() => {
|
|
386
|
+
try {
|
|
387
|
+
if (typeof unsub === 'function') unsub();
|
|
388
|
+
} catch {
|
|
389
|
+
/* unsubscribe is best-effort */
|
|
390
|
+
}
|
|
391
|
+
});
|
|
209
392
|
});
|
|
210
393
|
return box({}, [
|
|
211
394
|
() => {
|
|
@@ -829,8 +1012,31 @@ export async function openSettingsWizard(host, root, opts = {}) {
|
|
|
829
1012
|
);
|
|
830
1013
|
return { ok: false, reason: 'no-dialog', written: false };
|
|
831
1014
|
}
|
|
832
|
-
|
|
833
|
-
|
|
1015
|
+
// Preferred path: the server RPC snapshot. Absent `client.rpc` (or a
|
|
1016
|
+
// failed RPC read) falls back to the direct-fs path with a visible toast.
|
|
1017
|
+
let rpc = getSettingsRpcClient(host);
|
|
1018
|
+
if (!rpc) degradedRpcToast(host, 'unavailable');
|
|
1019
|
+
let raw;
|
|
1020
|
+
let draft;
|
|
1021
|
+
let startHash;
|
|
1022
|
+
if (rpc) {
|
|
1023
|
+
try {
|
|
1024
|
+
const snap = await rpc.get();
|
|
1025
|
+
if (!snap || typeof snap.config !== 'object') throw new Error('empty snapshot');
|
|
1026
|
+
// R1: provenance uses the RPC's on-disk `raw` (null/absent -> {}),
|
|
1027
|
+
// never the merged effective `config`.
|
|
1028
|
+
raw = snap.raw && typeof snap.raw === 'object' ? snap.raw : {};
|
|
1029
|
+
draft = JSON.parse(JSON.stringify(snap.config));
|
|
1030
|
+
startHash = typeof snap.hash === 'string' ? snap.hash : null;
|
|
1031
|
+
} catch {
|
|
1032
|
+
degradedRpcToast(host, 'read failed');
|
|
1033
|
+
rpc = null;
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
// B2 (phase-03 R6): the malformed-config warning fires on the RPC path
|
|
1037
|
+
// too — the server snapshot degrades to defaults for a corrupt file, and
|
|
1038
|
+
// silent defaults are not B2-compliant. Local fs read, advisory only.
|
|
1039
|
+
if (rpc && isConfigMalformed(baseDir)) {
|
|
834
1040
|
wizardToast(
|
|
835
1041
|
host,
|
|
836
1042
|
'IUMBTEMS settings',
|
|
@@ -838,8 +1044,19 @@ export async function openSettingsWizard(host, root, opts = {}) {
|
|
|
838
1044
|
'Saving will replace the malformed file; cancel writes nothing.'
|
|
839
1045
|
);
|
|
840
1046
|
}
|
|
841
|
-
|
|
842
|
-
|
|
1047
|
+
if (!rpc) {
|
|
1048
|
+
raw = readRawConfig(baseDir);
|
|
1049
|
+
if (isConfigMalformed(baseDir)) {
|
|
1050
|
+
wizardToast(
|
|
1051
|
+
host,
|
|
1052
|
+
'IUMBTEMS settings',
|
|
1053
|
+
`Could not parse ${baseDir}/config.json — showing defaults. ` +
|
|
1054
|
+
'Saving will replace the malformed file; cancel writes nothing.'
|
|
1055
|
+
);
|
|
1056
|
+
}
|
|
1057
|
+
startHash = configHash(baseDir);
|
|
1058
|
+
draft = loadConfig(baseDir);
|
|
1059
|
+
}
|
|
843
1060
|
if (raw && rawHasLegacyPin(raw, draft.backend)) {
|
|
844
1061
|
wizardToast(
|
|
845
1062
|
host,
|
|
@@ -893,6 +1110,56 @@ export async function openSettingsWizard(host, root, opts = {}) {
|
|
|
893
1110
|
return { ok: false, reason: 'dialog-error', written: false };
|
|
894
1111
|
}
|
|
895
1112
|
if (!confirmed) continue;
|
|
1113
|
+
// Preferred write path: the server RPC (validates + hash-guards
|
|
1114
|
+
// server-side, mirrors storage, emits `changed`). Transport failures
|
|
1115
|
+
// fall back to the direct-fs write below.
|
|
1116
|
+
if (rpc) {
|
|
1117
|
+
try {
|
|
1118
|
+
const rpcInput = { updates: draft };
|
|
1119
|
+
if (opts && opts.expected_hash !== undefined) {
|
|
1120
|
+
rpcInput.expected_hash = opts.expected_hash;
|
|
1121
|
+
}
|
|
1122
|
+
if (opts && opts.expectedHash !== undefined) {
|
|
1123
|
+
rpcInput.expectedHash = opts.expectedHash;
|
|
1124
|
+
}
|
|
1125
|
+
if (rpcInput.expected_hash === undefined && rpcInput.expectedHash === undefined) {
|
|
1126
|
+
rpcInput.expectedHash = startHash;
|
|
1127
|
+
}
|
|
1128
|
+
const res = await rpc.set(rpcInput);
|
|
1129
|
+
wizardToast(
|
|
1130
|
+
host,
|
|
1131
|
+
'IUMBTEMS settings',
|
|
1132
|
+
'Saved via server RPC — applies to the next swarm run.'
|
|
1133
|
+
);
|
|
1134
|
+
try {
|
|
1135
|
+
await writeUiPrefs(host, { lastSavedAt: new Date().toISOString() });
|
|
1136
|
+
} catch {
|
|
1137
|
+
/* UI prefs are best-effort */
|
|
1138
|
+
}
|
|
1139
|
+
return { ok: true, written: true, hash: (res && res.hash) || null };
|
|
1140
|
+
} catch (err) {
|
|
1141
|
+
if (err && (err.type === 'stale' || (err.data && err.data.current_hash !== undefined))) {
|
|
1142
|
+
wizardToast(
|
|
1143
|
+
host,
|
|
1144
|
+
'IUMBTEMS settings',
|
|
1145
|
+
'Config changed on disk since it was read; refusing the stale write. ' +
|
|
1146
|
+
'Re-open the wizard for a fresh snapshot — nothing was written.'
|
|
1147
|
+
);
|
|
1148
|
+
return { ok: false, reason: 'stale', written: false };
|
|
1149
|
+
}
|
|
1150
|
+
const failure = err && (err.rpcType || err.type);
|
|
1151
|
+
if (failure === 'invalid' || failure === 'conflict') {
|
|
1152
|
+
wizardToast(
|
|
1153
|
+
host,
|
|
1154
|
+
'IUMBTEMS settings',
|
|
1155
|
+
`Save failed: ${(err && err.message) || err} — nothing was written.`
|
|
1156
|
+
);
|
|
1157
|
+
return { ok: false, reason: 'save-failed', written: false };
|
|
1158
|
+
}
|
|
1159
|
+
degradedRpcToast(host, 'write failed');
|
|
1160
|
+
rpc = null;
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
896
1163
|
const guard =
|
|
897
1164
|
opts && (opts.expected_hash !== undefined || opts.expectedHash !== undefined)
|
|
898
1165
|
? { expected_hash: opts.expected_hash, expectedHash: opts.expectedHash }
|
|
@@ -904,6 +1171,11 @@ export async function openSettingsWizard(host, root, opts = {}) {
|
|
|
904
1171
|
'IUMBTEMS settings',
|
|
905
1172
|
`Saved ${res.path} — applies to the next swarm run.`
|
|
906
1173
|
);
|
|
1174
|
+
try {
|
|
1175
|
+
await writeUiPrefs(host, { lastSavedAt: new Date().toISOString() });
|
|
1176
|
+
} catch {
|
|
1177
|
+
/* UI prefs are best-effort */
|
|
1178
|
+
}
|
|
907
1179
|
return { ok: true, written: true, hash: res.hash };
|
|
908
1180
|
} catch (err) {
|
|
909
1181
|
if (err && err.code === 'CONFIG_STALE') {
|
|
@@ -941,7 +1213,7 @@ export async function openSettingsPanel(host, root, opts = {}) {
|
|
|
941
1213
|
try {
|
|
942
1214
|
const baseDir = researchDirOf(host, root);
|
|
943
1215
|
const env = (opts && opts.env) || process.env;
|
|
944
|
-
const body =
|
|
1216
|
+
const body = await effectiveSettingsBody(host, baseDir, env);
|
|
945
1217
|
let show = null;
|
|
946
1218
|
try {
|
|
947
1219
|
if (typeof host?.ui?.panel === 'function') show = host.ui.panel.bind(host.ui);
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/runner/mcp_server.py
CHANGED
|
@@ -145,6 +145,35 @@ def _allows_null(schema: Dict[str, Any]) -> bool:
|
|
|
145
145
|
return types == "null"
|
|
146
146
|
|
|
147
147
|
|
|
148
|
+
def _legacy_pin_migration_pending(base_dir: str) -> bool:
|
|
149
|
+
"""Would the legacy-pin migration apply on the next write? (read-only)
|
|
150
|
+
|
|
151
|
+
Phase-04 R11: the `show` leg reports this WITHOUT persisting, so a read API
|
|
152
|
+
stays side-effect free. Mirrors `heal_config`'s decision — the on-disk file
|
|
153
|
+
carries a legacy ``["claude", "-p"]`` pin AND the would-be-persisted config
|
|
154
|
+
passes canonical validation (an invalid file is never healed, R1) — but
|
|
155
|
+
never writes.
|
|
156
|
+
"""
|
|
157
|
+
try:
|
|
158
|
+
from skills.swarm_config.configure import (
|
|
159
|
+
load_config,
|
|
160
|
+
migrate_legacy_agent_backends,
|
|
161
|
+
validate_config,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
cfg_file = Path(base_dir) / "config.json"
|
|
165
|
+
if not cfg_file.is_file():
|
|
166
|
+
return False
|
|
167
|
+
raw = json.loads(cfg_file.read_bytes().decode("utf-8"))
|
|
168
|
+
if not isinstance(raw, dict):
|
|
169
|
+
return False
|
|
170
|
+
if not migrate_legacy_agent_backends(raw):
|
|
171
|
+
return False
|
|
172
|
+
return not validate_config(load_config(base_dir))
|
|
173
|
+
except Exception:
|
|
174
|
+
return False
|
|
175
|
+
|
|
176
|
+
|
|
148
177
|
def _handle_config(args: Dict[str, Any]) -> str:
|
|
149
178
|
"""Inspect or modify swarm parameters in .research/config.json.
|
|
150
179
|
|
|
@@ -154,10 +183,11 @@ def _handle_config(args: Dict[str, Any]) -> str:
|
|
|
154
183
|
locked, validated, atomic, unknown-key-preserving) with an optional
|
|
155
184
|
`expected_hash` guard (both `expected_hash` and `expectedHash` spellings).
|
|
156
185
|
"""
|
|
186
|
+
import hashlib
|
|
187
|
+
|
|
157
188
|
from skills.swarm_config.configure import (
|
|
189
|
+
DEFAULT_CONFIG,
|
|
158
190
|
ConfigError,
|
|
159
|
-
config_hash,
|
|
160
|
-
heal_config,
|
|
161
191
|
load_config,
|
|
162
192
|
merge_config,
|
|
163
193
|
save_config,
|
|
@@ -167,10 +197,6 @@ def _handle_config(args: Dict[str, Any]) -> str:
|
|
|
167
197
|
|
|
168
198
|
base_dir = str(_resolve_base_dir(args))
|
|
169
199
|
cfg = load_config(base_dir)
|
|
170
|
-
# Persist a legacy ["claude", "-p"] agent pin migration so an OpenCode host
|
|
171
|
-
# stops silently spawning Claude on every run. `heal_config` refuses to
|
|
172
|
-
# persist a schema-rejected file (R1).
|
|
173
|
-
healed = heal_config(base_dir)
|
|
174
200
|
|
|
175
201
|
# R4: both spellings must agree; never silently prefer one.
|
|
176
202
|
snake = args.get("expected_hash")
|
|
@@ -240,7 +266,16 @@ def _handle_config(args: Dict[str, Any]) -> str:
|
|
|
240
266
|
)
|
|
241
267
|
|
|
242
268
|
if not updates:
|
|
243
|
-
|
|
269
|
+
# R11: the show leg is strictly read-only — a settings panel reads
|
|
270
|
+
# through it, so it must never persist the legacy-pin migration. Report
|
|
271
|
+
# whether a migration is pending; only the write leg below heals.
|
|
272
|
+
return _tool_text(
|
|
273
|
+
{
|
|
274
|
+
"status": "current",
|
|
275
|
+
"config": cfg,
|
|
276
|
+
"migrated": _legacy_pin_migration_pending(base_dir),
|
|
277
|
+
}
|
|
278
|
+
)
|
|
244
279
|
|
|
245
280
|
candidate = merge_config(cfg, updates)
|
|
246
281
|
problems = validate_config(candidate)
|
|
@@ -255,6 +290,11 @@ def _handle_config(args: Dict[str, Any]) -> str:
|
|
|
255
290
|
}
|
|
256
291
|
)
|
|
257
292
|
|
|
293
|
+
# R12 (F1): do NOT heal before saving. The candidate is already migrated in
|
|
294
|
+
# memory (`load_config`) and `save_config` merges it over the on-disk bytes,
|
|
295
|
+
# so the legacy-pin migration persists as a side effect — while the caller's
|
|
296
|
+
# `expected_hash` guard still matches the bytes they read. A heal-before-save
|
|
297
|
+
# rewrote the file and turned a valid guarded write into a false `stale`.
|
|
258
298
|
try:
|
|
259
299
|
save_config(
|
|
260
300
|
candidate,
|
|
@@ -269,12 +309,43 @@ def _handle_config(args: Dict[str, Any]) -> str:
|
|
|
269
309
|
payload.update(exc.details)
|
|
270
310
|
return _tool_text(payload)
|
|
271
311
|
|
|
312
|
+
# R10 (single read): derive BOTH the effective config and the hash from ONE
|
|
313
|
+
# read of the just-published bytes, so the storage mirror and the `changed`
|
|
314
|
+
# event can never receive an era-A config paired with an era-B hash.
|
|
315
|
+
# `save_config` publishes a fully defaults-merged, validated object
|
|
316
|
+
# atomically, so the bytes on disk are already the effective config; the
|
|
317
|
+
# earlier `load_config()` + `config_hash()` were two independent reads.
|
|
318
|
+
cfg_file = Path(base_dir) / "config.json"
|
|
319
|
+
try:
|
|
320
|
+
raw_bytes = cfg_file.read_bytes()
|
|
321
|
+
except OSError:
|
|
322
|
+
raw_bytes = None
|
|
323
|
+
if raw_bytes is None:
|
|
324
|
+
# A successful save always leaves bytes; keep the payload shape without
|
|
325
|
+
# a second read in this (unreachable) defensive branch.
|
|
326
|
+
return _tool_text(
|
|
327
|
+
{
|
|
328
|
+
"status": "updated",
|
|
329
|
+
"config": candidate,
|
|
330
|
+
"written_to": str(cfg_file),
|
|
331
|
+
"hash": None,
|
|
332
|
+
}
|
|
333
|
+
)
|
|
334
|
+
try:
|
|
335
|
+
parsed = json.loads(raw_bytes.decode("utf-8"))
|
|
336
|
+
except (ValueError, UnicodeDecodeError):
|
|
337
|
+
parsed = None
|
|
338
|
+
effective = (
|
|
339
|
+
merge_config(DEFAULT_CONFIG, parsed)
|
|
340
|
+
if isinstance(parsed, dict)
|
|
341
|
+
else merge_config(DEFAULT_CONFIG, {})
|
|
342
|
+
)
|
|
272
343
|
return _tool_text(
|
|
273
344
|
{
|
|
274
345
|
"status": "updated",
|
|
275
|
-
"config":
|
|
276
|
-
"written_to": str(
|
|
277
|
-
"hash":
|
|
346
|
+
"config": effective,
|
|
347
|
+
"written_to": str(cfg_file),
|
|
348
|
+
"hash": hashlib.sha256(raw_bytes).hexdigest(),
|
|
278
349
|
}
|
|
279
350
|
)
|
|
280
351
|
|
package/runner/research_swarm.py
CHANGED
|
@@ -146,7 +146,10 @@ def _verify_threshold(config: Optional[Dict[str, Any]]) -> float:
|
|
|
146
146
|
|
|
147
147
|
|
|
148
148
|
def _config_child_env(
|
|
149
|
-
base_dir: Path,
|
|
149
|
+
base_dir: Path,
|
|
150
|
+
agent_cwd: str,
|
|
151
|
+
config: Optional[Dict[str, Any]],
|
|
152
|
+
role: Optional[str] = None,
|
|
150
153
|
) -> Dict[str, str]:
|
|
151
154
|
"""Environment for a spawned agent child: base env + config passthrough.
|
|
152
155
|
|
|
@@ -158,6 +161,11 @@ def _config_child_env(
|
|
|
158
161
|
* `cache_ttl_days` -> IUMBTEMS_FETCH_TTL_DAYS (webcache, when set)
|
|
159
162
|
* `search_timeout_s` -> IUMBTEMS_SEARCH_TIMEOUT_S (search.py, when set)
|
|
160
163
|
* `searxng_url` -> SEARXNG_URL (search provider + searxng_mcp)
|
|
164
|
+
* `role` -> IUMBTEMS_AGENT_ROLE (S3 temperature spike: lets
|
|
165
|
+
a spawned `opencode run` child know its swarm role so the project
|
|
166
|
+
plugin's `session.hook("context")` can key per-role temperature off
|
|
167
|
+
`event.agent` — if the child loads the project plugin; absent/empty
|
|
168
|
+
role exports nothing)
|
|
161
169
|
"""
|
|
162
170
|
cfg = config or {}
|
|
163
171
|
env: Dict[str, str] = {
|
|
@@ -181,6 +189,8 @@ def _config_child_env(
|
|
|
181
189
|
searxng = cfg.get("searxng_url")
|
|
182
190
|
if isinstance(searxng, str) and searxng.strip():
|
|
183
191
|
env["SEARXNG_URL"] = searxng.strip()
|
|
192
|
+
if role:
|
|
193
|
+
env["IUMBTEMS_AGENT_ROLE"] = str(role)
|
|
184
194
|
return env
|
|
185
195
|
|
|
186
196
|
|
|
@@ -524,7 +534,7 @@ class SwarmRunner:
|
|
|
524
534
|
# IUMBTEMS_RESEARCH_DIR points agent-invoked skill scripts
|
|
525
535
|
# (search.py/hasher.py) at this workspace for retrieval telemetry;
|
|
526
536
|
# the cache/search keys ride the same env channel.
|
|
527
|
-
env=_config_child_env(self.base_dir, agent_cwd, self.config),
|
|
537
|
+
env=_config_child_env(self.base_dir, agent_cwd, self.config, role),
|
|
528
538
|
# stdin MUST be DEVNULL: `opencode run` reads piped stdin to
|
|
529
539
|
# EOF before starting, and the MCP server's inherited stdin
|
|
530
540
|
# pipe is held open by the harness — every agent hung forever
|
package/runner/schemas.py
CHANGED
|
@@ -311,6 +311,7 @@ CONFIG_DEFAULTS: Dict[str, Any] = {
|
|
|
311
311
|
},
|
|
312
312
|
"opencode_auto": None,
|
|
313
313
|
"opencode_agent": None,
|
|
314
|
+
"mcp_servers": {},
|
|
314
315
|
}
|
|
315
316
|
|
|
316
317
|
|
|
@@ -590,6 +591,23 @@ CONFIG: Dict[str, Any] = {
|
|
|
590
591
|
"agents.<role>.opencode_agent wins."
|
|
591
592
|
),
|
|
592
593
|
},
|
|
594
|
+
"mcp_servers": {
|
|
595
|
+
"type": "object",
|
|
596
|
+
"default": copy.deepcopy(CONFIG_DEFAULTS["mcp_servers"]),
|
|
597
|
+
"x-label": "MCP Servers",
|
|
598
|
+
"x-scope": "dispatch",
|
|
599
|
+
"x-restart": "next-run",
|
|
600
|
+
"description": (
|
|
601
|
+
"Persisted toggle map for the plugin-controllable MCP server "
|
|
602
|
+
"set (bundled `iumbtems` server plus research servers declared "
|
|
603
|
+
"in the project OpenCode config). Keys are server names; "
|
|
604
|
+
"values are booleans (true = enabled, false = disabled). "
|
|
605
|
+
"Absent keys mean enabled. Applied via ctx.mcp.transform "
|
|
606
|
+
"(`disabled` reconcile); the catalog file "
|
|
607
|
+
"config/mcp-research-servers.json is never modified."
|
|
608
|
+
),
|
|
609
|
+
"additionalProperties": {"type": "boolean"},
|
|
610
|
+
},
|
|
593
611
|
},
|
|
594
612
|
}
|
|
595
613
|
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|