@try-works/dsh-recursive-mode 0.1.0

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 (88) hide show
  1. package/cordis.patch.yml +12 -0
  2. package/lib/bootstrap.d.ts +35 -0
  3. package/lib/client/board.d.ts +10 -0
  4. package/lib/client/contract.d.ts +51 -0
  5. package/lib/client/derive.d.ts +92 -0
  6. package/lib/client/index.d.ts +21 -0
  7. package/lib/client/inspector.d.ts +10 -0
  8. package/lib/client/node.d.ts +71 -0
  9. package/lib/client/settings.d.ts +6 -0
  10. package/lib/client/slots.d.ts +7 -0
  11. package/lib/client/strip.d.ts +7 -0
  12. package/lib/client.d.ts +10 -0
  13. package/lib/client.js +490 -0
  14. package/lib/closeout.d.ts +23 -0
  15. package/lib/commands.d.ts +51 -0
  16. package/lib/delegation.d.ts +92 -0
  17. package/lib/enforcement.d.ts +53 -0
  18. package/lib/events.d.ts +173 -0
  19. package/lib/handoff.d.ts +51 -0
  20. package/lib/index.d.ts +40 -0
  21. package/lib/lifecycle.d.ts +107 -0
  22. package/lib/lock.d.ts +92 -0
  23. package/lib/policy.d.ts +12 -0
  24. package/lib/projection.d.ts +29 -0
  25. package/lib/recursive_closeout.tool.d.ts +8 -0
  26. package/lib/recursive_init.tool.d.ts +2 -0
  27. package/lib/recursive_lint.tool.d.ts +2 -0
  28. package/lib/recursive_lock.tool.d.ts +2 -0
  29. package/lib/recursive_scratch.tool.d.ts +7 -0
  30. package/lib/recursive_status.tool.d.ts +2 -0
  31. package/lib/review.d.ts +39 -0
  32. package/lib/router.d.ts +77 -0
  33. package/lib/run.d.ts +29 -0
  34. package/lib/runtime.d.ts +241 -0
  35. package/lib/scratch.d.ts +18 -0
  36. package/lib/status.d.ts +19 -0
  37. package/lib/types.d.ts +104 -0
  38. package/lib/workspace.d.ts +50 -0
  39. package/package.json +119 -0
  40. package/preset/recursive/agent.cordis.yml +282 -0
  41. package/preset/recursive/preset.yml +3 -0
  42. package/scripts/install-recursive-mode.ps1 +956 -0
  43. package/scripts/install-recursive-mode.py +750 -0
  44. package/scripts/lint-recursive-run.py +2868 -0
  45. package/scripts/recursive-closeout.py +541 -0
  46. package/scripts/recursive-init.py +356 -0
  47. package/scripts/recursive-lock.py +302 -0
  48. package/scripts/recursive-status.py +2124 -0
  49. package/scripts/recursive_phase_rules.py +367 -0
  50. package/scripts/recursive_router_lib.py +2282 -0
  51. package/scripts/test-recursive-mode-smoke.ts +204 -0
  52. package/scripts/verify-locks.py +353 -0
  53. package/src/bootstrap.ts +118 -0
  54. package/src/client/board.tsx +61 -0
  55. package/src/client/contract.ts +58 -0
  56. package/src/client/derive.ts +241 -0
  57. package/src/client/index.ts +28 -0
  58. package/src/client/inspector.tsx +49 -0
  59. package/src/client/node.ts +156 -0
  60. package/src/client/settings.tsx +18 -0
  61. package/src/client/slots.ts +67 -0
  62. package/src/client/strip.tsx +28 -0
  63. package/src/client.ts +11 -0
  64. package/src/closeout.ts +183 -0
  65. package/src/commands.ts +142 -0
  66. package/src/delegation.ts +306 -0
  67. package/src/enforcement.ts +180 -0
  68. package/src/events.ts +173 -0
  69. package/src/handoff.ts +165 -0
  70. package/src/index.ts +283 -0
  71. package/src/lifecycle.ts +235 -0
  72. package/src/lock.ts +369 -0
  73. package/src/policy.ts +56 -0
  74. package/src/projection.ts +237 -0
  75. package/src/recursive_closeout.tool.ts +35 -0
  76. package/src/recursive_init.tool.ts +28 -0
  77. package/src/recursive_lint.tool.ts +29 -0
  78. package/src/recursive_lock.tool.ts +33 -0
  79. package/src/recursive_scratch.tool.ts +42 -0
  80. package/src/recursive_status.tool.ts +24 -0
  81. package/src/review.ts +178 -0
  82. package/src/router.ts +197 -0
  83. package/src/run.ts +85 -0
  84. package/src/runtime.ts +564 -0
  85. package/src/scratch.ts +85 -0
  86. package/src/status.ts +194 -0
  87. package/src/types.ts +112 -0
  88. package/src/workspace.ts +67 -0
@@ -0,0 +1,2282 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Shared helpers for recursive-router scripts.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import os
10
+ import queue
11
+ import re
12
+ import shutil
13
+ import subprocess
14
+ import tempfile
15
+ import threading
16
+ import time
17
+ import tomllib
18
+ import uuid
19
+ from copy import deepcopy
20
+ from dataclasses import dataclass
21
+ from datetime import datetime, timezone
22
+ from pathlib import Path
23
+
24
+
25
+ CANONICAL_ROLES = [
26
+ "orchestrator",
27
+ "analyst",
28
+ "planner",
29
+ "implementer",
30
+ "code-reviewer",
31
+ "tester",
32
+ "memory-auditor",
33
+ ]
34
+ LEGACY_ROLE_ALIASES = {
35
+ "phase-auditor": "analyst",
36
+ "traceability-auditor": "planner",
37
+ "bounded-implementer": "implementer",
38
+ "test-reviewer": "tester",
39
+ }
40
+ DEFAULT_ROLE_ROUTE_SPECS = {
41
+ "orchestrator": {
42
+ "enabled": True,
43
+ "mode": "local-only",
44
+ "cli": None,
45
+ "model": None,
46
+ "fallback": "local-controller",
47
+ },
48
+ "analyst": {
49
+ "enabled": True,
50
+ "mode": "external-cli",
51
+ "cli": None,
52
+ "model": None,
53
+ "fallback": "self-audit",
54
+ },
55
+ "planner": {
56
+ "enabled": True,
57
+ "mode": "external-cli",
58
+ "cli": None,
59
+ "model": None,
60
+ "fallback": "self-audit",
61
+ },
62
+ "implementer": {
63
+ "enabled": False,
64
+ "mode": "external-cli",
65
+ "cli": None,
66
+ "model": None,
67
+ "fallback": "local-controller",
68
+ },
69
+ "code-reviewer": {
70
+ "enabled": True,
71
+ "mode": "external-cli",
72
+ "cli": None,
73
+ "model": None,
74
+ "fallback": "self-audit",
75
+ },
76
+ "tester": {
77
+ "enabled": True,
78
+ "mode": "external-cli",
79
+ "cli": None,
80
+ "model": None,
81
+ "fallback": "self-audit",
82
+ },
83
+ "memory-auditor": {
84
+ "enabled": True,
85
+ "mode": "external-cli",
86
+ "cli": None,
87
+ "model": None,
88
+ "fallback": "self-audit",
89
+ },
90
+ }
91
+ CONFIG_VERSION = 1
92
+ DISCOVERY_VERSION = 1
93
+ ROUTER_NAME = "recursive-router"
94
+ LEGACY_ROUTER_NAME = "recursive-router-cli"
95
+ ROUTER_POLICY_FILENAME = "recursive-router.json"
96
+ LEGACY_ROUTER_POLICY_FILENAME = "recursive-router-cli.json"
97
+ ROUTER_DISCOVERY_FILENAME = "recursive-router-discovered.json"
98
+ LEGACY_ROUTER_DISCOVERY_FILENAME = "recursive-router-cli-discovered.json"
99
+ ALLOWED_ROLE_UNCONFIGURED = {"ask", "fallback-local", "block"}
100
+ ALLOWED_CLI_UNAVAILABLE = {"fallback-local", "ask", "block"}
101
+ ALLOWED_MODEL_UNKNOWN = {"ask", "use-as-literal", "fallback-local"}
102
+ ALLOWED_ROLE_MODES = {"external-cli", "local-only"}
103
+ ALLOWED_ROLE_FALLBACKS = {"self-audit", "local-controller", "ask", "block"}
104
+ ALLOWED_PROBE_STATUS = {"ok", "partial", "failed"}
105
+ ALLOWED_MODEL_SOURCE = {
106
+ "app-server-model-list",
107
+ "cli-list",
108
+ "adapter-defaults",
109
+ "cache-file",
110
+ "configured-static-list",
111
+ "config-file",
112
+ "unsupported",
113
+ "failed",
114
+ "unknown",
115
+ }
116
+
117
+
118
+ class RouterConfigError(ValueError):
119
+ """Raised when the router policy is invalid."""
120
+
121
+
122
+ @dataclass(frozen=True)
123
+ class CLIAdapter:
124
+ id: str
125
+ command: str | tuple[str, ...]
126
+ probe_args: tuple[str, ...] = ("--version",)
127
+ model_list_template: tuple[str, ...] | None = None
128
+ builtin: bool = True
129
+ default_models: tuple[str, ...] = ()
130
+ invoke_template: tuple[str, ...] = ()
131
+ transport: str = "cli-template"
132
+
133
+
134
+ BUILTIN_ADAPTERS: tuple[CLIAdapter, ...] = (
135
+ CLIAdapter(
136
+ id="codex",
137
+ command="codex",
138
+ invoke_template=("exec", "--model", "{model}", "--input-file", "{prompt_file}"),
139
+ transport="app-server",
140
+ ),
141
+ CLIAdapter(
142
+ id="kimi",
143
+ command="kimi",
144
+ invoke_template=(
145
+ "--model",
146
+ "{model}",
147
+ "--work-dir",
148
+ "{repo_root}",
149
+ "--print",
150
+ "--output-format",
151
+ "stream-json",
152
+ "--max-ralph-iterations",
153
+ "0",
154
+ "--prompt",
155
+ "{prompt}",
156
+ ),
157
+ ),
158
+ CLIAdapter(
159
+ id="opencode",
160
+ command="opencode",
161
+ model_list_template=("models",),
162
+ invoke_template=("run", "--model", "{model}", "--prompt-file", "{prompt_file}"),
163
+ ),
164
+ )
165
+ BUILTIN_ADAPTER_BY_ID = {adapter.id: adapter for adapter in BUILTIN_ADAPTERS}
166
+
167
+
168
+ def utc_now_iso() -> str:
169
+ return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
170
+
171
+
172
+ def normalize_repo_path(raw_path: str) -> str:
173
+ return "/" + raw_path.replace("\\", "/").strip().lstrip("/")
174
+
175
+
176
+ def canonicalize_router_role(role: str) -> str:
177
+ normalized = role.strip()
178
+ if normalized in CANONICAL_ROLES:
179
+ return normalized
180
+ legacy = LEGACY_ROLE_ALIASES.get(normalized)
181
+ if legacy is not None:
182
+ return legacy
183
+ raise RouterConfigError(f"Unknown router role: {role}")
184
+
185
+
186
+ def default_role_routes() -> dict[str, dict[str, object]]:
187
+ return {role: deepcopy(route) for role, route in DEFAULT_ROLE_ROUTE_SPECS.items()}
188
+
189
+
190
+ def router_config_dir(repo_root: Path) -> Path:
191
+ return repo_root / ".recursive" / "config"
192
+
193
+
194
+ def router_policy_path(repo_root: Path) -> Path:
195
+ return router_config_dir(repo_root) / ROUTER_POLICY_FILENAME
196
+
197
+
198
+ def legacy_router_policy_path(repo_root: Path) -> Path:
199
+ return router_config_dir(repo_root) / LEGACY_ROUTER_POLICY_FILENAME
200
+
201
+
202
+ def router_discovery_path(repo_root: Path) -> Path:
203
+ return router_config_dir(repo_root) / ROUTER_DISCOVERY_FILENAME
204
+
205
+
206
+ def legacy_router_discovery_path(repo_root: Path) -> Path:
207
+ return router_config_dir(repo_root) / LEGACY_ROUTER_DISCOVERY_FILENAME
208
+
209
+
210
+ def default_router_policy() -> dict[str, object]:
211
+ return {
212
+ "version": CONFIG_VERSION,
213
+ "defaults": {
214
+ "when_role_unconfigured": "ask",
215
+ "when_cli_unavailable": "fallback-local",
216
+ "when_model_unknown": "ask",
217
+ "allow_auto_assign_if_single_cli": False,
218
+ "probe_timeout_ms": 50000,
219
+ "invoke_timeout_ms": 180000,
220
+ },
221
+ "role_routes": default_role_routes(),
222
+ "cli_overrides": {},
223
+ "custom_clis": [],
224
+ }
225
+
226
+
227
+ def empty_discovery_inventory(*, probe_tool: str, probe_status: str = "partial", clis: list[dict[str, object]] | None = None) -> dict[str, object]:
228
+ return {
229
+ "version": DISCOVERY_VERSION,
230
+ "generated_at": utc_now_iso(),
231
+ "probe_tool": probe_tool,
232
+ "probe_status": probe_status,
233
+ "clis": clis or [],
234
+ }
235
+
236
+
237
+ def pretty_json(payload: object) -> str:
238
+ return json.dumps(payload, indent=2, ensure_ascii=True) + "\n"
239
+
240
+
241
+ def write_json(path: Path, payload: object) -> None:
242
+ path.parent.mkdir(parents=True, exist_ok=True)
243
+ path.write_text(pretty_json(payload), encoding="utf-8", newline="\n")
244
+
245
+
246
+ def read_json(path: Path) -> object:
247
+ return json.loads(path.read_text(encoding="utf-8"))
248
+
249
+
250
+ def load_policy(repo_root: Path) -> dict[str, object]:
251
+ policy_path = router_policy_path(repo_root)
252
+ if not policy_path.exists():
253
+ legacy_policy = legacy_router_policy_path(repo_root)
254
+ if legacy_policy.exists():
255
+ policy_path = legacy_policy
256
+ else:
257
+ raise RouterConfigError(f"Missing routing policy file: {normalize_repo_path(str(policy_path.relative_to(repo_root)))}")
258
+ try:
259
+ policy = read_json(policy_path)
260
+ except json.JSONDecodeError as exc:
261
+ raise RouterConfigError(f"Routing policy file is not valid JSON: {exc}") from exc
262
+ return validate_policy(policy)
263
+
264
+
265
+ def ensure_router_scaffold(repo_root: Path) -> tuple[Path, Path]:
266
+ config_dir = router_config_dir(repo_root)
267
+ config_dir.mkdir(parents=True, exist_ok=True)
268
+ policy_path = router_policy_path(repo_root)
269
+ discovery_path = router_discovery_path(repo_root)
270
+ legacy_policy_path = legacy_router_policy_path(repo_root)
271
+ legacy_discovery_path = legacy_router_discovery_path(repo_root)
272
+
273
+ if policy_path.exists():
274
+ try:
275
+ existing_raw = read_json(policy_path)
276
+ except json.JSONDecodeError as exc:
277
+ raise RouterConfigError(f"Existing routing policy is invalid JSON and will not be overwritten: {exc}") from exc
278
+ existing = validate_policy(existing_raw)
279
+ if existing != existing_raw:
280
+ write_json(policy_path, existing)
281
+ elif legacy_policy_path.exists():
282
+ try:
283
+ existing_raw = read_json(legacy_policy_path)
284
+ except json.JSONDecodeError as exc:
285
+ raise RouterConfigError(f"Existing legacy routing policy is invalid JSON and will not be migrated: {exc}") from exc
286
+ existing = validate_policy(existing_raw)
287
+ write_json(policy_path, existing)
288
+ legacy_policy_path.unlink(missing_ok=True)
289
+ else:
290
+ write_json(policy_path, default_router_policy())
291
+
292
+ if discovery_path.exists():
293
+ pass
294
+ elif legacy_discovery_path.exists():
295
+ legacy_discovery_path.replace(discovery_path)
296
+
297
+ return policy_path, discovery_path
298
+
299
+
300
+ def _as_dict(payload: object, label: str) -> dict[str, object]:
301
+ if not isinstance(payload, dict):
302
+ raise RouterConfigError(f"{label} must be a JSON object.")
303
+ return payload
304
+
305
+
306
+ def _as_list_of_strings(value: object, field_name: str, *, allow_empty: bool = True) -> list[str]:
307
+ if not isinstance(value, list) or any(not isinstance(item, str) for item in value):
308
+ raise RouterConfigError(f"{field_name} must be a list of strings.")
309
+ if not allow_empty and not value:
310
+ raise RouterConfigError(f"{field_name} must not be empty.")
311
+ return list(value)
312
+
313
+
314
+ def _normalize_command_spec(value: object, field_name: str) -> str | tuple[str, ...]:
315
+ if isinstance(value, str):
316
+ command = value.strip()
317
+ if not command:
318
+ raise RouterConfigError(f"{field_name} must be a non-empty string when provided.")
319
+ return command
320
+ if isinstance(value, list) and value and all(isinstance(item, str) and item.strip() for item in value):
321
+ return tuple(item.strip() for item in value)
322
+ raise RouterConfigError(f"{field_name} must be a non-empty string or a non-empty list of strings.")
323
+
324
+
325
+ def _validate_invoke_template(template: list[str], field_name: str) -> None:
326
+ if not any("{model}" in piece for piece in template):
327
+ raise RouterConfigError(f"{field_name} must contain a {{model}} placeholder.")
328
+ if not any("{prompt_file}" in piece or "{prompt}" in piece for piece in template):
329
+ raise RouterConfigError(f"{field_name} must contain either a {{prompt_file}} or {{prompt}} placeholder.")
330
+
331
+
332
+ def normalize_role_routes(role_routes_payload: object) -> dict[str, object]:
333
+ raw_role_routes = _as_dict(role_routes_payload, "role_routes")
334
+ normalized_routes: dict[str, object] = default_role_routes()
335
+ sources_by_canonical: dict[str, str] = {}
336
+ for role_name, route_payload in raw_role_routes.items():
337
+ if not isinstance(role_name, str):
338
+ raise RouterConfigError("role_routes keys must be strings.")
339
+ canonical_role = canonicalize_router_role(role_name)
340
+ prior_source = sources_by_canonical.get(canonical_role)
341
+ if prior_source is not None and prior_source != role_name:
342
+ raise RouterConfigError(
343
+ f"role_routes.{role_name} duplicates canonical role {canonical_role!r}; keep only {canonical_role!r}."
344
+ )
345
+ normalized_routes[canonical_role] = route_payload
346
+ sources_by_canonical[canonical_role] = role_name
347
+ return normalized_routes
348
+
349
+
350
+ def validate_policy(policy: object) -> dict[str, object]:
351
+ payload = deepcopy(_as_dict(policy, "Router policy"))
352
+ payload["role_routes"] = normalize_role_routes(payload.get("role_routes"))
353
+ default_defaults = _as_dict(default_router_policy()["defaults"], "default defaults")
354
+
355
+ version = payload.get("version")
356
+ if version != CONFIG_VERSION:
357
+ raise RouterConfigError(f"Unsupported config version: {version!r}. Expected {CONFIG_VERSION}.")
358
+
359
+ defaults = _as_dict(payload.get("defaults"), "defaults")
360
+ if defaults.get("when_role_unconfigured") not in ALLOWED_ROLE_UNCONFIGURED:
361
+ raise RouterConfigError("defaults.when_role_unconfigured must be one of: ask, fallback-local, block.")
362
+ if defaults.get("when_cli_unavailable") not in ALLOWED_CLI_UNAVAILABLE:
363
+ raise RouterConfigError("defaults.when_cli_unavailable must be one of: fallback-local, ask, block.")
364
+ if defaults.get("when_model_unknown") not in ALLOWED_MODEL_UNKNOWN:
365
+ raise RouterConfigError("defaults.when_model_unknown must be one of: ask, use-as-literal, fallback-local.")
366
+ if not isinstance(defaults.get("allow_auto_assign_if_single_cli"), bool):
367
+ raise RouterConfigError("defaults.allow_auto_assign_if_single_cli must be a boolean.")
368
+ probe_timeout_ms = defaults.get("probe_timeout_ms")
369
+ if not isinstance(probe_timeout_ms, int) or probe_timeout_ms < 500 or probe_timeout_ms > 60000:
370
+ raise RouterConfigError("defaults.probe_timeout_ms must be an integer between 500 and 60000.")
371
+ invoke_timeout_ms = defaults.get("invoke_timeout_ms", default_defaults["invoke_timeout_ms"])
372
+ if not isinstance(invoke_timeout_ms, int) or invoke_timeout_ms < 1000 or invoke_timeout_ms > 600000:
373
+ raise RouterConfigError("defaults.invoke_timeout_ms must be an integer between 1000 and 600000.")
374
+
375
+ role_routes = _as_dict(payload.get("role_routes"), "role_routes")
376
+ for role in CANONICAL_ROLES:
377
+ if role not in role_routes:
378
+ raise RouterConfigError(f"role_routes is missing canonical role: {role}")
379
+
380
+ for role_name, route_payload in role_routes.items():
381
+ route = _as_dict(route_payload, f"role_routes.{role_name}")
382
+ if not isinstance(route.get("enabled"), bool):
383
+ raise RouterConfigError(f"role_routes.{role_name}.enabled must be a boolean.")
384
+ if route.get("mode") not in ALLOWED_ROLE_MODES:
385
+ raise RouterConfigError(f"role_routes.{role_name}.mode must be one of: external-cli, local-only.")
386
+ cli_value = route.get("cli")
387
+ if cli_value is not None and not isinstance(cli_value, str):
388
+ raise RouterConfigError(f"role_routes.{role_name}.cli must be a string or null.")
389
+ model_value = route.get("model")
390
+ if model_value is not None and not isinstance(model_value, str):
391
+ raise RouterConfigError(f"role_routes.{role_name}.model must be a string or null.")
392
+ if route.get("fallback") not in ALLOWED_ROLE_FALLBACKS:
393
+ raise RouterConfigError(f"role_routes.{role_name}.fallback must be one of: self-audit, local-controller, ask, block.")
394
+
395
+ cli_overrides = payload.get("cli_overrides")
396
+ if not isinstance(cli_overrides, dict):
397
+ raise RouterConfigError("cli_overrides must be a JSON object.")
398
+ builtin_ids = set(BUILTIN_ADAPTER_BY_ID)
399
+ for cli_id, override_payload in cli_overrides.items():
400
+ if cli_id not in builtin_ids:
401
+ raise RouterConfigError(f"cli_overrides references unknown built-in CLI id: {cli_id}")
402
+ override = _as_dict(override_payload, f"cli_overrides.{cli_id}")
403
+ if "command" in override:
404
+ _normalize_command_spec(override["command"], f"cli_overrides.{cli_id}.command")
405
+ if "probe_args" in override:
406
+ _as_list_of_strings(override["probe_args"], f"cli_overrides.{cli_id}.probe_args")
407
+ if "invoke_template" in override:
408
+ invoke_template = _as_list_of_strings(override["invoke_template"], f"cli_overrides.{cli_id}.invoke_template", allow_empty=False)
409
+ _validate_invoke_template(invoke_template, f"cli_overrides.{cli_id}.invoke_template")
410
+ if "model_list_template" in override and override["model_list_template"] is not None:
411
+ _as_list_of_strings(override["model_list_template"], f"cli_overrides.{cli_id}.model_list_template")
412
+ if "default_models" in override:
413
+ _as_list_of_strings(override["default_models"], f"cli_overrides.{cli_id}.default_models")
414
+ transport = override.get("transport")
415
+ if transport is not None and (not isinstance(transport, str) or not transport.strip()):
416
+ raise RouterConfigError(f"cli_overrides.{cli_id}.transport must be a non-empty string when provided.")
417
+
418
+ custom_clis = payload.get("custom_clis")
419
+ if not isinstance(custom_clis, list):
420
+ raise RouterConfigError("custom_clis must be a list.")
421
+
422
+ known_ids = {adapter.id for adapter in BUILTIN_ADAPTERS}
423
+ for index, entry in enumerate(custom_clis):
424
+ item = _as_dict(entry, f"custom_clis[{index}]")
425
+ cli_id = item.get("id")
426
+ if not isinstance(cli_id, str) or not cli_id.strip():
427
+ raise RouterConfigError(f"custom_clis[{index}].id must be a non-empty string.")
428
+ if cli_id in known_ids:
429
+ raise RouterConfigError(f"custom_clis[{index}].id duplicates an existing CLI id: {cli_id}")
430
+ known_ids.add(cli_id)
431
+
432
+ command = _normalize_command_spec(item.get("command"), f"custom_clis[{index}].command")
433
+
434
+ probe_args = _as_list_of_strings(item.get("probe_args"), f"custom_clis[{index}].probe_args")
435
+ invoke_template = _as_list_of_strings(item.get("invoke_template"), f"custom_clis[{index}].invoke_template", allow_empty=False)
436
+ _validate_invoke_template(invoke_template, f"custom_clis[{index}].invoke_template")
437
+ model_list_template = item.get("model_list_template")
438
+ if model_list_template is not None:
439
+ _as_list_of_strings(model_list_template, f"custom_clis[{index}].model_list_template")
440
+ _as_list_of_strings(probe_args, f"custom_clis[{index}].probe_args")
441
+
442
+ valid_ids = known_ids
443
+ for role_name, route_payload in role_routes.items():
444
+ cli_value = route_payload.get("cli")
445
+ if cli_value is not None and cli_value not in valid_ids:
446
+ raise RouterConfigError(f"role_routes.{role_name}.cli references unknown CLI id: {cli_value}")
447
+ return payload
448
+
449
+
450
+ def adapters_from_policy(policy: dict[str, object] | None) -> list[CLIAdapter]:
451
+ adapters: list[CLIAdapter] = []
452
+ override_map = policy.get("cli_overrides", {}) if policy else {}
453
+ for adapter in BUILTIN_ADAPTERS:
454
+ override = override_map.get(adapter.id, {})
455
+ model_list_template = adapter.model_list_template
456
+ if "model_list_template" in override:
457
+ raw_model_list = override["model_list_template"]
458
+ model_list_template = None if raw_model_list is None else tuple(raw_model_list)
459
+ adapters.append(
460
+ CLIAdapter(
461
+ id=adapter.id,
462
+ command=_normalize_command_spec(override["command"], f"cli_overrides.{adapter.id}.command")
463
+ if "command" in override
464
+ else adapter.command,
465
+ probe_args=tuple(override.get("probe_args", list(adapter.probe_args))),
466
+ model_list_template=model_list_template,
467
+ builtin=adapter.builtin,
468
+ default_models=tuple(override.get("default_models", list(adapter.default_models))),
469
+ invoke_template=tuple(override.get("invoke_template", list(adapter.invoke_template))),
470
+ transport=override.get("transport", adapter.transport),
471
+ )
472
+ )
473
+ if not policy:
474
+ return adapters
475
+ for index, entry in enumerate(policy.get("custom_clis", [])):
476
+ item = _as_dict(entry, "custom CLI")
477
+ model_list_template = item.get("model_list_template")
478
+ adapters.append(
479
+ CLIAdapter(
480
+ id=item["id"],
481
+ command=_normalize_command_spec(item["command"], f"custom_clis[{index}].command"),
482
+ probe_args=tuple(item["probe_args"]),
483
+ model_list_template=tuple(model_list_template) if model_list_template is not None else None,
484
+ builtin=False,
485
+ invoke_template=tuple(item["invoke_template"]),
486
+ )
487
+ )
488
+ return adapters
489
+
490
+
491
+ def _command_display(command: str | tuple[str, ...]) -> str | list[str]:
492
+ return command if isinstance(command, str) else list(command)
493
+
494
+
495
+ def _resolve_executable_token(token: str) -> str | None:
496
+ stripped = token.strip().strip('"')
497
+ if not stripped:
498
+ return None
499
+ if os.path.isabs(stripped):
500
+ return stripped if Path(stripped).exists() else None
501
+ return shutil.which(stripped)
502
+
503
+
504
+ def _parse_windows_wrapper_command(wrapper_path: Path) -> list[str] | None:
505
+ suffix = wrapper_path.suffix.lower()
506
+ if suffix not in {".ps1", ".cmd", ".bat"}:
507
+ return None
508
+ try:
509
+ text = wrapper_path.read_text(encoding="utf-8")
510
+ except OSError:
511
+ return None
512
+ if suffix == ".ps1":
513
+ match = re.search(r"^\s*&\s+((?:\"[^\"]+\"|'[^']+')(?:(?:\s+)(?:\"[^\"]+\"|'[^']+'))*)\s+@args\b", text, re.MULTILINE)
514
+ else:
515
+ match = re.search(r"^\s*@?(?:echo off\s*)?[\r\n]+((?:\"[^\"]+\")(?:\s+\"[^\"]+\")*)\s+%\*\s*$", text, re.MULTILINE)
516
+ if match is None:
517
+ return None
518
+ quoted = re.findall(r"\"([^\"]+)\"|'([^']+)'", match.group(1))
519
+ tokens = [first or second for first, second in quoted if first or second]
520
+ if not tokens:
521
+ return None
522
+ resolved = _resolve_executable_token(tokens[0])
523
+ if resolved is None:
524
+ return None
525
+ return [resolved, *tokens[1:]]
526
+
527
+
528
+ def resolve_command_argv(command: str | tuple[str, ...]) -> list[str] | None:
529
+ if isinstance(command, str):
530
+ tokens = [command]
531
+ else:
532
+ tokens = list(command)
533
+ if not tokens:
534
+ return None
535
+ stripped = tokens[0].strip().strip('"')
536
+ if not stripped:
537
+ return None
538
+ resolved = _resolve_executable_token(stripped)
539
+ if resolved is None:
540
+ return None
541
+ if os.name == "nt" and os.path.isabs(resolved):
542
+ parsed = _parse_windows_wrapper_command(Path(resolved))
543
+ if parsed is not None:
544
+ return [*parsed, *tokens[1:]]
545
+ return [resolved, *tokens[1:]]
546
+
547
+
548
+ def resolve_command_path(command: str | tuple[str, ...]) -> str | None:
549
+ argv = resolve_command_argv(command)
550
+ return argv[0] if argv else None
551
+
552
+
553
+ def _run_command(command: list[str], *, timeout_ms: int, cwd: Path | None = None) -> subprocess.CompletedProcess[str]:
554
+ if command and os.name == "nt" and command[0].lower().endswith(".ps1"):
555
+ shell_path = resolve_command_path("pwsh") or resolve_command_path("powershell")
556
+ if shell_path is not None:
557
+ command = [shell_path, "-NoProfile", "-File", command[0], *command[1:]]
558
+ env = os.environ.copy()
559
+ env.setdefault("PYTHONIOENCODING", "utf-8")
560
+ env.setdefault("PYTHONUTF8", "1")
561
+ try:
562
+ return subprocess.run(
563
+ command,
564
+ cwd=str(cwd) if cwd else None,
565
+ text=True,
566
+ encoding="utf-8",
567
+ errors="replace",
568
+ capture_output=True,
569
+ env=env,
570
+ timeout=max(0.5, timeout_ms / 1000.0),
571
+ check=False,
572
+ )
573
+ except OSError as exc:
574
+ return subprocess.CompletedProcess(
575
+ args=command,
576
+ returncode=127,
577
+ stdout="",
578
+ stderr=f"Failed to start command: {exc}",
579
+ )
580
+
581
+
582
+ def _normalize_version(stdout: str, stderr: str) -> str | None:
583
+ for stream in (stdout, stderr):
584
+ for line in stream.splitlines():
585
+ if line.strip():
586
+ return line.strip()
587
+ return None
588
+
589
+
590
+ def _parse_models(stdout: str) -> list[str]:
591
+ text = stdout.strip()
592
+ if not text:
593
+ return []
594
+ if text.startswith("["):
595
+ payload = json.loads(text)
596
+ if isinstance(payload, list):
597
+ return [str(item) for item in payload]
598
+ if text.startswith("{"):
599
+ payload = json.loads(text)
600
+ if isinstance(payload, dict) and isinstance(payload.get("models"), list):
601
+ return [str(item) for item in payload["models"]]
602
+ normalized_text = text.replace("\\r\\n", "\n").replace("\\n", "\n")
603
+ return [line.strip() for line in normalized_text.splitlines() if line.strip()]
604
+
605
+
606
+ def _dedupe_strings(values: list[str]) -> list[str]:
607
+ result: list[str] = []
608
+ seen: set[str] = set()
609
+ for value in values:
610
+ if value not in seen:
611
+ seen.add(value)
612
+ result.append(value)
613
+ return result
614
+
615
+
616
+ def _discover_codex_cached_models() -> tuple[list[str], str | None, str | None]:
617
+ cache_path = Path.home() / ".codex" / "models_cache.json"
618
+ if not cache_path.exists():
619
+ return [], None, f"Codex model cache not found at {cache_path}."
620
+ try:
621
+ payload = read_json(cache_path)
622
+ except (OSError, json.JSONDecodeError) as exc:
623
+ return [], None, f"Failed to parse Codex model cache at {cache_path}: {exc}"
624
+
625
+ model_entries: object
626
+ if isinstance(payload, dict):
627
+ model_entries = payload.get("models", [])
628
+ else:
629
+ model_entries = payload
630
+
631
+ if not isinstance(model_entries, list):
632
+ return [], None, f"Codex model cache at {cache_path} does not contain a models list."
633
+
634
+ models: list[str] = []
635
+ for entry in model_entries:
636
+ if isinstance(entry, str) and entry.strip():
637
+ models.append(entry.strip())
638
+ elif isinstance(entry, dict):
639
+ slug = entry.get("slug")
640
+ if isinstance(slug, str) and slug.strip():
641
+ models.append(slug.strip())
642
+ return _dedupe_strings(models), "cache-file", None
643
+
644
+
645
+ def _discover_codex_app_server_models(command_argv: list[str], *, timeout_ms: int) -> tuple[list[str], str | None, str | None]:
646
+ process = subprocess.Popen(
647
+ [*command_argv, "app-server", "--listen", "stdio://"],
648
+ stdin=subprocess.PIPE,
649
+ stdout=subprocess.PIPE,
650
+ stderr=subprocess.STDOUT,
651
+ text=True,
652
+ bufsize=1,
653
+ )
654
+ if process.stdin is None or process.stdout is None:
655
+ try:
656
+ process.terminate()
657
+ except OSError:
658
+ pass
659
+ return [], None, "Failed to open Codex app-server stdio streams."
660
+
661
+ events: "queue.Queue[str | None]" = queue.Queue()
662
+ transcript_lines: list[str] = []
663
+
664
+ def reader() -> None:
665
+ try:
666
+ for raw_line in process.stdout:
667
+ events.put(raw_line.rstrip("\r\n"))
668
+ finally:
669
+ events.put(None)
670
+
671
+ reader_thread = threading.Thread(target=reader, daemon=True)
672
+ reader_thread.start()
673
+
674
+ def send(message: dict[str, object]) -> None:
675
+ process.stdin.write(json.dumps(message) + "\n")
676
+ process.stdin.flush()
677
+
678
+ def read_until(predicate) -> dict[str, object]:
679
+ deadline = time.monotonic() + max(0.5, timeout_ms / 1000.0)
680
+ while time.monotonic() < deadline:
681
+ remaining = max(0.1, deadline - time.monotonic())
682
+ try:
683
+ raw = events.get(timeout=remaining)
684
+ except queue.Empty as exc:
685
+ raise TimeoutError from exc
686
+ if raw is None:
687
+ break
688
+ transcript_lines.append(raw)
689
+ try:
690
+ payload = json.loads(raw)
691
+ except json.JSONDecodeError:
692
+ continue
693
+ if predicate(payload):
694
+ return payload
695
+ raise TimeoutError
696
+
697
+ try:
698
+ send(
699
+ {
700
+ "id": "initialize",
701
+ "method": "initialize",
702
+ "params": {
703
+ "clientInfo": {
704
+ "name": ROUTER_NAME,
705
+ "title": ROUTER_NAME,
706
+ "version": "1.0",
707
+ },
708
+ "capabilities": {"experimentalApi": True},
709
+ },
710
+ }
711
+ )
712
+ read_until(lambda payload: payload.get("id") == "initialize")
713
+ send({"method": "initialized"})
714
+ send({"id": "model-list", "method": "model/list", "params": {}})
715
+ response_line = read_until(lambda payload: payload.get("id") == "model-list")
716
+ except TimeoutError:
717
+ message = "\n".join(transcript_lines).strip()
718
+ if message:
719
+ return [], None, f"Codex app-server model/list timed out after {timeout_ms}ms. Transcript: {message}"
720
+ return [], None, f"Codex app-server model/list timed out after {timeout_ms}ms."
721
+ finally:
722
+ try:
723
+ process.terminate()
724
+ except OSError:
725
+ pass
726
+ try:
727
+ process.wait(timeout=5)
728
+ except subprocess.TimeoutExpired:
729
+ process.kill()
730
+ process.wait(timeout=5)
731
+
732
+ error = response_line.get("error")
733
+ if isinstance(error, dict):
734
+ message = error.get("message")
735
+ return [], None, f"Codex app-server model/list failed: {message or error!r}"
736
+
737
+ result = response_line.get("result")
738
+ if not isinstance(result, dict):
739
+ return [], None, "Codex app-server model/list returned an invalid result payload."
740
+
741
+ data = result.get("data", [])
742
+ if not isinstance(data, list):
743
+ return [], None, "Codex app-server model/list returned a non-list data payload."
744
+
745
+ models: list[str] = []
746
+ for entry in data:
747
+ if isinstance(entry, dict):
748
+ model_value = entry.get("model") or entry.get("id")
749
+ if isinstance(model_value, str) and model_value.strip():
750
+ models.append(model_value.strip())
751
+ elif isinstance(entry, str) and entry.strip():
752
+ models.append(entry.strip())
753
+ return _dedupe_strings(models), "app-server-model-list", None
754
+
755
+
756
+ def _discover_codex_models(command_argv: list[str], *, timeout_ms: int) -> tuple[list[str], str | None, str | None]:
757
+ app_server_models, app_server_source, app_server_note = _discover_codex_app_server_models(command_argv, timeout_ms=timeout_ms)
758
+ if app_server_source is not None:
759
+ return app_server_models, app_server_source, app_server_note
760
+
761
+ cached_models, cached_source, cached_note = _discover_codex_cached_models()
762
+ if cached_source is not None:
763
+ fallback_note = app_server_note
764
+ if cached_note:
765
+ fallback_note = f"{fallback_note} Falling back to local cache. {cached_note}" if fallback_note else cached_note
766
+ elif fallback_note:
767
+ fallback_note = f"{fallback_note} Falling back to local cache."
768
+ return cached_models, cached_source, fallback_note
769
+ return [], None, app_server_note or cached_note
770
+
771
+
772
+ def _discover_kimi_configured_models() -> tuple[list[str], str | None, str | None]:
773
+ config_path = Path.home() / ".kimi" / "config.toml"
774
+ if not config_path.exists():
775
+ return [], None, f"Kimi config file not found at {config_path}."
776
+ try:
777
+ payload = tomllib.loads(config_path.read_text(encoding="utf-8"))
778
+ except (OSError, tomllib.TOMLDecodeError) as exc:
779
+ return [], None, f"Failed to parse Kimi config at {config_path}: {exc}"
780
+
781
+ models_section = payload.get("models")
782
+ if not isinstance(models_section, dict):
783
+ return [], None, f"Kimi config at {config_path} does not contain a models table."
784
+
785
+ models: list[str] = []
786
+ for alias, entry in models_section.items():
787
+ if isinstance(alias, str) and alias.strip() and isinstance(entry, dict):
788
+ model_value = entry.get("model")
789
+ if isinstance(model_value, str) and model_value.strip():
790
+ models.append(alias.strip())
791
+ return _dedupe_strings(models), "config-file", None
792
+
793
+
794
+ def _discover_local_models(adapter: CLIAdapter) -> tuple[list[str], str | None, str | None]:
795
+ if adapter.id == "codex":
796
+ return _discover_codex_cached_models()
797
+ if adapter.id == "kimi":
798
+ return _discover_kimi_configured_models()
799
+ return [], None, None
800
+
801
+
802
+ def probe_adapter(adapter: CLIAdapter, *, timeout_ms: int) -> dict[str, object]:
803
+ command_argv = resolve_command_argv(adapter.command)
804
+ resolved_path = command_argv[0] if command_argv else None
805
+ notes: list[str] = []
806
+ entry: dict[str, object] = {
807
+ "id": adapter.id,
808
+ "available": False,
809
+ "builtin": adapter.builtin,
810
+ "command": _command_display(adapter.command),
811
+ "resolved_path": resolved_path,
812
+ "version": None,
813
+ "models": [],
814
+ "model_source": "unknown",
815
+ "transport": adapter.transport,
816
+ "notes": notes,
817
+ }
818
+ if resolved_path is None:
819
+ notes.append("Executable not found on PATH or at the configured absolute path.")
820
+ return entry
821
+
822
+ try:
823
+ probe_result = _run_command([*command_argv, *adapter.probe_args], timeout_ms=timeout_ms)
824
+ except subprocess.TimeoutExpired:
825
+ notes.append(f"Probe command timed out after {timeout_ms}ms.")
826
+ return entry
827
+
828
+ if probe_result.returncode != 0:
829
+ message = (probe_result.stderr or probe_result.stdout).strip() or f"Probe command exited with code {probe_result.returncode}."
830
+ notes.append(message)
831
+ return entry
832
+
833
+ entry["available"] = True
834
+ entry["version"] = _normalize_version(probe_result.stdout, probe_result.stderr)
835
+
836
+ if adapter.id == "codex" and adapter.transport == "app-server" and adapter.model_list_template is None:
837
+ models, model_source, note = _discover_codex_models(command_argv, timeout_ms=timeout_ms)
838
+ if model_source is not None:
839
+ entry["models"] = models
840
+ entry["model_source"] = model_source
841
+ if note:
842
+ notes.append(note)
843
+ return entry
844
+ if note:
845
+ notes.append(note)
846
+
847
+ if adapter.model_list_template:
848
+ try:
849
+ models_result = _run_command([*command_argv, *adapter.model_list_template], timeout_ms=timeout_ms)
850
+ except subprocess.TimeoutExpired:
851
+ entry["model_source"] = "failed"
852
+ notes.append(f"Model-list command timed out after {timeout_ms}ms.")
853
+ return entry
854
+
855
+ if models_result.returncode != 0:
856
+ entry["model_source"] = "failed"
857
+ message = (models_result.stderr or models_result.stdout).strip() or f"Model-list command exited with code {models_result.returncode}."
858
+ notes.append(message)
859
+ return entry
860
+
861
+ entry["models"] = _parse_models(models_result.stdout)
862
+ entry["model_source"] = "cli-list"
863
+ return entry
864
+
865
+ if adapter.default_models:
866
+ entry["models"] = list(adapter.default_models)
867
+ entry["model_source"] = "adapter-defaults"
868
+ return entry
869
+
870
+ local_models, local_source, local_note = _discover_local_models(adapter)
871
+ if local_source is not None:
872
+ entry["models"] = local_models
873
+ entry["model_source"] = local_source
874
+ if local_note:
875
+ notes.append(local_note)
876
+ elif local_note:
877
+ entry["model_source"] = "unknown"
878
+ notes.append(local_note)
879
+ else:
880
+ entry["model_source"] = "unsupported"
881
+ notes.append("Model discovery is not supported for this CLI adapter.")
882
+ return entry
883
+
884
+
885
+ def probe_inventory(
886
+ repo_root: Path,
887
+ *,
888
+ timeout_ms: int | None = None,
889
+ probe_tool: str,
890
+ write_discovery: bool = True,
891
+ policy: dict[str, object] | None = None,
892
+ ) -> dict[str, object]:
893
+ effective_policy = policy
894
+ if effective_policy is None:
895
+ try:
896
+ effective_policy = load_policy(repo_root)
897
+ except RouterConfigError:
898
+ effective_policy = None
899
+
900
+ if effective_policy is not None and timeout_ms is None:
901
+ timeout_ms = int(_as_dict(effective_policy["defaults"], "defaults")["probe_timeout_ms"])
902
+ timeout_ms = 2500 if timeout_ms is None else timeout_ms
903
+
904
+ clis = [probe_adapter(adapter, timeout_ms=timeout_ms) for adapter in adapters_from_policy(effective_policy)]
905
+ if not clis:
906
+ probe_status = "failed"
907
+ elif all(entry["available"] for entry in clis):
908
+ probe_status = "ok"
909
+ elif any(entry["available"] for entry in clis):
910
+ probe_status = "partial"
911
+ else:
912
+ probe_status = "failed"
913
+ inventory = empty_discovery_inventory(probe_tool=probe_tool, probe_status=probe_status, clis=clis)
914
+ if write_discovery:
915
+ discovery_path = router_discovery_path(repo_root)
916
+ discovery_path.parent.mkdir(parents=True, exist_ok=True)
917
+ write_json(discovery_path, inventory)
918
+ return inventory
919
+
920
+
921
+ def _available_cli_entries(inventory: dict[str, object]) -> list[dict[str, object]]:
922
+ return [entry for entry in inventory["clis"] if entry["available"]]
923
+
924
+
925
+ def _cli_entry_by_id(inventory: dict[str, object], cli_id: str) -> dict[str, object] | None:
926
+ for entry in inventory["clis"]:
927
+ if entry["id"] == cli_id:
928
+ return entry
929
+ return None
930
+
931
+
932
+ def _probe_timeout_ms_from_policy(policy: dict[str, object]) -> int:
933
+ return int(_as_dict(policy["defaults"], "defaults")["probe_timeout_ms"])
934
+
935
+
936
+ def _invoke_timeout_ms_from_policy(policy: dict[str, object]) -> int:
937
+ defaults = _as_dict(policy["defaults"], "defaults")
938
+ return int(defaults.get("invoke_timeout_ms", _as_dict(default_router_policy()["defaults"], "default defaults")["invoke_timeout_ms"]))
939
+
940
+
941
+ def adapter_by_id(policy: dict[str, object] | None, cli_id: str) -> CLIAdapter | None:
942
+ for adapter in adapters_from_policy(policy):
943
+ if adapter.id == cli_id:
944
+ return adapter
945
+ return None
946
+
947
+
948
+ def _verification_prompt(token: str) -> str:
949
+ return f"Reply with exactly this token and no extra text: {token}"
950
+
951
+
952
+ def _token_found(token: str, *streams: str | None) -> bool:
953
+ return any(token in stream for stream in streams if stream)
954
+
955
+
956
+ def _run_template_invocation(
957
+ *,
958
+ command_argv: list[str],
959
+ adapter: CLIAdapter,
960
+ repo_root: Path,
961
+ model: str,
962
+ prompt: str,
963
+ timeout_ms: int,
964
+ ) -> subprocess.CompletedProcess[str]:
965
+ with tempfile.TemporaryDirectory(prefix="recursive-router-verify-") as temp_dir:
966
+ prompt_path = Path(temp_dir) / "prompt.txt"
967
+ prompt_path.write_text(prompt, encoding="utf-8")
968
+ command_path = command_argv[0]
969
+ if (
970
+ os.name == "nt"
971
+ and "\n" in prompt
972
+ and any("{prompt}" in piece for piece in adapter.invoke_template)
973
+ and command_path.lower().endswith((".cmd", ".bat", ".ps1"))
974
+ ):
975
+ shell_path = resolve_command_path("pwsh") or resolve_command_path("powershell")
976
+ if shell_path is not None:
977
+ powershell_command_argv = list(command_argv)
978
+ if command_path.lower().endswith((".cmd", ".bat")):
979
+ companion_ps1 = Path(command_path).with_suffix(".ps1")
980
+ if companion_ps1.exists():
981
+ powershell_command_argv = [str(companion_ps1), *command_argv[1:]]
982
+ return _run_template_invocation_via_powershell(
983
+ shell_path=shell_path,
984
+ command_argv=powershell_command_argv,
985
+ adapter=adapter,
986
+ repo_root=repo_root,
987
+ model=model,
988
+ prompt_path=prompt_path,
989
+ timeout_ms=timeout_ms,
990
+ )
991
+ args = [
992
+ piece.format(model=model, prompt_file=str(prompt_path), prompt=prompt, repo_root=str(repo_root))
993
+ for piece in adapter.invoke_template
994
+ ]
995
+ return _run_command([*command_argv, *args], timeout_ms=timeout_ms, cwd=repo_root)
996
+
997
+
998
+ def _strip_ansi(text: str | None) -> str:
999
+ if text is None:
1000
+ return ""
1001
+ return re.sub(r"\x1b\[[0-9;]*[A-Za-z]", "", text)
1002
+
1003
+
1004
+ def _collect_text_content(value: object) -> list[str]:
1005
+ if isinstance(value, str):
1006
+ return [value] if value.strip() else []
1007
+ if isinstance(value, list):
1008
+ fragments: list[str] = []
1009
+ for item in value:
1010
+ fragments.extend(_collect_text_content(item))
1011
+ return fragments
1012
+ if isinstance(value, dict):
1013
+ fragments = []
1014
+ for key in ("text", "content"):
1015
+ if key in value:
1016
+ fragments.extend(_collect_text_content(value[key]))
1017
+ return fragments
1018
+ return []
1019
+
1020
+
1021
+ def _assistant_text_from_payload(value: object) -> list[str]:
1022
+ if isinstance(value, list):
1023
+ fragments: list[str] = []
1024
+ for item in value:
1025
+ fragments.extend(_assistant_text_from_payload(item))
1026
+ return fragments
1027
+ if not isinstance(value, dict):
1028
+ return []
1029
+
1030
+ role = str(value.get("role", "")).lower()
1031
+ payload_type = str(value.get("type", "")).lower()
1032
+ assistant_like = role == "assistant" or payload_type in {
1033
+ "assistant",
1034
+ "assistant_message",
1035
+ "agentmessage",
1036
+ "agent_message",
1037
+ }
1038
+ if assistant_like:
1039
+ fragments = _collect_text_content(value)
1040
+ if fragments:
1041
+ return fragments
1042
+
1043
+ fragments: list[str] = []
1044
+ for key in ("message", "delta", "part", "content", "data"):
1045
+ child = value.get(key)
1046
+ if isinstance(child, (dict, list)):
1047
+ fragments.extend(_assistant_text_from_payload(child))
1048
+ return fragments
1049
+
1050
+
1051
+ def _normalize_kimi_output(text: str) -> str:
1052
+ fragments: list[str] = []
1053
+ plain_lines: list[str] = []
1054
+ for raw_line in text.splitlines():
1055
+ line = raw_line.strip()
1056
+ if not line or line == "<choice>STOP</choice>":
1057
+ continue
1058
+ if line.startswith("{"):
1059
+ try:
1060
+ payload = json.loads(line)
1061
+ except json.JSONDecodeError:
1062
+ plain_lines.append(raw_line)
1063
+ continue
1064
+ fragments.extend(_assistant_text_from_payload(payload))
1065
+ else:
1066
+ plain_lines.append(raw_line)
1067
+ if fragments:
1068
+ return "\n".join(fragment.strip() for fragment in fragments if fragment.strip()).strip()
1069
+ return "\n".join(line for line in plain_lines if line.strip()).strip()
1070
+
1071
+
1072
+ def _normalize_opencode_output(text: str) -> str:
1073
+ stripped = text.strip()
1074
+ if not stripped:
1075
+ return ""
1076
+ fragments: list[str] = []
1077
+ for raw_line in stripped.splitlines():
1078
+ line = raw_line.strip()
1079
+ if not line or not line.startswith("{"):
1080
+ continue
1081
+ try:
1082
+ payload = json.loads(line)
1083
+ except json.JSONDecodeError:
1084
+ continue
1085
+ if not isinstance(payload, dict) or payload.get("type") != "text":
1086
+ continue
1087
+ part = payload.get("part")
1088
+ if not isinstance(part, dict):
1089
+ continue
1090
+ message = part.get("text")
1091
+ if isinstance(message, str) and message.strip():
1092
+ fragments.append(message.strip())
1093
+ if fragments:
1094
+ return "\n".join(fragments).strip()
1095
+ return stripped
1096
+
1097
+
1098
+ def _single_quote_for_powershell(value: str) -> str:
1099
+ return value.replace("'", "''")
1100
+
1101
+
1102
+ def _run_template_invocation_via_powershell(
1103
+ *,
1104
+ shell_path: str,
1105
+ command_argv: list[str],
1106
+ adapter: CLIAdapter,
1107
+ repo_root: Path,
1108
+ model: str,
1109
+ prompt_path: Path,
1110
+ timeout_ms: int,
1111
+ ) -> subprocess.CompletedProcess[str]:
1112
+ script_path = prompt_path.with_name("invoke-router-template.ps1")
1113
+ command_lines = [f" '{_single_quote_for_powershell(part)}'" for part in command_argv]
1114
+ args_lines: list[str] = []
1115
+ prompt_expression = f"(Get-Content -Raw -LiteralPath '{_single_quote_for_powershell(str(prompt_path))}')"
1116
+ for piece in adapter.invoke_template:
1117
+ if "{prompt}" in piece:
1118
+ before, after = piece.split("{prompt}", 1)
1119
+ expression_parts: list[str] = []
1120
+ if before:
1121
+ formatted_before = before.format(model=model, prompt_file=str(prompt_path), prompt="", repo_root=str(repo_root))
1122
+ expression_parts.append(f"'{_single_quote_for_powershell(formatted_before)}'")
1123
+ expression_parts.append(prompt_expression)
1124
+ if after:
1125
+ formatted_after = after.format(model=model, prompt_file=str(prompt_path), prompt="", repo_root=str(repo_root))
1126
+ expression_parts.append(f"'{_single_quote_for_powershell(formatted_after)}'")
1127
+ if len(expression_parts) == 1:
1128
+ args_lines.append(f" {expression_parts[0]}")
1129
+ else:
1130
+ args_lines.append(f" ({' + '.join(expression_parts)})")
1131
+ continue
1132
+ formatted = piece.format(model=model, prompt_file=str(prompt_path), prompt="", repo_root=str(repo_root))
1133
+ args_lines.append(f" '{_single_quote_for_powershell(formatted)}'")
1134
+ script = "\n".join(
1135
+ [
1136
+ '$ErrorActionPreference = "Stop"',
1137
+ "$command = @(",
1138
+ *command_lines,
1139
+ ")",
1140
+ "if ($command.Length -gt 1) {",
1141
+ " $commandPrefix = $command[1..($command.Length - 1)]",
1142
+ "} else {",
1143
+ " $commandPrefix = @()",
1144
+ "}",
1145
+ "$argsList = @(",
1146
+ *args_lines,
1147
+ ")",
1148
+ "& $command[0] @commandPrefix @argsList",
1149
+ "exit $LASTEXITCODE",
1150
+ ]
1151
+ )
1152
+ script_path.write_text(script, encoding="utf-8", newline="\n")
1153
+ return _run_command([shell_path, "-NoProfile", "-File", str(script_path)], timeout_ms=timeout_ms, cwd=repo_root)
1154
+
1155
+
1156
+ def _invoke_codex_app_server(
1157
+ *,
1158
+ command_argv: list[str],
1159
+ repo_root: Path,
1160
+ model: str,
1161
+ prompt: str,
1162
+ timeout_ms: int,
1163
+ ) -> dict[str, object]:
1164
+ process = subprocess.Popen(
1165
+ [*command_argv, "app-server", "--listen", "stdio://"],
1166
+ cwd=str(repo_root),
1167
+ stdin=subprocess.PIPE,
1168
+ stdout=subprocess.PIPE,
1169
+ stderr=subprocess.STDOUT,
1170
+ text=True,
1171
+ bufsize=1,
1172
+ )
1173
+ if process.stdin is None or process.stdout is None:
1174
+ try:
1175
+ process.terminate()
1176
+ except OSError:
1177
+ pass
1178
+ return {
1179
+ "success": False,
1180
+ "transport": "app-server",
1181
+ "exit_code": None,
1182
+ "stdout": "",
1183
+ "stderr": "",
1184
+ "reason": "Failed to open Codex app-server stdio streams.",
1185
+ "transcript": "",
1186
+ }
1187
+
1188
+ events: "queue.Queue[str | None]" = queue.Queue()
1189
+ transcript_lines: list[str] = []
1190
+
1191
+ def reader() -> None:
1192
+ try:
1193
+ for raw_line in process.stdout:
1194
+ events.put(raw_line.rstrip("\r\n"))
1195
+ finally:
1196
+ events.put(None)
1197
+
1198
+ reader_thread = threading.Thread(target=reader, daemon=True)
1199
+ reader_thread.start()
1200
+
1201
+ def send(message: dict[str, object]) -> None:
1202
+ process.stdin.write(json.dumps(message) + "\n")
1203
+ process.stdin.flush()
1204
+
1205
+ def read_until(predicate) -> dict[str, object]:
1206
+ deadline = time.monotonic() + max(0.5, timeout_ms / 1000.0)
1207
+ while time.monotonic() < deadline:
1208
+ remaining = max(0.1, deadline - time.monotonic())
1209
+ try:
1210
+ raw = events.get(timeout=remaining)
1211
+ except queue.Empty as exc:
1212
+ raise TimeoutError from exc
1213
+ if raw is None:
1214
+ break
1215
+ transcript_lines.append(raw)
1216
+ try:
1217
+ payload = json.loads(raw)
1218
+ except json.JSONDecodeError:
1219
+ continue
1220
+ if predicate(payload):
1221
+ return payload
1222
+ raise TimeoutError
1223
+
1224
+ final_message = ""
1225
+ try:
1226
+ send(
1227
+ {
1228
+ "id": "initialize",
1229
+ "method": "initialize",
1230
+ "params": {
1231
+ "clientInfo": {
1232
+ "name": ROUTER_NAME,
1233
+ "title": ROUTER_NAME,
1234
+ "version": "1.0",
1235
+ },
1236
+ "capabilities": {"experimentalApi": True},
1237
+ },
1238
+ }
1239
+ )
1240
+ read_until(lambda payload: payload.get("id") == "initialize")
1241
+ send({"method": "initialized"})
1242
+ send(
1243
+ {
1244
+ "id": "thread-start",
1245
+ "method": "thread/start",
1246
+ "params": {
1247
+ "model": model,
1248
+ "approvalPolicy": "never",
1249
+ "sandbox": "read-only",
1250
+ "cwd": str(repo_root),
1251
+ },
1252
+ }
1253
+ )
1254
+ thread_start = read_until(lambda payload: payload.get("id") == "thread-start")
1255
+ if isinstance(thread_start.get("error"), dict):
1256
+ message = thread_start["error"].get("message") or repr(thread_start["error"])
1257
+ return {
1258
+ "success": False,
1259
+ "transport": "app-server",
1260
+ "exit_code": None,
1261
+ "stdout": "",
1262
+ "stderr": "",
1263
+ "reason": f"Codex thread/start failed: {message}",
1264
+ "transcript": "\n".join(transcript_lines),
1265
+ }
1266
+ thread_id = str(_as_dict(thread_start["result"], "thread/start result")["thread"]["id"])
1267
+
1268
+ send(
1269
+ {
1270
+ "id": "turn-start",
1271
+ "method": "turn/start",
1272
+ "params": {
1273
+ "threadId": thread_id,
1274
+ "model": model,
1275
+ "input": [{"type": "text", "text": prompt, "text_elements": []}],
1276
+ },
1277
+ }
1278
+ )
1279
+ turn_start = read_until(lambda payload: payload.get("id") == "turn-start")
1280
+ if isinstance(turn_start.get("error"), dict):
1281
+ message = turn_start["error"].get("message") or repr(turn_start["error"])
1282
+ return {
1283
+ "success": False,
1284
+ "transport": "app-server",
1285
+ "exit_code": None,
1286
+ "stdout": "",
1287
+ "stderr": "",
1288
+ "reason": f"Codex turn/start failed: {message}",
1289
+ "transcript": "\n".join(transcript_lines),
1290
+ }
1291
+ turn_id = str(_as_dict(turn_start["result"], "turn/start result")["turn"]["id"])
1292
+
1293
+ deadline = time.monotonic() + max(0.5, timeout_ms / 1000.0)
1294
+ while time.monotonic() < deadline:
1295
+ remaining = max(0.1, deadline - time.monotonic())
1296
+ try:
1297
+ raw = events.get(timeout=remaining)
1298
+ except queue.Empty as exc:
1299
+ raise TimeoutError from exc
1300
+ if raw is None:
1301
+ break
1302
+ transcript_lines.append(raw)
1303
+ try:
1304
+ payload = json.loads(raw)
1305
+ except json.JSONDecodeError:
1306
+ continue
1307
+ if payload.get("method") == "item/completed":
1308
+ params = payload.get("params", {})
1309
+ item = params.get("item", {})
1310
+ if (
1311
+ params.get("threadId") == thread_id
1312
+ and params.get("turnId") == turn_id
1313
+ and item.get("type") == "agentMessage"
1314
+ and item.get("phase") == "final_answer"
1315
+ ):
1316
+ final_message = str(item.get("text", ""))
1317
+ if payload.get("method") == "turn/completed":
1318
+ params = payload.get("params", {})
1319
+ turn = params.get("turn", {})
1320
+ if params.get("threadId") == thread_id and turn.get("id") == turn_id:
1321
+ return {
1322
+ "success": bool(final_message.strip()),
1323
+ "transport": "app-server",
1324
+ "exit_code": 0,
1325
+ "stdout": final_message,
1326
+ "stderr": "",
1327
+ "reason": "Prompt completed via Codex app-server." if final_message.strip() else "Codex returned no final answer text.",
1328
+ "transcript": "\n".join(transcript_lines),
1329
+ }
1330
+ raise TimeoutError
1331
+ except TimeoutError:
1332
+ return {
1333
+ "success": False,
1334
+ "transport": "app-server",
1335
+ "exit_code": None,
1336
+ "stdout": "",
1337
+ "stderr": "",
1338
+ "reason": f"Codex app-server invocation timed out after {timeout_ms}ms.",
1339
+ "transcript": "\n".join(transcript_lines),
1340
+ }
1341
+ finally:
1342
+ try:
1343
+ process.terminate()
1344
+ except OSError:
1345
+ pass
1346
+ try:
1347
+ process.wait(timeout=5)
1348
+ except subprocess.TimeoutExpired:
1349
+ process.kill()
1350
+ process.wait(timeout=5)
1351
+
1352
+
1353
+ def _verify_codex_app_server(
1354
+ *,
1355
+ command_argv: list[str],
1356
+ repo_root: Path,
1357
+ model: str,
1358
+ prompt: str,
1359
+ timeout_ms: int,
1360
+ ) -> dict[str, object]:
1361
+ process = subprocess.Popen(
1362
+ [*command_argv, "app-server", "--listen", "stdio://"],
1363
+ cwd=str(repo_root),
1364
+ stdin=subprocess.PIPE,
1365
+ stdout=subprocess.PIPE,
1366
+ stderr=subprocess.STDOUT,
1367
+ text=True,
1368
+ bufsize=1,
1369
+ )
1370
+ if process.stdin is None or process.stdout is None:
1371
+ try:
1372
+ process.terminate()
1373
+ except OSError:
1374
+ pass
1375
+ return {
1376
+ "verified": False,
1377
+ "transport": "app-server",
1378
+ "exit_code": None,
1379
+ "token_found": False,
1380
+ "stdout": "",
1381
+ "stderr": "",
1382
+ "reason": "Failed to open Codex app-server stdio streams.",
1383
+ }
1384
+
1385
+ events: "queue.Queue[str | None]" = queue.Queue()
1386
+ transcript_lines: list[str] = []
1387
+
1388
+ def reader() -> None:
1389
+ try:
1390
+ for raw_line in process.stdout:
1391
+ events.put(raw_line.rstrip("\r\n"))
1392
+ finally:
1393
+ events.put(None)
1394
+
1395
+ reader_thread = threading.Thread(target=reader, daemon=True)
1396
+ reader_thread.start()
1397
+
1398
+ def send(message: dict[str, object]) -> None:
1399
+ process.stdin.write(json.dumps(message) + "\n")
1400
+ process.stdin.flush()
1401
+
1402
+ def read_until(predicate) -> dict[str, object]:
1403
+ deadline = time.monotonic() + max(0.5, timeout_ms / 1000.0)
1404
+ while time.monotonic() < deadline:
1405
+ remaining = max(0.1, deadline - time.monotonic())
1406
+ try:
1407
+ raw = events.get(timeout=remaining)
1408
+ except queue.Empty as exc:
1409
+ raise TimeoutError from exc
1410
+ if raw is None:
1411
+ break
1412
+ transcript_lines.append(raw)
1413
+ try:
1414
+ payload = json.loads(raw)
1415
+ except json.JSONDecodeError:
1416
+ continue
1417
+ if predicate(payload):
1418
+ return payload
1419
+ raise TimeoutError
1420
+
1421
+ final_message = ""
1422
+ try:
1423
+ send(
1424
+ {
1425
+ "id": "initialize",
1426
+ "method": "initialize",
1427
+ "params": {
1428
+ "clientInfo": {
1429
+ "name": ROUTER_NAME,
1430
+ "title": ROUTER_NAME,
1431
+ "version": "1.0",
1432
+ },
1433
+ "capabilities": {"experimentalApi": True},
1434
+ },
1435
+ }
1436
+ )
1437
+ read_until(lambda payload: payload.get("id") == "initialize")
1438
+ send({"method": "initialized"})
1439
+ send(
1440
+ {
1441
+ "id": "thread-start",
1442
+ "method": "thread/start",
1443
+ "params": {
1444
+ "model": model,
1445
+ "approvalPolicy": "never",
1446
+ "sandbox": "read-only",
1447
+ "cwd": str(repo_root),
1448
+ },
1449
+ }
1450
+ )
1451
+ thread_start = read_until(lambda payload: payload.get("id") == "thread-start")
1452
+ if isinstance(thread_start.get("error"), dict):
1453
+ message = thread_start["error"].get("message") or repr(thread_start["error"])
1454
+ return {
1455
+ "verified": False,
1456
+ "transport": "app-server",
1457
+ "exit_code": None,
1458
+ "token_found": False,
1459
+ "stdout": "\n".join(transcript_lines),
1460
+ "stderr": "",
1461
+ "reason": f"Codex thread/start failed: {message}",
1462
+ }
1463
+ thread_id = str(_as_dict(thread_start["result"], "thread/start result")["thread"]["id"])
1464
+
1465
+ send(
1466
+ {
1467
+ "id": "turn-start",
1468
+ "method": "turn/start",
1469
+ "params": {
1470
+ "threadId": thread_id,
1471
+ "model": model,
1472
+ "input": [{"type": "text", "text": prompt, "text_elements": []}],
1473
+ },
1474
+ }
1475
+ )
1476
+ turn_start = read_until(lambda payload: payload.get("id") == "turn-start")
1477
+ if isinstance(turn_start.get("error"), dict):
1478
+ message = turn_start["error"].get("message") or repr(turn_start["error"])
1479
+ return {
1480
+ "verified": False,
1481
+ "transport": "app-server",
1482
+ "exit_code": None,
1483
+ "token_found": False,
1484
+ "stdout": "\n".join(transcript_lines),
1485
+ "stderr": "",
1486
+ "reason": f"Codex turn/start failed: {message}",
1487
+ }
1488
+ turn_id = str(_as_dict(turn_start["result"], "turn/start result")["turn"]["id"])
1489
+
1490
+ deadline = time.monotonic() + max(0.5, timeout_ms / 1000.0)
1491
+ while time.monotonic() < deadline:
1492
+ remaining = max(0.1, deadline - time.monotonic())
1493
+ try:
1494
+ raw = events.get(timeout=remaining)
1495
+ except queue.Empty as exc:
1496
+ raise TimeoutError from exc
1497
+ if raw is None:
1498
+ break
1499
+ transcript_lines.append(raw)
1500
+ try:
1501
+ payload = json.loads(raw)
1502
+ except json.JSONDecodeError:
1503
+ continue
1504
+ if payload.get("method") == "item/completed":
1505
+ params = payload.get("params", {})
1506
+ item = params.get("item", {})
1507
+ if (
1508
+ params.get("threadId") == thread_id
1509
+ and params.get("turnId") == turn_id
1510
+ and item.get("type") == "agentMessage"
1511
+ and item.get("phase") == "final_answer"
1512
+ ):
1513
+ final_message = str(item.get("text", ""))
1514
+ if payload.get("method") == "turn/completed":
1515
+ params = payload.get("params", {})
1516
+ turn = params.get("turn", {})
1517
+ if params.get("threadId") == thread_id and turn.get("id") == turn_id:
1518
+ transcript = "\n".join(transcript_lines)
1519
+ token = prompt.rsplit(":", 1)[-1].strip()
1520
+ token_found = _token_found(token, final_message, transcript)
1521
+ return {
1522
+ "verified": token_found,
1523
+ "transport": "app-server",
1524
+ "exit_code": 0,
1525
+ "token_found": token_found,
1526
+ "stdout": final_message,
1527
+ "stderr": "",
1528
+ "reason": "Verification prompt completed via Codex app-server." if token_found else "Verification token not found in Codex response.",
1529
+ "transcript": transcript,
1530
+ }
1531
+ raise TimeoutError
1532
+ except TimeoutError:
1533
+ transcript = "\n".join(transcript_lines)
1534
+ return {
1535
+ "verified": False,
1536
+ "transport": "app-server",
1537
+ "exit_code": None,
1538
+ "token_found": False,
1539
+ "stdout": transcript,
1540
+ "stderr": "",
1541
+ "reason": f"Codex app-server verification timed out after {timeout_ms}ms.",
1542
+ }
1543
+ finally:
1544
+ try:
1545
+ process.terminate()
1546
+ except OSError:
1547
+ pass
1548
+ try:
1549
+ exit_code = process.wait(timeout=5)
1550
+ except subprocess.TimeoutExpired:
1551
+ process.kill()
1552
+ exit_code = process.wait(timeout=5)
1553
+ # Preserve a best-effort process exit code in the transcript for callers that need it.
1554
+ transcript_lines.append(f"[process-exit:{exit_code}]")
1555
+
1556
+
1557
+ def verify_route_binding(
1558
+ repo_root: Path,
1559
+ *,
1560
+ role: str,
1561
+ cli_id: str,
1562
+ model: str,
1563
+ timeout_ms: int | None = None,
1564
+ policy: dict[str, object] | None = None,
1565
+ ) -> dict[str, object]:
1566
+ effective_policy = load_policy(repo_root) if policy is None else policy
1567
+ resolved_role = canonicalize_router_role(role)
1568
+ if not model.strip():
1569
+ raise RouterConfigError("Configured model must be a non-empty string.")
1570
+
1571
+ adapter = adapter_by_id(effective_policy, cli_id)
1572
+ if adapter is None:
1573
+ raise RouterConfigError(f"Unknown CLI id: {cli_id}")
1574
+
1575
+ effective_timeout_ms = _probe_timeout_ms_from_policy(effective_policy) if timeout_ms is None else timeout_ms
1576
+ command_argv = resolve_command_argv(adapter.command)
1577
+ command_path = command_argv[0] if command_argv else None
1578
+ token = f"ROUTER_VERIFY_{uuid.uuid4().hex[:12].upper()}"
1579
+ prompt = _verification_prompt(token)
1580
+ result: dict[str, object] = {
1581
+ "role": resolved_role,
1582
+ "cli": cli_id,
1583
+ "model": model,
1584
+ "transport": adapter.transport,
1585
+ "verified": False,
1586
+ "token": token,
1587
+ "token_found": False,
1588
+ "exit_code": None,
1589
+ "stdout": "",
1590
+ "stderr": "",
1591
+ "reason": "",
1592
+ }
1593
+
1594
+ if command_path is None:
1595
+ result["reason"] = f"Executable for CLI {cli_id!r} was not found."
1596
+ return result
1597
+
1598
+ builtin_adapter = BUILTIN_ADAPTER_BY_ID.get(adapter.id)
1599
+ template_overridden = builtin_adapter is not None and adapter.invoke_template != builtin_adapter.invoke_template
1600
+ transport_overridden = builtin_adapter is not None and adapter.transport != builtin_adapter.transport
1601
+
1602
+ if adapter.id == "codex" and adapter.transport == "app-server":
1603
+ completed = _run_command(
1604
+ [
1605
+ *command_argv,
1606
+ "exec",
1607
+ "--model",
1608
+ model,
1609
+ "--json",
1610
+ "--skip-git-repo-check",
1611
+ "--ephemeral",
1612
+ "-c",
1613
+ "mcp_servers.playwright.enabled=false",
1614
+ "-c",
1615
+ "mcp_servers.subframe.enabled=false",
1616
+ "-c",
1617
+ 'mcp_servers["subframe-docs"].enabled=false',
1618
+ "-c",
1619
+ "mcp_servers.pencil.enabled=false",
1620
+ "-C",
1621
+ str(repo_root),
1622
+ prompt,
1623
+ ],
1624
+ timeout_ms=effective_timeout_ms,
1625
+ cwd=repo_root,
1626
+ )
1627
+ token_found = _token_found(token, completed.stdout, completed.stderr)
1628
+ verification = {
1629
+ "verified": token_found and completed.returncode == 0,
1630
+ "transport": adapter.transport,
1631
+ "exit_code": completed.returncode,
1632
+ "token_found": token_found,
1633
+ "stdout": completed.stdout,
1634
+ "stderr": completed.stderr,
1635
+ "reason": (
1636
+ "Verification token observed in Codex exec output."
1637
+ if token_found and completed.returncode == 0
1638
+ else f"Codex verification failed with exit code {completed.returncode}."
1639
+ ),
1640
+ }
1641
+ elif adapter.id == "kimi" and not template_overridden and not transport_overridden:
1642
+ completed = _run_command(
1643
+ [
1644
+ *command_argv,
1645
+ "--model",
1646
+ model,
1647
+ "--work-dir",
1648
+ str(repo_root),
1649
+ "--print",
1650
+ "--output-format",
1651
+ "stream-json",
1652
+ "--max-ralph-iterations",
1653
+ "0",
1654
+ "--prompt",
1655
+ prompt,
1656
+ ],
1657
+ timeout_ms=effective_timeout_ms,
1658
+ cwd=repo_root,
1659
+ )
1660
+ combined = (completed.stdout or "") + "\n" + (completed.stderr or "")
1661
+ token_found = _token_found(token, completed.stdout, completed.stderr)
1662
+ verification = {
1663
+ "verified": token_found,
1664
+ "transport": adapter.transport,
1665
+ "exit_code": completed.returncode,
1666
+ "token_found": token_found,
1667
+ "stdout": completed.stdout,
1668
+ "stderr": completed.stderr,
1669
+ "reason": (
1670
+ "Verification token observed in Kimi output."
1671
+ if token_found
1672
+ else f"Kimi verification failed with exit code {completed.returncode}."
1673
+ ),
1674
+ "combined_output": combined,
1675
+ }
1676
+ elif adapter.id == "opencode" and not template_overridden and not transport_overridden:
1677
+ completed = _run_command(
1678
+ [
1679
+ *command_argv,
1680
+ "run",
1681
+ "--model",
1682
+ model,
1683
+ "--format",
1684
+ "json",
1685
+ "--dir",
1686
+ str(repo_root),
1687
+ prompt,
1688
+ ],
1689
+ timeout_ms=effective_timeout_ms,
1690
+ cwd=repo_root,
1691
+ )
1692
+ token_found = _token_found(token, completed.stdout, completed.stderr)
1693
+ verification = {
1694
+ "verified": token_found and completed.returncode == 0,
1695
+ "transport": adapter.transport,
1696
+ "exit_code": completed.returncode,
1697
+ "token_found": token_found,
1698
+ "stdout": completed.stdout,
1699
+ "stderr": completed.stderr,
1700
+ "reason": (
1701
+ "Verification token observed in Opencode output."
1702
+ if token_found and completed.returncode == 0
1703
+ else f"Opencode verification failed with exit code {completed.returncode}."
1704
+ ),
1705
+ }
1706
+ else:
1707
+ completed = _run_template_invocation(
1708
+ command_argv=command_argv,
1709
+ adapter=adapter,
1710
+ repo_root=repo_root,
1711
+ model=model,
1712
+ prompt=prompt,
1713
+ timeout_ms=effective_timeout_ms,
1714
+ )
1715
+ token_found = _token_found(token, completed.stdout, completed.stderr)
1716
+ verification = {
1717
+ "verified": token_found and completed.returncode == 0,
1718
+ "transport": adapter.transport,
1719
+ "exit_code": completed.returncode,
1720
+ "token_found": token_found,
1721
+ "stdout": completed.stdout,
1722
+ "stderr": completed.stderr,
1723
+ "reason": (
1724
+ f"Verification token observed in {cli_id} output."
1725
+ if token_found and completed.returncode == 0
1726
+ else f"{cli_id} verification failed with exit code {completed.returncode}."
1727
+ ),
1728
+ }
1729
+
1730
+ result.update(verification)
1731
+ return result
1732
+
1733
+
1734
+ def invoke_route_binding(
1735
+ repo_root: Path,
1736
+ *,
1737
+ role: str,
1738
+ prompt: str,
1739
+ timeout_ms: int | None = None,
1740
+ policy: dict[str, object] | None = None,
1741
+ write_discovery: bool = True,
1742
+ ) -> dict[str, object]:
1743
+ effective_policy = load_policy(repo_root) if policy is None else policy
1744
+ resolved_role = canonicalize_router_role(role)
1745
+ decision = resolve_route(repo_root, role=resolved_role, timeout_ms=timeout_ms, write_discovery=write_discovery)
1746
+ effective_timeout_ms = _invoke_timeout_ms_from_policy(effective_policy) if timeout_ms is None else timeout_ms
1747
+ payload: dict[str, object] = {
1748
+ "role": resolved_role,
1749
+ "decision": decision,
1750
+ "cli": decision.get("cli"),
1751
+ "model": decision.get("model"),
1752
+ "transport": decision.get("transport"),
1753
+ "resolved_command": None,
1754
+ "output_text": "",
1755
+ "raw_stdout": "",
1756
+ "raw_stderr": "",
1757
+ "exit_code": None,
1758
+ "success": False,
1759
+ "reason": "",
1760
+ "transcript": "",
1761
+ }
1762
+
1763
+ if decision.get("decision") != "external-cli":
1764
+ payload["reason"] = f"Route did not resolve to external-cli: {decision.get('decision')}"
1765
+ return payload
1766
+
1767
+ cli_id = str(decision["cli"])
1768
+ model = str(decision["model"])
1769
+ adapter = adapter_by_id(effective_policy, cli_id)
1770
+ if adapter is None:
1771
+ raise RouterConfigError(f"Unknown CLI id: {cli_id}")
1772
+ command_argv = resolve_command_argv(adapter.command)
1773
+ command_path = command_argv[0] if command_argv else None
1774
+ payload["resolved_command"] = _command_display(tuple(command_argv)) if command_argv and len(command_argv) > 1 else command_path
1775
+ if command_path is None:
1776
+ payload["reason"] = f"Executable for CLI {cli_id!r} was not found."
1777
+ return payload
1778
+
1779
+ builtin_adapter = BUILTIN_ADAPTER_BY_ID.get(adapter.id)
1780
+ template_overridden = builtin_adapter is not None and adapter.invoke_template != builtin_adapter.invoke_template
1781
+ transport_overridden = builtin_adapter is not None and adapter.transport != builtin_adapter.transport
1782
+
1783
+ if adapter.id == "codex" and adapter.transport == "app-server":
1784
+ invocation = _invoke_codex_app_server(
1785
+ command_argv=command_argv,
1786
+ repo_root=repo_root,
1787
+ model=model,
1788
+ prompt=prompt,
1789
+ timeout_ms=effective_timeout_ms,
1790
+ )
1791
+ payload.update(
1792
+ {
1793
+ "transport": invocation["transport"],
1794
+ "output_text": str(invocation["stdout"]).strip(),
1795
+ "raw_stdout": str(invocation["stdout"]),
1796
+ "raw_stderr": str(invocation["stderr"]),
1797
+ "exit_code": invocation["exit_code"],
1798
+ "success": invocation["success"],
1799
+ "reason": invocation["reason"],
1800
+ "transcript": invocation["transcript"],
1801
+ }
1802
+ )
1803
+ return payload
1804
+
1805
+ if adapter.id == "kimi" and not template_overridden and not transport_overridden:
1806
+ completed = _run_command(
1807
+ [
1808
+ *command_argv,
1809
+ "--model",
1810
+ model,
1811
+ "--work-dir",
1812
+ str(repo_root),
1813
+ "--print",
1814
+ "--output-format",
1815
+ "stream-json",
1816
+ "--max-ralph-iterations",
1817
+ "0",
1818
+ "--prompt",
1819
+ prompt,
1820
+ ],
1821
+ timeout_ms=effective_timeout_ms,
1822
+ cwd=repo_root,
1823
+ )
1824
+ output_text = _normalize_kimi_output(completed.stdout)
1825
+ payload.update(
1826
+ {
1827
+ "output_text": output_text,
1828
+ "raw_stdout": completed.stdout,
1829
+ "raw_stderr": _strip_ansi(completed.stderr),
1830
+ "exit_code": completed.returncode,
1831
+ "success": bool(output_text) and completed.returncode == 0,
1832
+ "reason": (
1833
+ "Prompt completed through Kimi."
1834
+ if output_text and completed.returncode == 0
1835
+ else f"Kimi returned output with exit code {completed.returncode}."
1836
+ if output_text
1837
+ else f"Kimi invocation failed with exit code {completed.returncode}."
1838
+ ),
1839
+ }
1840
+ )
1841
+ return payload
1842
+
1843
+ if adapter.id == "opencode" and not template_overridden and not transport_overridden:
1844
+ completed = _run_command(
1845
+ [
1846
+ *command_argv,
1847
+ "run",
1848
+ "--model",
1849
+ model,
1850
+ "--format",
1851
+ "json",
1852
+ "--dir",
1853
+ str(repo_root),
1854
+ prompt,
1855
+ ],
1856
+ timeout_ms=effective_timeout_ms,
1857
+ cwd=repo_root,
1858
+ )
1859
+ output_text = _normalize_opencode_output(completed.stdout)
1860
+ payload.update(
1861
+ {
1862
+ "output_text": output_text,
1863
+ "raw_stdout": completed.stdout,
1864
+ "raw_stderr": _strip_ansi(completed.stderr),
1865
+ "exit_code": completed.returncode,
1866
+ "success": bool(output_text) and completed.returncode == 0,
1867
+ "reason": (
1868
+ "Prompt completed through opencode."
1869
+ if output_text and completed.returncode == 0
1870
+ else f"opencode returned output with exit code {completed.returncode}."
1871
+ if output_text
1872
+ else f"opencode invocation failed with exit code {completed.returncode}."
1873
+ ),
1874
+ }
1875
+ )
1876
+ return payload
1877
+
1878
+ completed = _run_template_invocation(
1879
+ command_argv=command_argv,
1880
+ adapter=adapter,
1881
+ repo_root=repo_root,
1882
+ model=model,
1883
+ prompt=prompt,
1884
+ timeout_ms=effective_timeout_ms,
1885
+ )
1886
+ output_text = completed.stdout.strip()
1887
+ payload.update(
1888
+ {
1889
+ "output_text": output_text,
1890
+ "raw_stdout": completed.stdout,
1891
+ "raw_stderr": _strip_ansi(completed.stderr),
1892
+ "exit_code": completed.returncode,
1893
+ "success": bool(output_text),
1894
+ "reason": (
1895
+ f"Prompt completed through {cli_id}."
1896
+ if output_text and completed.returncode == 0
1897
+ else f"{cli_id} returned output with exit code {completed.returncode}."
1898
+ if output_text
1899
+ else f"{cli_id} invocation failed with exit code {completed.returncode}."
1900
+ ),
1901
+ }
1902
+ )
1903
+ return payload
1904
+
1905
+
1906
+ def configure_verified_routes(
1907
+ repo_root: Path,
1908
+ *,
1909
+ assignments: dict[str, dict[str, str]],
1910
+ timeout_ms: int | None = None,
1911
+ ) -> dict[str, object]:
1912
+ policy = load_policy(repo_root)
1913
+ normalized_assignments: dict[str, dict[str, str]] = {}
1914
+ for role, binding in assignments.items():
1915
+ canonical_role = canonicalize_router_role(role)
1916
+ if canonical_role in normalized_assignments:
1917
+ raise RouterConfigError(f"Duplicate role assignment provided for {canonical_role}.")
1918
+ normalized_assignments[canonical_role] = binding
1919
+ verification_results: dict[str, dict[str, object]] = {}
1920
+ verification_cache: dict[tuple[str, str], dict[str, object]] = {}
1921
+ for role, binding in normalized_assignments.items():
1922
+ cli_id = binding["cli"]
1923
+ model = binding["model"]
1924
+ cache_key = (cli_id, model)
1925
+ cached_result = verification_cache.get(cache_key)
1926
+ if cached_result is None:
1927
+ cached_result = verify_route_binding(
1928
+ repo_root,
1929
+ role=role,
1930
+ cli_id=cli_id,
1931
+ model=model,
1932
+ timeout_ms=timeout_ms,
1933
+ policy=policy,
1934
+ )
1935
+ verification_cache[cache_key] = cached_result
1936
+ role_result = deepcopy(cached_result)
1937
+ role_result["role"] = role
1938
+ verification_results[role] = role_result
1939
+
1940
+ all_verified = all(result["verified"] for result in verification_results.values())
1941
+ if all_verified:
1942
+ updated_policy = deepcopy(policy)
1943
+ for role, binding in normalized_assignments.items():
1944
+ route = _as_dict(updated_policy["role_routes"][role], f"role_routes.{role}")
1945
+ route["cli"] = binding["cli"]
1946
+ route["model"] = binding["model"]
1947
+ write_json(router_policy_path(repo_root), validate_policy(updated_policy))
1948
+
1949
+ return {
1950
+ "saved": all_verified,
1951
+ "policy_path": normalize_repo_path(str(router_policy_path(repo_root).relative_to(repo_root))),
1952
+ "verification_results": verification_results,
1953
+ }
1954
+
1955
+
1956
+ def build_unresolved_prompt(*, roles: list[str], inventory: dict[str, object], reason: str, config_path: str, discovery_path: str) -> str:
1957
+ lines = ["I found these CLIs in this environment:"]
1958
+ for entry in inventory["clis"]:
1959
+ version = entry["version"] or "unknown version"
1960
+ models = entry["models"]
1961
+ model_suffix = f"; models: {', '.join(models)}" if models else ""
1962
+ lines.append(f"- {entry['id']} ({'available' if entry['available'] else 'unavailable'}, version {version}{model_suffix})")
1963
+ if len(lines) == 1:
1964
+ lines.append("- none discovered")
1965
+ lines.extend(
1966
+ [
1967
+ "",
1968
+ reason,
1969
+ f"Please choose a CLI and model for these roles in {config_path}.",
1970
+ f"For valid CLI ids and model ids, check {discovery_path}.",
1971
+ "",
1972
+ "Unresolved roles:",
1973
+ ]
1974
+ )
1975
+ lines.extend(f"- {role}" for role in roles)
1976
+ lines.extend(
1977
+ [
1978
+ "",
1979
+ "You can answer in a compact mapping like:",
1980
+ "analyst=codex:gpt-5",
1981
+ "code-reviewer=kimi:kimi-code/kimi-for-coding",
1982
+ ]
1983
+ )
1984
+ return "\n".join(lines)
1985
+
1986
+
1987
+ def resolve_route(
1988
+ repo_root: Path,
1989
+ *,
1990
+ role: str,
1991
+ timeout_ms: int | None = None,
1992
+ write_discovery: bool = True,
1993
+ ) -> dict[str, object]:
1994
+ resolved_role = canonicalize_router_role(role)
1995
+ config_path = normalize_repo_path(str(router_policy_path(repo_root).relative_to(repo_root)))
1996
+ discovery_path = normalize_repo_path(str(router_discovery_path(repo_root).relative_to(repo_root)))
1997
+ policy_error = ""
1998
+ try:
1999
+ policy = load_policy(repo_root)
2000
+ except RouterConfigError as exc:
2001
+ policy = None
2002
+ policy_error = str(exc)
2003
+
2004
+ inventory = probe_inventory(
2005
+ repo_root,
2006
+ timeout_ms=timeout_ms,
2007
+ probe_tool="recursive-router-probe",
2008
+ write_discovery=write_discovery,
2009
+ policy=policy,
2010
+ )
2011
+
2012
+ if policy is None:
2013
+ reason = policy_error or f"Missing routing policy file at {config_path}."
2014
+ return {
2015
+ "role": resolved_role,
2016
+ "decision": "ask-user",
2017
+ "cli": None,
2018
+ "model": None,
2019
+ "available": False,
2020
+ "fallback_used": False,
2021
+ "fallback": "ask",
2022
+ "reason": reason,
2023
+ "config_path": config_path,
2024
+ "discovery_path": discovery_path,
2025
+ "prompt": build_unresolved_prompt(
2026
+ roles=[resolved_role],
2027
+ inventory=inventory,
2028
+ reason=reason,
2029
+ config_path=config_path,
2030
+ discovery_path=discovery_path,
2031
+ ),
2032
+ }
2033
+
2034
+ if resolved_role not in policy["role_routes"]:
2035
+ raise RouterConfigError(f"Unknown router role: {role}")
2036
+
2037
+ defaults = policy["defaults"]
2038
+ route = policy["role_routes"][resolved_role]
2039
+ if not route["enabled"] or route["mode"] == "local-only":
2040
+ return {
2041
+ "role": resolved_role,
2042
+ "decision": "local-only",
2043
+ "cli": None,
2044
+ "model": None,
2045
+ "available": True,
2046
+ "fallback_used": False,
2047
+ "fallback": route["fallback"],
2048
+ "reason": f"role_routes.{resolved_role} is configured for local-only execution.",
2049
+ "config_path": config_path,
2050
+ "discovery_path": discovery_path,
2051
+ }
2052
+
2053
+ selected_cli = route["cli"]
2054
+ selected_model = route["model"]
2055
+ reason_parts: list[str] = [f"resolved from role_routes.{resolved_role}"]
2056
+ available_entries = _available_cli_entries(inventory)
2057
+
2058
+ if selected_cli is None:
2059
+ if defaults["allow_auto_assign_if_single_cli"] and len(available_entries) == 1:
2060
+ selected_cli = available_entries[0]["id"]
2061
+ reason_parts = ["auto-assigned the only discovered CLI for the unresolved role route"]
2062
+ else:
2063
+ policy_choice = defaults["when_role_unconfigured"]
2064
+ if policy_choice == "fallback-local":
2065
+ return {
2066
+ "role": resolved_role,
2067
+ "decision": "fallback-local",
2068
+ "cli": None,
2069
+ "model": None,
2070
+ "available": False,
2071
+ "fallback_used": True,
2072
+ "fallback": route["fallback"],
2073
+ "reason": f"role_routes.{resolved_role}.cli is unconfigured and defaults.when_role_unconfigured=fallback-local.",
2074
+ "config_path": config_path,
2075
+ "discovery_path": discovery_path,
2076
+ }
2077
+ if policy_choice == "block":
2078
+ return {
2079
+ "role": resolved_role,
2080
+ "decision": "blocked",
2081
+ "cli": None,
2082
+ "model": None,
2083
+ "available": False,
2084
+ "fallback_used": False,
2085
+ "fallback": route["fallback"],
2086
+ "reason": f"role_routes.{resolved_role}.cli is unconfigured and defaults.when_role_unconfigured=block.",
2087
+ "config_path": config_path,
2088
+ "discovery_path": discovery_path,
2089
+ }
2090
+ prompt_reason = f"role_routes.{resolved_role}.cli is unresolved."
2091
+ return {
2092
+ "role": resolved_role,
2093
+ "decision": "ask-user",
2094
+ "cli": None,
2095
+ "model": None,
2096
+ "available": False,
2097
+ "fallback_used": False,
2098
+ "fallback": "ask",
2099
+ "reason": prompt_reason,
2100
+ "config_path": config_path,
2101
+ "discovery_path": discovery_path,
2102
+ "prompt": build_unresolved_prompt(
2103
+ roles=[resolved_role],
2104
+ inventory=inventory,
2105
+ reason=prompt_reason,
2106
+ config_path=config_path,
2107
+ discovery_path=discovery_path,
2108
+ ),
2109
+ }
2110
+
2111
+ cli_entry = _cli_entry_by_id(inventory, selected_cli)
2112
+ if cli_entry is None or not cli_entry["available"]:
2113
+ policy_choice = defaults["when_cli_unavailable"]
2114
+ reason = f"Configured CLI {selected_cli!r} is unavailable in the current environment."
2115
+ if policy_choice == "ask":
2116
+ return {
2117
+ "role": resolved_role,
2118
+ "decision": "ask-user",
2119
+ "cli": selected_cli,
2120
+ "model": selected_model,
2121
+ "available": False,
2122
+ "fallback_used": False,
2123
+ "fallback": "ask",
2124
+ "reason": reason,
2125
+ "config_path": config_path,
2126
+ "discovery_path": discovery_path,
2127
+ "prompt": build_unresolved_prompt(
2128
+ roles=[resolved_role],
2129
+ inventory=inventory,
2130
+ reason=reason,
2131
+ config_path=config_path,
2132
+ discovery_path=discovery_path,
2133
+ ),
2134
+ }
2135
+ if policy_choice == "block":
2136
+ return {
2137
+ "role": resolved_role,
2138
+ "decision": "blocked",
2139
+ "cli": selected_cli,
2140
+ "model": selected_model,
2141
+ "available": False,
2142
+ "fallback_used": False,
2143
+ "fallback": route["fallback"],
2144
+ "reason": reason,
2145
+ "config_path": config_path,
2146
+ "discovery_path": discovery_path,
2147
+ }
2148
+ return {
2149
+ "role": resolved_role,
2150
+ "decision": "fallback-local",
2151
+ "cli": selected_cli,
2152
+ "model": selected_model,
2153
+ "available": False,
2154
+ "fallback_used": True,
2155
+ "fallback": route["fallback"],
2156
+ "reason": reason,
2157
+ "config_path": config_path,
2158
+ "discovery_path": discovery_path,
2159
+ }
2160
+
2161
+ models = list(cli_entry["models"])
2162
+ if selected_model is None:
2163
+ policy_choice = defaults["when_model_unknown"]
2164
+ reason = f"{'; '.join(reason_parts)}; model is unresolved for role {resolved_role}."
2165
+ if policy_choice == "fallback-local":
2166
+ return {
2167
+ "role": resolved_role,
2168
+ "decision": "fallback-local",
2169
+ "cli": selected_cli,
2170
+ "model": None,
2171
+ "available": True,
2172
+ "fallback_used": True,
2173
+ "fallback": route["fallback"],
2174
+ "reason": reason,
2175
+ "config_path": config_path,
2176
+ "discovery_path": discovery_path,
2177
+ }
2178
+ return {
2179
+ "role": resolved_role,
2180
+ "decision": "ask-user" if policy_choice in {"ask", "use-as-literal"} else "blocked",
2181
+ "cli": selected_cli,
2182
+ "model": None,
2183
+ "available": True,
2184
+ "fallback_used": False,
2185
+ "fallback": "ask" if policy_choice in {"ask", "use-as-literal"} else route["fallback"],
2186
+ "reason": reason,
2187
+ "config_path": config_path,
2188
+ "discovery_path": discovery_path,
2189
+ "prompt": build_unresolved_prompt(
2190
+ roles=[resolved_role],
2191
+ inventory=inventory,
2192
+ reason=reason,
2193
+ config_path=config_path,
2194
+ discovery_path=discovery_path,
2195
+ ),
2196
+ }
2197
+
2198
+ if models and selected_model not in models:
2199
+ policy_choice = defaults["when_model_unknown"]
2200
+ reason = f"{'; '.join(reason_parts)}; configured model {selected_model!r} was not listed by CLI {selected_cli!r}."
2201
+ if policy_choice == "use-as-literal":
2202
+ return {
2203
+ "role": resolved_role,
2204
+ "decision": "external-cli",
2205
+ "cli": selected_cli,
2206
+ "model": selected_model,
2207
+ "transport": cli_entry.get("transport"),
2208
+ "available": True,
2209
+ "fallback_used": False,
2210
+ "fallback": route["fallback"],
2211
+ "reason": reason + " Using the configured model string literally per policy.",
2212
+ "config_path": config_path,
2213
+ "discovery_path": discovery_path,
2214
+ }
2215
+ if policy_choice == "fallback-local":
2216
+ return {
2217
+ "role": resolved_role,
2218
+ "decision": "fallback-local",
2219
+ "cli": selected_cli,
2220
+ "model": selected_model,
2221
+ "available": True,
2222
+ "fallback_used": True,
2223
+ "fallback": route["fallback"],
2224
+ "reason": reason,
2225
+ "config_path": config_path,
2226
+ "discovery_path": discovery_path,
2227
+ }
2228
+ return {
2229
+ "role": resolved_role,
2230
+ "decision": "ask-user",
2231
+ "cli": selected_cli,
2232
+ "model": selected_model,
2233
+ "available": True,
2234
+ "fallback_used": False,
2235
+ "fallback": "ask",
2236
+ "reason": reason,
2237
+ "config_path": config_path,
2238
+ "discovery_path": discovery_path,
2239
+ "prompt": build_unresolved_prompt(
2240
+ roles=[resolved_role],
2241
+ inventory=inventory,
2242
+ reason=reason,
2243
+ config_path=config_path,
2244
+ discovery_path=discovery_path,
2245
+ ),
2246
+ }
2247
+
2248
+ if not models and defaults["when_model_unknown"] == "ask":
2249
+ reason = f"{'; '.join(reason_parts)}; model {selected_model!r} could not be verified because CLI {selected_cli!r} does not advertise models."
2250
+ return {
2251
+ "role": resolved_role,
2252
+ "decision": "ask-user",
2253
+ "cli": selected_cli,
2254
+ "model": selected_model,
2255
+ "available": True,
2256
+ "fallback_used": False,
2257
+ "fallback": "ask",
2258
+ "reason": reason,
2259
+ "config_path": config_path,
2260
+ "discovery_path": discovery_path,
2261
+ "prompt": build_unresolved_prompt(
2262
+ roles=[resolved_role],
2263
+ inventory=inventory,
2264
+ reason=reason,
2265
+ config_path=config_path,
2266
+ discovery_path=discovery_path,
2267
+ ),
2268
+ }
2269
+
2270
+ return {
2271
+ "role": resolved_role,
2272
+ "decision": "external-cli",
2273
+ "cli": selected_cli,
2274
+ "model": selected_model,
2275
+ "transport": cli_entry.get("transport"),
2276
+ "available": True,
2277
+ "fallback_used": False,
2278
+ "fallback": route["fallback"],
2279
+ "reason": "; ".join(reason_parts),
2280
+ "config_path": config_path,
2281
+ "discovery_path": discovery_path,
2282
+ }