@heretek-ai/epistemic-swarm 0.7.6 → 0.7.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 (54) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/config/opencode-snippet.json +102 -149
  3. package/install.sh +81 -58
  4. package/package.json +1 -1
  5. package/plugins/opencode/index.js +72 -8
  6. package/runner/__pycache__/__init__.cpython-311.pyc +0 -0
  7. package/runner/__pycache__/auctioneer.cpython-311.pyc +0 -0
  8. package/runner/__pycache__/auditor_engine.cpython-311.pyc +0 -0
  9. package/runner/__pycache__/claim_store.cpython-311.pyc +0 -0
  10. package/runner/__pycache__/claim_witness.cpython-311.pyc +0 -0
  11. package/runner/__pycache__/living_dossiers.cpython-311.pyc +0 -0
  12. package/runner/__pycache__/mcp_protocol.cpython-311.pyc +0 -0
  13. package/runner/__pycache__/mcp_server.cpython-311.pyc +0 -0
  14. package/runner/__pycache__/path_safety.cpython-311.pyc +0 -0
  15. package/runner/__pycache__/pcrb.cpython-311.pyc +0 -0
  16. package/runner/__pycache__/pcrb_verify.cpython-311.pyc +0 -0
  17. package/runner/__pycache__/refinement.cpython-311.pyc +0 -0
  18. package/runner/__pycache__/research_swarm.cpython-311.pyc +0 -0
  19. package/runner/__pycache__/state_machine.cpython-311.pyc +0 -0
  20. package/runner/mcp_server.py +7 -1
  21. package/runner/research_swarm.py +65 -3
  22. package/runner/tests/__pycache__/test_auction_order.cpython-311.pyc +0 -0
  23. package/runner/tests/__pycache__/test_backends.cpython-311.pyc +0 -0
  24. package/runner/tests/__pycache__/test_bet1_spike.cpython-311.pyc +0 -0
  25. package/runner/tests/__pycache__/test_claim_store.cpython-311.pyc +0 -0
  26. package/runner/tests/__pycache__/test_claim_witness.cpython-311.pyc +0 -0
  27. package/runner/tests/__pycache__/test_claude_plugin.cpython-311.pyc +0 -0
  28. package/runner/tests/__pycache__/test_domain_packs.cpython-311.pyc +0 -0
  29. package/runner/tests/__pycache__/test_factory.cpython-311.pyc +0 -0
  30. package/runner/tests/__pycache__/test_fleet_seam.cpython-311.pyc +0 -0
  31. package/runner/tests/__pycache__/test_living_dossiers.cpython-311.pyc +0 -0
  32. package/runner/tests/__pycache__/test_mcp_server.cpython-311.pyc +0 -0
  33. package/runner/tests/__pycache__/test_opencode_ux.cpython-311.pyc +0 -0
  34. package/runner/tests/__pycache__/test_opencode_v2_registrars.cpython-311.pyc +0 -0
  35. package/runner/tests/__pycache__/test_pcrb.cpython-311.pyc +0 -0
  36. package/runner/tests/__pycache__/test_refinement.cpython-311.pyc +0 -0
  37. package/runner/tests/__pycache__/test_swarm.cpython-311.pyc +0 -0
  38. package/runner/tests/__pycache__/test_swarm_config.cpython-311.pyc +0 -0
  39. package/runner/tests/__pycache__/test_sweep_regressions.cpython-311.pyc +0 -0
  40. package/runner/tests/__pycache__/test_webcache.cpython-311.pyc +0 -0
  41. package/runner/tests/test_factory.py +15 -7
  42. package/runner/tests/test_opencode_ux.py +37 -27
  43. package/runner/tests/test_opencode_v2_registrars.py +113 -16
  44. package/runner/tests/test_swarm_config.py +263 -0
  45. package/scripts/__pycache__/bet1_advisory_spike.cpython-311.pyc +0 -0
  46. package/scripts/__pycache__/build_adapters.cpython-311.pyc +0 -0
  47. package/scripts/__pycache__/divergence_experiment.cpython-311.pyc +0 -0
  48. package/skills/epistemic_search/scripts/__pycache__/search.cpython-311.pyc +0 -0
  49. package/skills/research_cache/__pycache__/__init__.cpython-311.pyc +0 -0
  50. package/skills/research_cache/__pycache__/hasher.cpython-311.pyc +0 -0
  51. package/skills/swarm_config/__init__.py +15 -2
  52. package/skills/swarm_config/__pycache__/__init__.cpython-311.pyc +0 -0
  53. package/skills/swarm_config/__pycache__/configure.cpython-311.pyc +0 -0
  54. package/skills/swarm_config/configure.py +103 -6
@@ -84,12 +84,16 @@ def _handle_config(args: Dict[str, Any]) -> str:
84
84
  """Inspect or modify swarm parameters in .research/config.json."""
85
85
  from skills.swarm_config.configure import (
86
86
  display_config,
87
+ heal_config,
87
88
  load_config,
88
89
  save_config,
89
90
  )
90
91
 
91
92
  base_dir = str(_resolve_base_dir(args))
92
93
  cfg = load_config(base_dir)
94
+ # Persist a legacy ["claude", "-p"] agent pin migration so an OpenCode host
95
+ # stops silently spawning Claude on every run.
96
+ healed = heal_config(base_dir)
93
97
 
94
98
  updates = {}
95
99
  for key in (
@@ -99,6 +103,8 @@ def _handle_config(args: Dict[str, Any]) -> str:
99
103
  "depth",
100
104
  "mode",
101
105
  "divergence_threshold",
106
+ "backend",
107
+ "agents",
102
108
  ):
103
109
  if key in args and args[key] is not None:
104
110
  canon = {
@@ -119,7 +125,7 @@ def _handle_config(args: Dict[str, Any]) -> str:
119
125
  }
120
126
  )
121
127
 
122
- return _tool_text({"status": "current", "config": cfg})
128
+ return _tool_text({"status": "current", "config": cfg, "migrated": healed})
123
129
 
124
130
 
125
131
  def _run_swarm_mode(mode: Optional[str], args: Dict[str, Any]) -> str:
@@ -66,6 +66,23 @@ def _backend_family(backend: List[str]) -> str:
66
66
  return Path(backend[0]).name if backend else "claude"
67
67
 
68
68
 
69
+ def _agent_cwd(project_root: Path) -> str:
70
+ """Working directory for spawned agent processes.
71
+
72
+ The canonical agent prompts name evidence paths RELATIVELY (`.research/...`)
73
+ and the runner reads dossiers from `<base_dir>/scratchpads/...`. Spawning in
74
+ the package root made those writes land inside node_modules (denied). Spawn
75
+ in the project root instead so `.research/` means the operator's evidence
76
+ tree. Override with IUMBTEMS_AGENT_CWD=package|project|<absolute path>.
77
+ """
78
+ override = os.environ.get("IUMBTEMS_AGENT_CWD", "").strip()
79
+ if override == "package":
80
+ return str(PROJECT_ROOT)
81
+ if override and override != "project":
82
+ return override
83
+ return str(project_root)
84
+
85
+
69
86
  def _build_opencode_cmd(
70
87
  backend: List[str],
71
88
  prompt: str,
@@ -190,6 +207,13 @@ class SwarmRunner:
190
207
  domain_pack: Optional[str] = None,
191
208
  ):
192
209
  self.base_dir = base_dir or Path(".research")
210
+ # Project root = the directory containing the evidence dir. Agents are
211
+ # spawned here so the relative `.research/...` paths named by the
212
+ # canonical prompts resolve to the RUNNER's evidence tree. Spawning in
213
+ # PROJECT_ROOT (the installed package) sent those writes into
214
+ # node_modules, where they were denied and no agent-authored dossier
215
+ # ever landed — runs survived only on the stdout-recovery fallback.
216
+ self.project_root = Path(os.path.realpath(str(self.base_dir))).parent
193
217
  self.mock_mode = mock_mode
194
218
  self.config = load_config(str(self.base_dir))
195
219
  cfg = self.config
@@ -233,8 +257,46 @@ class SwarmRunner:
233
257
  or _default_backend_cmd(self.config.get("backend"))
234
258
  )
235
259
  model = override.get("model") or env_model or role_cfg.get("model")
260
+ self._warn_backend_host_mismatch(role, backend)
236
261
  return list(backend), model
237
262
 
263
+ @staticmethod
264
+ def _warn_backend_host_mismatch(role: str, backend: List[str]) -> None:
265
+ """Surface a config pin that silently defeats the host-native default.
266
+
267
+ Observed live: a pre-0.7.6 config pin of ["claude", "-p"] beat
268
+ IUMBTEMS_HOST=opencode, so an OpenCode host spawned Claude with no
269
+ signal. Warn loudly instead of failing silently.
270
+ """
271
+ host = os.environ.get("IUMBTEMS_HOST", "").strip().lower()
272
+ if host not in ("claude", "opencode"):
273
+ return
274
+ family = _backend_family(backend)
275
+ if family == host:
276
+ return
277
+ sys.stderr.write(
278
+ f"[swarm] WARNING: role={role} resolved backend={family!r} from an "
279
+ f"explicit pin/override, but IUMBTEMS_HOST={host!r}. The explicit pin "
280
+ f"wins. Set backend: {host!r}, agents.{role}.backend: null, or "
281
+ f"IUMBTEMS_BACKEND_{role.upper()}={host} to follow the host.\n"
282
+ )
283
+
284
+ def _scope_prompt(self, role: str, scope: Dict[str, Any], scope_id: str) -> str:
285
+ """Prompt for a dialectic agent, naming the absolute scratchpad dir.
286
+
287
+ The canonical prompts describe evidence paths RELATIVELY, so a bare
288
+ scope id left the agent guessing which `.research` tree to write to.
289
+ Naming the absolute directory removes the ambiguity even when the
290
+ spawn cwd is overridden.
291
+ """
292
+ return (
293
+ f"Run Agent {role} ({self.mode} mode) for scope: {json.dumps(scope)}. "
294
+ f"Engine: {self.engine}. Depth: {self.depth}. "
295
+ f"Save findings to the scratchpad directory "
296
+ f"{self.state_machine.get_scope_dir(scope_id)} (scope {scope_id}); "
297
+ f"write the dossier files there and nowhere else."
298
+ )
299
+
238
300
  def _resolve_opencode_agent(self, role: str) -> Optional[str]:
239
301
  """Explicitly configured OpenCode agent for this role, else None.
240
302
 
@@ -310,7 +372,7 @@ class SwarmRunner:
310
372
  capture_output=True,
311
373
  text=True,
312
374
  check=True,
313
- cwd=str(PROJECT_ROOT),
375
+ cwd=_agent_cwd(self.project_root),
314
376
  # stdin MUST be DEVNULL: `opencode run` reads piped stdin to
315
377
  # EOF before starting, and the MCP server's inherited stdin
316
378
  # pipe is held open by the harness — every agent hung forever
@@ -619,7 +681,7 @@ Output ONLY valid JSON representing the scope decomposition conforming to prompt
619
681
  "negative_knowledge": [],
620
682
  }
621
683
  else:
622
- prompt = f"Run Agent Alpha ({self.mode} mode) for scope: {json.dumps(scope)}. Engine: {self.engine}. Depth: {self.depth}. Save findings to {scope_id} scratchpad."
684
+ prompt = self._scope_prompt("Alpha", scope, scope_id)
623
685
  if self.mode == "audit":
624
686
  system_prompt = self.prompts_dir / "agent_code_auditor.md"
625
687
  elif self.mode == "scout":
@@ -814,7 +876,7 @@ Output ONLY valid JSON representing the scope decomposition conforming to prompt
814
876
  ],
815
877
  }
816
878
  else:
817
- prompt = f"Run Agent Beta ({self.mode} mode) for scope: {json.dumps(scope)}. Engine: {self.engine}. Depth: {self.depth}. Save findings to {scope_id} scratchpad."
879
+ prompt = self._scope_prompt("Beta", scope, scope_id)
818
880
  if self.mode == "audit":
819
881
  system_prompt = self.prompts_dir / "agent_code_auditor.md"
820
882
  elif self.mode == "scout":
@@ -214,16 +214,24 @@ class TestFactoryHelper(unittest.TestCase):
214
214
  def test_snippet_factory_roster(self):
215
215
  with open(PROJECT_ROOT / "config" / "opencode-snippet.json") as f:
216
216
  snippet = json.load(f)
217
+ # V2 config key is `agents` (plural) and rules are a `permissions` list.
217
218
  for agent in ("manager", "programmer", "qa-a", "qa-b"):
218
- self.assertIn(agent, snippet["agent"])
219
- manager = snippet["agent"]["manager"]
219
+ self.assertIn(agent, snippet["agents"])
220
+
221
+ def rule(agent, action, resource="*"):
222
+ return next(
223
+ r
224
+ for r in snippet["agents"][agent]["permissions"]
225
+ if r["action"] == action and r.get("resource", "*") == resource
226
+ )
227
+
228
+ manager = snippet["agents"]["manager"]
220
229
  self.assertEqual(manager["mode"], "primary")
221
- task = manager["permission"]["task"]
222
- self.assertEqual(task["*"], "deny")
230
+ self.assertEqual(rule("manager", "subagent")["effect"], "deny")
223
231
  for allowed in ("programmer", "qa-a", "qa-b", "brainstormer", "darkharvester"):
224
- self.assertEqual(task[allowed], "allow")
225
- self.assertEqual(snippet["agent"]["qa-a"]["permission"]["edit"], "deny")
226
- self.assertEqual(snippet["agent"]["qa-b"]["permission"]["edit"], "deny")
232
+ self.assertEqual(rule("manager", "subagent", allowed)["effect"], "allow")
233
+ self.assertEqual(rule("qa-a", "edit")["effect"], "deny")
234
+ self.assertEqual(rule("qa-b", "edit")["effect"], "deny")
227
235
 
228
236
  def test_factory_commands_carry_agent_subtask(self):
229
237
  res = run_node(
@@ -30,6 +30,20 @@ EXPECTED_COMMANDS = [
30
30
  "domainexpansion",
31
31
  ]
32
32
 
33
+ EXPECTED_AGENTS = [
34
+ "code-auditor",
35
+ "oss-scout",
36
+ "alpha-thesis",
37
+ "beta-redteam",
38
+ "epistemic-auditor",
39
+ "brainstormer",
40
+ "darkharvester",
41
+ "manager",
42
+ "programmer",
43
+ "qa-a",
44
+ "qa-b",
45
+ ]
46
+
33
47
 
34
48
  def run_node(code):
35
49
  return subprocess.run(
@@ -259,48 +273,44 @@ class TestInstallOpenCode(unittest.TestCase):
259
273
  self.assertEqual(first.returncode, 0, f"install failed: {first.stderr}")
260
274
  oc_path = home / ".config" / "opencode" / "opencode.json"
261
275
  cfg = json.loads(oc_path.read_text(encoding="utf-8"))
262
- # Bare object repaired into tuple form, preserving user options.
263
- tuples = [
276
+ # V1 `plugin` migrated to V2 `plugins` in object form; the trailing
277
+ # bare options object is folded in and user options are preserved.
278
+ self.assertNotIn("plugin", cfg)
279
+ self.assertFalse(
280
+ any(isinstance(p, list) for p in cfg["plugins"]),
281
+ f"V2 plugins must not use tuple form: {cfg['plugins']}",
282
+ )
283
+ ours = [
264
284
  p
265
- for p in cfg["plugin"]
266
- if isinstance(p, list) and p and p[0] == "@heretek-ai/epistemic-swarm"
285
+ for p in cfg["plugins"]
286
+ if isinstance(p, dict)
287
+ and p.get("package") == "@heretek-ai/epistemic-swarm"
267
288
  ]
268
- self.assertEqual(len(tuples), 1)
289
+ self.assertEqual(len(ours), 1)
269
290
  self.assertEqual(
270
- tuples[0][1],
291
+ ours[0]["options"],
271
292
  {
272
293
  "search_engine": "duckduckgo",
273
294
  "max_iterations": 10,
274
295
  "mode": "research",
275
296
  },
276
297
  )
277
- self.assertFalse(any(isinstance(p, dict) for p in cfg["plugin"]))
278
- # Agents merged from the snippet, MCP server absolute.
279
- for agent in [
280
- "code-auditor",
281
- "oss-scout",
282
- "alpha-thesis",
283
- "beta-redteam",
284
- "epistemic-auditor",
285
- "brainstormer",
286
- "darkharvester",
287
- "manager",
288
- "programmer",
289
- "qa-a",
290
- "qa-b",
291
- ]:
292
- self.assertIn(agent, cfg.get("agent", {}))
293
- self.assertIn("iumbtems", cfg.get("mcp", {}))
294
- cmd = cfg["mcp"]["iumbtems"]["command"]
298
+ self.assertIn("@prevalentware/opencode-goal-plugin", cfg["plugins"])
299
+ # Agents merged from the snippet under the V2 plural key.
300
+ for agent in EXPECTED_AGENTS:
301
+ self.assertIn(agent, cfg.get("agents", {}))
302
+ # MCP server absolute under V2 mcp.servers (no legacy top-level key).
303
+ self.assertNotIn("iumbtems", cfg["mcp"])
304
+ cmd = cfg["mcp"]["servers"]["iumbtems"]["command"]
295
305
  self.assertTrue(Path(cmd[-1]).is_absolute())
296
306
  self.assertEqual(cmd[-1], str(PROJECT_ROOT / "runner" / "mcp_server.py"))
297
- # Skills merged as absolute repo paths (relative entries repaired).
298
- skill_paths = cfg.get("skills", {}).get("paths", [])
307
+ # Skills merged as a V2 list of absolute repo paths.
308
+ skill_paths = cfg.get("skills")
309
+ self.assertIsInstance(skill_paths, list)
299
310
  self.assertTrue(skill_paths)
300
311
  for sp in skill_paths:
301
312
  self.assertTrue(Path(sp).is_absolute(), f"relative skill path: {sp}")
302
313
  self.assertIn(str(PROJECT_ROOT / "skills" / "grilling"), skill_paths)
303
- self.assertFalse(any(not Path(sp).is_absolute() for sp in skill_paths))
304
314
  # Second run changes nothing.
305
315
  before = oc_path.read_bytes()
306
316
  second = self._run_install(home)
@@ -7,6 +7,12 @@ ran as the generic `build` agent, and skills were undiscoverable — the agent
7
7
  ran `find / -name factory.py` to learn its own mechanics. These tests pin the
8
8
  plugin-side fix: profiles and skills are registered through the V2 domains, and
9
9
  the command switches the session agent.
10
+
11
+ Regression (0.7.6, opencode v2.0.18): the mock below used to hand the plugin an
12
+ `{add}` agent editor. The real V2 `AgentEditor` has NO `add()` — only
13
+ `list/get/default/update/remove`, where `update()` upserts. The plugin's
14
+ `draft.add(...)` threw `draft.add is not a function` and the host disabled the
15
+ whole plugin. The mock now models the real editor so that defect cannot pass.
10
16
  """
11
17
 
12
18
  import json
@@ -16,21 +22,71 @@ from pathlib import Path
16
22
 
17
23
  PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
18
24
 
19
- V2_HOST = """
20
- const reg = {agents: [], skills: [], commands: [], tools: [], prompts: [], switches: []};
25
+ # Faithful V2 AgentEditor: `update(id, fn)` upserts, seeding a new entry from
26
+ # Agent.Info.default(id) (mode primary, allow-list) + global rules, exactly as
27
+ # the opencode v2.0.18 agent registry does. No `add`.
28
+ AGENT_EDITOR_JS = """
29
+ function makeAgentEditor(reg) {
30
+ const store = new Map();
31
+ return {
32
+ store,
33
+ editor: {
34
+ list: () => [...store.values()],
35
+ get: (id) => store.get(id),
36
+ default: (id) => { reg.defaultAgent = id; },
37
+ update: (id, fn) => {
38
+ reg.agentUpdates.push(id);
39
+ let agent = store.get(id);
40
+ if (!agent) {
41
+ agent = {
42
+ id, name: id,
43
+ request: {settings: {}, headers: {}, body: {}},
44
+ mode: 'primary', hidden: false,
45
+ permissions: [{action: '*', resource: '*', effect: 'allow'}],
46
+ };
47
+ store.set(id, agent);
48
+ }
49
+ fn(agent);
50
+ agent.id = id;
51
+ },
52
+ remove: (id) => { store.delete(id); },
53
+ },
54
+ };
55
+ }
56
+ function seedAgent(box, id) {
57
+ box.store.set(id, {
58
+ id, name: id,
59
+ request: {settings: {}, headers: {}, body: {}},
60
+ mode: 'primary', hidden: false,
61
+ permissions: [{action: '*', resource: '*', effect: 'allow'}],
62
+ });
63
+ }
64
+ """
65
+
66
+ V2_HOST = (
67
+ AGENT_EDITOR_JS
68
+ + """
69
+ const reg = {skills: [], commands: [], tools: [], prompts: [], switches: [], agentUpdates: [], defaultAgent: undefined};
70
+ const agentBox = makeAgentEditor(reg);
21
71
  const host = {
22
72
  options: {},
23
73
  location: {directory: '/home/john/Projects/STC'},
24
74
  command: {list: async()=>({data:[]}), transform: async(fn)=>{fn({add:(x)=>{reg.commands.push(x);}}); return {dispose:()=>{}};}, reload: async()=>{}},
25
75
  tool: {transform: async(fn)=>{fn({add:(t)=>{reg.tools.push(t);}}); return {dispose:()=>{}};}, reload: async()=>{}},
26
- agent: {list: async()=>({data:[]}), transform: async(fn)=>{fn({add:(a)=>{reg.agents.push(a);}}); return {dispose:()=>{}};}, reload: async()=>{}},
76
+ agent: {
77
+ list: async()=>({data: [...agentBox.store.values()].map((a)=>({id: a.id, name: a.name}))}),
78
+ transform: async(fn)=>{fn(agentBox.editor); return {dispose:()=>{}};},
79
+ reload: async()=>{}
80
+ },
27
81
  skill: {list: async()=>({data:[]}), transform: async(fn)=>{fn({add:(s)=>{reg.skills.push(s);}}); return {dispose:()=>{}};}, reload: async()=>{}},
28
82
  session: {
29
83
  prompt: async(p)=>{reg.prompts.push(p); return {};},
30
84
  switchAgent: async(a)=>{reg.switches.push(a); return {};}
31
85
  }
32
86
  };
87
+ const agents = () => [...agentBox.store.values()];
33
88
  """
89
+ )
34
90
 
35
91
 
36
92
  def run_node(code):
@@ -54,16 +110,17 @@ class TestV2Registration(unittest.TestCase):
54
110
  + """
55
111
  import plugin from "./plugins/opencode/index.js";
56
112
  await plugin.setup(host);
57
- const manager = reg.agents.find(a => a.id === 'manager');
58
- const qa = reg.agents.find(a => a.id === 'qa-a');
113
+ const manager = agents().find(a => a.id === 'manager');
114
+ const qa = agents().find(a => a.id === 'qa-a');
59
115
  const factory = reg.skills.find(s => s.id === 'factory');
60
116
  console.log(JSON.stringify({
61
- agentIds: reg.agents.map(a => a.id).sort(),
117
+ agentIds: agents().map(a => a.id).sort(),
62
118
  managerMode: manager?.mode,
63
119
  managerSystem: Boolean(manager?.system),
64
120
  managerPermissions: manager?.permissions?.length || 0,
65
121
  managerDeniesEdit: (manager?.permissions || []).some(p => p.action === 'edit' && p.effect === 'deny'),
66
122
  qaMode: qa?.mode,
123
+ qaDeniesEdit: (qa?.permissions || []).some(p => p.action === 'edit' && p.effect === 'deny'),
67
124
  skillIds: reg.skills.map(s => s.id).sort(),
68
125
  factoryHasContent: Boolean(factory?.content?.includes('Factory')),
69
126
  factoryPath: factory?.path,
@@ -85,11 +142,46 @@ class TestV2Registration(unittest.TestCase):
85
142
  self.assertTrue(d["managerSystem"])
86
143
  self.assertTrue(d["managerDeniesEdit"], "manager must not edit code")
87
144
  self.assertEqual(d["qaMode"], "subagent")
145
+ self.assertTrue(d["qaDeniesEdit"], "qa must not edit code")
88
146
  self.assertIn("factory", d["skillIds"])
89
147
  self.assertIn("darkharvest", d["skillIds"])
90
148
  self.assertTrue(d["factoryHasContent"])
91
149
  self.assertTrue(d["factoryPath"].endswith("skills/factory/SKILL.md"))
92
150
 
151
+ def test_agent_editor_never_calls_add(self):
152
+ """Pin the exact 0.7.6 failure: AgentEditor has no `add`."""
153
+ res = run_node(
154
+ """
155
+ const reg = {agentUpdates: []};
156
+ const editor = {
157
+ list: () => [],
158
+ get: () => undefined,
159
+ default: () => {},
160
+ update: (id, fn) => {
161
+ reg.agentUpdates.push(id);
162
+ fn({id, permissions: []});
163
+ },
164
+ remove: () => {},
165
+ // intentionally NO add
166
+ };
167
+ const host = {
168
+ options: {}, location: {directory: '/tmp'},
169
+ command: {list: async()=>({data:[]}), transform: async(fn)=>{fn({add:()=>{}}); return {dispose:()=>{}};}, reload: async()=>{}},
170
+ tool: {transform: async()=>({dispose:()=>{}}), reload: async()=>{}},
171
+ agent: {list: async()=>({data:[]}), transform: async(fn)=>{fn(editor); return {dispose:()=>{}};}, reload: async()=>{}},
172
+ skill: {list: async()=>({data:[]}), transform: async(fn)=>{fn({add:()=>{}}); return {dispose:()=>{}};}, reload: async()=>{}},
173
+ session: {prompt: async()=>({})}
174
+ };
175
+ import plugin from "./plugins/opencode/index.js";
176
+ await plugin.setup(host);
177
+ console.log(JSON.stringify({agentUpdates: reg.agentUpdates}));
178
+ """
179
+ )
180
+ self.assertEqual(res.returncode, 0, res.stderr)
181
+ d = last_json_object(res.stdout)
182
+ self.assertIn("manager", d["agentUpdates"])
183
+ self.assertIn("qa-b", d["agentUpdates"])
184
+
93
185
  def test_command_switches_session_agent(self):
94
186
  res = run_node(
95
187
  V2_HOST
@@ -100,7 +192,7 @@ class TestV2Registration(unittest.TestCase):
100
192
  await factory.execute({sessionID: 'ses_test', prompt: {text: 'Cockpit beta'}, delivery: 'steer'});
101
193
  console.log(JSON.stringify({
102
194
  switches: reg.switches,
103
- promptAgent: reg.prompts[0]?.agent,
195
+ promptAgentKey: Object.prototype.hasOwnProperty.call(reg.prompts[0] || {}, 'agent'),
104
196
  promptHasArena: reg.prompts[0]?.text?.includes('Cockpit beta'),
105
197
  }));
106
198
  """
@@ -108,39 +200,44 @@ class TestV2Registration(unittest.TestCase):
108
200
  self.assertEqual(res.returncode, 0, res.stderr)
109
201
  d = last_json_object(res.stdout)
110
202
  self.assertTrue(d["switches"], "switchAgent was never called")
111
- self.assertEqual(d["promptAgent"], "manager")
203
+ self.assertEqual(d["switches"][0]["agent"], "manager")
204
+ # V2 SessionPromptInput has no `agent` field; pinning is switchAgent's job.
205
+ self.assertFalse(d["promptAgentKey"])
112
206
  self.assertTrue(d["promptHasArena"])
113
207
 
114
208
  def test_preexisting_agent_and_skill_preserved(self):
115
209
  res = run_node(
116
- """
117
- const agents = [], skills = [];
210
+ AGENT_EDITOR_JS
211
+ + """
212
+ const reg = {agentUpdates: [], skills: []};
213
+ const agentBox = makeAgentEditor(reg);
214
+ seedAgent(agentBox, 'manager'); // user-defined manager wins
118
215
  const host = {
119
216
  options: {}, location: {directory: '/tmp'},
120
217
  command: {list: async()=>({data:[]}), transform: async(fn)=>{fn({add:()=>{}}); return {dispose:()=>{}};}, reload: async()=>{}},
121
218
  tool: {transform: async()=>({dispose:()=>{}}), reload: async()=>{}},
122
219
  agent: {
123
- list: async()=>({data:[{id:'manager'}]}),
124
- transform: async(fn)=>{fn({add:(a)=>{agents.push(a.id);}}); return {dispose:()=>{}};},
220
+ list: async()=>({data: [...agentBox.store.values()].map((a)=>({id: a.id}))}),
221
+ transform: async(fn)=>{fn(agentBox.editor); return {dispose:()=>{}};},
125
222
  reload: async()=>{}
126
223
  },
127
224
  skill: {
128
225
  list: async()=>({data:[{id:'factory'}]}),
129
- transform: async(fn)=>{fn({add:(s)=>{skills.push(s.id);}}); return {dispose:()=>{}};},
226
+ transform: async(fn)=>{fn({add:(s)=>{reg.skills.push(s.id);}}); return {dispose:()=>{}};},
130
227
  reload: async()=>{}
131
228
  },
132
229
  session: {prompt: async()=>({})}
133
230
  };
134
231
  import plugin from "./plugins/opencode/index.js";
135
232
  await plugin.setup(host);
136
- console.log(JSON.stringify({agents, skills}));
233
+ console.log(JSON.stringify({agentUpdates: reg.agentUpdates, skills: reg.skills}));
137
234
  """
138
235
  )
139
236
  self.assertEqual(res.returncode, 0, res.stderr)
140
237
  d = last_json_object(res.stdout)
141
- self.assertNotIn("manager", d["agents"], "user-defined manager must win")
238
+ self.assertNotIn("manager", d["agentUpdates"], "user-defined manager must win")
142
239
  self.assertNotIn("factory", d["skills"], "user-defined factory skill must win")
143
- self.assertIn("qa-a", d["agents"])
240
+ self.assertIn("qa-a", d["agentUpdates"])
144
241
 
145
242
  def test_graceful_without_agent_skill_domains(self):
146
243
  res = run_node(