agent-crossbar 0.2.0__py3-none-any.whl

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 (44) hide show
  1. agent_crossbar/__init__.py +1 -0
  2. agent_crossbar/acp_client.py +422 -0
  3. agent_crossbar/acp_lifecycle.py +240 -0
  4. agent_crossbar/acp_runtime.py +389 -0
  5. agent_crossbar/adapters/__init__.py +5 -0
  6. agent_crossbar/adapters/base.py +89 -0
  7. agent_crossbar/adapters/chatgpt_pro.py +20 -0
  8. agent_crossbar/adapters/claude.py +510 -0
  9. agent_crossbar/adapters/claude_model_probe.py +781 -0
  10. agent_crossbar/adapters/codex.py +145 -0
  11. agent_crossbar/adapters/opencode.py +253 -0
  12. agent_crossbar/adapters/reasonix.py +20 -0
  13. agent_crossbar/adapters/registry.py +21 -0
  14. agent_crossbar/agent_runner.py +435 -0
  15. agent_crossbar/cli.py +109 -0
  16. agent_crossbar/context.py +33 -0
  17. agent_crossbar/discovery.py +416 -0
  18. agent_crossbar/discovery_runner.py +324 -0
  19. agent_crossbar/env_compat.py +55 -0
  20. agent_crossbar/envelope.py +293 -0
  21. agent_crossbar/jobs.py +837 -0
  22. agent_crossbar/model_cache.py +204 -0
  23. agent_crossbar/models.py +67 -0
  24. agent_crossbar/profiles/__init__.py +88 -0
  25. agent_crossbar/profiles/chatgpt_pro.py +33 -0
  26. agent_crossbar/profiles/claude.py +41 -0
  27. agent_crossbar/profiles/codex.py +39 -0
  28. agent_crossbar/profiles/opencode.py +49 -0
  29. agent_crossbar/profiles/reasonix.py +35 -0
  30. agent_crossbar/providers.py +461 -0
  31. agent_crossbar/readiness.py +785 -0
  32. agent_crossbar/redaction.py +66 -0
  33. agent_crossbar/runner.py +2974 -0
  34. agent_crossbar/server.py +1156 -0
  35. agent_crossbar/shell_server.py +68 -0
  36. agent_crossbar/telemetry.py +266 -0
  37. agent_crossbar/tmux_output.py +241 -0
  38. agent_crossbar/validation.py +276 -0
  39. agent_crossbar-0.2.0.dist-info/METADATA +12 -0
  40. agent_crossbar-0.2.0.dist-info/RECORD +44 -0
  41. agent_crossbar-0.2.0.dist-info/WHEEL +4 -0
  42. agent_crossbar-0.2.0.dist-info/entry_points.txt +3 -0
  43. agent_crossbar-0.2.0.dist-info/licenses/LICENSE +21 -0
  44. agent_harness_mcp/__init__.py +87 -0
@@ -0,0 +1 @@
1
+ __version__ = "0.2.0"
@@ -0,0 +1,422 @@
1
+ """ACP Client — async one-shot agent prompt via Agent Client Protocol SDK.
2
+
3
+ Launches a provider command through ``acp.spawn_agent_process``, initializes
4
+ protocol v1, creates a session for *cwd*, optionally sets a model via
5
+ ``config_option``, sends one text prompt, accumulates assistant text from
6
+ ``session/update`` notifications, and returns a typed :class:`AcpResult`.
7
+
8
+ Permission policy:
9
+
10
+ * ``read_only`` tools → **denied** (selects ``reject_once``).
11
+ * ``edit_local`` tools → **allowed** with ``allow_once`` only; ``allow_always``
12
+ is treated as escalating and skipped.
13
+
14
+ Timeouts and cancellation are supported via ``asyncio.wait_for`` with clean
15
+ child-process termination through the context-manager.
16
+
17
+ The module is a focused abstraction layer; it does NOT integrate with
18
+ ``server.py``, ``jobs.py``, or the job store.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import asyncio
24
+ import logging
25
+ from dataclasses import dataclass
26
+ from typing import Any
27
+
28
+ from acp import PROTOCOL_VERSION, spawn_agent_process, text_block
29
+ from acp.schema import (
30
+ AgentMessageChunk,
31
+ AllowedOutcome,
32
+ ClientCapabilities,
33
+ DeniedOutcome,
34
+ PermissionOption,
35
+ RequestPermissionResponse,
36
+ SessionConfigOptionSelect,
37
+ SessionConfigSelectGroup,
38
+ SessionConfigSelectOption,
39
+ ToolCallUpdate,
40
+ )
41
+
42
+ from .models import Autonomy
43
+
44
+ logger = logging.getLogger(__name__)
45
+
46
+ # ── Public types ────────────────────────────────────────────────────────
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class AcpResult:
51
+ """Immutable result of a one-shot ACP prompt."""
52
+
53
+ output: str
54
+ stop_reason: str
55
+ session_id: str
56
+
57
+
58
+ class AcpError(Exception):
59
+ """Base exception for all ACP client errors."""
60
+
61
+
62
+ class AcpTimeoutError(AcpError):
63
+ """The ACP prompt exceeded the configured timeout.
64
+
65
+ ``stage`` distinguishes a timeout that struck before the prompt was
66
+ ever dispatched to the agent (``"prompt_delivery"``) from one that
67
+ struck while awaiting the agent's response to an already-dispatched
68
+ prompt (``"execution"``, the default) — see ``run_acp_prompt``.
69
+ """
70
+
71
+ def __init__(self, message: str, *, stage: str = "execution") -> None:
72
+ super().__init__(message)
73
+ self.stage = stage
74
+
75
+
76
+ class AcpProtocolError(AcpError):
77
+ """The ACP protocol sequence failed — e.g. session not created.
78
+
79
+ ``stage`` distinguishes a failure that struck before the prompt was
80
+ ever dispatched to the agent (handshake, session creation, model
81
+ config — ``"prompt_delivery"``) from one that struck while the agent
82
+ was already processing an already-dispatched prompt (``"execution"``,
83
+ the default) — mirrors :class:`AcpTimeoutError`.
84
+ """
85
+
86
+ def __init__(self, message: str, *, stage: str = "execution") -> None:
87
+ super().__init__(message)
88
+ self.stage = stage
89
+
90
+
91
+ class AcpLaunchError(AcpError):
92
+ """The ACP provider process could not be launched."""
93
+
94
+
95
+ # ── Internal :class:`Client` implementation ─────────────────────────────
96
+
97
+
98
+ class _OneShotClient:
99
+ """Implements the ``acp.Client`` protocol for a single prompt.
100
+
101
+ Accumulates ``AgentMessageChunk`` text into a list and selects
102
+ permission options according to the configured autonomy level.
103
+ """
104
+
105
+ def __init__(self, autonomy: Autonomy) -> None:
106
+ self._autonomy = autonomy
107
+ self._session_id: str | None = None
108
+ self._output_parts: list[str] = []
109
+ self._stop_reason = "unknown"
110
+ self.prompt_sent = False
111
+
112
+ # -- Client protocol --------------------------------------------------
113
+
114
+ def on_connect(self, conn: Any) -> None:
115
+ pass
116
+
117
+ async def request_permission(
118
+ self,
119
+ session_id: str,
120
+ tool_call: ToolCallUpdate,
121
+ options: list[PermissionOption],
122
+ **kwargs: Any,
123
+ ) -> RequestPermissionResponse:
124
+ if self._autonomy is Autonomy.EDIT_LOCAL and getattr(tool_call, "kind", None) == "edit":
125
+ return _select_allow_once(options)
126
+ return _select_reject_once(options)
127
+
128
+ async def session_update(
129
+ self,
130
+ session_id: str,
131
+ update: Any,
132
+ **kwargs: Any,
133
+ ) -> None:
134
+ if isinstance(update, AgentMessageChunk):
135
+ content = getattr(update, "content", None)
136
+ if content is not None and getattr(content, "type", None) == "text":
137
+ text = getattr(content, "text", "")
138
+ if text:
139
+ self._output_parts.append(str(text))
140
+
141
+ async def write_text_file(self, session_id: str, path: str, content: str, **kwargs: Any) -> Any:
142
+ return None # Not supported in one-shot mode
143
+
144
+ async def read_text_file(
145
+ self,
146
+ session_id: str,
147
+ path: str,
148
+ line: int | None = None,
149
+ limit: int | None = None,
150
+ **kwargs: Any,
151
+ ) -> Any:
152
+ # Return an empty read — one-shot client doesn't serve files
153
+ from acp.schema import ReadTextFileResponse
154
+
155
+ return ReadTextFileResponse(content="")
156
+
157
+ async def create_terminal(
158
+ self,
159
+ session_id: str,
160
+ command: str,
161
+ args: list[str] | None = None,
162
+ env: list[Any] | None = None,
163
+ cwd: str | None = None,
164
+ output_byte_limit: int | None = None,
165
+ **kwargs: Any,
166
+ ) -> Any:
167
+ raise AcpProtocolError("Terminal creation is not supported in one-shot mode")
168
+
169
+ async def terminal_output(self, session_id: str, terminal_id: str, **kwargs: Any) -> Any:
170
+ raise AcpProtocolError("Terminal output is not supported in one-shot mode")
171
+
172
+ async def release_terminal(self, session_id: str, terminal_id: str, **kwargs: Any) -> Any:
173
+ return None
174
+
175
+ async def wait_for_terminal_exit(self, session_id: str, terminal_id: str, **kwargs: Any) -> Any:
176
+ raise AcpProtocolError("Terminal wait is not supported in one-shot mode")
177
+
178
+ async def kill_terminal(self, session_id: str, terminal_id: str, **kwargs: Any) -> Any:
179
+ return None
180
+
181
+ async def create_elicitation(self, message: str, mode: Any, **kwargs: Any) -> Any:
182
+ raise AcpProtocolError("Elicitation is not supported in one-shot mode")
183
+
184
+ async def complete_elicitation(self, elicitation_id: str, **kwargs: Any) -> None:
185
+ pass
186
+
187
+ async def ext_method(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
188
+ raise AcpProtocolError(f"Extension method {method!r} is not supported in one-shot mode")
189
+
190
+ async def ext_notification(self, method: str, params: dict[str, Any]) -> None:
191
+ pass
192
+
193
+
194
+ # ── Permission helpers ──────────────────────────────────────────────────
195
+
196
+
197
+ def _select_reject_once(
198
+ options: list[PermissionOption],
199
+ ) -> RequestPermissionResponse:
200
+ """Select reject_once when offered, otherwise cancel."""
201
+ for opt in options:
202
+ if getattr(opt, "kind", None) == "reject_once":
203
+ return RequestPermissionResponse(
204
+ outcome=AllowedOutcome(option_id=opt.option_id, outcome="selected")
205
+ )
206
+ return RequestPermissionResponse(outcome=DeniedOutcome(outcome="cancelled"))
207
+
208
+
209
+ def _select_allow_once(
210
+ options: list[PermissionOption],
211
+ ) -> RequestPermissionResponse:
212
+ """Select the ``allow_once`` (non-escalating) option.
213
+
214
+ Deliberately skips ``allow_always`` — that is an escalation.
215
+ Falls back to ``reject_once`` if no ``allow_once`` is present.
216
+ """
217
+ for opt in options:
218
+ if getattr(opt, "kind", None) == "allow_once":
219
+ return RequestPermissionResponse(
220
+ outcome=AllowedOutcome(option_id=opt.option_id, outcome="selected")
221
+ )
222
+ return _select_reject_once(options)
223
+
224
+
225
+ # ── Model config helpers ───────────────────────────────────────────────
226
+
227
+
228
+ def _find_model_config_option(
229
+ config_options: list[Any] | None,
230
+ ) -> SessionConfigOptionSelect | None:
231
+ """Find the session config option for model selection.
232
+
233
+ Prefers `category=='model'` over `id=='model'` as a fallback.
234
+ Returns ``None`` when no matching select option is found.
235
+ """
236
+ if not config_options:
237
+ return None
238
+ # Prefer category == "model"
239
+ for opt in config_options:
240
+ if isinstance(opt, SessionConfigOptionSelect) and getattr(opt, "category", None) == "model":
241
+ return opt
242
+ # Fallback: id == "model"
243
+ for opt in config_options:
244
+ if isinstance(opt, SessionConfigOptionSelect) and getattr(opt, "id", None) == "model":
245
+ return opt
246
+ return None
247
+
248
+
249
+ def _model_value_available(option: SessionConfigOptionSelect, value: str) -> bool:
250
+ """Check if *value* exists among *option*'s flat options or grouped options."""
251
+ for entry in option.options:
252
+ if isinstance(entry, SessionConfigSelectOption) and entry.value == value:
253
+ return True
254
+ if isinstance(entry, SessionConfigSelectGroup):
255
+ for sub in entry.options:
256
+ if sub.value == value:
257
+ return True
258
+ return False
259
+
260
+
261
+ # ── Public API ──────────────────────────────────────────────────────────
262
+
263
+
264
+ async def run_acp_prompt(
265
+ provider_command: list[str],
266
+ prompt_text: str,
267
+ cwd: str,
268
+ *,
269
+ timeout: float | None = None,
270
+ autonomy: str | Autonomy = Autonomy.READ_ONLY,
271
+ model: str | None = None,
272
+ ) -> AcpResult:
273
+ """Launch a provider, optionally set model, run one ACP prompt, and return the result.
274
+
275
+ Sequence: ``initialize`` → ``session/new`` → (optional ``set_config_option``
276
+ for model) → ``session/prompt``.
277
+
278
+ During ``session/prompt`` the agent may send ``session/update``
279
+ notifications carrying ``AgentMessageChunk`` — those are accumulated
280
+ into :attr:`AcpResult.output`. Permission requests are answered
281
+ automatically according to the configured autonomy level.
282
+
283
+ The prompt text is NEVER included in any exception message or log
284
+ record — only a byte-length hint is emitted.
285
+
286
+ Args:
287
+ provider_command: ``argv`` list for the ACP agent process.
288
+ The first element is the executable; the rest are args.
289
+ prompt_text: Prompt content delivered via
290
+ ``[text_block(prompt_text)]``.
291
+ cwd: Working directory passed to ``session/new``.
292
+ timeout: Optional seconds for the entire operation (including
293
+ launch). Exceeding this raises :class:`AcpTimeoutError`.
294
+ autonomy: Permission policy for ACP tool calls.
295
+ model: Optional model identifier. When provided, looks for a
296
+ ``SessionConfigOptionSelect`` with ``category=="model"`` (or
297
+ ``id=="model"`` as fallback) in the ``NewSessionResponse``
298
+ config options, verifies the model value is available, and
299
+ calls ``set_config_option`` before the prompt. When ``None``,
300
+ no config option is set.
301
+
302
+ Returns:
303
+ ``AcpResult`` with ``output``, ``stop_reason``, and ``session_id``.
304
+
305
+ Raises:
306
+ AcpTimeoutError: The operation exceeded *timeout*.
307
+ AcpProtocolError: The protocol handshake failed, the requested
308
+ model is unavailable, or ``set_config_option`` failed.
309
+ AcpLaunchError: The provider process could not be started.
310
+ """
311
+ try:
312
+ normalized_autonomy = Autonomy(autonomy)
313
+ except ValueError:
314
+ raise AcpProtocolError(f"Invalid autonomy: {autonomy}", stage="prompt_delivery") from None
315
+
316
+ client_impl = _OneShotClient(normalized_autonomy)
317
+
318
+ async def _run() -> AcpResult:
319
+ try:
320
+ async with spawn_agent_process(
321
+ client_impl,
322
+ provider_command[0],
323
+ *provider_command[1:],
324
+ cwd=cwd,
325
+ ) as (conn, _process):
326
+ # 1. initialize
327
+ init_response = await conn.initialize(
328
+ protocol_version=PROTOCOL_VERSION,
329
+ client_capabilities=ClientCapabilities(),
330
+ )
331
+ logger.debug(
332
+ "ACP initialized: protocol_version=%s",
333
+ getattr(init_response, "protocol_version", None),
334
+ )
335
+
336
+ # 2. session/new
337
+ session_response = await conn.new_session(cwd=cwd)
338
+ session_id: str = session_response.session_id
339
+ client_impl._session_id = session_id
340
+ logger.debug("ACP session created: id=%s", session_id)
341
+
342
+ # 2b. optional model config
343
+ if model is not None:
344
+ config_options: list[Any] | None = getattr(
345
+ session_response, "config_options", None
346
+ )
347
+ model_option = _find_model_config_option(config_options)
348
+ if model_option is None:
349
+ raise AcpProtocolError(
350
+ "No model config option available from agent",
351
+ stage="prompt_delivery",
352
+ )
353
+ if not _model_value_available(model_option, model):
354
+ raise AcpProtocolError(
355
+ f"Requested model {model!r} not available from agent",
356
+ stage="prompt_delivery",
357
+ )
358
+ try:
359
+ set_response = await conn.set_config_option(
360
+ config_id=model_option.id,
361
+ session_id=session_id,
362
+ value=model,
363
+ )
364
+ except Exception as exc:
365
+ raise AcpProtocolError(
366
+ "Failed to set model config option", stage="prompt_delivery"
367
+ ) from exc
368
+
369
+ # Validate that the agent accepted the model value
370
+ response_options: list[Any] | None = getattr(
371
+ set_response, "config_options", None
372
+ )
373
+ response_model_option = _find_model_config_option(response_options)
374
+ if (
375
+ response_model_option is None
376
+ or response_model_option.current_value != model
377
+ ):
378
+ raise AcpProtocolError(
379
+ f"Agent rejected model {model!r}: the config option was not applied",
380
+ stage="prompt_delivery",
381
+ )
382
+
383
+ # 3. session/prompt
384
+ client_impl.prompt_sent = True
385
+ prompt_response = await conn.prompt(
386
+ session_id=session_id,
387
+ prompt=[text_block(prompt_text)],
388
+ )
389
+ stop_reason = getattr(prompt_response, "stop_reason", None) or "unknown"
390
+ client_impl._stop_reason = stop_reason
391
+ logger.debug(
392
+ "ACP prompt finished: stop_reason=%s prompt_bytes=%d",
393
+ stop_reason,
394
+ len(prompt_text.encode("utf-8")),
395
+ )
396
+
397
+ output = (
398
+ "".join(client_impl._output_parts)
399
+ if client_impl._output_parts
400
+ else "(no output)"
401
+ )
402
+
403
+ return AcpResult(
404
+ output=output,
405
+ stop_reason=stop_reason,
406
+ session_id=session_id,
407
+ )
408
+ except FileNotFoundError as exc:
409
+ raise AcpLaunchError(f"Provider binary not found: {provider_command[0]}") from exc
410
+ except AcpError:
411
+ raise
412
+ except Exception as exc:
413
+ stage = "execution" if client_impl.prompt_sent else "prompt_delivery"
414
+ raise AcpProtocolError(f"ACP protocol sequence failed: {exc}", stage=stage) from exc
415
+
416
+ try:
417
+ if timeout is not None:
418
+ return await asyncio.wait_for(_run(), timeout=timeout)
419
+ return await _run()
420
+ except asyncio.TimeoutError:
421
+ stage = "execution" if client_impl.prompt_sent else "prompt_delivery"
422
+ raise AcpTimeoutError(f"ACP prompt timed out after {timeout:.1f}s", stage=stage) from None
@@ -0,0 +1,240 @@
1
+ """ACP server readiness checks for Codex and OpenCode providers.
2
+
3
+ These are CLIENT-SIDE preflight checks — they probe the local binaries
4
+ before attempting to spawn an ACP agent process. Returns structured
5
+ results with actionable remediation so callers can surface clear errors.
6
+
7
+ - Codex: no native ``codex acp`` subcommand. Uses the standalone
8
+ ``@agentclientprotocol/codex-acp`` package (>= 1.1.7), accessed via
9
+ ``pnpm dlx @agentclientprotocol/codex-acp@1.1.7 --version``.
10
+ - OpenCode: uses the native ``opencode acp`` subcommand directly.
11
+ Readiness probes ``opencode --version`` followed by a nonblocking
12
+ ``opencode acp --help`` to verify the subcommand is available.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ from typing import Any, Protocol
19
+
20
+ _CODEX_ACP_MIN_VERSION = (1, 1, 7)
21
+ _VERSION_RE = re.compile(r"(\d+)\.(\d+)\.(\d+)")
22
+
23
+
24
+ class SubprocessRunner(Protocol):
25
+ def run(self, args, *, timeout=None, cwd=None, env=None) -> Any: ...
26
+
27
+
28
+ def _parse_version(output: str):
29
+ match = _VERSION_RE.search(output)
30
+ if not match:
31
+ return None
32
+ return (int(match.group(1)), int(match.group(2)), int(match.group(3)))
33
+
34
+
35
+ def check_codex_acp_readiness(runner) -> dict:
36
+ # Step 1: pnpm --version
37
+ try:
38
+ pnpm_result = runner.run(["pnpm", "--version"], timeout=10)
39
+ except FileNotFoundError:
40
+ return {
41
+ "ready": False,
42
+ "state": "missing_binary",
43
+ "error_code": "pnpm_missing",
44
+ "remediation": (
45
+ "pnpm is not installed or not on PATH. "
46
+ "Install pnpm (https://pnpm.io/installation). "
47
+ "The harness uses `pnpm dlx @agentclientprotocol/codex-acp` "
48
+ "(>= 1.1.7)."
49
+ ),
50
+ "version": None,
51
+ }
52
+ except Exception as exc:
53
+ return {
54
+ "ready": False,
55
+ "state": "probe_failed",
56
+ "error_code": "pnpm_probe_failed",
57
+ "remediation": f"Failed to probe pnpm: {exc}",
58
+ "version": None,
59
+ }
60
+ if pnpm_result.returncode != 0:
61
+ return {
62
+ "ready": False,
63
+ "state": "missing_binary",
64
+ "error_code": "pnpm_broken",
65
+ "remediation": "pnpm is present but broken. Reinstall pnpm.",
66
+ "version": None,
67
+ }
68
+
69
+ # Step 2: codex --version
70
+ try:
71
+ codex_result = runner.run(["codex", "--version"], timeout=10)
72
+ except FileNotFoundError:
73
+ return {
74
+ "ready": False,
75
+ "state": "missing_binary",
76
+ "error_code": "codex_missing",
77
+ "remediation": (
78
+ "codex CLI is not installed or not on PATH. "
79
+ "Install the Codex CLI to use the codex provider."
80
+ ),
81
+ "version": None,
82
+ }
83
+ except Exception as exc:
84
+ return {
85
+ "ready": False,
86
+ "state": "probe_failed",
87
+ "error_code": "codex_probe_failed",
88
+ "remediation": f"Failed to probe codex: {exc}",
89
+ "version": None,
90
+ }
91
+ if codex_result.returncode != 0:
92
+ return {
93
+ "ready": False,
94
+ "state": "missing_binary",
95
+ "error_code": "codex_broken",
96
+ "remediation": "codex CLI is present but broken. Reinstall the Codex CLI.",
97
+ "version": None,
98
+ }
99
+
100
+ # Step 3: pnpm dlx @agentclientprotocol/codex-acp@1.1.7 --version
101
+ try:
102
+ result = runner.run(
103
+ ["pnpm", "dlx", "@agentclientprotocol/codex-acp@1.1.7", "--version"],
104
+ timeout=30,
105
+ )
106
+ except FileNotFoundError:
107
+ return {
108
+ "ready": False,
109
+ "state": "missing_binary",
110
+ "error_code": "pnpm_missing",
111
+ "remediation": (
112
+ "pnpm is not installed. Install pnpm and run: "
113
+ "pnpm dlx @agentclientprotocol/codex-acp@1.1.7 --version"
114
+ ),
115
+ "version": None,
116
+ }
117
+ except Exception as exc:
118
+ return {
119
+ "ready": False,
120
+ "state": "probe_failed",
121
+ "error_code": "codex_acp_probe_failed",
122
+ "remediation": (
123
+ f"Failed to probe @agentclientprotocol/codex-acp: {exc}. "
124
+ "Install with: pnpm dlx @agentclientprotocol/codex-acp@1.1.7"
125
+ ),
126
+ "version": None,
127
+ }
128
+
129
+ if result.returncode != 0:
130
+ return {
131
+ "ready": False,
132
+ "state": "probe_failed",
133
+ "error_code": "codex_acp_version_failed",
134
+ "remediation": (
135
+ f"@agentclientprotocol/codex-acp --version exited with "
136
+ f"code {result.returncode}. "
137
+ f"stderr: {(result.stderr or '')[:200]}. "
138
+ "Install with: pnpm dlx @agentclientprotocol/codex-acp@1.1.7"
139
+ ),
140
+ "version": None,
141
+ }
142
+
143
+ version = _parse_version(result.stdout)
144
+ if version is None:
145
+ return {
146
+ "ready": True,
147
+ "state": "ready",
148
+ "error_code": None,
149
+ "remediation": None,
150
+ "version": None,
151
+ }
152
+
153
+ if version < _CODEX_ACP_MIN_VERSION:
154
+ current = ".".join(str(v) for v in version)
155
+ required = ".".join(str(v) for v in _CODEX_ACP_MIN_VERSION)
156
+ return {
157
+ "ready": False,
158
+ "state": "version_too_low",
159
+ "error_code": "codex_acp_version_too_low",
160
+ "remediation": (
161
+ f"@agentclientprotocol/codex-acp version {current} is too old. "
162
+ f"Upgrade to >= {required}. "
163
+ f"Run: pnpm dlx @agentclientprotocol/codex-acp@{required}"
164
+ ),
165
+ "version": current,
166
+ }
167
+
168
+ return {
169
+ "ready": True,
170
+ "state": "ready",
171
+ "error_code": None,
172
+ "remediation": None,
173
+ "version": ".".join(str(v) for v in version),
174
+ }
175
+
176
+
177
+ def check_opencode_acp_readiness(runner) -> dict:
178
+ # Step 1: opencode --version
179
+ try:
180
+ opencode_result = runner.run(["opencode", "--version"], timeout=10)
181
+ except FileNotFoundError:
182
+ return {
183
+ "ready": False,
184
+ "state": "missing_binary",
185
+ "error_code": "opencode_missing",
186
+ "remediation": (
187
+ "opencode CLI is not installed or not on PATH. "
188
+ "Install the OpenCode CLI to use the opencode provider."
189
+ ),
190
+ }
191
+ except Exception as exc:
192
+ return {
193
+ "ready": False,
194
+ "state": "probe_failed",
195
+ "error_code": "opencode_probe_failed",
196
+ "remediation": f"Failed to probe opencode: {exc}",
197
+ }
198
+ if opencode_result.returncode != 0:
199
+ return {
200
+ "ready": False,
201
+ "state": "missing_binary",
202
+ "error_code": "opencode_broken",
203
+ "remediation": "opencode CLI is present but broken. Reinstall the OpenCode CLI.",
204
+ }
205
+
206
+ # Step 2: opencode acp --help (nonblocking probe)
207
+ try:
208
+ acp_result = runner.run(["opencode", "acp", "--help"], timeout=30)
209
+ except Exception as exc:
210
+ stderr_snippet = str(exc)[:200]
211
+ return {
212
+ "ready": False,
213
+ "state": "probe_failed",
214
+ "error_code": "opencode_acp_probe_failed",
215
+ "remediation": (
216
+ f"Failed to probe `opencode acp --help`: {stderr_snippet}. "
217
+ "Ensure the opencode CLI is installed and the `acp` subcommand is available."
218
+ ),
219
+ }
220
+
221
+ if acp_result.returncode != 0:
222
+ stderr_snippet = (acp_result.stderr or "")[:200]
223
+ return {
224
+ "ready": False,
225
+ "state": "unavailable",
226
+ "error_code": "opencode_acp_unavailable",
227
+ "remediation": (
228
+ f"`opencode acp --help` exited with code {acp_result.returncode}. "
229
+ f"stderr: {stderr_snippet}. "
230
+ "Ensure your opencode version supports the native `acp` subcommand. "
231
+ "Upgrade opencode if needed."
232
+ ),
233
+ }
234
+
235
+ return {
236
+ "ready": True,
237
+ "state": "ready",
238
+ "error_code": None,
239
+ "remediation": None,
240
+ }