Files
ankushbhatiya cd91556d3c feat: add Kimi Code as a harness (#271) (#521)
* feat(kimi): add Kimi Code CLI as a harness (#271)

Wires Moonshot AI's upstream Kimi Code CLI
(https://github.com/MoonshotAI/Kimi-Code) into Omnigent as a first-class
harness alongside Claude Code, Codex, Cursor, Pi, and Antigravity. One
``kimi -p <prompt> --output-format stream-json`` subprocess per Omnigent
turn parses the JSONL transcript on stdout, captures the kimi session id
from the ``role:"meta"`` event for ``-S <id>`` resume on the next turn,
and uses the subprocess's ``cwd=`` for the working directory (upstream
has no ``--work-dir`` flag).

Only the upstream curl-installed ``kimi`` binary is supported. The
legacy pypi ``kimi-cli`` package is intentionally NOT detected — its
command-line surface (``--print``, list-of-blocks content, etc.) is
incompatible with the upstream binary the issue targets.

What landed:

- ``omnigent/inner/kimi_executor.py`` — Inner executor.
  ``handles_tools_internally=True`` (Kimi runs its own bash/edit/read
  tools); supports session resume, ``-C`` continue-last, ``--plan``,
  ``--skills-dir`` (repeatable), per-spawn model override via env-var
  contract.
- ``omnigent/inner/kimi_harness.py`` — FastAPI wrap via
  ``ExecutorAdapter`` with env-driven lazy executor construction.
- Runtime/registry: ``omnigent/runtime/harnesses/__init__.py`` registers
  ``kimi`` + ``kimi-code`` alias; ``omnigent/spec/_omnigent_compat.py``
  allowlist; ``omnigent/harness_aliases.py`` canonicalisation;
  ``omnigent/runtime/workflow.py`` ``AgentHarnessType`` entry +
  minimal ``_build_kimi_spawn_env`` (emits MODEL + CWD only — upstream
  kimi has no per-spawn provider override, so a spec declaring
  provider/Databricks auth now raises loudly).
- CLI/onboarding: ``omnigent kimi`` subcommand (shortcut for
  ``run --harness kimi``), default system prompt entry, ``_CLICK_SUBCOMMANDS``
  allowlist, first-run plan fallback gated on ``kimi`` binary presence,
  ``KIMI_KEY`` install spec with curl install_hint and ``kimi login``
  argv, ``KIMI_SURFACE`` readiness wiring.
- Model layer: ``model_override``, ``model_catalog`` identity entry,
  ``runner/app.py`` model env key + spawn-env dispatch.
- Frontend: ``ap-web/src/components/AgentCard.tsx`` fall-through
  comment (BotIcon for now; dedicated glyph deferred).
- Tests: ``tests/inner/test_kimi_harness.py`` (38 cases covering
  registry, FastAPI routes, env-var factory, argv builder for upstream
  syntax, event translator for content-as-string + ``role:"meta"``
  session capture + stderr fallback, capability flags, run-turn with
  stubbed subprocess, session resume, tools-without-bridge warning).
  Spawn-env tests in ``tests/runtime/test_provider_spawn_env.py``;
  readiness + install-spec tests; ``tests/cli/test_cli.py`` stubs the
  kimi binary check so first-run-plan tests stay deterministic.
- Docs: ``README.md`` mentions, ``docs/AGENT_YAML_SPEC.md`` Kimi
  section, ``examples/kimi_hello.yaml`` single-file launcher,
  ``docs/KIMI_FOLLOWUPS.md`` enumerating deferred work (Omnigent-side
  provider injection + MCP tool bridge via the ``kimi acp`` ACP server,
  native TUI in a tmux pane, dedicated glyph, multimodal/video input,
  mid-turn interrupt, token usage, spec-level plan/thinking fields,
  built-in agent specs).
- E2E: ``tests/e2e/test_kimi_executor_e2e.py`` gated on
  ``OMNIGENT_E2E_KIMI=1`` + ``kimi`` on PATH.

Resolves #271.

Signed-off-by: Ankush Bhatiya <ankushb@gmail.com>

* fix(kimi): address PR review — auth/sandbox/adapter/stream-limit

Incorporates the Polly review on #521:

- B1: drop unrelated `databricks_supervisor` from the harness allowlist
  (passed validation but had no module/builder, crashing at spawn).
- B2: reject declared `executor.auth` in `_build_kimi_spawn_env` (upstream
  kimi has no per-spawn provider override). Removed the unreachable raises
  in `configure_agent_harness_with_provider` (never called for kimi).
- B3: serialize `spec.os_env` into `HARNESS_KIMI_OS_ENV` and apply a
  platform sandbox launcher in `KimiExecutor` (mirrors qwen) so kimi's
  in-process tools run confined when the spec requests it.
- B4: add `Executor.forwards_observed_tool_results()` (True for kimi) so the
  adapter forwards self-contained tool-loop results instead of suppressing
  them as dispatched-tool duplicates.
- B5: pass a 16 MiB stdout `limit=` so large JSONL lines don't overrun
  asyncio's 64 KiB default and crash the turn.
- Non-blocking: drop the random-UUID session-id fallback; leave it None so a
  missed resume hint starts a fresh session instead of passing an id upstream
  may reject.

Adds tests for each and updates docs/KIMI_FOLLOWUPS.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(kimi-native): native Kimi Code TUI harness with web-UI transcript + tool approval

Add the kimi-native harness: `omni kimi` launches the interactive kimi TUI in a
tmux pane embedded in the web UI (mirrors cursor-native), alongside the existing
headless SDK `kimi` harness (kept for sub-agent / `run --harness kimi` use).

- harness: kimi_native + bridge/executor/credentials/hook; runner terminal
  auto-create, interrupt/stop, and registry/alias/onboarding/model-catalog wiring
- transcript forwarder: tail the kimi wire.jsonl and mirror user/assistant turns
  into the chat, so replies render in the web UI (not just the embedded pane)
- interactive tool approval: the PermissionRequest hook publishes the web-UI
  approval card and types the verdict (Approve once / Reject) into the TUI
- Kimi glyph (@lobehub/icons), `omni setup` drill-in, and new-session picker
  dedup (native TUI only; the SDK kimi agent is hidden from the picker)

Co-authored-by: Isaac

* fix(kimi-native): web-UI approvals, working dir, latency, icon

Round of fixes from live-testing the native + SDK Kimi harnesses:

- Approvals: the shared PermissionRequest endpoint hard-coded an
  ``elicit_claude_`` id regex, 400-ing every kimi hook POST so the
  approval card never published. Generalize to ``elicit_<harness>_``.
  Add ``timeout = 600`` to the kimi hooks (kimi kills hooks at 30s,
  severing the approval long-poll) and ``-I`` to the hook command
  (kimi runs hooks with cwd=workspace; a workspace with its own
  ``omnigent/`` shadowed the install and the hook died on ImportError).

- Working directory: ``omni --harness kimi`` now runs the SDK kimi in
  the launch folder, matching claude. Add ``kimi`` to
  ``_OS_ENV_HARNESSES`` (launcher os_env block), make the harness wrap
  fall back to ``OMNIGENT_RUNNER_WORKSPACE``, and — the real fix —
  thread the session workspace ``cwd`` (not the /tmp bundle workdir)
  into ``HARNESS_KIMI_CWD`` in ``_build_kimi_spawn_env``, mirroring pi.

- Latency: bring the forwarder poll (0.7→0.25s), bridge poll
  (0.2→0.15s), paste settle (0.3→0.1s) and send timeout (10→5s) to
  claude-native parity; replace the unverified ``_settle_pane`` idle
  markers (carried over from cursor-native, never matched, so every
  web→TUI injection ate the full 30s readiness timeout) with the real
  K2.7 footer marker ``context:``.

- Icon: SubagentsPanel branded SDK-harness sessions (no wrapper label)
  as the generic bot; add a harness-substring fallback mirroring
  AgentCard so ``omni --harness kimi`` shows the Kimi glyph.

- Docs: remove docs/KIMI_FOLLOWUPS.md and reword the 11 code comments
  that pointed at it (the deferred work stays noted inline).

Co-authored-by: Isaac

* fix(kimi): use os.environ.copy() for subprocess env (exfil-scan)

The CI exfil scanner blocks the `dict(os.environ)` shape in added lines
(wholesale-environ-dump heuristic). The native wrappers legitimately copy
the environment for the subprocess they spawn — the grandfathered
claude/codex/pi/cursor/opencode wrappers all do the same. Switch the two
new kimi sites to the idiomatic `os.environ.copy()`, which is identical
behavior and doesn't trip the heuristic.

Co-authored-by: Isaac

* test(e2e-ui): cover Kimi native picker + SDK-kimi dedup

Adds the Playwright e2e_ui coverage the E2E UI Required gate asked for on
the new user-visible Kimi UI:

- test_start_session_kimi_native_picker_and_wrapper_labels: the picker
  renders the harness-derived label "Kimi" (not the raw "kimi-native-ui"),
  and create POSTs the terminal-first wrapper labels
  (omnigent.ui: terminal + omnigent.wrapper: kimi-native-ui).
- test_start_session_picker_hides_sdk_kimi: with both the native and SDK
  kimi rows in the catalog, the picker offers only the native row and drops
  the SDK `kimi` (NEW_SESSION_HIDDEN_AGENTS) — one "Kimi" to pick.

Mirrors the existing pi/opencode/antigravity native-agent tests. Both pass
locally against a spawned server + chromium.

Co-authored-by: Isaac

* test(e2e): cover kimi in the example + live-harness drift guards

Two backend e2e drift guards failed because the kimi PR added the
`kimi`/`kimi-native` harnesses + examples/kimi_hello.yaml without
updating them:

- test_examples_coverage_sync: allowlist `kimi_hello` (SDK-kimi launcher
  YAML) — covered by tests/inner/test_kimi_harness.py + the picker e2e_ui
  suite; a live round-trip needs the kimi CLI + Moonshot auth (not in CI).
  Same shape as the qwen_perm_test entry.
- test_run_harness_live_matrix: exclude `kimi` (needs the kimi CLI +
  Moonshot auth, like hermes) and `kimi-native` (terminal-first TUI via
  `omni kimi`, like kiro-/qwen-/goose-native) from the live gateway probe
  matrix, with docstring rationale mirroring the existing exclusions.

Both pass locally.

Co-authored-by: Isaac

---------

Signed-off-by: Ankush Bhatiya <ankushb@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: aravind-segu <aravind.segu@databricks.com>
2026-06-25 02:01:57 +00:00

142 lines
6.2 KiB
Python

"""Per-session ``KIMI_CODE_HOME`` builder that injects Omnigent hooks.
Kimi Code reads a single ``config.toml`` at ``$KIMI_CODE_HOME/config.toml``
(default ``~/.kimi-code``) and stores its auth (``oauth/`` + ``credentials/``)
relative to the same home — there is no project-level merge for the ``hooks``
array. To gate a session's tools without mutating the user's global config, the
runner points the launched ``kimi`` process at a session-scoped home that:
- symlinks every entry of the user's global home (oauth, credentials,
sessions, …) so login / providers / history keep working, and
- carries a ``config.toml`` that is the user's config text with two Omnigent
``[[hooks]]`` appended — a ``PreToolUse`` deny-gate and a ``PermissionRequest``
read-only surface, both dispatched to :mod:`omnigent.kimi_native_hook`.
Appending as text (rather than parsing + re-emitting TOML) keeps the user's
config byte-for-byte and needs no TOML writer: a trailing ``[[hooks]]`` table
array is always valid regardless of what section preceded it.
"""
from __future__ import annotations
import contextlib
import os
import shlex
import sys
from pathlib import Path
#: Env var Kimi Code reads to locate its data dir (config.toml + oauth + …).
KIMI_CODE_HOME_ENV_VAR = "KIMI_CODE_HOME"
_CONFIG_FILE = "config.toml"
def resolve_user_kimi_home() -> Path:
"""Return the user's global Kimi Code home.
Mirrors kimi's own ``resolveKimiHome``: ``$KIMI_CODE_HOME`` when set, else
``~/.kimi-code``.
:returns: The resolved home path (may not exist if the user never ran kimi).
"""
env = os.environ.get(KIMI_CODE_HOME_ENV_VAR)
if env:
return Path(env)
return Path.home() / ".kimi-code"
def render_kimi_hooks_toml(*, bridge_dir: Path, python_executable: str | None = None) -> str:
"""Render the two Omnigent ``[[hooks]]`` entries as TOML text.
Both hooks dispatch to :mod:`omnigent.kimi_native_hook` with the bridge
dir baked into the command (no secrets on the command line — the hook reads
the server URL / auth / session id from the bridge's ``hook_config.json``).
:param bridge_dir: The kimi-native bridge dir the hook commands read.
:param python_executable: Interpreter to run the hook module; ``None`` uses
:data:`sys.executable` (the runner's interpreter, which has omnigent).
:returns: TOML text starting with a leading newline, safe to append.
"""
python = python_executable or sys.executable
# ``-I`` (isolated mode) is REQUIRED, not cosmetic: kimi runs the hook with
# ``cwd`` set to the session workspace, and ``python -m`` puts cwd on
# ``sys.path[0]``. A workspace that contains its own ``omnigent/`` directory
# (another checkout, a vendored copy) would otherwise shadow the installed
# package and the hook dies on ``ImportError`` before it can POST — so the
# approval card never publishes. ``-I`` drops cwd + PYTHONPATH + user-site
# from the path, importing only the interpreter's own omnigent. Mirrors
# claude-native's ``python -I -m omnigent.claude_native_hook``.
base = f"{shlex.quote(python)} -I -m omnigent.kimi_native_hook"
bridge = shlex.quote(str(bridge_dir))
pre = f"{base} evaluate-policy --bridge-dir {bridge}"
perm = f"{base} permission-request --bridge-dir {bridge}"
# No ``matcher`` → matches every tool. Commands are TOML basic strings;
# shlex.quote yields single-quoted POSIX tokens, which contain no double
# quotes or backslashes, so they embed in a "..." TOML string verbatim.
#
# ``timeout`` is required: kimi's DEFAULT_HOOK_TIMEOUT_SECONDS is 30s, which
# would kill the permission hook while it long-polls the web verdict (so the
# injected Approve/Deny keystroke never lands) and could sever a slow policy
# evaluate. Pin both to kimi's 600s ceiling — the longest the human may take
# to answer the card — after which kimi's own TUI prompt stands.
return (
"\n"
"# --- Omnigent native hooks (auto-generated; do not edit) ---\n"
"[[hooks]]\n"
'event = "PreToolUse"\n'
f'command = "{pre}"\n'
"timeout = 600\n"
"\n"
"[[hooks]]\n"
'event = "PermissionRequest"\n'
f'command = "{perm}"\n'
"timeout = 600\n"
)
def build_kimi_session_home(
session_home: Path,
*,
bridge_dir: Path,
python_executable: str | None = None,
) -> dict[str, str]:
"""Materialize a session-scoped ``KIMI_CODE_HOME`` with Omnigent hooks.
Symlinks every entry of the user's global kimi home (except
``config.toml``) into *session_home*, then writes a ``config.toml`` that is
the user's config plus the Omnigent hooks. Best-effort and idempotent:
re-running rewrites ``config.toml`` and leaves existing symlinks in place.
:param session_home: Directory to use as the session's ``KIMI_CODE_HOME``.
:param bridge_dir: The kimi-native bridge dir the hook commands read.
:param python_executable: Interpreter for the hook commands (see
:func:`render_kimi_hooks_toml`).
:returns: ``{"KIMI_CODE_HOME": str(session_home)}`` to merge into the
launched kimi process env.
"""
session_home.mkdir(parents=True, exist_ok=True)
with contextlib.suppress(OSError):
os.chmod(session_home, 0o700)
user_home = resolve_user_kimi_home()
base_config = ""
if user_home.is_dir():
for entry in user_home.iterdir():
if entry.name == _CONFIG_FILE:
# config.toml is materialized fresh below (user content + hooks).
continue
link = session_home / entry.name
if link.exists() or link.is_symlink():
continue
with contextlib.suppress(OSError):
link.symlink_to(entry)
with contextlib.suppress(OSError):
base_config = (user_home / _CONFIG_FILE).read_text(encoding="utf-8")
hooks = render_kimi_hooks_toml(bridge_dir=bridge_dir, python_executable=python_executable)
# Ensure a clean separation if the user's config has no trailing newline.
if base_config and not base_config.endswith("\n"):
base_config += "\n"
(session_home / _CONFIG_FILE).write_text(base_config + hooks, encoding="utf-8")
return {KIMI_CODE_HOME_ENV_VAR: str(session_home)}