Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 136 additions & 28 deletions codeframe/core/adapters/kilocode.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,36 +2,113 @@

from __future__ import annotations

import logging
import os
import shlex
import shutil
import subprocess
from pathlib import Path

from codeframe.core.adapters.agent_adapter import AgentResult
from codeframe.core.adapters.subprocess_adapter import SubprocessAdapter

logger = logging.getLogger(__name__)

# Exit code used by kilo when the timeout is exceeded
_KILO_TIMEOUT_EXIT_CODE = 124


class KilocodeAdapter(SubprocessAdapter):
"""Adapter that delegates code execution to Kilocode CLI.
#: The two incompatible CLIs that both answer to ``kilo``.
_MODERN = "modern" # 7.x: `kilo run <message> --dir <path>`
_LEGACY = "legacy" # 0.22.0: `kilo <prompt> --auto --workspace <path>`

#: Substring that appears in ``kilo --help`` only once ``run`` exists. Detection
#: reads the CLI's own help rather than parsing ``--version``: #1015 requires
#: that the invocation never be guessed from a version string, and this repo has
#: been bitten three times by adapters that assumed a CLI surface (#913/#914/
#: #1012). Help text is what the binary actually offers.
_RUN_SUBCOMMAND_MARKER = "kilo run"

#: Linux caps a *single* argv entry at MAX_ARG_STRLEN — 128 KiB — independently
#: of the much larger total ARG_MAX. Same constraint opencode hit in #913.
_MAX_ARG_BYTES = 128 * 1024

#: Detection runs a subprocess, so it is cached per binary path for the process.
_SURFACE_CACHE: dict[str, str] = {}


Invokes ``kilo <prompt> --auto --workspace <path>`` for headless
non-interactive execution. The prompt is the CLI's leading positional
(not stdin, and **not** behind a subcommand).
def _prompt_exceeds_argv(prompt: str) -> bool:
return len(prompt.encode("utf-8")) >= _MAX_ARG_BYTES

There is no ``run`` subcommand: verified against kilocode 0.22.0, whose
usage is ``kilocode [options] [command] [prompt]`` with commands
``auth``/``config``/``debug``/``models`` only. The adapter used to prepend
``run``, which was then swallowed as the prompt — ``--auto`` never took
effect, the interactive TUI opened, and the delegated run hung until the
timeout having written nothing (#1012).

Note on prompt length: the prompt is passed as a single positional argument.
Linux supports up to ~2 MB per argument, but macOS caps individual arguments
at 256 KB. Very large task contexts assembled by TaskContextPackager may fail
on macOS. If Kilocode adds stdin support in a future release, prefer that path.
def _detect_surface(binary_path: str) -> str:
"""Which kilo CLI is installed, according to its own ``--help``.

``@kilocode/cli`` was rewritten between 0.22.0 (2026-01-15) and 7.x
(2026-07-29) — 213 releases apart. The invocations share nothing:

============== ============================ ==========================
\\ 0.22.0 7.4.17
============== ============================ ==========================
usage ``kilocode [options] [prompt]`` ``kilo run [message..]``
workspace ``--workspace <path>`` ``--dir <path>``
``--auto`` non-interactive **auto-approve ALL perms**
============== ============================ ==========================

That last row is why this cannot be a blind rename: on 7.x ``--auto`` is the
old ``--yolo``, the permission bypass #916 established must stay off.

An unreadable or failing ``--help`` falls back to modern — the version any
new install gets, and the one whose ``run`` subcommand fails loudly rather
than opening a TUI that hangs until the timeout (#1012).
"""
if binary_path in _SURFACE_CACHE:
return _SURFACE_CACHE[binary_path]

try:
proc = subprocess.run(
[binary_path, "--help"], capture_output=True, text=True, timeout=90

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[major] _detect_surface reads kilo --help with text=True (strict decode), so on a POSIX / LANG=C locale the modern 7.x banner raises UnicodeDecodeError — which is not caught by (OSError, subprocess.SubprocessError) — and propagates out of build_command/run() instead of falling back to modern.

Failure scenario: CodeFrame runs under LANG=C (the default on many Docker / slim base images, where locale.getpreferredencoding() is ANSI_X3.4-1968 = ASCII), kilocode 7.x — the supported floor — is installed, and a task starts via the kilocode engine. build_command_detect_surfacesubprocess.run([kilo, "--help"], text=True); kilo prints its Unicode banner (the first line of the checked-in help-7.4.17.txt is ██ ██ ██🬺🬏 …), the strict ASCII decode raises on the first 0xE2 byte, and because build_command runs outside run()'s try/except (subprocess_adapter.py:145), the exception crashes the task with a traceback rather than running it. The PR's own tests miss this because _with_help returns an already-decoded str, bypassing the decode step. Detection only needs the ASCII "kilo run" marker, so errors="replace" is safe and never raises.

Suggested change
[binary_path, "--help"], capture_output=True, text=True, timeout=90
[binary_path, "--help"], capture_output=True, text=True, errors="replace", timeout=90

)
help_text = proc.stdout + proc.stderr
except (OSError, subprocess.SubprocessError):
logger.warning(
"Could not read `%s --help`; assuming the modern kilo surface.",
binary_path,
)
help_text = ""

surface = _MODERN if (not help_text or _RUN_SUBCOMMAND_MARKER in help_text) else _LEGACY
_SURFACE_CACHE[binary_path] = surface
return surface


class KilocodeAdapter(SubprocessAdapter):
"""Adapter that delegates code execution to Kilocode CLI.

**Supported version floor: ``@kilocode/cli`` 7.x**, the surface any new
install gets. 0.22.0 is still driven correctly when that is what is
installed, because #1012 verified it end-to-end and an unupgraded machine
should not silently break — but it is not the target, and support for it
can be dropped once nobody is on it.

Which invocation to use is **detected from the CLI's own ``--help``**, never
inferred from a version string (#1015). ``_detect_surface`` has the table:

* 7.x — ``kilo run --dir <path> <message>``. No ``--auto``: on this CLI that
flag means "auto-approve all permissions", i.e. the old ``--yolo`` the
adapter has always withheld (#916). ``run`` is non-interactive on its own,
exactly like ``opencode run``.
* 0.22.0 — ``kilo <prompt> --auto --workspace <path>``, where ``--auto`` is
merely "non-interactive". There is no ``run`` subcommand; prepending one
got it swallowed as the prompt, opening the TUI to hang until the timeout
having written nothing (#1012).

Prompt length: the prompt is a single argv entry, and Linux caps one entry
at 128 KiB (macOS at 256 KB) — well under CodeFrame's ~100K-token budget. On
7.x an oversized prompt goes to stdin instead, which ``kilo run`` accepts
when given no positional (verified: ``echo "say ok" | kilo run --dir /tmp``
reaches the model call). 0.22.0 has no stdin path, so there it still goes
positionally and fails loudly.

Exit codes:
0 — success
Expand Down Expand Up @@ -89,26 +166,44 @@ def check_ready(cls) -> dict[str, bool]:
"""Check if the kilo binary is available on PATH."""
return {"kilo_binary": shutil.which(cls._resolve_binary()) is not None}

def _surface(self) -> str:
"""Which kilo CLI this adapter is talking to."""
return _detect_surface(self._binary_path)

def build_command(self, prompt: str, workspace_path: Path) -> list[str]:
"""Build the kilo CLI command.
"""Build the kilo CLI command for whichever CLI is actually installed.

Kilocode takes the prompt as a positional argument, with ``--auto``
for non-interactive execution and ``--workspace`` for the repo root.
The two eras take completely different invocations (see
``_detect_surface``). Modern is the documented target; legacy is kept
because it is what #1012 verified end-to-end and what an unupgraded
install still speaks.

Args:
prompt: The task prompt passed as a positional argument.
workspace_path: Workspace root passed as ``--workspace``.
prompt: The task prompt.
workspace_path: Workspace root.

Returns:
Command list for subprocess.Popen.
"""
cmd = [
self._binary_path,
prompt,
"--auto",
"--workspace",
str(workspace_path),
]
if self._surface() == _MODERN:
# `run` is non-interactive by itself, exactly like `opencode run`.
# --auto is deliberately NOT passed: in 7.x it means "auto-approve
# all permissions", the 0.22 `--yolo` that #916 established must
# stay off. Renaming --workspace to --dir while keeping --auto
# would have silently upgraded the adapter into a permission bypass.
cmd = [self._binary_path, "run", "--dir", str(workspace_path)]
if not _prompt_exceeds_argv(prompt):
cmd.append(prompt)
else:
# 0.22.0: bare positional prompt, --auto is merely non-interactive
# and --yolo (never passed) is the bypass. Verified in #1012/#916.
cmd = [
self._binary_path,
prompt,
"--auto",
"--workspace",
str(workspace_path),
]

model = os.environ.get("KILOCODE_MODEL")
if model:
Expand All @@ -121,7 +216,20 @@ def build_command(self, prompt: str, workspace_path: Path) -> list[str]:
return cmd

def get_stdin(self, prompt: str) -> str | None:
"""Return None — prompt is passed as a positional CLI argument, not stdin."""
"""The prompt, only when it is too large to survive as an argv entry.

Normally None — both eras take the prompt positionally. But Linux caps a
*single* argv entry at 128 KiB while CodeFrame budgets ~100K tokens of
prompt, so a large task would raise OSError(E2BIG) before kilo starts.

``kilo run`` with no positional reads the message from stdin: verified
against 7.4.17, where `echo "say ok" | kilo run --dir /tmp` gets past
message validation to the model call. The legacy CLI has no such path,
so an oversized prompt still goes positionally there and fails loudly
rather than silently doing nothing.
"""
if self._surface() == _MODERN and _prompt_exceeds_argv(prompt):
return prompt
return None

def _map_result(
Expand Down
21 changes: 21 additions & 0 deletions tests/core/adapters/fixtures/kilocode_help/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# kilocode CLI help fixtures

Verbatim `kilo --help` output from the two incompatible CLIs that both answer to
`kilo`. `_detect_surface` is matched against these rather than against text
invented for the test — the same reason #914 checks in the codex app-server
schema.

| file | version | captured |
|---|---|---|
| `help-0.22.0.txt` | `@kilocode/cli@0.22.0` (2026-01-15) | 2026-08-01 |
| `help-7.4.17.txt` | `@kilocode/cli@7.4.17` (2026-07-29) | 2026-08-01 |

Regenerate with:

```
npm install -g @kilocode/cli@<version>
kilo --help > help-<version>.txt 2>&1
```

The 7.x file contains ANSI/box-drawing characters from the banner; that is
deliberate, since the real output does too and the detector must cope with it.
53 changes: 53 additions & 0 deletions tests/core/adapters/fixtures/kilocode_help/help-0.22.0.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
Usage: kilocode [options] [command] [prompt]

Kilo Code Terminal User Interface - AI-powered coding assistant

Arguments:
prompt The prompt or command to execute

Options:
-V, --version output the version number
-m, --mode <mode> Set the mode of operation (architect, code,
ask, debug, orchestrator)
-w, --workspace <path> Path to the workspace directory (default:
"/home/frankbria/projects/codeframe")
-a, --auto Run in autonomous mode (non-interactive)
(default: false)
--yolo Auto-approve all tool permissions (default:
false)
-j, --json Output messages as JSON (requires --auto)
(default: false)
-i, --json-io Bidirectional JSON mode (no TUI,
stdin/stdout enabled) (default: false)
-c, --continue Resume the last conversation from this
workspace (default: false)
-t, --timeout <seconds> Timeout in seconds for autonomous mode
(requires --auto)
-p, --parallel Run in parallel mode - the agent will create
a separate git branch, unless you provide
the --existing-branch option
-eb, --existing-branch <branch> (Parallel mode only) Instructs the agent to
work on an existing branch
-pv, --provider <id> Select provider by ID (e.g., 'kilocode-1')
-mo, --model <model> Override model for the selected provider
-s, --session <sessionId> Restore a session by ID
-f, --fork <shareId> Fork a session by ID
--nosplash Disable the welcome message and update
notifications (default: false)
--append-system-prompt <text> Append custom instructions to the system
prompt
--on-task-completed <prompt> Send a custom prompt to the agent when the
task completes
--attach <path> Attach a file to the prompt (can be
repeated). Currently supports images: png,
jpg, jpeg, webp, gif, tiff (default: [])
-h, --help display help for command

Commands:
auth Manage authentication for the Kilo Code CLI
config Open the configuration file in your default
editor
debug [mode] Run a system compatibility check for the
Kilo Code CLI
models [options] List available models for the current
provider as JSON
60 changes: 60 additions & 0 deletions tests/core/adapters/fixtures/kilocode_help/help-7.4.17.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
██ ██ ██🬺🬏 ██ ██ ██🬺🬏 ████ ██ ██🬺🬏
████🬺🬏 ~~██ ██ ~~ ██~~██ ██~~~~ ██ ~~██
██ ██ ██████ 🬁🬬████ 🬁🬬██~~ 🬁🬬████ 🬁🬬████ ██████
~~ ~~ ~~~~~~ ~~~~ ~~ ~~~~ ~~~~ ~~~~~~

Commands:
kilo completion generate shell completion script
kilo acp start ACP (Agent Client Protocol) server
kilo mcp manage MCP (Model Context Protocol) servers
kilo [project] start kilo tui [default]
kilo attach <url> attach to a running kilo server
kilo run [message..] run kilo with a message
kilo debug debugging and troubleshooting tools
kilo auth manage AI providers and credentials [aliases: providers]
kilo agent manage agents
kilo upgrade [target] upgrade kilo to the latest or a specific version
kilo uninstall uninstall kilo and remove all related files
kilo serve starts a headless kilo server
kilo web start kilo server and open web interface
kilo models [provider] list all available models
kilo stats show token usage and cost statistics
kilo export [sessionID] export session data as JSON
kilo import <file> import session data from JSON file or URL
kilo github manage GitHub agent
kilo pr <number> fetch and checkout a GitHub PR branch, then run kilo
kilo session manage sessions
kilo plugin <module> install plugin and update config [aliases: plug]
kilo db database tools
kilo console open or stop the local Kilo Console
kilo cloud run Cloud Agent tasks
kilo roll-call <filter> batch-test text models matching a filter for connectivity and latency
kilo profile show Kilo account profile
kilo remote enable remote connection for real-time session relay
kilo daemon manage the local kilo daemon
kilo config configuration tools
kilo help [command] show full CLI reference

Positionals:
project path to start kilo in [string]

Options:
-h, --help show help [boolean]
-v, --version show version number [boolean]
--print-logs print logs to stderr [boolean]
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean]
--port port to listen on [number] [default: 0]
--hostname hostname to listen on [string] [default: "127.0.0.1"]
--mdns enable mDNS service discovery (defaults hostname to 0.0.0.0)
[boolean] [default: false]
--mdns-domain custom domain name for mDNS service (default: kilo.local)
[string] [default: "kilo.local"]
--cors additional domains to allow for CORS [array] [default: []]
-m, --model model to use in the format of provider/model [string]
-c, --continue continue the last session [boolean]
-s, --session session id to continue [string]
--fork fork the session when continuing (use with --continue or --session) [boolean]
--cloud-fork fetch session from cloud and continue locally (use with --session) [boolean]
--prompt prompt to use [string]
--agent agent to use [string]
22 changes: 12 additions & 10 deletions tests/core/adapters/test_engine_smoke_tier.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,9 @@ class Engine:
#: ``opencode run --help``. Without this the tier would silently under-cover
#: exactly the flags most likely to drift.
help_subcommand: str | None = None
#: Currently unused — #1015 was the last standing drift. Kept because three
#: of four engines drifted in a single week, so the next one is a matter of
#: time, and re-deriving this mechanism then would be wasted work.
#: Set when the adapter is known to target a different CLI version than the
#: one likely installed. The contract check then xfails instead of blocking
#: every adapter PR — but it is `strict=False`, so it flips to a pass the
Expand Down Expand Up @@ -155,19 +158,17 @@ def _kilo_binary() -> str:
"opencode", lambda: "opencode", _opencode,
("opencode run", "--dir"), help_subcommand="run",
),
# kilocode 0.22.0 takes a bare positional prompt plus these flags and has no
# `run` subcommand (#1012). kilocode 7.x reinstated `run` and renamed
# --workspace to --dir, so the adapter is stale against a current install —
# tracked in #1015. This tier caught that drift on its own first CI run.
# kilocode 7.x is the supported floor: `kilo run <message> --dir <path>`.
# The adapter still drives 0.22.0 when that is what is installed, detecting
# the surface from --help rather than assuming one (#1015) — so the tier
# pins the *modern* entry point, which is what a fresh CI install gets.
# This tier caught the rewrite on its own first CI run, which is its purpose.
Engine(
"kilocode",
_kilo_binary,
_kilocode,
("--auto", "--workspace"),
known_drift=(
"adapter targets @kilocode/cli 0.22.0; 7.x renamed --workspace to "
"--dir and reinstated `run` (#1015)"
),
("kilo run", "--dir"),
help_subcommand="run",
),
)

Expand Down Expand Up @@ -243,7 +244,8 @@ def test_the_cli_still_documents_the_adapters_entry_point(engine: Engine) -> Non
pytest.xfail(f"{engine.name}: known drift — {engine.known_drift}; missing {missing}")
assert not missing, (
f"{engine.name}: {missing} absent from `{binary} --help` — the adapter's "
f"invocation may no longer be valid"
f"invocation may no longer be valid, or the installed CLI predates the "
f"version floor documented on the adapter class"
)


Expand Down
Loading
Loading