cd91556d3c
* 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>
142 lines
6.2 KiB
Python
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)}
|