22
33from __future__ import annotations
44
5+ import logging
56import os
67import shlex
78import shutil
9+ import subprocess
810from pathlib import Path
911
1012from codeframe .core .adapters .agent_adapter import AgentResult
1113from codeframe .core .adapters .subprocess_adapter import SubprocessAdapter
1214
15+ logger = logging .getLogger (__name__ )
16+
1317# Exit code used by kilo when the timeout is exceeded
1418_KILO_TIMEOUT_EXIT_CODE = 124
1519
1620
21+ #: The two incompatible CLIs that both answer to ``kilo``.
22+ _MODERN = "modern" # 7.x: `kilo run <message> --dir <path>`
23+ _LEGACY = "legacy" # 0.22.0: `kilo <prompt> --auto --workspace <path>`
24+
25+ #: Substring that appears in ``kilo --help`` only once ``run`` exists. Detection
26+ #: reads the CLI's own help rather than parsing ``--version``: #1015 requires
27+ #: that the invocation never be guessed from a version string, and this repo has
28+ #: been bitten three times by adapters that assumed a CLI surface (#913/#914/
29+ #: #1012). Help text is what the binary actually offers.
30+ _RUN_SUBCOMMAND_MARKER = "kilo run"
31+
32+ #: Linux caps a *single* argv entry at MAX_ARG_STRLEN — 128 KiB — independently
33+ #: of the much larger total ARG_MAX. Same constraint opencode hit in #913.
34+ _MAX_ARG_BYTES = 128 * 1024
35+
36+ #: Detection runs a subprocess, so it is cached per binary path for the process.
37+ _SURFACE_CACHE : dict [str , str ] = {}
38+
39+
40+ def _prompt_exceeds_argv (prompt : str ) -> bool :
41+ return len (prompt .encode ("utf-8" )) >= _MAX_ARG_BYTES
42+
43+
44+ def _detect_surface (binary_path : str ) -> str :
45+ """Which kilo CLI is installed, according to its own ``--help``.
46+
47+ ``@kilocode/cli`` was rewritten between 0.22.0 (2026-01-15) and 7.x
48+ (2026-07-29) — 213 releases apart. The invocations share nothing:
49+
50+ ============== ============================ ==========================
51+ \\ 0.22.0 7.4.17
52+ ============== ============================ ==========================
53+ usage ``kilocode [options] [prompt]`` ``kilo run [message..]``
54+ workspace ``--workspace <path>`` ``--dir <path>``
55+ ``--auto`` non-interactive **auto-approve ALL perms**
56+ ============== ============================ ==========================
57+
58+ That last row is why this cannot be a blind rename: on 7.x ``--auto`` is the
59+ old ``--yolo``, the permission bypass #916 established must stay off.
60+
61+ An unreadable or failing ``--help`` falls back to modern — the version any
62+ new install gets, and the one whose ``run`` subcommand fails loudly rather
63+ than opening a TUI that hangs until the timeout (#1012).
64+ """
65+ if binary_path in _SURFACE_CACHE :
66+ return _SURFACE_CACHE [binary_path ]
67+
68+ try :
69+ # Bytes, decoded permissively. `text=True` decodes with the locale
70+ # encoding and no error handler, so under a non-UTF-8 locale
71+ # (LC_ALL=C with UTF-8 coercion disabled — verified: encoding becomes
72+ # ANSI_X3.4-1968) kilo 7.x's box-drawing banner raises
73+ # UnicodeDecodeError. That is a ValueError, so it sailed straight past
74+ # the handler below and crashed build_command. (#1015 review)
75+ proc = subprocess .run (
76+ [binary_path , "--help" ], capture_output = True , timeout = 90
77+ )
78+ help_text = (proc .stdout + proc .stderr ).decode ("utf-8" , errors = "replace" )
79+ except (OSError , subprocess .SubprocessError , ValueError ):
80+ logger .warning (
81+ "Could not read `%s --help`; assuming the modern kilo surface." ,
82+ binary_path ,
83+ )
84+ help_text = ""
85+
86+ surface = _MODERN if (not help_text or _RUN_SUBCOMMAND_MARKER in help_text ) else _LEGACY
87+ _SURFACE_CACHE [binary_path ] = surface
88+ return surface
89+
90+
1791class KilocodeAdapter (SubprocessAdapter ):
1892 """Adapter that delegates code execution to Kilocode CLI.
1993
20- Invokes ``kilo <prompt> --auto --workspace <path>`` for headless
21- non-interactive execution. The prompt is the CLI's leading positional
22- (not stdin, and **not** behind a subcommand).
94+ **Supported version floor: ``@kilocode/cli`` 7.x**, the surface any new
95+ install gets. 0.22.0 is still driven correctly when that is what is
96+ installed, because #1012 verified it end-to-end and an unupgraded machine
97+ should not silently break — but it is not the target, and support for it
98+ can be dropped once nobody is on it.
99+
100+ Which invocation to use is **detected from the CLI's own ``--help``**, never
101+ inferred from a version string (#1015). ``_detect_surface`` has the table:
23102
24- There is no ``run`` subcommand: verified against kilocode 0.22.0, whose
25- usage is ``kilocode [options] [command] [prompt]`` with commands
26- ``auth``/``config``/``debug``/``models`` only. The adapter used to prepend
27- ``run``, which was then swallowed as the prompt — ``--auto`` never took
28- effect, the interactive TUI opened, and the delegated run hung until the
29- timeout having written nothing (#1012).
103+ * 7.x — ``kilo run --dir <path> <message>``. No ``--auto``: on this CLI that
104+ flag means "auto-approve all permissions", i.e. the old ``--yolo`` the
105+ adapter has always withheld (#916). ``run`` is non-interactive on its own,
106+ exactly like ``opencode run``.
107+ * 0.22.0 — ``kilo <prompt> --auto --workspace <path>``, where ``--auto`` is
108+ merely "non-interactive". There is no ``run`` subcommand; prepending one
109+ got it swallowed as the prompt, opening the TUI to hang until the timeout
110+ having written nothing (#1012).
30111
31- Note on prompt length: the prompt is passed as a single positional argument.
32- Linux supports up to ~2 MB per argument, but macOS caps individual arguments
33- at 256 KB. Very large task contexts assembled by TaskContextPackager may fail
34- on macOS. If Kilocode adds stdin support in a future release, prefer that path.
112+ Prompt length: the prompt is a single argv entry, and Linux caps one entry
113+ at 128 KiB (macOS at 256 KB) — well under CodeFrame's ~100K-token budget. On
114+ 7.x an oversized prompt goes to stdin instead, which ``kilo run`` accepts
115+ when given no positional (verified: ``echo "say ok" | kilo run --dir /tmp``
116+ reaches the model call). 0.22.0 has no stdin path, so there it still goes
117+ positionally and fails loudly.
35118
36119 Exit codes:
37120 0 — success
@@ -89,26 +172,44 @@ def check_ready(cls) -> dict[str, bool]:
89172 """Check if the kilo binary is available on PATH."""
90173 return {"kilo_binary" : shutil .which (cls ._resolve_binary ()) is not None }
91174
175+ def _surface (self ) -> str :
176+ """Which kilo CLI this adapter is talking to."""
177+ return _detect_surface (self ._binary_path )
178+
92179 def build_command (self , prompt : str , workspace_path : Path ) -> list [str ]:
93- """Build the kilo CLI command.
180+ """Build the kilo CLI command for whichever CLI is actually installed .
94181
95- Kilocode takes the prompt as a positional argument, with ``--auto``
96- for non-interactive execution and ``--workspace`` for the repo root.
182+ The two eras take completely different invocations (see
183+ ``_detect_surface``). Modern is the documented target; legacy is kept
184+ because it is what #1012 verified end-to-end and what an unupgraded
185+ install still speaks.
97186
98187 Args:
99- prompt: The task prompt passed as a positional argument .
100- workspace_path: Workspace root passed as ``--workspace`` .
188+ prompt: The task prompt.
189+ workspace_path: Workspace root.
101190
102191 Returns:
103192 Command list for subprocess.Popen.
104193 """
105- cmd = [
106- self ._binary_path ,
107- prompt ,
108- "--auto" ,
109- "--workspace" ,
110- str (workspace_path ),
111- ]
194+ if self ._surface () == _MODERN :
195+ # `run` is non-interactive by itself, exactly like `opencode run`.
196+ # --auto is deliberately NOT passed: in 7.x it means "auto-approve
197+ # all permissions", the 0.22 `--yolo` that #916 established must
198+ # stay off. Renaming --workspace to --dir while keeping --auto
199+ # would have silently upgraded the adapter into a permission bypass.
200+ cmd = [self ._binary_path , "run" , "--dir" , str (workspace_path )]
201+ if not _prompt_exceeds_argv (prompt ):
202+ cmd .append (prompt )
203+ else :
204+ # 0.22.0: bare positional prompt, --auto is merely non-interactive
205+ # and --yolo (never passed) is the bypass. Verified in #1012/#916.
206+ cmd = [
207+ self ._binary_path ,
208+ prompt ,
209+ "--auto" ,
210+ "--workspace" ,
211+ str (workspace_path ),
212+ ]
112213
113214 model = os .environ .get ("KILOCODE_MODEL" )
114215 if model :
@@ -121,7 +222,20 @@ def build_command(self, prompt: str, workspace_path: Path) -> list[str]:
121222 return cmd
122223
123224 def get_stdin (self , prompt : str ) -> str | None :
124- """Return None — prompt is passed as a positional CLI argument, not stdin."""
225+ """The prompt, only when it is too large to survive as an argv entry.
226+
227+ Normally None — both eras take the prompt positionally. But Linux caps a
228+ *single* argv entry at 128 KiB while CodeFrame budgets ~100K tokens of
229+ prompt, so a large task would raise OSError(E2BIG) before kilo starts.
230+
231+ ``kilo run`` with no positional reads the message from stdin: verified
232+ against 7.4.17, where `echo "say ok" | kilo run --dir /tmp` gets past
233+ message validation to the model call. The legacy CLI has no such path,
234+ so an oversized prompt still goes positionally there and fails loudly
235+ rather than silently doing nothing.
236+ """
237+ if self ._surface () == _MODERN and _prompt_exceeds_argv (prompt ):
238+ return prompt
125239 return None
126240
127241 def _map_result (
0 commit comments