diff --git a/CHANGELOG.md b/CHANGELOG.md index b711d728..80627843 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,18 @@ breaking changes may land in a minor release. ### Added +- **Kilo adapter (`kilo` profile, `kilo-http` kind).** Kilo (`kilocode`, an OpenCode fork) + is driven over its local HTTP/SSE server exactly like `opencode-http` — one headless + `kilo serve` per session, **no tmux window** — via a `KiloHttpAdapter` that extends the + OpenCode HTTP adapter. The fork differs from OpenCode in exactly two facts the adapter + overrides: it reads `KILO_*` env vars (`KILO_SERVER_PASSWORD`, + `KILO_CONFIG_CONTENT`, `KILO_DISABLE_EXTERNAL_SKILLS`) rather than `OPENCODE_*`, and its + server basic-auth username is `kilo`, not `opencode`. Everything else — the HTTP/SSE API + surface, SSE event frames, and usage schema — is identical to OpenCode. Needs the same + `[opencode]` extra for httpx; auth once globally with `kilo auth login`; skills live in + `.claude/skills/`; set `model` as `provider/model`. `env_fault_patterns` are intentionally + unseeded pending a captured kilo outage line. + - **`repo_root` in run `state.json`** (#716). A run records the git root its code work happens in, so an out-of-process reader — `bmad-loop resolve`'s re-arm — uses the tree the run measured instead of re-deriving one. A `state.json` written before the field existed degrades to the diff --git a/README.md b/README.md index 05cacb27..2d1a5b3c 100644 --- a/README.md +++ b/README.md @@ -436,7 +436,7 @@ skill = "bmad-dev-auto" # the only supported value — the generic upstream d # No settings-schema entry: edit it here, not in the TUI editor. [adapter] -name = "claude" # CLI profile: claude | codex | gemini | copilot | antigravity | opencode-http (alias: opencode) | custom +name = "claude" # CLI profile: claude | codex | gemini | copilot | antigravity | opencode-http (alias: opencode) | kilo | custom model = "" # empty = CLI default (opencode-http wants "provider/model") cleanup_session_on_finish = true # kill the run's tmux session when it finishes (false keeps it for inspection) # extra_args replaces the profile's default bypass flags when set: @@ -646,6 +646,7 @@ One generic driver (`adapters/generic.py`) runs any coding CLI that fits the inj | `copilot` | supported, E2E-verified | GitHub Copilot **CLI** (the `copilot` binary, GA ≥ 2026-02) — _not_ the VS Code extension. Launches with `-i` to stay interactive; turn-end is `agentStop` (per response turn); `--allow-all-tools` for unattended runs. `copilot-events` usage parser reads token totals from the trailing `session.shutdown` line, so the profile waits a short grace (`usage_grace_s = 8`) before tallying. **Pin a capable model** (see below). | | `antigravity` | experimental — `isolation = "none"` only | Google **Antigravity CLI** (`agy` ≥ 1.1.3). Launches with `-i` to stay interactive; `Stop` is the turn-end event (agy has no SessionStart/SessionEnd hook). Skills and hooks live in `.agents/` (flat `Stop` handler in `.agents/hooks.json`, keyed by hook-group name). Hook payloads are protojson/camelCase. **Trust is exact-path**: `agy` blocks on a "trust this folder" dialog for any workspace not listed verbatim in `settings.json` `trustedWorkspaces`, and `--dangerously-skip-permissions` does not bypass it — so `isolation = "worktree"` hangs ([#169](https://github.com/bmad-code-org/bmad-loop/issues/169)). `usage_parser = "none"` is permanent, not pending: agy's transcript carries no usage data (tokens live only in an internal SQLite/protobuf store), so runs work but token columns stay empty. Verify against your build with `probe-adapter antigravity`. | | `opencode` | supported, E2E-verified | **OpenCode** ≥ 1.18 (profile `opencode-http`), driven over HTTP/SSE — one headless `opencode serve` per session, **no tmux window**. Needs the extra: `pip install 'bmad-loop[opencode]'`. Auth once globally with `opencode auth login`; skills live in `.claude/skills/`; set `model` as `provider/model` (e.g. `anthropic/claude-haiku-4-5`). Watch sessions via `run_dir/logs/.log` or the TUI Log tab; `resolve` is `--no-interactive` only; the Unity plugin's window guards are unsupported here. | +| `kilo` | supported | **Kilo** (`kilocode`, an OpenCode fork, profile `kilo`), driven over HTTP/SSE exactly like OpenCode via the `kilo-http` adapter — **no tmux window**. Needs the same extra: `pip install 'bmad-loop[opencode]'`. Auth once globally with `kilo auth login`; skills live in `.claude/skills/`; set `model` as `provider/model`. The fork reads `KILO_*` env vars and authenticates with the basic-auth username `kilo` (not `opencode`), which is all the `kilo-http` adapter changes from the OpenCode family. | **Copilot — pin a capable model:** Copilot's free default (GPT-5 mini) is unreliable for the multi-step dev/review skills — it silently skips steps mid-workflow and fails the story. Set a capable model in policy, e.g. `[adapter] model = "claude-sonnet-4-6"` (passed through as `--model`), for end-to-end reliability. Because Copilot fires `agentStop` per response turn, a thorough multi-turn review needs more than one nudge to finish; the profile ships `stop_without_result_nudges = 5`, and you can tune it per stage (e.g. `[adapter.review] stop_without_result_nudges = …`). Both knobs are editable in the settings TUI under `[adapter]`. diff --git a/docs/adapter-authoring-guide.md b/docs/adapter-authoring-guide.md index 883f585f..7b5c6b7d 100644 --- a/docs/adapter-authoring-guide.md +++ b/docs/adapter-authoring-guide.md @@ -486,7 +486,7 @@ resolves to `claude`. | `name` | ✅ | — | Profile id, also the `--cli` value and override key. | | `binary` | ✅ | — | Executable to launch (resolved on `PATH`). `bmad-loop validate` also probes it — a name that resolves but fails `--version` (typically a dead WSL/npm shim) is reported as `adapter.binary-unrunnable` at warning severity, #294. Only a **packaged** profile's binary is probed. A project overlay (or an entry-point package's) profile is resolved and reported found but never launched, whatever its `binary` is called — profile fields arrive with a clone, so a project's own config cannot choose which binary this diagnostic launches. The boundary is provenance rather than the spelling of `binary`, because a bare name still resolves into the checkout whenever a checkout-local directory is on `PATH`. Note what it bounds: **which name** is probed, not what that name resolves to. Resolution runs through your `PATH`, so validate launches whatever `PATH` says the CLI is — the same file the session launch itself would run. | | `[hooks]` | ✅ | — | The `HookSpec` table (see below). | -| `adapter` | | `generic` | Which adapter **class** drives this CLI — a key resolved against the [adapter registry](#shipping-a-new-adapter-class-out-of-tree), not a fixed enum. `generic` = the bundled tmux + hook-signal adapter; `opencode-http` = the bundled HTTP/SSE adapter; an out-of-tree package registers its own. Membership is checked against the **live** registry (at run start, and by `bmad-loop validate`'s `adapter.kind`), never at parse time — so an unknown kind is a clear config error rather than a schema change. Orthogonal to `hooks.dialect = "none"` — hooklessness is about the transport, this is about the driving class — with two qualifications. A back-compat carve-out for files written before this field existed: when the key is **absent** and the dialect is `none`, the kind is `opencode-http` (what hooklessness used to select), not `generic`. And one refused pairing: an explicit `generic` beside `dialect = "none"` is rejected at load — that adapter completes on a `Stop` hook a hookless profile never registers, so the session could only wait out `session_timeout_min`. Hookless on any **other** kind stays legal, which is the decoupling this field exists for; a provider shipping a hookless profile must therefore set `adapter` rather than leave it at the default. | +| `adapter` | | `generic` | Which adapter **class** drives this CLI — a key resolved against the [adapter registry](#shipping-a-new-adapter-class-out-of-tree), not a fixed enum. `generic` = the bundled tmux + hook-signal adapter; `opencode-http` and `kilo-http` = the bundled HTTP/SSE adapters (OpenCode and the Kilo fork, respectively); an out-of-tree package registers its own. Membership is checked against the **live** registry (at run start, and by `bmad-loop validate`'s `adapter.kind`), never at parse time — so an unknown kind is a clear config error rather than a schema change. Orthogonal to `hooks.dialect = "none"` — hooklessness is about the transport, this is about the driving class — with two qualifications. A back-compat carve-out for files written before this field existed: when the key is **absent** and the dialect is `none`, the kind is `opencode-http` (what hooklessness used to select), not `generic`. And one refused pairing: an explicit `generic` beside `dialect = "none"` is rejected at load — that adapter completes on a `Stop` hook a hookless profile never registers, so the session could only wait out `session_timeout_min`. Hookless on any **other** kind stays legal, which is the decoupling this field exists for; a provider shipping a hookless profile must therefore set `adapter` rather than leave it at the default. | | `skill_tree` | | `.claude/skills` | Project-relative tree this CLI reads skills from (`.agents/skills` for codex/gemini); `bmad-loop init` installs the `bmad-loop-*` skills here. Must be relative. | | `prompt_template` | | `{prompt}` | How the canonical `/skill args` prompt is rendered. Placeholders: `{prompt}` (whole string), `{skill}` (leading slash-command name, no `/`), `{args}` (the remainder). | | `launch_args` | | `()` | Extra argv passed at launch, e.g. `["-i"]` to stay interactive (gemini/copilot). | diff --git a/docs/setup-guide.md b/docs/setup-guide.md index f30138f1..c7f0350d 100644 --- a/docs/setup-guide.md +++ b/docs/setup-guide.md @@ -260,10 +260,10 @@ bmad-loop init --project --cli claude --cli codex --cli gemini Run with no `--cli` and `init` registers hooks for every CLI the `policy.toml` references, so a dual-client setup that's already configured in policy needs no extra flags. Names must -be exactly `claude`, `codex`, `gemini`, `copilot`, `antigravity`, or `opencode-http` (alias -`opencode`) — `init` errors on an unknown profile and lists the valid ones. A hookless -profile like `opencode-http` installs its skills but registers no hooks (it signals over -HTTP/SSE). +be exactly `claude`, `codex`, `gemini`, `copilot`, `antigravity`, `opencode-http` (alias +`opencode`), or `kilo` — `init` errors on an unknown profile and lists the valid ones. A +hookless profile like `opencode-http`/`kilo` installs its skills but registers no hooks (it +signals over HTTP/SSE). ### First-run notes diff --git a/src/bmad_loop/adapters/kilo_http.py b/src/bmad_loop/adapters/kilo_http.py new file mode 100644 index 00000000..fdf22938 --- /dev/null +++ b/src/bmad_loop/adapters/kilo_http.py @@ -0,0 +1,131 @@ +"""Kilo HTTP adapter — drives the Kilo CLI (``kilocode``, an OpenCode fork) +over its local HTTP/SSE server, the same transport as :mod:`.opencode_http`. + +The Kilo fork renames the OpenCode contract in exactly two ways that matter to +the HTTP adapter: + +* It reads ``KILO_*`` env vars, NOT the ``OPENCODE_*`` vars the bundled + ``opencode-http`` adapter injects. With ``OPENCODE_SERVER_PASSWORD`` set, + `kilo serve` still warns "KILO_SERVER_PASSWORD is not set; server is + unsecured" and starts unauthenticated; ``OPENCODE_CONFIG_CONTENT`` is + likewise ignored. So the OpenCode adapter's auth + config + hermetic-skills + injection would silently no-op against a kilo server. +* Its server basic-auth username is ``kilo``, not ``opencode`` (verified: + ``-u kilo:`` authenticates, ``-u opencode:`` 401s). + +Everything else — the HTTP/SSE API surface (``GET /global/health``, +``POST /session``, ``POST /session/:id/prompt_async``, ``GET /event``, +``GET /session/status``, ``POST /session/:id/abort``), the SSE event frame +shapes, and the message/usage schemas — is byte-identical to OpenCode, because +Kilo is a rename of the same codebase. Verified live: health returns +``{"healthy": true, "version": "7.5.5"}`` and ``POST /session`` returns the +same ``{id, slug, ...}`` shape the base adapter consumes. + +So this module subclasses the OpenCode adapters and overrides only the two +facts the rename changed: the env-var prefix and the auth username. It is +selected by the bundled ``kilo`` profile (``data/profiles/kilo.toml``) via the +``kilo-http`` adapter kind registered in :mod:`.registry`. +""" + +from __future__ import annotations + +import os +from typing import Any + +from ..bmadconfig import ProjectPaths +from .base import SessionHandle, SessionSpec +from .generic import _DevSynthesisMixin +from .opencode_http import ( + OpencodeHttpAdapter, + OpencodeServerError, + _parse_sse_lines, + _ServerSession, +) + +# Kilo's basic-auth username (the base module's AUTH_USER is "opencode"; kilo +# rejects that and accepts the bare "kilo" username with the shared password). +KILO_AUTH_USER = "kilo" + +# Env-var names kilo reads, mirroring opencode's OPENCODE_* contract. +KILO_DISABLE_EXTERNAL_SKILLS = "KILO_DISABLE_EXTERNAL_SKILLS" +KILO_SERVER_PASSWORD = "KILO_SERVER_PASSWORD" +KILO_CONFIG_CONTENT = "KILO_CONFIG_CONTENT" + + +class KiloHttpAdapter(OpencodeHttpAdapter): + """opencode-http adapter tuned for the Kilo CLI. + + The base ``OpencodeHttpAdapter`` injects ``OPENCODE_*`` env vars and + authenticates with the literal ``AUTH_USER`` ("opencode"); both are wrong + for kilo. The module-level ``AUTH_USER`` is not a class attribute, so we + override ``_session_env`` (KILO_* names) and the two places the base builds + an authenticated httpx client (``_make_client`` and the SSE reader) to use + our own ``kilo`` username. + """ + + def _session_env(self, spec: SessionSpec, password: str) -> dict[str, str]: + return { + **os.environ, + **self.profile.env, + **spec.env, + KILO_DISABLE_EXTERNAL_SKILLS: "1", + KILO_SERVER_PASSWORD: password, + KILO_CONFIG_CONTENT: self._config_content(spec), + } + + def _make_client(self, sess: _ServerSession): + return self._httpx.Client( + base_url=sess.base_url, + auth=(KILO_AUTH_USER, sess.password), + timeout=self._httpx.Timeout(10.0, connect=5.0), + ) + + def _sse_loop(self, sess: _ServerSession) -> None: + httpx = self._httpx + while not sess.sse_stop.is_set(): + try: + with httpx.Client( + base_url=sess.base_url, + auth=(KILO_AUTH_USER, sess.password), + timeout=httpx.Timeout(5.0, read=self.sse_read_timeout_s), + ) as client: + with client.stream("GET", "/event") as resp: + if resp.status_code != 200: + raise OpencodeServerError(f"/event -> {resp.status_code}") + sess.sse_connected.set() + for event in _parse_sse_lines(resp.iter_lines()): + if sess.sse_stop.is_set(): + return + self._dispatch_sse(sess, event) + except Exception: # nosec B110 - reader must never die silently + pass + if sess.sse_stop.is_set(): + return + sess.events.put("gap") + sess.sse_stop.wait(self.reconnect_sleep_s) + + +class KiloDevAdapter(_DevSynthesisMixin, KiloHttpAdapter): + """Dev/review adapter for the bundled ``bmad-build-auto`` skill over kilo. + + Mirrors ``OpencodeDevAdapter`` (the dev primitive writes no ``result.json``; + :class:`_DevSynthesisMixin` synthesizes it from the spec on disk) but + composes :class:`KiloHttpAdapter` so the kilo env/auth deltas carry through. + """ + + def __init__(self, *args, paths: ProjectPaths, **kwargs): + super().__init__(*args, **kwargs) + self.paths = paths + self._configure_dev_knobs() + self._server_procs: dict[str, Any] = {} + + def start_session(self, spec: SessionSpec) -> SessionHandle: + handle = super().start_session(spec) + self._server_procs[spec.task_id] = self._sessions[spec.task_id].process + return handle + + def _probe_alive(self, handle: SessionHandle) -> bool | None: + proc = self._server_procs.get(handle.task_id) + if proc is None: + return False + return proc.poll() is None diff --git a/src/bmad_loop/adapters/registry.py b/src/bmad_loop/adapters/registry.py index 8a1f4b94..836563e8 100644 --- a/src/bmad_loop/adapters/registry.py +++ b/src/bmad_loop/adapters/registry.py @@ -68,6 +68,7 @@ # never a literal). GENERIC is the `profile.adapter` default. GENERIC = "generic" OPENCODE_HTTP = "opencode-http" +KILO_HTTP = "kilo-http" class AdapterError(Exception): @@ -132,13 +133,29 @@ def _opencode_http_builder() -> AdapterBuilder: ) +def _kilo_http_builder() -> AdapterBuilder: + from .kilo_http import ( + KiloDevAdapter, + KiloHttpAdapter, + OpencodeServerError, + ) + + return AdapterBuilder( + plain=KiloHttpAdapter, + dev=KiloDevAdapter, + construct_error=(OpencodeServerError,), + ) + + # The bundled kinds, as (name, needs_mux, load-thunk). A module constant, not # mutable registry state, so detect_adapters can label a row builtin-vs-external # without the fixtures having to snapshot it. `generic` drives tmux + hooks and -# needs the multiplexer; `opencode-http` is hookless HTTP/SSE and does not. +# needs the multiplexer; `opencode-http`/`kilo-http` are hookless HTTP/SSE and +# do not. _BUILTIN_ADAPTERS: tuple[tuple[str, bool, Callable[[], AdapterBuilder]], ...] = ( (GENERIC, True, _generic_builder), (OPENCODE_HTTP, False, _opencode_http_builder), + (KILO_HTTP, False, _kilo_http_builder), ) _BUILTIN_NAMES = frozenset(name for name, _, _ in _BUILTIN_ADAPTERS) diff --git a/src/bmad_loop/cli.py b/src/bmad_loop/cli.py index fc20550d..b019e9f0 100644 --- a/src/bmad_loop/cli.py +++ b/src/bmad_loop/cli.py @@ -4638,7 +4638,7 @@ def add(name: str, func, help: str, *, aliases=()) -> argparse.ArgumentParser: action="append", metavar="PROFILE", help="CLI profile(s) to register hooks for (claude | codex | gemini | copilot | " - "antigravity | opencode-http (alias: opencode) | custom; " + "antigravity | opencode-http (alias: opencode) | kilo | custom; " "repeatable; default: profiles referenced by .bmad-loop/policy.toml, or claude)", ) init_p.add_argument( @@ -4700,7 +4700,7 @@ def add(name: str, func, help: str, *, aliases=()) -> argparse.ArgumentParser: probe_p.add_argument( "cli", help="CLI profile name (claude | codex | gemini | copilot | antigravity | custom; " - "opencode-http is HTTP-driven — nothing to probe)", + "opencode-http and kilo-http are HTTP-driven — nothing to probe)", ) probe_p.add_argument( "--probe", diff --git a/src/bmad_loop/data/profiles/kilo.toml b/src/bmad_loop/data/profiles/kilo.toml new file mode 100644 index 00000000..8c336091 --- /dev/null +++ b/src/bmad_loop/data/profiles/kilo.toml @@ -0,0 +1,21 @@ +# Kilo (kilocode) — an OpenCode fork driven over its local HTTP server +# (`kilo serve`), same hookless transport as the `opencode-http` family. +# `adapter = "kilo-http"` selects the bundled KiloHttpAdapter, which injects the +# KILO_* env vars kilo reads and authenticates with the basic-auth username +# `kilo` (both differ from the OPENCODE_* contract of the opencode.toml profile). +name = "kilo" +binary = "kilo" +adapter = "kilo-http" +prompt_template = "Use the {skill} skill now: {args}" +usage_parser = "none" +skill_tree = ".claude/skills" +first_run_note = "run `kilo auth login` once on this machine; headless kilo servers reuse the stored credentials" + +# env_fault_patterns deliberately UNSEEDED: the base family's patterns are pinned +# to OpenCode's captured `error.error=...` lines in the `serve --print-logs` +# logfmt, and no kilo outage line has been captured yet to seed a kilo-specific +# pattern against. Leave inert (no env-fault auto-pause) rather than ship an +# unverified claim; seed from a real captured kilo line when one exists. + +[hooks] +dialect = "none" diff --git a/tests/test_adapter_registry.py b/tests/test_adapter_registry.py index 3b9e707f..f1bca984 100644 --- a/tests/test_adapter_registry.py +++ b/tests/test_adapter_registry.py @@ -181,13 +181,20 @@ def _run_dir(project): def test_builtin_kinds_registered_with_needs_mux(fresh_adapter_registry): - """The two bundled kinds register with the correct transport requirement: - generic drives tmux (needs_mux), opencode-http is hookless HTTP/SSE (does not).""" + """The bundled kinds register with the correct transport requirement: + generic drives tmux (needs_mux); opencode-http and kilo-http are hookless + HTTP/SSE (do not).""" generic = fresh_adapter_registry.get_adapter_kind("generic") http = fresh_adapter_registry.get_adapter_kind("opencode-http") + kilo = fresh_adapter_registry.get_adapter_kind("kilo-http") assert generic.needs_mux is True assert http.needs_mux is False - assert fresh_adapter_registry.known_adapter_kinds() == ["generic", "opencode-http"] + assert kilo.needs_mux is False + assert fresh_adapter_registry.known_adapter_kinds() == [ + "generic", + "kilo-http", + "opencode-http", + ] def test_builtin_name_constants_match_the_registered_names(fresh_adapter_registry): @@ -196,9 +203,11 @@ def test_builtin_name_constants_match_the_registered_names(fresh_adapter_registr that check silently stop firing — an absent finding reads as a pass.""" assert fresh_adapter_registry.GENERIC == "generic" assert fresh_adapter_registry.OPENCODE_HTTP == "opencode-http" + assert fresh_adapter_registry.KILO_HTTP == "kilo-http" assert set(fresh_adapter_registry.known_adapter_kinds()) == { fresh_adapter_registry.GENERIC, fresh_adapter_registry.OPENCODE_HTTP, + fresh_adapter_registry.KILO_HTTP, } @@ -235,9 +244,10 @@ def test_detect_adapters_labels_builtin(fresh_adapter_registry): registry = fresh_adapter_registry registry.register_adapter("extra", needs_mux=False, load=lambda: _stub_builder()) rows = {r.name: r for r in registry.detect_adapters()} - assert set(rows) == {"generic", "opencode-http", "extra"} + assert set(rows) == {"generic", "kilo-http", "opencode-http", "extra"} assert rows["generic"].builtin is True and rows["generic"].needs_mux is True assert rows["opencode-http"].builtin is True and rows["opencode-http"].needs_mux is False + assert rows["kilo-http"].builtin is True and rows["kilo-http"].needs_mux is False assert rows["extra"].builtin is False assert [r.name for r in registry.detect_adapters()] == sorted(rows) diff --git a/tests/test_kilo_http.py b/tests/test_kilo_http.py new file mode 100644 index 00000000..3f73dcbf --- /dev/null +++ b/tests/test_kilo_http.py @@ -0,0 +1,134 @@ +"""KiloHttpAdapter: unit tests for the two kilo-specific deltas from the OpenCode +HTTP family — the ``KILO_*`` session env prefix and the ``kilo`` basic-auth +username — plus the bundled ``kilo`` profile and the ``kilo-http`` adapter kind. + +Kilo is an OpenCode fork whose HTTP/SSE surface (``/global/health``, +``/session``, ``/event``, …) is byte-identical to OpenCode's, so the full +transport behavior is already exercised by ``test_opencode_http.py``; this module +pins only what the rename changed. No real kilo binary is needed — these tests +exercise the class methods directly. +""" + +from __future__ import annotations + +import base64 +import json +from pathlib import Path + +import pytest + +from bmad_loop.adapters import kilo_http +from bmad_loop.adapters.base import SessionSpec +from bmad_loop.adapters.kilo_http import ( + KILO_AUTH_USER, + KILO_CONFIG_CONTENT, + KILO_DISABLE_EXTERNAL_SKILLS, + KILO_SERVER_PASSWORD, + KiloDevAdapter, + KiloHttpAdapter, +) +from bmad_loop.adapters.profile import get_profile +from bmad_loop.adapters.registry import get_adapter_kind, known_adapter_kinds +from bmad_loop.policy import LimitsPolicy, Policy + + +def _policy(**limits) -> Policy: + return Policy(limits=LimitsPolicy(**limits) if limits else LimitsPolicy()) + + +def make_adapter(tmp_path: Path, binary: str = "kilo", **kwargs) -> KiloHttpAdapter: + adapter = KiloHttpAdapter( + run_dir=tmp_path / "run", + policy=kwargs.pop("policy", _policy()), + profile=get_profile("kilo"), + binary=binary, + **kwargs, + ) + adapter.health_timeout_s = 10.0 + adapter.health_poll_s = 0.05 + adapter.reconnect_sleep_s = 0.05 + return adapter + + +# --------------------------------------------------------------- the profile + + +def test_kilo_profile_selects_kilo_http(tmp_path): + """The bundled `kilo` profile drives the kilo binary over the kilo-http kind, + hookless, with the same hermetic-skills tree as opencode.""" + profile = get_profile("kilo") + assert profile.name == "kilo" + assert profile.binary == "kilo" + assert profile.adapter == "kilo-http" + assert profile.skill_tree == ".claude/skills" + assert profile.hooks.dialect == "none" + + +def test_kilo_is_a_bundled_hookless_adapter_kind(tmp_path): + """kilo-http registers as a builtin hookless kind (no multiplexer), and its + lazy thunk resolves to the kilo classes.""" + assert "kilo-http" in known_adapter_kinds() + kind = get_adapter_kind("kilo-http") + assert kind.needs_mux is False + builder = kind.load() + assert builder.plain is KiloHttpAdapter + assert builder.dev is KiloDevAdapter + assert builder.construct_error == (kilo_http.OpencodeServerError,) + + +# --------------------------------------------------------- the kilo deltas + + +def test_kilo_session_env_uses_kilo_prefix(tmp_path): + """The kilo fork reads KILO_* env vars, not OPENCODE_*. The adapter injects + the KILO_* names — and crucially NOT the OPENCODE_* names kilo ignores.""" + adapter = make_adapter(tmp_path) + spec = SessionSpec( + task_id="t", role="triage", prompt="p", cwd=tmp_path, env={"BMAD_LOOP_TASK_ID": "t"} + ) + env = adapter._session_env(spec, "sekrit") + assert env["BMAD_LOOP_TASK_ID"] == "t" + assert env[KILO_SERVER_PASSWORD] == "sekrit" + assert env[KILO_DISABLE_EXTERNAL_SKILLS] == "1" + json.loads(env[KILO_CONFIG_CONTENT]) # valid JSON + # the OPENCODE_* pair kilo ignores must NOT be present + assert "OPENCODE_SERVER_PASSWORD" not in env + assert "OPENCODE_CONFIG_CONTENT" not in env + + +def test_kilo_config_content_hermetic_skills(tmp_path): + """The injected KILO_CONFIG_CONTENT carries the blanket permission allow, the + hermetic project skill tree, and the model when the policy sets one.""" + adapter = make_adapter(tmp_path) + spec = SessionSpec(task_id="t", role="triage", prompt="p", cwd=tmp_path) + config = json.loads(adapter._config_content(spec)) + assert config["permission"] == "allow" + assert config["skills"]["paths"] == [str(tmp_path / ".claude" / "skills")] + assert "model" not in config + + spec_model = SessionSpec( + task_id="t", role="triage", prompt="p", cwd=tmp_path, model="anthropic/claude-x" + ) + config = json.loads(adapter._config_content(spec_model)) + assert config["model"] == "anthropic/claude-x" + + +def test_kilo_make_client_authenticates_as_kilo(tmp_path): + """Kilo's server basic-auth accepts the username `kilo`, not `opencode`. The + adapter's client (used for health + POST requests) must send `kilo`.""" + adapter = make_adapter(tmp_path) + sess = kilo_http._ServerSession.__new__(kilo_http._ServerSession) + sess.base_url = "http://127.0.0.1:9999" + sess.password = "sekrit" + client = adapter._make_client(sess) + auth = client._auth + assert auth is not None + req = client.build_request("GET", "http://127.0.0.1:9999/global/health") + authed = list(auth.sync_auth_flow(req))[0] + expected = "Basic " + base64.b64encode(b"kilo:sekrit").decode() + assert authed.headers["Authorization"] == expected + + +def test_kilo_auth_user_is_not_opencode(): + assert KILO_AUTH_USER == "kilo" + assert KILO_AUTH_USER != "opencode" diff --git a/tests/test_profile.py b/tests/test_profile.py index c1b9b738..8131e144 100644 --- a/tests/test_profile.py +++ b/tests/test_profile.py @@ -88,14 +88,15 @@ def test_builtin_profiles_load(): # sentences: the "Unable to connect to API" connection failure and the # provider 5xx pair; still no quota cause, since no captured Claude Code # usage-limit line exists) and opencode-http (the serve process's - # `error.error="AI_APICallError: …"` field). The other four stay inert on + # `error.error="AI_APICallError: …"` field). The others stay inert on # purpose: patterns for them could only be written from strings scraped off # public issue trackers, and an unverified pattern that fires on a healthy - # session pauses the whole run. Precision is asserted in + # session pauses the whole run. kilo is among the inert set — no captured + # kilo outage line exists yet to seed against. Precision is asserted in # tests/test_env_fault_patterns.py; here we only pin which profiles are seeded. for name in ("claude", "opencode-http"): assert profiles[name].env_fault_patterns, f"{name} ships no env_fault_patterns" - for name in ("codex", "gemini", "copilot", "antigravity"): + for name in ("codex", "gemini", "copilot", "antigravity", "kilo"): assert profiles[name].env_fault_patterns == () # opencode-http is hookless (HTTP/SSE transport): no hook dialect surfaces, # skills read from the claude tree, usage comes over HTTP (no transcript parser) @@ -107,8 +108,18 @@ def test_builtin_profiles_load(): assert opencode.skill_tree == ".claude/skills" assert opencode.usage_parser == "none" assert opencode.binary == "opencode" - # every hook-driven built-in stays non-hookless - for name in sorted(set(profiles) - {"opencode-http"}): + # kilo is the second hookless builtin: same HTTP/SSE transport, driven by the + # kilo-http adapter kind against the `kilo` binary + kilo = profiles["kilo"] + assert kilo.hookless is True + assert kilo.hooks.dialect == "none" + assert kilo.adapter == "kilo-http" + assert kilo.binary == "kilo" + assert kilo.skill_tree == ".claude/skills" + assert kilo.usage_parser == "none" + # every hook-driven built-in stays non-hookless (kilo is the only sibling of + # opencode-http in the HTTP/SSE family) + for name in sorted(set(profiles) - {"opencode-http", "kilo"}): assert profiles[name].hookless is False @@ -189,13 +200,14 @@ def test_unknown_profile_raises(): def test_adapter_field_defaults_to_generic_and_parses(): """The adapter kind is read at parse time: unset defaults to the bundled tmux - generic; opencode-http declares its HTTP adapter kind. Asserted across ALL - built-ins, not two spot checks — a profile silently defaulting to `generic` - would dispatch to the tmux adapter, which cannot host it.""" + generic; opencode-http and kilo declare their HTTP adapter kinds. Asserted + across ALL built-ins, not spot checks — a profile silently defaulting to + `generic` would dispatch to the tmux adapter, which cannot host it.""" profiles = load_profiles() assert profiles["opencode-http"].adapter == "opencode-http" + assert profiles["kilo"].adapter == "kilo-http" assert {name for name, p in profiles.items() if p.adapter == "generic"} == ( - set(profiles) - {"opencode-http"} + set(profiles) - {"opencode-http", "kilo"} )